Special awards: Rounds tab UI, auto-filter threshold, remove auto-tag rules
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
- Add Rounds tab to award detail page with create/list/delete functionality - Add "Entry point" badge on first award round (confirmShortlist routes here) - Fix round detail back-link to navigate to parent award when specialAwardId set - Filter award rounds out of competition round list - Add specialAwardId to competition getById round select - Warn on confirmShortlist when no award rounds exist (SEPARATE_POOL mode) - Remove auto-tag rules from award config, edit page, router, and AI service - Fix competitionId not passed when creating awards from competition context - Add AUTO_FILTER quality threshold to AI filtering dashboard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,15 +25,7 @@ import {
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, Save, Loader2, Plus, X, Info } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
type AutoTagRule = {
|
||||
id: string
|
||||
field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue'
|
||||
operator: 'equals' | 'contains' | 'in'
|
||||
value: string
|
||||
}
|
||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function EditAwardPage({
|
||||
params,
|
||||
@@ -70,7 +62,6 @@ export default function EditAwardPage({
|
||||
const [votingEndAt, setVotingEndAt] = useState('')
|
||||
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
||||
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
||||
const [autoTagRules, setAutoTagRules] = useState<AutoTagRule[]>([])
|
||||
|
||||
// Helper to format date for datetime-local input
|
||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||
@@ -93,14 +84,6 @@ export default function EditAwardPage({
|
||||
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
||||
setEvaluationRoundId(award.evaluationRoundId || '')
|
||||
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
|
||||
|
||||
// Parse autoTagRulesJson
|
||||
if (award.autoTagRulesJson && typeof award.autoTagRulesJson === 'object') {
|
||||
const rules = award.autoTagRulesJson as { rules?: AutoTagRule[] }
|
||||
setAutoTagRules(rules.rules || [])
|
||||
} else {
|
||||
setAutoTagRules([])
|
||||
}
|
||||
}
|
||||
}, [award])
|
||||
|
||||
@@ -119,7 +102,6 @@ export default function EditAwardPage({
|
||||
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
||||
evaluationRoundId: evaluationRoundId || undefined,
|
||||
eligibilityMode,
|
||||
autoTagRulesJson: autoTagRules.length > 0 ? { rules: autoTagRules } : undefined,
|
||||
})
|
||||
toast.success('Award updated')
|
||||
router.push(`/admin/awards/${awardId}`)
|
||||
@@ -130,28 +112,6 @@ export default function EditAwardPage({
|
||||
}
|
||||
}
|
||||
|
||||
const addRule = () => {
|
||||
setAutoTagRules([
|
||||
...autoTagRules,
|
||||
{
|
||||
id: `rule-${Date.now()}`,
|
||||
field: 'competitionCategory',
|
||||
operator: 'equals',
|
||||
value: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const removeRule = (id: string) => {
|
||||
setAutoTagRules(autoTagRules.filter((r) => r.id !== id))
|
||||
}
|
||||
|
||||
const updateRule = (id: string, updates: Partial<AutoTagRule>) => {
|
||||
setAutoTagRules(
|
||||
autoTagRules.map((r) => (r.id === id ? { ...r, ...updates } : r))
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -348,135 +308,6 @@ export default function EditAwardPage({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Auto-Tag Rules */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Auto-Tag Rules</CardTitle>
|
||||
<CardDescription>
|
||||
Deterministic eligibility rules based on project metadata
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={addRule}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{autoTagRules.length === 0 ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p>
|
||||
No rules defined. Add rules to automatically filter projects based on category, location, tags, or ocean issues.
|
||||
Rules work together with the source round setting.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{autoTagRules.map((rule, index) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex-1 grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Field</Label>
|
||||
<Select
|
||||
value={rule.field}
|
||||
onValueChange={(v) =>
|
||||
updateRule(rule.id, {
|
||||
field: v as AutoTagRule['field'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="competitionCategory">
|
||||
Competition Category
|
||||
</SelectItem>
|
||||
<SelectItem value="country">Country</SelectItem>
|
||||
<SelectItem value="geographicZone">
|
||||
Geographic Zone
|
||||
</SelectItem>
|
||||
<SelectItem value="tags">Tags</SelectItem>
|
||||
<SelectItem value="oceanIssue">Ocean Issue</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Operator</Label>
|
||||
<Select
|
||||
value={rule.operator}
|
||||
onValueChange={(v) =>
|
||||
updateRule(rule.id, {
|
||||
operator: v as AutoTagRule['operator'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="equals">Equals</SelectItem>
|
||||
<SelectItem value="contains">Contains</SelectItem>
|
||||
<SelectItem value="in">In (comma-separated)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Value</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={rule.value}
|
||||
onChange={(e) =>
|
||||
updateRule(rule.id, { value: e.target.value })
|
||||
}
|
||||
placeholder={
|
||||
rule.operator === 'in'
|
||||
? 'value1,value2,value3'
|
||||
: 'Enter value...'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => removeRule(rule.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{autoTagRules.length > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-muted p-3 text-xs text-muted-foreground">
|
||||
<Info className="h-3 w-3 mt-0.5 shrink-0" />
|
||||
<p>
|
||||
<strong>How it works:</strong> Filter from{' '}
|
||||
<Badge variant="outline" className="mx-1">
|
||||
{evaluationRoundId
|
||||
? competition?.rounds?.find((r) => r.id === evaluationRoundId)
|
||||
?.name || 'Selected Round'
|
||||
: 'All Projects'}
|
||||
</Badge>
|
||||
, where ALL rules match (AND logic). Projects matching these deterministic rules will be marked eligible.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Voting Window Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -89,6 +89,8 @@ import {
|
||||
Vote,
|
||||
ChevronDown,
|
||||
AlertCircle,
|
||||
Layers,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
@@ -151,6 +153,8 @@ export default function AwardDetailPage({
|
||||
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||
const [activeTab, setActiveTab] = useState('eligibility')
|
||||
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
||||
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
|
||||
|
||||
// Pagination for eligibility list
|
||||
const [eligibilityPage, setEligibilityPage] = useState(1)
|
||||
@@ -175,6 +179,10 @@ export default function AwardDetailPage({
|
||||
trpc.specialAward.getVoteResults.useQuery({ awardId }, {
|
||||
enabled: activeTab === 'results',
|
||||
})
|
||||
const { data: awardRounds, refetch: refetchRounds } =
|
||||
trpc.specialAward.listRounds.useQuery({ awardId }, {
|
||||
enabled: activeTab === 'rounds',
|
||||
})
|
||||
|
||||
// Deferred queries - only load when needed
|
||||
const { data: allUsers } = trpc.user.list.useQuery(
|
||||
@@ -258,6 +266,22 @@ export default function AwardDetailPage({
|
||||
const deleteAward = trpc.specialAward.delete.useMutation({
|
||||
onSuccess: () => utils.specialAward.list.invalidate(),
|
||||
})
|
||||
const createRound = trpc.specialAward.createRound.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchRounds()
|
||||
setAddRoundOpen(false)
|
||||
setRoundForm({ name: '', roundType: 'EVALUATION' })
|
||||
toast.success('Round created')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
const deleteRound = trpc.specialAward.deleteRound.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchRounds()
|
||||
toast.success('Round deleted')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleStatusChange = async (
|
||||
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
||||
@@ -619,6 +643,10 @@ export default function AwardDetailPage({
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Jurors ({award._count.jurors})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rounds">
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
Rounds {awardRounds ? `(${awardRounds.length})` : ''}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="results">
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
Results
|
||||
@@ -1083,6 +1111,199 @@ export default function AwardDetailPage({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Rounds Tab */}
|
||||
<TabsContent value="rounds" className="space-y-4">
|
||||
{award.eligibilityMode !== 'SEPARATE_POOL' && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
|
||||
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p className="text-sm">
|
||||
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!award.competitionId && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p className="text-sm">
|
||||
Link this award to a competition first before creating rounds.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold">Award Rounds ({awardRounds?.length ?? 0})</h2>
|
||||
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" disabled={!award.competitionId}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Round
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Award Round</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new round to the "{award.name}" award evaluation track.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="round-name">Round Name</Label>
|
||||
<Input
|
||||
id="round-name"
|
||||
placeholder="e.g. Award Evaluation"
|
||||
value={roundForm.name}
|
||||
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="round-type">Round Type</Label>
|
||||
<Select
|
||||
value={roundForm.roundType}
|
||||
onValueChange={(v) => setRoundForm({ ...roundForm, roundType: v })}
|
||||
>
|
||||
<SelectTrigger id="round-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EVALUATION">Evaluation</SelectItem>
|
||||
<SelectItem value="FILTERING">Filtering</SelectItem>
|
||||
<SelectItem value="SUBMISSION">Submission</SelectItem>
|
||||
<SelectItem value="MENTORING">Mentoring</SelectItem>
|
||||
<SelectItem value="LIVE_FINAL">Live Final</SelectItem>
|
||||
<SelectItem value="DELIBERATION">Deliberation</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => createRound.mutate({
|
||||
awardId,
|
||||
name: roundForm.name.trim(),
|
||||
roundType: roundForm.roundType as any,
|
||||
})}
|
||||
disabled={!roundForm.name.trim() || createRound.isPending}
|
||||
>
|
||||
{createRound.isPending ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Creating...</>
|
||||
) : 'Create Round'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{!awardRounds ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-32 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : awardRounds.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No rounds yet. Create your first award round to build an evaluation track.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{awardRounds.map((round: any, index: number) => {
|
||||
const projectCount = round._count?.projectRoundStates ?? 0
|
||||
const assignmentCount = round._count?.assignments ?? 0
|
||||
const statusLabel = round.status.replace('ROUND_', '')
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-600',
|
||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||
CLOSED: 'bg-blue-100 text-blue-700',
|
||||
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
const roundTypeColors: Record<string, string> = {
|
||||
EVALUATION: 'bg-violet-100 text-violet-700',
|
||||
FILTERING: 'bg-amber-100 text-amber-700',
|
||||
SUBMISSION: 'bg-blue-100 text-blue-700',
|
||||
MENTORING: 'bg-teal-100 text-teal-700',
|
||||
LIVE_FINAL: 'bg-rose-100 text-rose-700',
|
||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||
}
|
||||
return (
|
||||
<Card key={round.id} className="hover:shadow-md transition-shadow h-full">
|
||||
<CardContent className="pt-4 pb-3 space-y-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
|
||||
{round.name}
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
<Badge variant="secondary" className={`text-[10px] ${roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={`text-[10px] ${statusColors[statusLabel]}`}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
{index === 0 && (
|
||||
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
|
||||
Entry point
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
{assignmentCount > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<ListChecks className="h-3.5 w-3.5" />
|
||||
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{round.status === 'ROUND_DRAFT' && (
|
||||
<div className="flex justify-end pt-1">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete "{round.name}". This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteRound.mutate({ roundId: round.id })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Results Tab */}
|
||||
<TabsContent value="results" className="space-y-4">
|
||||
{voteResults && voteResults.results.length > 0 ? (() => {
|
||||
|
||||
@@ -60,6 +60,7 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
|
||||
|
||||
createMutation.mutate({
|
||||
programId: competition.programId,
|
||||
competitionId: params.competitionId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
scoringMode: formData.scoringMode,
|
||||
|
||||
@@ -338,14 +338,14 @@ export default function CompetitionDetailPage() {
|
||||
{/* Rounds Tab */}
|
||||
<TabsContent value="rounds" className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.length})</h2>
|
||||
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.filter((r: any) => !r.specialAwardId).length})</h2>
|
||||
<Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={() => setAddRoundOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Round
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{competition.rounds.length === 0 ? (
|
||||
{competition.rounds.filter((r: any) => !r.specialAwardId).length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No rounds configured. Add rounds to define the competition flow.
|
||||
@@ -353,7 +353,7 @@ export default function CompetitionDetailPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{competition.rounds.map((round: any, index: number) => {
|
||||
{competition.rounds.filter((r: any) => !r.specialAwardId).map((round: any, index: number) => {
|
||||
const projectCount = round._count?.projectRoundStates ?? 0
|
||||
const assignmentCount = round._count?.assignments ?? 0
|
||||
const statusLabel = round.status.replace('ROUND_', '')
|
||||
|
||||
@@ -548,8 +548,8 @@ export default function RoundDetailPage() {
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Link href={'/admin/rounds' as Route} className="mt-0.5 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white/80 hover:text-white hover:bg-white/10" aria-label="Back to rounds">
|
||||
<Link href={(round.specialAwardId ? `/admin/awards/${round.specialAwardId}` : '/admin/rounds') as Route} className="mt-0.5 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white/80 hover:text-white hover:bg-white/10" aria-label={round.specialAwardId ? 'Back to Award' : 'Back to rounds'}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -1950,8 +1950,6 @@ export default function RoundDetailPage() {
|
||||
<div className="space-y-3">
|
||||
{roundAwards.map((award) => {
|
||||
const eligibleCount = award._count?.eligibilities || 0
|
||||
const autoTagRules = award.autoTagRulesJson as { rules?: unknown[] } | null
|
||||
const ruleCount = autoTagRules?.rules?.length || 0
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -1983,10 +1981,6 @@ export default function RoundDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
|
||||
<div className="text-right">
|
||||
<div className="font-medium text-foreground">{ruleCount}</div>
|
||||
<div className="text-xs">{ruleCount === 1 ? 'rule' : 'rules'}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium text-foreground">{eligibleCount}</div>
|
||||
<div className="text-xs">eligible</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Play,
|
||||
Star,
|
||||
Trophy,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
|
||||
type AwardShortlistProps = {
|
||||
@@ -92,6 +93,12 @@ export function AwardShortlist({
|
||||
onError: (err) => toast.error(`Failed: ${err.message}`),
|
||||
})
|
||||
|
||||
const { data: awardRounds } = trpc.specialAward.listRounds.useQuery(
|
||||
{ awardId },
|
||||
{ enabled: expanded && eligibilityMode === 'SEPARATE_POOL' }
|
||||
)
|
||||
const hasAwardRounds = (awardRounds?.length ?? 0) > 0
|
||||
|
||||
const confirmMutation = trpc.specialAward.confirmShortlist.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.specialAward.listShortlist.invalidate({ awardId })
|
||||
@@ -210,11 +217,23 @@ export function AwardShortlist({
|
||||
<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 asChild>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
{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.`
|
||||
}
|
||||
</p>
|
||||
{eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p className="text-sm">
|
||||
No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -107,9 +107,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
|
||||
// AI criteria state
|
||||
const [criteriaText, setCriteriaText] = useState('')
|
||||
const [aiAction, setAiAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('FLAG')
|
||||
const [aiAction, setAiAction] = useState<'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER'>('FLAG')
|
||||
const [batchSize, setBatchSize] = useState(20)
|
||||
const [parallelBatches, setParallelBatches] = useState(3)
|
||||
const [autoFilterThreshold, setAutoFilterThreshold] = useState(4)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [criteriaDirty, setCriteriaDirty] = useState(false)
|
||||
const criteriaLoaded = useRef(false)
|
||||
@@ -168,9 +169,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
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')
|
||||
setAiAction((config.action as 'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER') || 'FLAG')
|
||||
setBatchSize((config.batchSize as number) || 20)
|
||||
setParallelBatches((config.parallelBatches as number) || 3)
|
||||
setAutoFilterThreshold((config.autoFilterThreshold as number) || 4)
|
||||
criteriaLoaded.current = true
|
||||
}
|
||||
}, [aiRule])
|
||||
@@ -269,6 +271,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
action: aiAction,
|
||||
batchSize,
|
||||
parallelBatches,
|
||||
...(aiAction === 'AUTO_FILTER' && { autoFilterThreshold }),
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -296,6 +299,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
action: aiAction,
|
||||
batchSize,
|
||||
parallelBatches,
|
||||
...(aiAction === 'AUTO_FILTER' && { autoFilterThreshold }),
|
||||
}
|
||||
|
||||
if (aiRule) {
|
||||
@@ -519,42 +523,87 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
{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 className="space-y-3 rounded-lg border p-3 bg-muted/20">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<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' | 'AUTO_FILTER'); setCriteriaDirty(true) }}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AUTO_FILTER">Smart auto-filter</SelectItem>
|
||||
<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>
|
||||
|
||||
{aiAction === 'AUTO_FILTER' && (
|
||||
<div className="rounded-md border border-purple-200 bg-purple-50/50 p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">Quality threshold</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Projects scoring at or below this are auto-rejected as spam/junk
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={9}
|
||||
className="h-8 w-16 text-xs text-center"
|
||||
value={autoFilterThreshold}
|
||||
onChange={(e) => {
|
||||
const v = Math.min(9, Math.max(1, parseInt(e.target.value) || 4))
|
||||
setAutoFilterThreshold(v)
|
||||
setCriteriaDirty(true)
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">/10</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5 text-xs">
|
||||
<div className="rounded bg-red-100 border border-red-200 px-2 py-1.5 text-center">
|
||||
<span className="font-medium text-red-800">Auto-reject</span>
|
||||
<p className="text-red-600 mt-0.5">Score 1-{autoFilterThreshold} or spam</p>
|
||||
</div>
|
||||
<div className="rounded bg-amber-100 border border-amber-200 px-2 py-1.5 text-center">
|
||||
<span className="font-medium text-amber-800">Flag for review</span>
|
||||
<p className="text-amber-600 mt-0.5">Score {autoFilterThreshold + 1}+ but fails criteria</p>
|
||||
</div>
|
||||
<div className="rounded bg-green-100 border border-green-200 px-2 py-1.5 text-center">
|
||||
<span className="font-medium text-green-800">Auto-pass</span>
|
||||
<p className="text-green-600 mt-0.5">Meets criteria</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -74,6 +74,7 @@ export const competitionRouter = router({
|
||||
sortOrder: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
specialAwardId: true,
|
||||
juryGroup: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
|
||||
@@ -93,7 +93,6 @@ export const specialAwardRouter = router({
|
||||
useAiEligibility: z.boolean().optional(),
|
||||
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
|
||||
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
||||
autoTagRulesJson: z.record(z.unknown()).optional(),
|
||||
competitionId: z.string().optional(),
|
||||
evaluationRoundId: z.string().optional(),
|
||||
juryGroupId: z.string().optional(),
|
||||
@@ -115,7 +114,6 @@ export const specialAwardRouter = router({
|
||||
useAiEligibility: input.useAiEligibility ?? true,
|
||||
scoringMode: input.scoringMode,
|
||||
maxRankedPicks: input.maxRankedPicks,
|
||||
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
|
||||
competitionId: input.competitionId,
|
||||
evaluationRoundId: input.evaluationRoundId,
|
||||
juryGroupId: input.juryGroupId,
|
||||
@@ -152,7 +150,6 @@ export const specialAwardRouter = router({
|
||||
useAiEligibility: z.boolean().optional(),
|
||||
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(),
|
||||
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
||||
autoTagRulesJson: z.record(z.unknown()).optional(),
|
||||
votingStartAt: z.date().optional(),
|
||||
votingEndAt: z.date().optional(),
|
||||
competitionId: z.string().nullable().optional(),
|
||||
@@ -162,13 +159,10 @@ export const specialAwardRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, autoTagRulesJson, ...rest } = input
|
||||
const { id, ...rest } = input
|
||||
const award = await ctx.prisma.specialAward.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(autoTagRulesJson !== undefined && { autoTagRulesJson: autoTagRulesJson as Prisma.InputJsonValue }),
|
||||
},
|
||||
data: rest,
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* AI-Powered Award Eligibility Service
|
||||
*
|
||||
* Determines project eligibility for special awards using:
|
||||
* - Deterministic field matching (tags, country, category)
|
||||
* - AI interpretation of plain-language criteria
|
||||
* Determines project eligibility for special awards using
|
||||
* AI interpretation of plain-language criteria.
|
||||
*
|
||||
* GDPR Compliance:
|
||||
* - All project data is anonymized before AI processing
|
||||
@@ -70,12 +69,6 @@ quality_score is a 0-100 integer measuring how well the project fits the award c
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AutoTagRule = {
|
||||
field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue'
|
||||
operator: 'equals' | 'contains' | 'in'
|
||||
value: string | string[]
|
||||
}
|
||||
|
||||
export interface EligibilityResult {
|
||||
projectId: string
|
||||
eligible: boolean
|
||||
@@ -106,66 +99,6 @@ interface ProjectForEligibility {
|
||||
files?: Array<{ fileType: string | null }>
|
||||
}
|
||||
|
||||
// ─── Auto Tag Rules ─────────────────────────────────────────────────────────
|
||||
|
||||
export function applyAutoTagRules(
|
||||
rules: AutoTagRule[],
|
||||
projects: ProjectForEligibility[]
|
||||
): Map<string, boolean> {
|
||||
const results = new Map<string, boolean>()
|
||||
|
||||
for (const project of projects) {
|
||||
const matches = rules.every((rule) => {
|
||||
const fieldValue = getFieldValue(project, rule.field)
|
||||
|
||||
switch (rule.operator) {
|
||||
case 'equals':
|
||||
return String(fieldValue).toLowerCase() === String(rule.value).toLowerCase()
|
||||
case 'contains':
|
||||
if (Array.isArray(fieldValue)) {
|
||||
return fieldValue.some((v) =>
|
||||
String(v).toLowerCase().includes(String(rule.value).toLowerCase())
|
||||
)
|
||||
}
|
||||
return String(fieldValue || '').toLowerCase().includes(String(rule.value).toLowerCase())
|
||||
case 'in':
|
||||
if (Array.isArray(rule.value)) {
|
||||
return rule.value.some((v) =>
|
||||
String(v).toLowerCase() === String(fieldValue).toLowerCase()
|
||||
)
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
results.set(project.id, matches)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function getFieldValue(
|
||||
project: ProjectForEligibility,
|
||||
field: AutoTagRule['field']
|
||||
): unknown {
|
||||
switch (field) {
|
||||
case 'competitionCategory':
|
||||
return project.competitionCategory
|
||||
case 'country':
|
||||
return project.country
|
||||
case 'geographicZone':
|
||||
return project.geographicZone
|
||||
case 'tags':
|
||||
return project.tags
|
||||
case 'oceanIssue':
|
||||
return project.oceanIssue
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AI Criteria Interpretation ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,10 +74,12 @@ export type DocumentCheckConfig = {
|
||||
|
||||
export type AIScreeningConfig = {
|
||||
criteriaText: string
|
||||
action: 'PASS' | 'REJECT' | 'FLAG' // REJECT = auto-filter-out, FLAG = flag for human review
|
||||
action: 'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER' // AUTO_FILTER = reject spam/low-quality, flag borderline
|
||||
// Performance settings
|
||||
batchSize?: number // Projects per API call (1-50, default 20)
|
||||
parallelBatches?: number // Concurrent API calls (1-10, default 1)
|
||||
// AUTO_FILTER settings
|
||||
autoFilterThreshold?: number // Quality score cutoff (1-10, default 4). Scores at or below are auto-rejected.
|
||||
}
|
||||
|
||||
export type RuleConfig = FieldRuleConfig | DocumentCheckConfig | AIScreeningConfig
|
||||
@@ -794,8 +796,26 @@ export async function executeFilteringRules(
|
||||
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'
|
||||
let passed: boolean
|
||||
let aiAction: string
|
||||
if (config.action === 'AUTO_FILTER') {
|
||||
const threshold = config.autoFilterThreshold ?? 4
|
||||
if (aiResult.spamRisk || aiResult.qualityScore <= threshold) {
|
||||
// Clear spam/junk — auto-reject
|
||||
passed = false
|
||||
aiAction = 'REJECT'
|
||||
} else if (!aiResult.meetsCriteria) {
|
||||
// Borderline — flag for human review
|
||||
passed = false
|
||||
aiAction = 'FLAG'
|
||||
} else {
|
||||
passed = true
|
||||
aiAction = 'FLAG'
|
||||
}
|
||||
} else {
|
||||
passed = aiResult.meetsCriteria && !aiResult.spamRisk
|
||||
aiAction = config.action || 'FLAG'
|
||||
}
|
||||
batchResults.push(
|
||||
computeProjectResult(
|
||||
projectId,
|
||||
@@ -827,9 +847,25 @@ export async function executeFilteringRules(
|
||||
for (const aiRule of aiRules) {
|
||||
const screening = aiResults.get(aiRule.id)?.get(project.id)
|
||||
if (screening) {
|
||||
const passed = screening.meetsCriteria && !screening.spamRisk
|
||||
const aiConfig = aiRule.configJson as unknown as AIScreeningConfig
|
||||
const aiAction = aiConfig?.action || 'FLAG'
|
||||
let passed: boolean
|
||||
let aiAction: string
|
||||
if (aiConfig?.action === 'AUTO_FILTER') {
|
||||
const threshold = aiConfig.autoFilterThreshold ?? 4
|
||||
if (screening.spamRisk || screening.qualityScore <= threshold) {
|
||||
passed = false
|
||||
aiAction = 'REJECT'
|
||||
} else if (!screening.meetsCriteria) {
|
||||
passed = false
|
||||
aiAction = 'FLAG'
|
||||
} else {
|
||||
passed = true
|
||||
aiAction = 'FLAG'
|
||||
}
|
||||
} else {
|
||||
passed = screening.meetsCriteria && !screening.spamRisk
|
||||
aiAction = aiConfig?.action || 'FLAG'
|
||||
}
|
||||
aiRuleResults.push({ ruleId: aiRule.id, ruleName: aiRule.name, passed, action: aiAction, reasoning: screening.reasoning })
|
||||
aiScreeningData[aiRule.id] = screening
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
applyAutoTagRules,
|
||||
aiInterpretCriteria,
|
||||
type AutoTagRule,
|
||||
} from './ai-award-eligibility'
|
||||
import { aiInterpretCriteria } from './ai-award-eligibility'
|
||||
|
||||
const BATCH_SIZE = 20
|
||||
|
||||
@@ -118,14 +114,7 @@ export async function processEligibilityJob(
|
||||
},
|
||||
})
|
||||
|
||||
// Phase 1: Auto-tag rules (deterministic, fast)
|
||||
const autoTagRules = award.autoTagRulesJson as unknown as AutoTagRule[] | null
|
||||
let autoResults: Map<string, boolean> | undefined
|
||||
if (autoTagRules && Array.isArray(autoTagRules) && autoTagRules.length > 0) {
|
||||
autoResults = applyAutoTagRules(autoTagRules, projects)
|
||||
}
|
||||
|
||||
// Phase 2: AI interpretation (if criteria text exists AND AI eligibility is enabled)
|
||||
// AI interpretation (if criteria text exists AND AI eligibility is enabled)
|
||||
// Process in batches to avoid timeouts
|
||||
let aiResults: Map<string, { eligible: boolean; confidence: number; qualityScore: number; reasoning: string }> | undefined
|
||||
|
||||
@@ -161,14 +150,11 @@ export async function processEligibilityJob(
|
||||
})
|
||||
}
|
||||
|
||||
// Combine results: auto-tag AND AI must agree (or just one if only one configured)
|
||||
// Combine results
|
||||
const eligibilities = projects.map((project) => {
|
||||
const autoEligible = autoResults?.get(project.id) ?? true
|
||||
const aiEval = aiResults?.get(project.id)
|
||||
const aiEligible = aiEval?.eligible ?? true
|
||||
|
||||
const eligible = autoEligible && aiEligible
|
||||
const method = autoResults && aiResults ? 'AUTO' : autoResults ? 'AUTO' : 'MANUAL'
|
||||
const eligible = aiEval?.eligible ?? true
|
||||
const method = aiResults ? 'AUTO' : 'MANUAL'
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
|
||||
@@ -317,22 +317,6 @@ export const AwardConfigSchema = z.object({
|
||||
useAiEligibility: z.boolean().default(true),
|
||||
/** Plain-language criteria for AI interpretation */
|
||||
criteriaText: z.string().optional(),
|
||||
/** Structured auto-tag rules for deterministic eligibility */
|
||||
autoTagRules: z
|
||||
.array(
|
||||
z.object({
|
||||
field: z.enum([
|
||||
'competitionCategory',
|
||||
'country',
|
||||
'geographicZone',
|
||||
'tags',
|
||||
'oceanIssue',
|
||||
]),
|
||||
operator: z.enum(['equals', 'contains', 'in']),
|
||||
value: z.union([z.string(), z.array(z.string())]),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
|
||||
export type AwardConfig = z.infer<typeof AwardConfigSchema>
|
||||
|
||||
Reference in New Issue
Block a user