feat(confidence-engine): stabilize investigation workspace with semantic decomposition and deterministic presentation anchors

This commit is contained in:
2026-08-23 16:59:55 +01:00
parent 01c57788ee
commit c134b5cb04
4 changed files with 1237 additions and 50 deletions
+121 -50
View File
@@ -798,7 +798,7 @@ function OpenQuestionsPanel({
(n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id),
);
if (openNodes.length <= 1) return null;
if (openNodes.length <= 0) return null;
return (
<div className="space-y-4">
@@ -1178,6 +1178,11 @@ export default function ReasoningWorkspace({
});
const data = await res.json();
if (!data.success) throw new Error(data.error || "Formulation failed");
// Deterministic presentation anchor: explicitly set the focused item to the
// target node so formulation success always renders correctly.
setFocusedPresentationItemId(nodeId);
setFocusedInvestigations((prev) => ({
...prev,
[nodeId]: { ...prev[nodeId], question: data.question, status: "formulated", error: null },
@@ -1235,12 +1240,20 @@ export default function ReasoningWorkspace({
setProcessingStep("idle");
// Deterministic presentation anchor: explicitly set the focused item to the
// target node so the post-API success path always renders the correct state
// regardless of render timing or concurrent parent updates.
setFocusedPresentationItemId(targetNodeId);
setFocusedInvestigations((prev) => ({
...prev,
[targetNodeId]: { ...prev[targetNodeId], result: data, answer: answerText, error: null },
}));
} catch (err) {
setProcessingStep("idle");
setFocusedPresentationItemId(targetNodeId);
setFocusedInvestigations((prev) => ({
...prev,
[targetNodeId]: { ...prev[targetNodeId], result: null, error: err.message || "Deconstruction failed" },
@@ -1267,6 +1280,10 @@ export default function ReasoningWorkspace({
function setFollowUpQuestion(followUpText) {
const target = focusedPresentationItemId;
if (!target || !followUpText?.trim()) return;
// Deterministic presentation anchor: explicitly set after follow-up selection.
setFocusedPresentationItemId(target);
setFocusedInvestigations((prev) => ({
...prev,
[target]: { ...prev[target], question: followUpText.trim(), answer: null },
@@ -1393,46 +1410,75 @@ export default function ReasoningWorkspace({
</div>
{/* Initial proposed findings — unknowns + plausible interpretations from reconstruction */}
<div className="space-y-3" data-testid="initial-proposed-findings">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Open Questions
</h2>
{(() => {
const resolvedIds = new Set(graph?.resolvedNodeIds || []);
// Surface only candidate items from the semantic reconstruction that are worth investigating:
// — unknowns (importantUnknowns from the LLM's reconstruction)
// — assumptions (plausibleInterpretations from the LLM's reconstruction)
// Skips observations, states, relationships, transitions — these are already established facts/context.
// Both kinds check status !== "resolved" and excluded resolvedIds to mirror OpenQuestionsPanel logic.
const candidateKinds = ["unknown", "assumption"];
return (
(graph?.nodes || [])
.filter(
<div className="space-y-6" data-testid="initial-proposed-findings">
{(() => {
const resolvedIds = new Set(graph?.resolvedNodeIds || []);
// Open Questions: unresolved unknown nodes only (investigable)
const openUnknowns = (graph?.nodes || []).filter(
(n) =>
candidateKinds.includes(n.kind) &&
n.kind === "unknown" &&
n.status !== "resolved" &&
!resolvedIds.has(n.id),
)
.map((node) => {
const tag = node.kind === "assumption" ? "Plausible interpretation" : "Unclear";
return (
<button
key={node.id}
onClick={() => startFocused(node.id)}
style={{ cursor: "pointer" }}
className="w-full text-left rounded-lg border border-gray-200 bg-white px-5 py-4 transition hover:border-gray-300 hover:bg-gray-50"
>
<span className="block text-sm leading-relaxed text-gray-900">{node.label}</span>
{node.description && node.description !== node.label && (
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
)}
<span className="mt-2 block text-[10px] uppercase tracking-wider text-gray-400">{tag}</span>
</button>
);
})
);
})()}
</div>
);
// Possible Interpretations: unresolved assumption nodes only (informational, not investigable)
const possibleInterpretations = (graph?.nodes || []).filter(
(n) =>
n.kind === "assumption" &&
n.status !== "resolved" &&
!resolvedIds.has(n.id),
);
return (
<>
{/* OPEN QUESTIONS — unknown nodes (clickable → focused investigation) */}
{openUnknowns.length > 0 && (
<div className="space-y-3">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Open Questions
</h2>
{openUnknowns.map((node) => (
<button
key={node.id}
onClick={() => startFocused(node.id)}
style={{ cursor: "pointer" }}
className="w-full text-left rounded-lg border border-gray-200 bg-white px-5 py-4 transition hover:border-gray-300 hover:bg-gray-50"
>
<span className="block text-sm leading-relaxed text-gray-900">{node.label}</span>
{node.description && node.description !== node.label && (
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
)}
<span className="mt-2 block text-[10px] uppercase tracking-wider text-gray-400">Unclear</span>
</button>
))}
</div>
)}
{/* POSSIBLE INTERPRETATIONS — assumption nodes (informational, not investigable) */}
{possibleInterpretations.length > 0 && (
<div className="space-y-3">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Possible Interpretations
</h2>
{possibleInterpretations.map((node) => (
<div
key={node.id}
className="w-full text-left rounded-lg border border-blue-100 bg-blue-50/40 px-5 py-4"
>
<span className="block text-sm leading-relaxed text-gray-900">{node.label}</span>
{node.description && node.description !== node.label && (
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
)}
<span className="mt-2 block text-[10px] uppercase tracking-wider text-blue-400">Plausible interpretation</span>
</div>
))}
</div>
)}
</>
);
})()}
</div>
</div>
)}
@@ -1448,9 +1494,8 @@ export default function ReasoningWorkspace({
</div>
)}
{/* Left column below Understanding: Investigation + Open Questions */}
<div className="lg:row-start-2 lg:col-start-1 lg:col-span-2 space-y-6">
{/* Current investigation (prominent hero section) */}
{/* Left column: Investigation + Open Questions (rows 2-3, columns 1-2) */}
<div className="lg:row-start-2 lg:row-end-4 lg:col-start-1 lg:col-span-2 space-y-6">
{postAnalyseStatus !== "success" && (
<CurrentInvestigationCard selectedQuestion={result?.selectedQuestion} graph={graph} />
)}
@@ -1551,22 +1596,48 @@ export default function ReasoningWorkspace({
)}
</div>
{/* ── Right lane: stable supporting reference (independent column) ───────── */}
{hasCurrentSummaryCondition && (
<div className="lg:row-start-2 lg:col-start-3 space-y-6">
{/* Right lane: stable supporting reference (independent column) */}
{(scenario || graph?.centralStatement) && hasCurrentSummaryCondition && postAnalyseStatus !== "success" && (
<div className="lg:row-start-2 lg:row-end-4 lg:col-start-3 space-y-6">
{/* Situation — always here when condition met, independent of left column height */}
{(scenario || graph?.centralStatement) && (
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement || scenario} />
)}
{!propUnderstanding && graph && postAnalyseStatus !== "success" && (
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement} />
)}
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement || scenario} />
{/* RTO.25B — temporarily hidden to reduce competing navigation while branch-experiment is active */}
<div className="hidden">
<InvestigationMap turnCount={investigationHistory.length} />
</div>
</div>
)}
{/* Possible Interpretations — persistent provisional hypothesis cards (spans full workspace width, below Investigation) */}
{(() => {
const resolvedIds = new Set(graph?.resolvedNodeIds || []);
const interpretationNodes = (graph?.nodes || []).filter(
(n) =>
n.kind === "assumption" &&
n.status !== "resolved" &&
!resolvedIds.has(n.id),
);
return postAnalyseStatus !== "success" && interpretationNodes.length > 0 ? (
<div className="lg:row-start-4 lg:col-span-full space-y-3">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Possible Interpretations
</h2>
{interpretationNodes.map((node) => (
<div
key={node.id}
className="w-full text-left rounded-lg border border-blue-100 bg-blue-50/40 px-5 py-4"
>
<span className="block text-sm leading-relaxed text-gray-900">{node.label}</span>
{node.description && node.description !== node.label && (
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
)}
<span className="mt-2 block text-[10px] uppercase tracking-wider text-blue-400">Plausible interpretation</span>
</div>
))}
</div>
) : null;
})()}
</div>
)}