fix: ranking sorted by composite score, deduplicate AI results, single cutoff line
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m0s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 19:34:31 +01:00
parent 1f4f29c2cc
commit 2bccb52a16
2 changed files with 78 additions and 23 deletions

View File

@@ -407,10 +407,22 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[] const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[]
const concept = (snapshot.conceptRankingJson ?? []) 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<string>()
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) // Track original AI order for override detection (same effect = always in sync)
const order: Record<string, number> = {} const order: Record<string, number> = {}
startup.forEach((r, i) => { order[r.projectId] = i + 1 }) dedupedStartup.forEach((r, i) => { order[r.projectId] = i + 1 })
concept.forEach((r, i) => { order[r.projectId] = i + 1 }) dedupedConcept.forEach((r, i) => { order[r.projectId] = i + 1 })
setSnapshotOrder(order) setSnapshotOrder(order)
// Apply saved reorders so the ranking persists across all admin sessions. // 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 latestStartupReorder = [...reorders].reverse().find((r) => r.category === 'STARTUP')
const latestConceptReorder = [...reorders].reverse().find((r) => r.category === 'BUSINESS_CONCEPT') 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>): string[] => {
const seen = new Set<string>()
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({ setLocalOrder({
STARTUP: latestStartupReorder?.orderedProjectIds ?? startup.map((r) => r.projectId), STARTUP: latestStartupReorder
BUSINESS_CONCEPT: latestConceptReorder?.orderedProjectIds ?? concept.map((r) => r.projectId), ? dedupIds(latestStartupReorder.orderedProjectIds, startupIdSet)
: dedupedStartup.map((r) => r.projectId),
BUSINESS_CONCEPT: latestConceptReorder
? dedupIds(latestConceptReorder.orderedProjectIds, conceptIdSet)
: dedupedConcept.map((r) => r.projectId),
}) })
initialized.current = true initialized.current = true
@@ -828,7 +856,33 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No {category === 'STARTUP' ? 'startup' : 'business concept'} projects ranked. No {category === 'STARTUP' ? 'startup' : 'business concept'} projects ranked.
</p> </p>
) : ( ) : (() => {
// 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 (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@@ -841,24 +895,12 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
<div className="space-y-2"> <div className="space-y-2">
{localOrder[category].map((projectId, index) => { {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 entry = rankingMap.get(projectId)
const projectAvg = entry?.avgGlobalScore ?? 0 const projectAvg = entry?.avgGlobalScore ?? 0
const threshold = evalConfig?.advanceScoreThreshold ?? 0
const isAdvancing = isThresholdMode const isAdvancing = isThresholdMode
? projectAvg >= threshold ? projectAvg >= threshold
: (advanceCount > 0 && index < advanceCount) : (advanceCount > 0 && index < advanceCount)
// Show cutoff line: in threshold mode, after last project above threshold const isCutoffRow = cutoffIndex >= 0 && index === cutoffIndex
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)
return ( return (
<React.Fragment key={projectId}> <React.Fragment key={projectId}>
@@ -877,7 +919,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
jurorScores={evalScores?.[projectId]} jurorScores={evalScores?.[projectId]}
onSelect={() => setSelectedProjectId(projectId)} onSelect={() => setSelectedProjectId(projectId)}
isSelected={selectedProjectId === projectId} isSelected={selectedProjectId === projectId}
originalRank={snapshotOrder[projectId]} originalRank={hasReorders ? snapshotOrder[projectId] : undefined}
/> />
</motion.div> </motion.div>
{isCutoffRow && ( {isCutoffRow && (
@@ -896,7 +938,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
</AnimatePresence> </AnimatePresence>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
)} )
})()}
</CardContent> </CardContent>
</Card> </Card>
))} ))}

View File

@@ -510,6 +510,14 @@ export async function executeAIRanking(
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to parse ranking response as JSON' }) 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 // Build a lookup by anonymousId for project data
const projectByAnonId = new Map( const projectByAnonId = new Map(
anonymized.map((a) => [a.project_id, projects.find((p) => p.id === idMap.get(a.project_id))!]) 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) .sort((a, b) => b.compositeScore - a.compositeScore)
let nextRank = rankedProjects.length + 1
for (const proj of unrankedProjects) { for (const proj of unrankedProjects) {
proj.rank = nextRank++
rankedProjects.push(proj) 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 }) rankedProjects.forEach((p, i) => { p.rank = i + 1 })
return { return {