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

@@ -0,0 +1,20 @@
-- AlterTable: Add shortlistSize to SpecialAward
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "shortlistSize" INTEGER NOT NULL DEFAULT 10;
-- AlterTable: Add qualityScore, shortlisted, confirmedAt, confirmedBy to AwardEligibility
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "qualityScore" DOUBLE PRECISION;
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "shortlisted" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "confirmedAt" TIMESTAMP(3);
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "confirmedBy" TEXT;
-- AlterTable: Add specialAwardId to Round
ALTER TABLE "Round" ADD COLUMN IF NOT EXISTS "specialAwardId" TEXT;
-- AddForeignKey: AwardEligibility.confirmedBy -> User.id
ALTER TABLE "AwardEligibility" ADD CONSTRAINT "AwardEligibility_confirmedBy_fkey" FOREIGN KEY ("confirmedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey: Round.specialAwardId -> SpecialAward.id
ALTER TABLE "Round" ADD CONSTRAINT "Round_specialAwardId_fkey" FOREIGN KEY ("specialAwardId") REFERENCES "SpecialAward"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Round_specialAwardId_idx" ON "Round"("specialAwardId");

View File

@@ -378,8 +378,9 @@ model User {
filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy") filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy")
// Award overrides // Award overrides
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy") awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy") awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
// In-app notifications // In-app notifications
notifications InAppNotification[] @relation("UserNotifications") notifications InAppNotification[] @relation("UserNotifications")
@@ -1507,6 +1508,7 @@ model SpecialAward {
juryGroupId String? juryGroupId String?
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN) eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION" decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"
shortlistSize Int @default(10)
// Eligibility job tracking // Eligibility job tracking
eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED
@@ -1530,6 +1532,7 @@ model SpecialAward {
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull) competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull) evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
rounds Round[] @relation("AwardRounds")
@@index([programId]) @@index([programId])
@@index([status]) @@index([status])
@@ -1545,11 +1548,17 @@ model AwardEligibility {
method EligibilityMethod @default(AUTO) method EligibilityMethod @default(AUTO)
eligible Boolean @default(false) eligible Boolean @default(false)
aiReasoningJson Json? @db.JsonB aiReasoningJson Json? @db.JsonB
qualityScore Float?
shortlisted Boolean @default(false)
// Admin override // Admin override
overriddenBy String? overriddenBy String?
overriddenAt DateTime? overriddenAt DateTime?
// Shortlist confirmation
confirmedAt DateTime?
confirmedBy String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -1557,6 +1566,7 @@ model AwardEligibility {
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade) award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull) overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull)
confirmer User? @relation("AwardEligibilityConfirmer", fields: [confirmedBy], references: [id], onDelete: SetNull)
@@unique([awardId, projectId]) @@unique([awardId, projectId])
@@index([awardId]) @@index([awardId])
@@ -2118,12 +2128,14 @@ model Round {
// Links to other entities // Links to other entities
juryGroupId String? juryGroupId String?
submissionWindowId String? submissionWindowId String?
specialAwardId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull)
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull) submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
projectRoundStates ProjectRoundState[] projectRoundStates ProjectRoundState[]
@@ -2157,6 +2169,7 @@ model Round {
@@index([competitionId]) @@index([competitionId])
@@index([roundType]) @@index([roundType])
@@index([status]) @@index([status])
@@index([specialAwardId])
} }
model ProjectRoundState { model ProjectRoundState {

View File

@@ -0,0 +1,323 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Award,
ChevronDown,
ChevronUp,
Loader2,
CheckCircle2,
Play,
Star,
Trophy,
} from 'lucide-react'
type AwardShortlistProps = {
awardId: string
roundId: string
awardName: string
criteriaText?: string | null
eligibilityMode: string
shortlistSize: number
jobStatus?: string | null
jobTotal?: number | null
jobDone?: number | null
}
export function AwardShortlist({
awardId,
roundId,
awardName,
criteriaText,
eligibilityMode,
shortlistSize,
jobStatus,
jobTotal,
jobDone,
}: AwardShortlistProps) {
const [expanded, setExpanded] = useState(false)
const utils = trpc.useUtils()
const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING'
const { data: shortlist, isLoading: isLoadingShortlist } = trpc.specialAward.listShortlist.useQuery(
{ awardId, perPage: 100 },
{ enabled: expanded && !isRunning }
)
const { data: jobPoll } = trpc.specialAward.getEligibilityJobStatus.useQuery(
{ awardId },
{ enabled: isRunning, refetchInterval: 3000 }
)
const runMutation = trpc.specialAward.runEligibilityForRound.useMutation({
onSuccess: () => {
toast.success('Eligibility evaluation started')
utils.specialAward.getEligibilityJobStatus.invalidate({ awardId })
utils.specialAward.listForRound.invalidate({ roundId })
},
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const toggleMutation = trpc.specialAward.toggleShortlisted.useMutation({
onSuccess: (data) => {
utils.specialAward.listShortlist.invalidate({ awardId })
utils.specialAward.listForRound.invalidate({ roundId })
toast.success(data.shortlisted ? 'Added to shortlist' : 'Removed from shortlist')
},
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const confirmMutation = trpc.specialAward.confirmShortlist.useMutation({
onSuccess: (data) => {
utils.specialAward.listShortlist.invalidate({ awardId })
utils.specialAward.listForRound.invalidate({ roundId })
toast.success(
`Confirmed ${data.confirmedCount} projects` +
(data.routedCount > 0 ? `${data.routedCount} routed to award track` : '')
)
},
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const currentJobStatus = jobPoll?.eligibilityJobStatus ?? jobStatus
const currentJobDone = jobPoll?.eligibilityJobDone ?? jobDone
const currentJobTotal = jobPoll?.eligibilityJobTotal ?? jobTotal
const jobProgress = currentJobTotal && currentJobTotal > 0
? Math.round(((currentJobDone ?? 0) / currentJobTotal) * 100)
: 0
const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0
return (
<Collapsible open={expanded} onOpenChange={setExpanded}>
<div className="border rounded-lg">
<CollapsibleTrigger asChild>
<button className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors text-left">
<div className="flex items-center gap-3">
<Trophy className="h-5 w-5 text-amber-600" />
<div>
<h4 className="font-semibold text-sm">{awardName}</h4>
{criteriaText && (
<p className="text-xs text-muted-foreground line-clamp-1 max-w-md">
{criteriaText}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className={eligibilityMode === 'SEPARATE_POOL'
? 'bg-purple-50 text-purple-700 border-purple-200'
: 'bg-blue-50 text-blue-700 border-blue-200'
}>
{eligibilityMode === 'SEPARATE_POOL' ? 'Separate Pool' : 'Main Pool'}
</Badge>
{currentJobStatus === 'COMPLETED' && (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
<CheckCircle2 className="h-3 w-3 mr-1" />
Evaluated
</Badge>
)}
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t p-4 space-y-4">
{/* Job controls */}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Evaluate PASSED projects against this award&apos;s criteria
</div>
<Button
size="sm"
onClick={() => runMutation.mutate({ awardId, roundId })}
disabled={runMutation.isPending || isRunning}
>
{isRunning ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Processing...</>
) : runMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Starting...</>
) : currentJobStatus === 'COMPLETED' ? (
<><Play className="h-4 w-4 mr-2" />Re-evaluate</>
) : (
<><Play className="h-4 w-4 mr-2" />Run Eligibility</>
)}
</Button>
</div>
{/* Progress bar */}
{isRunning && currentJobTotal && currentJobTotal > 0 && (
<div className="space-y-1">
<Progress value={jobProgress} className="h-2" />
<p className="text-xs text-muted-foreground text-right">
{currentJobDone ?? 0} / {currentJobTotal} projects
</p>
</div>
)}
{/* Shortlist table */}
{expanded && currentJobStatus === 'COMPLETED' && (
<>
{isLoadingShortlist ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : shortlist && shortlist.eligibilities.length > 0 ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">
{shortlist.total} eligible projects
{shortlistedCount > 0 && (
<span className="text-muted-foreground ml-1">
({shortlistedCount} shortlisted)
</span>
)}
</p>
{shortlistedCount > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="default">
<CheckCircle2 className="h-4 w-4 mr-2" />
Confirm Shortlist ({shortlistedCount})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Shortlist</AlertDialogTitle>
<AlertDialogDescription>
{eligibilityMode === 'SEPARATE_POOL'
? `This will confirm ${shortlistedCount} projects for the "${awardName}" award track. Projects will be routed to the award's rounds for separate evaluation.`
: `This will confirm ${shortlistedCount} projects as eligible for the "${awardName}" award. Projects remain in the main competition pool.`
}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => confirmMutation.mutate({ awardId })}
disabled={confirmMutation.isPending}
>
{confirmMutation.isPending ? 'Confirming...' : 'Confirm'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<div className="border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left w-8">#</th>
<th className="px-3 py-2 text-left">Project</th>
<th className="px-3 py-2 text-left w-24">Score</th>
<th className="px-3 py-2 text-left w-32">Reasoning</th>
<th className="px-3 py-2 text-center w-20">Shortlist</th>
</tr>
</thead>
<tbody>
{shortlist.eligibilities.map((e, i) => {
const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined
return (
<tr key={e.id} className={`border-t ${e.shortlisted ? 'bg-amber-50/50' : ''}`}>
<td className="px-3 py-2 text-muted-foreground font-mono">
{i + 1}
</td>
<td className="px-3 py-2">
<div>
<p className="font-medium">{e.project.title}</p>
<p className="text-xs text-muted-foreground">
{e.project.teamName || e.project.country || e.project.competitionCategory || '—'}
</p>
</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<Progress
value={e.qualityScore ?? 0}
className="h-2 w-16"
/>
<span className="text-xs font-mono font-medium">
{e.qualityScore ?? 0}
</span>
</div>
</td>
<td className="px-3 py-2">
{reasoning ? (
<p className="text-xs text-muted-foreground line-clamp-2" title={reasoning}>
{reasoning}
</p>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2 text-center">
<Checkbox
checked={e.shortlisted}
onCheckedChange={() =>
toggleMutation.mutate({ awardId, projectId: e.project.id })
}
disabled={toggleMutation.isPending}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
No eligible projects found
</p>
)}
</>
)}
{/* Not yet evaluated */}
{expanded && !currentJobStatus && (
<p className="text-sm text-muted-foreground text-center py-4">
Click &quot;Run Eligibility&quot; to evaluate projects against this award&apos;s criteria
</p>
)}
{/* Failed */}
{currentJobStatus === 'FAILED' && (
<p className="text-sm text-red-600 text-center py-2">
Eligibility evaluation failed. Try again.
</p>
)}
</div>
</CollapsibleContent>
</div>
</Collapsible>
)
}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -45,11 +45,11 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { import {
Award,
Play, Play,
Loader2, Loader2,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
AlertTriangle,
RefreshCw, RefreshCw,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@@ -68,10 +68,11 @@ import {
FileText, FileText,
Brain, Brain,
ListFilter, ListFilter,
GripVertical, Settings2,
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { AwardShortlist } from './award-shortlist'
type FilteringDashboardProps = { type FilteringDashboardProps = {
competitionId: string competitionId: string
@@ -103,17 +104,29 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
// AI criteria state
const [criteriaText, setCriteriaText] = useState('')
const [aiAction, setAiAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('FLAG')
const [batchSize, setBatchSize] = useState(20)
const [parallelBatches, setParallelBatches] = useState(3)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [criteriaDirty, setCriteriaDirty] = useState(false)
const criteriaLoaded = useRef(false)
const utils = trpc.useUtils() const utils = trpc.useUtils()
// -- Queries -- // -- Queries --
const { data: stats, isLoading: statsLoading } = trpc.filtering.getResultStats.useQuery( const { data: latestJob } = trpc.filtering.getLatestJob.useQuery(
{ roundId }, { roundId },
{ refetchInterval: 15_000 }, { refetchInterval: pollingJobId ? 3_000 : 15_000 },
) )
const { data: latestJob, isLoading: jobLoading } = trpc.filtering.getLatestJob.useQuery( // Dynamic refetch: 3s during running job, 15s otherwise
const isRunning = !!pollingJobId || latestJob?.status === 'RUNNING'
const { data: stats, isLoading: statsLoading } = trpc.filtering.getResultStats.useQuery(
{ roundId }, { roundId },
{ refetchInterval: 15_000 }, { refetchInterval: isRunning ? 3_000 : 15_000 },
) )
const { data: rules } = trpc.filtering.getRules.useQuery( const { data: rules } = trpc.filtering.getRules.useQuery(
@@ -128,7 +141,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
page, page,
perPage: 25, perPage: 25,
}, },
{ refetchInterval: 15_000 }, { refetchInterval: isRunning ? 3_000 : 15_000 },
) )
const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery( const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
@@ -139,6 +152,19 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
}, },
) )
// Load AI criteria from existing rule
const aiRule = rules?.find((r: any) => r.ruleType === 'AI_SCREENING')
useEffect(() => {
if (aiRule && !criteriaLoaded.current) {
const config = (aiRule.configJson || {}) as Record<string, unknown>
setCriteriaText((config.criteriaText as string) || '')
setAiAction((config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG')
setBatchSize((config.batchSize as number) || 20)
setParallelBatches((config.parallelBatches as number) || 3)
criteriaLoaded.current = true
}
}, [aiRule])
// Stop polling when job completes // Stop polling when job completes
useEffect(() => { useEffect(() => {
if (jobStatus && (jobStatus.status === 'COMPLETED' || jobStatus.status === 'FAILED')) { if (jobStatus && (jobStatus.status === 'COMPLETED' || jobStatus.status === 'FAILED')) {
@@ -162,6 +188,16 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
}, [latestJob]) }, [latestJob])
// -- Mutations -- // -- Mutations --
const createRuleMutation = trpc.filtering.createRule.useMutation({
onSuccess: () => utils.filtering.getRules.invalidate({ roundId }),
onError: (err) => toast.error(err.message),
})
const updateRuleMutation = trpc.filtering.updateRule.useMutation({
onSuccess: () => utils.filtering.getRules.invalidate({ roundId }),
onError: (err) => toast.error(err.message),
})
const startJobMutation = trpc.filtering.startJob.useMutation({ const startJobMutation = trpc.filtering.startJob.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
setPollingJobId(data.jobId) setPollingJobId(data.jobId)
@@ -212,8 +248,59 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
}) })
// -- Handlers -- // -- Handlers --
const handleStartJob = () => { const handleSaveAndRun = async () => {
startJobMutation.mutate({ roundId }) if (!criteriaText.trim()) {
toast.error('Please write screening criteria first')
return
}
const configJson = {
criteriaText,
action: aiAction,
batchSize,
parallelBatches,
}
try {
if (aiRule) {
await updateRuleMutation.mutateAsync({ id: (aiRule as any).id, configJson })
} else {
await createRuleMutation.mutateAsync({
roundId,
name: 'AI Screening',
ruleType: 'AI_SCREENING',
configJson,
priority: 0,
})
}
setCriteriaDirty(false)
startJobMutation.mutate({ roundId })
} catch {
// Error handled by mutation onError
}
}
const handleSaveCriteria = async () => {
const configJson = {
criteriaText,
action: aiAction,
batchSize,
parallelBatches,
}
if (aiRule) {
await updateRuleMutation.mutateAsync({ id: (aiRule as any).id, configJson })
} else {
await createRuleMutation.mutateAsync({
roundId,
name: 'AI Screening',
ruleType: 'AI_SCREENING',
configJson,
priority: 0,
})
}
setCriteriaDirty(false)
toast.success('Criteria saved')
} }
const handleOverride = () => { const handleOverride = () => {
@@ -275,14 +362,25 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
const parseAIData = (json: unknown): AIScreeningData | null => { const parseAIData = (json: unknown): AIScreeningData | null => {
if (!json || typeof json !== 'object') return null if (!json || typeof json !== 'object') return null
return json as AIScreeningData const obj = json as Record<string, unknown>
// aiScreeningJson is nested under rule ID: { [ruleId]: { outcome, confidence, ... } }
// Unwrap first entry if top-level keys don't include expected AI fields
if (!('outcome' in obj)) {
const keys = Object.keys(obj)
if (keys.length > 0) {
const inner = obj[keys[0]]
if (inner && typeof inner === 'object') return inner as AIScreeningData
}
return null
}
return obj as unknown as AIScreeningData
} }
// Is there a running job?
const isRunning = !!pollingJobId || latestJob?.status === 'RUNNING'
const activeJob = jobStatus || (latestJob?.status === 'RUNNING' ? latestJob : null) const activeJob = jobStatus || (latestJob?.status === 'RUNNING' ? latestJob : null)
const hasResults = stats && stats.total > 0 const hasResults = stats && stats.total > 0
const hasRules = rules && rules.length > 0 const nonAiRules = rules?.filter((r: any) => r.ruleType !== 'AI_SCREENING') ?? []
const hasNonAiRules = nonAiRules.length > 0
const isSaving = createRuleMutation.isPending || updateRuleMutation.isPending
// Filter results by search query (client-side) // Filter results by search query (client-side)
const displayResults = resultsPage?.results.filter((r: any) => { const displayResults = resultsPage?.results.filter((r: any) => {
@@ -296,35 +394,48 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Job Control */} {/* Main Card: AI Screening Criteria + Controls */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div> <div>
<CardTitle className="text-base">AI Filtering</CardTitle> <CardTitle className="text-base flex items-center gap-2">
<Brain className="h-5 w-5 text-purple-600" />
AI Screening
</CardTitle>
<CardDescription> <CardDescription>
Run AI screening against {hasRules ? rules.length : 0} active rule{rules?.length !== 1 ? 's' : ''} Write all your screening criteria below the AI evaluates each project against them
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
{criteriaDirty && (
<Button
variant="outline"
size="sm"
onClick={handleSaveCriteria}
disabled={isSaving}
>
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Save
</Button>
)}
<Button <Button
onClick={handleStartJob}
disabled={isRunning || startJobMutation.isPending || !hasRules}
size="sm" size="sm"
onClick={handleSaveAndRun}
disabled={isRunning || startJobMutation.isPending || !criteriaText.trim()}
> >
{isRunning ? ( {isRunning ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Running...</>
) : ( ) : (
<Play className="h-4 w-4 mr-2" /> <><Play className="h-4 w-4 mr-2" />Run Filtering</>
)} )}
{isRunning ? 'Running...' : 'Run Filtering'}
</Button> </Button>
{hasResults && ( {hasResults && !isRunning && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={isRunning || finalizeMutation.isPending}> <Button variant="outline" size="sm" disabled={finalizeMutation.isPending}>
<Shield className="h-4 w-4 mr-2" /> <Shield className="h-4 w-4 mr-2" />
Finalize Results Finalize
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
@@ -353,16 +464,76 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder={`Write all your screening criteria here. The AI will evaluate each project against these requirements.\n\nExample:\n1. Project must have clear ocean conservation impact\n2. Documents should be in English or French\n3. For Business Concepts, academic rigor is acceptable\n4. For African projects, apply a lower quality threshold (score >= 5/10)\n5. Flag any submissions that appear to be AI-generated filler`}
value={criteriaText}
onChange={(e) => { setCriteriaText(e.target.value); setCriteriaDirty(true) }}
rows={8}
className="text-sm font-mono"
/>
<p className="text-xs text-muted-foreground">
Available data per project: category, country, region, founded year, ocean issue, tags,
description, file details (type, pages, size, detected language), and team size.
</p>
{/* Job Progress */} {/* Advanced Settings */}
{isRunning && activeJob && ( <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CardContent className="pt-0"> <CollapsibleTrigger className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
<div className="space-y-2"> <Settings2 className="h-3.5 w-3.5" />
Advanced Settings
{advancedOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="grid grid-cols-3 gap-3 rounded-lg border p-3 bg-muted/20">
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Default Action</Label>
<Select value={aiAction} onValueChange={(v) => { setAiAction(v as 'PASS' | 'REJECT' | 'FLAG'); setCriteriaDirty(true) }}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FLAG">Flag for review</SelectItem>
<SelectItem value="REJECT">Auto-reject failures</SelectItem>
<SelectItem value="PASS">Auto-pass matches</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Batch Size</Label>
<Input
type="number"
min={1}
max={50}
className="h-8 text-xs"
value={batchSize}
onChange={(e) => { setBatchSize(parseInt(e.target.value) || 20); setCriteriaDirty(true) }}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Parallel Batches</Label>
<Input
type="number"
min={1}
max={10}
className="h-8 text-xs"
value={parallelBatches}
onChange={(e) => { setParallelBatches(parseInt(e.target.value) || 3); setCriteriaDirty(true) }}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* Job Progress */}
{isRunning && activeJob && (
<div className="space-y-2 pt-3 border-t">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> <span className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Processing batch {activeJob.currentBatch} of {activeJob.totalBatches || '?'} Processing batch {activeJob.currentBatch} of {activeJob.totalBatches || '?'}
</span> </span>
<span className="font-mono"> <span className="font-mono text-sm">
{activeJob.processedCount}/{activeJob.totalProjects} {activeJob.processedCount}/{activeJob.totalProjects}
</span> </span>
</div> </div>
@@ -372,43 +543,34 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
: 0 : 0
} }
/> />
<p className="text-xs text-muted-foreground">
Results appear in the table below as each batch completes
</p>
</div> </div>
</CardContent> )}
)}
{/* Last job summary */} {/* Last job summary */}
{!isRunning && latestJob && latestJob.status === 'COMPLETED' && ( {!isRunning && latestJob && latestJob.status === 'COMPLETED' && (
<CardContent className="pt-0"> <div className="flex items-center gap-2 text-sm text-muted-foreground pt-3 border-t">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-green-600" /> <CheckCircle2 className="h-4 w-4 text-green-600" />
Last run completed: {latestJob.passedCount} passed, {latestJob.filteredCount} filtered, {latestJob.flaggedCount} flagged Last run: {latestJob.passedCount} passed, {latestJob.filteredCount} filtered, {latestJob.flaggedCount} flagged
<span className="text-xs"> <span className="text-xs">
({new Date(latestJob.completedAt!).toLocaleDateString()}) ({new Date(latestJob.completedAt!).toLocaleDateString()})
</span> </span>
</div> </div>
</CardContent> )}
)}
{!isRunning && latestJob && latestJob.status === 'FAILED' && ( {!isRunning && latestJob && latestJob.status === 'FAILED' && (
<CardContent className="pt-0"> <div className="flex items-center gap-2 text-sm text-red-600 pt-3 border-t">
<div className="flex items-center gap-2 text-sm text-red-600">
<XCircle className="h-4 w-4" /> <XCircle className="h-4 w-4" />
Last run failed: {latestJob.errorMessage || 'Unknown error'} Last run failed: {latestJob.errorMessage || 'Unknown error'}
</div> </div>
</CardContent> )}
)} </CardContent>
{!hasRules && (
<CardContent className="pt-0">
<p className="text-sm text-amber-600">
No active filtering rules configured. Add rules in the Configuration tab first.
</p>
</CardContent>
)}
</Card> </Card>
{/* Filtering Rules */} {/* Additional Rules (Field-Based & Document Checks) */}
<FilteringRulesSection roundId={roundId} /> <AdditionalRulesSection roundId={roundId} hasNonAiRules={hasNonAiRules} />
{/* Stats Cards */} {/* Stats Cards */}
{statsLoading ? ( {statsLoading ? (
@@ -477,7 +639,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
) : null} ) : null}
{/* Results Table */} {/* Results Table */}
{(hasResults || resultsLoading) && ( {(hasResults || resultsLoading || isRunning) && (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
@@ -485,6 +647,11 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
<CardTitle className="text-base">Filtering Results</CardTitle> <CardTitle className="text-base">Filtering Results</CardTitle>
<CardDescription> <CardDescription>
Review AI screening outcomes &mdash; click a row to see reasoning, use quick buttons to override Review AI screening outcomes &mdash; click a row to see reasoning, use quick buttons to override
{isRunning && (
<span className="ml-2 text-purple-600 font-medium animate-pulse">
New results streaming in...
</span>
)}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -573,7 +740,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
const isExpanded = expandedId === result.id const isExpanded = expandedId === result.id
return ( return (
<div key={result.id} className="border-b last:border-b-0"> <div key={result.id} className="border-b last:border-b-0 animate-in fade-in-0 duration-300">
{/* Main Row */} {/* Main Row */}
<div <div
className="grid grid-cols-[40px_1fr_120px_100px_70px_70px_120px] gap-2 px-3 py-2.5 items-center hover:bg-muted/50 text-sm cursor-pointer" className="grid grid-cols-[40px_1fr_120px_100px_70px_70px_120px] gap-2 px-3 py-2.5 items-center hover:bg-muted/50 text-sm cursor-pointer"
@@ -816,10 +983,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<Sparkles className="h-8 w-8 text-muted-foreground mb-3" /> <Sparkles className="h-8 w-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{searchQuery.trim() ? 'No results match your search' : 'No results yet'} {searchQuery.trim() ? 'No results match your search' : isRunning ? 'Results will appear here as batches complete' : 'No results yet'}
</p> </p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{searchQuery.trim() ? 'Try a different search term' : 'Run the filtering job to screen projects'} {searchQuery.trim() ? 'Try a different search term' : isRunning ? 'The AI is processing projects...' : 'Run filtering to screen projects'}
</p> </p>
</div> </div>
)} )}
@@ -827,6 +994,9 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</Card> </Card>
)} )}
{/* Special Award Tracks */}
{hasResults && <AwardTracksSection competitionId={competitionId} roundId={roundId} />}
{/* Single Override Dialog (with reason) */} {/* Single Override Dialog (with reason) */}
<Dialog open={overrideDialogOpen} onOpenChange={setOverrideDialogOpen}> <Dialog open={overrideDialogOpen} onOpenChange={setOverrideDialogOpen}>
<DialogContent> <DialogContent>
@@ -948,14 +1118,13 @@ function ConfidenceIndicator({ value }: { value: number }) {
) )
} }
// ─── Filtering Rules Section ──────────────────────────────────────────────── // ─── Additional Rules Section (Field-Based & Document Checks only) ──────────
type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING' type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK'
const RULE_TYPE_META: Record<RuleType, { label: string; icon: typeof ListFilter; color: string; description: string }> = { const RULE_TYPE_META: Record<RuleType, { label: string; icon: typeof ListFilter; color: string; description: string }> = {
FIELD_BASED: { label: 'Field-Based', icon: ListFilter, color: 'bg-blue-100 text-blue-800 border-blue-200', description: 'Evaluate project fields (category, founding date, location, etc.)' }, FIELD_BASED: { label: 'Field-Based', icon: ListFilter, color: 'bg-blue-100 text-blue-800 border-blue-200', description: 'Evaluate project fields (category, founding date, location, etc.)' },
DOCUMENT_CHECK: { label: 'Document Check', icon: FileText, color: 'bg-teal-100 text-teal-800 border-teal-200', description: 'Validate file uploads (min count, formats, page limits)' }, DOCUMENT_CHECK: { label: 'Document Check', icon: FileText, color: 'bg-teal-100 text-teal-800 border-teal-200', description: 'Validate file uploads (min count, formats, page limits)' },
AI_SCREENING: { label: 'AI Screening', icon: Brain, color: 'bg-purple-100 text-purple-800 border-purple-200', description: 'GPT evaluates projects against natural language criteria' },
} }
const FIELD_OPTIONS = [ const FIELD_OPTIONS = [
@@ -997,11 +1166,6 @@ type RuleFormData = {
maxPages: number | '' maxPages: number | ''
maxPagesByFileType: Record<string, number> maxPagesByFileType: Record<string, number>
docAction: 'PASS' | 'REJECT' | 'FLAG' docAction: 'PASS' | 'REJECT' | 'FLAG'
// AI_SCREENING
criteriaText: string
aiAction: 'PASS' | 'REJECT' | 'FLAG'
batchSize: number
parallelBatches: number
} }
const DEFAULT_FORM: RuleFormData = { const DEFAULT_FORM: RuleFormData = {
@@ -1016,10 +1180,6 @@ const DEFAULT_FORM: RuleFormData = {
maxPages: '', maxPages: '',
maxPagesByFileType: {}, maxPagesByFileType: {},
docAction: 'REJECT', docAction: 'REJECT',
criteriaText: '',
aiAction: 'FLAG',
batchSize: 20,
parallelBatches: 1,
} }
function buildConfigJson(form: RuleFormData): Record<string, unknown> { function buildConfigJson(form: RuleFormData): Record<string, unknown> {
@@ -1044,13 +1204,6 @@ function buildConfigJson(form: RuleFormData): Record<string, unknown> {
if (Object.keys(form.maxPagesByFileType).length > 0) config.maxPagesByFileType = form.maxPagesByFileType if (Object.keys(form.maxPagesByFileType).length > 0) config.maxPagesByFileType = form.maxPagesByFileType
return config return config
} }
case 'AI_SCREENING':
return {
criteriaText: form.criteriaText,
action: form.aiAction,
batchSize: form.batchSize,
parallelBatches: form.parallelBatches,
}
} }
} }
@@ -1075,21 +1228,13 @@ function parseConfigToForm(rule: { name: string; ruleType: string; configJson: u
maxPagesByFileType: (config.maxPagesByFileType as Record<string, number>) || {}, maxPagesByFileType: (config.maxPagesByFileType as Record<string, number>) || {},
docAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT', docAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT',
} }
case 'AI_SCREENING':
return {
...base,
criteriaText: (config.criteriaText as string) || '',
aiAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG',
batchSize: (config.batchSize as number) || 20,
parallelBatches: (config.parallelBatches as number) || 1,
}
default: default:
return base return base
} }
} }
function FilteringRulesSection({ roundId }: { roundId: string }) { function AdditionalRulesSection({ roundId, hasNonAiRules }: { roundId: string; hasNonAiRules: boolean }) {
const [isOpen, setIsOpen] = useState(true) const [isOpen, setIsOpen] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editingRule, setEditingRule] = useState<string | null>(null) const [editingRule, setEditingRule] = useState<string | null>(null)
const [form, setForm] = useState<RuleFormData>({ ...DEFAULT_FORM }) const [form, setForm] = useState<RuleFormData>({ ...DEFAULT_FORM })
@@ -1097,11 +1242,14 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
const { data: rules, isLoading } = trpc.filtering.getRules.useQuery( const { data: allRules, isLoading } = trpc.filtering.getRules.useQuery(
{ roundId }, { roundId },
{ refetchInterval: 30_000 }, { refetchInterval: 30_000 },
) )
// Only show non-AI rules
const rules = allRules?.filter((r: any) => r.ruleType !== 'AI_SCREENING') ?? []
const createMutation = trpc.filtering.createRule.useMutation({ const createMutation = trpc.filtering.createRule.useMutation({
onSuccess: () => { onSuccess: () => {
utils.filtering.getRules.invalidate({ roundId }) utils.filtering.getRules.invalidate({ roundId })
@@ -1156,12 +1304,10 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
const openCreate = () => { const openCreate = () => {
setEditingRule(null) setEditingRule(null)
setForm({ ...DEFAULT_FORM, priority: (rules?.length ?? 0) }) setForm({ ...DEFAULT_FORM, priority: rules.length })
setDialogOpen(true) setDialogOpen(true)
} }
const meta = RULE_TYPE_META[form.ruleType]
return ( return (
<> <>
<Collapsible open={isOpen} onOpenChange={setIsOpen}> <Collapsible open={isOpen} onOpenChange={setIsOpen}>
@@ -1172,9 +1318,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ListFilter className="h-5 w-5 text-[#053d57]" /> <ListFilter className="h-5 w-5 text-[#053d57]" />
<div> <div>
<CardTitle className="text-base">Filtering Rules</CardTitle> <CardTitle className="text-base">Additional Rules</CardTitle>
<CardDescription> <CardDescription>
{rules?.length ?? 0} active rule{(rules?.length ?? 0) !== 1 ? 's' : ''} &mdash; executed in priority order {rules.length} field-based / document check rule{rules.length !== 1 ? 's' : ''} applied alongside AI screening
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -1199,7 +1345,7 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
<div className="space-y-2"> <div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)} {[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div> </div>
) : rules && rules.length > 0 ? ( ) : rules.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{rules.map((rule: any, idx: number) => { {rules.map((rule: any, idx: number) => {
const typeMeta = RULE_TYPE_META[rule.ruleType as RuleType] || RULE_TYPE_META.FIELD_BASED const typeMeta = RULE_TYPE_META[rule.ruleType as RuleType] || RULE_TYPE_META.FIELD_BASED
@@ -1212,7 +1358,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
className="flex items-center gap-3 rounded-lg border p-3 bg-background hover:bg-muted/30 transition-colors group" className="flex items-center gap-3 rounded-lg border p-3 bg-background hover:bg-muted/30 transition-colors group"
> >
<div className="flex items-center gap-1 text-muted-foreground"> <div className="flex items-center gap-1 text-muted-foreground">
<GripVertical className="h-4 w-4 opacity-0 group-hover:opacity-50" />
<span className="text-xs font-mono w-5 text-center">{idx + 1}</span> <span className="text-xs font-mono w-5 text-center">{idx + 1}</span>
</div> </div>
@@ -1234,17 +1379,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
{config.minFileCount ? `Min ${config.minFileCount} files` : ''} {config.minFileCount ? `Min ${config.minFileCount} files` : ''}
{config.requiredFileTypes ? ` \u00b7 Types: ${(config.requiredFileTypes as string[]).join(', ')}` : ''} {config.requiredFileTypes ? ` \u00b7 Types: ${(config.requiredFileTypes as string[]).join(', ')}` : ''}
{config.maxPages ? ` \u00b7 Max ${config.maxPages} pages` : ''} {config.maxPages ? ` \u00b7 Max ${config.maxPages} pages` : ''}
{config.maxPagesByFileType && Object.keys(config.maxPagesByFileType as object).length > 0
? ` \u00b7 Page limits per type`
: ''}
{' \u2192 '}{config.action as string} {' \u2192 '}{config.action as string}
</> </>
)} )}
{rule.ruleType === 'AI_SCREENING' && (
<>
{((config.criteriaText as string) || '').substring(0, 80)}{((config.criteriaText as string) || '').length > 80 ? '...' : ''} &rarr; {config.action as string}
</>
)}
</p> </p>
</div> </div>
@@ -1277,15 +1414,14 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
})} })}
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-6 text-center">
<ListFilter className="h-8 w-8 text-muted-foreground mb-3" /> <ListFilter className="h-6 w-6 text-muted-foreground mb-2" />
<p className="text-sm font-medium">No filtering rules configured</p> <p className="text-sm text-muted-foreground">
<p className="text-xs text-muted-foreground mt-1 mb-3"> No additional rules AI screening criteria handle everything
Add rules to define how projects are screened
</p> </p>
<Button variant="outline" size="sm" onClick={openCreate}> <Button variant="outline" size="sm" className="mt-2" onClick={openCreate}>
<Plus className="h-3.5 w-3.5 mr-1" /> <Plus className="h-3.5 w-3.5 mr-1" />
Add First Rule Add Rule
</Button> </Button>
</div> </div>
)} )}
@@ -1301,9 +1437,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
}}> }}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>{editingRule ? 'Edit Rule' : 'Create Filtering Rule'}</DialogTitle> <DialogTitle>{editingRule ? 'Edit Rule' : 'Create Rule'}</DialogTitle>
<DialogDescription> <DialogDescription>
{editingRule ? 'Update this filtering rule configuration' : 'Define a new rule for screening projects'} {editingRule ? 'Update this filtering rule' : 'Add a field-based or document check rule'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -1329,10 +1465,10 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
</div> </div>
</div> </div>
{/* Rule Type Selector */} {/* Rule Type Selector (only Field-Based and Document Check) */}
<div> <div>
<Label className="text-sm font-medium mb-2 block">Rule Type</Label> <Label className="text-sm font-medium mb-2 block">Rule Type</Label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-2 gap-2">
{(Object.entries(RULE_TYPE_META) as [RuleType, typeof RULE_TYPE_META[RuleType]][]).map(([type, m]) => { {(Object.entries(RULE_TYPE_META) as [RuleType, typeof RULE_TYPE_META[RuleType]][]).map(([type, m]) => {
const Icon = m.icon const Icon = m.icon
const selected = form.ruleType === type const selected = form.ruleType === type
@@ -1361,17 +1497,15 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
<div className="space-y-3 rounded-lg border p-4 bg-muted/20"> <div className="space-y-3 rounded-lg border p-4 bg-muted/20">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-sm font-medium">Conditions</Label> <Label className="text-sm font-medium">Conditions</Label>
<div className="flex items-center gap-2"> <Select value={form.logic} onValueChange={(v) => setForm((f) => ({ ...f, logic: v as 'AND' | 'OR' }))}>
<Select value={form.logic} onValueChange={(v) => setForm((f) => ({ ...f, logic: v as 'AND' | 'OR' }))}> <SelectTrigger className="w-20 h-7 text-xs">
<SelectTrigger className="w-20 h-7 text-xs"> <SelectValue />
<SelectValue /> </SelectTrigger>
</SelectTrigger> <SelectContent>
<SelectContent> <SelectItem value="AND">AND</SelectItem>
<SelectItem value="AND">AND</SelectItem> <SelectItem value="OR">OR</SelectItem>
<SelectItem value="OR">OR</SelectItem> </SelectContent>
</SelectContent> </Select>
</Select>
</div>
</div> </div>
{form.conditions.map((cond, i) => { {form.conditions.map((cond, i) => {
@@ -1586,62 +1720,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
</div> </div>
</div> </div>
)} )}
{form.ruleType === 'AI_SCREENING' && (
<div className="space-y-4 rounded-lg border p-4 bg-muted/20">
<div>
<Label className="text-xs text-muted-foreground mb-1.5 block">Screening Criteria</Label>
<Textarea
placeholder="Write the criteria the AI should evaluate against. Example:&#10;&#10;1. Ocean conservation impact must be clearly stated&#10;2. Documents must be in English&#10;3. For Business Concepts, academic rigor is acceptable&#10;4. For African projects, apply a lower quality threshold (score >= 5/10)"
value={form.criteriaText}
onChange={(e) => setForm((f) => ({ ...f, criteriaText: e.target.value }))}
rows={8}
className="text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
The AI has access to: category, country, region, founded year, ocean issue, tags, description, file details (type, page count, size, detected language), and team size.
</p>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Action</Label>
<Select value={form.aiAction} onValueChange={(v) => setForm((f) => ({ ...f, aiAction: v as any }))}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FLAG">Flag for review</SelectItem>
<SelectItem value="REJECT">Auto-reject</SelectItem>
<SelectItem value="PASS">Auto-pass</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Batch Size</Label>
<Input
type="number"
min={1}
max={50}
className="h-8 text-xs"
value={form.batchSize}
onChange={(e) => setForm((f) => ({ ...f, batchSize: parseInt(e.target.value) || 20 }))}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Parallel Batches</Label>
<Input
type="number"
min={1}
max={10}
className="h-8 text-xs"
value={form.parallelBatches}
onChange={(e) => setForm((f) => ({ ...f, parallelBatches: parseInt(e.target.value) || 1 }))}
/>
</div>
</div>
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>
@@ -1685,3 +1763,45 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
</> </>
) )
} }
// ─── Award Tracks Section ───────────────────────────────────────────────────
function AwardTracksSection({ competitionId, roundId }: { competitionId: string; roundId: string }) {
const { data: awards, isLoading } = trpc.specialAward.listForRound.useQuery(
{ roundId },
{ enabled: !!roundId }
)
if (isLoading) return null
if (!awards || awards.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Award className="h-5 w-5 text-amber-600" />
Special Award Tracks
</CardTitle>
<CardDescription>
Evaluate passed projects against special award criteria and manage shortlists
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{awards.map((award) => (
<AwardShortlist
key={award.id}
awardId={award.id}
roundId={roundId}
awardName={award.name}
criteriaText={award.criteriaText}
eligibilityMode={award.eligibilityMode}
shortlistSize={award.shortlistSize}
jobStatus={award.eligibilityJobStatus}
jobTotal={award.eligibilityJobTotal}
jobDone={award.eligibilityJobDone}
/>
))}
</CardContent>
</Card>
)
}

View File

@@ -20,8 +20,20 @@ function getAIConfidenceScore(aiScreeningJson: Prisma.JsonValue | null): number
if (!aiScreeningJson || typeof aiScreeningJson !== 'object' || Array.isArray(aiScreeningJson)) { if (!aiScreeningJson || typeof aiScreeningJson !== 'object' || Array.isArray(aiScreeningJson)) {
return 0 return 0
} }
const obj = aiScreeningJson as Record<string, unknown> let obj = aiScreeningJson as Record<string, unknown>
for (const key of ['overallScore', 'confidenceScore', 'score', 'qualityScore']) { // 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') { if (typeof obj[key] === 'number') {
return obj[key] as number return obj[key] as number
} }
@@ -146,44 +158,44 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
}) })
} }
// Execute rules // Execute rules — upsert results per batch for streaming to the UI
const results = await executeFilteringRules(rules, projects, userId, roundId, onProgress) 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 // Count outcomes
const passedCount = results.filter((r) => r.outcome === 'PASSED').length const passedCount = results.filter((r) => r.outcome === 'PASSED').length
const filteredCount = results.filter((r) => r.outcome === 'FILTERED_OUT').length const filteredCount = results.filter((r) => r.outcome === 'FILTERED_OUT').length
const flaggedCount = results.filter((r) => r.outcome === 'FLAGGED').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 // Mark job as completed
await prisma.filteringJob.update({ await prisma.filteringJob.update({
where: { id: jobId }, where: { id: jobId },

View File

@@ -764,4 +764,372 @@ export const specialAwardRouter = router({
return award 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", "project_id": "PROJECT_001",
"eligible": true/false, "eligible": true/false,
"confidence": 0.0-1.0, "confidence": 0.0-1.0,
"quality_score": 0-100,
"reasoning": "2-3 sentence explanation covering key dimensions", "reasoning": "2-3 sentence explanation covering key dimensions",
"dimensionScores": { "dimensionScores": {
"geographic": 0.0-1.0, "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 ## Guidelines
- Base evaluation only on provided data — do not infer missing information - Base evaluation only on provided data — do not infer missing information
- eligible=true only when ALL required dimensions score above 0.5 - eligible=true only when ALL required dimensions score above 0.5
@@ -77,6 +80,7 @@ export interface EligibilityResult {
projectId: string projectId: string
eligible: boolean eligible: boolean
confidence: number confidence: number
qualityScore: number
reasoning: string reasoning: string
method: 'AUTO' | 'AI' method: 'AUTO' | 'AI'
} }
@@ -229,6 +233,7 @@ Evaluate eligibility for each project.`
project_id: string project_id: string
eligible: boolean eligible: boolean
confidence: number confidence: number
quality_score?: number
reasoning: string reasoning: string
}> }>
} }
@@ -273,6 +278,7 @@ Evaluate eligibility for each project.`
projectId: mapping.realId, projectId: mapping.realId,
eligible: eval_.eligible, eligible: eval_.eligible,
confidence: eval_.confidence, confidence: eval_.confidence,
qualityScore: Math.max(0, Math.min(100, eval_.quality_score ?? 0)),
reasoning: eval_.reasoning, reasoning: eval_.reasoning,
method: 'AI', method: 'AI',
}) })
@@ -305,6 +311,7 @@ Evaluate eligibility for each project.`
projectId: mapping.realId, projectId: mapping.realId,
eligible: false, eligible: false,
confidence: 0, confidence: 0,
qualityScore: 0,
reasoning: 'AI response parse error — requires manual review', reasoning: 'AI response parse error — requires manual review',
method: 'AI', method: 'AI',
}) })
@@ -333,6 +340,7 @@ export async function aiInterpretCriteria(
projectId: p.id, projectId: p.id,
eligible: false, eligible: false,
confidence: 0, confidence: 0,
qualityScore: 0,
reasoning: 'AI unavailable — requires manual eligibility review', reasoning: 'AI unavailable — requires manual eligibility review',
method: 'AI' as const, method: 'AI' as const,
})) }))
@@ -401,6 +409,7 @@ export async function aiInterpretCriteria(
projectId: p.id, projectId: p.id,
eligible: false, eligible: false,
confidence: 0, confidence: 0,
qualityScore: 0,
reasoning: `AI error: ${classified.message}`, reasoning: `AI error: ${classified.message}`,
method: 'AI' as const, method: 'AI' as const,
})) }))

View File

@@ -510,7 +510,8 @@ export async function executeAIScreening(
projects: ProjectForFiltering[], projects: ProjectForFiltering[],
userId?: string, userId?: string,
entityId?: string, entityId?: string,
onProgress?: ProgressCallback onProgress?: ProgressCallback,
onBatchComplete?: (batchResults: Map<string, AIScreeningResult>) => Promise<void>
): Promise<Map<string, AIScreeningResult>> { ): Promise<Map<string, AIScreeningResult>> {
const results = new Map<string, AIScreeningResult>() const results = new Map<string, AIScreeningResult>()
@@ -599,6 +600,17 @@ export async function executeAIScreening(
processedBatches++ 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 // Report progress after each parallel chunk
if (onProgress) { if (onProgress) {
await onProgress({ await onProgress({
@@ -653,43 +665,29 @@ export async function executeFilteringRules(
projects: ProjectForFiltering[], projects: ProjectForFiltering[],
userId?: string, userId?: string,
roundId?: string, roundId?: string,
onProgress?: ProgressCallback onProgress?: ProgressCallback,
onResultsBatch?: (results: ProjectFilteringResult[]) => Promise<void>
): Promise<ProjectFilteringResult[]> { ): Promise<ProjectFilteringResult[]> {
const activeRules = rules const activeRules = rules
.filter((r) => r.isActive) .filter((r) => r.isActive)
.sort((a, b) => a.priority - b.priority) .sort((a, b) => a.priority - b.priority)
// Separate AI screening rules (need batch processing)
const aiRules = activeRules.filter((r) => r.ruleType === 'AI_SCREENING') const aiRules = activeRules.filter((r) => r.ruleType === 'AI_SCREENING')
const nonAiRules = activeRules.filter((r) => r.ruleType !== 'AI_SCREENING') const nonAiRules = activeRules.filter((r) => r.ruleType !== 'AI_SCREENING')
// Pre-compute AI screening results if needed // Pre-evaluate non-AI rules for all projects (instant)
const aiResults = new Map<string, Map<string, AIScreeningResult>>() const nonAiEval = new Map<string, { ruleResults: RuleResult[]; hasFailed: boolean; hasFlagged: boolean }>()
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[] = []
for (const project of projects) { for (const project of projects) {
const ruleResults: RuleResult[] = [] const ruleResults: RuleResult[] = []
let hasFailed = false let hasFailed = false
let hasFlagged = false let hasFlagged = false
// Evaluate non-AI rules
for (const rule of nonAiRules) { for (const rule of nonAiRules) {
let result: { passed: boolean; action: 'PASS' | 'REJECT' | 'FLAG' } let result: { passed: boolean; action: 'PASS' | 'REJECT' | 'FLAG' }
if (rule.ruleType === 'FIELD_BASED') { if (rule.ruleType === 'FIELD_BASED') {
const config = rule.configJson as unknown as FieldRuleConfig result = evaluateFieldRule(rule.configJson as unknown as FieldRuleConfig, project)
result = evaluateFieldRule(config, project)
} else if (rule.ruleType === 'DOCUMENT_CHECK') { } else if (rule.ruleType === 'DOCUMENT_CHECK') {
const config = rule.configJson as unknown as DocumentCheckConfig result = evaluateDocumentRule(rule.configJson as unknown as DocumentCheckConfig, project)
result = evaluateDocumentRule(config, project)
} else { } else {
continue continue
} }
@@ -701,65 +699,107 @@ export async function executeFilteringRules(
passed: result.passed, passed: result.passed,
action: result.action, action: result.action,
}) })
if (!result.passed) { if (!result.passed) {
if (result.action === 'REJECT') hasFailed = true if (result.action === 'REJECT') hasFailed = true
if (result.action === 'FLAG') hasFlagged = 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) { for (const aiRule of aiRules) {
const ruleScreening = aiResults.get(aiRule.id) const screening = aiResults.get(aiRule.id)?.get(project.id)
const screening = ruleScreening?.get(project.id)
if (screening) { if (screening) {
const passed = screening.meetsCriteria && !screening.spamRisk const passed = screening.meetsCriteria && !screening.spamRisk
const aiConfig = aiRule.configJson as unknown as AIScreeningConfig const aiConfig = aiRule.configJson as unknown as AIScreeningConfig
const aiAction = aiConfig?.action || 'FLAG' const aiAction = aiConfig?.action || 'FLAG'
ruleResults.push({ aiRuleResults.push({ ruleId: aiRule.id, ruleName: aiRule.name, passed, action: aiAction, reasoning: screening.reasoning })
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) {
aiScreeningData[aiRule.id] = screening aiScreeningData[aiRule.id] = screening
} }
} }
results.push(computeProjectResult(project.id, aiRuleResults, aiScreeningData))
results.push({
projectId: project.id,
outcome,
ruleResults,
aiScreeningJson:
Object.keys(aiScreeningData).length > 0 ? aiScreeningData : undefined,
})
} }
if (onResultsBatch) await onResultsBatch(results)
return results return results
} }

View File

@@ -141,7 +141,16 @@ export interface ProjectWithRelations {
teamMembers?: number teamMembers?: number
files?: 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, fileType: (f.fileType as FileType) ?? null,
size: f.size, size: f.size,
pageCount: f.pageCount ?? null, 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( export async function processEligibilityJob(
awardId: string, awardId: string,
includeSubmitted: boolean, includeSubmitted: boolean,
userId: string userId: string,
filteringRoundId?: string
): Promise<void> { ): Promise<void> {
try { try {
// Mark job as PROCESSING // Mark job as PROCESSING
@@ -23,27 +24,76 @@ export async function processEligibilityJob(
include: { program: true }, include: { program: true },
}) })
// Get projects // Get projects — scoped to filtering round PASSED projects if provided
const statusFilter = includeSubmitted let projects: Array<{
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const) id: string
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const) 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({ if (filteringRoundId) {
where: { // Scope to projects that PASSED filtering in the specified round
programId: award.programId, const passedResults = await prisma.filteringResult.findMany({
status: { in: [...statusFilter] }, where: { roundId: filteringRoundId, outcome: 'PASSED' },
}, select: { projectId: true },
select: { })
id: true, const passedIds = passedResults.map((r) => r.projectId)
title: true,
description: true, if (passedIds.length === 0) {
competitionCategory: true, await prisma.specialAward.update({
country: true, where: { id: awardId },
geographicZone: true, data: {
tags: true, eligibilityJobStatus: 'COMPLETED',
oceanIssue: true, 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) { if (projects.length === 0) {
await prisma.specialAward.update({ 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) // Phase 2: AI interpretation (if criteria text exists AND AI eligibility is enabled)
// Process in batches to avoid timeouts // 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) { if (award.criteriaText && award.useAiEligibility) {
aiResults = new Map() aiResults = new Map()
@@ -90,6 +140,7 @@ export async function processEligibilityJob(
aiResults.set(e.projectId, { aiResults.set(e.projectId, {
eligible: e.eligible, eligible: e.eligible,
confidence: e.confidence, confidence: e.confidence,
qualityScore: e.qualityScore,
reasoning: e.reasoning, reasoning: e.reasoning,
}) })
} }
@@ -123,8 +174,9 @@ export async function processEligibilityJob(
projectId: project.id, projectId: project.id,
eligible, eligible,
method, method,
qualityScore: aiEval?.qualityScore ?? null,
aiReasoningJson: aiEval aiReasoningJson: aiEval
? { confidence: aiEval.confidence, reasoning: aiEval.reasoning } ? { confidence: aiEval.confidence, qualityScore: aiEval.qualityScore, reasoning: aiEval.reasoning }
: null, : null,
} }
}) })
@@ -144,19 +196,47 @@ export async function processEligibilityJob(
projectId: e.projectId, projectId: e.projectId,
eligible: e.eligible, eligible: e.eligible,
method: e.method as 'AUTO' | 'MANUAL', method: e.method as 'AUTO' | 'MANUAL',
qualityScore: e.qualityScore,
aiReasoningJson: e.aiReasoningJson ?? undefined, aiReasoningJson: e.aiReasoningJson ?? undefined,
}, },
update: { update: {
eligible: e.eligible, eligible: e.eligible,
method: e.method as 'AUTO' | 'MANUAL', method: e.method as 'AUTO' | 'MANUAL',
qualityScore: e.qualityScore,
aiReasoningJson: e.aiReasoningJson ?? undefined, aiReasoningJson: e.aiReasoningJson ?? undefined,
overriddenBy: null, overriddenBy: null,
overriddenAt: 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 // Mark as completed
await prisma.specialAward.update({ await prisma.specialAward.update({
where: { id: awardId }, where: { id: awardId },