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

@@ -172,6 +172,8 @@ function computeCompositeScore(
maxEvaluatorCount: number,
criteriaWeights: Record<string, number> | undefined,
criterionDefs: CriterionDef[],
scoreWeight = 5,
passRateWeight = 5,
): number {
let scoreComponent: number
@@ -201,7 +203,12 @@ function computeCompositeScore(
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)
const tiebreakBonus = maxEvaluatorCount > 0
? (project.evaluatorCount / maxEvaluatorCount) * 0.0001
@@ -432,6 +439,8 @@ export async function executeAIRanking(
criterionDefs: CriterionDef[],
userId?: string,
entityId?: string,
scoreWeight = 5,
passRateWeight = 5,
): Promise<RankingResult> {
if (projects.length === 0) {
return { category, rankedProjects: [], parsedRules, totalEligible: 0 }
@@ -531,7 +540,7 @@ export async function executeAIRanking(
return {
projectId: realId,
rank: entry.rank,
compositeScore: computeCompositeScore(proj, maxEvaluatorCount, criteriaWeights, criterionDefs),
compositeScore: computeCompositeScore(proj, maxEvaluatorCount, criteriaWeights, criterionDefs, scoreWeight, passRateWeight),
avgGlobalScore: proj.avgGlobalScore,
normalizedAvgScore: proj.normalizedAvgScore,
passRate: proj.passRate,
@@ -595,22 +604,27 @@ export async function quickRank(
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.
* Excluded: withdrawn projects and projects with zero submitted evaluations (locked decision).
* Shared data-gathering helper: fetch eligible projects for one category.
* 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
* 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.
* Used by both `fetchAndRankCategory` (AI path) and `formulaRankCategory` (formula path).
*/
export async function fetchAndRankCategory(
async function fetchCategoryProjects(
category: CompetitionCategory,
parsedRules: ParsedRankingRule[],
roundId: string,
prisma: PrismaClient,
userId?: string,
): Promise<RankingResult> {
): Promise<CategoryProjectData> {
// Fetch the round config and evaluation form in parallel
const [round, evalForm] = await Promise.all([
prisma.round.findUniqueOrThrow({
@@ -625,9 +639,11 @@ export async function fetchAndRankCategory(
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 criteriaWeights = evalConfig?.criteriaWeights ?? undefined
const scoreWeight = evalConfig?.scoreWeight ?? 5
const passRateWeight = evalConfig?.passRateWeight ?? 5
// Parse criterion definitions from the evaluation form
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),
}
}