Add AI eligibility toggle and include-submitted filter for awards
Build and Push Docker Image / build (push) Has been cancelled Details

- Add useAiEligibility boolean to SpecialAward schema (default true)
- Toggle on creation form lets admins disable AI for feeling-based awards
- Detail page shows "Load All Projects" when AI is off vs "Run AI Eligibility"
- Include Submitted toggle lets admins include SUBMITTED-status projects
- Fix perPage: 200 → 100 to match user.list validation max
- Fix edition display on award detail page
- Add migration for new column

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-02 20:02:58 +01:00
parent e34cafebbf
commit 8931da98ba
5 changed files with 85 additions and 21 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SpecialAward" ADD COLUMN "useAiEligibility" BOOLEAN NOT NULL DEFAULT true;

View File

@ -1092,6 +1092,7 @@ model SpecialAward {
// Criteria // Criteria
criteriaText String? @db.Text // Plain-language criteria for AI criteriaText String? @db.Text // Plain-language criteria for AI
autoTagRulesJson Json? @db.JsonB // Deterministic eligibility rules autoTagRulesJson Json? @db.JsonB // Deterministic eligibility rules
useAiEligibility Boolean @default(true) // Whether AI evaluates eligibility
// Scoring // Scoring
scoringMode AwardScoringMode @default(PICK_WINNER) scoringMode AwardScoringMode @default(PICK_WINNER)

View File

@ -14,6 +14,7 @@ import {
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { import {
Table, Table,
TableBody, TableBody,
@ -76,7 +77,7 @@ export default function AwardDetailPage({
trpc.specialAward.listJurors.useQuery({ awardId }) trpc.specialAward.listJurors.useQuery({ awardId })
const { data: voteResults } = const { data: voteResults } =
trpc.specialAward.getVoteResults.useQuery({ awardId }) trpc.specialAward.getVoteResults.useQuery({ awardId })
const { data: allUsers } = trpc.user.list.useQuery({ page: 1, perPage: 200 }) const { data: allUsers } = trpc.user.list.useQuery({ page: 1, perPage: 100 })
const updateStatus = trpc.specialAward.updateStatus.useMutation() const updateStatus = trpc.specialAward.updateStatus.useMutation()
const runEligibility = trpc.specialAward.runEligibility.useMutation() const runEligibility = trpc.specialAward.runEligibility.useMutation()
@ -86,6 +87,7 @@ export default function AwardDetailPage({
const setWinner = trpc.specialAward.setWinner.useMutation() const setWinner = trpc.specialAward.setWinner.useMutation()
const [selectedJurorId, setSelectedJurorId] = useState('') const [selectedJurorId, setSelectedJurorId] = useState('')
const [includeSubmitted, setIncludeSubmitted] = useState(true)
const handleStatusChange = async ( const handleStatusChange = async (
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED' status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
@ -103,7 +105,7 @@ export default function AwardDetailPage({
const handleRunEligibility = async () => { const handleRunEligibility = async () => {
try { try {
const result = await runEligibility.mutateAsync({ awardId }) const result = await runEligibility.mutateAsync({ awardId, includeSubmitted })
toast.success( toast.success(
`Eligibility run: ${result.eligible} eligible, ${result.ineligible} ineligible` `Eligibility run: ${result.eligible} eligible, ${result.ineligible} ineligible`
) )
@ -201,7 +203,7 @@ export default function AwardDetailPage({
{award.status.replace('_', ' ')} {award.status.replace('_', ' ')}
</Badge> </Badge>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{award.program.name} {award.program.year} Edition
</span> </span>
</div> </div>
</div> </div>
@ -262,11 +264,23 @@ export default function AwardDetailPage({
{/* Eligibility Tab */} {/* Eligibility Tab */}
<TabsContent value="eligibility" className="space-y-4"> <TabsContent value="eligibility" className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{award.eligibleCount} of {award._count.eligibilities} projects {award.eligibleCount} of {award._count.eligibilities} projects
eligible eligible
</p> </p>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
id="include-submitted"
checked={includeSubmitted}
onCheckedChange={setIncludeSubmitted}
/>
<Label htmlFor="include-submitted" className="text-sm whitespace-nowrap">
Include submitted
</Label>
</div>
{award.useAiEligibility ? (
<Button <Button
onClick={handleRunEligibility} onClick={handleRunEligibility}
disabled={runEligibility.isPending} disabled={runEligibility.isPending}
@ -278,7 +292,27 @@ export default function AwardDetailPage({
)} )}
Run AI Eligibility Run AI Eligibility
</Button> </Button>
) : (
<Button
onClick={handleRunEligibility}
disabled={runEligibility.isPending}
variant="outline"
>
{runEligibility.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
Load All Projects
</Button>
)}
</div> </div>
</div>
{!award.useAiEligibility && (
<p className="text-sm text-muted-foreground italic">
AI eligibility is off for this award. Projects are loaded for manual selection.
</p>
)}
{eligibilityData && eligibilityData.eligibilities.length > 0 ? ( {eligibilityData && eligibilityData.eligibilities.length > 0 ? (
<Card> <Card>

View File

@ -22,6 +22,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ArrowLeft, Save, Loader2 } from 'lucide-react' import { ArrowLeft, Save, Loader2 } from 'lucide-react'
@ -33,6 +34,7 @@ export default function CreateAwardPage() {
const [scoringMode, setScoringMode] = useState< const [scoringMode, setScoringMode] = useState<
'PICK_WINNER' | 'RANKED' | 'SCORED' 'PICK_WINNER' | 'RANKED' | 'SCORED'
>('PICK_WINNER') >('PICK_WINNER')
const [useAiEligibility, setUseAiEligibility] = useState(true)
const [maxRankedPicks, setMaxRankedPicks] = useState('3') const [maxRankedPicks, setMaxRankedPicks] = useState('3')
const [programId, setProgramId] = useState('') const [programId, setProgramId] = useState('')
@ -47,6 +49,7 @@ export default function CreateAwardPage() {
name: name.trim(), name: name.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
criteriaText: criteriaText.trim() || undefined, criteriaText: criteriaText.trim() || undefined,
useAiEligibility,
scoringMode, scoringMode,
maxRankedPicks: maxRankedPicks:
scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined, scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
@ -140,6 +143,21 @@ export default function CreateAwardPage() {
</p> </p>
</div> </div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="ai-toggle">AI Eligibility</Label>
<p className="text-xs text-muted-foreground">
Use AI to automatically evaluate project eligibility based on the criteria above.
Turn off for awards decided by feeling or manual selection.
</p>
</div>
<Switch
id="ai-toggle"
checked={useAiEligibility}
onCheckedChange={setUseAiEligibility}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="scoring">Scoring Mode</Label> <Label htmlFor="scoring">Scoring Mode</Label>

View File

@ -60,7 +60,7 @@ export const specialAwardRouter = router({
select: { id: true, title: true, teamName: true }, select: { id: true, title: true, teamName: true },
}, },
program: { program: {
select: { id: true, name: true }, select: { id: true, name: true, year: true },
}, },
}, },
}) })
@ -85,6 +85,7 @@ export const specialAwardRouter = router({
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
criteriaText: z.string().optional(), criteriaText: z.string().optional(),
useAiEligibility: z.boolean().optional(),
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']), scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
maxRankedPicks: z.number().int().min(1).max(20).optional(), maxRankedPicks: z.number().int().min(1).max(20).optional(),
autoTagRulesJson: z.record(z.unknown()).optional(), autoTagRulesJson: z.record(z.unknown()).optional(),
@ -102,6 +103,7 @@ export const specialAwardRouter = router({
name: input.name, name: input.name,
description: input.description, description: input.description,
criteriaText: input.criteriaText, criteriaText: input.criteriaText,
useAiEligibility: input.useAiEligibility ?? true,
scoringMode: input.scoringMode, scoringMode: input.scoringMode,
maxRankedPicks: input.maxRankedPicks, maxRankedPicks: input.maxRankedPicks,
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined, autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
@ -130,6 +132,7 @@ export const specialAwardRouter = router({
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
description: z.string().optional(), description: z.string().optional(),
criteriaText: z.string().optional(), criteriaText: z.string().optional(),
useAiEligibility: z.boolean().optional(),
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(), scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(),
maxRankedPicks: z.number().int().min(1).max(20).optional(), maxRankedPicks: z.number().int().min(1).max(20).optional(),
autoTagRulesJson: z.record(z.unknown()).optional(), autoTagRulesJson: z.record(z.unknown()).optional(),
@ -220,18 +223,24 @@ export const specialAwardRouter = router({
* Run auto-tag + AI eligibility * Run auto-tag + AI eligibility
*/ */
runEligibility: adminProcedure runEligibility: adminProcedure
.input(z.object({ awardId: z.string() })) .input(z.object({
awardId: z.string(),
includeSubmitted: z.boolean().optional(),
}))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({ const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId }, where: { id: input.awardId },
include: { program: true }, include: { program: true },
}) })
// Get all projects in the program's rounds // Get projects in the program's rounds
const statusFilter = input.includeSubmitted
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: { where: {
round: { programId: award.programId }, round: { programId: award.programId },
status: { in: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] }, status: { in: [...statusFilter] },
}, },
select: { select: {
id: true, id: true,
@ -259,9 +268,9 @@ export const specialAwardRouter = router({
autoResults = applyAutoTagRules(autoTagRules, projects) autoResults = applyAutoTagRules(autoTagRules, projects)
} }
// Phase 2: AI interpretation (if criteria text exists) // Phase 2: AI interpretation (if criteria text exists AND AI eligibility is enabled)
let aiResults: Map<string, { eligible: boolean; confidence: number; reasoning: string }> | undefined let aiResults: Map<string, { eligible: boolean; confidence: number; reasoning: string }> | undefined
if (award.criteriaText) { if (award.criteriaText && award.useAiEligibility) {
const aiEvals = await aiInterpretCriteria(award.criteriaText, projects) const aiEvals = await aiInterpretCriteria(award.criteriaText, projects)
aiResults = new Map( aiResults = new Map(
aiEvals.map((e) => [ aiEvals.map((e) => [