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
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:
@@ -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>
|
||||
|
||||
@@ -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. "Prioritize innovation and ocean impact. Weight jury scores 60%, feasibility 40%."
|
||||
</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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 // 0–1
|
||||
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 // 0–1 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 (1–10 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 (0–1)
|
||||
- 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 0–1 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 0–1
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user