Fix AI filtering bugs, add special award shortlist integration
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m20s

Part 1 - Bug Fixes:
- Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.)
- Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID
- Fix getAIConfidenceScore() with same nesting issue (always returned 0)

Part 2 - Special Award Track Integration:
- Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility
- Add specialAwardId to Round for award-owned rounds
- Update AI eligibility service to return qualityScore (0-100) for ranking
- Update eligibility job with filteringRoundId scoping and auto-shortlist top N
- Add 8 new specialAward router procedures (listForRound, runEligibilityForRound,
  listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound)
- Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog
- Add "Special Award Tracks" section to filtering dashboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-17 15:38:31 +01:00
parent 6743119c4d
commit a02ed59158
10 changed files with 1308 additions and 309 deletions

View File

@@ -20,8 +20,20 @@ function getAIConfidenceScore(aiScreeningJson: Prisma.JsonValue | null): number
if (!aiScreeningJson || typeof aiScreeningJson !== 'object' || Array.isArray(aiScreeningJson)) {
return 0
}
const obj = aiScreeningJson as Record<string, unknown>
for (const key of ['overallScore', 'confidenceScore', 'score', 'qualityScore']) {
let obj = aiScreeningJson as Record<string, unknown>
// aiScreeningJson is nested under rule ID: { [ruleId]: { confidence, ... } }
// Unwrap if top-level keys don't include expected score fields
const scoreKeys = ['overallScore', 'confidenceScore', 'score', 'qualityScore', 'confidence']
if (!scoreKeys.some((k) => k in obj)) {
const keys = Object.keys(obj)
if (keys.length > 0) {
const inner = obj[keys[0]]
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
obj = inner as Record<string, unknown>
}
}
}
for (const key of scoreKeys) {
if (typeof obj[key] === 'number') {
return obj[key] as number
}
@@ -146,44 +158,44 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
})
}
// Execute rules
const results = await executeFilteringRules(rules, projects, userId, roundId, onProgress)
// Execute rules — upsert results per batch for streaming to the UI
const results = await executeFilteringRules(rules, projects, userId, roundId, onProgress, async (batchResults) => {
if (batchResults.length === 0) return
await prisma.$transaction(
batchResults.map((r) =>
prisma.filteringResult.upsert({
where: {
roundId_projectId: {
roundId,
projectId: r.projectId,
},
},
create: {
roundId,
projectId: r.projectId,
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
},
update: {
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
overriddenBy: null,
overriddenAt: null,
overrideReason: null,
finalOutcome: null,
},
})
)
)
})
// Count outcomes
const passedCount = results.filter((r) => r.outcome === 'PASSED').length
const filteredCount = results.filter((r) => r.outcome === 'FILTERED_OUT').length
const flaggedCount = results.filter((r) => r.outcome === 'FLAGGED').length
// Upsert results
await prisma.$transaction(
results.map((r) =>
prisma.filteringResult.upsert({
where: {
roundId_projectId: {
roundId,
projectId: r.projectId,
},
},
create: {
roundId,
projectId: r.projectId,
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
},
update: {
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
overriddenBy: null,
overriddenAt: null,
overrideReason: null,
finalOutcome: null,
},
})
)
)
// Mark job as completed
await prisma.filteringJob.update({
where: { id: jobId },

View File

@@ -764,4 +764,372 @@ export const specialAwardRouter = router({
return award
}),
// ─── Round-Scoped Eligibility & Shortlists ──────────────────────────────
/**
* List awards for a competition (from a filtering round context)
*/
listForRound: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Get competition from round
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { competitionId: true },
})
const awards = await ctx.prisma.specialAward.findMany({
where: { competitionId: round.competitionId },
orderBy: { sortOrder: 'asc' },
include: {
_count: {
select: {
eligibilities: { where: { eligible: true } },
},
},
},
})
// Get shortlisted counts
const shortlistedCounts = await ctx.prisma.awardEligibility.groupBy({
by: ['awardId'],
where: {
awardId: { in: awards.map((a) => a.id) },
shortlisted: true,
},
_count: true,
})
const shortlistMap = new Map(shortlistedCounts.map((s) => [s.awardId, s._count]))
return awards.map((a) => ({
...a,
shortlistedCount: shortlistMap.get(a.id) ?? 0,
}))
}),
/**
* Run eligibility scoped to a filtering round's PASSED projects
*/
runEligibilityForRound: adminProcedure
.input(z.object({
awardId: z.string(),
roundId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
// Set job status to PENDING
await ctx.prisma.specialAward.update({
where: { id: input.awardId },
data: {
eligibilityJobStatus: 'PENDING',
eligibilityJobTotal: null,
eligibilityJobDone: null,
eligibilityJobError: null,
eligibilityJobStarted: null,
},
})
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: input.awardId,
detailsJson: { action: 'RUN_ELIGIBILITY_FOR_ROUND', roundId: input.roundId },
})
// Fire and forget - process in background with round scoping
void processEligibilityJob(
input.awardId,
true, // include submitted
ctx.user.id,
input.roundId
)
return { started: true }
}),
/**
* Get ranked shortlist for an award
*/
listShortlist: protectedProcedure
.input(z.object({
awardId: z.string(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(50),
}))
.query(async ({ ctx, input }) => {
const { awardId, page, perPage } = input
const skip = (page - 1) * perPage
const [eligibilities, total, award] = await Promise.all([
ctx.prisma.awardEligibility.findMany({
where: { awardId, eligible: true },
skip,
take: perPage,
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
country: true,
tags: true,
description: true,
},
},
},
orderBy: { qualityScore: 'desc' },
}),
ctx.prisma.awardEligibility.count({ where: { awardId, eligible: true } }),
ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: awardId },
select: { shortlistSize: true, eligibilityMode: true, name: true },
}),
])
return {
eligibilities,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
shortlistSize: award.shortlistSize,
eligibilityMode: award.eligibilityMode,
awardName: award.name,
}
}),
/**
* Toggle shortlisted status for a project-award pair
*/
toggleShortlisted: adminProcedure
.input(z.object({
awardId: z.string(),
projectId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
const current = await ctx.prisma.awardEligibility.findUniqueOrThrow({
where: {
awardId_projectId: {
awardId: input.awardId,
projectId: input.projectId,
},
},
select: { shortlisted: true },
})
const updated = await ctx.prisma.awardEligibility.update({
where: {
awardId_projectId: {
awardId: input.awardId,
projectId: input.projectId,
},
},
data: { shortlisted: !current.shortlisted },
})
return { shortlisted: updated.shortlisted }
}),
/**
* Confirm shortlist — for SEPARATE_POOL awards, creates ProjectRoundState entries
*/
confirmShortlist: adminProcedure
.input(z.object({ awardId: z.string() }))
.mutation(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
select: { eligibilityMode: true, name: true, competitionId: true },
})
// Get shortlisted projects
const shortlisted = await ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, shortlisted: true, eligible: true },
select: { projectId: true },
})
if (shortlisted.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No shortlisted projects to confirm',
})
}
// Mark all as confirmed
await ctx.prisma.awardEligibility.updateMany({
where: { awardId: input.awardId, shortlisted: true, eligible: true },
data: {
confirmedAt: new Date(),
confirmedBy: ctx.user.id,
},
})
// For SEPARATE_POOL: create ProjectRoundState entries in award rounds (if any exist)
let routedCount = 0
if (award.eligibilityMode === 'SEPARATE_POOL') {
const awardRounds = await ctx.prisma.round.findMany({
where: { specialAwardId: input.awardId },
select: { id: true },
orderBy: { sortOrder: 'asc' },
})
if (awardRounds.length > 0) {
const firstRound = awardRounds[0]
const projectIds = shortlisted.map((s) => s.projectId)
// Create ProjectRoundState entries for confirmed projects in the first award round
await ctx.prisma.projectRoundState.createMany({
data: projectIds.map((projectId) => ({
projectId,
roundId: firstRound.id,
state: 'PENDING' as const,
})),
skipDuplicates: true,
})
routedCount = projectIds.length
}
}
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: input.awardId,
detailsJson: {
action: 'CONFIRM_SHORTLIST',
confirmedCount: shortlisted.length,
eligibilityMode: award.eligibilityMode,
routedCount,
},
})
return {
confirmedCount: shortlisted.length,
routedCount,
eligibilityMode: award.eligibilityMode,
}
}),
// ─── Award Rounds ────────────────────────────────────────────────────────
/**
* List rounds belonging to an award
*/
listRounds: protectedProcedure
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.round.findMany({
where: { specialAwardId: input.awardId },
orderBy: { sortOrder: 'asc' },
include: {
juryGroup: { select: { id: true, name: true } },
_count: {
select: {
projectRoundStates: true,
assignments: true,
},
},
},
})
}),
/**
* Create a round linked to an award
*/
createRound: adminProcedure
.input(z.object({
awardId: z.string(),
name: z.string().min(1),
roundType: z.enum(['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION']).default('EVALUATION'),
}))
.mutation(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
select: { competitionId: true, name: true },
})
if (!award.competitionId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Award must be linked to a competition before creating rounds',
})
}
// Get max sort order for this award's rounds
const maxOrder = await ctx.prisma.round.aggregate({
where: { specialAwardId: input.awardId },
_max: { sortOrder: true },
})
// Also need max sortOrder across the competition to avoid unique constraint conflicts
const maxCompOrder = await ctx.prisma.round.aggregate({
where: { competitionId: award.competitionId },
_max: { sortOrder: true },
})
const sortOrder = Math.max(
(maxOrder._max.sortOrder ?? 0) + 1,
(maxCompOrder._max.sortOrder ?? 0) + 1
)
const slug = `${award.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${input.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
const round = await ctx.prisma.round.create({
data: {
competitionId: award.competitionId,
specialAwardId: input.awardId,
name: input.name,
slug,
roundType: input.roundType,
sortOrder,
},
})
await logAudit({
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Round',
entityId: round.id,
detailsJson: { awardId: input.awardId, awardName: award.name, roundType: input.roundType },
})
return round
}),
/**
* Delete an award round (only if DRAFT)
*/
deleteRound: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { status: true, specialAwardId: true },
})
if (!round.specialAwardId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This round is not an award round',
})
}
if (round.status !== 'ROUND_DRAFT') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Only draft rounds can be deleted',
})
}
await ctx.prisma.round.delete({ where: { id: input.roundId } })
await logAudit({
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { awardId: round.specialAwardId },
})
}),
})

View File

@@ -48,6 +48,7 @@ Return a JSON object:
"project_id": "PROJECT_001",
"eligible": true/false,
"confidence": 0.0-1.0,
"quality_score": 0-100,
"reasoning": "2-3 sentence explanation covering key dimensions",
"dimensionScores": {
"geographic": 0.0-1.0,
@@ -59,6 +60,8 @@ Return a JSON object:
]
}
quality_score is a 0-100 integer measuring how well the project fits the award criteria (used for ranking shortlists). 100 = perfect fit, 0 = no fit. Even ineligible projects should receive a score for reference.
## Guidelines
- Base evaluation only on provided data — do not infer missing information
- eligible=true only when ALL required dimensions score above 0.5
@@ -77,6 +80,7 @@ export interface EligibilityResult {
projectId: string
eligible: boolean
confidence: number
qualityScore: number
reasoning: string
method: 'AUTO' | 'AI'
}
@@ -229,6 +233,7 @@ Evaluate eligibility for each project.`
project_id: string
eligible: boolean
confidence: number
quality_score?: number
reasoning: string
}>
}
@@ -273,6 +278,7 @@ Evaluate eligibility for each project.`
projectId: mapping.realId,
eligible: eval_.eligible,
confidence: eval_.confidence,
qualityScore: Math.max(0, Math.min(100, eval_.quality_score ?? 0)),
reasoning: eval_.reasoning,
method: 'AI',
})
@@ -305,6 +311,7 @@ Evaluate eligibility for each project.`
projectId: mapping.realId,
eligible: false,
confidence: 0,
qualityScore: 0,
reasoning: 'AI response parse error — requires manual review',
method: 'AI',
})
@@ -333,6 +340,7 @@ export async function aiInterpretCriteria(
projectId: p.id,
eligible: false,
confidence: 0,
qualityScore: 0,
reasoning: 'AI unavailable — requires manual eligibility review',
method: 'AI' as const,
}))
@@ -401,6 +409,7 @@ export async function aiInterpretCriteria(
projectId: p.id,
eligible: false,
confidence: 0,
qualityScore: 0,
reasoning: `AI error: ${classified.message}`,
method: 'AI' as const,
}))

View File

@@ -510,7 +510,8 @@ export async function executeAIScreening(
projects: ProjectForFiltering[],
userId?: string,
entityId?: string,
onProgress?: ProgressCallback
onProgress?: ProgressCallback,
onBatchComplete?: (batchResults: Map<string, AIScreeningResult>) => Promise<void>
): Promise<Map<string, AIScreeningResult>> {
const results = new Map<string, AIScreeningResult>()
@@ -599,6 +600,17 @@ export async function executeAIScreening(
processedBatches++
}
// Emit batch results for streaming
if (onBatchComplete) {
const chunkResults = new Map<string, AIScreeningResult>()
for (const { batchResults: br } of parallelResults) {
for (const [id, result] of br) {
chunkResults.set(id, result)
}
}
await onBatchComplete(chunkResults)
}
// Report progress after each parallel chunk
if (onProgress) {
await onProgress({
@@ -653,43 +665,29 @@ export async function executeFilteringRules(
projects: ProjectForFiltering[],
userId?: string,
roundId?: string,
onProgress?: ProgressCallback
onProgress?: ProgressCallback,
onResultsBatch?: (results: ProjectFilteringResult[]) => Promise<void>
): Promise<ProjectFilteringResult[]> {
const activeRules = rules
.filter((r) => r.isActive)
.sort((a, b) => a.priority - b.priority)
// Separate AI screening rules (need batch processing)
const aiRules = activeRules.filter((r) => r.ruleType === 'AI_SCREENING')
const nonAiRules = activeRules.filter((r) => r.ruleType !== 'AI_SCREENING')
// Pre-compute AI screening results if needed
const aiResults = new Map<string, Map<string, AIScreeningResult>>()
for (const aiRule of aiRules) {
const config = aiRule.configJson as unknown as AIScreeningConfig
const screeningResults = await executeAIScreening(config, projects, userId, roundId, onProgress)
aiResults.set(aiRule.id, screeningResults)
}
// Evaluate each project
const results: ProjectFilteringResult[] = []
// Pre-evaluate non-AI rules for all projects (instant)
const nonAiEval = new Map<string, { ruleResults: RuleResult[]; hasFailed: boolean; hasFlagged: boolean }>()
for (const project of projects) {
const ruleResults: RuleResult[] = []
let hasFailed = false
let hasFlagged = false
// Evaluate non-AI rules
for (const rule of nonAiRules) {
let result: { passed: boolean; action: 'PASS' | 'REJECT' | 'FLAG' }
if (rule.ruleType === 'FIELD_BASED') {
const config = rule.configJson as unknown as FieldRuleConfig
result = evaluateFieldRule(config, project)
result = evaluateFieldRule(rule.configJson as unknown as FieldRuleConfig, project)
} else if (rule.ruleType === 'DOCUMENT_CHECK') {
const config = rule.configJson as unknown as DocumentCheckConfig
result = evaluateDocumentRule(config, project)
result = evaluateDocumentRule(rule.configJson as unknown as DocumentCheckConfig, project)
} else {
continue
}
@@ -701,65 +699,107 @@ export async function executeFilteringRules(
passed: result.passed,
action: result.action,
})
if (!result.passed) {
if (result.action === 'REJECT') hasFailed = true
if (result.action === 'FLAG') hasFlagged = true
}
}
nonAiEval.set(project.id, { ruleResults, hasFailed, hasFlagged })
}
// Helper: combine non-AI + AI results for a single project
function computeProjectResult(
projectId: string,
aiRuleResults: Array<{ ruleId: string; ruleName: string; passed: boolean; action: string; reasoning?: string }>,
aiScreeningData: Record<string, unknown>
): ProjectFilteringResult {
const nonAi = nonAiEval.get(projectId)!
const ruleResults: RuleResult[] = [...nonAi.ruleResults]
let hasFailed = nonAi.hasFailed
let hasFlagged = nonAi.hasFlagged
for (const ar of aiRuleResults) {
ruleResults.push({
ruleId: ar.ruleId,
ruleName: ar.ruleName,
ruleType: 'AI_SCREENING',
passed: ar.passed,
action: ar.action as 'PASS' | 'REJECT' | 'FLAG',
reasoning: ar.reasoning,
})
if (!ar.passed) {
if (ar.action === 'REJECT') hasFailed = true
else hasFlagged = true
}
}
return {
projectId,
outcome: hasFailed ? 'FILTERED_OUT' : hasFlagged ? 'FLAGGED' : 'PASSED',
ruleResults,
aiScreeningJson: Object.keys(aiScreeningData).length > 0 ? aiScreeningData : undefined,
}
}
// No AI rules → compute all results immediately
if (aiRules.length === 0) {
const results = projects.map((p) => computeProjectResult(p.id, [], {}))
if (onResultsBatch) await onResultsBatch(results)
return results
}
// Single AI rule → stream results per batch
if (aiRules.length === 1) {
const aiRule = aiRules[0]
const config = aiRule.configJson as unknown as AIScreeningConfig
const allResults: ProjectFilteringResult[] = []
await executeAIScreening(config, projects, userId, roundId, onProgress, async (batchAIResults) => {
const batchResults: ProjectFilteringResult[] = []
for (const [projectId, aiResult] of batchAIResults) {
const passed = aiResult.meetsCriteria && !aiResult.spamRisk
const aiAction = config.action || 'FLAG'
batchResults.push(
computeProjectResult(
projectId,
[{ ruleId: aiRule.id, ruleName: aiRule.name, passed, action: aiAction, reasoning: aiResult.reasoning }],
{ [aiRule.id]: aiResult }
)
)
}
allResults.push(...batchResults)
if (onResultsBatch) await onResultsBatch(batchResults)
})
return allResults
}
// Multiple AI rules → run all sequentially, then compute (no per-batch streaming)
const aiResults = new Map<string, Map<string, AIScreeningResult>>()
for (const aiRule of aiRules) {
const config = aiRule.configJson as unknown as AIScreeningConfig
const screeningResults = await executeAIScreening(config, projects, userId, roundId, onProgress)
aiResults.set(aiRule.id, screeningResults)
}
const results: ProjectFilteringResult[] = []
for (const project of projects) {
const aiRuleResults: Array<{ ruleId: string; ruleName: string; passed: boolean; action: string; reasoning?: string }> = []
const aiScreeningData: Record<string, unknown> = {}
// Evaluate AI rules
for (const aiRule of aiRules) {
const ruleScreening = aiResults.get(aiRule.id)
const screening = ruleScreening?.get(project.id)
const screening = aiResults.get(aiRule.id)?.get(project.id)
if (screening) {
const passed = screening.meetsCriteria && !screening.spamRisk
const aiConfig = aiRule.configJson as unknown as AIScreeningConfig
const aiAction = aiConfig?.action || 'FLAG'
ruleResults.push({
ruleId: aiRule.id,
ruleName: aiRule.name,
ruleType: 'AI_SCREENING',
passed,
action: aiAction,
reasoning: screening.reasoning,
})
if (!passed) {
if (aiAction === 'REJECT') hasFailed = true
else hasFlagged = true
}
}
}
// Determine overall outcome
let outcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'
if (hasFailed) {
outcome = 'FILTERED_OUT'
} else if (hasFlagged) {
outcome = 'FLAGGED'
} else {
outcome = 'PASSED'
}
// Collect AI screening data
const aiScreeningData: Record<string, unknown> = {}
for (const aiRule of aiRules) {
const screening = aiResults.get(aiRule.id)?.get(project.id)
if (screening) {
aiRuleResults.push({ ruleId: aiRule.id, ruleName: aiRule.name, passed, action: aiAction, reasoning: screening.reasoning })
aiScreeningData[aiRule.id] = screening
}
}
results.push({
projectId: project.id,
outcome,
ruleResults,
aiScreeningJson:
Object.keys(aiScreeningData).length > 0 ? aiScreeningData : undefined,
})
results.push(computeProjectResult(project.id, aiRuleResults, aiScreeningData))
}
if (onResultsBatch) await onResultsBatch(results)
return results
}

View File

@@ -141,7 +141,16 @@ export interface ProjectWithRelations {
teamMembers?: number
files?: number
}
files?: Array<{ fileType: FileType | null; size?: number; pageCount?: number | null }>
files?: Array<{
fileType: FileType | null
size?: number
pageCount?: number | null
detectedLang?: string
langConfidence?: number
roundName?: string
isCurrentRound?: boolean
textContent?: string
}>
}
/**
@@ -197,6 +206,11 @@ export function toProjectWithRelations(project: {
fileType: (f.fileType as FileType) ?? null,
size: f.size,
pageCount: f.pageCount ?? null,
detectedLang: f.detectedLang as string | undefined,
langConfidence: f.langConfidence as number | undefined,
roundName: f.roundName as string | undefined,
isCurrentRound: f.isCurrentRound as boolean | undefined,
textContent: f.textContent as string | undefined,
})) ?? [],
}
}

View File

@@ -14,7 +14,8 @@ const BATCH_SIZE = 20
export async function processEligibilityJob(
awardId: string,
includeSubmitted: boolean,
userId: string
userId: string,
filteringRoundId?: string
): Promise<void> {
try {
// Mark job as PROCESSING
@@ -23,27 +24,76 @@ export async function processEligibilityJob(
include: { program: true },
})
// Get projects
const statusFilter = includeSubmitted
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
// 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
}>
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 (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({
@@ -77,7 +127,7 @@ export async function processEligibilityJob(
// 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
let aiResults: Map<string, { eligible: boolean; confidence: number; qualityScore: number; reasoning: string }> | undefined
if (award.criteriaText && award.useAiEligibility) {
aiResults = new Map()
@@ -90,6 +140,7 @@ export async function processEligibilityJob(
aiResults.set(e.projectId, {
eligible: e.eligible,
confidence: e.confidence,
qualityScore: e.qualityScore,
reasoning: e.reasoning,
})
}
@@ -123,8 +174,9 @@ export async function processEligibilityJob(
projectId: project.id,
eligible,
method,
qualityScore: aiEval?.qualityScore ?? null,
aiReasoningJson: aiEval
? { confidence: aiEval.confidence, reasoning: aiEval.reasoning }
? { confidence: aiEval.confidence, qualityScore: aiEval.qualityScore, reasoning: aiEval.reasoning }
: null,
}
})
@@ -144,19 +196,47 @@ export async function processEligibilityJob(
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 },