From 8432ed45d438e6e9d2d51872df68d5a13e8bd535 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 1 Sep 2026 11:24:48 +0100 Subject: [PATCH] fix(confidence-engine): keep episode reasoning server-side --- app/api/cases/update/route.js | 71 ++++++- components/scenario-form.jsx | 34 ++-- lib/graph/schema.js | 14 ++ tests/ui/scenario-form-episode-done.test.jsx | 187 +++++++++---------- 4 files changed, 183 insertions(+), 123 deletions(-) diff --git a/app/api/cases/update/route.js b/app/api/cases/update/route.js index adcc5af..e4e0a3f 100644 --- a/app/api/cases/update/route.js +++ b/app/api/cases/update/route.js @@ -1,4 +1,7 @@ -import { updateCase } from "@/lib/graph/orchestrator.js"; +import { updateCase, reconsiderCompletedEpisode } from "@/lib/graph/orchestrator.js"; +import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; +import { prepareCompletedEpisode } from "@/lib/graph/episode-preparation.js"; +import { updateCaseEpisodeRequestSchema } from "@/lib/graph/schema.js"; function mapFailureStatus(result) { switch (result?.stage) { @@ -35,6 +38,25 @@ function buildFailureResponse(result) { export async function POST(request) { try { const body = await request.json(); + const isEpisodeMode = body?.episodeMode === true; + + if (isEpisodeMode) { + const parsed = updateCaseEpisodeRequestSchema.safeParse(body); + if (!parsed.success) { + return Response.json( + { + success: false, + stage: "request_validation", + error: "Invalid episode request", + validationErrors: parsed.error.issues, + }, + { status: 400 }, + ); + } + + return await handleEpisodeMode(body.situationGraph, body); + } + const result = await updateCase(body, { applyProposal: true }); if (result.success) { @@ -66,3 +88,50 @@ export async function POST(request) { ); } } + +/** Server-side completed-episode reconsideration flow. */ +async function handleEpisodeMode(situationGraph, body) { + const prepared = prepareCompletedEpisode({ + situationGraph, + targetNodeId: body.targetNodeId, + contributions: body.contributions ?? [], + findings: body.findings, + }); + + if (!prepared?.turns?.length && !prepared?.eligibleCanonicalFindings?.length) { + return Response.json( + { success: false, stage: "preparation", error: "no_episodic_content" }, + { status: 400 }, + ); + } + + const reasoning = await reconsiderCompletedEpisode(prepared); + if (!reasoning.success) { + return Response.json( + buildFailureResponse(reasoning), + { status: mapFailureStatus(reasoning) }, + ); + } + + const application = await applyValidatedProposal({ + situationGraph, + proposal: reasoning.proposal, + evidenceContext: { + isCompletedEpisode: true, + episodeEvidence: prepared, + }, + }); + + if (!application.success) { + return Response.json( + buildFailureResponse(application), + { status: mapFailureStatus(application) }, + ); + } + + return Response.json({ + success: true, + updatedSituationGraph: application.updatedSituationGraph, + proposal: reasoning.proposal, + }, { status: 200 }); +} diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index bbe5b07..148b3fd 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -7,8 +7,6 @@ import ReasoningWorkspace, { LoadingOverlay, ContinueLaterBanner } from "@/compo import { mockFetch, AVAILABLE_SCENARIOS } from "@/lib/mocks/confidence-engine/mock-client"; import { deriveFindingsFromContributions, normalizeFindings } from "@/lib/graph/finding-helpers"; import { prepareCompletedEpisode } from "@/lib/graph/episode-preparation.js"; -import { reconsiderCompletedEpisode } from "@/lib/graph/orchestrator.js"; -import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; import { loadInvestigation, saveInvestigation, clearInvestigation } from "@/lib/storage/investigation-storage"; /* Compile-time env resolution — NEXT_PUBLIC_ vars are injected by Next.js at build */ @@ -244,7 +242,7 @@ export function derivePrimarySurface(result, status, _showExperimentView, scenar /** * Orchestrate the authoritative episode reconsideration flow. - * Exported for deterministic testing — all domain functions accepted as parameters. + * Exported for deterministic testing — domain functions and server endpoint accepted as parameters. */ export async function executeEpisodeDone({ resultSituationGraph, @@ -252,8 +250,7 @@ export async function executeEpisodeDone({ focusedContributions, findings, prepareCompletedEpisode: prepFn = prepareCompletedEpisode, - reconsiderCompletedEpisode: reconsiderFn = reconsiderCompletedEpisode, - applyValidatedProposal: applyFn = applyValidatedProposal, + episodeDoneServer, synthesizeFn, setResult: setAppState, }) { @@ -268,25 +265,18 @@ export async function executeEpisodeDone({ return { success: false, stage: "preparation", reason: "no_episodic_content" }; } - const reasoning = await reconsiderFn(prepared); - if (!reasoning.success) { - return { success: false, stage: "reconsideration", error: reasoning.error }; - } - - const application = await applyFn({ + const serverResult = await episodeDoneServer({ situationGraph: resultSituationGraph, - proposal: reasoning.proposal, - evidenceContext: { - isCompletedEpisode: true, - episodeEvidence: prepared, - }, + targetNodeId, + contributions: focusedContributions ?? [], + findings, }); - if (!application.success) { - return { success: false, stage: "application", error: application.error }; + if (!serverResult.success) { + return { success: false, stage: "episode_done", error: serverResult.error }; } - const nextGraph = application.updatedSituationGraph; + const nextGraph = serverResult.updatedSituationGraph; setAppState(prev => ({ ...(prev ?? {}), situationGraph: nextGraph })); const synthesisResult = await synthesizeFn(nextGraph, findings); @@ -393,6 +383,12 @@ export default function ScenarioForm() { targetNodeId, focusedContributions: focusedContributions ?? [], findings, + episodeDoneServer: (payload) => + fetch("/api/cases/update", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...payload, episodeMode: true }), + }).then((res) => res.json()), synthesizeFn: (graph, fn) => synthesizeFromFindings(fetch, { situationGraph: graph, findings: fn }), setResult, }); diff --git a/lib/graph/schema.js b/lib/graph/schema.js index 518ca2c..832c7ae 100644 --- a/lib/graph/schema.js +++ b/lib/graph/schema.js @@ -204,6 +204,7 @@ export const startCaseRequestSchema = z.object({ promptVersion: z.string().optional(), }); +/** Legacy schema — unchanged contract for existing consumers. */ export const updateCaseRequestSchema = z.object({ situationGraph: situationGraphSchema, previousQuestion: z.string().min(1), @@ -222,6 +223,19 @@ export const updateCaseRequestSchema = z.object({ ).optional(), }); +/** Extended schema with optional fields accepted by the route for all requests. */ +export const updateCaseRequestSchemaExtended = updateCaseRequestSchema.extend({ + targetNodeId: z.string().optional(), + contributions: z.any().array().optional(), +}); + +/** Minimal schema for episode-mode requests (legacy fields not required). */ +export const updateCaseEpisodeRequestSchema = z.object({ + situationGraph: situationGraphSchema, + targetNodeId: z.string().min(1), + contributions: z.any().array().optional(), +}).passthrough(); + // ── Helpers ────────────────────────────────────────── /** Generate a short deterministic ID from a label */ diff --git a/tests/ui/scenario-form-episode-done.test.jsx b/tests/ui/scenario-form-episode-done.test.jsx index 844a733..134927c 100644 --- a/tests/ui/scenario-form-episode-done.test.jsx +++ b/tests/ui/scenario-form-episode-done.test.jsx @@ -33,123 +33,111 @@ function makeProposal() { import { executeEpisodeDone } from "@/components/scenario-form.jsx"; describe("episode-done orchestration", () => { - function makeMocks() { + function makeServerMock() { + const calls = []; return { - prepareCompletedEpisode: vi.fn().mockReturnValue(makePreparedEpisode()), - reconsiderCompletedEpisode: vi.fn().mockResolvedValue({ success: true, proposal: makeProposal() }), - applyValidatedProposal: vi.fn().mockResolvedValue({ success: true, updatedSituationGraph: DISTINCTIVE_GRAPH }), + fn: vi.fn().mockImplementation(async (payload) => { + calls.push(payload); + return { success: true, updatedSituationGraph: DISTINCTIVE_GRAPH, proposal: makeProposal() }; + }), + calls, }; } - it("1. Successful path order", async () => { - const m = makeMocks(); + function makePrepMock(returnValue) { + return vi.fn().mockReturnValue(returnValue ?? makePreparedEpisode()); + } + + it("1. Successful path order (prepare → synthesize)", async () => { const order = []; - - const trackReconsider = vi.fn().mockImplementation(async (...args) => { - order.push("reconsider"); - return { success: true, proposal: makeProposal() }; - }); - const trackApply = vi.fn().mockImplementation(async (...args) => { - order.push("apply"); - return { success: true, updatedSituationGraph: DISTINCTIVE_GRAPH }; - }); - const trackSynth = vi.fn().mockImplementation(async (...args) => { - order.push("synthesis"); - return { ok: true, data: {} }; - }); - - // Override prepare to record in order too - m.prepareCompletedEpisode.mockImplementation(() => { + const serverMock = makeServerMock(); + const prepFn = makePrepMock().mockImplementation(() => { order.push("prepare"); return makePreparedEpisode(); }); + serverMock.fn.mockImplementation(async (payload) => { + order.push("server"); + return { success: true, updatedSituationGraph: DISTINCTIVE_GRAPH, proposal: makeProposal() }; + }); await executeEpisodeDone({ resultSituationGraph: MOCK_SITUATION_GRAPH, targetNodeId: "n-q1", focusedContributions: [{ id: "c-1" }], findings: [], - prepareCompletedEpisode: m.prepareCompletedEpisode, - reconsiderCompletedEpisode: trackReconsider, - applyValidatedProposal: trackApply, - synthesizeFn: trackSynth, + prepareCompletedEpisode: prepFn, + episodeDoneServer: serverMock.fn, + synthesizeFn: vi.fn().mockImplementation(async () => { + order.push("synthesize"); + return { ok: true, data: {} }; + }), setResult: vi.fn(), }); - // Verify exact order expect(order[0]).toBe("prepare"); - expect(order[1]).toBe("reconsider"); - expect(order[2]).toBe("apply"); - expect(order[3]).toBe("synthesis"); + expect(order[1]).toBe("server"); + expect(order[2]).toBe("synthesize"); }); - it("2. Correct prepared episode input", async () => { - const m = makeMocks(); + it("2. Client sends prepared episode source state to server", async () => { const findings = [{ id: "f-1", proposition: "P1" }]; const contributions = [{ id: "c-1", question: "Q?", answer: "A?" }]; + const serverMock = makeServerMock(); await executeEpisodeDone({ resultSituationGraph: MOCK_SITUATION_GRAPH, targetNodeId: "n-q1", focusedContributions: contributions, findings, - prepareCompletedEpisode: m.prepareCompletedEpisode, - reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, - applyValidatedProposal: m.applyValidatedProposal, + prepareCompletedEpisode: makePrepMock(), + episodeDoneServer: serverMock.fn, synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }), setResult: vi.fn(), }); - expect(m.prepareCompletedEpisode).toHaveBeenCalledWith({ - situationGraph: MOCK_SITUATION_GRAPH, - targetNodeId: "n-q1", - contributions, - findings, - }); + const [serverPayload] = serverMock.calls; + expect(serverPayload).toHaveProperty("situationGraph", MOCK_SITUATION_GRAPH); + expect(serverPayload.targetNodeId).toBe("n-q1"); + expect(serverPayload.contributions).toEqual(contributions); + expect(serverPayload.findings).toEqual(findings); }); - it("3. Structured application evidence — no answer fields", async () => { - const m = makeMocks(); - let capturedEvidenceContext = null; - m.applyValidatedProposal.mockImplementation(async (args) => { - capturedEvidenceContext = args?.evidenceContext; - return { success: true, updatedSituationGraph: DISTINCTIVE_GRAPH }; - }); + it("3. No synthetic answer fields in server payload", async () => { + const serverMock = makeServerMock(); await executeEpisodeDone({ resultSituationGraph: MOCK_SITUATION_GRAPH, targetNodeId: "n-q1", focusedContributions: [], findings: [], - prepareCompletedEpisode: m.prepareCompletedEpisode, - reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, - applyValidatedProposal: m.applyValidatedProposal, + prepareCompletedEpisode: makePrepMock(), + episodeDoneServer: serverMock.fn, synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }), setResult: vi.fn(), }); - expect(capturedEvidenceContext?.isCompletedEpisode).toBe(true); - expect(capturedEvidenceContext?.episodeEvidence).toBeDefined(); - const keys = Object.keys(capturedEvidenceContext || {}); + const [serverPayload] = serverMock.calls; + const keys = Object.keys(serverPayload); expect(keys).not.toContain("answer"); expect(keys).not.toContain("syntheticAnswer"); expect(keys).not.toContain("combinedAnswer"); expect(keys).not.toContain("lastAnswer"); + expect(keys).not.toContain("previousQuestion"); }); it("4. nextGraph used for synthesis (not stale state)", async () => { - const m = makeMocks(); + const serverMock = makeServerMock(); let synthesizedGraph = null; - m.applyValidatedProposal.mockResolvedValue({ success: true, updatedSituationGraph: DISTINCTIVE_GRAPH }); + + serverMock.fn.mockResolvedValue({ success: true, updatedSituationGraph: DISTINCTIVE_GRAPH, proposal: makeProposal() }); await executeEpisodeDone({ resultSituationGraph: MOCK_SITUATION_GRAPH, targetNodeId: "n-q1", focusedContributions: [], findings: [{ id: "f-1" }], - prepareCompletedEpisode: m.prepareCompletedEpisode, - reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, - applyValidatedProposal: m.applyValidatedProposal, + prepareCompletedEpisode: makePrepMock(), + episodeDoneServer: serverMock.fn, synthesizeFn: (graph) => { synthesizedGraph = graph; return { ok: true, data: {} }; @@ -160,49 +148,28 @@ describe("episode-done orchestration", () => { expect(synthesizedGraph).toBe(DISTINCTIVE_GRAPH); }); - it("5. Reasoning failure — apply not called, CU synthesis not called", async () => { - const m = makeMocks(); - m.reconsiderCompletedEpisode.mockResolvedValue({ success: false, stage: "provider" }); + it("5. Reasoning/application failure — CU synthesis not called", async () => { + const serverMock = makeServerMock(); + serverMock.fn.mockResolvedValue({ success: false, stage: "provider", error: "provider failed" }); const result = await executeEpisodeDone({ resultSituationGraph: MOCK_SITUATION_GRAPH, targetNodeId: "n-q1", focusedContributions: [], findings: [], - prepareCompletedEpisode: m.prepareCompletedEpisode, - reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, - applyValidatedProposal: m.applyValidatedProposal, + prepareCompletedEpisode: makePrepMock(), + episodeDoneServer: serverMock.fn, synthesizeFn: vi.fn(), setResult: vi.fn(), }); expect(result.success).toBe(false); - expect(m.applyValidatedProposal).not.toHaveBeenCalled(); - expect(m.prepareCompletedEpisode).toHaveBeenCalledTimes(1); - expect(m.reconsiderCompletedEpisode).toHaveBeenCalledTimes(1); }); it("6. Application failure — CU synthesis not called, graph not replaced", async () => { - const m = makeMocks(); - m.applyValidatedProposal.mockResolvedValue({ success: false, stage: "proposal_compatibility" }); + const serverMock = makeServerMock(); + serverMock.fn.mockResolvedValue({ success: false, stage: "proposal_compatibility", error: "incompatible" }); - const result = await executeEpisodeDone({ - resultSituationGraph: MOCK_SITUATION_GRAPH, - targetNodeId: "n-q1", - focusedContributions: [], - findings: [], - prepareCompletedEpisode: m.prepareCompletedEpisode, - reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, - applyValidatedProposal: m.applyValidatedProposal, - synthesizeFn: vi.fn(), - setResult: vi.fn(), - }); - - expect(result.success).toBe(false); - }); - - it("7. Synthesis failure — nextGraph remains installed", async () => { - const m = makeMocks(); const setResult = vi.fn(); await executeEpisodeDone({ @@ -210,9 +177,26 @@ describe("episode-done orchestration", () => { targetNodeId: "n-q1", focusedContributions: [], findings: [], - prepareCompletedEpisode: m.prepareCompletedEpisode, - reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, - applyValidatedProposal: m.applyValidatedProposal, + prepareCompletedEpisode: makePrepMock(), + episodeDoneServer: serverMock.fn, + synthesizeFn: vi.fn(), + setResult, + }); + + expect(setResult).not.toHaveBeenCalled(); + }); + + it("7. Synthesis failure — nextGraph remains installed", async () => { + const serverMock = makeServerMock(); + const setResult = vi.fn(); + + await executeEpisodeDone({ + resultSituationGraph: MOCK_SITUATION_GRAPH, + targetNodeId: "n-q1", + focusedContributions: [], + findings: [], + prepareCompletedEpisode: makePrepMock(), + episodeDoneServer: serverMock.fn, synthesizeFn: vi.fn().mockReturnValue({ ok: false }), setResult, }); @@ -224,37 +208,34 @@ describe("episode-done orchestration", () => { expect(updatedState.situationGraph).toBe(DISTINCTIVE_GRAPH); }); - it("8. Exactly-once — each step invoked once per call", async () => { - const m = makeMocks(); + it("8. Exactly-once — server called once per done action", async () => { + const serverMock = makeServerMock(); await executeEpisodeDone({ resultSituationGraph: MOCK_SITUATION_GRAPH, targetNodeId: "n-q1", focusedContributions: [], findings: [], - prepareCompletedEpisode: m.prepareCompletedEpisode, - reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, - applyValidatedProposal: m.applyValidatedProposal, + prepareCompletedEpisode: makePrepMock(), + episodeDoneServer: serverMock.fn, synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }), setResult: vi.fn(), }); - expect(m.prepareCompletedEpisode).toHaveBeenCalledTimes(1); - expect(m.reconsiderCompletedEpisode).toHaveBeenCalledTimes(1); - expect(m.applyValidatedProposal).toHaveBeenCalledTimes(1); + expect(serverMock.fn).toHaveBeenCalledTimes(1); + expect(serverMock.calls.length).toBe(1); }); - it("9. Legacy Done CU writer retired — pipeline does not use deterministic summary", async () => { - const m = makeMocks(); + it("9. Success returns authoritative nextGraph", async () => { + const serverMock = makeServerMock(); const result = await executeEpisodeDone({ resultSituationGraph: MOCK_SITUATION_GRAPH, targetNodeId: "n-q1", focusedContributions: [], findings: [], - prepareCompletedEpisode: m.prepareCompletedEpisode, - reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, - applyValidatedProposal: m.applyValidatedProposal, + prepareCompletedEpisode: makePrepMock(), + episodeDoneServer: serverMock.fn, synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }), setResult: vi.fn(), });