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

185 lines
5.5 KiB
TypeScript
Raw Normal View History

import { prisma } from '@/lib/prisma'
import {
applyAutoTagRules,
aiInterpretCriteria,
type AutoTagRule,
} 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
): Promise<void> {
try {
// Mark job as PROCESSING
const award = await prisma.specialAward.findUniqueOrThrow({
where: { id: awardId },
include: { program: true },
})
// Get projects
const statusFilter = includeSubmitted
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
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(),
},
})
// Phase 1: Auto-tag rules (deterministic, fast)
const autoTagRules = award.autoTagRulesJson as unknown as AutoTagRule[] | null
let autoResults: Map<string, boolean> | undefined
if (autoTagRules && Array.isArray(autoTagRules) && autoTagRules.length > 0) {
autoResults = applyAutoTagRules(autoTagRules, projects)
}
// Phase 2: 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; 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,
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: auto-tag AND AI must agree (or just one if only one configured)
const eligibilities = projects.map((project) => {
const autoEligible = autoResults?.get(project.id) ?? true
const aiEval = aiResults?.get(project.id)
const aiEligible = aiEval?.eligible ?? true
const eligible = autoEligible && aiEligible
const method = autoResults && aiResults ? 'AUTO' : autoResults ? 'AUTO' : 'MANUAL'
return {
projectId: project.id,
eligible,
method,
aiReasoningJson: aiEval
? { confidence: aiEval.confidence, 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',
aiReasoningJson: e.aiReasoningJson ?? undefined,
},
update: {
eligible: e.eligible,
method: e.method as 'AUTO' | 'MANUAL',
aiReasoningJson: e.aiReasoningJson ?? undefined,
overriddenBy: null,
overriddenAt: null,
},
})
)
)
// 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)
}
}
}