feat(ui): implement disagreement detection, prediction summary, loading states, and update documentation

- Add disagreement detection logic comparing human annotations vs predictions
- Display prediction summary in PredictionPanel (agreements/disagreements)
- Wire up 'Show only disagreements' filter toggle
- Add loading overlay during prediction fetching
- Update docker-compose.yml with healthchecks for all services
- Update DEPLOYMENT.md with comprehensive ML service setup instructions
- Update README.md with ML pipeline overview and architecture diagrams
- Update CLAUDE_DESCRIPTION.md with v3.0.0 ML integration details

Remaining tasks (11.2, 11.4, 11.5) deferred - core functionality complete
This commit is contained in:
Marko Djordjevic 2026-02-15 16:34:02 +01:00
parent 952eb7413c
commit 21f184aa8d
8 changed files with 585 additions and 56 deletions

View file

@ -6,7 +6,112 @@ import FileUpload from '@/components/FileUpload';
import CandleChart, { CandleChartHandle } from '@/components/CandleChart';
import ChartSelector from '@/components/ChartSelector';
import PredictionPanel from '@/components/PredictionPanel';
import type { PredictionState, PredictionSpan, ModelInfoResponse } from '@/types/predictions';
import type { PredictionState, PredictionSpan, ModelInfoResponse, Disagreement, DisagreementType, PredictionSummary } from '@/types/predictions';
/**
* Calculate overlap between two time ranges
* Returns overlap ratio (0-1) relative to the smaller span
*/
function calculateOverlap(
start1: number,
end1: number,
start2: number,
end2: number
): number {
const overlapStart = Math.max(start1, start2);
const overlapEnd = Math.min(end1, end2);
const overlap = Math.max(0, overlapEnd - overlapStart);
const minLength = Math.min(end1 - start1, end2 - start2);
return minLength > 0 ? overlap / minLength : 0;
}
/**
* Detect disagreements between human annotations and model predictions
* Returns summary with agreement/disagreement counts and detailed disagreement list
*/
function detectDisagreements(
humanSpans: SpanAnnotation[],
predictionSpans: PredictionSpan[],
overlapThreshold: number = 0.5
): PredictionSummary {
const disagreements: Disagreement[] = [];
const matchedHumanIds = new Set<number>();
const matchedPredictionIndices = new Set<number>();
// Filter human spans to only those with a source of "human" (not programmatic)
const humanOnlySpans = humanSpans.filter(span => {
// Assuming human-created spans don't have a source field or have source="human"
// This will be properly implemented when we add the source field
return true; // For now, consider all spans
});
// Find matches and label mismatches
humanOnlySpans.forEach((humanSpan) => {
predictionSpans.forEach((predSpan, predIdx) => {
const overlap = calculateOverlap(
humanSpan.start_time,
humanSpan.end_time,
predSpan.start_time,
predSpan.end_time
);
if (overlap >= overlapThreshold) {
matchedHumanIds.add(humanSpan.id);
matchedPredictionIndices.add(predIdx);
// Check for label mismatch
if (humanSpan.label !== predSpan.label) {
disagreements.push({
type: 'label_mismatch',
humanSpan: {
id: humanSpan.id,
label: humanSpan.label,
start_time: humanSpan.start_time,
end_time: humanSpan.end_time,
},
predictionSpan: predSpan,
overlap_ratio: overlap,
});
}
}
});
});
// Find spans missed by model (human annotation but no prediction)
humanOnlySpans.forEach((humanSpan) => {
if (!matchedHumanIds.has(humanSpan.id)) {
disagreements.push({
type: 'missed_by_model',
humanSpan: {
id: humanSpan.id,
label: humanSpan.label,
start_time: humanSpan.start_time,
end_time: humanSpan.end_time,
},
});
}
});
// Find spans missed by human (prediction but no human annotation)
predictionSpans.forEach((predSpan, idx) => {
if (!matchedPredictionIndices.has(idx)) {
disagreements.push({
type: 'missed_by_human',
predictionSpan: predSpan,
});
}
});
// Calculate agreements (matched spans with same label)
const agreements = matchedHumanIds.size - disagreements.filter(d => d.type === 'label_mismatch').length;
return {
total_predictions: predictionSpans.length,
total_human_annotations: humanOnlySpans.length,
agreements,
disagreements,
};
}
interface Chart {
id: number;
@ -86,6 +191,12 @@ export default function Home() {
// Model health state
const [isModelOnline, setIsModelOnline] = useState(true);
// Prediction summary state
const [predictionSummary, setPredictionSummary] = useState<PredictionSummary | null>(null);
// Disagreement filter state
const [showOnlyDisagreements, setShowOnlyDisagreements] = useState(false);
// Fetch charts list
const fetchCharts = useCallback(async () => {
try {
@ -361,6 +472,11 @@ export default function Home() {
});
}, []);
// Toggle show only disagreements
const toggleShowOnlyDisagreements = useCallback(() => {
setShowOnlyDisagreements((prev) => !prev);
}, []);
// Handle on-demand prediction for visible candles
const handleFetchVisiblePredictions = useCallback(() => {
// This will be called by the PredictionPanel
@ -473,6 +589,16 @@ export default function Home() {
fetchModelInfo();
}, [fetchModelInfo]);
// Compute prediction summary when predictions or span annotations change
useEffect(() => {
if (predictionState.visible && predictionState.spans.length > 0) {
const summary = detectDisagreements(spanAnnotations, predictionState.spans);
setPredictionSummary(summary);
} else {
setPredictionSummary(null);
}
}, [predictionState.visible, predictionState.spans, spanAnnotations]);
// Keyboard handler for Delete/Backspace key
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
@ -554,12 +680,26 @@ export default function Home() {
onFetchBatchPredictions={handleFetchBatchPredictions}
onConfidenceChange={setConfidenceThreshold}
onToggleLabelSelection={toggleLabelSelection}
predictionSummary={predictionSummary}
isModelOnline={isModelOnline}
showOnlyDisagreements={showOnlyDisagreements}
onToggleShowOnlyDisagreements={toggleShowOnlyDisagreements}
/>
</aside>
{/* Main chart area */}
<main className="flex-1 relative bg-background">
{/* Loading overlay for predictions */}
{predictionState.isLoading && (
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="bg-card border border-border rounded-lg p-6 shadow-lg">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-5 w-5 border-2 border-primary border-t-transparent" />
<span className="text-sm text-foreground">Loading predictions...</span>
</div>
</div>
</div>
)}
<CandleChart
ref={chartRef}
activeTool={activeTool}
@ -579,6 +719,8 @@ export default function Home() {
confidenceThreshold={predictionState.confidenceThreshold}
selectedLabels={predictionState.selectedLabels}
modelInfo={predictionState.modelInfo}
predictionSummary={predictionSummary}
showOnlyDisagreements={showOnlyDisagreements}
/>
</main>
</div>