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

- 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:
2026-02-17 19:53:20 +01:00
parent 4fa3ca0bb6
commit 1fe6667400
13 changed files with 389 additions and 340 deletions

View File

@@ -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>

View File

@@ -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 &quot;{award.name}&quot; 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 &quot;{round.name}&quot;. 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 ? (() => {

View File

@@ -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,

View File

@@ -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_', '')

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -74,6 +74,7 @@ export const competitionRouter = router({
sortOrder: true,
windowOpenAt: true,
windowCloseAt: true,
specialAwardId: true,
juryGroup: {
select: { id: true, name: true },
},

View File

@@ -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({

View File

@@ -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 ─────────────────────────────────────────────
/**

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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>