From 2bccb52a1678d4c280effd54cffbce48ef1154c6 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 2 Mar 2026 19:34:31 +0100 Subject: [PATCH] fix: ranking sorted by composite score, deduplicate AI results, single cutoff line - Sort all ranked projects by compositeScore descending so highest-rated projects always appear first (instead of relying on AI's inconsistent rank order) - Deduplicate AI ranking response (AI sometimes returns same project multiple times) - Deduplicate ranking entries and reorder IDs on dashboard load as defensive measure - Show advancement cutoff line only once (precompute last advancing index) - Override badge only shown when admin has actually drag-reordered (not on fresh rankings) Co-Authored-By: Claude Opus 4.6 --- .../admin/round/ranking-dashboard.tsx | 83 ++++++++++++++----- src/server/services/ai-ranking.ts | 18 +++- 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/src/components/admin/round/ranking-dashboard.tsx b/src/components/admin/round/ranking-dashboard.tsx index 58893e3..5421123 100644 --- a/src/components/admin/round/ranking-dashboard.tsx +++ b/src/components/admin/round/ranking-dashboard.tsx @@ -407,10 +407,22 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[] const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[] + // Deduplicate ranking entries (AI may return duplicates) — keep first occurrence + const dedup = (arr: RankedProjectEntry[]): RankedProjectEntry[] => { + const seen = new Set() + return arr.filter((r) => { + if (seen.has(r.projectId)) return false + seen.add(r.projectId) + return true + }) + } + const dedupedStartup = dedup(startup) + const dedupedConcept = dedup(concept) + // Track original AI order for override detection (same effect = always in sync) const order: Record = {} - startup.forEach((r, i) => { order[r.projectId] = i + 1 }) - concept.forEach((r, i) => { order[r.projectId] = i + 1 }) + dedupedStartup.forEach((r, i) => { order[r.projectId] = i + 1 }) + dedupedConcept.forEach((r, i) => { order[r.projectId] = i + 1 }) setSnapshotOrder(order) // Apply saved reorders so the ranking persists across all admin sessions. @@ -423,9 +435,25 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran const latestStartupReorder = [...reorders].reverse().find((r) => r.category === 'STARTUP') const latestConceptReorder = [...reorders].reverse().find((r) => r.category === 'BUSINESS_CONCEPT') + // Deduplicate reorder IDs too, and filter out IDs not in the current snapshot + const dedupIds = (ids: string[], validSet: Set): string[] => { + const seen = new Set() + return ids.filter((id) => { + if (seen.has(id) || !validSet.has(id)) return false + seen.add(id) + return true + }) + } + const startupIdSet = new Set(dedupedStartup.map((r) => r.projectId)) + const conceptIdSet = new Set(dedupedConcept.map((r) => r.projectId)) + setLocalOrder({ - STARTUP: latestStartupReorder?.orderedProjectIds ?? startup.map((r) => r.projectId), - BUSINESS_CONCEPT: latestConceptReorder?.orderedProjectIds ?? concept.map((r) => r.projectId), + STARTUP: latestStartupReorder + ? dedupIds(latestStartupReorder.orderedProjectIds, startupIdSet) + : dedupedStartup.map((r) => r.projectId), + BUSINESS_CONCEPT: latestConceptReorder + ? dedupIds(latestConceptReorder.orderedProjectIds, conceptIdSet) + : dedupedConcept.map((r) => r.projectId), }) initialized.current = true @@ -828,7 +856,33 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran

No {category === 'STARTUP' ? 'startup' : 'business concept'} projects ranked.

- ) : ( + ) : (() => { + // Precompute cutoff index so it only shows ONCE + const isThresholdMode = evalConfig?.advanceMode === 'threshold' && evalConfig.advanceScoreThreshold != null + const advanceCount = isThresholdMode ? 0 : (category === 'STARTUP' + ? (evalConfig?.startupAdvanceCount ?? 0) + : (evalConfig?.conceptAdvanceCount ?? 0)) + const threshold = evalConfig?.advanceScoreThreshold ?? 0 + + let cutoffIndex = -1 + if (isThresholdMode) { + // Find the LAST index in the list where the project meets the threshold + for (let i = localOrder[category].length - 1; i >= 0; i--) { + const e = rankingMap.get(localOrder[category][i]) + if ((e?.avgGlobalScore ?? 0) >= threshold) { cutoffIndex = i; break } + } + } else if (advanceCount > 0) { + cutoffIndex = advanceCount - 1 + } + + // Check if admin has reordered this category + const reorders = (snapshot?.reordersJson as Array<{ + category: 'STARTUP' | 'BUSINESS_CONCEPT' + orderedProjectIds: string[] + }> | null) ?? [] + const hasReorders = reorders.some((r) => r.category === category) + + return (
{localOrder[category].map((projectId, index) => { - const isThresholdMode = evalConfig?.advanceMode === 'threshold' && evalConfig.advanceScoreThreshold != null - const advanceCount = isThresholdMode ? 0 : (category === 'STARTUP' - ? (evalConfig?.startupAdvanceCount ?? 0) - : (evalConfig?.conceptAdvanceCount ?? 0)) - // In threshold mode, check if this project's avg score meets the threshold const entry = rankingMap.get(projectId) const projectAvg = entry?.avgGlobalScore ?? 0 - const threshold = evalConfig?.advanceScoreThreshold ?? 0 const isAdvancing = isThresholdMode ? projectAvg >= threshold : (advanceCount > 0 && index < advanceCount) - // Show cutoff line: in threshold mode, after last project above threshold - const nextProjectId = localOrder[category][index + 1] - const nextEntry = nextProjectId ? rankingMap.get(nextProjectId) : null - const nextAvg = nextEntry?.avgGlobalScore ?? 0 - const isCutoffRow = isThresholdMode - ? (projectAvg >= threshold && nextAvg < threshold) - : (advanceCount > 0 && index === advanceCount - 1) + const isCutoffRow = cutoffIndex >= 0 && index === cutoffIndex return ( @@ -877,7 +919,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran jurorScores={evalScores?.[projectId]} onSelect={() => setSelectedProjectId(projectId)} isSelected={selectedProjectId === projectId} - originalRank={snapshotOrder[projectId]} + originalRank={hasReorders ? snapshotOrder[projectId] : undefined} /> {isCutoffRow && ( @@ -896,7 +938,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran - )} + ) + })()} ))} diff --git a/src/server/services/ai-ranking.ts b/src/server/services/ai-ranking.ts index fe2540f..00008bc 100644 --- a/src/server/services/ai-ranking.ts +++ b/src/server/services/ai-ranking.ts @@ -510,6 +510,14 @@ export async function executeAIRanking( 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))!]) @@ -548,13 +556,17 @@ export async function executeAIRanking( })) .sort((a, b) => b.compositeScore - a.compositeScore) - let nextRank = rankedProjects.length + 1 for (const proj of unrankedProjects) { - proj.rank = nextRank++ rankedProjects.push(proj) } - // Re-normalize ranks to be contiguous (1, 2, 3, …) + // Sort ALL projects by compositeScore descending (deterministic, score-based order). + // The AI's rank is advisory — the computed composite score (which already incorporates + // weighted criteria, z-score normalization, pass rate, and evaluator count) is the + // authoritative sort key so that 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 {