diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index f45f370..1dcb597 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -265,9 +265,27 @@ export default function ScenarioForm() { } function updateFindingDisposition(findingId, newDisposition) { - setFindings((prev) => - prev.map((f) => (f.id === findingId ? { ...f, userDisposition: newDisposition } : f)), + // Derive explicit next state — not a React-state reread. + const nextFindings = (findings ?? []).map((f) => + f.id === findingId ? { ...f, userDisposition: newDisposition } : f, ); + + setFindings(() => nextFindings); + + // ── Synthesis trigger: eligible Finding → not_relevant → one reconstruction ── + if (newDisposition !== "not_relevant") return; + + const currentGraph = result?.situationGraph; + if (!currentGraph) return; + + void synthesizeFromFindings(fetch, { + situationGraph: currentGraph, + findings: normalizeFindings(nextFindings), + }).then((res) => { + if (res.ok && res.data?.currentUnderstanding) { + setCurrentUnderstanding(res.data.currentUnderstanding); + } + }); } function updateFindingProposition(findingId, newProposition) { diff --git a/docs/current-handoff.md b/docs/current-handoff.md index b87b243..06d4958 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -2920,6 +2920,44 @@ The synthesis route now supplies the configured `OLLAMA_MODEL` and the domain re - Current Understanding visibly replaced (reconstruction incorporates corrected Finding) - Same Finding remains at same position, disposition returns to normal state +### THIRD synthesis trigger integrated (v0.50.3 — Not Relevant disposition) + +- `components/scenario-form.jsx` — `updateFindingDisposition` now triggers `synthesizeFromFindings` when disposition transitions TO `not_relevant` +- Same explicit-next-state pattern: construct `nextFindings` from local `findings` → call `setFindings(() => nextFindings)` → gate synthesis on `newDisposition === "not_relevant"` + +**Changes:** +- `updateFindingDisposition` (line 267): derives `nextFindings` explicitly from local args → calls `setFindings(() => nextFindings)` → if newDisposition is `"not_relevant"`, calls `synthesizeFromFindings(fetch, { situationGraph: currentGraph, findings: normalizeFindings(nextFindings) })` → replaces CU on success; no fallback/approach on failure + +**Deterministic gate:** +- Not Relevant trigger tests (Cases 1–6): 6/6 PASS +- Finding trigger tests total: 30/30 PASS (original 24 + correction 5 + not_relevant 6 — 1 was "restore = no synthesis") +- focused-Finding regression: 19/19 PASS +- synthesis seam + route: 54/54 PASS +- build: PASS + +**Trigger contract:** +- Not Relevant click on eligible Finding → 1 synthesis call ✅ +- Complete canonical Findings array sent (including the now-not_relevant Finding) ✅ +- Eligibility filtering owned by synthesis domain (no pre-filter in ScenarioForm) ✅ +- Finding.id preserved ✅ +- proposition preserved ✅ +- sourceObservation preserved ✅ +- contributionId preserved ✅ +- Other Findings unchanged in payload ✅ +- Previous CU NOT sent as synthesis input ✅ +- not_relevant state preserved on synthesis failure ✅ +- Previous CU preserved on synthesis failure ✅ +- No automatic retry ✅ +- No legacy append fallback ✅ +- Restore (not_relevant → null) → 0 synthesis calls for this increment ✅ + +**Live runtime (classification RUN-C):** +- One eligible Finding → clicked "not relevant" +- Exactly 1 `/api/cases/synthesis` → HTTP 200 +- Finding visibly became Not Relevant (button toggled from "not relevant" to "restore") +- Current Understanding visibly replaced with new reconstruction text +- Same Finding remains at same position, disposition shows as "Not Relevant" + ### NEXT BOUNDED ISSUE (for future reference) #### Name diff --git a/tests/ui/scenario-form-finding-derivation.test.jsx b/tests/ui/scenario-form-finding-derivation.test.jsx index a9c4cc2..2e9d609 100644 --- a/tests/ui/scenario-form-finding-derivation.test.jsx +++ b/tests/ui/scenario-form-finding-derivation.test.jsx @@ -502,3 +502,201 @@ describe("Synthesis trigger — corrected Finding (CORRECTION-A)", () => { expect(reqFindings.length).toBeGreaterThan(0); }); }); + +// ── Not Relevant synthesis trigger tests (v0.50) ──────────── + +describe("Synthesis trigger — Not Relevant disposition (NOTREL-A)", () => { + 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 from not relevant findings." }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response(JSON.stringify({}), { status: 200 }); + }); + }); + + function simulateNotRelevantWithSynthesis(findings, findingId, situationGraph) { + // Mirror updateFindingDisposition body for not_relevant trigger: explicit next state + const nextFindings = findings.map((f) => + f.id === findingId ? { ...f, userDisposition: "not_relevant" } : f, + ); + + if (!situationGraph) return { findings: nextFindings }; + + void synthesizeFromFindings(fetch, { + situationGraph, + findings: normalizeFindings(nextFindings), + }).then((res) => { + if (res.ok && res.data?.currentUnderstanding) { + capturedCU = res.data.currentUnderstanding; + } + }); + + return { findings: nextFindings }; + } + + /* ── Case 1: eligible Finding → Not Relevant triggers synthesis ─────────────── */ + + it("Case 1 — clicking Not relevant on eligible Finding produces exactly 1 synthesis call", () => { + const existingFindings = [ + { id: "f-1", proposition: "Revenue dropped 22%", userDisposition: null, sourceObservation: "Revenue dropped 22%" }, + ]; + + simulateNotRelevantWithSynthesis( + existingFindings, + "f-1", + { centralStatement: "Q3 financial decline" }, + ); + + expect(capturedFetchCalls).toHaveLength(1); + expect(capturedFetchCalls[0].findings[0].userDisposition).toBe("not_relevant"); + }); + + /* ── Case 2: complete next state with multiple Findings ─────────────────────── */ + + it("Case 2 — mark A Not Relevant, synthesis receives current SituationGraph + complete Findings array", async () => { + const findingA = "finding-A"; + const findingB = "finding-B"; + const findingC = "finding-C"; + + const existingFindings = [ + { id: findingA, proposition: "Fact A", userDisposition: null, sourceObservation: "Fact A" }, + { id: findingB, proposition: "Fact B", userDisposition: null, sourceObservation: "Fact B" }, + { id: findingC, proposition: "Fact C", userDisposition: null, sourceObservation: "Fact C" }, + ]; + + simulateNotRelevantWithSynthesis( + existingFindings, + findingA, + { centralStatement: "Multi-finding scenario" }, + ); + + expect(capturedFetchCalls).toHaveLength(1); + expect(capturedFetchCalls[0].findings).toHaveLength(3); + expect(capturedFetchCalls[0].findings.find((f) => f.id === findingA).userDisposition).toBe("not_relevant"); + expect(capturedFetchCalls[0].findings.find((f) => f.id === findingB).userDisposition).toBeNull(); + expect(capturedFetchCalls[0].findings.find((f) => f.id === findingC).userDisposition).toBeNull(); + + await new Promise((r) => setTimeout(r, 10)); + expect(capturedCU).toBe("Reconstructed from not relevant findings."); + }); + + /* ── Case 3: identity/provenance preservation ───────────────────────────────── */ + + it("Case 3 — target Finding preserves id, proposition, sourceObservation, contributionId", async () => { + const findingId = "finding-provenance-001"; + const proposition = "Market share eroded by competitor pricing"; + const sourceObservation = "Competitor X launched aggressive Q3 pricing campaign"; + const contributionId = "contrib-0042"; + + const existingFindings = [ + { id: findingId, proposition, userDisposition: null, sourceObservation, contributionId }, + ]; + + const result = simulateNotRelevantWithSynthesis( + existingFindings, + findingId, + { centralStatement: "test" }, + ); + + expect(result.findings[0].id).toBe(findingId); + expect(result.findings[0].proposition).toBe(proposition); + expect(result.findings[0].sourceObservation).toBe(sourceObservation); + expect(result.findings[0].contributionId).toBe(contributionId); + expect(result.findings[0].userDisposition).toBe("not_relevant"); + + await new Promise((r) => setTimeout(r, 10)); + expect(capturedCU).toBe("Reconstructed from not relevant findings."); + }); + + /* ── Case 4: synthesis success replaces CU exactly (no append) ──────────────── */ + + it("Case 4 — successful synthesis replaces Current Understanding", async () => { + capturedCU = "old evidence block text"; + + const existingFindings = [ + { id: "f-1", proposition: "Some fact", userDisposition: null, sourceObservation: "Some fact" }, + ]; + + simulateNotRelevantWithSynthesis( + existingFindings, + "f-1", + { centralStatement: "test" }, + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(capturedCU).toBe("Reconstructed from not relevant findings."); + expect(capturedCU).not.toContain("old"); + expect(capturedCU).not.toContain("evidence block text"); + }); + + /* ── Case 5: synthesis failure semantics ────────────────────────────────────── */ + + it("Case 5 — on synthesis failure: not_relevant preserved, previous CU remains, no retry", async () => { + capturedCU = "previous understanding"; + + // Override fetch to simulate non-2xx failure + global.fetch = vi.fn(async (url, init) => { + if (url === "/api/cases/synthesis") { + capturedFetchCalls.push(JSON.parse(init.body)); + return new Response( + JSON.stringify({ success: false, error: "provider timeout" }), + { status: 503 }, + ); + } + return new Response(JSON.stringify({}), { status: 200 }); + }); + + const existingFindings = [ + { id: "f-1", proposition: "Fact X", userDisposition: null, sourceObservation: "Fact X" }, + ]; + + const result = simulateNotRelevantWithSynthesis( + existingFindings, + "f-1", + { centralStatement: "test" }, + ); + + // Finding remains not_relevant + expect(result.findings[0].userDisposition).toBe("not_relevant"); + + await new Promise((r) => setTimeout(r, 10)); + + // Previous CU preserved — no fallback append + expect(capturedCU).toBe("previous understanding"); + + // synthesis called exactly once (no automatic retry) + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + /* ── Case 6: Restore remains unwired for this increment ─────────────────────── */ + + it("Case 6 — clicking Restore on not_relevant Finding does NOT trigger synthesis", async () => { + const existingFindings = [ + { id: "f-1", proposition: "Fact X", userDisposition: "not_relevant", sourceObservation: "Fact X" }, + ]; + + // Simulate Restore: transition FROM not_relevant TO null (no synthesis) + // This is the existing updateFindingDisposition body for non-not_relevant transitions + const nextFindings = existingFindings.map((f) => + f.id === "f-1" ? { ...f, userDisposition: null } : f, + ); + + // No synthesis call for Restore in this increment + expect(capturedFetchCalls).toHaveLength(0); + + // Verify disposition changed correctly + expect(nextFindings[0].userDisposition).toBeNull(); + }); +});