Files
MOPC-Portal/src/server/services/ai-ranking.ts

775 lines
28 KiB
TypeScript
Raw Normal View History

/**
* 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)
* - compositeScore uses weighted criteria when available, falls back to avgGlobalScore
* - Z-score normalization corrects for juror grading bias
*/
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'
import type { EvaluationConfig } from '@/types/competition-configs'
// ─── Types ────────────────────────────────────────────────────────────────────
// Criterion definition from EvaluationForm.criteriaJson
interface CriterionDef {
id: string
label: string
type?: string
scale?: number | string
weight?: number
}
// Internal shape of a project before anonymization
interface ProjectForRanking {
id: string
competitionCategory: CompetitionCategory
avgGlobalScore: number | null // average of submitted Evaluation.globalScore
normalizedAvgScore: number | null // z-score normalized average
passRate: number // proportion of binaryDecision=true among SUBMITTED evaluations
evaluatorCount: number // count of SUBMITTED evaluations
criterionAverages: Record<string, number> // criterionId → raw average score
normalizedCriterionAverages: Record<string, number> // criterionId → z-score normalized average
}
// Anonymized shape sent to OpenAI
interface AnonymizedProjectForRanking {
project_id: string // "P001", "P002", etc. — never real IDs
avg_score: number | null
normalized_avg_score: number | null
pass_rate: number // 01
evaluator_count: number
category: string
criteria_scores: Record<string, number>
normalized_criteria_scores: Record<string, number>
}
// Criterion definition sent to OpenAI
interface CriterionDefForAI {
name: string
weight: number
scale: string
}
// 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 // 01 floating point
avgGlobalScore: number | null
normalizedAvgScore: number | null
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 (110 scale, null if not scored)
- normalized_avg_score: bias-corrected average (z-score normalized across jurors)
- pass_rate: proportion of jury members who voted to advance the project (01)
- evaluator_count: number of jury members who submitted evaluations (tiebreak)
- criteria_scores: per-criterion averages (keyed by criterion name)
- normalized_criteria_scores: bias-corrected per-criterion averages
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.
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.
Apply the rules in order and return the final ranked list.
Return JSON only:
{
"ranked": [
{
"project_id": "P001",
"rank": 1,
"rationale": "Brief explanation"
}
]
}
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`
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Compute composite score using weighted criteria if available,
* falling back to avgGlobalScore otherwise.
*/
function computeCompositeScore(
project: ProjectForRanking,
maxEvaluatorCount: number,
criteriaWeights: Record<string, number> | undefined,
criterionDefs: CriterionDef[],
): number {
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 01 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 01
const avg = project.normalizedAvgScore ?? project.avgGlobalScore
scoreComponent = avg != null ? (avg - 1) / 9 : 0.5
}
const composite = scoreComponent * 0.5 + project.passRate * 0.5
// Tiebreak: tiny bonus for more evaluators (won't change rank unless composite is equal)
const tiebreakBonus = maxEvaluatorCount > 0
? (project.evaluatorCount / maxEvaluatorCount) * 0.0001
: 0
return composite + tiebreakBonus
}
function anonymizeProjectsForRanking(
projects: ProjectForRanking[],
criterionDefs: CriterionDef[],
): { anonymized: AnonymizedProjectForRanking[]; idMap: Map<string, string> } {
// Build id → label map for criterion names (anonymize IDs)
const idToLabel = new Map(criterionDefs.map((d) => [d.id, d.label]))
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)
// 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
}
return {
project_id: anonId,
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,
pass_rate: p.passRate,
evaluator_count: p.evaluatorCount,
category: p.competitionCategory,
criteria_scores: criteriaScores,
normalized_criteria_scores: normalizedCriteriaScores,
}
})
return { anonymized, idMap }
}
/**
* Find the boolean criterion ID for "Move to the Next Stage?" from criteria definitions.
* Accepts criteria from EvaluationForm.criteriaJson (preferred) or round configJson (legacy).
*/
function findBooleanCriterionId(criterionDefs: Array<{ id: string; label: string; type?: string }>): string | null {
const boolCriterion = criterionDefs.find(
(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
}
/**
* Compute pass rate from Evaluation records.
* Counts evaluations where the advance decision resolved to true.
* Evaluations with null decision are treated as "no" (not counted as pass).
*/
function computePassRate(evaluations: Array<{ resolvedDecision: boolean | null }>): number {
if (evaluations.length === 0) return 0
const passCount = evaluations.filter((e) => e.resolvedDecision === true).length
return passCount / evaluations.length
}
// ─── 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
}
// ─── 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()
* criteriaWeights: optional admin-configured weights from round config
* criterionDefs: criterion definitions from the evaluation form
*/
export async function executeAIRanking(
parsedRules: ParsedRankingRule[],
projects: ProjectForRanking[],
category: CompetitionCategory,
criteriaWeights: Record<string, number> | undefined,
criterionDefs: CriterionDef[],
userId?: string,
entityId?: string,
): Promise<RankingResult> {
if (projects.length === 0) {
return { category, rankedProjects: [], parsedRules, totalEligible: 0 }
}
const maxEvaluatorCount = Math.max(...projects.map((p) => p.evaluatorCount))
const { anonymized, idMap } = anonymizeProjectsForRanking(projects, criterionDefs)
const openai = await getOpenAI()
if (!openai) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'OpenAI not configured' })
}
const model = await getConfiguredModel()
// 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> = {
rules: parsedRules.filter((r) => r.dataAvailable),
projects: anonymized,
}
if (criteriaDefsForAI.length > 0) {
promptData.criteria_definitions = criteriaDefsForAI
}
const userPrompt = JSON.stringify(promptData)
const params = buildCompletionParams(model, {
messages: [
{ role: 'system', content: RANKING_SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
jsonMode: true,
temperature: 0,
// ~50 tokens per project entry; scale for large pools with generous buffer
maxTokens: Math.max(2000, projects.length * 80),
})
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' })
}
// 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
})
// 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,
compositeScore: computeCompositeScore(proj, maxEvaluatorCount, criteriaWeights, criterionDefs),
avgGlobalScore: proj.avgGlobalScore,
normalizedAvgScore: proj.normalizedAvgScore,
passRate: proj.passRate,
evaluatorCount: proj.evaluatorCount,
aiRationale: entry.rationale,
}
})
.sort((a, b) => a.rank - b.rank)
// ─── 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)
}
// Sort ALL projects by compositeScore descending (deterministic, score-based order).
// The compositeScore incorporates weighted criteria, z-score normalization, pass rate,
// and evaluator count — so highest-rated projects always appear first.
rankedProjects.sort((a, b) => b.compositeScore - a.compositeScore)
// Re-number ranks to be contiguous (1, 2, 3, …)
rankedProjects.forEach((p, i) => { p.rank = i + 1 })
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 }
}
/**
* Internal helper: fetch eligible projects for one category and execute ranking.
* Excluded: withdrawn projects and projects with zero submitted evaluations (locked decision).
*
* Fetches evaluation form criteria, computes per-criterion averages, applies z-score
* normalization to correct for juror bias, and passes weighted criteria to the AI.
*
* 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> {
// 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
// Parse evaluation config for criteria weights
const evalConfig = roundConfig as EvaluationConfig | null
const criteriaWeights = evalConfig?.criteriaWeights ?? undefined
// Parse criterion definitions from the evaluation form
const criterionDefs: CriterionDef[] = evalForm?.criteriaJson
? (evalForm.criteriaJson as unknown as CriterionDef[])
: []
const boolCriterionId = findBooleanCriterionId(criterionDefs)
const numericCriterionIds = new Set(
criterionDefs.filter((d) => !d.type || d.type === 'numeric').map((d) => d.id),
)
// 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: {
select: { globalScore: true, binaryDecision: true, criterionScoresJson: true },
},
project: {
select: { id: true, competitionCategory: true },
},
},
})
// 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[]>()
for (const a of assignments) {
if (!a.evaluation) continue
const resolved = resolveBinaryDecision(
a.evaluation.binaryDecision,
a.evaluation.criterionScoresJson as Record<string, unknown> | null,
boolCriterionId,
)
const list = byProject.get(a.project.id) ?? []
list.push({
globalScore: a.evaluation.globalScore,
resolvedDecision: resolved,
criterionScores: a.evaluation.criterionScoresJson as Record<string, unknown> | null,
userId: a.userId,
})
byProject.set(a.project.id, list)
}
// Build ProjectForRanking, excluding projects with zero submitted evaluations
const projects: ProjectForRanking[] = []
for (const [projectId, evals] of byProject.entries()) {
if (evals.length === 0) continue
// Raw avg global score
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
// 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
}
}
const passRate = computePassRate(evals)
projects.push({
id: projectId,
competitionCategory: category,
avgGlobalScore,
normalizedAvgScore,
passRate,
evaluatorCount: evals.length,
criterionAverages,
normalizedCriterionAverages,
})
}
return executeAIRanking(parsedRules, projects, category, criteriaWeights, criterionDefs, userId, roundId)
}