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:
parent
952eb7413c
commit
21f184aa8d
8 changed files with 585 additions and 56 deletions
144
src/app/page.tsx
144
src/app/page.tsx
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { createChart, IChartApi, ISeriesApi, CandlestickData, HistogramData, Tim
|
|||
import { useTheme } from 'next-themes';
|
||||
import SvgOverlay from './SvgOverlay';
|
||||
import SpanAnnotationManager from './SpanAnnotationManager';
|
||||
import type { PerCandlePrediction, PredictionSpan, ModelInfoResponse } from '@/types/predictions';
|
||||
import type { PerCandlePrediction, PredictionSpan, ModelInfoResponse, PredictionSummary } from '@/types/predictions';
|
||||
|
||||
interface Candle {
|
||||
time: number;
|
||||
|
|
@ -82,6 +82,8 @@ interface CandleChartProps {
|
|||
confidenceThreshold?: number;
|
||||
selectedLabels?: Set<string>;
|
||||
modelInfo?: ModelInfoResponse | null;
|
||||
predictionSummary?: PredictionSummary | null;
|
||||
showOnlyDisagreements?: boolean;
|
||||
}
|
||||
|
||||
export interface CandleChartHandle {
|
||||
|
|
@ -108,6 +110,8 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
confidenceThreshold = 0.5,
|
||||
selectedLabels = new Set<string>(),
|
||||
modelInfo = null,
|
||||
predictionSummary = null,
|
||||
showOnlyDisagreements = false,
|
||||
}, ref) => {
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { PredictionState, ModelInfoResponse, PredictionSummary } from '@/types/predictions';
|
||||
|
||||
interface PredictionPanelProps {
|
||||
|
|
@ -12,6 +11,8 @@ interface PredictionPanelProps {
|
|||
onToggleLabelSelection: (label: string) => void;
|
||||
predictionSummary?: PredictionSummary;
|
||||
isModelOnline: boolean;
|
||||
showOnlyDisagreements?: boolean;
|
||||
onToggleShowOnlyDisagreements?: () => void;
|
||||
}
|
||||
|
||||
export default function PredictionPanel({
|
||||
|
|
@ -23,8 +24,9 @@ export default function PredictionPanel({
|
|||
onToggleLabelSelection,
|
||||
predictionSummary,
|
||||
isModelOnline,
|
||||
showOnlyDisagreements = false,
|
||||
onToggleShowOnlyDisagreements,
|
||||
}: PredictionPanelProps) {
|
||||
const [showOnlyDisagreements, setShowOnlyDisagreements] = useState(false);
|
||||
|
||||
const {
|
||||
visible,
|
||||
|
|
@ -175,13 +177,13 @@ export default function PredictionPanel({
|
|||
)}
|
||||
|
||||
{/* Disagreement Filter */}
|
||||
{predictionSummary && predictionSummary.disagreements.length > 0 && (
|
||||
{predictionSummary && predictionSummary.disagreements.length > 0 && onToggleShowOnlyDisagreements && (
|
||||
<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)}
|
||||
onChange={onToggleShowOnlyDisagreements}
|
||||
className="w-3 h-3"
|
||||
/>
|
||||
<span className="text-xs text-foreground">Show only disagreements</span>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue