feat: formula-based ranking with optional AI, configurable score/pass-rate weights
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m56s

Add scoreWeight and passRateWeight (0-10) to evaluation config for
configurable composite score formula. When ranking criteria text is
empty, triggerAutoRank uses pure formula ranking (no LLM calls).
When criteria text is present, AI-assisted ranking runs as before.

- Add FORMULA to RankingMode enum with migration
- Extract fetchCategoryProjects helper, add formulaRank service
- Update computeCompositeScore to accept configurable weights
- Add score/pass-rate weight sliders to ranking dashboard UI
- Mode-aware button labels (Calculator/formula vs Sparkles/AI)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 20:24:17 +01:00
parent ac86e025e2
commit cb688ba3e6
6 changed files with 226 additions and 42 deletions

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "RankingMode" ADD VALUE 'FORMULA';

View File

@@ -1425,6 +1425,7 @@ enum RankingMode {
PREVIEW // Parsed rules shown to admin (not yet applied) PREVIEW // Parsed rules shown to admin (not yet applied)
CONFIRMED // Admin confirmed rules, ranking applied CONFIRMED // Admin confirmed rules, ranking applied
QUICK // Quick-rank: parse + apply without preview QUICK // Quick-rank: parse + apply without preview
FORMULA // Formula-only: no LLM, pure math ranking
} }
enum RankingSnapshotStatus { enum RankingSnapshotStatus {

View File

@@ -53,8 +53,10 @@ import {
import { import {
GripVertical, GripVertical,
BarChart3, BarChart3,
Calculator,
Loader2, Loader2,
RefreshCw, RefreshCw,
Sparkles,
Trophy, Trophy,
ExternalLink, ExternalLink,
ChevronDown, ChevronDown,
@@ -263,6 +265,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
const [weightsOpen, setWeightsOpen] = useState(false) const [weightsOpen, setWeightsOpen] = useState(false)
const [localWeights, setLocalWeights] = useState<Record<string, number>>({}) const [localWeights, setLocalWeights] = useState<Record<string, number>>({})
const [localCriteriaText, setLocalCriteriaText] = useState<string>('') const [localCriteriaText, setLocalCriteriaText] = useState<string>('')
const [localScoreWeight, setLocalScoreWeight] = useState(5)
const [localPassRateWeight, setLocalPassRateWeight] = useState(5)
const weightsInitialized = useRef(false) const weightsInitialized = useRef(false)
// ─── Sensors ────────────────────────────────────────────────────────────── // ─── Sensors ──────────────────────────────────────────────────────────────
@@ -476,6 +480,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
const saved = (cfg.criteriaWeights ?? {}) as Record<string, number> const saved = (cfg.criteriaWeights ?? {}) as Record<string, number>
setLocalWeights(saved) setLocalWeights(saved)
setLocalCriteriaText((cfg.rankingCriteria as string) ?? '') setLocalCriteriaText((cfg.rankingCriteria as string) ?? '')
setLocalScoreWeight((cfg.scoreWeight as number) ?? 5)
setLocalPassRateWeight((cfg.passRateWeight as number) ?? 5)
weightsInitialized.current = true weightsInitialized.current = true
} }
}, [roundData]) }, [roundData])
@@ -486,10 +492,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
const cfg = roundData.configJson as Record<string, unknown> const cfg = roundData.configJson as Record<string, unknown>
updateRoundMutation.mutate({ updateRoundMutation.mutate({
id: roundId, id: roundId,
configJson: { ...cfg, criteriaWeights: localWeights, rankingCriteria: localCriteriaText }, configJson: {
...cfg,
criteriaWeights: localWeights,
rankingCriteria: localCriteriaText,
scoreWeight: localScoreWeight,
passRateWeight: localPassRateWeight,
},
}) })
} }
// Derive ranking mode from criteria text
const isFormulaMode = !localCriteriaText.trim()
// ─── sync advance dialog defaults from config ──────────────────────────── // ─── sync advance dialog defaults from config ────────────────────────────
useEffect(() => { useEffect(() => {
if (evalConfig) { if (evalConfig) {
@@ -621,7 +636,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<> <>
<Loader2 className="h-10 w-10 text-blue-500 animate-spin" /> <Loader2 className="h-10 w-10 text-blue-500 animate-spin" />
<div> <div>
<p className="font-medium">AI ranking in progress&hellip;</p> <p className="font-medium">Ranking in progress&hellip;</p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
This may take a minute. You can continue working results will appear automatically. This may take a minute. You can continue working results will appear automatically.
</p> </p>
@@ -643,8 +658,12 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
onClick={() => triggerRankMutation.mutate({ roundId })} onClick={() => triggerRankMutation.mutate({ roundId })}
disabled={triggerRankMutation.isPending} disabled={triggerRankMutation.isPending}
> >
<RefreshCw className="mr-2 h-4 w-4" /> {isFormulaMode ? (
Run Ranking Now <Calculator className="mr-2 h-4 w-4" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{isFormulaMode ? 'Run Ranking Now' : 'Run AI Ranking Now'}
</Button> </Button>
</> </>
)} )}
@@ -714,10 +733,15 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Ranking&hellip; Ranking&hellip;
</> </>
) : isFormulaMode ? (
<>
<Calculator className="mr-2 h-4 w-4" />
Run Ranking
</>
) : ( ) : (
<> <>
<RefreshCw className="mr-2 h-4 w-4" /> <Sparkles className="mr-2 h-4 w-4" />
Run Ranking Run AI Ranking
</> </>
)} )}
</Button> </Button>
@@ -754,11 +778,48 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<CardContent className="space-y-5 pt-0"> <CardContent className="space-y-5 pt-0">
{/* Ranking criteria text */} {/* Score vs Pass Rate weights */}
<div className="space-y-2"> <div className="space-y-3">
<Label htmlFor="rankingCriteria">Ranking Criteria (natural language)</Label> <div>
<Label>Formula Weights</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Describe how projects should be ranked. The AI will parse this into rules. Control the balance between evaluation scores and yes/no pass rate in the composite ranking
</p>
</div>
<div className="space-y-3">
<div className="flex items-center gap-4">
<span className="text-sm w-40 flex-shrink-0">Score Weight</span>
<Slider
min={0}
max={10}
step={1}
value={[localScoreWeight]}
onValueChange={([v]) => setLocalScoreWeight(v)}
className="flex-1"
/>
<span className="text-sm font-mono w-6 text-right">{localScoreWeight}</span>
</div>
<div className="flex items-center gap-4">
<span className="text-sm w-40 flex-shrink-0">Pass Rate Weight</span>
<Slider
min={0}
max={10}
step={1}
value={[localPassRateWeight]}
onValueChange={([v]) => setLocalPassRateWeight(v)}
className="flex-1"
/>
<span className="text-sm font-mono w-6 text-right">{localPassRateWeight}</span>
</div>
</div>
</div>
{/* Ranking criteria text (optional — triggers AI mode) */}
<div className="space-y-2">
<Label htmlFor="rankingCriteria">AI Ranking Criteria (optional)</Label>
<p className="text-xs text-muted-foreground">
Optional: describe special ranking criteria for AI-assisted ranking.
Leave empty for formula-based ranking (faster, no AI cost).
</p> </p>
<Textarea <Textarea
id="rankingCriteria" id="rankingCriteria"
@@ -768,6 +829,15 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
onChange={(e) => setLocalCriteriaText(e.target.value)} onChange={(e) => setLocalCriteriaText(e.target.value)}
className="resize-y" className="resize-y"
/> />
{isFormulaMode ? (
<p className="text-xs text-emerald-600 flex items-center gap-1">
<Calculator className="h-3 w-3" /> Formula mode ranking uses weights only, no AI calls
</p>
) : (
<p className="text-xs text-amber-600 flex items-center gap-1">
<Sparkles className="h-3 w-3" /> AI mode criteria will be parsed and used for ranking (uses API credits)
</p>
)}
</div> </div>
{/* Per-criterion weights */} {/* Per-criterion weights */}
@@ -823,7 +893,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<Loader2 className="h-5 w-5 animate-spin text-blue-600 dark:text-blue-400 flex-shrink-0" /> <Loader2 className="h-5 w-5 animate-spin text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-blue-900 dark:text-blue-200"> <p className="text-sm font-medium text-blue-900 dark:text-blue-200">
AI ranking in progress&hellip; Ranking in progress&hellip;
</p> </p>
<p className="text-xs text-blue-700 dark:text-blue-400"> <p className="text-xs text-blue-700 dark:text-blue-400">
This may take a minute. You can continue working results will appear automatically. This may take a minute. You can continue working results will appear automatically.

View File

@@ -6,6 +6,7 @@ import {
parseRankingCriteria, parseRankingCriteria,
executeAIRanking, executeAIRanking,
quickRank as aiQuickRank, quickRank as aiQuickRank,
formulaRank,
fetchAndRankCategory, fetchAndRankCategory,
type ParsedRankingRule, type ParsedRankingRule,
} from '../services/ai-ranking' } from '../services/ai-ranking'
@@ -269,14 +270,8 @@ export const rankingRouter = router({
}) })
const config = (round.configJson as EvaluationConfig | null) ?? ({} as EvaluationConfig) const config = (round.configJson as EvaluationConfig | null) ?? ({} as EvaluationConfig)
const criteriaText = config?.rankingCriteria ?? null const criteriaText = config?.rankingCriteria?.trim() || null
const isFormulaMode = !criteriaText
if (!criteriaText) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No ranking criteria configured for this round. Add criteria in round settings first.',
})
}
// Create a RUNNING snapshot so all admins see the in-progress indicator // Create a RUNNING snapshot so all admins see the in-progress indicator
const snapshot = await ctx.prisma.rankingSnapshot.create({ const snapshot = await ctx.prisma.rankingSnapshot.create({
@@ -284,26 +279,46 @@ export const rankingRouter = router({
roundId, roundId,
triggeredById: ctx.user.id, triggeredById: ctx.user.id,
triggerType: 'MANUAL', triggerType: 'MANUAL',
criteriaText, criteriaText: criteriaText ?? '',
parsedRulesJson: {} as Prisma.InputJsonValue, parsedRulesJson: {} as Prisma.InputJsonValue,
mode: 'QUICK', mode: isFormulaMode ? 'FORMULA' : 'QUICK',
status: 'RUNNING', status: 'RUNNING',
}, },
}) })
try { try {
const result = await aiQuickRank(criteriaText, roundId, ctx.prisma, ctx.user.id) let startup: { rankedProjects: unknown[] }
let concept: { rankedProjects: unknown[] }
let parsedRulesWithWeights: Prisma.InputJsonValue
// Embed weights alongside rules for audit if (isFormulaMode) {
// Formula-only: no LLM, pure math ranking
const result = await formulaRank(roundId, ctx.prisma)
startup = result.startup
concept = result.concept
const criteriaWeights = config.criteriaWeights ?? undefined const criteriaWeights = config.criteriaWeights ?? undefined
const parsedRulesWithWeights = { rules: result.parsedRules, weights: criteriaWeights } as unknown as Prisma.InputJsonValue parsedRulesWithWeights = {
rules: [],
weights: criteriaWeights,
scoreWeight: config.scoreWeight ?? 5,
passRateWeight: config.passRateWeight ?? 5,
} as unknown as Prisma.InputJsonValue
} else {
// AI-assisted: parse criteria + rank with LLM
const result = await aiQuickRank(criteriaText, roundId, ctx.prisma, ctx.user.id)
startup = result.startup
concept = result.concept
const criteriaWeights = config.criteriaWeights ?? undefined
parsedRulesWithWeights = { rules: result.parsedRules, weights: criteriaWeights } as unknown as Prisma.InputJsonValue
}
await ctx.prisma.rankingSnapshot.update({ await ctx.prisma.rankingSnapshot.update({
where: { id: snapshot.id }, where: { id: snapshot.id },
data: { data: {
status: 'COMPLETED', status: 'COMPLETED',
parsedRulesJson: parsedRulesWithWeights, parsedRulesJson: parsedRulesWithWeights,
startupRankingJson: result.startup.rankedProjects as unknown as Prisma.InputJsonValue, startupRankingJson: startup.rankedProjects as unknown as Prisma.InputJsonValue,
conceptRankingJson: result.concept.rankedProjects as unknown as Prisma.InputJsonValue, conceptRankingJson: concept.rankedProjects as unknown as Prisma.InputJsonValue,
}, },
}) })
@@ -313,12 +328,12 @@ export const rankingRouter = router({
action: 'RANKING_MANUAL_TRIGGERED', action: 'RANKING_MANUAL_TRIGGERED',
entityType: 'RankingSnapshot', entityType: 'RankingSnapshot',
entityId: snapshot.id, entityId: snapshot.id,
detailsJson: { roundId }, detailsJson: { roundId, mode: isFormulaMode ? 'FORMULA' : 'AI' },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}) })
return { snapshotId: snapshot.id, startup: result.startup, concept: result.concept } return { snapshotId: snapshot.id, startup, concept }
} catch (err) { } catch (err) {
// Mark snapshot as FAILED so the indicator clears // Mark snapshot as FAILED so the indicator clears
await ctx.prisma.rankingSnapshot.update({ await ctx.prisma.rankingSnapshot.update({

View File

@@ -172,6 +172,8 @@ function computeCompositeScore(
maxEvaluatorCount: number, maxEvaluatorCount: number,
criteriaWeights: Record<string, number> | undefined, criteriaWeights: Record<string, number> | undefined,
criterionDefs: CriterionDef[], criterionDefs: CriterionDef[],
scoreWeight = 5,
passRateWeight = 5,
): number { ): number {
let scoreComponent: number let scoreComponent: number
@@ -201,7 +203,12 @@ function computeCompositeScore(
scoreComponent = avg != null ? (avg - 1) / 9 : 0.5 scoreComponent = avg != null ? (avg - 1) / 9 : 0.5
} }
const composite = scoreComponent * 0.5 + project.passRate * 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) // Tiebreak: tiny bonus for more evaluators (won't change rank unless composite is equal)
const tiebreakBonus = maxEvaluatorCount > 0 const tiebreakBonus = maxEvaluatorCount > 0
? (project.evaluatorCount / maxEvaluatorCount) * 0.0001 ? (project.evaluatorCount / maxEvaluatorCount) * 0.0001
@@ -432,6 +439,8 @@ export async function executeAIRanking(
criterionDefs: CriterionDef[], criterionDefs: CriterionDef[],
userId?: string, userId?: string,
entityId?: string, entityId?: string,
scoreWeight = 5,
passRateWeight = 5,
): Promise<RankingResult> { ): Promise<RankingResult> {
if (projects.length === 0) { if (projects.length === 0) {
return { category, rankedProjects: [], parsedRules, totalEligible: 0 } return { category, rankedProjects: [], parsedRules, totalEligible: 0 }
@@ -531,7 +540,7 @@ export async function executeAIRanking(
return { return {
projectId: realId, projectId: realId,
rank: entry.rank, rank: entry.rank,
compositeScore: computeCompositeScore(proj, maxEvaluatorCount, criteriaWeights, criterionDefs), compositeScore: computeCompositeScore(proj, maxEvaluatorCount, criteriaWeights, criterionDefs, scoreWeight, passRateWeight),
avgGlobalScore: proj.avgGlobalScore, avgGlobalScore: proj.avgGlobalScore,
normalizedAvgScore: proj.normalizedAvgScore, normalizedAvgScore: proj.normalizedAvgScore,
passRate: proj.passRate, passRate: proj.passRate,
@@ -595,22 +604,27 @@ export async function quickRank(
return { startup, concept, parsedRules } return { startup, concept, parsedRules }
} }
// Result of fetchCategoryProjects — shared data for both AI and formula ranking
interface CategoryProjectData {
projects: ProjectForRanking[]
criteriaWeights: Record<string, number> | undefined
criterionDefs: CriterionDef[]
scoreWeight: number
passRateWeight: number
}
/** /**
* Internal helper: fetch eligible projects for one category and execute ranking. * Shared data-gathering helper: fetch eligible projects for one category.
* Excluded: withdrawn projects and projects with zero submitted evaluations (locked decision). * Handles: round config + eval form loading, z-score normalization,
* per-criterion averages, pass rates, etc.
* *
* Fetches evaluation form criteria, computes per-criterion averages, applies z-score * Used by both `fetchAndRankCategory` (AI path) and `formulaRankCategory` (formula path).
* 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( async function fetchCategoryProjects(
category: CompetitionCategory, category: CompetitionCategory,
parsedRules: ParsedRankingRule[],
roundId: string, roundId: string,
prisma: PrismaClient, prisma: PrismaClient,
userId?: string, ): Promise<CategoryProjectData> {
): Promise<RankingResult> {
// Fetch the round config and evaluation form in parallel // Fetch the round config and evaluation form in parallel
const [round, evalForm] = await Promise.all([ const [round, evalForm] = await Promise.all([
prisma.round.findUniqueOrThrow({ prisma.round.findUniqueOrThrow({
@@ -625,9 +639,11 @@ export async function fetchAndRankCategory(
const roundConfig = round.configJson as Record<string, unknown> | null const roundConfig = round.configJson as Record<string, unknown> | null
// Parse evaluation config for criteria weights // Parse evaluation config for criteria weights and formula weights
const evalConfig = roundConfig as EvaluationConfig | null const evalConfig = roundConfig as EvaluationConfig | null
const criteriaWeights = evalConfig?.criteriaWeights ?? undefined const criteriaWeights = evalConfig?.criteriaWeights ?? undefined
const scoreWeight = evalConfig?.scoreWeight ?? 5
const passRateWeight = evalConfig?.passRateWeight ?? 5
// Parse criterion definitions from the evaluation form // Parse criterion definitions from the evaluation form
const criterionDefs: CriterionDef[] = evalForm?.criteriaJson const criterionDefs: CriterionDef[] = evalForm?.criteriaJson
@@ -770,5 +786,81 @@ export async function fetchAndRankCategory(
}) })
} }
return executeAIRanking(parsedRules, projects, category, criteriaWeights, criterionDefs, userId, roundId) 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<RankingResult> {
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),
}
} }

View File

@@ -147,6 +147,10 @@ export const EvaluationConfigSchema = z.object({
// Ranking (Phase 2) — per-criterion weights for AI ranking // Ranking (Phase 2) — per-criterion weights for AI ranking
criteriaWeights: z.record(z.string(), z.number().min(0).max(10)).optional(), criteriaWeights: z.record(z.string(), z.number().min(0).max(10)).optional(),
// Ranking (Phase 3) — composite score formula weights
scoreWeight: z.number().min(0).max(10).default(5),
passRateWeight: z.number().min(0).max(10).default(5),
}) })
export type EvaluationConfig = z.infer<typeof EvaluationConfigSchema> export type EvaluationConfig = z.infer<typeof EvaluationConfigSchema>