feat(confidence-engine): add completed episode reasoning seam
This commit is contained in:
@@ -31,6 +31,8 @@ import {
|
|||||||
validateGraphReferences,
|
validateGraphReferences,
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
import { validateFindings, produceFindingInformedSummary } from "./finding-helpers.js";
|
import { validateFindings, produceFindingInformedSummary } from "./finding-helpers.js";
|
||||||
|
import { prepareCompletedEpisode } from "./episode-preparation.js";
|
||||||
|
import { buildEpisodeAwareGraphPrompt } from "./prompt-builder-episode.js";
|
||||||
|
|
||||||
function toValidationErrors(error) {
|
function toValidationErrors(error) {
|
||||||
return (
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user