From a9bce79658359cad68cb54bf4506e1e4ae2b27e6 Mon Sep 17 00:00:00 2001 From: robbond Date: Sun, 2 Aug 2026 09:43:42 +0100 Subject: [PATCH] feat: add one-turn situation graph update UI --- components/diagnostics-view.jsx | 17 +- components/graph-update-view.jsx | 169 ++++++++++++++++++++ components/scenario-form.jsx | 184 ++++++++++++++++++++- components/situation-graph-view.jsx | 9 +- lib/graph/apply-proposal.js | 146 ++++++++++++++++- lib/graph/orchestrator.js | 57 +++++-- lib/graph/prompt-builder.js | 1 + tests/graph/apply-proposal.test.js | 54 +++++++ tests/graph/orchestrator.test.js | 13 +- tests/smoke.test.js | 55 +++++-- tests/ui/scenario-form.test.jsx | 238 ++++++++++++++++++++++++++++ 11 files changed, 901 insertions(+), 42 deletions(-) create mode 100644 components/graph-update-view.jsx diff --git a/components/diagnostics-view.jsx b/components/diagnostics-view.jsx index 07433ea..8049c26 100644 --- a/components/diagnostics-view.jsx +++ b/components/diagnostics-view.jsx @@ -55,11 +55,21 @@ export default function DiagnosticsView({ result }) { }, { label: "Node count", - value: diagnostics.nodeCount != null ? diagnostics.nodeCount : "?", + value: + diagnostics.nodeCount != null + ? diagnostics.nodeCount + : diagnostics.graphNodeCount != null + ? diagnostics.graphNodeCount + : "?", }, { label: "Edge count", - value: diagnostics.edgeCount != null ? diagnostics.edgeCount : "?", + value: + diagnostics.edgeCount != null + ? diagnostics.edgeCount + : diagnostics.graphEdgeCount != null + ? diagnostics.graphEdgeCount + : "?", }, { label: "Graph references", @@ -75,6 +85,9 @@ export default function DiagnosticsView({ result }) { const errors = [ ...(result.errors || []), ...(result.validationErrors || []), + ...(result.graphValidationErrors || []), + ...(result.proposalErrors || []), + ...(result.providerErrors || []), ...(result.analysisErrors || []), ]; diff --git a/components/graph-update-view.jsx b/components/graph-update-view.jsx new file mode 100644 index 0000000..78f9bcd --- /dev/null +++ b/components/graph-update-view.jsx @@ -0,0 +1,169 @@ +import React from "react"; + +function ListSection({ title, items, renderItem = (item) => item }) { + if (!items?.length) return null; + + return ( +
+

{title}

+ +
+ ); +} + +export default function GraphUpdateView({ updateResult }) { + if (!updateResult?.proposal) return null; + + const { + resolvedUnknownNodeIds, + affectedNodeIds, + previousActiveUnknownNodeId, + newActiveUnknownNodeId, + changesApplied, + proposal, + previousSituationGraph, + updatedSituationGraph, + } = updateResult; + + const previousNodesById = new Map( + (previousSituationGraph?.nodes || []).map((node) => [node.id, node]), + ); + const updatedNodesById = new Map( + (updatedSituationGraph?.nodes || []).map((node) => [node.id, node]), + ); + const proposalUpdatesByNodeId = new Map( + (proposal.updatedNodes || []).map((update) => [update.nodeId, update]), + ); + + function resolveNodePresentation(nodeId) { + const previousNode = previousNodesById.get(nodeId) || null; + const updatedNode = updatedNodesById.get(nodeId) || null; + const node = updatedNode || previousNode; + const update = proposalUpdatesByNodeId.get(nodeId) || null; + + if (!node) { + return ( +
+
Unknown node (ID: {nodeId})
+
+ ); + } + + return ( +
+
{node.label}
+
+ {node.kind} · {node.confidence} +
+ {(update?.previousStatus || update?.newStatus || node.status) && ( +
+ {update?.previousStatus ? `Previous status: ${update.previousStatus}` : null} + {update?.previousStatus && update?.newStatus ? " → " : null} + {update?.newStatus + ? `New status: ${update.newStatus}` + : !update?.previousStatus + ? `Status: ${node.status}` + : null} +
+ )} + {update?.reason &&
{update.reason}
} +
+ ); + } + + function resolveActiveUnknown(nodeId) { + if (!nodeId) return null; + + const node = updatedNodesById.get(nodeId) || previousNodesById.get(nodeId); + if (!node) { + return `Unknown node (ID: ${nodeId})`; + } + + return `${node.label} · ${node.status} · ${node.confidence}`; + } + + const changeItems = [ + changesApplied?.addedNodeCount + ? `${changesApplied.addedNodeCount} node(s) added` + : null, + changesApplied?.updatedNodeCount + ? `${changesApplied.updatedNodeCount} node(s) updated` + : null, + changesApplied?.addedEdgeCount + ? `${changesApplied.addedEdgeCount} edge(s) added` + : null, + changesApplied?.removedEdgeCount + ? `${changesApplied.removedEdgeCount} edge(s) removed` + : null, + changesApplied?.resolvedUnknownCount + ? `${changesApplied.resolvedUnknownCount} unknown(s) resolved` + : null, + ].filter(Boolean); + + return ( +
+
+

+ Graph update applied +

+
+ {previousActiveUnknownNodeId && ( +
+ Previous active unknown:{" "} + {resolveActiveUnknown(previousActiveUnknownNodeId)} +
+ )} + {newActiveUnknownNodeId && ( +
+ New active unknown:{" "} + {resolveActiveUnknown(newActiveUnknownNodeId)} +
+ )} + {!newActiveUnknownNodeId && previousActiveUnknownNodeId && ( +
+ Next question status: No next + question selected yet. +
+ )} +
+
+ + + + + +
+ + Proposal details + +
+          {JSON.stringify(proposal, null, 2)}
+        
+
+          {JSON.stringify(
+            {
+              previousActiveUnknownNodeId,
+              newActiveUnknownNodeId,
+              resolvedUnknownNodeIds,
+              affectedNodeIds,
+            },
+            null,
+            2,
+          )}
+        
+
+
+ ); +} \ No newline at end of file diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index e71bc28..58e6cc6 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -3,6 +3,7 @@ import React from "react"; import { useState, useRef } from "react"; import DiagnosticsView from "@/components/diagnostics-view"; +import GraphUpdateView from "@/components/graph-update-view"; import SituationGraphView from "@/components/situation-graph-view"; const MAX_LENGTH = 10000; @@ -15,6 +16,45 @@ export async function submitScenarioForStartCase(fetchImpl, scenario) { }); } +export async function submitAnswerForUpdateCase( + fetchImpl, + { situationGraph, previousQuestion, answer }, +) { + if (!answer?.trim()) { + return { + ok: false, + skipped: true, + data: { + success: false, + stage: "request_validation", + error: "Please enter an answer before updating.", + }, + }; + } + + const response = await fetchImpl("/api/cases/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ situationGraph, previousQuestion, answer }), + }); + + return { + ok: response.ok, + skipped: false, + data: await response.json(), + }; +} + +function normaliseStartResult(data) { + return { + ...data, + selectedQuestion: + typeof data?.selectedQuestion === "string" + ? data.selectedQuestion + : data?.selectedQuestion?.question ?? null, + }; +} + export function ScenarioResultPanels({ status, result }) { if (!result) return null; @@ -51,16 +91,58 @@ export function ScenarioResultPanels({ status, result }) { ); } +export function UpdateErrorPanel({ updateError }) { + if (!updateError) return null; + + const errors = [ + ...(updateError.errors || []), + ...(updateError.validationErrors || []), + ...(updateError.graphValidationErrors || []), + ...(updateError.proposalErrors || []), + ...(updateError.providerErrors || []), + ]; + + return ( +
+
+ Update error: {updateError.error} +
+ {errors.length > 0 && ( +
+ + Update details ({errors.length}) + +
    + {errors.map((item, index) => ( +
  • + {typeof item === "string" ? item : item?.message || JSON.stringify(item)} +
  • + ))} +
+
+ )} +
+ ); +} + export default function ScenarioForm() { const [scenario, setScenario] = useState(""); const [status, setStatus] = useState("idle"); // idle | loading | error | success const [result, setResult] = useState(null); + const [answer, setAnswer] = useState(""); + const [updateStatus, setUpdateStatus] = useState("idle"); // idle | loading | error | success + const [updateError, setUpdateError] = useState(null); + const [updateResult, setUpdateResult] = useState(null); const textareaRef = useRef(null); const handleSubmit = async (e) => { e.preventDefault(); setStatus("loading"); setResult(null); + setAnswer(""); + setUpdateStatus("idle"); + setUpdateError(null); + setUpdateResult(null); try { const res = await submitScenarioForStartCase(fetch, scenario); @@ -69,10 +151,10 @@ export default function ScenarioForm() { if (res.ok && data.success) { setStatus("success"); - setResult(data); + setResult(normaliseStartResult(data)); } else { setStatus("error"); - setResult(data); + setResult(normaliseStartResult(data)); } } catch (err) { setStatus("error"); @@ -80,6 +162,55 @@ export default function ScenarioForm() { } }; + const handleUpdate = async (e) => { + e.preventDefault(); + + const submission = await submitAnswerForUpdateCase(fetch, { + situationGraph: result?.situationGraph, + previousQuestion: result?.selectedQuestion, + answer, + }); + + if (submission.skipped) { + setUpdateStatus("error"); + setUpdateError(submission.data); + return; + } + + setUpdateStatus("loading"); + setUpdateError(null); + + try { + const outcome = submission.data; + + if (submission.ok && outcome.success) { + setUpdateStatus("success"); + setUpdateResult({ + ...outcome, + previousSituationGraph: result?.situationGraph ?? null, + }); + setResult((current) => ({ + ...current, + situationGraph: outcome.updatedSituationGraph, + selectedQuestion: null, + diagnostics: outcome.diagnostics, + })); + setAnswer(""); + } else { + setUpdateStatus("error"); + setUpdateError(outcome); + } + } catch (err) { + setUpdateStatus("error"); + setUpdateError({ error: err.message || "Network request failed" }); + } + }; + + const canRenderAnswerForm = + status === "success" && + Boolean(result?.situationGraph) && + Boolean(result?.selectedQuestion); + return (
@@ -105,9 +236,56 @@ export default function ScenarioForm() {
+ {canRenderAnswerForm && ( +
+
+

Selected Question

+

{result.selectedQuestion}

+
+
+ +