260 lines
10 KiB
React
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;
|