feat(confidence-engine): synthesize restored findings

This commit is contained in:
2026-08-31 08:18:44 +01:00
parent addec52461
commit 989b88a4a1
3 changed files with 223 additions and 11 deletions
@@ -545,6 +545,26 @@ describe("Synthesis trigger — Not Relevant disposition (NOTREL-A)", () => {
return { findings: nextFindings };
}
function simulateRestoreWithSynthesis(findings, findingId, situationGraph) {
// Mirror updateFindingDisposition body for Restore trigger: explicit next state
const nextFindings = findings.map((f) =>
f.id === findingId ? { ...f, userDisposition: null } : f,
);
if (!situationGraph) return { findings: nextFindings };
void synthesizeFromFindings(fetch, {
situationGraph,
findings: normalizeFindings(nextFindings),
}).then((res) => {
if (res.ok && res.data?.currentUnderstanding) {
capturedCU = res.data.currentUnderstanding;
}
});
return { findings: nextFindings };
}
/* ── Case 1: eligible Finding → Not Relevant triggers synthesis ─────────────── */
it("Case 1 — clicking Not relevant on eligible Finding produces exactly 1 synthesis call", () => {
@@ -682,21 +702,166 @@ describe("Synthesis trigger — Not Relevant disposition (NOTREL-A)", () => {
/* ── Case 6: Restore remains unwired for this increment ─────────────────────── */
it("Case 6 — clicking Restore on not_relevant Finding does NOT trigger synthesis", async () => {
/* ── Case 6: Restore triggers synthesis once ─────────────────────────── */
it("Case 6 — clicking Restore on not_relevant Finding triggers exactly 1 synthesis call", async () => {
const existingFindings = [
{ id: "f-1", proposition: "Fact X", userDisposition: "not_relevant", sourceObservation: "Fact X" },
];
// Simulate Restore: transition FROM not_relevant TO null (no synthesis)
// This is the existing updateFindingDisposition body for non-not_relevant transitions
const nextFindings = existingFindings.map((f) =>
f.id === "f-1" ? { ...f, userDisposition: null } : f,
const prevCU = capturedCU;
simulateRestoreWithSynthesis(
existingFindings,
"f-1",
{ centralStatement: "Test" },
);
// No synthesis call for Restore in this increment
expect(capturedFetchCalls).toHaveLength(0);
await new Promise((r) => setTimeout(r, 0));
// Verify disposition changed correctly
expect(nextFindings[0].userDisposition).toBeNull();
expect(capturedFetchCalls).toHaveLength(1);
expect(capturedFetchCalls[0].findings[0].userDisposition).toBeNull();
expect(capturedCU).not.toBe(prevCU);
});
/* ── Case 7: complete nextFindings payload ─────────────────────────── */
it("Case 7 — synthesis receives complete Findings array with Restore", async () => {
const findingA = "finding-A";
const findingB = "finding-B";
const findingC = "finding-C";
const existingFindings = [
{ id: findingA, proposition: "Fact A", userDisposition: "not_relevant", sourceObservation: "Obs A" },
{ id: findingB, proposition: "Fact B", userDisposition: null, sourceObservation: "Obs B" },
{ id: findingC, proposition: "Fact C", userDisposition: "agree", sourceObservation: "Obs C" },
];
simulateRestoreWithSynthesis(
existingFindings,
findingA,
{ centralStatement: "Multi-finding scenario" },
);
expect(capturedFetchCalls).toHaveLength(1);
expect(capturedFetchCalls[0].findings).toHaveLength(3);
expect(capturedFetchCalls[0].findings.find((f) => f.id === findingA).userDisposition).toBeNull();
expect(capturedFetchCalls[0].findings.find((f) => f.id === findingB).userDisposition).toBeNull();
expect(capturedFetchCalls[0].findings.find((f) => f.id === findingC).userDisposition).toBe("agree");
});
/* ── Case 8: identity/provenance preserved on Restore ─────────────── */
it("Case 8 — restored Finding preserves id, proposition, sourceObservation, contributionId", async () => {
const existingFindings = [
{
id: "f-restore-id",
proposition: "Proposition A",
userDisposition: "not_relevant",
sourceObservation: "Source Obs A",
contributionId: "contrib-0001",
origin: "manual",
},
];
simulateRestoreWithSynthesis(
existingFindings,
"f-restore-id",
{ centralStatement: "Identity test" },
);
const sent = capturedFetchCalls[0].findings[0];
expect(sent.id).toBe("f-restore-id");
expect(sent.proposition).toBe("Proposition A");
expect(sent.sourceObservation).toBe("Source Obs A");
expect(sent.contributionId).toBe("contrib-0001");
expect(sent.userDisposition).toBeNull();
});
/* ── Case 9: successful reconstruction replaces CU exactly ──────── */
it("Case 9 — synthesis success replaces Current Understanding (no append)", async () => {
const existingFindings = [
{ id: "f-1", proposition: "X", userDisposition: "not_relevant", sourceObservation: "X" },
];
simulateRestoreWithSynthesis(
existingFindings,
"f-1",
{ centralStatement: "CU replacement test" },
);
await new Promise((r) => setTimeout(r, 0));
expect(capturedCU).toBe("Reconstructed from not relevant findings.");
});
/* ── Case 10: synthesis failure preserves restored Finding + previous CU ─ */
it("Case 10 — on synthesis failure: Finding remains restored, previous CU preserved, no retry", async () => {
global.fetch.mockClear();
capturedFetchCalls = [];
capturedCU = "previous understanding";
let callCount = 0;
global.fetch = vi.fn(async (url, init) => {
if (url === "/api/cases/synthesis") {
callCount++;
capturedFetchCalls.push(JSON.parse(init.body));
return new Response(
JSON.stringify({ error: "synthesis failed" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
return new Response(JSON.stringify({}), { status: 200 });
});
const existingFindings = [
{ id: "f-1", proposition: "X", userDisposition: "not_relevant", sourceObservation: "X" },
];
simulateRestoreWithSynthesis(
existingFindings,
"f-1",
{ centralStatement: "Failure test" },
);
await new Promise((r) => setTimeout(r, 0));
expect(callCount).toBe(1);
expect(capturedCU).toBe("previous understanding");
});
/* ── Case 11: null → null no-op does not synthesize ─────────── */
it("Case 11 — null → null does NOT trigger synthesis", async () => {
// Direct handler-level test: transition where previousDisposition === null
const nextFindings = [
{ id: "f-1", proposition: "X", userDisposition: null, sourceObservation: "X" },
];
const findingId = "f-1";
const newDisposition = null;
const prevFinding = nextFindings.find((f) => f.id === findingId);
const previousDisposition = prevFinding?.userDisposition;
const eligibilityChanged =
previousDisposition === "not_relevant" && newDisposition === null;
expect(eligibilityChanged).toBe(false);
});
/* ── Case 7: Not Relevant regression — eligible → not_relevant still triggers ─ */
it("Case 12 — eligible → not_relevant still produces exactly 1 synthesis call", () => {
const existingFindings = [
{ id: "f-reg", proposition: "Regression check", userDisposition: null, sourceObservation: "check" },
];
simulateNotRelevantWithSynthesis(
existingFindings,
"f-reg",
{ centralStatement: "Regression test" },
);
expect(capturedFetchCalls).toHaveLength(1);
});
});