/** * 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 // criterionId → raw average score normalizedCriterionAverages: Record // 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 // 0–1 evaluator_count: number category: string criteria_scores: Record normalized_criteria_scores: Record } // 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 // 0–1 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 (1–10 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 (0–1) - 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": , "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 | undefined, criterionDefs: CriterionDef[], scoreWeight = 5, passRateWeight = 5, ): 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 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 } // 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 // 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 } { // Build id → label map for criterion names (anonymize IDs) const idToLabel = new Map(criterionDefs.map((d) => [d.id, d.label])) const idMap = new Map() 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 = {} 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 = {} 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 | 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 | null } | null }>, numericCriterionIds: Set, ): Map { // Collect all numeric scores per juror const jurorScores = new Map() 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() 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 { 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> 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 | undefined, criterionDefs: CriterionDef[], userId?: string, entityId?: string, scoreWeight = 5, passRateWeight = 5, ): Promise { 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 = { 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> 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() 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, scoreWeight, passRateWeight), 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 } } // Result of fetchCategoryProjects — shared data for both AI and formula ranking interface CategoryProjectData { projects: ProjectForRanking[] criteriaWeights: Record | undefined criterionDefs: CriterionDef[] scoreWeight: number passRateWeight: number } /** * 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. * * Used by both `fetchAndRankCategory` (AI path) and `formulaRankCategory` (formula path). */ async function fetchCategoryProjects( category: CompetitionCategory, roundId: string, prisma: PrismaClient, ): Promise { // 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 | null // Parse evaluation config for criteria weights and formula weights const evalConfig = roundConfig as EvaluationConfig | null const criteriaWeights = evalConfig?.criteriaWeights ?? undefined const scoreWeight = evalConfig?.scoreWeight ?? 5 const passRateWeight = evalConfig?.passRateWeight ?? 5 // 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 | null, } : null, })), numericCriterionIds, ) // Group by projectId, collect per-juror scores for aggregation type EvalData = { globalScore: number | null resolvedDecision: boolean | null criterionScores: Record | null userId: string } const byProject = new Map() for (const a of assignments) { if (!a.evaluation) continue const resolved = resolveBinaryDecision( a.evaluation.binaryDecision, a.evaluation.criterionScoresJson as Record | null, boolCriterionId, ) const list = byProject.get(a.project.id) ?? [] list.push({ globalScore: a.evaluation.globalScore, resolvedDecision: resolved, criterionScores: a.evaluation.criterionScoresJson as Record | 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 = {} 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 = {} 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 { 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 { 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), } }