2026-02-27 00:48:09 +01:00
/ * *
* AI Ranking Service
*
* Parses natural - language ranking criteria into structured rules and
* executes per - category project ranking using OpenAI .
*
* GDPR Compliance :
* - All project data is anonymized before AI processing ( P001 , P002 , … )
* - No personal identifiers or real project IDs in prompts or responses
*
* Design decisions :
* - Per - category processing ( STARTUP / BUSINESS_CONCEPT ) — two parallel AI calls
* - Projects with zero submitted evaluations are excluded ( not ranked last )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
* - compositeScore uses weighted criteria when available , falls back to avgGlobalScore
* - Z - score normalization corrects for juror grading bias
2026-02-27 00:48:09 +01:00
* /
import { getOpenAI , getConfiguredModel , buildCompletionParams } from '@/lib/openai'
import { logAIUsage , extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError , logAIError } from './ai-errors'
import { sanitizeUserInput } from '@/server/services/ai-prompt-guard'
import { TRPCError } from '@trpc/server'
import type { CompetitionCategory , PrismaClient } from '@prisma/client'
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
import type { EvaluationConfig } from '@/types/competition-configs'
2026-02-27 00:48:09 +01:00
// ─── Types ────────────────────────────────────────────────────────────────────
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
// Criterion definition from EvaluationForm.criteriaJson
interface CriterionDef {
id : string
label : string
type ? : string
scale? : number | string
weight? : number
}
2026-02-27 00:48:09 +01:00
// Internal shape of a project before anonymization
interface ProjectForRanking {
id : string
competitionCategory : CompetitionCategory
avgGlobalScore : number | null // average of submitted Evaluation.globalScore
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
normalizedAvgScore : number | null // z-score normalized average
2026-02-27 00:48:09 +01:00
passRate : number // proportion of binaryDecision=true among SUBMITTED evaluations
evaluatorCount : number // count of SUBMITTED evaluations
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
criterionAverages : Record < string , number > // criterionId → raw average score
normalizedCriterionAverages : Record < string , number > // criterionId → z-score normalized average
2026-02-27 00:48:09 +01:00
}
// Anonymized shape sent to OpenAI
interface AnonymizedProjectForRanking {
project_id : string // "P001", "P002", etc. — never real IDs
avg_score : number | null
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
normalized_avg_score : number | null
2026-02-27 00:48:09 +01:00
pass_rate : number // 0– 1
evaluator_count : number
category : string
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
criteria_scores : Record < string , number >
normalized_criteria_scores : Record < string , number >
}
// Criterion definition sent to OpenAI
interface CriterionDefForAI {
name : string
weight : number
scale : string
2026-02-27 00:48:09 +01:00
}
// A single parsed rule returned by the criteria parser
export interface ParsedRankingRule {
step : number
type : 'filter' | 'sort' | 'limit'
description : string // Human-readable rule text
field : 'pass_rate' | 'avg_score' | 'evaluator_count' | null
operator : 'gte' | 'lte' | 'eq' | 'top_n' | null
value : number | null
dataAvailable : boolean // false = rule references unavailable data; UI should warn
}
// A single project entry in the ranked output
export interface RankedProjectEntry {
projectId : string // Real project ID (de-anonymized)
rank : number // 1-indexed
compositeScore : number // 0– 1 floating point
avgGlobalScore : number | null
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
normalizedAvgScore : number | null
2026-02-27 00:48:09 +01:00
passRate : number
evaluatorCount : number
aiRationale? : string // Optional: AI explanation for this project's rank
}
// Full result for one category
export interface RankingResult {
category : CompetitionCategory
rankedProjects : RankedProjectEntry [ ]
parsedRules : ParsedRankingRule [ ]
totalEligible : number
}
// ─── System Prompts ────────────────────────────────────────────────────────────
const CRITERIA_PARSING_SYSTEM_PROMPT = ` You are a ranking criteria interpreter for an ocean conservation project competition (Monaco Ocean Protection Challenge).
Admin will describe how they want projects ranked in natural language . Parse this into structured rules .
Available data fields for ranking :
- avg_score : average jury evaluation score ( 1 – 10 scale , null if not scored )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
- normalized_avg_score : bias - corrected average ( z - score normalized across jurors )
2026-02-27 00:48:09 +01:00
- pass_rate : proportion of jury members who voted to advance the project ( 0 – 1 )
- evaluator_count : number of jury members who submitted evaluations ( tiebreak )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
- criteria_scores : per - criterion averages ( keyed by criterion name )
- normalized_criteria_scores : bias - corrected per - criterion averages
2026-02-27 00:48:09 +01:00
Return JSON only :
{
"rules" : [
{
"step" : 1 ,
"type" : "filter | sort | limit" ,
"description" : "Human-readable description of this rule" ,
"field" : "pass_rate | avg_score | evaluator_count | null" ,
"operator" : "gte | lte | eq | top_n | null" ,
"value" : < number or null > ,
"dataAvailable" : true
}
]
}
Set dataAvailable = false if the rule requires data not in the available fields list above . Do NOT invent new fields .
Rules with dataAvailable = false will be shown as warnings to the admin — still include them .
Order rules so filters come first , sorts next , limits last . `
const RANKING_SYSTEM_PROMPT = ` You are a project ranking engine for an ocean conservation competition.
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
You will receive :
1 . A list of anonymized projects with numeric scores ( including per - criterion averages and bias - corrected scores )
2 . A set of parsed ranking rules
3 . Optional : criteria_definitions with weights indicating the relative importance of each evaluation criterion
When criteria_definitions with weights are provided , use the weighted criteria scores as a PRIMARY ranking factor .
The weighted score is : sum ( criterion_avg * weight ) / sum ( weights ) .
Use normalized ( bias - corrected ) scores when available — they account for differences in juror grading harshness .
2026-02-27 00:48:09 +01:00
Apply the rules in order and return the final ranked list .
Return JSON only :
{
"ranked" : [
{
"project_id" : "P001" ,
"rank" : 1 ,
"rationale" : "Brief explanation"
}
]
}
2026-03-02 14:10:48 +01:00
CRITICAL Rules :
- You MUST include EVERY project in the ranked output — never exclude or filter out any project
- Apply sort rules to determine the ranking order
- If filter criteria exist , use them to inform ranking priority ( projects meeting all criteria rank higher , those failing criteria rank lower ) but still include ALL projects
- Ignore any limit rules — always return all projects
- Use the project_id values exactly as given — do not change them
- Ranks must be contiguous ( 1 , 2 , 3 , … ) with no gaps `
2026-02-27 00:48:09 +01:00
// ─── Helpers ──────────────────────────────────────────────────────────────────
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
/ * *
* Compute composite score using weighted criteria if available ,
* falling back to avgGlobalScore otherwise .
* /
2026-02-27 00:48:09 +01:00
function computeCompositeScore (
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
project : ProjectForRanking ,
2026-02-27 00:48:09 +01:00
maxEvaluatorCount : number ,
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
criteriaWeights : Record < string , number > | undefined ,
criterionDefs : CriterionDef [ ] ,
2026-03-02 20:24:17 +01:00
scoreWeight = 5 ,
passRateWeight = 5 ,
2026-02-27 00:48:09 +01:00
) : number {
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
let scoreComponent : number
// Try weighted criteria first
if ( criteriaWeights && Object . keys ( criteriaWeights ) . length > 0 ) {
let weightedSum = 0
let totalWeight = 0
for ( const [ criterionId , weight ] of Object . entries ( criteriaWeights ) ) {
if ( weight <= 0 ) continue
// Use normalized scores if available, otherwise raw
const score = project . normalizedCriterionAverages [ criterionId ]
? ? project . criterionAverages [ criterionId ]
if ( score == null ) continue
// Normalize to 0– 1 based on criterion scale
const def = criterionDefs . find ( ( d ) = > d . id === criterionId )
const maxScale = typeof def ? . scale === 'number' ? def . scale
: typeof def ? . scale === 'string' ? parseInt ( def . scale . split ( '-' ) . pop ( ) ? ? '10' , 10 )
: 10
const normalizedScore = maxScale > 1 ? ( score - 1 ) / ( maxScale - 1 ) : score
weightedSum += normalizedScore * weight
totalWeight += weight
}
scoreComponent = totalWeight > 0 ? weightedSum / totalWeight : 0.5
} else {
// Fallback: use avgGlobalScore normalized to 0– 1
const avg = project . normalizedAvgScore ? ? project . avgGlobalScore
scoreComponent = avg != null ? ( avg - 1 ) / 9 : 0.5
}
2026-03-02 20:24:17 +01:00
// Configurable score vs pass-rate weighting
const totalW = scoreWeight + passRateWeight
const sW = totalW > 0 ? scoreWeight / totalW : 0.5
const pW = totalW > 0 ? passRateWeight / totalW : 0.5
const composite = scoreComponent * sW + project . passRate * pW
2026-02-27 00:48:09 +01:00
// Tiebreak: tiny bonus for more evaluators (won't change rank unless composite is equal)
const tiebreakBonus = maxEvaluatorCount > 0
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
? ( project . evaluatorCount / maxEvaluatorCount ) * 0.0001
2026-02-27 00:48:09 +01:00
: 0
return composite + tiebreakBonus
}
function anonymizeProjectsForRanking (
projects : ProjectForRanking [ ] ,
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
criterionDefs : CriterionDef [ ] ,
2026-02-27 00:48:09 +01:00
) : { anonymized : AnonymizedProjectForRanking [ ] ; idMap : Map < string , string > } {
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
// Build id → label map for criterion names (anonymize IDs)
const idToLabel = new Map ( criterionDefs . map ( ( d ) = > [ d . id , d . label ] ) )
2026-02-27 00:48:09 +01:00
const idMap = new Map < string , string > ( )
const anonymized = projects . map ( ( p , i ) = > {
const anonId = ` P ${ String ( i + 1 ) . padStart ( 3 , '0' ) } `
idMap . set ( anonId , p . id )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
// Convert criterion ID keys to human-readable labels
const criteriaScores : Record < string , number > = { }
for ( const [ id , score ] of Object . entries ( p . criterionAverages ) ) {
const label = idToLabel . get ( id ) ? ? id
criteriaScores [ label ] = Math . round ( score * 100 ) / 100
}
const normalizedCriteriaScores : Record < string , number > = { }
for ( const [ id , score ] of Object . entries ( p . normalizedCriterionAverages ) ) {
const label = idToLabel . get ( id ) ? ? id
normalizedCriteriaScores [ label ] = Math . round ( score * 100 ) / 100
}
2026-02-27 00:48:09 +01:00
return {
project_id : anonId ,
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
avg_score : p.avgGlobalScore != null ? Math . round ( p . avgGlobalScore * 100 ) / 100 : null ,
normalized_avg_score : p.normalizedAvgScore != null ? Math . round ( p . normalizedAvgScore * 100 ) / 100 : null ,
2026-02-27 00:48:09 +01:00
pass_rate : p.passRate ,
evaluator_count : p.evaluatorCount ,
category : p.competitionCategory ,
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
criteria_scores : criteriaScores ,
normalized_criteria_scores : normalizedCriteriaScores ,
2026-02-27 00:48:09 +01:00
}
} )
return { anonymized , idMap }
}
2026-03-02 10:46:52 +01:00
/ * *
2026-03-02 12:48:08 +01:00
* Find the boolean criterion ID for "Move to the Next Stage?" from criteria definitions .
* Accepts criteria from EvaluationForm . criteriaJson ( preferred ) or round configJson ( legacy ) .
2026-03-02 10:46:52 +01:00
* /
2026-03-02 12:48:08 +01:00
function findBooleanCriterionId ( criterionDefs : Array < { id : string ; label : string ; type ? : string } > ) : string | null {
const boolCriterion = criterionDefs . find (
2026-03-02 10:46:52 +01:00
( c ) = > c . type === 'boolean' && c . label ? . toLowerCase ( ) . includes ( 'move to the next stage' ) ,
)
return boolCriterion ? . id ? ? null
}
/ * *
* Resolve the binary advance decision for an evaluation .
* 1 . Use binaryDecision column if set
* 2 . Fall back to the boolean criterion in criterionScoresJson
* /
function resolveBinaryDecision (
binaryDecision : boolean | null ,
criterionScoresJson : Record < string , unknown > | null ,
boolCriterionId : string | null ,
) : boolean | null {
if ( binaryDecision != null ) return binaryDecision
if ( ! boolCriterionId || ! criterionScoresJson ) return null
const value = criterionScoresJson [ boolCriterionId ]
if ( typeof value === 'boolean' ) return value
if ( value === 'true' ) return true
if ( value === 'false' ) return false
return null
}
2026-02-27 00:48:09 +01:00
/ * *
* Compute pass rate from Evaluation records .
2026-03-02 10:46:52 +01:00
* Counts evaluations where the advance decision resolved to true .
* Evaluations with null decision are treated as "no" ( not counted as pass ) .
2026-02-27 00:48:09 +01:00
* /
2026-03-02 10:46:52 +01:00
function computePassRate ( evaluations : Array < { resolvedDecision : boolean | null } > ) : number {
2026-02-27 00:48:09 +01:00
if ( evaluations . length === 0 ) return 0
2026-03-02 10:46:52 +01:00
const passCount = evaluations . filter ( ( e ) = > e . resolvedDecision === true ) . length
2026-02-27 00:48:09 +01:00
return passCount / evaluations . length
}
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
// ─── Z-Score Normalization ──────────────────────────────────────────────────
interface JurorStats {
mean : number
stddev : number
count : number
}
/ * *
* Compute per - juror grading statistics ( mean and stddev ) for z - score normalization .
* Only considers numeric criterion scores and globalScore from SUBMITTED evaluations .
* /
function computeJurorStats (
assignments : Array < {
userId : string
evaluation : {
globalScore : number | null
criterionScoresJson : Record < string , unknown > | null
} | null
} > ,
numericCriterionIds : Set < string > ,
) : Map < string , JurorStats > {
// Collect all numeric scores per juror
const jurorScores = new Map < string , number [ ] > ( )
for ( const a of assignments ) {
if ( ! a . evaluation ) continue
const scores : number [ ] = [ ]
if ( a . evaluation . globalScore != null ) scores . push ( a . evaluation . globalScore )
if ( a . evaluation . criterionScoresJson ) {
for ( const [ id , val ] of Object . entries ( a . evaluation . criterionScoresJson ) ) {
if ( numericCriterionIds . has ( id ) && typeof val === 'number' ) {
scores . push ( val )
}
}
}
const existing = jurorScores . get ( a . userId ) ? ? [ ]
existing . push ( . . . scores )
jurorScores . set ( a . userId , existing )
}
const stats = new Map < string , JurorStats > ( )
for ( const [ userId , scores ] of jurorScores . entries ( ) ) {
if ( scores . length < 2 ) {
// Not enough data for meaningful normalization — skip
stats . set ( userId , { mean : 0 , stddev : 0 , count : scores.length } )
continue
}
const mean = scores . reduce ( ( a , b ) = > a + b , 0 ) / scores . length
const variance = scores . reduce ( ( sum , s ) = > sum + ( s - mean ) * * 2 , 0 ) / scores . length
const stddev = Math . sqrt ( variance )
stats . set ( userId , { mean , stddev , count : scores.length } )
}
return stats
}
/ * *
* Normalize a raw score using z - score normalization .
* Returns the z - score , or null if normalization isn ' t possible ( too few evals or stddev = 0 ) .
* /
function zScoreNormalize ( raw : number , stats : JurorStats ) : number | null {
if ( stats . count < 2 || stats . stddev === 0 ) return null
return ( raw - stats . mean ) / stats . stddev
}
2026-02-27 00:48:09 +01:00
// ─── Exported Functions ───────────────────────────────────────────────────────
/ * *
* Parse natural - language ranking criteria into structured rules .
* Returns ParsedRankingRule [ ] for admin review ( preview mode — RANK - 02 , RANK - 03 ) .
* /
export async function parseRankingCriteria (
criteriaText : string ,
userId? : string ,
entityId? : string ,
) : Promise < ParsedRankingRule [ ] > {
const { sanitized : safeCriteria } = sanitizeUserInput ( criteriaText )
const openai = await getOpenAI ( )
if ( ! openai ) {
throw new TRPCError ( { code : 'PRECONDITION_FAILED' , message : 'OpenAI not configured' } )
}
const model = await getConfiguredModel ( )
const params = buildCompletionParams ( model , {
messages : [
{ role : 'system' , content : CRITERIA_PARSING_SYSTEM_PROMPT } ,
{ role : 'user' , content : safeCriteria } ,
] ,
jsonMode : true ,
temperature : 0.1 ,
maxTokens : 1000 ,
} )
let response : Awaited < ReturnType < typeof openai.chat.completions.create > >
try {
response = await openai . chat . completions . create ( params )
} catch ( error ) {
const classified = classifyAIError ( error )
logAIError ( 'Ranking' , 'parseRankingCriteria' , classified )
throw new TRPCError ( { code : 'INTERNAL_SERVER_ERROR' , message : classified.message } )
}
const usage = extractTokenUsage ( response )
await logAIUsage ( {
userId ,
action : 'RANKING' ,
entityType : 'Round' ,
entityId ,
model ,
promptTokens : usage.promptTokens ,
completionTokens : usage.completionTokens ,
totalTokens : usage.totalTokens ,
itemsProcessed : 1 ,
status : 'SUCCESS' ,
} )
const content = response . choices [ 0 ] ? . message ? . content
if ( ! content ) throw new TRPCError ( { code : 'INTERNAL_SERVER_ERROR' , message : 'Empty response from AI' } )
try {
const parsed = JSON . parse ( content ) as { rules : ParsedRankingRule [ ] }
return parsed . rules ? ? [ ]
} catch {
throw new TRPCError ( { code : 'INTERNAL_SERVER_ERROR' , message : 'Failed to parse AI response as JSON' } )
}
}
/ * *
* Execute AI ranking for one category using pre - parsed rules .
* Returns RankingResult with ranked project list ( RANK - 05 , RANK - 06 ) .
*
* projects : raw data queried from Prisma , already filtered to one category
* parsedRules : from parseRankingCriteria ( )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
* criteriaWeights : optional admin - configured weights from round config
* criterionDefs : criterion definitions from the evaluation form
2026-02-27 00:48:09 +01:00
* /
export async function executeAIRanking (
parsedRules : ParsedRankingRule [ ] ,
projects : ProjectForRanking [ ] ,
category : CompetitionCategory ,
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
criteriaWeights : Record < string , number > | undefined ,
criterionDefs : CriterionDef [ ] ,
2026-02-27 00:48:09 +01:00
userId? : string ,
entityId? : string ,
2026-03-02 20:24:17 +01:00
scoreWeight = 5 ,
passRateWeight = 5 ,
2026-02-27 00:48:09 +01:00
) : Promise < RankingResult > {
if ( projects . length === 0 ) {
return { category , rankedProjects : [ ] , parsedRules , totalEligible : 0 }
}
const maxEvaluatorCount = Math . max ( . . . projects . map ( ( p ) = > p . evaluatorCount ) )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
const { anonymized , idMap } = anonymizeProjectsForRanking ( projects , criterionDefs )
2026-02-27 00:48:09 +01:00
const openai = await getOpenAI ( )
if ( ! openai ) {
throw new TRPCError ( { code : 'PRECONDITION_FAILED' , message : 'OpenAI not configured' } )
}
const model = await getConfiguredModel ( )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
// Build criteria_definitions for the AI prompt (only numeric criteria)
const numericDefs = criterionDefs . filter ( ( d ) = > ! d . type || d . type === 'numeric' )
const criteriaDefsForAI : CriterionDefForAI [ ] = numericDefs . map ( ( d ) = > {
const adminWeight = criteriaWeights ? . [ d . id ] ? ? d . weight ? ? 1
const scale = typeof d . scale === 'number' ? ` 1- ${ d . scale } ` : typeof d . scale === 'string' ? d . scale : '1-10'
return { name : d.label , weight : adminWeight , scale }
} )
const promptData : Record < string , unknown > = {
2026-02-27 00:48:09 +01:00
rules : parsedRules.filter ( ( r ) = > r . dataAvailable ) ,
projects : anonymized ,
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
}
if ( criteriaDefsForAI . length > 0 ) {
promptData . criteria_definitions = criteriaDefsForAI
}
const userPrompt = JSON . stringify ( promptData )
2026-02-27 00:48:09 +01:00
const params = buildCompletionParams ( model , {
messages : [
{ role : 'system' , content : RANKING_SYSTEM_PROMPT } ,
{ role : 'user' , content : userPrompt } ,
] ,
jsonMode : true ,
temperature : 0 ,
2026-03-02 14:10:48 +01:00
// ~50 tokens per project entry; scale for large pools with generous buffer
maxTokens : Math.max ( 2000 , projects . length * 80 ) ,
2026-02-27 00:48:09 +01:00
} )
let response : Awaited < ReturnType < typeof openai.chat.completions.create > >
try {
response = await openai . chat . completions . create ( params )
} catch ( error ) {
const classified = classifyAIError ( error )
logAIError ( 'Ranking' , 'executeAIRanking' , classified )
throw new TRPCError ( { code : 'INTERNAL_SERVER_ERROR' , message : classified.message } )
}
const usage = extractTokenUsage ( response )
await logAIUsage ( {
userId ,
action : 'RANKING' ,
entityType : 'Round' ,
entityId ,
model ,
promptTokens : usage.promptTokens ,
completionTokens : usage.completionTokens ,
totalTokens : usage.totalTokens ,
itemsProcessed : projects.length ,
status : 'SUCCESS' ,
} )
const content = response . choices [ 0 ] ? . message ? . content
if ( ! content ) throw new TRPCError ( { code : 'INTERNAL_SERVER_ERROR' , message : 'Empty ranking response from AI' } )
let aiRanked : Array < { project_id : string ; rank : number ; rationale? : string } >
try {
const parsed = JSON . parse ( content ) as { ranked : typeof aiRanked }
aiRanked = parsed . ranked ? ? [ ]
} catch {
throw new TRPCError ( { code : 'INTERNAL_SERVER_ERROR' , message : 'Failed to parse ranking response as JSON' } )
}
2026-03-02 19:34:31 +01:00
// Deduplicate AI response — keep only the first occurrence of each project_id
const seenAnonIds = new Set < string > ( )
aiRanked = aiRanked . filter ( ( entry ) = > {
if ( seenAnonIds . has ( entry . project_id ) ) return false
seenAnonIds . add ( entry . project_id )
return true
} )
2026-02-27 00:48:09 +01:00
// Build a lookup by anonymousId for project data
const projectByAnonId = new Map (
anonymized . map ( ( a ) = > [ a . project_id , projects . find ( ( p ) = > p . id === idMap . get ( a . project_id ) ) ! ] )
)
const rankedProjects : RankedProjectEntry [ ] = aiRanked
. filter ( ( entry ) = > idMap . has ( entry . project_id ) )
. map ( ( entry ) = > {
const realId = idMap . get ( entry . project_id ) !
const proj = projectByAnonId . get ( entry . project_id ) !
return {
projectId : realId ,
rank : entry.rank ,
2026-03-02 20:24:17 +01:00
compositeScore : computeCompositeScore ( proj , maxEvaluatorCount , criteriaWeights , criterionDefs , scoreWeight , passRateWeight ) ,
2026-02-27 00:48:09 +01:00
avgGlobalScore : proj.avgGlobalScore ,
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
normalizedAvgScore : proj.normalizedAvgScore ,
2026-02-27 00:48:09 +01:00
passRate : proj.passRate ,
evaluatorCount : proj.evaluatorCount ,
aiRationale : entry.rationale ,
}
} )
. sort ( ( a , b ) = > a . rank - b . rank )
2026-03-02 14:10:48 +01:00
// ─── Ensure ALL projects are included (AI may omit some due to token limits) ──
const rankedIds = new Set ( rankedProjects . map ( ( r ) = > r . projectId ) )
const unrankedProjects = projects
. filter ( ( p ) = > ! rankedIds . has ( p . id ) )
. map ( ( p ) = > ( {
projectId : p.id ,
rank : 0 ,
compositeScore : computeCompositeScore ( p , maxEvaluatorCount , criteriaWeights , criterionDefs ) ,
avgGlobalScore : p.avgGlobalScore ,
normalizedAvgScore : p.normalizedAvgScore ,
passRate : p.passRate ,
evaluatorCount : p.evaluatorCount ,
} ) )
. sort ( ( a , b ) = > b . compositeScore - a . compositeScore )
for ( const proj of unrankedProjects ) {
rankedProjects . push ( proj )
}
2026-03-02 19:34:31 +01:00
// Sort ALL projects by compositeScore descending (deterministic, score-based order).
2026-03-02 19:50:15 +01:00
// The compositeScore incorporates weighted criteria, z-score normalization, pass rate,
// and evaluator count — so highest-rated projects always appear first.
2026-03-02 19:34:31 +01:00
rankedProjects . sort ( ( a , b ) = > b . compositeScore - a . compositeScore )
// Re-number ranks to be contiguous (1, 2, 3, …)
2026-03-02 14:10:48 +01:00
rankedProjects . forEach ( ( p , i ) = > { p . rank = i + 1 } )
2026-02-27 00:48:09 +01:00
return {
category ,
rankedProjects ,
parsedRules ,
totalEligible : projects.length ,
}
}
/ * *
* Quick - rank : parse criteria and execute ranking in one step .
* Returns results for all categories ( RANK - 04 ) .
* The prisma parameter is used to fetch project evaluation data .
* /
export async function quickRank (
criteriaText : string ,
roundId : string ,
prisma : PrismaClient ,
userId? : string ,
) : Promise < { startup : RankingResult ; concept : RankingResult ; parsedRules : ParsedRankingRule [ ] } > {
const parsedRules = await parseRankingCriteria ( criteriaText , userId , roundId )
const [ startup , concept ] = await Promise . all ( [
fetchAndRankCategory ( 'STARTUP' , parsedRules , roundId , prisma , userId ) ,
fetchAndRankCategory ( 'BUSINESS_CONCEPT' , parsedRules , roundId , prisma , userId ) ,
] )
return { startup , concept , parsedRules }
}
2026-03-02 20:24:17 +01:00
// Result of fetchCategoryProjects — shared data for both AI and formula ranking
interface CategoryProjectData {
projects : ProjectForRanking [ ]
criteriaWeights : Record < string , number > | undefined
criterionDefs : CriterionDef [ ]
scoreWeight : number
passRateWeight : number
}
2026-02-27 00:48:09 +01:00
/ * *
2026-03-02 20:24:17 +01:00
* Shared data - gathering helper : fetch eligible projects for one category .
* Handles : round config + eval form loading , z - score normalization ,
* per - criterion averages , pass rates , etc .
2026-02-27 00:48:09 +01:00
*
2026-03-02 20:24:17 +01:00
* Used by both ` fetchAndRankCategory ` ( AI path ) and ` formulaRankCategory ` ( formula path ) .
2026-02-27 00:48:09 +01:00
* /
2026-03-02 20:24:17 +01:00
async function fetchCategoryProjects (
2026-02-27 00:48:09 +01:00
category : CompetitionCategory ,
roundId : string ,
prisma : PrismaClient ,
2026-03-02 20:24:17 +01:00
) : Promise < CategoryProjectData > {
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
// Fetch the round config and evaluation form in parallel
const [ round , evalForm ] = await Promise . all ( [
prisma . round . findUniqueOrThrow ( {
where : { id : roundId } ,
select : { configJson : true } ,
} ) ,
prisma . evaluationForm . findFirst ( {
where : { roundId , isActive : true } ,
select : { criteriaJson : true } ,
} ) ,
] )
const roundConfig = round . configJson as Record < string , unknown > | null
2026-03-02 20:24:17 +01:00
// Parse evaluation config for criteria weights and formula weights
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
const evalConfig = roundConfig as EvaluationConfig | null
const criteriaWeights = evalConfig ? . criteriaWeights ? ? undefined
2026-03-02 20:24:17 +01:00
const scoreWeight = evalConfig ? . scoreWeight ? ? 5
const passRateWeight = evalConfig ? . passRateWeight ? ? 5
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
// Parse criterion definitions from the evaluation form
const criterionDefs : CriterionDef [ ] = evalForm ? . criteriaJson
? ( evalForm . criteriaJson as unknown as CriterionDef [ ] )
: [ ]
2026-03-02 12:48:08 +01:00
const boolCriterionId = findBooleanCriterionId ( criterionDefs )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
const numericCriterionIds = new Set (
criterionDefs . filter ( ( d ) = > ! d . type || d . type === 'numeric' ) . map ( ( d ) = > d . id ) ,
)
2026-03-02 10:46:52 +01:00
2026-02-27 00:48:09 +01:00
// Query submitted evaluations grouped by projectId for this category
const assignments = await prisma . assignment . findMany ( {
where : {
roundId ,
isRequired : true ,
project : {
competitionCategory : category ,
// Exclude withdrawn projects
projectRoundStates : {
none : { roundId , state : 'WITHDRAWN' } ,
} ,
} ,
evaluation : {
status : 'SUBMITTED' , // Only count completed evaluations
} ,
} ,
include : {
evaluation : {
2026-03-02 10:46:52 +01:00
select : { globalScore : true , binaryDecision : true , criterionScoresJson : true } ,
2026-02-27 00:48:09 +01:00
} ,
project : {
select : { id : true , competitionCategory : true } ,
} ,
} ,
} )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
// Compute per-juror stats for z-score normalization
const jurorStats = computeJurorStats (
assignments . map ( ( a ) = > ( {
userId : a.userId ,
evaluation : a.evaluation ? {
globalScore : a.evaluation.globalScore ,
criterionScoresJson : a.evaluation.criterionScoresJson as Record < string , unknown > | null ,
} : null ,
} ) ) ,
numericCriterionIds ,
)
// Group by projectId, collect per-juror scores for aggregation
type EvalData = {
globalScore : number | null
resolvedDecision : boolean | null
criterionScores : Record < string , unknown > | null
userId : string
}
const byProject = new Map < string , EvalData [ ] > ( )
2026-02-27 00:48:09 +01:00
for ( const a of assignments ) {
if ( ! a . evaluation ) continue
2026-03-02 10:46:52 +01:00
const resolved = resolveBinaryDecision (
a . evaluation . binaryDecision ,
a . evaluation . criterionScoresJson as Record < string , unknown > | null ,
boolCriterionId ,
)
2026-02-27 00:48:09 +01:00
const list = byProject . get ( a . project . id ) ? ? [ ]
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
list . push ( {
globalScore : a.evaluation.globalScore ,
resolvedDecision : resolved ,
criterionScores : a.evaluation.criterionScoresJson as Record < string , unknown > | null ,
userId : a.userId ,
} )
2026-02-27 00:48:09 +01:00
byProject . set ( a . project . id , list )
}
// Build ProjectForRanking, excluding projects with zero submitted evaluations
const projects : ProjectForRanking [ ] = [ ]
for ( const [ projectId , evals ] of byProject . entries ( ) ) {
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
if ( evals . length === 0 ) continue
// Raw avg global score
2026-02-27 00:48:09 +01:00
const avgGlobalScore = evals . some ( ( e ) = > e . globalScore != null )
? evals . filter ( ( e ) = > e . globalScore != null ) . reduce ( ( sum , e ) = > sum + e . globalScore ! , 0 ) /
evals . filter ( ( e ) = > e . globalScore != null ) . length
: null
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
// Z-score normalized avg global score
const normalizedGlobalScores : number [ ] = [ ]
for ( const e of evals ) {
if ( e . globalScore == null ) continue
const stats = jurorStats . get ( e . userId )
if ( ! stats ) continue
const z = zScoreNormalize ( e . globalScore , stats )
if ( z != null ) normalizedGlobalScores . push ( z )
}
const normalizedAvgScore = normalizedGlobalScores . length > 0
? normalizedGlobalScores . reduce ( ( a , b ) = > a + b , 0 ) / normalizedGlobalScores . length
: null
// Per-criterion raw averages (numeric criteria only)
const criterionAverages : Record < string , number > = { }
for ( const criterionId of numericCriterionIds ) {
const values : number [ ] = [ ]
for ( const e of evals ) {
if ( ! e . criterionScores ) continue
const val = e . criterionScores [ criterionId ]
if ( typeof val === 'number' ) values . push ( val )
}
if ( values . length > 0 ) {
criterionAverages [ criterionId ] = values . reduce ( ( a , b ) = > a + b , 0 ) / values . length
}
}
// Per-criterion z-score normalized averages
const normalizedCriterionAverages : Record < string , number > = { }
for ( const criterionId of numericCriterionIds ) {
const zScores : number [ ] = [ ]
for ( const e of evals ) {
if ( ! e . criterionScores ) continue
const val = e . criterionScores [ criterionId ]
if ( typeof val !== 'number' ) continue
const stats = jurorStats . get ( e . userId )
if ( ! stats ) continue
const z = zScoreNormalize ( val , stats )
if ( z != null ) zScores . push ( z )
}
if ( zScores . length > 0 ) {
normalizedCriterionAverages [ criterionId ] = zScores . reduce ( ( a , b ) = > a + b , 0 ) / zScores . length
}
}
2026-02-27 00:48:09 +01:00
const passRate = computePassRate ( evals )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- 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>
2026-03-02 11:24:14 +01:00
projects . push ( {
id : projectId ,
competitionCategory : category ,
avgGlobalScore ,
normalizedAvgScore ,
passRate ,
evaluatorCount : evals.length ,
criterionAverages ,
normalizedCriterionAverages ,
} )
2026-02-27 00:48:09 +01:00
}
2026-03-02 20:24:17 +01:00
return { projects , criteriaWeights , criterionDefs , scoreWeight , passRateWeight }
}
/ * *
* Internal helper : fetch eligible projects for one category and execute AI ranking .
* Excluded : withdrawn projects and projects with zero submitted evaluations ( locked decision ) .
*
* Exported so the tRPC router can call it separately when executing pre - parsed rules .
* /
export async function fetchAndRankCategory (
category : CompetitionCategory ,
parsedRules : ParsedRankingRule [ ] ,
roundId : string ,
prisma : PrismaClient ,
userId? : string ,
) : Promise < RankingResult > {
const { projects , criteriaWeights , criterionDefs , scoreWeight , passRateWeight } =
await fetchCategoryProjects ( category , roundId , prisma )
return executeAIRanking (
parsedRules , projects , category , criteriaWeights , criterionDefs ,
userId , roundId , scoreWeight , passRateWeight ,
)
}
/ * *
* Formula - only ranking for one category — no LLM calls .
* Computes compositeScore for each project and sorts by score descending .
* /
function formulaRankCategory (
category : CompetitionCategory ,
data : CategoryProjectData ,
) : RankingResult {
const { projects , criteriaWeights , criterionDefs , scoreWeight , passRateWeight } = data
if ( projects . length === 0 ) {
return { category , rankedProjects : [ ] , parsedRules : [ ] , totalEligible : 0 }
}
const maxEvaluatorCount = Math . max ( . . . projects . map ( ( p ) = > p . evaluatorCount ) )
const rankedProjects : RankedProjectEntry [ ] = projects
. map ( ( p ) = > ( {
projectId : p.id ,
rank : 0 ,
compositeScore : computeCompositeScore (
p , maxEvaluatorCount , criteriaWeights , criterionDefs , scoreWeight , passRateWeight ,
) ,
avgGlobalScore : p.avgGlobalScore ,
normalizedAvgScore : p.normalizedAvgScore ,
passRate : p.passRate ,
evaluatorCount : p.evaluatorCount ,
} ) )
. sort ( ( a , b ) = > b . compositeScore - a . compositeScore )
// Assign contiguous ranks
rankedProjects . forEach ( ( p , i ) = > { p . rank = i + 1 } )
return { category , rankedProjects , parsedRules : [ ] , totalEligible : projects.length }
}
/ * *
* Formula - only ranking : rank all projects by configurable composite score ( no LLM ) .
* Uses scoreWeight / passRateWeight from round config + per - criterion weights .
* Returns results for both categories .
* /
export async function formulaRank (
roundId : string ,
prisma : PrismaClient ,
) : Promise < { startup : RankingResult ; concept : RankingResult } > {
const [ startupData , conceptData ] = await Promise . all ( [
fetchCategoryProjects ( 'STARTUP' , roundId , prisma ) ,
fetchCategoryProjects ( 'BUSINESS_CONCEPT' , roundId , prisma ) ,
] )
return {
startup : formulaRankCategory ( 'STARTUP' , startupData ) ,
concept : formulaRankCategory ( 'BUSINESS_CONCEPT' , conceptData ) ,
}
2026-02-27 00:48:09 +01:00
}