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
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:
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user