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

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

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

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

View File

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

View File

@@ -1,6 +1,6 @@
'use client'
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<string | null>(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<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
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<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 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 (
<div className="space-y-6">
{/* Job Control */}
{/* Main Card: AI Screening Criteria + Controls */}
<Card>
<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>
<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>
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>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
{criteriaDirty && (
<Button
variant="outline"
size="sm"
onClick={handleSaveCriteria}
disabled={isSaving}
>
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Save
</Button>
)}
<Button
onClick={handleStartJob}
disabled={isRunning || startJobMutation.isPending || !hasRules}
size="sm"
onClick={handleSaveAndRun}
disabled={isRunning || startJobMutation.isPending || !criteriaText.trim()}
>
{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>
{hasResults && (
{hasResults && !isRunning && (
<AlertDialog>
<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" />
Finalize Results
Finalize
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
@@ -353,16 +464,76 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder={`Write all your screening criteria here. The AI will evaluate each project against these requirements.\n\nExample:\n1. Project must have clear ocean conservation impact\n2. Documents should be in English or French\n3. For Business Concepts, academic rigor is acceptable\n4. For African projects, apply a lower quality threshold (score >= 5/10)\n5. Flag any submissions that appear to be AI-generated filler`}
value={criteriaText}
onChange={(e) => { setCriteriaText(e.target.value); setCriteriaDirty(true) }}
rows={8}
className="text-sm font-mono"
/>
<p className="text-xs text-muted-foreground">
Available data per project: category, country, region, founded year, ocean issue, tags,
description, file details (type, pages, size, detected language), and team size.
</p>
{/* Job Progress */}
{isRunning && activeJob && (
<CardContent className="pt-0">
<div className="space-y-2">
{/* 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 */}
{isRunning && activeJob && (
<div className="space-y-2 pt-3 border-t">
<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 || '?'}
</span>
<span className="font-mono">
<span className="font-mono text-sm">
{activeJob.processedCount}/{activeJob.totalProjects}
</span>
</div>
@@ -372,43 +543,34 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
: 0
}
/>
<p className="text-xs text-muted-foreground">
Results appear in the table below as each batch completes
</p>
</div>
</CardContent>
)}
)}
{/* Last job summary */}
{!isRunning && latestJob && latestJob.status === 'COMPLETED' && (
<CardContent className="pt-0">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{/* Last job summary */}
{!isRunning && latestJob && latestJob.status === 'COMPLETED' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground pt-3 border-t">
<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">
({new Date(latestJob.completedAt!).toLocaleDateString()})
</span>
</div>
</CardContent>
)}
)}
{!isRunning && latestJob && latestJob.status === 'FAILED' && (
<CardContent className="pt-0">
<div className="flex items-center gap-2 text-sm text-red-600">
{!isRunning && latestJob && latestJob.status === 'FAILED' && (
<div className="flex items-center gap-2 text-sm text-red-600 pt-3 border-t">
<XCircle className="h-4 w-4" />
Last run failed: {latestJob.errorMessage || 'Unknown error'}
</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>
{/* Filtering Rules */}
<FilteringRulesSection roundId={roundId} />
{/* Additional Rules (Field-Based & Document Checks) */}
<AdditionalRulesSection roundId={roundId} hasNonAiRules={hasNonAiRules} />
{/* Stats Cards */}
{statsLoading ? (
@@ -477,7 +639,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
) : null}
{/* Results Table */}
{(hasResults || resultsLoading) && (
{(hasResults || resultsLoading || isRunning) && (
<Card>
<CardHeader>
<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>
<CardDescription>
Review AI screening outcomes &mdash; click a row to see reasoning, use quick buttons to override
{isRunning && (
<span className="ml-2 text-purple-600 font-medium animate-pulse">
New results streaming in...
</span>
)}
</CardDescription>
</div>
<div className="flex items-center gap-2">
@@ -573,7 +740,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
const isExpanded = expandedId === result.id
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 */}
<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"
@@ -816,10 +983,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
<div className="flex flex-col items-center justify-center py-12 text-center">
<Sparkles className="h-8 w-8 text-muted-foreground mb-3" />
<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 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>
</div>
)}
@@ -827,6 +994,9 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</Card>
)}
{/* Special Award Tracks */}
{hasResults && <AwardTracksSection competitionId={competitionId} roundId={roundId} />}
{/* Single Override Dialog (with reason) */}
<Dialog open={overrideDialogOpen} onOpenChange={setOverrideDialogOpen}>
<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 }> = {
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)' },
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 = [
@@ -997,11 +1166,6 @@ type RuleFormData = {
maxPages: number | ''
maxPagesByFileType: Record<string, number>
docAction: 'PASS' | 'REJECT' | 'FLAG'
// AI_SCREENING
criteriaText: string
aiAction: 'PASS' | 'REJECT' | 'FLAG'
batchSize: number
parallelBatches: number
}
const DEFAULT_FORM: RuleFormData = {
@@ -1016,10 +1180,6 @@ const DEFAULT_FORM: RuleFormData = {
maxPages: '',
maxPagesByFileType: {},
docAction: 'REJECT',
criteriaText: '',
aiAction: 'FLAG',
batchSize: 20,
parallelBatches: 1,
}
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
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>) || {},
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:
return base
}
}
function FilteringRulesSection({ roundId }: { roundId: string }) {
const [isOpen, setIsOpen] = useState(true)
function AdditionalRulesSection({ roundId, hasNonAiRules }: { roundId: string; hasNonAiRules: boolean }) {
const [isOpen, setIsOpen] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingRule, setEditingRule] = useState<string | null>(null)
const [form, setForm] = useState<RuleFormData>({ ...DEFAULT_FORM })
@@ -1097,11 +1242,14 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const { data: rules, isLoading } = trpc.filtering.getRules.useQuery(
const { data: allRules, isLoading } = trpc.filtering.getRules.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
// Only show non-AI rules
const rules = allRules?.filter((r: any) => r.ruleType !== 'AI_SCREENING') ?? []
const createMutation = trpc.filtering.createRule.useMutation({
onSuccess: () => {
utils.filtering.getRules.invalidate({ roundId })
@@ -1156,12 +1304,10 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
const openCreate = () => {
setEditingRule(null)
setForm({ ...DEFAULT_FORM, priority: (rules?.length ?? 0) })
setForm({ ...DEFAULT_FORM, priority: rules.length })
setDialogOpen(true)
}
const meta = RULE_TYPE_META[form.ruleType]
return (
<>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
@@ -1172,9 +1318,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
<div className="flex items-center gap-3">
<ListFilter className="h-5 w-5 text-[#053d57]" />
<div>
<CardTitle className="text-base">Filtering Rules</CardTitle>
<CardTitle className="text-base">Additional Rules</CardTitle>
<CardDescription>
{rules?.length ?? 0} active rule{(rules?.length ?? 0) !== 1 ? 's' : ''} &mdash; executed in priority order
{rules.length} field-based / document check rule{rules.length !== 1 ? 's' : ''} applied alongside AI screening
</CardDescription>
</div>
</div>
@@ -1199,7 +1345,7 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : rules && rules.length > 0 ? (
) : rules.length > 0 ? (
<div className="space-y-2">
{rules.map((rule: any, idx: number) => {
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"
>
<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>
</div>
@@ -1234,17 +1379,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
{config.minFileCount ? `Min ${config.minFileCount} files` : ''}
{config.requiredFileTypes ? ` \u00b7 Types: ${(config.requiredFileTypes as string[]).join(', ')}` : ''}
{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}
</>
)}
{rule.ruleType === 'AI_SCREENING' && (
<>
{((config.criteriaText as string) || '').substring(0, 80)}{((config.criteriaText as string) || '').length > 80 ? '...' : ''} &rarr; {config.action as string}
</>
)}
</p>
</div>
@@ -1277,15 +1414,14 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<ListFilter className="h-8 w-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium">No filtering rules configured</p>
<p className="text-xs text-muted-foreground mt-1 mb-3">
Add rules to define how projects are screened
<div className="flex flex-col items-center justify-center py-6 text-center">
<ListFilter className="h-6 w-6 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
No additional rules AI screening criteria handle everything
</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" />
Add First Rule
Add Rule
</Button>
</div>
)}
@@ -1301,9 +1437,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
}}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingRule ? 'Edit Rule' : 'Create Filtering Rule'}</DialogTitle>
<DialogTitle>{editingRule ? 'Edit Rule' : 'Create Rule'}</DialogTitle>
<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>
</DialogHeader>
@@ -1329,10 +1465,10 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
</div>
</div>
{/* Rule Type Selector */}
{/* Rule Type Selector (only Field-Based and Document Check) */}
<div>
<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]) => {
const Icon = m.icon
const selected = form.ruleType === type
@@ -1361,17 +1497,15 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
<div className="space-y-3 rounded-lg border p-4 bg-muted/20">
<div className="flex items-center justify-between">
<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' }))}>
<SelectTrigger className="w-20 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={form.logic} onValueChange={(v) => setForm((f) => ({ ...f, logic: v as 'AND' | 'OR' }))}>
<SelectTrigger className="w-20 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
</div>
{form.conditions.map((cond, i) => {
@@ -1586,62 +1720,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
</div>
</div>
)}
{form.ruleType === 'AI_SCREENING' && (
<div className="space-y-4 rounded-lg border p-4 bg-muted/20">
<div>
<Label className="text-xs text-muted-foreground mb-1.5 block">Screening Criteria</Label>
<Textarea
placeholder="Write the criteria the AI should evaluate against. Example:&#10;&#10;1. Ocean conservation impact must be clearly stated&#10;2. Documents must be in English&#10;3. For Business Concepts, academic rigor is acceptable&#10;4. For African projects, apply a lower quality threshold (score >= 5/10)"
value={form.criteriaText}
onChange={(e) => setForm((f) => ({ ...f, criteriaText: e.target.value }))}
rows={8}
className="text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
The AI has access to: category, country, region, founded year, ocean issue, tags, description, file details (type, page count, size, detected language), and team size.
</p>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Action</Label>
<Select value={form.aiAction} onValueChange={(v) => setForm((f) => ({ ...f, aiAction: v as any }))}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FLAG">Flag for review</SelectItem>
<SelectItem value="REJECT">Auto-reject</SelectItem>
<SelectItem value="PASS">Auto-pass</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Batch Size</Label>
<Input
type="number"
min={1}
max={50}
className="h-8 text-xs"
value={form.batchSize}
onChange={(e) => setForm((f) => ({ ...f, batchSize: parseInt(e.target.value) || 20 }))}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Parallel Batches</Label>
<Input
type="number"
min={1}
max={10}
className="h-8 text-xs"
value={form.parallelBatches}
onChange={(e) => setForm((f) => ({ ...f, parallelBatches: parseInt(e.target.value) || 1 }))}
/>
</div>
</div>
</div>
)}
</div>
<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>
)
}