feat: complete prediction UI feedback tasks (11.2, 11.4, 11.5)

- Implement disagreement visual highlighting with distinct colors
  - Yellow highlight for 'missed_by_human' predictions
  - Orange for 'label_mismatch' disagreements
  - Warning icon on disagreement markers
- Add click-to-convert prediction feedback
  - Click disagreement predictions to create span annotations
  - Auto-fill with predicted label and times
  - Set source as 'model_confirmed' or 'model_corrected'
- Add dismiss action for false positive predictions
  - Alt+Click or Ctrl+Click to dismiss predictions
  - Saves negative annotation with label 'O'
  - Records original prediction in model_prediction field
- Filter predictions when 'Show only disagreements' is enabled
This commit is contained in:
Marko Djordjevic 2026-02-16 11:40:55 +01:00
parent a18c6d110a
commit 65f00e6ce7
13 changed files with 905 additions and 11 deletions

View file

@ -84,6 +84,8 @@ interface CandleChartProps {
modelInfo?: ModelInfoResponse | null;
predictionSummary?: PredictionSummary | null;
showOnlyDisagreements?: boolean;
onPredictionClick?: (span: PredictionSpan, disagreementType: string | null) => void;
onPredictionDismiss?: (span: PredictionSpan, disagreementType: string | null) => void;
}
export interface CandleChartHandle {
@ -112,6 +114,8 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
modelInfo = null,
predictionSummary = null,
showOnlyDisagreements = false,
onPredictionClick,
onPredictionDismiss,
}, ref) => {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
@ -392,6 +396,20 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
});
}
// Build disagreement lookup map for highlighting
// Map: predictionSpan start_time -> disagreement type
const disagreementMap = new Map<number, string>();
const disagreementSpanSet = new Set<string>(); // Set of "start-end" keys for filtering
if (predictionSummary?.disagreements) {
predictionSummary.disagreements.forEach((d) => {
if (d.predictionSpan) {
disagreementMap.set(d.predictionSpan.start_time, d.type);
disagreementSpanSet.add(`${d.predictionSpan.start_time}-${d.predictionSpan.end_time}`);
}
});
}
// Helper to convert hex to rgba
const hexToRgba = (hex: string, alpha: number): string => {
hex = hex.replace('#', '');
@ -404,7 +422,16 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
// Build candle price lookup for histogram values
const candleMap = new Map(candles.map((c) => [c.time, c]));
// Filter and map predictions to histogram data
// Build a map from prediction time to its span for disagreement lookup
const predictionTimeToSpan = new Map<number, PredictionSpan>();
predictionSpans.forEach((span) => {
// Associate all times in the span with the span object
for (let t = span.start_time; t <= span.end_time; t += 60) { // Assuming 1-minute candles, adjust if needed
predictionTimeToSpan.set(t, span);
}
});
// Filter and map predictions to histogram data with disagreement highlighting
const histogramData: (HistogramData & { color?: string })[] = perCandlePredictions
.filter((p) => {
// Filter by confidence threshold
@ -413,17 +440,44 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
if (!selectedLabels.has(p.label)) return false;
// Skip "O" (no-pattern) labels
if (p.label === 'O') return false;
// If showOnlyDisagreements is enabled, only show predictions that are part of disagreements
if (showOnlyDisagreements) {
const span = predictionTimeToSpan.get(p.time);
if (!span) return false;
const spanKey = `${span.start_time}-${span.end_time}`;
if (!disagreementSpanSet.has(spanKey)) return false;
}
return true;
})
.map((p) => {
const candle = candleMap.get(p.time);
// Use candle high as the histogram value so it overlays correctly
const value = candle ? candle.high : 0;
const baseColor = labelColorMap[p.label] || '#888888';
// Check if this prediction is part of a disagreement
const span = predictionTimeToSpan.get(p.time);
const disagreementType = span ? disagreementMap.get(span.start_time) : null;
let baseColor = labelColorMap[p.label] || '#888888';
let alpha = 0.15;
// Apply disagreement-specific colors and styling
if (disagreementType === 'missed_by_human') {
// Yellow highlight for predictions missed by humans
baseColor = '#eab308'; // yellow
alpha = 0.25;
} else if (disagreementType === 'label_mismatch') {
// Orange for label mismatches
baseColor = '#f97316'; // orange
alpha = 0.25;
}
return {
time: p.time as Time,
value,
color: hexToRgba(baseColor, 0.15),
color: hexToRgba(baseColor, alpha),
};
})
.sort((a, b) => (a.time as number) - (b.time as number));
@ -436,24 +490,43 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
if (span.avg_confidence < confidenceThreshold) return false;
if (!selectedLabels.has(span.label)) return false;
if (span.label === 'O') return false;
// If showOnlyDisagreements is enabled, only show spans that are disagreements
if (showOnlyDisagreements) {
const spanKey = `${span.start_time}-${span.end_time}`;
if (!disagreementSpanSet.has(spanKey)) return false;
}
return true;
})
.map((span) => {
const baseColor = labelColorMap[span.label] || '#888888';
const disagreementType = disagreementMap.get(span.start_time);
let baseColor = labelColorMap[span.label] || '#888888';
let labelText = span.label;
// Apply disagreement-specific styling to markers
if (disagreementType === 'missed_by_human') {
baseColor = '#eab308'; // yellow
labelText = `${span.label}`;
} else if (disagreementType === 'label_mismatch') {
baseColor = '#f97316'; // orange
labelText = `${span.label}`;
}
const confidencePct = Math.round(span.avg_confidence * 100);
return {
time: span.start_time as Time,
position: 'belowBar' as const,
color: baseColor,
shape: 'square' as const,
text: `${span.label} (${confidencePct}%)`,
text: `${labelText} (${confidencePct}%)`,
size: 1,
};
})
.sort((a, b) => (a.time as number) - (b.time as number));
histogramSeriesRef.current.setMarkers(spanMarkers);
}, [predictionVisible, perCandlePredictions, predictionSpans, confidenceThreshold, selectedLabels, modelInfo, candles]);
}, [predictionVisible, perCandlePredictions, predictionSpans, confidenceThreshold, selectedLabels, modelInfo, candles, predictionSummary, showOnlyDisagreements]);
// Handle chart clicks for annotation
useEffect(() => {
@ -537,6 +610,41 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
}
}
// Handle clicks on prediction spans (for converting to annotations or dismissing)
if (!activeTool && predictionVisible && predictionSpans.length > 0) {
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
// Find if click is within any prediction span
const clickedSpan = predictionSpans.find(
(span) => timestamp >= span.start_time && timestamp <= span.end_time
);
if (clickedSpan) {
// Check if this span is a disagreement
const disagreementMap = new Map<number, string>();
if (predictionSummary?.disagreements) {
predictionSummary.disagreements.forEach((d) => {
if (d.predictionSpan) {
disagreementMap.set(d.predictionSpan.start_time, d.type);
}
});
}
const disagreementType = disagreementMap.get(clickedSpan.start_time) || null;
// Check if Alt or Ctrl key is pressed for dismiss action
if ((param.sourceEvent?.altKey || param.sourceEvent?.ctrlKey) && onPredictionDismiss) {
// Alt+Click or Ctrl+Click: Dismiss as "not a pattern"
onPredictionDismiss(clickedSpan, disagreementType);
} else if (onPredictionClick) {
// Normal click: Convert to annotation (only for disagreements)
if (disagreementType === 'missed_by_human' || disagreementType === 'label_mismatch') {
onPredictionClick(clickedSpan, disagreementType);
}
}
}
}
// Select/deselect label markers by clicking them
const isMarkerTool = annotationTypes.find(
(t) => t.category === 'marker' && t.name === activeTool
@ -567,7 +675,7 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
return () => {
chartRef.current?.unsubscribeClick(handleClick);
};
}, [activeTool, candles, annotations, annotationTypes, onAnnotationChange]);
}, [activeTool, candles, annotations, annotationTypes, onAnnotationChange, predictionVisible, predictionSpans, predictionSummary, onPredictionClick, onPredictionDismiss]);
// Fetch data on mount
useEffect(() => {