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

260 lines
10 KiB
React

/**
* InvestigationSummaryPanelV2 — Phase 4, Experiment 11
* A facilitator-style progress panel that translates the reasoning graph
* into a human-friendly "what is known / what remains" view.
*
* Design principle:
* The UI should progressively become a translation layer over the
* reasoning graph rather than maintaining separate duplicated summaries.
* Internal graph concepts remain available for developers, while end
* users see a facilitator-style explanation of what is currently understood
* and what remains uncertain.
*
* This component uses exactly the same graph data as InvestigationSummaryPanel
* (Version A). No new backend fields or API contracts are required.
*/
import React from "react";
/* ── Helpers ──────────────────────────────────────────────── */
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`;
}
/* ── Data extraction helpers ─────────────────────────────── */
/**
* Classify nodes into "known" (resolved / observations with values) and
* "still investigating" (unresolved unknowns and assumptions needing validation).
*/
function classifyNodes(graph, resolvedIds) {
if (!graph?.nodes) return { known: [], stillInvestigating: [] };
const resolved = new Set(resolvedIds || []);
const known = [];
const stillInvestigating = [];
for (const node of graph.nodes) {
const isResolved = resolved.has(node.id) || node.status === "resolved";
// Resolved nodes become known facts
if (isResolved) {
known.push({
label: node.label,
description: node.description,
kind: node.kind,
confidence: node.confidence,
});
} else {
// Unresolved unknowns and assumptions go into "still investigating"
stillInvestigating.push({
label: node.label,
description: node.description,
kind: node.kind,
confidence: node.confidence,
});
}
}
return { known, stillInvestigating };
}
/**
* Map graph node kinds to end-user-friendly group labels.
*/
function groupLabelForKind(kind) {
const map = {
unknown: "Still investigating",
assumption: "Assumptions to validate",
observation: "Observations",
state: "Current states",
metric: "Metrics",
conclusion: "Conclusions",
};
return map[kind] || kind.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/* ── Rendering helpers ───────────────────────────────────── */
/**
* Render a single item from the known or still-investigating lists.
* Show only meaningful content — hide labels that duplicate description.
*/
function renderListItem(item) {
// Prefer description if it adds something beyond the label
const text = (item.description && item.description !== item.label)
? item.description
: item.label;
return text;
}
/* ── Component ────────────────────────────────────────────── */
function InvestigationSummaryPanelV2({ graph, selectedQuestion, result, updateStatus }) {
// ── Status (same derivation logic as Version A) ──────────
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 {
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 (same source as Version A) ────
const currentUnderstanding =
result?.summary ||
result?.updatedSituationGraph?.currentSummary ||
graph?.currentSummary ||
null;
// ── Classify graph data ─────────────────────────────────
const resolvedIds = new Set(graph?.resolvedNodeIds || []);
const { known, stillInvestigating } = classifyNodes(graph, resolvedIds);
// Group still-investigating items by kind for a cleaner view
const investigatingByGroup = {};
for (const item of stillInvestigating) {
const key = groupLabelForKind(item.kind);
if (!investigatingByGroup[key]) investigatingByGroup[key] = [];
investigatingByGroup[key].push(item);
}
// ── Reasoning summary counts (quiet, at bottom) ─────────
const reasonCounts = {
observations: graph?.nodes?.filter((n) => n.kind === "observation").length || 0,
unknowns: stillInvestigating.filter((n) => n.kind === "unknown").length || 0,
assumptions: graph?.nodes?.filter((n) => n.kind === "assumption" && !resolvedIds.has(n.id)).length || 0,
relationships: graph?.edges?.length || 0,
metrics: graph?.nodes?.filter((n) => n.kind === "metric").length || 0,
states: graph?.nodes?.filter((n) => n.kind === "state").length || 0,
conclusions: graph?.nodes?.filter((n) => n.kind === "conclusion").length || 0,
};
// Only show non-zero counts in the reasoning summary
const reasonEntries = Object.entries(reasonCounts).filter(([_, v]) => v > 0);
return (
<div className={`rounded-lg border ${colors.border} ${colors.bg} p-5 space-y-4`}>
{/* Status — minimal indicator */}
<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 (if any) ──────────────── */}
{currentUnderstanding && (
<div>
<p className="text-sm leading-relaxed text-gray-600">{currentUnderstanding}</p>
</div>
)}
{/* ── Still investigating — primary focus ─────────── */}
{(stillInvestigating.length > 0 || known.length === 0) && (
<div>
{stillInvestigating.length > 1 ? (
<>
<h3 className="mb-2 text-xs font-medium text-gray-400">Still investigating</h3>
<ul className="space-y-1.5">
{Object.entries(investigatingByGroup).map(([group, items]) => (
<li key={group}>
<span className="text-xs font-medium text-gray-500">{group}</span>
<ul className="mt-1 space-y-1">
{items.map((item, i) => (
<li key={i} className="flex items-start gap-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-blue-400/60" />
<span className="text-sm text-gray-700">
{renderListItem(item)}
</span>
</li>
))}
</ul>
</li>
))}
</ul>
</>
) : stillInvestigating.length === 1 ? (
<div className="flex items-start gap-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-blue-400/60" />
<p className="text-sm text-gray-700">{renderListItem(stillInvestigating[0])}</p>
</div>
) : null}
</div>
)}
{/* ── What we have learned ────────────────────────── */}
{known.length > 0 && (
<div>
<h3 className="mb-2 text-xs font-medium text-gray-400">What we know</h3>
<ul className="space-y-1.5">
{known.map((item, i) => (
<li key={i} className="flex items-start gap-2">
<span className="mt-1 h-4 w-4 shrink-0 rounded-full bg-green-400/30" style={{ fontSize: "8px", lineHeight: "1" }}></span>
<span className="text-sm text-gray-700">
{renderListItem(item)}
</span>
</li>
))}
</ul>
</div>
)}
{/* ── Quiet reasoning summary — secondary ─────────── */}
<div className="pt-2 border-t border-gray-200/40">
<p className="text-[10px] font-medium tracking-widest uppercase text-gray-300 mb-1.5">Reasoning</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-400">
{reasonEntries.map(([label, count]) => (
<span key={label}>
{count} {label}
</span>
))}
</div>
</div>
</div>
);
}
export default InvestigationSummaryPanelV2;