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:
@@ -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");
|
||||||
@@ -379,6 +379,7 @@ model User {
|
|||||||
|
|
||||||
// Award overrides
|
// Award overrides
|
||||||
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
||||||
|
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
|
||||||
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
|
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
|
||||||
|
|
||||||
// In-app notifications
|
// In-app notifications
|
||||||
@@ -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 {
|
||||||
|
|||||||
323
src/components/admin/round/award-shortlist.tsx
Normal file
323
src/components/admin/round/award-shortlist.tsx
Normal 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'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 "Run Eligibility" to evaluate projects against this award'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 () => {
|
||||||
|
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 })
|
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
|
<Button
|
||||||
onClick={handleStartJob}
|
variant="outline"
|
||||||
disabled={isRunning || startJobMutation.isPending || !hasRules}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
|
onClick={handleSaveCriteria}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Advanced Settings */}
|
||||||
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||||
|
<CollapsibleTrigger className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
<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 */}
|
{/* Job Progress */}
|
||||||
{isRunning && activeJob && (
|
{isRunning && activeJob && (
|
||||||
<CardContent className="pt-0">
|
<div className="space-y-2 pt-3 border-t">
|
||||||
<div className="space-y-2">
|
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!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>
|
</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 — click a row to see reasoning, use quick buttons to override
|
Review AI screening outcomes — 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' : ''} — 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 ? '...' : ''} → {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,7 +1497,6 @@ 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 />
|
||||||
@@ -1372,7 +1507,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{form.conditions.map((cond, i) => {
|
{form.conditions.map((cond, i) => {
|
||||||
const fieldMeta = FIELD_OPTIONS.find((f) => f.value === cond.field)
|
const fieldMeta = FIELD_OPTIONS.find((f) => f.value === cond.field)
|
||||||
@@ -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: 1. Ocean conservation impact must be clearly stated 2. Documents must be in English 3. For Business Concepts, academic rigor is acceptable 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,17 +158,11 @@ 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
|
||||||
// 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(
|
await prisma.$transaction(
|
||||||
results.map((r) =>
|
batchResults.map((r) =>
|
||||||
prisma.filteringResult.upsert({
|
prisma.filteringResult.upsert({
|
||||||
where: {
|
where: {
|
||||||
roundId_projectId: {
|
roundId_projectId: {
|
||||||
@@ -183,6 +189,12 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
// Mark job as completed
|
// Mark job as completed
|
||||||
await prisma.filteringJob.update({
|
await prisma.filteringJob.update({
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
})
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
|
||||||
// Evaluate AI rules
|
// 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) {
|
for (const aiRule of aiRules) {
|
||||||
const ruleScreening = aiResults.get(aiRule.id)
|
const config = aiRule.configJson as unknown as AIScreeningConfig
|
||||||
const screening = ruleScreening?.get(project.id)
|
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> = {}
|
||||||
|
|
||||||
|
for (const aiRule of aiRules) {
|
||||||
|
const screening = aiResults.get(aiRule.id)?.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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +24,60 @@ export async function processEligibilityJob(
|
|||||||
include: { program: true },
|
include: { program: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get projects
|
// 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
|
||||||
|
}>
|
||||||
|
|
||||||
|
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
|
const statusFilter = includeSubmitted
|
||||||
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
||||||
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
||||||
|
|
||||||
const projects = await prisma.project.findMany({
|
projects = await prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
programId: award.programId,
|
programId: award.programId,
|
||||||
status: { in: [...statusFilter] },
|
status: { in: [...statusFilter] },
|
||||||
@@ -44,6 +93,7 @@ export async function processEligibilityJob(
|
|||||||
oceanIssue: 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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user