feat(ui): add prediction state management and PredictionPanel component

- Create prediction type definitions in src/types/predictions.ts
- Add prediction state management to page.tsx with caching
- Implement PredictionPanel component with:
  - Master visibility toggle
  - Model info display (name, version, type, metrics)
  - Action buttons (Run on Visible, Predict All)
  - Confidence threshold slider
  - Label filter checkboxes with per-class metrics
  - Disagreement filter toggle
  - Prediction summary display
  - Model server offline banner
- Add on-demand and batch prediction fetching
- Implement prediction caching by chart and model version
- Add health polling for inference API (30s interval when offline)
- Ensure annotation tools work independently of prediction API

Tasks completed: 9.1-9.5, 12.1-12.3 (59/78 total)
This commit is contained in:
Marko Djordjevic 2026-02-15 16:20:07 +01:00
parent bb1b6d573f
commit 28ebe2c5d1
4 changed files with 608 additions and 8 deletions

View file

@ -0,0 +1,215 @@
'use client';
import { useState } from 'react';
import type { PredictionState, ModelInfoResponse, PredictionSummary } from '@/types/predictions';
interface PredictionPanelProps {
predictionState: PredictionState;
onToggleVisibility: () => void;
onFetchPredictions: () => void;
onFetchBatchPredictions: () => void;
onConfidenceChange: (threshold: number) => void;
onToggleLabelSelection: (label: string) => void;
predictionSummary?: PredictionSummary;
isModelOnline: boolean;
}
export default function PredictionPanel({
predictionState,
onToggleVisibility,
onFetchPredictions,
onFetchBatchPredictions,
onConfidenceChange,
onToggleLabelSelection,
predictionSummary,
isModelOnline,
}: PredictionPanelProps) {
const [showOnlyDisagreements, setShowOnlyDisagreements] = useState(false);
const {
visible,
isLoading,
error,
modelInfo,
confidenceThreshold,
selectedLabels,
spans,
} = predictionState;
if (!isModelOnline) {
return (
<div className="p-4 border-t border-border bg-card">
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-red-500" />
<h3 className="text-sm font-semibold text-foreground">Model Server Offline</h3>
</div>
<p className="text-xs text-muted-foreground">
Prediction service is unavailable. Annotation tools continue to work normally.
</p>
</div>
);
}
return (
<div className="p-4 border-t border-border bg-card">
{/* Header with master toggle */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isModelOnline ? 'bg-green-500' : 'bg-red-500'}`} />
<h3 className="text-sm font-semibold text-foreground">Predictions</h3>
</div>
<button
onClick={onToggleVisibility}
className={`px-3 py-1 text-xs rounded ${
visible
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{visible ? 'Hide' : 'Show'}
</button>
</div>
{/* Model Info */}
{modelInfo && (
<div className="mb-3 p-2 bg-muted/50 rounded text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Model:</span>
<span className="font-mono text-foreground">{modelInfo.model_info.model_name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Version:</span>
<span className="font-mono text-foreground">{modelInfo.model_info.model_version}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Type:</span>
<span className="text-foreground">{modelInfo.model_info.model_type}</span>
</div>
<div className="flex justify-between mt-1 pt-1 border-t border-border">
<span className="text-muted-foreground">Accuracy:</span>
<span className="text-foreground">{(modelInfo.metrics.accuracy * 100).toFixed(1)}%</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">F1 (macro):</span>
<span className="text-foreground">{(modelInfo.metrics.f1_macro * 100).toFixed(1)}%</span>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2 mb-3">
<button
onClick={onFetchPredictions}
disabled={isLoading || !isModelOnline}
className="flex-1 px-3 py-2 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Loading...' : 'Run on Visible'}
</button>
<button
onClick={onFetchBatchPredictions}
disabled={isLoading || !isModelOnline}
className="flex-1 px-3 py-2 text-xs bg-secondary text-secondary-foreground rounded hover:bg-secondary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
Predict All
</button>
</div>
{/* Error Display */}
{error && (
<div className="mb-3 p-2 bg-destructive/10 border border-destructive/20 rounded text-xs text-destructive">
{error}
</div>
)}
{/* Confidence Slider */}
<div className="mb-3">
<div className="flex justify-between items-center mb-1">
<label className="text-xs text-muted-foreground">Confidence Threshold</label>
<span className="text-xs font-mono text-foreground">{(confidenceThreshold * 100).toFixed(0)}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={confidenceThreshold * 100}
onChange={(e) => onConfidenceChange(Number(e.target.value) / 100)}
className="w-full h-1 bg-muted rounded-lg appearance-none cursor-pointer"
/>
</div>
{/* Label Filter Checkboxes */}
{modelInfo && (
<div className="mb-3">
<label className="text-xs text-muted-foreground mb-2 block">Filter by Label</label>
<div className="space-y-1 max-h-32 overflow-y-auto">
{modelInfo.label_config.map((labelConfig) => {
const metrics = modelInfo.metrics.per_class[labelConfig.name];
const isSelected = selectedLabels.has(labelConfig.name);
return (
<label
key={labelConfig.name}
className="flex items-center gap-2 p-1 rounded hover:bg-muted/50 cursor-pointer"
>
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleLabelSelection(labelConfig.name)}
className="w-3 h-3"
/>
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: labelConfig.color }}
/>
<span className="text-xs text-foreground flex-1">{labelConfig.name}</span>
{metrics && (
<span className="text-xs text-muted-foreground font-mono">
F1: {(metrics.f1_score * 100).toFixed(0)}%
</span>
)}
</label>
);
})}
</div>
</div>
)}
{/* Disagreement Filter */}
{predictionSummary && predictionSummary.disagreements.length > 0 && (
<div className="mb-3">
<label className="flex items-center gap-2 p-2 bg-muted/50 rounded cursor-pointer hover:bg-muted">
<input
type="checkbox"
checked={showOnlyDisagreements}
onChange={(e) => setShowOnlyDisagreements(e.target.checked)}
className="w-3 h-3"
/>
<span className="text-xs text-foreground">Show only disagreements</span>
</label>
</div>
)}
{/* Prediction Summary */}
{visible && spans.length > 0 && predictionSummary && (
<div className="p-2 bg-muted/30 rounded text-xs space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Predictions:</span>
<span className="text-foreground font-mono">{predictionSummary.total_predictions}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Human annotations:</span>
<span className="text-foreground font-mono">{predictionSummary.total_human_annotations}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Agreements:</span>
<span className="text-green-600 font-mono">{predictionSummary.agreements}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Disagreements:</span>
<span className="text-orange-600 font-mono">{predictionSummary.disagreements.length}</span>
</div>
</div>
)}
</div>
);
}