feat(confidence-engine): add completed episode reasoning seam

This commit is contained in:
2026-09-01 09:28:02 +01:00
parent ab655e2222
commit c89cc51ae6
2 changed files with 264 additions and 0 deletions
+158
View File
@@ -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);
});
});