feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m16s

- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
  z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
  alongside existing top-N mode in advance dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 11:24:14 +01:00
parent c6ebd169dd
commit 19b58e4434
6 changed files with 674 additions and 107 deletions

View File

@@ -43,6 +43,13 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
GripVertical,
BarChart3,
@@ -50,6 +57,9 @@ import {
RefreshCw,
Trophy,
ExternalLink,
ChevronDown,
Settings2,
Download,
} from 'lucide-react'
import type { RankedProjectEntry } from '@/server/services/ai-ranking'
@@ -231,13 +241,24 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
// ─── Advance dialog state ─────────────────────────────────────────────────
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
const [advanceMode, setAdvanceMode] = useState<'top_n' | 'threshold'>('top_n')
const [topNStartup, setTopNStartup] = useState(3)
const [topNConceptual, setTopNConceptual] = useState(3)
const [scoreThreshold, setScoreThreshold] = useState(5)
const [includeReject, setIncludeReject] = useState(false)
// ─── Export state ──────────────────────────────────────────────────────────
const [exportLoading, setExportLoading] = useState(false)
// ─── Expandable review state ──────────────────────────────────────────────
const [expandedReviews, setExpandedReviews] = useState<Set<string>>(new Set())
// ─── Criteria weights state ────────────────────────────────────────────────
const [weightsOpen, setWeightsOpen] = useState(false)
const [localWeights, setLocalWeights] = useState<Record<string, number>>({})
const [localCriteriaText, setLocalCriteriaText] = useState<string>('')
const weightsInitialized = useRef(false)
// ─── Sensors ──────────────────────────────────────────────────────────────
const sensors = useSensors(
useSensor(PointerSensor),
@@ -273,6 +294,10 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
{ roundId },
)
const { data: evalForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId },
)
// ─── tRPC mutations ───────────────────────────────────────────────────────
const utils = trpc.useUtils()
@@ -283,6 +308,14 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
// Do NOT invalidate getSnapshot — would reset localOrder
})
const updateRoundMutation = trpc.round.update.useMutation({
onSuccess: () => {
toast.success('Ranking config saved')
void utils.round.getById.invalidate({ id: roundId })
},
onError: (err) => toast.error(`Failed to save: ${err.message}`),
})
const [rankingInProgress, setRankingInProgress] = useState(false)
const triggerRankMutation = trpc.ranking.triggerAutoRank.useMutation({
@@ -372,6 +405,34 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
}
}, [snapshot])
// ─── numericCriteria from eval form ─────────────────────────────────────
const numericCriteria = useMemo(() => {
if (!evalForm?.criteriaJson) return []
return (evalForm.criteriaJson as Array<{ id: string; label: string; type?: string; scale?: number | string }>)
.filter((c) => !c.type || c.type === 'numeric')
}, [evalForm])
// ─── Init local weights + criteriaText from round config ──────────────────
useEffect(() => {
if (!weightsInitialized.current && roundData?.configJson) {
const cfg = roundData.configJson as Record<string, unknown>
const saved = (cfg.criteriaWeights ?? {}) as Record<string, number>
setLocalWeights(saved)
setLocalCriteriaText((cfg.rankingCriteria as string) ?? '')
weightsInitialized.current = true
}
}, [roundData])
// ─── Save weights + criteria text to round config ─────────────────────────
const saveRankingConfig = () => {
if (!roundData?.configJson) return
const cfg = roundData.configJson as Record<string, unknown>
updateRoundMutation.mutate({
id: roundId,
configJson: { ...cfg, criteriaWeights: localWeights, rankingCriteria: localCriteriaText },
})
}
// ─── sync advance dialog defaults from config ────────────────────────────
useEffect(() => {
if (evalConfig) {
@@ -400,12 +461,36 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
})
}
// ─── Compute threshold-based project IDs ──────────────────────────────────
const thresholdAdvanceIds = useMemo(() => {
if (advanceMode !== 'threshold') return { ids: [] as string[], startupCount: 0, conceptCount: 0 }
const ids: string[] = []
let startupCount = 0
let conceptCount = 0
for (const cat of ['STARTUP', 'BUSINESS_CONCEPT'] as const) {
for (const projectId of localOrder[cat]) {
const entry = rankingMap.get(projectId)
if (entry?.avgGlobalScore != null && entry.avgGlobalScore >= scoreThreshold) {
ids.push(projectId)
if (cat === 'STARTUP') startupCount++
else conceptCount++
}
}
}
return { ids, startupCount, conceptCount }
}, [advanceMode, scoreThreshold, localOrder, rankingMap])
// ─── handleAdvance ────────────────────────────────────────────────────────
function handleAdvance() {
const advanceIds = [
...localOrder.STARTUP.slice(0, topNStartup),
...localOrder.BUSINESS_CONCEPT.slice(0, topNConceptual),
]
let advanceIds: string[]
if (advanceMode === 'threshold') {
advanceIds = thresholdAdvanceIds.ids
} else {
advanceIds = [
...localOrder.STARTUP.slice(0, topNStartup),
...localOrder.BUSINESS_CONCEPT.slice(0, topNConceptual),
]
}
const advanceSet = new Set(advanceIds)
advanceMutation.mutate({ roundId, projectIds: advanceIds })
@@ -420,6 +505,44 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
}
}
// ─── handleExport ──────────────────────────────────────────────────────────
async function handleExportScores() {
setExportLoading(true)
try {
const result = await utils.export.projectScores.fetch({ roundId })
if (!result.data || result.data.length === 0) {
toast.error('No data to export')
return
}
const headers = result.columns
const csvRows = [
headers.join(','),
...result.data.map((row: Record<string, unknown>) =>
headers.map((h: string) => {
const val = row[h]
if (val == null) return ''
const str = String(val)
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"`
: str
}).join(','),
),
]
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `round-scores-${roundId.slice(-8)}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success('CSV exported')
} catch (err) {
toast.error(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
setExportLoading(false)
}
}
// ─── Loading state ────────────────────────────────────────────────────────
if (snapshotsLoading || snapshotLoading) {
return (
@@ -510,6 +633,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
size="sm"
variant="outline"
onClick={handleExportScores}
disabled={exportLoading}
>
{exportLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Export CSV
</Button>
<Button
size="sm"
variant="outline"
@@ -544,6 +680,85 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
</CardHeader>
</Card>
{/* Ranking Configuration: criteria text + weights */}
<Collapsible open={weightsOpen} onOpenChange={setWeightsOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer flex flex-row items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<div>
<CardTitle className="text-base">Ranking Configuration</CardTitle>
<CardDescription className="mt-0.5">Criteria text, per-criterion weights, and bias correction</CardDescription>
</div>
</div>
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', weightsOpen && 'rotate-180')} />
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-5 pt-0">
{/* Ranking criteria text */}
<div className="space-y-2">
<Label htmlFor="rankingCriteria">Ranking Criteria (natural language)</Label>
<p className="text-xs text-muted-foreground">
Describe how projects should be ranked. The AI will parse this into rules.
</p>
<Textarea
id="rankingCriteria"
rows={3}
placeholder='E.g. "Prioritize innovation and ocean impact. Filter out projects with pass rate below 50%."'
value={localCriteriaText}
onChange={(e) => setLocalCriteriaText(e.target.value)}
className="resize-y"
/>
</div>
{/* Per-criterion weights */}
{numericCriteria.length > 0 && (
<div className="space-y-3">
<div>
<Label>Criteria Weights</Label>
<p className="text-xs text-muted-foreground">
Set relative importance of each evaluation criterion (0 = ignore, 10 = highest priority)
</p>
</div>
<div className="space-y-3">
{numericCriteria.map((c) => (
<div key={c.id} className="flex items-center gap-4">
<span className="text-sm w-40 truncate flex-shrink-0" title={c.label}>{c.label}</span>
<Slider
min={0}
max={10}
step={1}
value={[localWeights[c.id] ?? 1]}
onValueChange={([v]) => setLocalWeights((prev) => ({ ...prev, [c.id]: v }))}
className="flex-1"
/>
<span className="text-sm font-mono w-6 text-right">{localWeights[c.id] ?? 1}</span>
</div>
))}
</div>
</div>
)}
<div className="flex items-center gap-2 pt-2 border-t">
<Button
size="sm"
onClick={saveRankingConfig}
disabled={updateRoundMutation.isPending}
>
{updateRoundMutation.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
Save Configuration
</Button>
<p className="text-xs text-muted-foreground">
Weights are applied when ranking is run. Z-score normalization corrects for juror bias automatically.
</p>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
{/* Ranking in-progress banner */}
{rankingInProgress && (
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30">
@@ -642,55 +857,97 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
))}
</div>
{/* Advance Top N dialog */}
{/* Advance dialog */}
<Dialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Advance Top Projects</DialogTitle>
<DialogTitle>Advance Projects</DialogTitle>
<DialogDescription>
Select how many top-ranked projects to advance to the next round per category.
Projects are advanced in the order shown in the ranking list.
Choose how to select which projects advance to the next round.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Top N for STARTUP */}
{localOrder.STARTUP.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Startups to advance</Label>
<Input
type="number"
min={0}
max={localOrder.STARTUP.length}
value={topNStartup}
onChange={(e) =>
setTopNStartup(
Math.max(0, Math.min(localOrder.STARTUP.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.STARTUP.length}</span>
</div>
)}
{/* Mode toggle */}
<div className="flex gap-2">
<Button
size="sm"
variant={advanceMode === 'top_n' ? 'default' : 'outline'}
onClick={() => setAdvanceMode('top_n')}
className={advanceMode === 'top_n' ? 'bg-[#053d57] hover:bg-[#053d57]/90' : ''}
>
Top N per category
</Button>
<Button
size="sm"
variant={advanceMode === 'threshold' ? 'default' : 'outline'}
onClick={() => setAdvanceMode('threshold')}
className={advanceMode === 'threshold' ? 'bg-[#053d57] hover:bg-[#053d57]/90' : ''}
>
Score threshold
</Button>
</div>
{/* Top N for BUSINESS_CONCEPT */}
{localOrder.BUSINESS_CONCEPT.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Business concepts to advance</Label>
<Input
type="number"
min={0}
max={localOrder.BUSINESS_CONCEPT.length}
value={topNConceptual}
onChange={(e) =>
setTopNConceptual(
Math.max(0, Math.min(localOrder.BUSINESS_CONCEPT.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.BUSINESS_CONCEPT.length}</span>
{advanceMode === 'top_n' ? (
<>
{/* Top N for STARTUP */}
{localOrder.STARTUP.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Startups to advance</Label>
<Input
type="number"
min={0}
max={localOrder.STARTUP.length}
value={topNStartup}
onChange={(e) =>
setTopNStartup(
Math.max(0, Math.min(localOrder.STARTUP.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.STARTUP.length}</span>
</div>
)}
{/* Top N for BUSINESS_CONCEPT */}
{localOrder.BUSINESS_CONCEPT.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Concepts to advance</Label>
<Input
type="number"
min={0}
max={localOrder.BUSINESS_CONCEPT.length}
value={topNConceptual}
onChange={(e) =>
setTopNConceptual(
Math.max(0, Math.min(localOrder.BUSINESS_CONCEPT.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.BUSINESS_CONCEPT.length}</span>
</div>
)}
</>
) : (
<div className="space-y-3">
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Minimum avg score</Label>
<Input
type="number"
min={1}
max={10}
step={0.1}
value={scoreThreshold}
onChange={(e) => setScoreThreshold(Math.max(0, Math.min(10, parseFloat(e.target.value) || 5)))}
className="w-24"
/>
<span className="text-xs text-muted-foreground">out of 10</span>
</div>
<p className="text-xs text-muted-foreground">
All projects with an average global score at or above this threshold will advance, regardless of category.
</p>
</div>
)}
@@ -709,16 +966,25 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
</div>
{/* Preview */}
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-3">
<p>Advancing: {topNStartup + topNConceptual} projects</p>
{includeReject && (
<p>
Rejecting:{' '}
{localOrder.STARTUP.length - topNStartup + (localOrder.BUSINESS_CONCEPT.length - topNConceptual)}{' '}
projects
</p>
)}
</div>
{(() => {
const advCount = advanceMode === 'top_n'
? topNStartup + topNConceptual
: thresholdAdvanceIds.ids.length
const totalProjects = localOrder.STARTUP.length + localOrder.BUSINESS_CONCEPT.length
return (
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-3">
<p>
Advancing: {advCount} project{advCount !== 1 ? 's' : ''}
{advanceMode === 'threshold' && (
<> ({thresholdAdvanceIds.startupCount} startups, {thresholdAdvanceIds.conceptCount} concepts)</>
)}
</p>
{includeReject && (
<p>Rejecting: {totalProjects - advCount} project{totalProjects - advCount !== 1 ? 's' : ''}</p>
)}
</div>
)
})()}
</div>
<DialogFooter>
@@ -734,7 +1000,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
disabled={
advanceMutation.isPending ||
batchRejectMutation.isPending ||
topNStartup + topNConceptual === 0
(advanceMode === 'top_n' ? topNStartup + topNConceptual === 0 : thresholdAdvanceIds.ids.length === 0)
}
className="bg-[#053d57] hover:bg-[#053d57]/90"
>
@@ -743,7 +1009,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...
</>
) : (
`Advance ${topNStartup + topNConceptual} Project${topNStartup + topNConceptual !== 1 ? 's' : ''}`
`Advance ${advanceMode === 'top_n' ? topNStartup + topNConceptual : thresholdAdvanceIds.ids.length} Project${(advanceMode === 'top_n' ? topNStartup + topNConceptual : thresholdAdvanceIds.ids.length) !== 1 ? 's' : ''}`
)}
</Button>
</DialogFooter>

View File

@@ -4,7 +4,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
type EvaluationConfigProps = {
@@ -225,7 +224,7 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
<div className="flex items-center justify-between">
<div>
<Label htmlFor="autoRankEnabled">AI Ranking</Label>
<p className="text-xs text-muted-foreground">Rank projects using AI when all evaluations are complete</p>
<p className="text-xs text-muted-foreground">Rank projects using AI when all evaluations are complete. Configure ranking criteria and weights in the Ranking tab.</p>
</div>
<Switch
id="autoRankEnabled"
@@ -233,21 +232,6 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
onCheckedChange={(v) => update('autoRankEnabled', v)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rankingCriteria">Ranking Criteria</Label>
<p className="text-xs text-muted-foreground">
Natural-language criteria the AI uses to rank projects. E.g. &quot;Prioritize innovation and ocean impact. Weight jury scores 60%, feasibility 40%.&quot;
</p>
<Textarea
id="rankingCriteria"
rows={4}
placeholder="Describe how projects should be ranked..."
value={(config.rankingCriteria as string) ?? ''}
onChange={(e) => update('rankingCriteria', e.target.value)}
className="resize-y"
/>
</div>
</div>
</CardContent>
</Card>

View File

@@ -98,20 +98,36 @@ export const exportRouter = router({
}),
/**
* Export project scores summary
* Export project scores summary with per-criterion averages
*/
projectScores: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Fetch evaluation form to get criteria labels
const evalForm = await ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
select: { criteriaJson: true },
})
const criteria = (evalForm?.criteriaJson as Array<{
id: string; label: string; type?: string
}> | null) ?? []
const numericCriteria = criteria.filter((c) => !c.type || c.type === 'numeric')
const projects = await ctx.prisma.project.findMany({
where: {
assignments: { some: { roundId: input.roundId } },
},
include: {
assignments: {
where: { roundId: input.roundId },
include: {
evaluation: {
where: { status: 'SUBMITTED' },
select: {
globalScore: true,
binaryDecision: true,
criterionScoresJson: true,
},
},
},
},
@@ -132,9 +148,24 @@ export const exportRouter = router({
(e) => e?.binaryDecision === true
).length
// Per-criterion averages
const criterionAvgs: Record<string, string | null> = {}
for (const c of numericCriteria) {
const values: number[] = []
for (const e of evaluations) {
const scores = e?.criterionScoresJson as Record<string, number> | null
if (scores && typeof scores[c.id] === 'number') values.push(scores[c.id])
}
criterionAvgs[c.label] = values.length > 0
? (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2)
: null
}
return {
title: p.title,
teamName: p.teamName,
category: p.competitionCategory ?? '',
country: p.country ?? '',
status: p.status,
tags: p.tags.join(', '),
totalEvaluations: evaluations.length,
@@ -146,6 +177,7 @@ export const exportRouter = router({
: null,
minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
...criterionAvgs,
yesVotes,
noVotes: evaluations.length - yesVotes,
yesPercentage:
@@ -171,12 +203,15 @@ export const exportRouter = router({
columns: [
'title',
'teamName',
'category',
'country',
'status',
'tags',
'totalEvaluations',
'averageScore',
'minScore',
'maxScore',
...numericCriteria.map((c) => c.label),
'yesVotes',
'noVotes',
'yesPercentage',

View File

@@ -85,7 +85,16 @@ export const rankingRouter = router({
fetchAndRankCategory('BUSINESS_CONCEPT', rules, input.roundId, ctx.prisma, ctx.user.id),
])
// Persist snapshot
// Read criteria weights for snapshot audit trail
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { configJson: true },
})
const evalConfig = (round.configJson as EvaluationConfig | null) ?? ({} as EvaluationConfig)
const criteriaWeights = evalConfig.criteriaWeights ?? undefined
// Persist snapshot — embed weights alongside rules for audit
const parsedRulesWithWeights = { rules, weights: criteriaWeights } as unknown as Prisma.InputJsonValue
const snapshot = await ctx.prisma.rankingSnapshot.create({
data: {
roundId: input.roundId,
@@ -94,7 +103,7 @@ export const rankingRouter = router({
mode: 'CONFIRMED',
status: 'COMPLETED',
criteriaText: input.criteriaText,
parsedRulesJson: rules as unknown as Prisma.InputJsonValue,
parsedRulesJson: parsedRulesWithWeights,
startupRankingJson: startup.rankedProjects as unknown as Prisma.InputJsonValue,
conceptRankingJson: concept.rankedProjects as unknown as Prisma.InputJsonValue,
},
@@ -271,13 +280,16 @@ export const rankingRouter = router({
const result = await aiQuickRank(criteriaText, roundId, ctx.prisma, ctx.user.id)
// Embed weights alongside rules for audit
const criteriaWeights = config.criteriaWeights ?? undefined
const parsedRulesWithWeights = { rules: result.parsedRules, weights: criteriaWeights } as unknown as Prisma.InputJsonValue
const snapshot = await ctx.prisma.rankingSnapshot.create({
data: {
roundId,
triggeredById: ctx.user.id,
triggerType: 'MANUAL',
criteriaText,
parsedRulesJson: result.parsedRules as unknown as Prisma.InputJsonValue,
parsedRulesJson: parsedRulesWithWeights,
startupRankingJson: result.startup.rankedProjects as unknown as Prisma.InputJsonValue,
conceptRankingJson: result.concept.rankedProjects as unknown as Prisma.InputJsonValue,
mode: 'QUICK',

View File

@@ -11,7 +11,8 @@
* Design decisions:
* - Per-category processing (STARTUP / BUSINESS_CONCEPT) — two parallel AI calls
* - Projects with zero submitted evaluations are excluded (not ranked last)
* - compositeScore = 50% normalised avgGlobalScore + 50% passRate + tiny tiebreak
* - compositeScore uses weighted criteria when available, falls back to avgGlobalScore
* - Z-score normalization corrects for juror grading bias
*/
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
@@ -20,25 +21,48 @@ import { classifyAIError, logAIError } from './ai-errors'
import { sanitizeUserInput } from '@/server/services/ai-prompt-guard'
import { TRPCError } from '@trpc/server'
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
import type { EvaluationConfig } from '@/types/competition-configs'
// ─── Types ────────────────────────────────────────────────────────────────────
// Criterion definition from EvaluationForm.criteriaJson
interface CriterionDef {
id: string
label: string
type?: string
scale?: number | string
weight?: number
}
// Internal shape of a project before anonymization
interface ProjectForRanking {
id: string
competitionCategory: CompetitionCategory
avgGlobalScore: number | null // average of submitted Evaluation.globalScore
normalizedAvgScore: number | null // z-score normalized average
passRate: number // proportion of binaryDecision=true among SUBMITTED evaluations
evaluatorCount: number // count of SUBMITTED evaluations
criterionAverages: Record<string, number> // criterionId → raw average score
normalizedCriterionAverages: Record<string, number> // criterionId → z-score normalized average
}
// Anonymized shape sent to OpenAI
interface AnonymizedProjectForRanking {
project_id: string // "P001", "P002", etc. — never real IDs
avg_score: number | null
normalized_avg_score: number | null
pass_rate: number // 01
evaluator_count: number
category: string
criteria_scores: Record<string, number>
normalized_criteria_scores: Record<string, number>
}
// Criterion definition sent to OpenAI
interface CriterionDefForAI {
name: string
weight: number
scale: string
}
// A single parsed rule returned by the criteria parser
@@ -58,6 +82,7 @@ export interface RankedProjectEntry {
rank: number // 1-indexed
compositeScore: number // 01 floating point
avgGlobalScore: number | null
normalizedAvgScore: number | null
passRate: number
evaluatorCount: number
aiRationale?: string // Optional: AI explanation for this project's rank
@@ -79,8 +104,11 @@ Admin will describe how they want projects ranked in natural language. Parse thi
Available data fields for ranking:
- avg_score: average jury evaluation score (110 scale, null if not scored)
- normalized_avg_score: bias-corrected average (z-score normalized across jurors)
- pass_rate: proportion of jury members who voted to advance the project (01)
- evaluator_count: number of jury members who submitted evaluations (tiebreak)
- criteria_scores: per-criterion averages (keyed by criterion name)
- normalized_criteria_scores: bias-corrected per-criterion averages
Return JSON only:
{
@@ -103,7 +131,15 @@ Order rules so filters come first, sorts next, limits last.`
const RANKING_SYSTEM_PROMPT = `You are a project ranking engine for an ocean conservation competition.
You will receive a list of anonymized projects with numeric scores and a set of parsed ranking rules.
You will receive:
1. A list of anonymized projects with numeric scores (including per-criterion averages and bias-corrected scores)
2. A set of parsed ranking rules
3. Optional: criteria_definitions with weights indicating the relative importance of each evaluation criterion
When criteria_definitions with weights are provided, use the weighted criteria scores as a PRIMARY ranking factor.
The weighted score is: sum(criterion_avg * weight) / sum(weights).
Use normalized (bias-corrected) scores when available — they account for differences in juror grading harshness.
Apply the rules in order and return the final ranked list.
Return JSON only:
@@ -126,34 +162,85 @@ Rules:
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Compute composite score using weighted criteria if available,
* falling back to avgGlobalScore otherwise.
*/
function computeCompositeScore(
avgGlobalScore: number | null,
passRate: number,
evaluatorCount: number,
project: ProjectForRanking,
maxEvaluatorCount: number,
criteriaWeights: Record<string, number> | undefined,
criterionDefs: CriterionDef[],
): number {
const normalizedScore = avgGlobalScore != null ? (avgGlobalScore - 1) / 9 : 0.5
const composite = normalizedScore * 0.5 + passRate * 0.5
let scoreComponent: number
// Try weighted criteria first
if (criteriaWeights && Object.keys(criteriaWeights).length > 0) {
let weightedSum = 0
let totalWeight = 0
for (const [criterionId, weight] of Object.entries(criteriaWeights)) {
if (weight <= 0) continue
// Use normalized scores if available, otherwise raw
const score = project.normalizedCriterionAverages[criterionId]
?? project.criterionAverages[criterionId]
if (score == null) continue
// Normalize to 01 based on criterion scale
const def = criterionDefs.find((d) => d.id === criterionId)
const maxScale = typeof def?.scale === 'number' ? def.scale
: typeof def?.scale === 'string' ? parseInt(def.scale.split('-').pop() ?? '10', 10)
: 10
const normalizedScore = maxScale > 1 ? (score - 1) / (maxScale - 1) : score
weightedSum += normalizedScore * weight
totalWeight += weight
}
scoreComponent = totalWeight > 0 ? weightedSum / totalWeight : 0.5
} else {
// Fallback: use avgGlobalScore normalized to 01
const avg = project.normalizedAvgScore ?? project.avgGlobalScore
scoreComponent = avg != null ? (avg - 1) / 9 : 0.5
}
const composite = scoreComponent * 0.5 + project.passRate * 0.5
// Tiebreak: tiny bonus for more evaluators (won't change rank unless composite is equal)
const tiebreakBonus = maxEvaluatorCount > 0
? (evaluatorCount / maxEvaluatorCount) * 0.0001
? (project.evaluatorCount / maxEvaluatorCount) * 0.0001
: 0
return composite + tiebreakBonus
}
function anonymizeProjectsForRanking(
projects: ProjectForRanking[],
criterionDefs: CriterionDef[],
): { anonymized: AnonymizedProjectForRanking[]; idMap: Map<string, string> } {
// Build id → label map for criterion names (anonymize IDs)
const idToLabel = new Map(criterionDefs.map((d) => [d.id, d.label]))
const idMap = new Map<string, string>()
const anonymized = projects.map((p, i) => {
const anonId = `P${String(i + 1).padStart(3, '0')}`
idMap.set(anonId, p.id)
// Convert criterion ID keys to human-readable labels
const criteriaScores: Record<string, number> = {}
for (const [id, score] of Object.entries(p.criterionAverages)) {
const label = idToLabel.get(id) ?? id
criteriaScores[label] = Math.round(score * 100) / 100
}
const normalizedCriteriaScores: Record<string, number> = {}
for (const [id, score] of Object.entries(p.normalizedCriterionAverages)) {
const label = idToLabel.get(id) ?? id
normalizedCriteriaScores[label] = Math.round(score * 100) / 100
}
return {
project_id: anonId,
avg_score: p.avgGlobalScore,
avg_score: p.avgGlobalScore != null ? Math.round(p.avgGlobalScore * 100) / 100 : null,
normalized_avg_score: p.normalizedAvgScore != null ? Math.round(p.normalizedAvgScore * 100) / 100 : null,
pass_rate: p.passRate,
evaluator_count: p.evaluatorCount,
category: p.competitionCategory,
criteria_scores: criteriaScores,
normalized_criteria_scores: normalizedCriteriaScores,
}
})
return { anonymized, idMap }
@@ -206,6 +293,70 @@ function computePassRate(evaluations: Array<{ resolvedDecision: boolean | null }
return passCount / evaluations.length
}
// ─── Z-Score Normalization ──────────────────────────────────────────────────
interface JurorStats {
mean: number
stddev: number
count: number
}
/**
* Compute per-juror grading statistics (mean and stddev) for z-score normalization.
* Only considers numeric criterion scores and globalScore from SUBMITTED evaluations.
*/
function computeJurorStats(
assignments: Array<{
userId: string
evaluation: {
globalScore: number | null
criterionScoresJson: Record<string, unknown> | null
} | null
}>,
numericCriterionIds: Set<string>,
): Map<string, JurorStats> {
// Collect all numeric scores per juror
const jurorScores = new Map<string, number[]>()
for (const a of assignments) {
if (!a.evaluation) continue
const scores: number[] = []
if (a.evaluation.globalScore != null) scores.push(a.evaluation.globalScore)
if (a.evaluation.criterionScoresJson) {
for (const [id, val] of Object.entries(a.evaluation.criterionScoresJson)) {
if (numericCriterionIds.has(id) && typeof val === 'number') {
scores.push(val)
}
}
}
const existing = jurorScores.get(a.userId) ?? []
existing.push(...scores)
jurorScores.set(a.userId, existing)
}
const stats = new Map<string, JurorStats>()
for (const [userId, scores] of jurorScores.entries()) {
if (scores.length < 2) {
// Not enough data for meaningful normalization — skip
stats.set(userId, { mean: 0, stddev: 0, count: scores.length })
continue
}
const mean = scores.reduce((a, b) => a + b, 0) / scores.length
const variance = scores.reduce((sum, s) => sum + (s - mean) ** 2, 0) / scores.length
const stddev = Math.sqrt(variance)
stats.set(userId, { mean, stddev, count: scores.length })
}
return stats
}
/**
* Normalize a raw score using z-score normalization.
* Returns the z-score, or null if normalization isn't possible (too few evals or stddev=0).
*/
function zScoreNormalize(raw: number, stats: JurorStats): number | null {
if (stats.count < 2 || stats.stddev === 0) return null
return (raw - stats.mean) / stats.stddev
}
// ─── Exported Functions ───────────────────────────────────────────────────────
/**
@@ -275,11 +426,15 @@ export async function parseRankingCriteria(
*
* projects: raw data queried from Prisma, already filtered to one category
* parsedRules: from parseRankingCriteria()
* criteriaWeights: optional admin-configured weights from round config
* criterionDefs: criterion definitions from the evaluation form
*/
export async function executeAIRanking(
parsedRules: ParsedRankingRule[],
projects: ProjectForRanking[],
category: CompetitionCategory,
criteriaWeights: Record<string, number> | undefined,
criterionDefs: CriterionDef[],
userId?: string,
entityId?: string,
): Promise<RankingResult> {
@@ -288,7 +443,7 @@ export async function executeAIRanking(
}
const maxEvaluatorCount = Math.max(...projects.map((p) => p.evaluatorCount))
const { anonymized, idMap } = anonymizeProjectsForRanking(projects)
const { anonymized, idMap } = anonymizeProjectsForRanking(projects, criterionDefs)
const openai = await getOpenAI()
if (!openai) {
@@ -297,10 +452,23 @@ export async function executeAIRanking(
const model = await getConfiguredModel()
const userPrompt = JSON.stringify({
// Build criteria_definitions for the AI prompt (only numeric criteria)
const numericDefs = criterionDefs.filter((d) => !d.type || d.type === 'numeric')
const criteriaDefsForAI: CriterionDefForAI[] = numericDefs.map((d) => {
const adminWeight = criteriaWeights?.[d.id] ?? d.weight ?? 1
const scale = typeof d.scale === 'number' ? `1-${d.scale}` : typeof d.scale === 'string' ? d.scale : '1-10'
return { name: d.label, weight: adminWeight, scale }
})
const promptData: Record<string, unknown> = {
rules: parsedRules.filter((r) => r.dataAvailable),
projects: anonymized,
})
}
if (criteriaDefsForAI.length > 0) {
promptData.criteria_definitions = criteriaDefsForAI
}
const userPrompt = JSON.stringify(promptData)
const params = buildCompletionParams(model, {
messages: [
@@ -359,13 +527,9 @@ export async function executeAIRanking(
return {
projectId: realId,
rank: entry.rank,
compositeScore: computeCompositeScore(
proj.avgGlobalScore,
proj.passRate,
proj.evaluatorCount,
maxEvaluatorCount,
),
compositeScore: computeCompositeScore(proj, maxEvaluatorCount, criteriaWeights, criterionDefs),
avgGlobalScore: proj.avgGlobalScore,
normalizedAvgScore: proj.normalizedAvgScore,
passRate: proj.passRate,
evaluatorCount: proj.evaluatorCount,
aiRationale: entry.rationale,
@@ -404,6 +568,9 @@ export async function quickRank(
* Internal helper: fetch eligible projects for one category and execute ranking.
* Excluded: withdrawn projects and projects with zero submitted evaluations (locked decision).
*
* 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.
*/
export async function fetchAndRankCategory(
@@ -413,12 +580,32 @@ export async function fetchAndRankCategory(
prisma: PrismaClient,
userId?: string,
): Promise<RankingResult> {
// Fetch the round config to find the boolean criterion ID (legacy fallback)
const round = await prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { configJson: true },
})
const boolCriterionId = findBooleanCriterionId(round.configJson as Record<string, unknown> | null)
// Fetch the round config and evaluation form in parallel
const [round, evalForm] = await Promise.all([
prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { configJson: true },
}),
prisma.evaluationForm.findFirst({
where: { roundId, isActive: true },
select: { criteriaJson: true },
}),
])
const roundConfig = round.configJson as Record<string, unknown> | null
const boolCriterionId = findBooleanCriterionId(roundConfig)
// Parse evaluation config for criteria weights
const evalConfig = roundConfig as EvaluationConfig | null
const criteriaWeights = evalConfig?.criteriaWeights ?? undefined
// Parse criterion definitions from the evaluation form
const criterionDefs: CriterionDef[] = evalForm?.criteriaJson
? (evalForm.criteriaJson as unknown as CriterionDef[])
: []
const numericCriterionIds = new Set(
criterionDefs.filter((d) => !d.type || d.type === 'numeric').map((d) => d.id),
)
// Query submitted evaluations grouped by projectId for this category
const assignments = await prisma.assignment.findMany({
@@ -446,8 +633,26 @@ export async function fetchAndRankCategory(
},
})
// Group by projectId, resolving binaryDecision from column or criterionScoresJson fallback
const byProject = new Map<string, Array<{ globalScore: number | null; resolvedDecision: boolean | null }>>()
// Compute per-juror stats for z-score normalization
const jurorStats = computeJurorStats(
assignments.map((a) => ({
userId: a.userId,
evaluation: a.evaluation ? {
globalScore: a.evaluation.globalScore,
criterionScoresJson: a.evaluation.criterionScoresJson as Record<string, unknown> | null,
} : null,
})),
numericCriterionIds,
)
// Group by projectId, collect per-juror scores for aggregation
type EvalData = {
globalScore: number | null
resolvedDecision: boolean | null
criterionScores: Record<string, unknown> | null
userId: string
}
const byProject = new Map<string, EvalData[]>()
for (const a of assignments) {
if (!a.evaluation) continue
const resolved = resolveBinaryDecision(
@@ -456,21 +661,83 @@ export async function fetchAndRankCategory(
boolCriterionId,
)
const list = byProject.get(a.project.id) ?? []
list.push({ globalScore: a.evaluation.globalScore, resolvedDecision: resolved })
list.push({
globalScore: a.evaluation.globalScore,
resolvedDecision: resolved,
criterionScores: a.evaluation.criterionScoresJson as Record<string, unknown> | null,
userId: a.userId,
})
byProject.set(a.project.id, list)
}
// Build ProjectForRanking, excluding projects with zero submitted evaluations
const projects: ProjectForRanking[] = []
for (const [projectId, evals] of byProject.entries()) {
if (evals.length === 0) continue // Exclude: no submitted evaluations
if (evals.length === 0) continue
// Raw avg global score
const avgGlobalScore = evals.some((e) => e.globalScore != null)
? evals.filter((e) => e.globalScore != null).reduce((sum, e) => sum + e.globalScore!, 0) /
evals.filter((e) => e.globalScore != null).length
: null
// Z-score normalized avg global score
const normalizedGlobalScores: number[] = []
for (const e of evals) {
if (e.globalScore == null) continue
const stats = jurorStats.get(e.userId)
if (!stats) continue
const z = zScoreNormalize(e.globalScore, stats)
if (z != null) normalizedGlobalScores.push(z)
}
const normalizedAvgScore = normalizedGlobalScores.length > 0
? normalizedGlobalScores.reduce((a, b) => a + b, 0) / normalizedGlobalScores.length
: null
// Per-criterion raw averages (numeric criteria only)
const criterionAverages: Record<string, number> = {}
for (const criterionId of numericCriterionIds) {
const values: number[] = []
for (const e of evals) {
if (!e.criterionScores) continue
const val = e.criterionScores[criterionId]
if (typeof val === 'number') values.push(val)
}
if (values.length > 0) {
criterionAverages[criterionId] = values.reduce((a, b) => a + b, 0) / values.length
}
}
// Per-criterion z-score normalized averages
const normalizedCriterionAverages: Record<string, number> = {}
for (const criterionId of numericCriterionIds) {
const zScores: number[] = []
for (const e of evals) {
if (!e.criterionScores) continue
const val = e.criterionScores[criterionId]
if (typeof val !== 'number') continue
const stats = jurorStats.get(e.userId)
if (!stats) continue
const z = zScoreNormalize(val, stats)
if (z != null) zScores.push(z)
}
if (zScores.length > 0) {
normalizedCriterionAverages[criterionId] = zScores.reduce((a, b) => a + b, 0) / zScores.length
}
}
const passRate = computePassRate(evals)
projects.push({ id: projectId, competitionCategory: category, avgGlobalScore, passRate, evaluatorCount: evals.length })
projects.push({
id: projectId,
competitionCategory: category,
avgGlobalScore,
normalizedAvgScore,
passRate,
evaluatorCount: evals.length,
criterionAverages,
normalizedCriterionAverages,
})
}
return executeAIRanking(parsedRules, projects, category, userId, roundId)
return executeAIRanking(parsedRules, projects, category, criteriaWeights, criterionDefs, userId, roundId)
}

View File

@@ -142,6 +142,9 @@ export const EvaluationConfigSchema = z.object({
rankingEnabled: z.boolean().default(false),
rankingCriteria: z.string().optional(),
autoRankOnComplete: z.boolean().default(false),
// Ranking (Phase 2) — per-criterion weights for AI ranking
criteriaWeights: z.record(z.string(), z.number().min(0).max(10)).optional(),
})
export type EvaluationConfig = z.infer<typeof EvaluationConfigSchema>