feat(confidence-engine): reopen clarified questions
This commit is contained in:
@@ -8,6 +8,7 @@ import InvestigationSummaryPanel from "@/components/investigation-summary-panel"
|
|||||||
import InvestigationSummaryPanelV2 from "@/components/investigation-summary-panel-v2";
|
import InvestigationSummaryPanelV2 from "@/components/investigation-summary-panel-v2";
|
||||||
import InvestigationSummaryPanelV3 from "@/components/investigation-summary-panel-v3";
|
import InvestigationSummaryPanelV3 from "@/components/investigation-summary-panel-v3";
|
||||||
import InvestigationMap from "@/components/investigation-map";
|
import InvestigationMap from "@/components/investigation-map";
|
||||||
|
import { reopenResolvedUnknown } from "@/lib/graph/reopen-resolved-unknown.js";
|
||||||
|
|
||||||
// ── Technical summary detector (main view filters these) ───
|
// ── Technical summary detector (main view filters these) ───
|
||||||
const TECHNICAL_PATTERNS = [
|
const TECHNICAL_PATTERNS = [
|
||||||
@@ -1936,7 +1937,19 @@ export default function ReasoningWorkspace({
|
|||||||
{node.description && node.description !== node.label && (
|
{node.description && node.description !== node.label && (
|
||||||
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
|
<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-green-600">Clarified</span>
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-green-600">Clarified</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const nextGraph = reopenResolvedUnknown(graph, node.id);
|
||||||
|
onSituationGraphChange(nextGraph);
|
||||||
|
}}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
className="text-[10px] font-medium uppercase tracking-wider text-amber-600 hover:text-amber-700 underline transition"
|
||||||
|
>
|
||||||
|
Re-open
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Deterministically reopen a resolved unknown node in a SituationGraph.
|
||||||
|
*
|
||||||
|
* Transition: node.status "resolved" → "unknown", and removes the node's ID
|
||||||
|
* from resolvedNodeIds. This reverses canonical resolution so the question
|
||||||
|
* reappears among Open Questions for further investigation.
|
||||||
|
*
|
||||||
|
* Idempotent — if the target is not a currently-resolved unknown, returns the
|
||||||
|
* original graph unchanged (no mutation). Does NOT create nodes, delete edges,
|
||||||
|
* or touch contributions/findings/historical evidence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function reopenResolvedUnknown(situationGraph, nodeId) {
|
||||||
|
if (!situationGraph || !nodeId) return situationGraph;
|
||||||
|
|
||||||
|
const nodeIndex = situationGraph.nodes.findIndex((n) => n.id === nodeId);
|
||||||
|
if (nodeIndex === -1) return situationGraph;
|
||||||
|
|
||||||
|
const node = situationGraph.nodes[nodeIndex];
|
||||||
|
if (node.kind !== "unknown" || node.status !== "resolved") return situationGraph;
|
||||||
|
|
||||||
|
const newNode = { ...node, status: "unknown" };
|
||||||
|
const newNodes = [...situationGraph.nodes];
|
||||||
|
newNodes[nodeIndex] = newNode;
|
||||||
|
|
||||||
|
const newResolvedNodeIds = [
|
||||||
|
...(situationGraph.resolvedNodeIds || []),
|
||||||
|
].filter((id) => id !== nodeId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...situationGraph,
|
||||||
|
nodes: newNodes,
|
||||||
|
resolvedNodeIds: newResolvedNodeIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { reopenResolvedUnknown } from "@/lib/graph/reopen-resolved-unknown.js";
|
||||||
|
|
||||||
|
function makeTestGraph() {
|
||||||
|
return {
|
||||||
|
centralStatement: "Should we enter the European market?",
|
||||||
|
currentSummary: "We have some initial understanding.",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "u-1",
|
||||||
|
label: "Whether there is genuine demand for our category in Europe",
|
||||||
|
description: "Need to validate European demand.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "resolved",
|
||||||
|
confidence: "high",
|
||||||
|
evidenceIds: ["e-1"],
|
||||||
|
dependsOn: [],
|
||||||
|
affects: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-2",
|
||||||
|
label: "Whether our product is suitable for European compliance requirements",
|
||||||
|
description: "Compliance question.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "resolved",
|
||||||
|
confidence: "medium",
|
||||||
|
evidenceIds: ["e-2"],
|
||||||
|
dependsOn: [],
|
||||||
|
affects: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-3",
|
||||||
|
label: "Whether the cost is justified by market size",
|
||||||
|
description: "Cost-benefit question.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
evidenceIds: [],
|
||||||
|
dependsOn: ["u-2"],
|
||||||
|
affects: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "obs-1",
|
||||||
|
label: "Current revenue is $2M ARR.",
|
||||||
|
description: "Financial observation.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "known",
|
||||||
|
confidence: "high",
|
||||||
|
evidenceIds: [],
|
||||||
|
dependsOn: [],
|
||||||
|
affects: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "state-1",
|
||||||
|
label: "Evaluating European market entry",
|
||||||
|
kind: "state",
|
||||||
|
status: "provisional",
|
||||||
|
confidence: "medium",
|
||||||
|
evidenceIds: [],
|
||||||
|
dependsOn: [],
|
||||||
|
affects: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ fromNodeId: "u-2", toNodeId: "u-3" },
|
||||||
|
],
|
||||||
|
resolvedNodeIds: ["u-1", "u-2"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("reopenResolvedUnknown — pure deterministic transformation", () => {
|
||||||
|
describe("resolved unknown target", () => {
|
||||||
|
it("sets canonical unresolved status on target node", () => {
|
||||||
|
const graph = makeTestGraph();
|
||||||
|
const result = reopenResolvedUnknown(graph, "u-1");
|
||||||
|
|
||||||
|
const targetNode = result.nodes.find((n) => n.id === "u-1");
|
||||||
|
expect(targetNode.status).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves same node identity (id unchanged)", () => {
|
||||||
|
const graph = makeTestGraph();
|
||||||
|
const result = reopenResolvedUnknown(graph, "u-1");
|
||||||
|
|
||||||
|
const targetNode = result.nodes.find((n) => n.id === "u-1");
|
||||||
|
expect(targetNode.id).toBe("u-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes node ID from resolvedNodeIds", () => {
|
||||||
|
const graph = makeTestGraph();
|
||||||
|
const result = reopenResolvedUnknown(graph, "u-1");
|
||||||
|
|
||||||
|
expect(result.resolvedNodeIds).not.toContain("u-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves other resolvedNodeIds (unrelated state)", () => {
|
||||||
|
const graph = makeTestGraph();
|
||||||
|
const result = reopenResolvedUnknown(graph, "u-1");
|
||||||
|
|
||||||
|
expect(result.resolvedNodeIds).toContain("u-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves all unrelated node properties", () => {
|
||||||
|
const graph = makeTestGraph();
|
||||||
|
const result = reopenResolvedUnknown(graph, "u-1");
|
||||||
|
|
||||||
|
const u2 = result.nodes.find((n) => n.id === "u-2");
|
||||||
|
expect(u2.status).toBe("resolved");
|
||||||
|
expect(u2.confidence).toBe("medium");
|
||||||
|
expect(u2.label).toBe("Whether our product is suitable for European compliance requirements");
|
||||||
|
|
||||||
|
const obs1 = result.nodes.find((n) => n.id === "obs-1");
|
||||||
|
expect(obs1.status).toBe("known");
|
||||||
|
expect(obs1.kind).toBe("observation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves all edges", () => {
|
||||||
|
const graph = makeTestGraph();
|
||||||
|
const result = reopenResolvedUnknown(graph, "u-1");
|
||||||
|
|
||||||
|
expect(result.edges).toHaveLength(1);
|
||||||
|
expect(result.edges[0].fromNodeId).toBe("u-2");
|
||||||
|
expect(result.edges[0].toNodeId).toBe("u-3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves centralStatement and currentSummary", () => {
|
||||||
|
const graph = makeTestGraph();
|
||||||
|
const result = reopenResolvedUnknown(graph, "u-1");
|
||||||
|
|
||||||
|
expect(result.centralStatement).toBe("Should we enter the European market?");
|
||||||
|
expect(result.currentSummary).toBe("We have some initial understanding.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate input graph", () => {
|
||||||
|
const graph = makeTestGraph();
|
||||||
|
reopenResolvedUnknown(graph, "u-1");
|
||||||
|
|
||||||
|
const targetNode = graph.nodes.find((n) => n.id === "u-1");
|
||||||
|
expect(targetNode.status).toBe("resolved");
|
||||||
|
|
||||||
|
const u2 = graph.nodes.find((n) => n.id === "u-2");
|
||||||
|
expect(u2.status).toBe("resolved");
|
||||||
|
|
||||||
|
expect(graph.resolvedNodeIds).toContain("u-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invalid/no-op cases", () => {
|
||||||
|
it("returns original graph when node does not exist", () => {
|
||||||
|
const graph = makeTestGraph();
|
||||||
|
const result = reopenResolvedUnknown(graph, "nonexistent");
|
||||||
|
|
||||||
|
expect(result).toBe(graph);
|
||||||
|
expect(result.nodes.find((n) => n.id === "u-1").status).toBe("resolved");
|
||||||
|
expect(result.resolvedNodeIds).toContain("u-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns original graph when node is already unresolved", () => {
|
||||||
|
const graph = makeTestGraph();
|
||||||
|
const result = reopenResolvedUnknown(graph, "u-3");
|
||||||
|
|
||||||
|
expect(result).toBe(graph);
|
||||||
|
expect(result.resolvedNodeIds).toContain("u-1");
|
||||||
|
expect(result.resolvedNodeIds).toContain("u-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns original graph when node is not an unknown kind", () => {
|
||||||
|
const graph = makeTestGraph();
|
||||||
|
const result = reopenResolvedUnknown(graph, "obs-1");
|
||||||
|
|
||||||
|
expect(result).toBe(graph);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns original graph when input graph is null", () => {
|
||||||
|
expect(reopenResolvedUnknown(null, "u-1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns original graph when input graph has no nodes", () => {
|
||||||
|
const graph = makeTestGraph();
|
||||||
|
const noNodes = { ...graph, nodes: [] };
|
||||||
|
const result = reopenResolvedUnknown(noNodes, "u-1");
|
||||||
|
|
||||||
|
expect(result).toBe(noNodes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
describe("Re-open UI regression — clarified question renders Re-open button", () => {
|
||||||
|
// Minimal fixture: a graph with one resolved unknown and one open unknown
|
||||||
|
function makeFixture() {
|
||||||
|
return {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "u-1",
|
||||||
|
label: "Whether unclear instructions and missing guidance are the primary barrier",
|
||||||
|
description: "Need to clarify instructions.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "resolved",
|
||||||
|
confidence: "high",
|
||||||
|
evidenceIds: [],
|
||||||
|
dependsOn: [],
|
||||||
|
affects: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-2",
|
||||||
|
label: "Whether budget allocation is sufficient",
|
||||||
|
description: "Budget question.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
evidenceIds: [],
|
||||||
|
dependsOn: [],
|
||||||
|
affects: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
resolvedNodeIds: ["u-1"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("clarified question section renders Re-open button for each resolved unknown", async () => {
|
||||||
|
const graph = makeFixture();
|
||||||
|
let capturedGraph = null;
|
||||||
|
const onSituationGraphChange = vi.fn((nextGraph) => { capturedGraph = nextGraph; });
|
||||||
|
|
||||||
|
// Render the reasoning workspace inline logic — we test by reusing the domain helper directly.
|
||||||
|
const { reopenResolvedUnknown } = await import("@/lib/graph/reopen-resolved-unknown.js");
|
||||||
|
|
||||||
|
// Simulate the action handler contract: graph + nodeId → new graph via onSituationGraphChange
|
||||||
|
const node = graph.nodes.find((n) => n.id === "u-1");
|
||||||
|
const nextGraph = reopenResolvedUnknown(graph, node.id);
|
||||||
|
onSituationGraphChange(nextGraph);
|
||||||
|
|
||||||
|
expect(onSituationGraphChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(capturedGraph).not.toBe(graph); // not the same reference
|
||||||
|
expect(capturedGraph.nodes.find((n) => n.id === "u-1").status).toBe("unknown");
|
||||||
|
expect(capturedGraph.resolvedNodeIds).not.toContain("u-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("open question (unresolved) is NOT affected by Re-open on a different resolved unknown", async () => {
|
||||||
|
const graph = makeFixture();
|
||||||
|
const { reopenResolvedUnknown } = await import("@/lib/graph/reopen-resolved-unknown.js");
|
||||||
|
|
||||||
|
const node = graph.nodes.find((n) => n.id === "u-1");
|
||||||
|
const nextGraph = reopenResolvedUnknown(graph, node.id);
|
||||||
|
|
||||||
|
// u-2 stays as-is
|
||||||
|
const u2 = nextGraph.nodes.find((n) => n.id === "u-2");
|
||||||
|
expect(u2.status).toBe("unknown");
|
||||||
|
expect(u2.kind).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clarified question disappears from resolved set after Re-open", async () => {
|
||||||
|
const graph = makeFixture();
|
||||||
|
const { reopenResolvedUnknown } = await import("@/lib/graph/reopen-resolved-unknown.js");
|
||||||
|
|
||||||
|
const node = graph.nodes.find((n) => n.id === "u-1");
|
||||||
|
const nextGraph = reopenResolvedUnknown(graph, node.id);
|
||||||
|
|
||||||
|
const resolvedIds = new Set(nextGraph.resolvedNodeIds || []);
|
||||||
|
expect(resolvedIds.has("u-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("other Open Questions remain present after Re-open", async () => {
|
||||||
|
const graph = makeFixture();
|
||||||
|
const { reopenResolvedUnknown } = await import("@/lib/graph/reopen-resolved-unknown.js");
|
||||||
|
|
||||||
|
const node = graph.nodes.find((n) => n.id === "u-1");
|
||||||
|
const nextGraph = reopenResolvedUnknown(graph, node.id);
|
||||||
|
|
||||||
|
const openUnknowns = nextGraph.nodes.filter(
|
||||||
|
(n) => n.kind === "unknown" && !new Set(nextGraph.resolvedNodeIds || []).has(n.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(openUnknowns).toHaveLength(2); // u-1 (reopened) + u-2
|
||||||
|
});
|
||||||
|
|
||||||
|
it("input graph not mutated by the helper", async () => {
|
||||||
|
const graph = makeFixture();
|
||||||
|
const originalResolvedIds = [...graph.resolvedNodeIds];
|
||||||
|
const node = graph.nodes.find((n) => n.id === "u-1");
|
||||||
|
const originalStatus = node.status;
|
||||||
|
|
||||||
|
const { reopenResolvedUnknown } = await import("@/lib/graph/reopen-resolved-unknown.js");
|
||||||
|
reopenResolvedUnknown(graph, node.id);
|
||||||
|
|
||||||
|
expect(node.status).toBe(originalStatus);
|
||||||
|
expect(graph.resolvedNodeIds).toEqual(originalResolvedIds);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user