feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m16s

- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
  z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
  alongside existing top-N mode in advance dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 11:24:14 +01:00
parent c6ebd169dd
commit 19b58e4434
6 changed files with 674 additions and 107 deletions

View File

@@ -43,6 +43,13 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
GripVertical,
BarChart3,
@@ -50,6 +57,9 @@ import {
RefreshCw,
Trophy,
ExternalLink,
ChevronDown,
Settings2,
Download,
} from 'lucide-react'
import type { RankedProjectEntry } from '@/server/services/ai-ranking'
@@ -231,13 +241,24 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
// ─── Advance dialog state ─────────────────────────────────────────────────
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
const [advanceMode, setAdvanceMode] = useState<'top_n' | 'threshold'>('top_n')
const [topNStartup, setTopNStartup] = useState(3)
const [topNConceptual, setTopNConceptual] = useState(3)
const [scoreThreshold, setScoreThreshold] = useState(5)
const [includeReject, setIncludeReject] = useState(false)
// ─── Export state ──────────────────────────────────────────────────────────
const [exportLoading, setExportLoading] = useState(false)
// ─── Expandable review state ──────────────────────────────────────────────
const [expandedReviews, setExpandedReviews] = useState<Set<string>>(new Set())
// ─── Criteria weights state ────────────────────────────────────────────────
const [weightsOpen, setWeightsOpen] = useState(false)
const [localWeights, setLocalWeights] = useState<Record<string, number>>({})
const [localCriteriaText, setLocalCriteriaText] = useState<string>('')
const weightsInitialized = useRef(false)
// ─── Sensors ──────────────────────────────────────────────────────────────
const sensors = useSensors(
useSensor(PointerSensor),
@@ -273,6 +294,10 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
{ roundId },
)
const { data: evalForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId },
)
// ─── tRPC mutations ───────────────────────────────────────────────────────
const utils = trpc.useUtils()
@@ -283,6 +308,14 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
// Do NOT invalidate getSnapshot — would reset localOrder
})
const updateRoundMutation = trpc.round.update.useMutation({
onSuccess: () => {
toast.success('Ranking config saved')
void utils.round.getById.invalidate({ id: roundId })
},
onError: (err) => toast.error(`Failed to save: ${err.message}`),
})
const [rankingInProgress, setRankingInProgress] = useState(false)
const triggerRankMutation = trpc.ranking.triggerAutoRank.useMutation({
@@ -372,6 +405,34 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
}
}, [snapshot])
// ─── numericCriteria from eval form ─────────────────────────────────────
const numericCriteria = useMemo(() => {
if (!evalForm?.criteriaJson) return []
return (evalForm.criteriaJson as Array<{ id: string; label: string; type?: string; scale?: number | string }>)
.filter((c) => !c.type || c.type === 'numeric')
}, [evalForm])
// ─── Init local weights + criteriaText from round config ──────────────────
useEffect(() => {
if (!weightsInitialized.current && roundData?.configJson) {
const cfg = roundData.configJson as Record<string, unknown>
const saved = (cfg.criteriaWeights ?? {}) as Record<string, number>
setLocalWeights(saved)
setLocalCriteriaText((cfg.rankingCriteria as string) ?? '')
weightsInitialized.current = true
}
}, [roundData])
// ─── Save weights + criteria text to round config ─────────────────────────
const saveRankingConfig = () => {
if (!roundData?.configJson) return
const cfg = roundData.configJson as Record<string, unknown>
updateRoundMutation.mutate({
id: roundId,
configJson: { ...cfg, criteriaWeights: localWeights, rankingCriteria: localCriteriaText },
})
}
// ─── sync advance dialog defaults from config ────────────────────────────
useEffect(() => {
if (evalConfig) {
@@ -400,12 +461,36 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
})
}
// ─── Compute threshold-based project IDs ──────────────────────────────────
const thresholdAdvanceIds = useMemo(() => {
if (advanceMode !== 'threshold') return { ids: [] as string[], startupCount: 0, conceptCount: 0 }
const ids: string[] = []
let startupCount = 0
let conceptCount = 0
for (const cat of ['STARTUP', 'BUSINESS_CONCEPT'] as const) {
for (const projectId of localOrder[cat]) {
const entry = rankingMap.get(projectId)
if (entry?.avgGlobalScore != null && entry.avgGlobalScore >= scoreThreshold) {
ids.push(projectId)
if (cat === 'STARTUP') startupCount++
else conceptCount++
}
}
}
return { ids, startupCount, conceptCount }
}, [advanceMode, scoreThreshold, localOrder, rankingMap])
// ─── handleAdvance ────────────────────────────────────────────────────────
function handleAdvance() {
const advanceIds = [
...localOrder.STARTUP.slice(0, topNStartup),
...localOrder.BUSINESS_CONCEPT.slice(0, topNConceptual),
]
let advanceIds: string[]
if (advanceMode === 'threshold') {
advanceIds = thresholdAdvanceIds.ids
} else {
advanceIds = [
...localOrder.STARTUP.slice(0, topNStartup),
...localOrder.BUSINESS_CONCEPT.slice(0, topNConceptual),
]
}
const advanceSet = new Set(advanceIds)
advanceMutation.mutate({ roundId, projectIds: advanceIds })
@@ -420,6 +505,44 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
}
}
// ─── handleExport ──────────────────────────────────────────────────────────
async function handleExportScores() {
setExportLoading(true)
try {
const result = await utils.export.projectScores.fetch({ roundId })
if (!result.data || result.data.length === 0) {
toast.error('No data to export')
return
}
const headers = result.columns
const csvRows = [
headers.join(','),
...result.data.map((row: Record<string, unknown>) =>
headers.map((h: string) => {
const val = row[h]
if (val == null) return ''
const str = String(val)
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"`
: str
}).join(','),
),
]
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `round-scores-${roundId.slice(-8)}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success('CSV exported')
} catch (err) {
toast.error(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
setExportLoading(false)
}
}
// ─── Loading state ────────────────────────────────────────────────────────
if (snapshotsLoading || snapshotLoading) {
return (
@@ -510,6 +633,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
size="sm"
variant="outline"
onClick={handleExportScores}
disabled={exportLoading}
>
{exportLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Export CSV
</Button>
<Button
size="sm"
variant="outline"
@@ -544,6 +680,85 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
</CardHeader>
</Card>
{/* Ranking Configuration: criteria text + weights */}
<Collapsible open={weightsOpen} onOpenChange={setWeightsOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer flex flex-row items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<div>
<CardTitle className="text-base">Ranking Configuration</CardTitle>
<CardDescription className="mt-0.5">Criteria text, per-criterion weights, and bias correction</CardDescription>
</div>
</div>
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', weightsOpen && 'rotate-180')} />
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-5 pt-0">
{/* Ranking criteria text */}
<div className="space-y-2">
<Label htmlFor="rankingCriteria">Ranking Criteria (natural language)</Label>
<p className="text-xs text-muted-foreground">
Describe how projects should be ranked. The AI will parse this into rules.
</p>
<Textarea
id="rankingCriteria"
rows={3}
placeholder='E.g. "Prioritize innovation and ocean impact. Filter out projects with pass rate below 50%."'
value={localCriteriaText}
onChange={(e) => setLocalCriteriaText(e.target.value)}
className="resize-y"
/>
</div>
{/* Per-criterion weights */}
{numericCriteria.length > 0 && (
<div className="space-y-3">
<div>
<Label>Criteria Weights</Label>
<p className="text-xs text-muted-foreground">
Set relative importance of each evaluation criterion (0 = ignore, 10 = highest priority)
</p>
</div>
<div className="space-y-3">
{numericCriteria.map((c) => (
<div key={c.id} className="flex items-center gap-4">
<span className="text-sm w-40 truncate flex-shrink-0" title={c.label}>{c.label}</span>
<Slider
min={0}
max={10}
step={1}
value={[localWeights[c.id] ?? 1]}
onValueChange={([v]) => setLocalWeights((prev) => ({ ...prev, [c.id]: v }))}
className="flex-1"
/>
<span className="text-sm font-mono w-6 text-right">{localWeights[c.id] ?? 1}</span>
</div>
))}
</div>
</div>
)}
<div className="flex items-center gap-2 pt-2 border-t">
<Button
size="sm"
onClick={saveRankingConfig}
disabled={updateRoundMutation.isPending}
>
{updateRoundMutation.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
Save Configuration
</Button>
<p className="text-xs text-muted-foreground">
Weights are applied when ranking is run. Z-score normalization corrects for juror bias automatically.
</p>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
{/* Ranking in-progress banner */}
{rankingInProgress && (
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30">
@@ -642,55 +857,97 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
))}
</div>
{/* Advance Top N dialog */}
{/* Advance dialog */}
<Dialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Advance Top Projects</DialogTitle>
<DialogTitle>Advance Projects</DialogTitle>
<DialogDescription>
Select how many top-ranked projects to advance to the next round per category.
Projects are advanced in the order shown in the ranking list.
Choose how to select which projects advance to the next round.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Top N for STARTUP */}
{localOrder.STARTUP.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Startups to advance</Label>
<Input
type="number"
min={0}
max={localOrder.STARTUP.length}
value={topNStartup}
onChange={(e) =>
setTopNStartup(
Math.max(0, Math.min(localOrder.STARTUP.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.STARTUP.length}</span>
</div>
)}
{/* Mode toggle */}
<div className="flex gap-2">
<Button
size="sm"
variant={advanceMode === 'top_n' ? 'default' : 'outline'}
onClick={() => setAdvanceMode('top_n')}
className={advanceMode === 'top_n' ? 'bg-[#053d57] hover:bg-[#053d57]/90' : ''}
>
Top N per category
</Button>
<Button
size="sm"
variant={advanceMode === 'threshold' ? 'default' : 'outline'}
onClick={() => setAdvanceMode('threshold')}
className={advanceMode === 'threshold' ? 'bg-[#053d57] hover:bg-[#053d57]/90' : ''}
>
Score threshold
</Button>
</div>
{/* Top N for BUSINESS_CONCEPT */}
{localOrder.BUSINESS_CONCEPT.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Business concepts to advance</Label>
<Input
type="number"
min={0}
max={localOrder.BUSINESS_CONCEPT.length}
value={topNConceptual}
onChange={(e) =>
setTopNConceptual(
Math.max(0, Math.min(localOrder.BUSINESS_CONCEPT.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.BUSINESS_CONCEPT.length}</span>
{advanceMode === 'top_n' ? (
<>
{/* Top N for STARTUP */}
{localOrder.STARTUP.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Startups to advance</Label>
<Input
type="number"
min={0}
max={localOrder.STARTUP.length}
value={topNStartup}
onChange={(e) =>
setTopNStartup(
Math.max(0, Math.min(localOrder.STARTUP.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.STARTUP.length}</span>
</div>
)}
{/* Top N for BUSINESS_CONCEPT */}
{localOrder.BUSINESS_CONCEPT.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Concepts to advance</Label>
<Input
type="number"
min={0}
max={localOrder.BUSINESS_CONCEPT.length}
value={topNConceptual}
onChange={(e) =>
setTopNConceptual(
Math.max(0, Math.min(localOrder.BUSINESS_CONCEPT.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.BUSINESS_CONCEPT.length}</span>
</div>
)}
</>
) : (
<div className="space-y-3">
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Minimum avg score</Label>
<Input
type="number"
min={1}
max={10}
step={0.1}
value={scoreThreshold}
onChange={(e) => setScoreThreshold(Math.max(0, Math.min(10, parseFloat(e.target.value) || 5)))}
className="w-24"
/>
<span className="text-xs text-muted-foreground">out of 10</span>
</div>
<p className="text-xs text-muted-foreground">
All projects with an average global score at or above this threshold will advance, regardless of category.
</p>
</div>
)}
@@ -709,16 +966,25 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
</div>
{/* Preview */}
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-3">
<p>Advancing: {topNStartup + topNConceptual} projects</p>
{includeReject && (
<p>
Rejecting:{' '}
{localOrder.STARTUP.length - topNStartup + (localOrder.BUSINESS_CONCEPT.length - topNConceptual)}{' '}
projects
</p>
)}
</div>
{(() => {
const advCount = advanceMode === 'top_n'
? topNStartup + topNConceptual
: thresholdAdvanceIds.ids.length
const totalProjects = localOrder.STARTUP.length + localOrder.BUSINESS_CONCEPT.length
return (
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-3">
<p>
Advancing: {advCount} project{advCount !== 1 ? 's' : ''}
{advanceMode === 'threshold' && (
<> ({thresholdAdvanceIds.startupCount} startups, {thresholdAdvanceIds.conceptCount} concepts)</>
)}
</p>
{includeReject && (
<p>Rejecting: {totalProjects - advCount} project{totalProjects - advCount !== 1 ? 's' : ''}</p>
)}
</div>
)
})()}
</div>
<DialogFooter>
@@ -734,7 +1000,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
disabled={
advanceMutation.isPending ||
batchRejectMutation.isPending ||
topNStartup + topNConceptual === 0
(advanceMode === 'top_n' ? topNStartup + topNConceptual === 0 : thresholdAdvanceIds.ids.length === 0)
}
className="bg-[#053d57] hover:bg-[#053d57]/90"
>
@@ -743,7 +1009,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...
</>
) : (
`Advance ${topNStartup + topNConceptual} Project${topNStartup + topNConceptual !== 1 ? 's' : ''}`
`Advance ${advanceMode === 'top_n' ? topNStartup + topNConceptual : thresholdAdvanceIds.ids.length} Project${(advanceMode === 'top_n' ? topNStartup + topNConceptual : thresholdAdvanceIds.ids.length) !== 1 ? 's' : ''}`
)}
</Button>
</DialogFooter>