Files
MOPC-Portal/src/server/services/award-eligibility-job.ts

251 lines
7.3 KiB
TypeScript
Raw Normal View History

import { prisma } from '@/lib/prisma'
import { aiInterpretCriteria } from './ai-award-eligibility'
const BATCH_SIZE = 20
/**
* Process eligibility for an award in the background.
* Updates progress in the database as it goes so the frontend can poll.
*/
export async function processEligibilityJob(
awardId: string,
includeSubmitted: boolean,
userId: string,
filteringRoundId?: string
): Promise<void> {
try {
// Mark job as PROCESSING
const award = await prisma.specialAward.findUniqueOrThrow({
where: { id: awardId },
include: { program: true },
})
// Get projects — scoped to filtering round PASSED projects if provided
let projects: Array<{
id: string
title: string
description: string | null
competitionCategory: string | null
country: string | null
geographicZone: string | null
tags: string[]
oceanIssue: string | null
}>
if (filteringRoundId) {
// Scope to projects that PASSED filtering in the specified round
const passedResults = await prisma.filteringResult.findMany({
where: { roundId: filteringRoundId, outcome: 'PASSED' },
select: { projectId: true },
})
const passedIds = passedResults.map((r) => r.projectId)
if (passedIds.length === 0) {
await prisma.specialAward.update({
where: { id: awardId },
data: {
eligibilityJobStatus: 'COMPLETED',
eligibilityJobTotal: 0,
eligibilityJobDone: 0,
},
})
return
}
projects = await prisma.project.findMany({
where: {
id: { in: passedIds },
programId: award.programId,
},
select: {
id: true,
title: true,
description: true,
competitionCategory: true,
country: true,
geographicZone: true,
tags: true,
oceanIssue: true,
},
})
} else {
const statusFilter = includeSubmitted
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
projects = await prisma.project.findMany({
where: {
programId: award.programId,
status: { in: [...statusFilter] },
},
select: {
id: true,
title: true,
description: true,
competitionCategory: true,
country: true,
geographicZone: true,
tags: true,
oceanIssue: true,
},
})
}
if (projects.length === 0) {
await prisma.specialAward.update({
where: { id: awardId },
data: {
eligibilityJobStatus: 'COMPLETED',
eligibilityJobTotal: 0,
eligibilityJobDone: 0,
},
})
return
}
await prisma.specialAward.update({
where: { id: awardId },
data: {
eligibilityJobStatus: 'PROCESSING',
eligibilityJobTotal: projects.length,
eligibilityJobDone: 0,
eligibilityJobError: null,
eligibilityJobStarted: new Date(),
},
})
// AI interpretation (if criteria text exists AND AI eligibility is enabled)
// Process in batches to avoid timeouts
let aiResults: Map<string, { eligible: boolean; confidence: number; qualityScore: number; reasoning: string }> | undefined
if (award.criteriaText && award.useAiEligibility) {
aiResults = new Map()
for (let i = 0; i < projects.length; i += BATCH_SIZE) {
const batch = projects.slice(i, i + BATCH_SIZE)
const aiEvals = await aiInterpretCriteria(award.criteriaText, batch)
for (const e of aiEvals) {
aiResults.set(e.projectId, {
eligible: e.eligible,
confidence: e.confidence,
qualityScore: e.qualityScore,
reasoning: e.reasoning,
})
}
// Update progress
await prisma.specialAward.update({
where: { id: awardId },
data: {
eligibilityJobDone: Math.min(i + BATCH_SIZE, projects.length),
},
})
}
} else {
// No AI needed, mark all as done
await prisma.specialAward.update({
where: { id: awardId },
data: { eligibilityJobDone: projects.length },
})
}
// Combine results
const eligibilities = projects.map((project) => {
const aiEval = aiResults?.get(project.id)
const eligible = aiEval?.eligible ?? true
const method = aiResults ? 'AUTO' : 'MANUAL'
return {
projectId: project.id,
eligible,
method,
qualityScore: aiEval?.qualityScore ?? null,
aiReasoningJson: aiEval
? { confidence: aiEval.confidence, qualityScore: aiEval.qualityScore, reasoning: aiEval.reasoning }
: null,
}
})
// Upsert eligibilities
await prisma.$transaction(
eligibilities.map((e) =>
prisma.awardEligibility.upsert({
where: {
awardId_projectId: {
awardId,
projectId: e.projectId,
},
},
create: {
awardId,
projectId: e.projectId,
eligible: e.eligible,
method: e.method as 'AUTO' | 'MANUAL',
qualityScore: e.qualityScore,
aiReasoningJson: e.aiReasoningJson ?? undefined,
},
update: {
eligible: e.eligible,
method: e.method as 'AUTO' | 'MANUAL',
qualityScore: e.qualityScore,
aiReasoningJson: e.aiReasoningJson ?? undefined,
overriddenBy: null,
overriddenAt: null,
shortlisted: false,
confirmedAt: null,
confirmedBy: null,
},
})
)
)
// Auto-shortlist top N eligible projects by qualityScore
const shortlistSize = award.shortlistSize ?? 10
const topEligible = eligibilities
.filter((e) => e.eligible && e.qualityScore != null)
.sort((a, b) => (b.qualityScore ?? 0) - (a.qualityScore ?? 0))
.slice(0, shortlistSize)
if (topEligible.length > 0) {
await prisma.$transaction(
topEligible.map((e) =>
prisma.awardEligibility.update({
where: {
awardId_projectId: {
awardId,
projectId: e.projectId,
},
},
data: { shortlisted: true },
})
)
)
}
// Mark as completed
await prisma.specialAward.update({
where: { id: awardId },
data: {
eligibilityJobStatus: 'COMPLETED',
eligibilityJobDone: projects.length,
},
})
} catch (error) {
// Mark as failed
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
try {
await prisma.specialAward.update({
where: { id: awardId },
data: {
eligibilityJobStatus: 'FAILED',
eligibilityJobError: errorMessage,
},
})
} catch {
// If we can't even update the status, log and give up
console.error('Failed to update eligibility job status:', error)
}
}
}