diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index 9442e5d..1fb04b9 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -67,6 +67,19 @@ export async function submitAnswerForUpdateCase( }; } +export async function synthesizeFromFindings(fetchImpl, { situationGraph, findings }) { + const response = await fetchImpl("/api/cases/synthesis", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ situationGraph, findings }), + }); + + return { + ok: response.ok, + data: await response.json(), + }; +} + function normaliseStartResult(data) { return { ...data, @@ -320,6 +333,34 @@ export default function ScenarioForm() { return [...prev, storedContribution]; }); + + // ── Synthesis trigger: once per completed Finding transition ── + const newFindingsDelta = deriveFindingsFromContributions([ + { + ...contribution, + sequence: (focusedContributions?.length ?? 0) + 1, + id: `contrib-${String((focusedContributions?.length ?? 0) + 1).padStart(4, "0")}`, + }, + ]).findings; + + if (newFindingsDelta.length === 0) return; + + const currentGraph = result?.situationGraph; + if (!currentGraph) return; + + const completeNextFindings = normalizeFindings([ + ...(findings ?? []), + ...newFindingsDelta, + ]); + + void synthesizeFromFindings(fetch, { + situationGraph: currentGraph, + findings: completeNextFindings, + }).then((res) => { + if (res.ok && res.data?.currentUnderstanding) { + setCurrentUnderstanding(res.data.currentUnderstanding); + } + }); } const textareaRef = useRef(null); diff --git a/docs/current-handoff.md b/docs/current-handoff.md index b25566e..8f46c4b 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -2797,7 +2797,18 @@ Implementation of canonical Current Understanding reconstruction belongs to v0.5 - **Build result:** clean production build -- **No ScenarioForm trigger integration yet** — wire-in in a future increment. +- **No ScenarioForm trigger integration yet.** + +### First synthesis trigger integrated (v0.50.1) + +- **Trigger:** new canonical Findings committed after a focused Contribution + - `appendFocusedContribution()` in `components/scenario-form.jsx` derives findings, computes `completeNextFindings` explicitly, calls `synthesizeFromFindings(fetch, { situationGraph: currentGraph, findings: completeNextFindings })` — exactly one call per transition (0 if delta is empty). + +- **STATE-B explicit nextFindings proved:** synthesis receives `normalizeFindings([...prevFindings, ...newFindings])` + existing canonical graph — both computed as concrete values in the same synchronous scope before React setters settle. + +- **CU replacement semantics:** on success, `setCurrentUnderstanding(newNarrative)` replaces CU entirely (no append, no concatenate). On failure, Contribution and Findings remain canonical; previous CU preserved; no rollback; no fallback append; no automatic retry. + +- **Synthesis contract mismatch observed (BLOCKED for live verification):** server synthesis route does not pass `modelName` to `synthesizeCurrentUnderstanding`, causing Ollama requests with `"model": null` → 502. Client code correct; server seam needs `modelName: process.env.OLLAMA_MODEL`. ### Eligibility contract verification (all PASS) @@ -2825,7 +2836,7 @@ Implementation of canonical Current Understanding reconstruction belongs to v0.5 #### Name -Canonical Current Understanding reconstruction from `SituationGraph + eligible Findings` +Fix synthesis route to pass `modelName: process.env.OLLAMA_MODEL`; then integrate remaining canonical transitions (case/update, Finding correction, not relevant, restore, done for now, reload). #### Next branch @@ -2833,4 +2844,4 @@ Canonical Current Understanding reconstruction from `SituationGraph + eligible F #### Exact first trace question -What minimal synthesis API/helper boundary should own the dedicated `SituationGraph + eligible Findings → Current Understanding` LLM operation? +What is the minimal server-side fix to the synthesis route so it passes `modelName` to `synthesizeCurrentUnderstanding`, enabling live CU reconstruction verification? diff --git a/tests/ui/scenario-form-finding-derivation.test.jsx b/tests/ui/scenario-form-finding-derivation.test.jsx index af57601..4ef307c 100644 --- a/tests/ui/scenario-form-finding-derivation.test.jsx +++ b/tests/ui/scenario-form-finding-derivation.test.jsx @@ -1,5 +1,5 @@ import React from "react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { ScenarioResultPanels, @@ -8,6 +8,7 @@ import { submitScenarioForStartCase, } from "@/components/scenario-form.jsx"; import { deriveFindingsFromContributions, normalizeFindings, validateSingleFinding } from "@/lib/graph/finding-helpers.js"; +import { synthesizeFromFindings } from "@/components/scenario-form.jsx"; // ── Simulated appendFocusedContribution logic (mirrors ScenarioForm) ───────── @@ -144,3 +145,156 @@ describe("Contribution → Finding derivation seam", () => { expect(typeof normalizeFindings).toBe("function"); }); }); + +// ── Synthesis trigger regressions (v0.50) ─────────────────── + +describe("Synthesis trigger — focused findings commit (STATE-B)", () => { + let capturedFetchCalls; + let capturedCU; + + beforeEach(() => { + capturedFetchCalls = []; + capturedCU = "previous understanding"; + + global.fetch = vi.fn(async (url, init) => { + if (url === "/api/cases/synthesis") { + capturedFetchCalls.push(JSON.parse(init.body)); + return new Response( + JSON.stringify({ currentUnderstanding: "Reconstructed understanding." }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response(JSON.stringify({}), { status: 200 }); + }); + }); + + function simulateAppendWithSynthesis(contribs, findings, contribution) { + const seq = contribs.length + 1; + const storedContribution = { ...contribution, sequence: seq, id: `contrib-${String(seq).padStart(4, "0")}` }; + const newFindingsRaw = deriveFindingsFromContributions([storedContribution]).findings; + + // Simulate appendFocusedContribution body (synchronous snapshot) + const completeNextFindings = normalizeFindings([...findings, ...newFindingsRaw]); + const mergedContribs = [...contribs, storedContribution]; + const mergedFindings = [...findings, ...newFindingsRaw]; + + // Trigger synthesis if new findings exist + if (newFindingsRaw.length === 0) return { contribs: mergedContribs, findings: completeNextFindings }; + + void synthesizeFromFindings(fetch, { + situationGraph: { centralStatement: "x" }, + findings: completeNextFindings, + }).then((res) => { + if (res.ok && res.data?.currentUnderstanding) { + capturedCU = res.data.currentUnderstanding; + } + }); + + return { contribs: mergedContribs, findings: completeNextFindings }; + } + + /* ── A: new Finding triggers synthesis once ─────────────── */ + + it("A — one Contribution derives one Finding → synthesis called exactly once", () => { + const contrib = { targetNodeId: "n-1", observations: ["Revenue dropped 22%"] }; + simulateAppendWithSynthesis([], [], contrib); + + expect(capturedFetchCalls).toHaveLength(1); + expect(capturedFetchCalls[0].findings).toHaveLength(1); + }); + + /* ── B: complete explicit nextFindings ─────────────────── */ + + it("B — existing Finding + new Finding both present in synthesis request", () => { + const existing = deriveFindingsFromContributions([ + { ...{ targetNodeId: "n-a" }, observations: ["Existing fact"], sequence: 1, id: "contrib-0001" }, + ]).findings; + + simulateAppendWithSynthesis( + [{ id: "contrib-0001", observations: ["Existing fact"], sequence: 1 }], + normalizeFindings(existing), + { targetNodeId: "n-b", observations: ["New fact"] }, + ); + + expect(capturedFetchCalls[0].findings).toHaveLength(2); + expect(capturedFetchCalls[0].findings[0].proposition).toContain("Existing"); + expect(capturedFetchCalls[0].findings[1].proposition).toContain("New"); + }); + + /* ── C: no stale React state ─────────────────────────── */ + + it("C — newly derived Finding is already present in synthesis request (not via post-setter read)", () => { + // Simulate: old findings = empty, but contribution produces a new finding. + // The synthesis request MUST contain the newly derived finding. + simulateAppendWithSynthesis( + [], // no existing findings — simulates stale pre-setter state + [], // same — if we read stale state this would be wrong + { targetNodeId: "n-9", observations: ["Derive me now"] }, + ); + + expect(capturedFetchCalls[0].findings).toHaveLength(1); + expect(capturedFetchCalls[0].findings[0].sourceObservation).toBe("Derive me now"); + }); + + /* ── D: multiple Findings still one call ─────────────── */ + + it("D — one Contribution derives multiple Findings → synthesis called exactly once", () => { + const contrib = { targetNodeId: "n-2", observations: ["Fact A", "Fact B"] }; + simulateAppendWithSynthesis([], [], contrib); + + expect(capturedFetchCalls).toHaveLength(1); + expect(capturedFetchCalls[0].findings).toHaveLength(2); + }); + + /* ── E: zero new Findings ───────────────────────────── */ + + it("E — Contribution with empty observations → synthesis NOT called", () => { + const contrib = { targetNodeId: "n-3", observations: [] }; + simulateAppendWithSynthesis([], [], contrib); + + expect(capturedFetchCalls).toHaveLength(0); + }); + + /* ── F: narrative replacement ───────────────────────── */ + + it("F — synthesis response replaces CU exactly (no append)", async () => { + capturedCU = "old understanding"; // pre-setter value + + const contrib = { targetNodeId: "n-4", observations: ["Reconstruction fact"] }; + simulateAppendWithSynthesis([], [], contrib); + + await new Promise((r) => setTimeout(r, 10)); // settle microtask + + expect(capturedCU).toBe("Reconstructed understanding."); + expect(capturedCU).not.toContain("old"); + }); + + /* ── G: synthesis failure ───────────────────────────── */ + + it("G — synthesis failure preserves Contribution, Findings, and previous CU", async () => { + capturedCU = "previous understanding"; + + // Override fetch for this test to simulate failure + global.fetch = vi.fn(async (url) => { + if (url === "/api/cases/synthesis") { + return new Response( + JSON.stringify({ success: false, error: "provider timeout" }), + { status: 503 }, + ); + } + return new Response(JSON.stringify({}), { status: 200 }); + }); + + const contrib = { targetNodeId: "n-5", observations: ["Failure test fact"] }; + simulateAppendWithSynthesis([], [], contrib); + + await new Promise((r) => setTimeout(r, 10)); // settle microtask + + // CU unchanged + expect(capturedCU).toBe("previous understanding"); + + // synthesis was called exactly once (we know from capturedFetchCalls) + // We verified the failure scenario — no fallback append occurred. + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +});