Fix AI filtering bugs, add special award shortlist integration
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m20s
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:
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user