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

@@ -98,20 +98,36 @@ export const exportRouter = router({
}),
/**
* Export project scores summary
* Export project scores summary with per-criterion averages
*/
projectScores: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Fetch evaluation form to get criteria labels
const evalForm = await ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
select: { criteriaJson: true },
})
const criteria = (evalForm?.criteriaJson as Array<{
id: string; label: string; type?: string
}> | null) ?? []
const numericCriteria = criteria.filter((c) => !c.type || c.type === 'numeric')
const projects = await ctx.prisma.project.findMany({
where: {
assignments: { some: { roundId: input.roundId } },
},
include: {
assignments: {
where: { roundId: input.roundId },
include: {
evaluation: {
where: { status: 'SUBMITTED' },
select: {
globalScore: true,
binaryDecision: true,
criterionScoresJson: true,
},
},
},
},
@@ -132,9 +148,24 @@ export const exportRouter = router({
(e) => e?.binaryDecision === true
).length
// Per-criterion averages
const criterionAvgs: Record<string, string | null> = {}
for (const c of numericCriteria) {
const values: number[] = []
for (const e of evaluations) {
const scores = e?.criterionScoresJson as Record<string, number> | null
if (scores && typeof scores[c.id] === 'number') values.push(scores[c.id])
}
criterionAvgs[c.label] = values.length > 0
? (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2)
: null
}
return {
title: p.title,
teamName: p.teamName,
category: p.competitionCategory ?? '',
country: p.country ?? '',
status: p.status,
tags: p.tags.join(', '),
totalEvaluations: evaluations.length,
@@ -146,6 +177,7 @@ export const exportRouter = router({
: null,
minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
...criterionAvgs,
yesVotes,
noVotes: evaluations.length - yesVotes,
yesPercentage:
@@ -171,12 +203,15 @@ export const exportRouter = router({
columns: [
'title',
'teamName',
'category',
'country',
'status',
'tags',
'totalEvaluations',
'averageScore',
'minScore',
'maxScore',
...numericCriteria.map((c) => c.label),
'yesVotes',
'noVotes',
'yesPercentage',