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

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