/** * dashboard-client/src/components/HydeDetailPanel.tsx — per-turn HyDE + recall * quality detail (H3.1). * * Fetches /api/rag-metrics once and indexes telemetry rows by the composite * `conversationId:turnIndex` key (the dashboard TurnRow has no DB id, so the * pair is the stable identifier). Renders one of three HyDE states: * - HyDE ran: hypothetical doc (collapsible), raw/hyde/fused hit counts, * lift multiplier, generation latency. * - HyDE skipped: reason (disabled / no-llm / generation-failed). * - No telemetry: nothing (the parent only renders us when flagged). * Plus a CRAG-style mini score line when recall metrics exist. * * PREVENT-PI-004: relative-path fetch to the same-origin dashboard server. */ import type React from "react"; import { useMemo, useState } from "react"; import { useApi } from "../hooks/useApi"; import { fetchRagMetrics } from "../api/client"; import type { RagMetricsResponse } from "@contracts"; import { Card } from "./ui/card"; import { Badge } from "./ui/badge"; /** Per-turn telemetry row shape derived from the rag-metrics contract. */ type TelemetryRow = RagMetricsResponse["recent"][number]; export interface HydeDetailPanelProps { conversationId: string; turnIndex: number; } /** Composite key matching a telemetry row to a rendered turn. */ function rowKey(conversationId: string, turnIndex: number): string { return `${conversationId}:${turnIndex}`; } /** First N lines of the hypothetical doc (or full when expanded). */ function truncateDoc(doc: string, maxLines: number): string { const lines = doc.split("\n"); return lines.length > maxLines ? lines.slice(0, maxLines).join("\n") : doc; } /** CRAG-style mini score line when recall metrics were captured. */ function ScoreLine({ row }: { row: TelemetryRow }): React.ReactElement { const hasScore = row.recallScore > 0 || row.recallPass === 1; if (!hasScore) { return (
No recall-quality metrics for this turn.
); } return (
Recall quality {row.recallPass === 1 ? "pass" : "fail"} score {row.recallScore.toFixed(2)}
relevance {row.recallRelevance.toFixed(2)} coverage {row.recallCoverage.toFixed(2)} diversity {row.recallDiversity.toFixed(2)} specificity {row.recallSpecificity.toFixed(2)}
); } export const HydeDetailPanel: React.FC = ({ conversationId, turnIndex, }) => { const { data: metrics } = useApi( useMemo(() => () => fetchRagMetrics(), []), { pollInterval: 30_000 }, ); const [showFullDoc, setShowFullDoc] = useState(false); // Rebuild the index whenever metrics arrive; rare enough that a full // rebuild on each render is cheaper than memoizing with a mutable dep. const row = useMemo( () => metrics?.recent.find( (t) => rowKey(t.conversationId, t.turnIndex) === rowKey(conversationId, turnIndex), ) ?? null, [metrics, conversationId, turnIndex], ); if (!metrics) { return
Loading HyDE stats…
; } if (!row) { return (
No HyDE/recall telemetry recorded for this turn.
); } const doc = row.hydeDoc ?? ""; const docLines = doc.split("\n").length; const showDoc = docLines > 3; const visibleDoc = showFullDoc || !showDoc ? doc : truncateDoc(doc, 3); return ( {row.hydeRan === 1 ? ( <>
HyDE ran gen {row.hydeGenerationMs}ms raw {row.hydeRawCount} hyde {row.hydeHydeCount} fused {row.hydeFusedCount} lift{" "} {row.hydeLift.toFixed(2)}×
{doc && (
							{visibleDoc}
						
)} {showDoc && ( )} ) : (
HyDE skipped {skipReason(row)}
)}
); }; function Arrow(): React.ReactElement { return ; } function skipReason(row: TelemetryRow): string { switch (row.hydeReason) { case "no-llm": return "No LLM embedder available (requires Ollama/HTTP embedder)."; case "generation-failed": return "Hypothetical doc generation failed this turn."; case "disabled": return "HyDE is disabled (MEGACOMPACT_HYDE_DISABLED)."; default: return "HyDE was not invoked for this turn."; } }