AI-powered assignment generation with enriched data and streaming UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m19s

- Add aiPreview mutation with full project/juror data (bios, descriptions,
  documents, categories, ocean issues, countries, team sizes)
- Increase AI description limit from 300 to 2000 chars for richer context
- Update GPT system prompt to use all available data fields
- Add mode toggle (AI default / Algorithm fallback) in assignment preview
- Lift AI mutation to parent page for background generation persistence
- Show visual indicator on page while AI generates (spinner + progress card)
- Toast notification with "Review" action when AI completes
- Staggered reveal animation for assignment results (streaming feel)
- Fix assignment balance with dynamic penalty (25pts per existing assignment)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-17 14:45:57 +01:00
parent a7b6031f4d
commit 6743119c4d
7 changed files with 640 additions and 73 deletions

View File

@@ -2,7 +2,8 @@
import { useState } from 'react' import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { ArrowLeft, PlayCircle } from 'lucide-react' import { ArrowLeft, Loader2, PlayCircle, Zap } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -26,6 +27,16 @@ export default function AssignmentsDashboardPage() {
const [selectedRoundId, setSelectedRoundId] = useState<string>('') const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const [previewSheetOpen, setPreviewSheetOpen] = useState(false) const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
const aiAssignmentMutation = trpc.roundAssignment.aiPreview.useMutation({
onSuccess: () => {
toast.success('AI assignments ready!', {
action: { label: 'Review', onClick: () => setPreviewSheetOpen(true) },
duration: 10000,
})
},
onError: (err) => toast.error(`AI generation failed: ${err.message}`),
})
const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({ const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({
id: competitionId, id: competitionId,
}) })
@@ -104,11 +115,24 @@ export default function AssignmentsDashboardPage() {
{selectedRoundId && ( {selectedRoundId && (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-end"> <div className="flex justify-end gap-2">
<Button onClick={() => setPreviewSheetOpen(true)}> <Button
<PlayCircle className="mr-2 h-4 w-4" /> onClick={() => {
Generate Assignments aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })
}}
disabled={aiAssignmentMutation.isPending}
>
{aiAssignmentMutation.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Generating...</>
) : (
<><Zap className="mr-2 h-4 w-4" />{aiAssignmentMutation.data ? 'Regenerate' : 'Generate with AI'}</>
)}
</Button> </Button>
{aiAssignmentMutation.data && (
<Button variant="outline" onClick={() => setPreviewSheetOpen(true)}>
Review Assignments
</Button>
)}
</div> </div>
<Tabs defaultValue="coverage" className="w-full"> <Tabs defaultValue="coverage" className="w-full">
@@ -170,6 +194,10 @@ export default function AssignmentsDashboardPage() {
open={previewSheetOpen} open={previewSheetOpen}
onOpenChange={setPreviewSheetOpen} onOpenChange={setPreviewSheetOpen}
requiredReviews={requiredReviews} requiredReviews={requiredReviews}
aiResult={aiAssignmentMutation.data ?? null}
isAIGenerating={aiAssignmentMutation.isPending}
onGenerateAI={() => aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })}
onResetAI={() => aiAssignmentMutation.reset()}
/> />
</div> </div>
)} )}

View File

@@ -170,6 +170,22 @@ export default function RoundDetailPage() {
const pendingSaveRef = useRef(false) const pendingSaveRef = useRef(false)
const [activeTab, setActiveTab] = useState('overview') const [activeTab, setActiveTab] = useState('overview')
const [previewSheetOpen, setPreviewSheetOpen] = useState(false) const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
// AI assignment generation (lifted to page level so it persists when sheet closes)
const aiAssignmentMutation = trpc.roundAssignment.aiPreview.useMutation({
onSuccess: () => {
toast.success('AI assignments ready!', {
action: {
label: 'Review',
onClick: () => setPreviewSheetOpen(true),
},
duration: 10000,
})
},
onError: (err) => {
toast.error(`AI generation failed: ${err.message}`)
},
})
const [exportOpen, setExportOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false)
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false) const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
const [aiRecommendations, setAiRecommendations] = useState<{ const [aiRecommendations, setAiRecommendations] = useState<{
@@ -1514,23 +1530,62 @@ export default function RoundDetailPage() {
<CoverageReport roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} /> <CoverageReport roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
{/* Generate Assignments */} {/* Generate Assignments */}
<Card> <Card className={cn(aiAssignmentMutation.isPending && 'border-violet-300 shadow-violet-100 shadow-sm')}>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle className="text-base">Assignment Generation</CardTitle> <CardTitle className="text-base flex items-center gap-2">
Assignment Generation
{aiAssignmentMutation.isPending && (
<Badge variant="outline" className="gap-1.5 text-violet-600 border-violet-300 animate-pulse">
<Loader2 className="h-3 w-3 animate-spin" />
AI generating...
</Badge>
)}
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
<Badge variant="outline" className="gap-1 text-emerald-600 border-emerald-300">
<CheckCircle2 className="h-3 w-3" />
{aiAssignmentMutation.data.stats.assignmentsGenerated} ready
</Badge>
)}
</CardTitle>
<CardDescription> <CardDescription>
AI-suggested jury-to-project assignments based on expertise and workload AI-suggested jury-to-project assignments based on expertise and workload
</CardDescription> </CardDescription>
</div> </div>
<Button <div className="flex items-center gap-2">
size="sm" {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
onClick={() => setPreviewSheetOpen(true)} <Button
disabled={projectCount === 0 || !juryGroup} size="sm"
> variant="outline"
<Zap className="h-4 w-4 mr-1.5" /> onClick={() => setPreviewSheetOpen(true)}
Generate Assignments >
</Button> Review Assignments
</Button>
)}
<Button
size="sm"
onClick={() => {
aiAssignmentMutation.mutate({
roundId,
requiredReviews: (config.requiredReviewsPerProject as number) || 3,
})
}}
disabled={projectCount === 0 || !juryGroup || aiAssignmentMutation.isPending}
>
{aiAssignmentMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
Generating...
</>
) : (
<>
<Zap className="h-4 w-4 mr-1.5" />
{aiAssignmentMutation.data ? 'Regenerate' : 'Generate with AI'}
</>
)}
</Button>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -1546,12 +1601,41 @@ export default function RoundDetailPage() {
Add projects to this round first. Add projects to this round first.
</div> </div>
)} )}
{juryGroup && projectCount > 0 && ( {juryGroup && projectCount > 0 && !aiAssignmentMutation.isPending && !aiAssignmentMutation.data && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Click &quot;Generate Assignments&quot; to preview AI-suggested assignments. Click &quot;Generate with AI&quot; to create assignments using GPT analysis of juror expertise, project descriptions, and documents. Or open the preview to use the algorithm instead.
You can review and execute them from the preview sheet.
</p> </p>
)} )}
{aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200 dark:bg-violet-950/20 dark:border-violet-800">
<div className="relative">
<div className="h-8 w-8 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
</div>
<div>
<p className="text-sm font-medium text-violet-800 dark:text-violet-200">AI is analyzing projects and jurors...</p>
<p className="text-xs text-violet-600 dark:text-violet-400">
Matching expertise, reviewing bios, and balancing workloads
</p>
</div>
</div>
)}
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-800">
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200">
{aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated
</p>
<p className="text-xs text-emerald-600 dark:text-emerald-400">
{aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects
{aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'}
</p>
</div>
<Button size="sm" variant="outline" onClick={() => setPreviewSheetOpen(true)}>
Review &amp; Execute
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -1582,6 +1666,13 @@ export default function RoundDetailPage() {
open={previewSheetOpen} open={previewSheetOpen}
onOpenChange={setPreviewSheetOpen} onOpenChange={setPreviewSheetOpen}
requiredReviews={(config.requiredReviewsPerProject as number) || 3} requiredReviews={(config.requiredReviewsPerProject as number) || 3}
aiResult={aiAssignmentMutation.data ?? null}
isAIGenerating={aiAssignmentMutation.isPending}
onGenerateAI={() => aiAssignmentMutation.mutate({
roundId,
requiredReviews: (config.requiredReviewsPerProject as number) || 3,
})}
onResetAI={() => aiAssignmentMutation.reset()}
/> />
{/* CSV Export Dialog */} {/* CSV Export Dialog */}

View File

@@ -1,18 +1,19 @@
'use client' 'use client'
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo, useRef } from 'react'
import { import {
AlertTriangle, AlertTriangle,
Bot,
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Cpu,
Loader2, Loader2,
Plus, Plus,
Sparkles, Sparkles,
Tag, Tag,
User, User,
X, X,
Zap,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@@ -49,6 +50,8 @@ import {
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type AssignmentMode = 'ai' | 'algorithm'
type EditableAssignment = { type EditableAssignment = {
localId: string localId: string
userId: string userId: string
@@ -63,11 +66,27 @@ type EditableAssignment = {
isManual: boolean isManual: boolean
} }
type AIPreviewData = {
assignments: any[]
warnings: string[]
stats: { totalProjects: number; totalJurors: number; assignmentsGenerated: number; unassignedProjects: number }
fallbackUsed?: boolean
tokensUsed?: number
}
type AssignmentPreviewSheetProps = { type AssignmentPreviewSheetProps = {
roundId: string roundId: string
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
requiredReviews?: number requiredReviews?: number
/** AI preview result from parent (lifted mutation) */
aiResult?: AIPreviewData | null
/** Whether AI is currently generating */
isAIGenerating?: boolean
/** Trigger AI generation from parent */
onGenerateAI?: () => void
/** Reset AI results */
onResetAI?: () => void
} }
export function AssignmentPreviewSheet({ export function AssignmentPreviewSheet({
@@ -75,25 +94,38 @@ export function AssignmentPreviewSheet({
open, open,
onOpenChange, onOpenChange,
requiredReviews = 3, requiredReviews = 3,
aiResult,
isAIGenerating = false,
onGenerateAI,
onResetAI,
}: AssignmentPreviewSheetProps) { }: AssignmentPreviewSheetProps) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
const [mode, setMode] = useState<AssignmentMode>('ai')
const [assignments, setAssignments] = useState<EditableAssignment[]>([]) const [assignments, setAssignments] = useState<EditableAssignment[]>([])
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
const [expandedJurors, setExpandedJurors] = useState<Set<string>>(new Set()) const [expandedJurors, setExpandedJurors] = useState<Set<string>>(new Set())
const [addJurorId, setAddJurorId] = useState<string>('') const [addJurorId, setAddJurorId] = useState<string>('')
const [addProjectId, setAddProjectId] = useState<string>('') const [addProjectId, setAddProjectId] = useState<string>('')
// Track staggered reveal for streaming effect
const [visibleCount, setVisibleCount] = useState(0)
const staggerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// ── Queries ────────────────────────────────────────────────────────────── // ── Queries ──────────────────────────────────────────────────────────────
// Algorithm mode query (only runs when algorithm mode is selected)
const { const {
data: preview, data: algoPreview,
isLoading, isLoading: isAlgoLoading,
refetch,
} = trpc.roundAssignment.preview.useQuery( } = trpc.roundAssignment.preview.useQuery(
{ roundId, honorIntents: true, requiredReviews }, { roundId, honorIntents: true, requiredReviews },
{ enabled: open }, { enabled: open && mode === 'algorithm' },
) )
// Active preview data based on mode
const preview = mode === 'ai' ? aiResult : algoPreview
const isLoading = mode === 'ai' ? isAIGenerating : isAlgoLoading
// Fetch round data for jury group members // Fetch round data for jury group members
const { data: round } = trpc.round.getById.useQuery( const { data: round } = trpc.round.getById.useQuery(
{ id: roundId }, { id: roundId },
@@ -127,12 +159,12 @@ export function AssignmentPreviewSheet({
}, },
}) })
// ── Initialize local state from preview ────────────────────────────────── // ── Initialize local state from preview with staggered reveal ──────────
useEffect(() => { useEffect(() => {
if (preview && !initialized) { if (preview && !initialized) {
const mapped: EditableAssignment[] = preview.assignments.map( const mapped: EditableAssignment[] = preview.assignments.map(
(a: any, idx: number) => ({ (a: any, idx: number) => ({
localId: `ai-${idx}`, localId: `${mode}-${idx}`,
userId: a.userId, userId: a.userId,
userName: a.userName, userName: a.userName,
projectId: a.projectId, projectId: a.projectId,
@@ -146,14 +178,36 @@ export function AssignmentPreviewSheet({
}), }),
) )
setAssignments(mapped) setAssignments(mapped)
// Auto-expand all jurors // Auto-expand all jurors
const jurorIds = new Set(mapped.map((a) => a.userId)) const jurorIds = new Set(mapped.map((a) => a.userId))
setExpandedJurors(jurorIds) setExpandedJurors(jurorIds)
setInitialized(true) setInitialized(true)
}
}, [preview, initialized])
// Reset when sheet closes // Staggered reveal: show assignments one by one
setVisibleCount(0)
const totalGroups = new Set(mapped.map((a) => a.userId)).size
let count = 0
const reveal = () => {
count++
setVisibleCount(count)
if (count < totalGroups) {
staggerTimerRef.current = setTimeout(reveal, 150)
}
}
// Start after a small delay for visual effect
staggerTimerRef.current = setTimeout(reveal, 100)
}
}, [preview, initialized, mode])
// Cleanup stagger timer
useEffect(() => {
return () => {
if (staggerTimerRef.current) clearTimeout(staggerTimerRef.current)
}
}, [])
// Reset when sheet closes (but preserve AI results in parent)
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setInitialized(false) setInitialized(false)
@@ -161,9 +215,21 @@ export function AssignmentPreviewSheet({
setExpandedJurors(new Set()) setExpandedJurors(new Set())
setAddJurorId('') setAddJurorId('')
setAddProjectId('') setAddProjectId('')
setVisibleCount(0)
if (staggerTimerRef.current) clearTimeout(staggerTimerRef.current)
} }
}, [open]) }, [open])
// Reset assignments when mode changes
const handleModeChange = (newMode: AssignmentMode) => {
if (newMode === mode) return
setMode(newMode)
setInitialized(false)
setAssignments([])
setExpandedJurors(new Set())
setVisibleCount(0)
}
// ── Derived data ───────────────────────────────────────────────────────── // ── Derived data ─────────────────────────────────────────────────────────
const juryMembers = useMemo(() => { const juryMembers = useMemo(() => {
if (!round?.juryGroup?.members) return [] if (!round?.juryGroup?.members) return []
@@ -271,8 +337,9 @@ export function AssignmentPreviewSheet({
}, },
]) ])
// Expand the juror's section // Expand the juror's section and make visible
setExpandedJurors((prev) => new Set([...prev, addJurorId])) setExpandedJurors((prev) => new Set([...prev, addJurorId]))
setVisibleCount((prev) => Math.max(prev, groupedByJuror.length + 1))
setAddProjectId('') setAddProjectId('')
} }
@@ -329,25 +396,139 @@ export function AssignmentPreviewSheet({
<SheetContent className="w-full sm:max-w-2xl p-0 flex flex-col"> <SheetContent className="w-full sm:max-w-2xl p-0 flex flex-col">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0"> <SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle className="text-lg">Assignment Preview</SheetTitle> <SheetTitle className="text-lg">Assignment Preview</SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-2 text-sm"> <SheetDescription className="text-sm">
<Badge variant="outline" className="text-xs gap-1 shrink-0"> Review and fine-tune assignments before executing.
<Bot className="h-3 w-3" />
AI Suggested
</Badge>
Review and fine-tune before executing.
</SheetDescription> </SheetDescription>
{/* Mode toggle */}
<div className="flex items-center gap-1 mt-2 p-1 rounded-lg bg-muted/50 w-fit">
<button
type="button"
onClick={() => handleModeChange('ai')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all',
mode === 'ai'
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Sparkles className="h-3.5 w-3.5" />
AI (GPT)
</button>
<button
type="button"
onClick={() => handleModeChange('algorithm')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all',
mode === 'algorithm'
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Cpu className="h-3.5 w-3.5" />
Algorithm
</button>
</div>
</SheetHeader> </SheetHeader>
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
<div className="px-6 py-4 space-y-4"> <div className="px-6 py-4 space-y-4">
{/* AI mode: show generate button if no results yet */}
{mode === 'ai' && !aiResult && !isAIGenerating && (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-8 gap-3">
<div className="h-12 w-12 rounded-full bg-violet-100 dark:bg-violet-950 flex items-center justify-center">
<Sparkles className="h-6 w-6 text-violet-600" />
</div>
<div className="text-center space-y-1">
<p className="font-medium text-sm">AI-Powered Assignments</p>
<p className="text-xs text-muted-foreground max-w-xs">
Uses GPT to analyze juror expertise, bios, project descriptions,
and documents to generate optimal assignments.
</p>
</div>
<Button
onClick={onGenerateAI}
className="gap-2 bg-violet-600 hover:bg-violet-700"
disabled={!onGenerateAI}
>
<Zap className="h-4 w-4" />
Generate with AI
</Button>
</CardContent>
</Card>
)}
{/* Loading state */}
{isLoading ? ( {isLoading ? (
<div className="space-y-3"> <div className="space-y-3">
{mode === 'ai' && (
<Card className="border-violet-200 bg-violet-50/50 dark:bg-violet-950/20">
<CardContent className="flex items-center gap-3 py-4">
<div className="relative">
<div className="h-6 w-6 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
</div>
<div>
<p className="text-sm font-medium">Generating AI assignments...</p>
<p className="text-xs text-muted-foreground">
Analyzing juror expertise and project data with GPT
</p>
</div>
</CardContent>
</Card>
)}
{[1, 2, 3, 4].map((i) => ( {[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-16 w-full" /> <Skeleton key={i} className="h-16 w-full" />
))} ))}
</div> </div>
) : preview ? ( ) : preview ? (
<> <>
{/* AI metadata */}
{mode === 'ai' && aiResult && (
<div className="flex flex-wrap items-center gap-2 text-xs">
<Badge variant="outline" className="gap-1 text-violet-600 border-violet-300">
<Sparkles className="h-3 w-3" />
AI Generated
</Badge>
{aiResult.fallbackUsed && (
<Badge variant="outline" className="gap-1 text-amber-600 border-amber-300">
<AlertTriangle className="h-3 w-3" />
Fallback algorithm used
</Badge>
)}
{(aiResult.tokensUsed ?? 0) > 0 && (
<span className="text-muted-foreground">
{aiResult.tokensUsed?.toLocaleString()} tokens used
</span>
)}
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
setInitialized(false)
setAssignments([])
setVisibleCount(0)
onGenerateAI?.()
}}
>
Regenerate
</Button>
</div>
)}
{mode === 'algorithm' && (
<div className="flex items-center gap-2 text-xs">
<Badge variant="outline" className="gap-1">
<Cpu className="h-3 w-3" />
Algorithm
</Badge>
<span className="text-muted-foreground">
Tag overlap + bio match + workload balancing
</span>
</div>
)}
{/* ── Summary stats ── */} {/* ── Summary stats ── */}
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
{[ {[
@@ -402,7 +583,7 @@ export function AssignmentPreviewSheet({
</Card> </Card>
)} )}
{/* ── Juror groups ── */} {/* ── Juror groups with staggered reveal ── */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Assignments by Juror Assignments by Juror
@@ -414,19 +595,28 @@ export function AssignmentPreviewSheet({
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
groupedByJuror.map((group) => ( groupedByJuror.map((group, index) => (
<JurorGroup <div
key={group.userId} key={group.userId}
group={group} className={cn(
expanded={expandedJurors.has(group.userId)} 'transition-all duration-300',
onToggle={() => toggleJuror(group.userId)} index < visibleCount
onRemove={removeAssignment} ? 'opacity-100 translate-y-0'
onAddProject={(projectId) => : 'opacity-0 translate-y-2 pointer-events-none h-0 overflow-hidden',
addProjectToJuror(group.userId, projectId) )}
} >
availableProjects={getAvailableProjectsForJuror(group.userId)} <JurorGroup
requiredReviews={requiredReviews} group={group}
/> expanded={expandedJurors.has(group.userId)}
onToggle={() => toggleJuror(group.userId)}
onRemove={removeAssignment}
onAddProject={(projectId) =>
addProjectToJuror(group.userId, projectId)
}
availableProjects={getAvailableProjectsForJuror(group.userId)}
requiredReviews={requiredReviews}
/>
</div>
)) ))
)} )}
</div> </div>
@@ -504,11 +694,11 @@ export function AssignmentPreviewSheet({
</CardContent> </CardContent>
</Card> </Card>
</> </>
) : ( ) : mode === 'algorithm' ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No preview data available No preview data available
</p> </p>
)} ) : null}
</div> </div>
</ScrollArea> </ScrollArea>
@@ -561,7 +751,6 @@ function JurorGroup({
onRemove, onRemove,
onAddProject, onAddProject,
availableProjects, availableProjects,
requiredReviews,
}: JurorGroupProps) { }: JurorGroupProps) {
const [inlineProjectId, setInlineProjectId] = useState('') const [inlineProjectId, setInlineProjectId] = useState('')
@@ -571,8 +760,6 @@ function JurorGroup({
setInlineProjectId('') setInlineProjectId('')
} }
const aiCount = group.assignments.filter((a) => !a.isManual).length
const manualCount = group.assignments.filter((a) => a.isManual).length
const avgScore = const avgScore =
group.assignments.length > 0 group.assignments.length > 0
? Math.round( ? Math.round(

View File

@@ -7,10 +7,205 @@ import {
getRoundCoverageReport, getRoundCoverageReport,
getUnassignedQueue, getUnassignedQueue,
} from '../services/round-assignment' } from '../services/round-assignment'
import { generateAIAssignments } from '../services/ai-assignment'
export const roundAssignmentRouter = router({ export const roundAssignmentRouter = router({
/** /**
* Preview round assignments without committing * AI-powered assignment preview using GPT with enriched project/juror data
*/
aiPreview: adminProcedure
.input(
z.object({
roundId: z.string(),
requiredReviews: z.number().int().min(1).max(20).default(3),
})
)
.mutation(async ({ ctx, input }) => {
// Load round with jury group
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
include: {
juryGroup: {
include: {
members: {
include: {
user: {
select: {
id: true, name: true, email: true,
bio: true, expertiseTags: true, country: true,
},
},
},
},
},
},
},
})
if (!round) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
}
if (!round.juryGroup) {
return {
assignments: [],
warnings: ['Round has no linked jury group'],
stats: { totalProjects: 0, totalJurors: 0, assignmentsGenerated: 0, unassignedProjects: 0 },
fallbackUsed: false,
tokensUsed: 0,
}
}
// Load projects with rich data (descriptions, tags, files, team members, etc.)
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
include: {
project: {
include: {
projectTags: { include: { tag: true } },
files: { select: { fileType: true, size: true, pageCount: true } },
_count: { select: { teamMembers: true } },
},
},
},
})
if (projectStates.length === 0) {
return {
assignments: [],
warnings: ['No active projects in this round'],
stats: { totalProjects: 0, totalJurors: round.juryGroup.members.length, assignmentsGenerated: 0, unassignedProjects: 0 },
fallbackUsed: false,
tokensUsed: 0,
}
}
// Load existing assignments
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
// Load COI records to exclude
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
where: { assignment: { roundId: input.roundId }, hasConflict: true },
select: { userId: true, projectId: true },
})
const coiPairs = new Set(coiRecords.map((c: { userId: string; projectId: string }) => `${c.userId}:${c.projectId}`))
// Build enriched juror data for AI
const jurors = round.juryGroup.members.map((m) => ({
id: m.user.id,
name: m.user.name,
email: m.user.email,
expertiseTags: (m.user.expertiseTags as string[]) ?? [],
bio: m.user.bio as string | null,
country: m.user.country as string | null,
maxAssignments: (m as any).maxAssignments as number | null ?? null,
_count: {
assignments: existingAssignments.filter((a) => a.userId === m.user.id).length,
},
}))
// Build enriched project data for AI
const projects = projectStates.map((ps) => {
const p = ps.project as any
return {
id: p.id as string,
title: p.title as string,
description: p.description as string | null,
tags: (p.projectTags?.map((pt: any) => pt.tag?.name).filter(Boolean) ?? p.tags ?? []) as string[],
tagConfidences: p.projectTags?.map((pt: any) => ({
name: pt.tag?.name as string,
confidence: (pt.confidence as number) ?? 1.0,
})) as Array<{ name: string; confidence: number }> | undefined,
teamName: p.teamName as string | null,
competitionCategory: p.competitionCategory as string | null,
oceanIssue: p.oceanIssue as string | null,
country: p.country as string | null,
institution: p.institution as string | null,
teamSize: (p._count?.teamMembers as number) ?? 0,
fileTypes: (p.files?.map((f: any) => f.fileType).filter(Boolean) ?? []) as string[],
_count: {
assignments: existingAssignments.filter((a) => a.projectId === p.id).length,
},
}
})
// Build constraints
const configJson = round.configJson as Record<string, unknown> | null
const maxPerJuror = (configJson?.maxAssignmentsPerJuror as number) ?? undefined
const constraints = {
requiredReviewsPerProject: input.requiredReviews,
maxAssignmentsPerJuror: maxPerJuror,
existingAssignments: existingAssignments.map((a) => ({
jurorId: a.userId,
projectId: a.projectId,
})),
}
// Call AI service
const result = await generateAIAssignments(
jurors,
projects,
constraints,
ctx.user.id,
input.roundId,
)
// Filter out COI pairs and already-assigned pairs
const existingPairSet = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
const filteredSuggestions = result.suggestions.filter((s) =>
!coiPairs.has(`${s.jurorId}:${s.projectId}`) &&
!existingPairSet.has(`${s.jurorId}:${s.projectId}`)
)
// Map to common AssignmentPreview format
const jurorNameMap = new Map(jurors.map((j) => [j.id, j.name ?? 'Unknown']))
const projectTitleMap = new Map(projects.map((p) => [p.id, p.title]))
const assignments = filteredSuggestions.map((s) => ({
userId: s.jurorId,
userName: jurorNameMap.get(s.jurorId) ?? 'Unknown',
projectId: s.projectId,
projectTitle: projectTitleMap.get(s.projectId) ?? 'Unknown',
score: Math.round(s.confidenceScore * 100),
breakdown: {
tagOverlap: Math.round(s.expertiseMatchScore * 100),
bioMatch: 0,
workloadBalance: 0,
countryMatch: 0,
geoDiversityPenalty: 0,
previousRoundFamiliarity: 0,
coiPenalty: 0,
availabilityPenalty: 0,
categoryQuotaPenalty: 0,
},
reasoning: [s.reasoning],
matchingTags: [] as string[],
policyViolations: [] as string[],
fromIntent: false,
}))
const assignedProjectIds = new Set(assignments.map((a) => a.projectId))
return {
assignments,
warnings: result.error ? [result.error] : [],
stats: {
totalProjects: projects.length,
totalJurors: jurors.length,
assignmentsGenerated: assignments.length,
unassignedProjects: projects.length - assignedProjectIds.size,
},
fallbackUsed: result.fallbackUsed ?? false,
tokensUsed: result.tokensUsed ?? 0,
}
}),
/**
* Preview round assignments without committing (algorithmic)
*/ */
preview: adminProcedure preview: adminProcedure
.input( .input(

View File

@@ -35,11 +35,15 @@ const ASSIGNMENT_BATCH_SIZE = 15
const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert jury assignment optimizer for an ocean conservation competition. const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert jury assignment optimizer for an ocean conservation competition.
## Your Role ## Your Role
Match jurors to projects based on expertise alignment, workload balance, and coverage requirements. Match jurors to projects based on expertise alignment, workload balance, geographic diversity, and coverage requirements. You have access to rich data about both jurors and projects — use ALL available information to make optimal assignments.
## Available Data
- **Jurors**: expertiseTags (areas of expertise), bio (background description with deeper domain knowledge), country, currentAssignmentCount, maxAssignments
- **Projects**: title, description (detailed project overview), tags (with confidence 0-1), category (e.g. STARTUP, BUSINESS_CONCEPT), oceanIssue (focus area like CORAL_REEFS, POLLUTION), country, institution, teamSize, fileTypes (submitted document types)
## Matching Criteria (Weighted) ## Matching Criteria (Weighted)
- Expertise Match (50%): How well juror tags/expertise align with project topics. Project tags include a confidence score (0-1) — weight higher-confidence tags more heavily as they are more reliably assigned. A tag with confidence 0.9 is a strong signal; one with 0.5 is uncertain. - Expertise & Domain Match (50%): How well juror tags, bio, and background align with project topics, category, ocean issue, and description. Use bio text to identify deeper domain expertise beyond explicit tags — e.g., a bio mentioning "20 years of coral research" matches coral-related projects even without explicit tags. Weight higher-confidence tags more heavily.
- Workload Balance (30%): Distribute assignments evenly; prefer jurors below capacity - Workload Balance (30%): Distribute assignments as evenly as possible; strongly prefer jurors below capacity. Never let one juror get significantly more assignments than another.
- Minimum Target (20%): Prioritize jurors who haven't reached their minimum assignment count - Minimum Target (20%): Prioritize jurors who haven't reached their minimum assignment count
## Output Format ## Output Format
@@ -51,18 +55,20 @@ Return a JSON object:
"project_id": "PROJECT_001", "project_id": "PROJECT_001",
"confidence_score": 0.0-1.0, "confidence_score": 0.0-1.0,
"expertise_match_score": 0.0-1.0, "expertise_match_score": 0.0-1.0,
"reasoning": "1-2 sentence justification" "reasoning": "1-2 sentence justification referencing specific expertise matches"
} }
] ]
} }
## Guidelines ## Guidelines
- Each project should receive the required number of reviews - Each project MUST receive the required number of reviews — ensure full coverage
- Distribute assignments as evenly as possible across all jurors
- Do not assign jurors who are at or above their capacity - Do not assign jurors who are at or above their capacity
- Favor geographic and disciplinary diversity in assignments - Favor geographic diversity: avoid assigning jurors from the same country as the project when possible
- confidence_score reflects overall assignment quality; expertise_match_score reflects tag overlap only - Consider disciplinary diversity: mix different expertise backgrounds per project
- A strong match: shared expertise tags + available capacity + under minimum target - confidence_score reflects overall assignment quality; expertise_match_score reflects tag/expertise overlap
- An acceptable match: related domain + available capacity - A strong match: shared expertise tags + relevant bio background + available capacity
- An acceptable match: related domain/ocean issue + available capacity
- A poor match: no expertise overlap, only assigned for coverage` - A poor match: no expertise overlap, only assigned for coverage`
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -88,6 +94,8 @@ interface JurorForAssignment {
name?: string | null name?: string | null
email: string email: string
expertiseTags: string[] expertiseTags: string[]
bio?: string | null
country?: string | null
maxAssignments?: number | null maxAssignments?: number | null
_count?: { _count?: {
assignments: number assignments: number
@@ -101,6 +109,12 @@ interface ProjectForAssignment {
tags: string[] tags: string[]
tagConfidences?: Array<{ name: string; confidence: number }> tagConfidences?: Array<{ name: string; confidence: number }>
teamName?: string | null teamName?: string | null
competitionCategory?: string | null
oceanIssue?: string | null
country?: string | null
institution?: string | null
teamSize?: number
fileTypes?: string[]
_count?: { _count?: {
assignments: number assignments: number
} }

View File

@@ -21,7 +21,7 @@ import type {
// ─── Description Limits ────────────────────────────────────────────────────── // ─── Description Limits ──────────────────────────────────────────────────────
export const DESCRIPTION_LIMITS = { export const DESCRIPTION_LIMITS = {
ASSIGNMENT: 300, ASSIGNMENT: 2000,
FILTERING: 500, FILTERING: 500,
ELIGIBILITY: 400, ELIGIBILITY: 400,
MENTOR: 350, MENTOR: 350,
@@ -46,6 +46,8 @@ export interface AnonymizedJuror {
expertiseTags: string[] expertiseTags: string[]
currentAssignmentCount: number currentAssignmentCount: number
maxAssignments: number | null maxAssignments: number | null
bio?: string | null
country?: string | null
} }
export interface AnonymizedProject { export interface AnonymizedProject {
@@ -54,6 +56,12 @@ export interface AnonymizedProject {
description: string | null description: string | null
tags: Array<{ name: string; confidence: number }> tags: Array<{ name: string; confidence: number }>
teamName: string | null teamName: string | null
category?: string | null
oceanIssue?: string | null
country?: string | null
institution?: string | null
teamSize?: number
fileTypes?: string[]
} }
export interface JurorMapping { export interface JurorMapping {
@@ -200,6 +208,8 @@ interface JurorInput {
name?: string | null name?: string | null
email: string email: string
expertiseTags: string[] expertiseTags: string[]
bio?: string | null
country?: string | null
maxAssignments?: number | null maxAssignments?: number | null
_count?: { _count?: {
assignments: number assignments: number
@@ -213,6 +223,12 @@ interface ProjectInput {
tags: string[] tags: string[]
tagConfidences?: Array<{ name: string; confidence: number }> tagConfidences?: Array<{ name: string; confidence: number }>
teamName?: string | null teamName?: string | null
competitionCategory?: string | null
oceanIssue?: string | null
country?: string | null
institution?: string | null
teamSize?: number
fileTypes?: string[]
} }
/** /**
@@ -238,6 +254,8 @@ export function anonymizeForAI(
expertiseTags: juror.expertiseTags, expertiseTags: juror.expertiseTags,
currentAssignmentCount: juror._count?.assignments ?? 0, currentAssignmentCount: juror._count?.assignments ?? 0,
maxAssignments: juror.maxAssignments ?? null, maxAssignments: juror.maxAssignments ?? null,
bio: juror.bio ? truncateAndSanitize(juror.bio, 500) : null,
country: juror.country ?? null,
} }
}) })
@@ -260,6 +278,12 @@ export function anonymizeForAI(
? project.tagConfidences ? project.tagConfidences
: project.tags.map((t) => ({ name: t, confidence: 1.0 })), : project.tags.map((t) => ({ name: t, confidence: 1.0 })),
teamName: project.teamName ? `Team ${index + 1}` : null, teamName: project.teamName ? `Team ${index + 1}` : null,
category: project.competitionCategory ?? null,
oceanIssue: project.oceanIssue ?? null,
country: project.country ?? null,
institution: project.institution ? sanitizeText(project.institution) : null,
teamSize: project.teamSize,
fileTypes: project.fileTypes,
} }
} }
) )

View File

@@ -303,19 +303,47 @@ export async function previewRoundAssignment(
} }
} }
// Sort by score descending // Balance-aware greedy assignment: at each step, pick the best suggestion
suggestions.sort((a, b) => b.score - a.score) // while dynamically penalizing jurors who already have more assignments
// to distribute work as evenly as possible.
const BALANCE_PENALTY_PER_ASSIGNMENT = 25
// Greedy assignment: pick top suggestions respecting caps
const finalAssignments: AssignmentSuggestion[] = [] const finalAssignments: AssignmentSuggestion[] = []
const finalJurorCounts = new Map(jurorAssignmentCounts) const finalJurorCounts = new Map(jurorAssignmentCounts)
const finalProjectCounts = new Map(projectAssignmentCounts) const finalProjectCounts = new Map(projectAssignmentCounts)
const consumed = new Set<number>()
while (consumed.size < suggestions.length) {
let bestIdx = -1
let bestAdjustedScore = -Infinity
for (let i = 0; i < suggestions.length; i++) {
if (consumed.has(i)) continue
const s = suggestions[i]
const projectCount = finalProjectCounts.get(s.projectId) ?? 0
if (projectCount >= requiredReviews) {
consumed.add(i)
continue
}
const jurorCount = finalJurorCounts.get(s.userId) ?? 0
// Dynamic balance penalty: penalize jurors who already have more assignments
const balancePenalty = jurorCount * BALANCE_PENALTY_PER_ASSIGNMENT
const adjustedScore = s.score - balancePenalty
if (adjustedScore > bestAdjustedScore) {
bestAdjustedScore = adjustedScore
bestIdx = i
}
}
if (bestIdx === -1) break
const suggestion = suggestions[bestIdx]
consumed.add(bestIdx)
for (const suggestion of suggestions) {
const jurorCount = finalJurorCounts.get(suggestion.userId) ?? 0 const jurorCount = finalJurorCounts.get(suggestion.userId) ?? 0
const projectCount = finalProjectCounts.get(suggestion.projectId) ?? 0
if (projectCount >= requiredReviews) continue
// Re-check cap // Re-check cap
const member = members.find((m: any) => m.userId === suggestion.userId) const member = members.find((m: any) => m.userId === suggestion.userId)
@@ -334,7 +362,7 @@ export async function previewRoundAssignment(
finalAssignments.push(suggestion) finalAssignments.push(suggestion)
finalJurorCounts.set(suggestion.userId, jurorCount + 1) finalJurorCounts.set(suggestion.userId, jurorCount + 1)
finalProjectCounts.set(suggestion.projectId, projectCount + 1) finalProjectCounts.set(suggestion.projectId, (finalProjectCounts.get(suggestion.projectId) ?? 0) + 1)
} }
const unassignedProjects = projects.filter( const unassignedProjects = projects.filter(