Files
confidence-engine/components/investigation-summary-panel.jsx

155 lines
6.7 KiB
React

/**
* InvestigationSummaryPanel — Phase 4
* Displays key investigation metrics in a compact card.
* Some fields are currently mocked; TODO comments identify what the
* reasoning engine must eventually provide.
*/
/* ── Helpers ──────────────────────────────────────────────── */
import React from "react";
function formatTimestamp(iso) {
if (!iso) return "—";
try {
const d = new Date(iso);
if (isNaN(d)) return iso;
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
} catch {
return iso;
}
}
function humaniseDuration(seconds) {
if (!seconds || seconds < 0) return "—";
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins === 0) return `${secs}s`;
return `${mins}m ${secs}s`;
}
/* ── Component ────────────────────────────────────────────── */
function InvestigationSummaryPanel({ graph, selectedQuestion, result, updateStatus }) {
// ── Current status ────────────────────────────────────────────
// TODO: reasoning should emit an explicit status field such as
// "investigating", "evidence_limit_reached", "resolution_achieved".
// Currently derived heuristically from graph state.
const isInvestigating = Boolean(selectedQuestion);
const hasGraph = Boolean(graph);
let currentStatus;
if (updateStatus === "loading") {
currentStatus = { label: "Reasoning", level: "investigating" };
} else if (!hasGraph) {
currentStatus = { label: "Not started", level: "idle" };
} else if (isInvestigating) {
currentStatus = { label: "Investigation in progress", level: "investigating" };
} else if (graph.resolvedNodeIds?.length > 0 && graph.nodes) {
const unresolvedUnknowns = graph.nodes.filter(
(n) => n.kind === "unknown" && !graph.resolvedNodeIds.includes(n.id)
);
if (unresolvedUnknowns.length === 0) {
currentStatus = { label: "Investigation complete", level: "complete" };
} else {
// TODO: reasoning should emit a terminal "evidence_limit_reached"
// status when it stops selecting questions because no unknown has
// sufficient upstream evidence. Currently we infer this from the
// absence of an active question combined with unresolved unknowns.
currentStatus = { label: "Current evidence limit reached", level: "limit" };
}
} else {
currentStatus = { label: "Analysis complete", level: "complete" };
}
const statusColors = {
idle: { border: "border-gray-200/60", bg: "bg-gray-50/40", text: "text-gray-400" },
investigating: { border: "border-blue-200/60", bg: "bg-blue-50/30", text: "text-blue-600" },
complete: { border: "border-green-200/60", bg: "bg-green-50/30", text: "text-green-600" },
limit: { border: "border-gray-200", bg: "bg-gray-50/40", text: "text-gray-400" },
};
const colors = statusColors[currentStatus.level] || statusColors.idle;
// ── Current understanding ────────────────────────────────
// TODO: reasoning should provide a durable summary field that is
// guaranteed to be the latest plain-language synthesis.
// Currently falls back to graph.currentSummary which may not exist
// in all mock scenarios.
const currentUnderstanding =
result?.summary ||
result?.updatedSituationGraph?.currentSummary ||
graph?.currentSummary ||
null;
// ── Questions answered / remaining ───────────────────────
// TODO: reasoning should emit a list of resolved unknown node IDs
// and the total set of unknown nodes it identified at start.
// Currently we count from the graph snapshot: every unknown whose
// status is "resolved" (or whose ID appears in resolvedNodeIds).
let questionsAnswered = 0;
let questionsRemaining = 0;
if (graph?.nodes) {
const allUnknowns = graph.nodes.filter((n) => n.kind === "unknown");
const resolvedCount = allUnknowns.filter(
(n) => n.status === "resolved" || (graph.resolvedNodeIds && graph.resolvedNodeIds.includes(n.id))
).length;
questionsAnswered = resolvedCount;
// TODO: this is a rough heuristic — the reasoning engine should
// explicitly track which unknowns were proposed for questioning.
questionsRemaining = allUnknowns.length - resolvedCount;
}
// ── Timestamps ───────────────────────────────────────────
// TODO: reasoning should provide investigationStartedAt and
// lastUpdatedAt as part of the start/update contract.
// Currently we use the session updatedAt timestamp (persisted by
// the UI layer) as a best-effort approximation.
const investigationStartTime = result?.updatedAt || null;
const lastUpdatedAt = result?.updatedAt || null;
// Derive elapsed time since last update
let elapsedSeconds = 0;
if (lastUpdatedAt) {
elapsedSeconds = Math.floor((Date.now() - new Date(lastUpdatedAt).getTime()) / 1000);
}
return (
<div className={`rounded-lg border ${colors.border} ${colors.bg} p-5 space-y-4`}>
{/* Status */}
<div className="flex items-center gap-2">
<span className={`inline-block h-2.5 w-2.5 rounded-full bg-current ${colors.text}`} />
<span className={`text-sm font-medium ${colors.text}`}>{currentStatus.label}</span>
</div>
{/* Current understanding */}
{currentUnderstanding && (
<div>
<h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
What we understand so far
</h3>
<p className="text-sm leading-relaxed text-gray-600">{currentUnderstanding}</p>
</div>
)}
{/* Questions — hidden when no meaningful value to show */}
{isInvestigating && questionsRemaining > 0 && (
<div className="grid grid-cols-2 gap-4">
<div>
<span className="block text-xs text-gray-400">Questions answered</span>
<span className={`text-lg font-semibold ${colors.text}`}>{questionsAnswered}</span>
</div>
<div>
<span className="block text-xs text-gray-400">Still working on</span>
<span className={`text-lg font-semibold ${colors.text}`}>{questionsRemaining + " items"}</span>
</div>
</div>
)}
</div>
);
}
export default InvestigationSummaryPanel;