Add AI eligibility toggle and include-submitted filter for awards
Build and Push Docker Image / build (push) Has been cancelled
Details
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:
parent
e34cafebbf
commit
8931da98ba
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SpecialAward" ADD COLUMN "useAiEligibility" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) => [
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue