AI category-aware evaluation: per-round config, file parsing, shortlist, advance flow
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- Per-juror cap mode (HARD/SOFT/NONE) in add-member dialog and members table
- Jury invite flow: create user + add to group + send invitation from dialog
- Per-round config: notifyOnAdvance, aiParseFiles, startupAdvanceCount, conceptAdvanceCount
- Moved notify-on-advance from competition-level to per-round setting
- AI filtering: round-tagged files with newest-first sorting, optional file content extraction
- File content extractor service (pdf-parse for PDF, utf-8 for text files)
- AI shortlist runs independently per category (STARTUP / BUSINESS_CONCEPT)
- generateAIRecommendations tRPC endpoint with per-round config integration
- AI recommendations UI: trigger button, confirmation dialog, per-category results display
- Category-aware advance dialog: select/deselect projects by category with target caps
- STAGE_ACTIVE bug fix in assignment router

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 10:09:52 +01:00
parent 93f4ad4b31
commit 80c9e35971
21 changed files with 1886 additions and 1381 deletions

View File

@@ -12,6 +12,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
@@ -147,6 +148,11 @@ export default function RoundDetailPage() {
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
const [aiRecommendations, setAiRecommendations] = useState<{
STARTUP: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }>
BUSINESS_CONCEPT: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }>
} | null>(null)
const [shortlistDialogOpen, setShortlistDialogOpen] = useState(false)
const utils = trpc.useUtils()
@@ -243,6 +249,25 @@ export default function RoundDetailPage() {
onError: (err) => toast.error(err.message),
})
const shortlistMutation = trpc.round.generateAIRecommendations.useMutation({
onSuccess: (data) => {
if (data.success) {
setAiRecommendations(data.recommendations)
toast.success(
`AI recommendations generated: ${data.recommendations.STARTUP.length} startups, ${data.recommendations.BUSINESS_CONCEPT.length} concepts` +
(data.tokensUsed ? ` (${data.tokensUsed} tokens)` : ''),
)
} else {
toast.error(data.errors?.join('; ') || 'AI shortlist failed')
}
setShortlistDialogOpen(false)
},
onError: (err) => {
toast.error(err.message)
setShortlistDialogOpen(false)
},
})
const isTransitioning = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => {
@@ -828,6 +853,25 @@ export default function RoundDetailPage() {
</div>
</button>
{/* AI Shortlist Recommendations */}
{(isEvaluation || isFiltering) && projectCount > 0 && (
<button
onClick={() => setShortlistDialogOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-purple-200 bg-purple-50/50"
disabled={shortlistMutation.isPending}
>
<BarChart3 className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">
{shortlistMutation.isPending ? 'Generating...' : 'AI Recommendations'}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Generate ranked shortlist per category using AI analysis
</p>
</div>
</button>
)}
{/* Advance projects (shown when PASSED > 0) */}
{passedCount > 0 && (
<button
@@ -847,29 +891,62 @@ export default function RoundDetailPage() {
</CardContent>
</Card>
{/* Advance Projects Confirmation Dialog */}
<AlertDialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
{/* Advance Projects Dialog */}
<AdvanceProjectsDialog
open={advanceDialogOpen}
onOpenChange={setAdvanceDialogOpen}
roundId={roundId}
projectStates={projectStates}
config={config}
advanceMutation={advanceMutation}
/>
{/* AI Shortlist Confirmation Dialog */}
<AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Advance {passedCount} project(s)?</AlertDialogTitle>
<AlertDialogTitle>Generate AI Recommendations?</AlertDialogTitle>
<AlertDialogDescription>
All projects with PASSED status in this round will be moved to the next round.
This action creates new entries in the next round and marks current entries as completed.
The AI will analyze all project evaluations and generate a ranked shortlist
for each category independently.
{config.startupAdvanceCount ? (
<span className="block mt-1">
Startup target: top {String(config.startupAdvanceCount)}
</span>
) : null}
{config.conceptAdvanceCount ? (
<span className="block">
Business Concept target: top {String(config.conceptAdvanceCount)}
</span>
) : null}
{config.aiParseFiles ? (
<span className="block mt-1 text-amber-600">
Document parsing is enabled the AI will read uploaded file contents.
</span>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => advanceMutation.mutate({ roundId })}
disabled={advanceMutation.isPending}
onClick={() => shortlistMutation.mutate({ roundId })}
disabled={shortlistMutation.isPending}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance Projects
{shortlistMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Generate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* AI Recommendations Display */}
{aiRecommendations && (
<AIRecommendationsDisplay
recommendations={aiRecommendations}
onClear={() => setAiRecommendations(null)}
/>
)}
{/* Round Info + Project Breakdown */}
<div className="grid gap-4 sm:grid-cols-2">
<Card>
@@ -1054,7 +1131,7 @@ export default function RoundDetailPage() {
<CardTitle className="text-base">General Settings</CardTitle>
<CardDescription>Settings that apply to this round regardless of type</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-5">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
@@ -1072,6 +1149,85 @@ export default function RoundDetailPage() {
}}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-on-advance" className="text-sm font-medium">
Notify on advance
</Label>
<p className="text-xs text-muted-foreground">
Send an email to project applicants when their project advances from this round to the next
</p>
</div>
<Switch
id="notify-on-advance"
checked={!!config.notifyOnAdvance}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, notifyOnAdvance: checked })
}}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="ai-parse-files" className="text-sm font-medium">
AI document parsing
</Label>
<p className="text-xs text-muted-foreground">
Allow AI to read the contents of uploaded files (PDF/text) for deeper analysis during filtering and evaluation
</p>
</div>
<Switch
id="ai-parse-files"
checked={!!config.aiParseFiles}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, aiParseFiles: checked })
}}
/>
</div>
<div className="border-t pt-4">
<Label className="text-sm font-medium">Advancement Targets</Label>
<p className="text-xs text-muted-foreground mb-3">
Target number of projects per category to advance from this round
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground">
Startup Projects
</Label>
<Input
id="startup-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.startupAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, startupAdvanceCount: val })
}}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground">
Concept Projects
</Label>
<Input
id="concept-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.conceptAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, conceptAdvanceCount: val })
}}
/>
</div>
</div>
</div>
</CardContent>
</Card>
@@ -1585,6 +1741,304 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
// ── Evaluation Criteria Editor ───────────────────────────────────────────
// ── Advance Projects Dialog ─────────────────────────────────────────────
function AdvanceProjectsDialog({
open,
onOpenChange,
roundId,
projectStates,
config,
advanceMutation,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
projectStates: any[] | undefined
config: Record<string, unknown>
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[] }) => void; isPending: boolean }
}) {
const passedProjects = useMemo(() =>
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
[projectStates])
const startups = useMemo(() =>
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
[passedProjects])
const concepts = useMemo(() =>
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'BUSINESS_CONCEPT'),
[passedProjects])
const other = useMemo(() =>
passedProjects.filter((ps: any) =>
ps.project?.competitionCategory !== 'STARTUP' && ps.project?.competitionCategory !== 'BUSINESS_CONCEPT',
),
[passedProjects])
const startupCap = (config.startupAdvanceCount as number) || 0
const conceptCap = (config.conceptAdvanceCount as number) || 0
const [selected, setSelected] = useState<Set<string>>(new Set())
// Reset selection when dialog opens
if (open && selected.size === 0 && passedProjects.length > 0) {
const initial = new Set<string>()
// Auto-select all (or up to cap if configured)
const startupSlice = startupCap > 0 ? startups.slice(0, startupCap) : startups
const conceptSlice = conceptCap > 0 ? concepts.slice(0, conceptCap) : concepts
for (const ps of startupSlice) initial.add(ps.project?.id)
for (const ps of conceptSlice) initial.add(ps.project?.id)
for (const ps of other) initial.add(ps.project?.id)
setSelected(initial)
}
const toggleProject = (projectId: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(projectId)) next.delete(projectId)
else next.add(projectId)
return next
})
}
const toggleAll = (projects: any[], on: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
for (const ps of projects) {
if (on) next.add(ps.project?.id)
else next.delete(ps.project?.id)
}
return next
})
}
const handleAdvance = () => {
const ids = Array.from(selected)
if (ids.length === 0) return
advanceMutation.mutate({ roundId, projectIds: ids })
onOpenChange(false)
setSelected(new Set())
}
const handleClose = () => {
onOpenChange(false)
setSelected(new Set())
}
const renderCategorySection = (
label: string,
projects: any[],
cap: number,
badgeColor: string,
) => {
const selectedInCategory = projects.filter((ps: any) => selected.has(ps.project?.id)).length
const overCap = cap > 0 && selectedInCategory > cap
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={projects.length > 0 && projects.every((ps: any) => selected.has(ps.project?.id))}
onCheckedChange={(checked) => toggleAll(projects, !!checked)}
/>
<span className="text-sm font-medium">{label}</span>
<Badge variant="secondary" className={cn('text-[10px]', badgeColor)}>
{selectedInCategory}/{projects.length}
</Badge>
{cap > 0 && (
<span className={cn('text-[10px]', overCap ? 'text-red-500 font-medium' : 'text-muted-foreground')}>
(target: {cap})
</span>
)}
</div>
</div>
{projects.length === 0 ? (
<p className="text-xs text-muted-foreground pl-7">No passed projects in this category</p>
) : (
<div className="space-y-1 pl-7">
{projects.map((ps: any) => (
<label
key={ps.project?.id}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/30 cursor-pointer"
>
<Checkbox
checked={selected.has(ps.project?.id)}
onCheckedChange={() => toggleProject(ps.project?.id)}
/>
<span className="text-sm truncate flex-1">{ps.project?.title || 'Untitled'}</span>
{ps.project?.teamName && (
<span className="text-xs text-muted-foreground shrink-0">{ps.project.teamName}</span>
)}
</label>
))}
</div>
)}
</div>
)
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Advance Projects</DialogTitle>
<DialogDescription>
Select which passed projects to advance to the next round.
{selected.size} of {passedProjects.length} selected.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 py-2">
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>Cancel</Button>
<Button
onClick={handleAdvance}
disabled={selected.size === 0 || advanceMutation.isPending}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ── AI Recommendations Display ──────────────────────────────────────────
type RecommendationItem = {
projectId: string
rank: number
score: number
category: string
strengths: string[]
concerns: string[]
recommendation: string
}
function AIRecommendationsDisplay({
recommendations,
onClear,
}: {
recommendations: { STARTUP: RecommendationItem[]; BUSINESS_CONCEPT: RecommendationItem[] }
onClear: () => void
}) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const renderCategory = (label: string, items: RecommendationItem[], colorClass: string) => {
if (items.length === 0) return (
<div className="text-center py-4 text-muted-foreground text-sm">
No {label.toLowerCase()} projects evaluated
</div>
)
return (
<div className="space-y-2">
{items.map((item) => {
const isExpanded = expandedId === `${item.category}-${item.projectId}`
return (
<div
key={item.projectId}
className="border rounded-lg overflow-hidden"
>
<button
onClick={() => setExpandedId(isExpanded ? null : `${item.category}-${item.projectId}`)}
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/30 transition-colors"
>
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0',
colorClass,
)}>
{item.rank}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.projectId}</p>
<p className="text-xs text-muted-foreground truncate">{item.recommendation}</p>
</div>
<Badge variant="outline" className="shrink-0 text-xs font-mono">
{item.score}/100
</Badge>
<ChevronDown className={cn(
'h-4 w-4 text-muted-foreground transition-transform shrink-0',
isExpanded && 'rotate-180',
)} />
</button>
{isExpanded && (
<div className="px-3 pb-3 pt-0 space-y-2 border-t bg-muted/10">
<div className="pt-2">
<p className="text-xs font-medium text-emerald-700 mb-1">Strengths</p>
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
{item.strengths.map((s, i) => <li key={i}>{s}</li>)}
</ul>
</div>
{item.concerns.length > 0 && (
<div>
<p className="text-xs font-medium text-amber-700 mb-1">Concerns</p>
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
{item.concerns.map((c, i) => <li key={i}>{c}</li>)}
</ul>
</div>
)}
<div>
<p className="text-xs font-medium text-blue-700 mb-1">Recommendation</p>
<p className="text-xs text-muted-foreground">{item.recommendation}</p>
</div>
</div>
)}
</div>
)
})}
</div>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">AI Shortlist Recommendations</CardTitle>
<CardDescription>
Ranked independently per category {recommendations.STARTUP.length} startups, {recommendations.BUSINESS_CONCEPT.length} concepts
</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={onClear}>
<X className="h-4 w-4 mr-1" />
Dismiss
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-6 lg:grid-cols-2">
<div>
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-blue-500" />
Startup ({recommendations.STARTUP.length})
</h4>
{renderCategory('Startup', recommendations.STARTUP, 'bg-blue-500')}
</div>
<div>
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-purple-500" />
Business Concept ({recommendations.BUSINESS_CONCEPT.length})
</h4>
{renderCategory('Business Concept', recommendations.BUSINESS_CONCEPT, 'bg-purple-500')}
</div>
</div>
</CardContent>
</Card>
)
}
// ── Evaluation Criteria Editor ───────────────────────────────────────────
function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
const [editing, setEditing] = useState(false)
const [criteria, setCriteria] = useState<Array<{

View File

@@ -169,7 +169,6 @@ export default function RoundsPage() {
categoryMode: comp.categoryMode,
startupFinalistCount: comp.startupFinalistCount,
conceptFinalistCount: comp.conceptFinalistCount,
notifyOnRoundAdvance: comp.notifyOnRoundAdvance,
notifyOnDeadlineApproach: comp.notifyOnDeadlineApproach,
})
}
@@ -492,13 +491,6 @@ function CompetitionGroup({
onChange={(e) => onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })}
/>
</div>
<div className="flex items-center gap-3 pt-6">
<Switch
checked={(competitionEdits.notifyOnRoundAdvance as boolean) ?? false}
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnRoundAdvance: v })}
/>
<Label className="text-xs font-medium">Notify on Advance</Label>
</div>
<div className="flex items-center gap-3 pt-6">
<Switch
checked={(competitionEdits.notifyOnDeadlineApproach as boolean) ?? false}