From c89cc51ae619c2033153c9aa8c1817cbd637a292 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 1 Sep 2026 09:28:02 +0100 Subject: [PATCH] feat(confidence-engine): add completed episode reasoning seam --- lib/graph/orchestrator.js | 106 ++++++++++++++ tests/graph/episode-reasoning-seam.test.js | 158 +++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 tests/graph/episode-reasoning-seam.test.js diff --git a/lib/graph/orchestrator.js b/lib/graph/orchestrator.js index 7af8eda..c5974b4 100644 --- a/lib/graph/orchestrator.js +++ b/lib/graph/orchestrator.js @@ -31,6 +31,8 @@ import { validateGraphReferences, } from "./utils.js"; import { validateFindings, produceFindingInformedSummary } from "./finding-helpers.js"; +import { prepareCompletedEpisode } from "./episode-preparation.js"; +import { buildEpisodeAwareGraphPrompt } from "./prompt-builder-episode.js"; function toValidationErrors(error) { return ( @@ -1106,3 +1108,107 @@ async function updateCaseWithDependencies(body, dependencies = {}) { }), }; } + +// ── Episode-aware reconsideration seam ───────────────────── + +/** + * Reason over a completed focused episode and return a parsed GraphUpdateProposal. + * + * This is the smallest sibling to updateCase that reuses existing provider, + * model, and parsing mechanics — only the prompt path differs. + */ +export async function reconsiderCompletedEpisode(epiParams, deps = {}) { + const { situationGraph: epiGraph, targetNodeId, contributions, findings, episode: rawEpisode } = epiParams; + + // Accept prepared episode or produce it deterministically + + let episode; + + if (rawEpisode) { + episode = rawEpisode; + } else if (epiParams?.turns || epiParams?.eligibleCanonicalFindings) { + // Already a prepared episode passed as first positional arg + episode = epiParams; + } else { + episode = prepareCompletedEpisode(epiParams); + } + + if (!episode?.turns && !episode?.eligibleCanonicalFindings) { + return { + success: false, + stage: "input_validation", + error: "Invalid prepared episode — requires turns or findings", + statusCode: 400, + }; + } + + const getModelName = deps.getModelName; + const buildEpisodePrompt = deps.buildEpisodePrompt; + const parseProposal = deps.parseProposal; + const provider = epiParams.provider ?? deps.provider; + + let modelName = null; + let rawResponse; + const startedAt = Date.now(); + + try { + modelName = deps.modelName ?? (deps.config ? deps.config.OLLAMA_MODEL : null) ?? getModelName?.() ?? null; + + const promptBuilder = buildEpisodePrompt ?? buildEpisodeAwareGraphPrompt; + const prompt = promptBuilder({ episode }); + + const prov = provider ?? getProvider(); + rawResponse = await prov.generateReconstruction(prompt, modelName); + } catch (error) { + return { + success: false, + stage: "provider", + error: "Graph update proposal generation failed", + providerErrors: [ + sanitiseErrorMessage( + error, + "Provider failed to generate graph update proposal from episode", + ), + ], + diagnostics: { + promptVersion: null, + modelName, + responseDurationMs: Date.now() - startedAt, + normalisationsApplied: [], + }, + statusCode: 502, + }; + } + + const parser = parseProposal ?? parseGraphUpdateProposal; + const parsedProposal = parser(rawResponse); + const responseDurationMs = Date.now() - startedAt; + + if (!parsedProposal.success) { + return { + success: false, + stage: "proposal_validation", + error: "Invalid graph update proposal", + proposalErrors: parsedProposal.errors, + diagnostics: { + promptVersion: null, + modelName, + responseDurationMs, + normalisationsApplied: parsedProposal.normalisationsApplied, + }, + statusCode: 502, + }; + } + + return { + success: true, + stage: "proposal_ready", + proposal: parsedProposal.proposal, + diagnostics: { + promptVersion: null, + modelName, + responseDurationMs, + normalisationsApplied: parsedProposal.normalisationsApplied, + }, + }; +} diff --git a/tests/graph/episode-reasoning-seam.test.js b/tests/graph/episode-reasoning-seam.test.js new file mode 100644 index 0000000..153edb8 --- /dev/null +++ b/tests/graph/episode-reasoning-seam.test.js @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Fixtures ──────────────────────────────────────────────── + +function makePreparedEpisode(overrides = {}) { + return { + situationGraph: { + centralStatement: "Complaint counts increased.", + nodes: [ + { id: "n-unknown", label: "Rate denominator", kind: "unknown", status: "unknown" }, + ], + edges: [], + activeUnknownNodeId: "n-unknown", + resolvedNodeIds: [], + }, + targetNodeId: "n-unknown", + turns: [ + { contributionId: "contrib-a", sequence: 1, question: "What denominator?", answer: "100 units" }, + { contributionId: "contrib-b", sequence: 2, question: "Is it consistent?", answer: "Yes" }, + ], + eligibleCanonicalFindings: [ + { findingId: "f-1", contributionId: "contrib-a", proposition: "Denominator is fixed at 100.", endorsement: null }, + ], + excludedFindingProvenance: [], + ...overrides, + }; +} + +function makeProposalResponse(overrides = {}) { + return { + addedNodes: [], + updatedNodes: [ + { + nodeId: "n-unknown", + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: "100 units", + reason: "Denominator confirmed.", + }, + ], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: ["n-unknown"], + affectedNodeIds: [], + selectedQuestion: null, + answerMeaning: null, + structuralActionRequired: false, + ...overrides, + }; +} + +const MOCK_CONFIG = { OLLAMA_MODEL: "configured" }; +const TEST_EPISODE = makePreparedEpisode(); + +// ── Tests ─────────────────────────────────────────────────── + +describe("episode reasoning seam (reconsiderCompletedEpisode)", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("uses the episode-aware prompt builder, not legacy buildGraphUpdatePrompt", async () => { + const buildEpisodeAwareGraphPrompt = vi.fn().mockReturnValue("EPISODE_PROMPT"); + const buildGraphUpdatePrompt = vi.fn(); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue(makeProposalResponse()), + }; + + // epiParams carries provider via its own key (as in the API contract) + const result = await (await import("@/lib/graph/orchestrator.js")).reconsiderCompletedEpisode( + { ...TEST_EPISODE, provider }, + { getModelName: () => "configured", buildEpisodePrompt: buildEpisodeAwareGraphPrompt }, + ); + + expect(result.success).toBe(true); + expect(buildEpisodeAwareGraphPrompt).toHaveBeenCalledWith({ episode: expect.any(Object) }); + expect(provider.generateReconstruction).toHaveBeenCalledWith("EPISODE_PROMPT", "configured"); + expect(buildGraphUpdatePrompt).not.toHaveBeenCalled(); + }); + + it("calls provider.generateReconstruction exactly once with the built episode prompt", async () => { + const buildEpisodeAwareGraphPrompt = vi.fn().mockReturnValue("EPISODE_PROMPT_V2"); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue(makeProposalResponse()), + }; + + const { reconsiderCompletedEpisode } = await import("@/lib/graph/orchestrator.js"); + const result = await reconsiderCompletedEpisode( + { ...TEST_EPISODE, provider }, + { getModelName: () => "configured", buildEpisodePrompt: buildEpisodeAwareGraphPrompt }, + ); + + expect(result.success).toBe(true); + expect(provider.generateReconstruction.mock.calls.length).toBe(1); + expect(provider.generateReconstruction).toHaveBeenCalledWith("EPISODE_PROMPT_V2", "configured"); + }); + + it("reuses existing parser — returns parsed GraphUpdateProposal shape for valid JSON", async () => { + const provider = { + generateReconstruction: vi.fn().mockResolvedValue(JSON.stringify(makeProposalResponse())), + }; + + const { reconsiderCompletedEpisode } = await import("@/lib/graph/orchestrator.js"); + const result = await reconsiderCompletedEpisode( + TEST_EPISODE, + { getModelName: () => "configured", provider }, + ); + + expect(result.success).toBe(true); + expect(result.stage).toBe("proposal_ready"); + expect(result.proposal).toMatchObject({ + updatedNodes: [expect.objectContaining({ nodeId: "n-unknown" })], + resolvedUnknownNodeIds: ["n-unknown"], + answerMeaning: null, + structuralActionRequired: false, + }); + }); + + it("preserves existing parser error behaviour for invalid provider result", async () => { + const provider = { + generateReconstruction: vi.fn().mockResolvedValue("{ not valid json }"), + }; + + const { reconsiderCompletedEpisode } = await import("@/lib/graph/orchestrator.js"); + const result = await reconsiderCompletedEpisode( + TEST_EPISODE, + { getModelName: () => "configured", provider }, + ); + + expect(result.success).toBe(false); + expect(result.stage).toBe("proposal_validation"); + expect(result.error).toBe("Invalid graph update proposal"); + expect(result.statusCode).toBe(502); + expect(result.proposalErrors).toBeInstanceOf(Array); + }); + + it("does not perform any graph mutation or application", async () => { + const applyValidatedProposal = vi.fn(); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue(makeProposalResponse()), + }; + + const { reconsiderCompletedEpisode } = await import("@/lib/graph/orchestrator.js"); + const result = await reconsiderCompletedEpisode( + TEST_EPISODE, + { getModelName: () => "configured", provider, applyValidatedProposal }, + ); + + expect(result.success).toBe(true); + expect(applyValidatedProposal).not.toHaveBeenCalled(); + + // No mutation of the input episode's graph + expect(TEST_EPISODE.situationGraph.nodes.length).toBe(1); + }); + +});