From a02ed5915897fd54d1847e1806d78cd33fe65670 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 17 Feb 2026 15:38:31 +0100 Subject: [PATCH] Fix AI filtering bugs, add special award shortlist integration 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 --- .../migration.sql | 20 + prisma/schema.prisma | 17 +- .../admin/round/award-shortlist.tsx | 323 ++++++++++++ .../admin/round/filtering-dashboard.tsx | 488 +++++++++++------- src/server/routers/filtering.ts | 80 +-- src/server/routers/specialAward.ts | 368 +++++++++++++ src/server/services/ai-award-eligibility.ts | 9 + src/server/services/ai-filtering.ts | 170 +++--- src/server/services/anonymization.ts | 16 +- src/server/services/award-eligibility-job.ts | 126 ++++- 10 files changed, 1308 insertions(+), 309 deletions(-) create mode 100644 prisma/migrations/20260217200000_add_award_shortlist_and_round_fields/migration.sql create mode 100644 src/components/admin/round/award-shortlist.tsx diff --git a/prisma/migrations/20260217200000_add_award_shortlist_and_round_fields/migration.sql b/prisma/migrations/20260217200000_add_award_shortlist_and_round_fields/migration.sql new file mode 100644 index 0000000..05b6f3f --- /dev/null +++ b/prisma/migrations/20260217200000_add_award_shortlist_and_round_fields/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4c957e5..c313cda 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -378,8 +378,9 @@ model User { filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy") // Award overrides - awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy") - awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy") + awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy") + awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer") + awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy") // In-app notifications notifications InAppNotification[] @relation("UserNotifications") @@ -1507,6 +1508,7 @@ model SpecialAward { juryGroupId String? eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN) decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION" + shortlistSize Int @default(10) // Eligibility job tracking eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED @@ -1530,6 +1532,7 @@ model SpecialAward { competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull) evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull) awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) + rounds Round[] @relation("AwardRounds") @@index([programId]) @@index([status]) @@ -1545,11 +1548,17 @@ model AwardEligibility { method EligibilityMethod @default(AUTO) eligible Boolean @default(false) aiReasoningJson Json? @db.JsonB + qualityScore Float? + shortlisted Boolean @default(false) // Admin override overriddenBy String? overriddenAt DateTime? + // Shortlist confirmation + confirmedAt DateTime? + confirmedBy String? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1557,6 +1566,7 @@ model AwardEligibility { award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull) + confirmer User? @relation("AwardEligibilityConfirmer", fields: [confirmedBy], references: [id], onDelete: SetNull) @@unique([awardId, projectId]) @@index([awardId]) @@ -2118,12 +2128,14 @@ model Round { // Links to other entities juryGroupId String? submissionWindowId String? + specialAwardId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations 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) submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull) projectRoundStates ProjectRoundState[] @@ -2157,6 +2169,7 @@ model Round { @@index([competitionId]) @@index([roundType]) @@index([status]) + @@index([specialAwardId]) } model ProjectRoundState { diff --git a/src/components/admin/round/award-shortlist.tsx b/src/components/admin/round/award-shortlist.tsx new file mode 100644 index 0000000..6fd1d25 --- /dev/null +++ b/src/components/admin/round/award-shortlist.tsx @@ -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 ( + +
+ + + + + +
+ {/* Job controls */} +
+
+ Evaluate PASSED projects against this award's criteria +
+ +
+ + {/* Progress bar */} + {isRunning && currentJobTotal && currentJobTotal > 0 && ( +
+ +

+ {currentJobDone ?? 0} / {currentJobTotal} projects +

+
+ )} + + {/* Shortlist table */} + {expanded && currentJobStatus === 'COMPLETED' && ( + <> + {isLoadingShortlist ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : shortlist && shortlist.eligibilities.length > 0 ? ( +
+
+

+ {shortlist.total} eligible projects + {shortlistedCount > 0 && ( + + ({shortlistedCount} shortlisted) + + )} +

+ {shortlistedCount > 0 && ( + + + + + + + Confirm Shortlist + + {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.` + } + + + + Cancel + confirmMutation.mutate({ awardId })} + disabled={confirmMutation.isPending} + > + {confirmMutation.isPending ? 'Confirming...' : 'Confirm'} + + + + + )} +
+ +
+ + + + + + + + + + + + {shortlist.eligibilities.map((e, i) => { + const reasoning = (e.aiReasoningJson as Record)?.reasoning as string | undefined + return ( + + + + + + + + ) + })} + +
#ProjectScoreReasoningShortlist
+ {i + 1} + +
+

{e.project.title}

+

+ {e.project.teamName || e.project.country || e.project.competitionCategory || '—'} +

+
+
+
+ + + {e.qualityScore ?? 0} + +
+
+ {reasoning ? ( +

+ {reasoning} +

+ ) : ( + + )} +
+ + toggleMutation.mutate({ awardId, projectId: e.project.id }) + } + disabled={toggleMutation.isPending} + /> +
+
+
+ ) : ( +

+ No eligible projects found +

+ )} + + )} + + {/* Not yet evaluated */} + {expanded && !currentJobStatus && ( +

+ Click "Run Eligibility" to evaluate projects against this award's criteria +

+ )} + + {/* Failed */} + {currentJobStatus === 'FAILED' && ( +

+ Eligibility evaluation failed. Try again. +

+ )} +
+
+
+
+ ) +} diff --git a/src/components/admin/round/filtering-dashboard.tsx b/src/components/admin/round/filtering-dashboard.tsx index 067f31c..e3b60aa 100644 --- a/src/components/admin/round/filtering-dashboard.tsx +++ b/src/components/admin/round/filtering-dashboard.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -45,11 +45,11 @@ import { CollapsibleTrigger, } from '@/components/ui/collapsible' import { + Award, Play, Loader2, CheckCircle2, XCircle, - AlertTriangle, RefreshCw, ChevronLeft, ChevronRight, @@ -68,10 +68,11 @@ import { FileText, Brain, ListFilter, - GripVertical, + Settings2, } from 'lucide-react' import Link from 'next/link' import type { Route } from 'next' +import { AwardShortlist } from './award-shortlist' type FilteringDashboardProps = { competitionId: string @@ -103,17 +104,29 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar const [expandedId, setExpandedId] = useState(null) 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() // -- Queries -- - const { data: stats, isLoading: statsLoading } = trpc.filtering.getResultStats.useQuery( + const { data: latestJob } = trpc.filtering.getLatestJob.useQuery( { 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 }, - { refetchInterval: 15_000 }, + { refetchInterval: isRunning ? 3_000 : 15_000 }, ) const { data: rules } = trpc.filtering.getRules.useQuery( @@ -128,7 +141,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar page, perPage: 25, }, - { refetchInterval: 15_000 }, + { refetchInterval: isRunning ? 3_000 : 15_000 }, ) 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 + 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 useEffect(() => { if (jobStatus && (jobStatus.status === 'COMPLETED' || jobStatus.status === 'FAILED')) { @@ -162,6 +188,16 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar }, [latestJob]) // -- 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({ onSuccess: (data) => { setPollingJobId(data.jobId) @@ -212,8 +248,59 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar }) // -- Handlers -- - const handleStartJob = () => { - startJobMutation.mutate({ roundId }) + 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 }) + } 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 = () => { @@ -275,14 +362,25 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar const parseAIData = (json: unknown): AIScreeningData | null => { if (!json || typeof json !== 'object') return null - return json as AIScreeningData + const obj = json as Record + // 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 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) const displayResults = resultsPage?.results.filter((r: any) => { @@ -296,35 +394,48 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar return (
- {/* Job Control */} + {/* Main Card: AI Screening Criteria + Controls */} -
+
- AI Filtering + + + AI Screening + - 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
-
+
+ {criteriaDirty && ( + + )} - {hasResults && ( + {hasResults && !isRunning && ( - @@ -353,16 +464,76 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
+ +