Integrate special award eligibility into AI filtering pass
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m18s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m18s
Single AI call now evaluates both screening criteria AND award eligibility. Awards with useAiEligibility + criteriaText are appended to the system prompt, AI returns award_matches per project, results auto-populate AwardEligibility and auto-shortlist top-N. Re-running filtering clears and re-evaluates awards. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,8 @@ import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma, PrismaClient } from '@prisma/client'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { executeFilteringRules, type ProgressCallback } from '../services/ai-filtering'
|
||||
import { executeFilteringRules, type ProgressCallback, type AwardCriteriaInput, type AwardMatchResult } from '../services/ai-filtering'
|
||||
import { sanitizeUserInput } from '../services/ai-prompt-guard'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { isOpenAIConfigured, testOpenAIConnection } from '@/lib/openai'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
@@ -58,11 +59,38 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||
// Get current round with config
|
||||
const currentRound = await prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { id: true, name: true, configJson: true },
|
||||
select: { id: true, name: true, configJson: true, competitionId: true },
|
||||
})
|
||||
const roundConfig = (currentRound.configJson as Record<string, unknown>) || {}
|
||||
const aiParseFiles = !!roundConfig.aiParseFiles
|
||||
|
||||
// Load special awards for integrated AI evaluation
|
||||
let awardsForAI: AwardCriteriaInput[] = []
|
||||
if (currentRound.competitionId) {
|
||||
const rawAwards = await prisma.specialAward.findMany({
|
||||
where: {
|
||||
competitionId: currentRound.competitionId,
|
||||
useAiEligibility: true,
|
||||
criteriaText: { not: null },
|
||||
},
|
||||
select: { id: true, name: true, criteriaText: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
for (const a of rawAwards) {
|
||||
if (a.criteriaText && a.criteriaText.trim().length > 0) {
|
||||
const { sanitized } = sanitizeUserInput(a.criteriaText)
|
||||
awardsForAI.push({
|
||||
awardId: a.id,
|
||||
awardName: a.name,
|
||||
criteriaText: sanitized,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (awardsForAI.length > 0) {
|
||||
console.log(`[Filtering] Including ${awardsForAI.length} special award(s) in AI evaluation`)
|
||||
}
|
||||
}
|
||||
|
||||
// Get projects in this round via ProjectRoundState
|
||||
const projectStates = await prisma.projectRoundState.findMany({
|
||||
where: {
|
||||
@@ -189,7 +217,107 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// Upsert AwardEligibility for PASSED projects with award matches
|
||||
if (awardsForAI.length > 0) {
|
||||
const awardUpserts: Prisma.PrismaPromise<unknown>[] = []
|
||||
for (const r of batchResults) {
|
||||
if (r.outcome !== 'PASSED' || !r.awardMatches || r.awardMatches.length === 0) continue
|
||||
for (const am of r.awardMatches) {
|
||||
awardUpserts.push(
|
||||
prisma.awardEligibility.upsert({
|
||||
where: {
|
||||
awardId_projectId: {
|
||||
awardId: am.awardId,
|
||||
projectId: r.projectId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
awardId: am.awardId,
|
||||
projectId: r.projectId,
|
||||
eligible: am.eligible,
|
||||
method: 'AUTO',
|
||||
qualityScore: am.qualityScore,
|
||||
aiReasoningJson: { reasoning: am.reasoning, confidence: am.confidence },
|
||||
},
|
||||
update: {
|
||||
eligible: am.eligible,
|
||||
method: 'AUTO',
|
||||
qualityScore: am.qualityScore,
|
||||
aiReasoningJson: { reasoning: am.reasoning, confidence: am.confidence },
|
||||
overriddenBy: null,
|
||||
overriddenAt: null,
|
||||
shortlisted: false,
|
||||
confirmedAt: null,
|
||||
confirmedBy: null,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
if (awardUpserts.length > 0) {
|
||||
await prisma.$transaction(awardUpserts)
|
||||
}
|
||||
}
|
||||
}, awardsForAI)
|
||||
|
||||
// Auto-shortlist top-N per award and mark eligibility job as completed
|
||||
if (awardsForAI.length > 0) {
|
||||
// Collect all award matches from PASSED results
|
||||
const awardMatchesByAward = new Map<string, Array<{ projectId: string; qualityScore: number }>>()
|
||||
for (const r of results) {
|
||||
if (r.outcome !== 'PASSED' || !r.awardMatches) continue
|
||||
for (const am of r.awardMatches) {
|
||||
if (!am.eligible) continue
|
||||
const arr = awardMatchesByAward.get(am.awardId) || []
|
||||
arr.push({ projectId: r.projectId, qualityScore: am.qualityScore })
|
||||
awardMatchesByAward.set(am.awardId, arr)
|
||||
}
|
||||
}
|
||||
|
||||
// Load shortlistSize per award
|
||||
const awardIds = awardsForAI.map((a) => a.awardId)
|
||||
const awardsWithSize = await prisma.specialAward.findMany({
|
||||
where: { id: { in: awardIds } },
|
||||
select: { id: true, shortlistSize: true },
|
||||
})
|
||||
|
||||
for (const award of awardsWithSize) {
|
||||
const eligible = awardMatchesByAward.get(award.id) || []
|
||||
const shortlistSize = award.shortlistSize ?? 10
|
||||
const topN = eligible
|
||||
.sort((a, b) => b.qualityScore - a.qualityScore)
|
||||
.slice(0, shortlistSize)
|
||||
|
||||
if (topN.length > 0) {
|
||||
await prisma.$transaction(
|
||||
topN.map((e) =>
|
||||
prisma.awardEligibility.update({
|
||||
where: {
|
||||
awardId_projectId: {
|
||||
awardId: award.id,
|
||||
projectId: e.projectId,
|
||||
},
|
||||
},
|
||||
data: { shortlisted: true },
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Mark award eligibility job as completed
|
||||
await prisma.specialAward.update({
|
||||
where: { id: award.id },
|
||||
data: {
|
||||
eligibilityJobStatus: 'COMPLETED',
|
||||
eligibilityJobDone: results.length,
|
||||
eligibilityJobTotal: results.length,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[Filtering] Auto-shortlisted for ${awardsWithSize.length} award(s)`)
|
||||
}
|
||||
|
||||
// Count outcomes
|
||||
const passedCount = results.filter((r) => r.outcome === 'PASSED').length
|
||||
@@ -498,6 +626,37 @@ export const filteringRouter = router({
|
||||
where: { roundId: input.roundId },
|
||||
})
|
||||
|
||||
// Clear award eligibilities for awards linked to this competition
|
||||
const roundForComp = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { competitionId: true },
|
||||
})
|
||||
if (roundForComp.competitionId) {
|
||||
const linkedAwards = await ctx.prisma.specialAward.findMany({
|
||||
where: {
|
||||
competitionId: roundForComp.competitionId,
|
||||
useAiEligibility: true,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
const awardIds = linkedAwards.map((a) => a.id)
|
||||
if (awardIds.length > 0) {
|
||||
await ctx.prisma.awardEligibility.deleteMany({
|
||||
where: { awardId: { in: awardIds } },
|
||||
})
|
||||
await ctx.prisma.specialAward.updateMany({
|
||||
where: { id: { in: awardIds } },
|
||||
data: {
|
||||
eligibilityJobStatus: null,
|
||||
eligibilityJobTotal: null,
|
||||
eligibilityJobDone: null,
|
||||
eligibilityJobError: null,
|
||||
eligibilityJobStarted: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const job = await ctx.prisma.filteringJob.create({
|
||||
data: {
|
||||
roundId: input.roundId,
|
||||
|
||||
Reference in New Issue
Block a user