From 8931da98ba2c4c28a31a333a335646f8a4274852 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 2 Feb 2026 20:02:58 +0100 Subject: [PATCH] Add AI eligibility toggle and include-submitted filter for awards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../migration.sql | 2 + prisma/schema.prisma | 5 +- src/app/(admin)/admin/awards/[id]/page.tsx | 60 +++++++++++++++---- src/app/(admin)/admin/awards/new/page.tsx | 18 ++++++ src/server/routers/specialAward.ts | 21 +++++-- 5 files changed, 85 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/20260202100000_add_award_ai_toggle/migration.sql diff --git a/prisma/migrations/20260202100000_add_award_ai_toggle/migration.sql b/prisma/migrations/20260202100000_add_award_ai_toggle/migration.sql new file mode 100644 index 0000000..3f241c4 --- /dev/null +++ b/prisma/migrations/20260202100000_add_award_ai_toggle/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "SpecialAward" ADD COLUMN "useAiEligibility" BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fbe856f..5708552 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1090,8 +1090,9 @@ model SpecialAward { status AwardStatus @default(DRAFT) // Criteria - criteriaText String? @db.Text // Plain-language criteria for AI - autoTagRulesJson Json? @db.JsonB // Deterministic eligibility rules + criteriaText String? @db.Text // Plain-language criteria for AI + autoTagRulesJson Json? @db.JsonB // Deterministic eligibility rules + useAiEligibility Boolean @default(true) // Whether AI evaluates eligibility // Scoring scoringMode AwardScoringMode @default(PICK_WINNER) diff --git a/src/app/(admin)/admin/awards/[id]/page.tsx b/src/app/(admin)/admin/awards/[id]/page.tsx index bda010e..3a4d4d5 100644 --- a/src/app/(admin)/admin/awards/[id]/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/page.tsx @@ -14,6 +14,7 @@ import { import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' import { Table, TableBody, @@ -76,7 +77,7 @@ export default function AwardDetailPage({ trpc.specialAward.listJurors.useQuery({ awardId }) const { data: voteResults } = 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 runEligibility = trpc.specialAward.runEligibility.useMutation() @@ -86,6 +87,7 @@ export default function AwardDetailPage({ const setWinner = trpc.specialAward.setWinner.useMutation() const [selectedJurorId, setSelectedJurorId] = useState('') + const [includeSubmitted, setIncludeSubmitted] = useState(true) const handleStatusChange = async ( status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED' @@ -103,7 +105,7 @@ export default function AwardDetailPage({ const handleRunEligibility = async () => { try { - const result = await runEligibility.mutateAsync({ awardId }) + const result = await runEligibility.mutateAsync({ awardId, includeSubmitted }) toast.success( `Eligibility run: ${result.eligible} eligible, ${result.ineligible} ineligible` ) @@ -201,7 +203,7 @@ export default function AwardDetailPage({ {award.status.replace('_', ' ')} - {award.program.name} + {award.program.year} Edition @@ -262,23 +264,55 @@ export default function AwardDetailPage({ {/* Eligibility Tab */} -
+

{award.eligibleCount} of {award._count.eligibilities} projects eligible

- ) : ( - + )} - Run AI Eligibility - +
+ {!award.useAiEligibility && ( +

+ AI eligibility is off for this award. Projects are loaded for manual selection. +

+ )} {eligibilityData && eligibilityData.eligibilities.length > 0 ? ( diff --git a/src/app/(admin)/admin/awards/new/page.tsx b/src/app/(admin)/admin/awards/new/page.tsx index cd11404..6576227 100644 --- a/src/app/(admin)/admin/awards/new/page.tsx +++ b/src/app/(admin)/admin/awards/new/page.tsx @@ -22,6 +22,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' import { toast } from 'sonner' import { ArrowLeft, Save, Loader2 } from 'lucide-react' @@ -33,6 +34,7 @@ export default function CreateAwardPage() { const [scoringMode, setScoringMode] = useState< 'PICK_WINNER' | 'RANKED' | 'SCORED' >('PICK_WINNER') + const [useAiEligibility, setUseAiEligibility] = useState(true) const [maxRankedPicks, setMaxRankedPicks] = useState('3') const [programId, setProgramId] = useState('') @@ -47,6 +49,7 @@ export default function CreateAwardPage() { name: name.trim(), description: description.trim() || undefined, criteriaText: criteriaText.trim() || undefined, + useAiEligibility, scoringMode, maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined, @@ -140,6 +143,21 @@ export default function CreateAwardPage() {

+
+
+ +

+ Use AI to automatically evaluate project eligibility based on the criteria above. + Turn off for awards decided by feeling or manual selection. +

+
+ +
+
diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts index 621a393..a9d357e 100644 --- a/src/server/routers/specialAward.ts +++ b/src/server/routers/specialAward.ts @@ -60,7 +60,7 @@ export const specialAwardRouter = router({ select: { id: true, title: true, teamName: true }, }, 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), description: z.string().optional(), criteriaText: z.string().optional(), + 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(), @@ -102,6 +103,7 @@ export const specialAwardRouter = router({ name: input.name, description: input.description, criteriaText: input.criteriaText, + useAiEligibility: input.useAiEligibility ?? true, scoringMode: input.scoringMode, maxRankedPicks: input.maxRankedPicks, autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined, @@ -130,6 +132,7 @@ export const specialAwardRouter = router({ name: z.string().min(1).optional(), description: z.string().optional(), criteriaText: z.string().optional(), + 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(), @@ -220,18 +223,24 @@ export const specialAwardRouter = router({ * Run auto-tag + AI eligibility */ runEligibility: adminProcedure - .input(z.object({ awardId: z.string() })) + .input(z.object({ + awardId: z.string(), + includeSubmitted: z.boolean().optional(), + })) .mutation(async ({ ctx, input }) => { const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, 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({ where: { round: { programId: award.programId }, - status: { in: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] }, + status: { in: [...statusFilter] }, }, select: { id: true, @@ -259,9 +268,9 @@ export const specialAwardRouter = router({ 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 | undefined - if (award.criteriaText) { + if (award.criteriaText && award.useAiEligibility) { const aiEvals = await aiInterpretCriteria(award.criteriaText, projects) aiResults = new Map( aiEvals.map((e) => [