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

@@ -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<Record<string, number>>({})
const [localCriteriaText, setLocalCriteriaText] = useState<string>('')
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<string, number>
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<string, unknown>
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
<>
<Loader2 className="h-10 w-10 text-blue-500 animate-spin" />
<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">
This may take a minute. You can continue working results will appear automatically.
</p>
@@ -643,8 +658,12 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
onClick={() => triggerRankMutation.mutate({ roundId })}
disabled={triggerRankMutation.isPending}
>
<RefreshCw className="mr-2 h-4 w-4" />
Run Ranking Now
{isFormulaMode ? (
<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>
</>
)}
@@ -714,10 +733,15 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Ranking&hellip;
</>
) : isFormulaMode ? (
<>
<Calculator className="mr-2 h-4 w-4" />
Run Ranking
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Run Ranking
<Sparkles className="mr-2 h-4 w-4" />
Run AI Ranking
</>
)}
</Button>
@@ -754,11 +778,48 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-5 pt-0">
{/* Ranking criteria text */}
{/* Score vs Pass Rate weights */}
<div className="space-y-3">
<div>
<Label>Formula Weights</Label>
<p className="text-xs text-muted-foreground">
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">Ranking Criteria (natural language)</Label>
<Label htmlFor="rankingCriteria">AI Ranking Criteria (optional)</Label>
<p className="text-xs text-muted-foreground">
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).
</p>
<Textarea
id="rankingCriteria"
@@ -768,6 +829,15 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
onChange={(e) => setLocalCriteriaText(e.target.value)}
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>
{/* 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" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-blue-900 dark:text-blue-200">
AI ranking in progress&hellip;
Ranking in progress&hellip;
</p>
<p className="text-xs text-blue-700 dark:text-blue-400">
This may take a minute. You can continue working results will appear automatically.