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>
|
||||
|
||||
Reference in New Issue
Block a user