fix(confidence-engine): keep episode reasoning server-side

This commit is contained in:
2026-09-01 11:24:48 +01:00
parent 650877e5bf
commit 8432ed45d4
4 changed files with 183 additions and 123 deletions
+70 -1
View File
@@ -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) { function mapFailureStatus(result) {
switch (result?.stage) { switch (result?.stage) {
@@ -35,6 +38,25 @@ function buildFailureResponse(result) {
export async function POST(request) { export async function POST(request) {
try { try {
const body = await request.json(); 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 }); const result = await updateCase(body, { applyProposal: true });
if (result.success) { 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 });
}
+15 -19
View File
@@ -7,8 +7,6 @@ import ReasoningWorkspace, { LoadingOverlay, ContinueLaterBanner } from "@/compo
import { mockFetch, AVAILABLE_SCENARIOS } from "@/lib/mocks/confidence-engine/mock-client"; import { mockFetch, AVAILABLE_SCENARIOS } from "@/lib/mocks/confidence-engine/mock-client";
import { deriveFindingsFromContributions, normalizeFindings } from "@/lib/graph/finding-helpers"; import { deriveFindingsFromContributions, normalizeFindings } from "@/lib/graph/finding-helpers";
import { prepareCompletedEpisode } from "@/lib/graph/episode-preparation.js"; 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"; import { loadInvestigation, saveInvestigation, clearInvestigation } from "@/lib/storage/investigation-storage";
/* Compile-time env resolution — NEXT_PUBLIC_ vars are injected by Next.js at build */ /* 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. * 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({ export async function executeEpisodeDone({
resultSituationGraph, resultSituationGraph,
@@ -252,8 +250,7 @@ export async function executeEpisodeDone({
focusedContributions, focusedContributions,
findings, findings,
prepareCompletedEpisode: prepFn = prepareCompletedEpisode, prepareCompletedEpisode: prepFn = prepareCompletedEpisode,
reconsiderCompletedEpisode: reconsiderFn = reconsiderCompletedEpisode, episodeDoneServer,
applyValidatedProposal: applyFn = applyValidatedProposal,
synthesizeFn, synthesizeFn,
setResult: setAppState, setResult: setAppState,
}) { }) {
@@ -268,25 +265,18 @@ export async function executeEpisodeDone({
return { success: false, stage: "preparation", reason: "no_episodic_content" }; return { success: false, stage: "preparation", reason: "no_episodic_content" };
} }
const reasoning = await reconsiderFn(prepared); const serverResult = await episodeDoneServer({
if (!reasoning.success) {
return { success: false, stage: "reconsideration", error: reasoning.error };
}
const application = await applyFn({
situationGraph: resultSituationGraph, situationGraph: resultSituationGraph,
proposal: reasoning.proposal, targetNodeId,
evidenceContext: { contributions: focusedContributions ?? [],
isCompletedEpisode: true, findings,
episodeEvidence: prepared,
},
}); });
if (!application.success) { if (!serverResult.success) {
return { success: false, stage: "application", error: application.error }; return { success: false, stage: "episode_done", error: serverResult.error };
} }
const nextGraph = application.updatedSituationGraph; const nextGraph = serverResult.updatedSituationGraph;
setAppState(prev => ({ ...(prev ?? {}), situationGraph: nextGraph })); setAppState(prev => ({ ...(prev ?? {}), situationGraph: nextGraph }));
const synthesisResult = await synthesizeFn(nextGraph, findings); const synthesisResult = await synthesizeFn(nextGraph, findings);
@@ -393,6 +383,12 @@ export default function ScenarioForm() {
targetNodeId, targetNodeId,
focusedContributions: focusedContributions ?? [], focusedContributions: focusedContributions ?? [],
findings, 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 }), synthesizeFn: (graph, fn) => synthesizeFromFindings(fetch, { situationGraph: graph, findings: fn }),
setResult, setResult,
}); });
+14
View File
@@ -204,6 +204,7 @@ export const startCaseRequestSchema = z.object({
promptVersion: z.string().optional(), promptVersion: z.string().optional(),
}); });
/** Legacy schema — unchanged contract for existing consumers. */
export const updateCaseRequestSchema = z.object({ export const updateCaseRequestSchema = z.object({
situationGraph: situationGraphSchema, situationGraph: situationGraphSchema,
previousQuestion: z.string().min(1), previousQuestion: z.string().min(1),
@@ -222,6 +223,19 @@ export const updateCaseRequestSchema = z.object({
).optional(), ).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 ────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────
/** Generate a short deterministic ID from a label */ /** Generate a short deterministic ID from a label */
+84 -103
View File
@@ -33,123 +33,111 @@ function makeProposal() {
import { executeEpisodeDone } from "@/components/scenario-form.jsx"; import { executeEpisodeDone } from "@/components/scenario-form.jsx";
describe("episode-done orchestration", () => { describe("episode-done orchestration", () => {
function makeMocks() { function makeServerMock() {
const calls = [];
return { return {
prepareCompletedEpisode: vi.fn().mockReturnValue(makePreparedEpisode()), fn: vi.fn().mockImplementation(async (payload) => {
reconsiderCompletedEpisode: vi.fn().mockResolvedValue({ success: true, proposal: makeProposal() }), calls.push(payload);
applyValidatedProposal: vi.fn().mockResolvedValue({ success: true, updatedSituationGraph: DISTINCTIVE_GRAPH }), return { success: true, updatedSituationGraph: DISTINCTIVE_GRAPH, proposal: makeProposal() };
}),
calls,
}; };
} }
it("1. Successful path order", async () => { function makePrepMock(returnValue) {
const m = makeMocks(); return vi.fn().mockReturnValue(returnValue ?? makePreparedEpisode());
}
it("1. Successful path order (prepare → synthesize)", async () => {
const order = []; const order = [];
const serverMock = makeServerMock();
const trackReconsider = vi.fn().mockImplementation(async (...args) => { const prepFn = makePrepMock().mockImplementation(() => {
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(() => {
order.push("prepare"); order.push("prepare");
return makePreparedEpisode(); return makePreparedEpisode();
}); });
serverMock.fn.mockImplementation(async (payload) => {
order.push("server");
return { success: true, updatedSituationGraph: DISTINCTIVE_GRAPH, proposal: makeProposal() };
});
await executeEpisodeDone({ await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH, resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1", targetNodeId: "n-q1",
focusedContributions: [{ id: "c-1" }], focusedContributions: [{ id: "c-1" }],
findings: [], findings: [],
prepareCompletedEpisode: m.prepareCompletedEpisode, prepareCompletedEpisode: prepFn,
reconsiderCompletedEpisode: trackReconsider, episodeDoneServer: serverMock.fn,
applyValidatedProposal: trackApply, synthesizeFn: vi.fn().mockImplementation(async () => {
synthesizeFn: trackSynth, order.push("synthesize");
return { ok: true, data: {} };
}),
setResult: vi.fn(), setResult: vi.fn(),
}); });
// Verify exact order
expect(order[0]).toBe("prepare"); expect(order[0]).toBe("prepare");
expect(order[1]).toBe("reconsider"); expect(order[1]).toBe("server");
expect(order[2]).toBe("apply"); expect(order[2]).toBe("synthesize");
expect(order[3]).toBe("synthesis");
}); });
it("2. Correct prepared episode input", async () => { it("2. Client sends prepared episode source state to server", async () => {
const m = makeMocks();
const findings = [{ id: "f-1", proposition: "P1" }]; const findings = [{ id: "f-1", proposition: "P1" }];
const contributions = [{ id: "c-1", question: "Q?", answer: "A?" }]; const contributions = [{ id: "c-1", question: "Q?", answer: "A?" }];
const serverMock = makeServerMock();
await executeEpisodeDone({ await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH, resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1", targetNodeId: "n-q1",
focusedContributions: contributions, focusedContributions: contributions,
findings, findings,
prepareCompletedEpisode: m.prepareCompletedEpisode, prepareCompletedEpisode: makePrepMock(),
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, episodeDoneServer: serverMock.fn,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }), synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }),
setResult: vi.fn(), setResult: vi.fn(),
}); });
expect(m.prepareCompletedEpisode).toHaveBeenCalledWith({ const [serverPayload] = serverMock.calls;
situationGraph: MOCK_SITUATION_GRAPH, expect(serverPayload).toHaveProperty("situationGraph", MOCK_SITUATION_GRAPH);
targetNodeId: "n-q1", expect(serverPayload.targetNodeId).toBe("n-q1");
contributions, expect(serverPayload.contributions).toEqual(contributions);
findings, expect(serverPayload.findings).toEqual(findings);
});
}); });
it("3. Structured application evidence — no answer fields", async () => { it("3. No synthetic answer fields in server payload", async () => {
const m = makeMocks(); const serverMock = makeServerMock();
let capturedEvidenceContext = null;
m.applyValidatedProposal.mockImplementation(async (args) => {
capturedEvidenceContext = args?.evidenceContext;
return { success: true, updatedSituationGraph: DISTINCTIVE_GRAPH };
});
await executeEpisodeDone({ await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH, resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1", targetNodeId: "n-q1",
focusedContributions: [], focusedContributions: [],
findings: [], findings: [],
prepareCompletedEpisode: m.prepareCompletedEpisode, prepareCompletedEpisode: makePrepMock(),
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, episodeDoneServer: serverMock.fn,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }), synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }),
setResult: vi.fn(), setResult: vi.fn(),
}); });
expect(capturedEvidenceContext?.isCompletedEpisode).toBe(true); const [serverPayload] = serverMock.calls;
expect(capturedEvidenceContext?.episodeEvidence).toBeDefined(); const keys = Object.keys(serverPayload);
const keys = Object.keys(capturedEvidenceContext || {});
expect(keys).not.toContain("answer"); expect(keys).not.toContain("answer");
expect(keys).not.toContain("syntheticAnswer"); expect(keys).not.toContain("syntheticAnswer");
expect(keys).not.toContain("combinedAnswer"); expect(keys).not.toContain("combinedAnswer");
expect(keys).not.toContain("lastAnswer"); expect(keys).not.toContain("lastAnswer");
expect(keys).not.toContain("previousQuestion");
}); });
it("4. nextGraph used for synthesis (not stale state)", async () => { it("4. nextGraph used for synthesis (not stale state)", async () => {
const m = makeMocks(); const serverMock = makeServerMock();
let synthesizedGraph = null; let synthesizedGraph = null;
m.applyValidatedProposal.mockResolvedValue({ success: true, updatedSituationGraph: DISTINCTIVE_GRAPH });
serverMock.fn.mockResolvedValue({ success: true, updatedSituationGraph: DISTINCTIVE_GRAPH, proposal: makeProposal() });
await executeEpisodeDone({ await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH, resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1", targetNodeId: "n-q1",
focusedContributions: [], focusedContributions: [],
findings: [{ id: "f-1" }], findings: [{ id: "f-1" }],
prepareCompletedEpisode: m.prepareCompletedEpisode, prepareCompletedEpisode: makePrepMock(),
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, episodeDoneServer: serverMock.fn,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: (graph) => { synthesizeFn: (graph) => {
synthesizedGraph = graph; synthesizedGraph = graph;
return { ok: true, data: {} }; return { ok: true, data: {} };
@@ -160,49 +148,28 @@ describe("episode-done orchestration", () => {
expect(synthesizedGraph).toBe(DISTINCTIVE_GRAPH); expect(synthesizedGraph).toBe(DISTINCTIVE_GRAPH);
}); });
it("5. Reasoning failure — apply not called, CU synthesis not called", async () => { it("5. Reasoning/application failure — CU synthesis not called", async () => {
const m = makeMocks(); const serverMock = makeServerMock();
m.reconsiderCompletedEpisode.mockResolvedValue({ success: false, stage: "provider" }); serverMock.fn.mockResolvedValue({ success: false, stage: "provider", error: "provider failed" });
const result = await executeEpisodeDone({ const result = await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH, resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1", targetNodeId: "n-q1",
focusedContributions: [], focusedContributions: [],
findings: [], findings: [],
prepareCompletedEpisode: m.prepareCompletedEpisode, prepareCompletedEpisode: makePrepMock(),
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, episodeDoneServer: serverMock.fn,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: vi.fn(), synthesizeFn: vi.fn(),
setResult: vi.fn(), setResult: vi.fn(),
}); });
expect(result.success).toBe(false); 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 () => { it("6. Application failure — CU synthesis not called, graph not replaced", async () => {
const m = makeMocks(); const serverMock = makeServerMock();
m.applyValidatedProposal.mockResolvedValue({ success: false, stage: "proposal_compatibility" }); 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(); const setResult = vi.fn();
await executeEpisodeDone({ await executeEpisodeDone({
@@ -210,9 +177,26 @@ describe("episode-done orchestration", () => {
targetNodeId: "n-q1", targetNodeId: "n-q1",
focusedContributions: [], focusedContributions: [],
findings: [], findings: [],
prepareCompletedEpisode: m.prepareCompletedEpisode, prepareCompletedEpisode: makePrepMock(),
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, episodeDoneServer: serverMock.fn,
applyValidatedProposal: m.applyValidatedProposal, 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 }), synthesizeFn: vi.fn().mockReturnValue({ ok: false }),
setResult, setResult,
}); });
@@ -224,37 +208,34 @@ describe("episode-done orchestration", () => {
expect(updatedState.situationGraph).toBe(DISTINCTIVE_GRAPH); expect(updatedState.situationGraph).toBe(DISTINCTIVE_GRAPH);
}); });
it("8. Exactly-once — each step invoked once per call", async () => { it("8. Exactly-once — server called once per done action", async () => {
const m = makeMocks(); const serverMock = makeServerMock();
await executeEpisodeDone({ await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH, resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1", targetNodeId: "n-q1",
focusedContributions: [], focusedContributions: [],
findings: [], findings: [],
prepareCompletedEpisode: m.prepareCompletedEpisode, prepareCompletedEpisode: makePrepMock(),
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, episodeDoneServer: serverMock.fn,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }), synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }),
setResult: vi.fn(), setResult: vi.fn(),
}); });
expect(m.prepareCompletedEpisode).toHaveBeenCalledTimes(1); expect(serverMock.fn).toHaveBeenCalledTimes(1);
expect(m.reconsiderCompletedEpisode).toHaveBeenCalledTimes(1); expect(serverMock.calls.length).toBe(1);
expect(m.applyValidatedProposal).toHaveBeenCalledTimes(1);
}); });
it("9. Legacy Done CU writer retired — pipeline does not use deterministic summary", async () => { it("9. Success returns authoritative nextGraph", async () => {
const m = makeMocks(); const serverMock = makeServerMock();
const result = await executeEpisodeDone({ const result = await executeEpisodeDone({
resultSituationGraph: MOCK_SITUATION_GRAPH, resultSituationGraph: MOCK_SITUATION_GRAPH,
targetNodeId: "n-q1", targetNodeId: "n-q1",
focusedContributions: [], focusedContributions: [],
findings: [], findings: [],
prepareCompletedEpisode: m.prepareCompletedEpisode, prepareCompletedEpisode: makePrepMock(),
reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, episodeDoneServer: serverMock.fn,
applyValidatedProposal: m.applyValidatedProposal,
synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }), synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }),
setResult: vi.fn(), setResult: vi.fn(),
}); });