From cb688ba3e631088e0c17ea84c14a5517277f3a6b Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 2 Mar 2026 20:24:17 +0100 Subject: [PATCH] feat: formula-based ranking with optional AI, configurable score/pass-rate weights 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 --- .../migration.sql | 2 + prisma/schema.prisma | 1 + .../admin/round/ranking-dashboard.tsx | 90 +++++++++++-- src/server/routers/ranking.ts | 51 +++++--- src/server/services/ai-ranking.ts | 120 ++++++++++++++++-- src/types/competition-configs.ts | 4 + 6 files changed, 226 insertions(+), 42 deletions(-) create mode 100644 prisma/migrations/20260302100000_add_formula_ranking_mode/migration.sql diff --git a/prisma/migrations/20260302100000_add_formula_ranking_mode/migration.sql b/prisma/migrations/20260302100000_add_formula_ranking_mode/migration.sql new file mode 100644 index 0000000..fd75fb4 --- /dev/null +++ b/prisma/migrations/20260302100000_add_formula_ranking_mode/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "RankingMode" ADD VALUE 'FORMULA'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 72ceb68..c4f4791 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1425,6 +1425,7 @@ enum RankingMode { PREVIEW // Parsed rules shown to admin (not yet applied) CONFIRMED // Admin confirmed rules, ranking applied QUICK // Quick-rank: parse + apply without preview + FORMULA // Formula-only: no LLM, pure math ranking } enum RankingSnapshotStatus { diff --git a/src/components/admin/round/ranking-dashboard.tsx b/src/components/admin/round/ranking-dashboard.tsx index 34ee06d..c835f0c 100644 --- a/src/components/admin/round/ranking-dashboard.tsx +++ b/src/components/admin/round/ranking-dashboard.tsx @@ -53,8 +53,10 @@ import { import { GripVertical, BarChart3, + Calculator, Loader2, RefreshCw, + Sparkles, Trophy, ExternalLink, ChevronDown, @@ -263,6 +265,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran const [weightsOpen, setWeightsOpen] = useState(false) const [localWeights, setLocalWeights] = useState>({}) const [localCriteriaText, setLocalCriteriaText] = useState('') + const [localScoreWeight, setLocalScoreWeight] = useState(5) + const [localPassRateWeight, setLocalPassRateWeight] = useState(5) const weightsInitialized = useRef(false) // ─── Sensors ────────────────────────────────────────────────────────────── @@ -476,6 +480,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran const saved = (cfg.criteriaWeights ?? {}) as Record setLocalWeights(saved) setLocalCriteriaText((cfg.rankingCriteria as string) ?? '') + setLocalScoreWeight((cfg.scoreWeight as number) ?? 5) + setLocalPassRateWeight((cfg.passRateWeight as number) ?? 5) weightsInitialized.current = true } }, [roundData]) @@ -486,10 +492,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran const cfg = roundData.configJson as Record updateRoundMutation.mutate({ 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 ──────────────────────────── useEffect(() => { if (evalConfig) { @@ -621,7 +636,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran <>
-

AI ranking in progress…

+

Ranking in progress…

This may take a minute. You can continue working — results will appear automatically.

@@ -643,8 +658,12 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran onClick={() => triggerRankMutation.mutate({ roundId })} disabled={triggerRankMutation.isPending} > - - Run Ranking Now + {isFormulaMode ? ( + + ) : ( + + )} + {isFormulaMode ? 'Run Ranking Now' : 'Run AI Ranking Now'} )} @@ -714,10 +733,15 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran Ranking… + ) : isFormulaMode ? ( + <> + + Run Ranking + ) : ( <> - - Run Ranking + + Run AI Ranking )} @@ -754,11 +778,48 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran - {/* Ranking criteria text */} + {/* Score vs Pass Rate weights */} +
+
+ +

+ Control the balance between evaluation scores and yes/no pass rate in the composite ranking +

+
+
+
+ Score Weight + setLocalScoreWeight(v)} + className="flex-1" + /> + {localScoreWeight} +
+
+ Pass Rate Weight + setLocalPassRateWeight(v)} + className="flex-1" + /> + {localPassRateWeight} +
+
+
+ + {/* Ranking criteria text (optional — triggers AI mode) */}
- +

- Describe how projects should be ranked. The AI will parse this into rules. + Optional: describe special ranking criteria for AI-assisted ranking. + Leave empty for formula-based ranking (faster, no AI cost).