feat(confidence-engine): synthesize not relevant findings

This commit is contained in:
2026-08-31 08:03:24 +01:00
parent 5fb32e628c
commit addec52461
3 changed files with 256 additions and 2 deletions
@@ -502,3 +502,201 @@ describe("Synthesis trigger — corrected Finding (CORRECTION-A)", () => {
expect(reqFindings.length).toBeGreaterThan(0);
});
});
// ── Not Relevant synthesis trigger tests (v0.50) ────────────
describe("Synthesis trigger — Not Relevant disposition (NOTREL-A)", () => {
let capturedFetchCalls;
let capturedCU;
beforeEach(() => {
capturedFetchCalls = [];
capturedCU = "previous understanding";
global.fetch = vi.fn(async (url, init) => {
if (url === "/api/cases/synthesis") {
capturedFetchCalls.push(JSON.parse(init.body));
return new Response(
JSON.stringify({ currentUnderstanding: "Reconstructed from not relevant findings." }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response(JSON.stringify({}), { status: 200 });
});
});
function simulateNotRelevantWithSynthesis(findings, findingId, situationGraph) {
// Mirror updateFindingDisposition body for not_relevant trigger: explicit next state
const nextFindings = findings.map((f) =>
f.id === findingId ? { ...f, userDisposition: "not_relevant" } : 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", () => {
const existingFindings = [
{ id: "f-1", proposition: "Revenue dropped 22%", userDisposition: null, sourceObservation: "Revenue dropped 22%" },
];
simulateNotRelevantWithSynthesis(
existingFindings,
"f-1",
{ centralStatement: "Q3 financial decline" },
);
expect(capturedFetchCalls).toHaveLength(1);
expect(capturedFetchCalls[0].findings[0].userDisposition).toBe("not_relevant");
});
/* ── Case 2: complete next state with multiple Findings ─────────────────────── */
it("Case 2 — mark A Not Relevant, synthesis receives current SituationGraph + complete Findings array", async () => {
const findingA = "finding-A";
const findingB = "finding-B";
const findingC = "finding-C";
const existingFindings = [
{ id: findingA, proposition: "Fact A", userDisposition: null, sourceObservation: "Fact A" },
{ id: findingB, proposition: "Fact B", userDisposition: null, sourceObservation: "Fact B" },
{ id: findingC, proposition: "Fact C", userDisposition: null, sourceObservation: "Fact C" },
];
simulateNotRelevantWithSynthesis(
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).toBe("not_relevant");
expect(capturedFetchCalls[0].findings.find((f) => f.id === findingB).userDisposition).toBeNull();
expect(capturedFetchCalls[0].findings.find((f) => f.id === findingC).userDisposition).toBeNull();
await new Promise((r) => setTimeout(r, 10));
expect(capturedCU).toBe("Reconstructed from not relevant findings.");
});
/* ── Case 3: identity/provenance preservation ───────────────────────────────── */
it("Case 3 — target Finding preserves id, proposition, sourceObservation, contributionId", async () => {
const findingId = "finding-provenance-001";
const proposition = "Market share eroded by competitor pricing";
const sourceObservation = "Competitor X launched aggressive Q3 pricing campaign";
const contributionId = "contrib-0042";
const existingFindings = [
{ id: findingId, proposition, userDisposition: null, sourceObservation, contributionId },
];
const result = simulateNotRelevantWithSynthesis(
existingFindings,
findingId,
{ centralStatement: "test" },
);
expect(result.findings[0].id).toBe(findingId);
expect(result.findings[0].proposition).toBe(proposition);
expect(result.findings[0].sourceObservation).toBe(sourceObservation);
expect(result.findings[0].contributionId).toBe(contributionId);
expect(result.findings[0].userDisposition).toBe("not_relevant");
await new Promise((r) => setTimeout(r, 10));
expect(capturedCU).toBe("Reconstructed from not relevant findings.");
});
/* ── Case 4: synthesis success replaces CU exactly (no append) ──────────────── */
it("Case 4 — successful synthesis replaces Current Understanding", async () => {
capturedCU = "old evidence block text";
const existingFindings = [
{ id: "f-1", proposition: "Some fact", userDisposition: null, sourceObservation: "Some fact" },
];
simulateNotRelevantWithSynthesis(
existingFindings,
"f-1",
{ centralStatement: "test" },
);
await new Promise((r) => setTimeout(r, 10));
expect(capturedCU).toBe("Reconstructed from not relevant findings.");
expect(capturedCU).not.toContain("old");
expect(capturedCU).not.toContain("evidence block text");
});
/* ── Case 5: synthesis failure semantics ────────────────────────────────────── */
it("Case 5 — on synthesis failure: not_relevant preserved, previous CU remains, no retry", async () => {
capturedCU = "previous understanding";
// Override fetch to simulate non-2xx failure
global.fetch = vi.fn(async (url, init) => {
if (url === "/api/cases/synthesis") {
capturedFetchCalls.push(JSON.parse(init.body));
return new Response(
JSON.stringify({ success: false, error: "provider timeout" }),
{ status: 503 },
);
}
return new Response(JSON.stringify({}), { status: 200 });
});
const existingFindings = [
{ id: "f-1", proposition: "Fact X", userDisposition: null, sourceObservation: "Fact X" },
];
const result = simulateNotRelevantWithSynthesis(
existingFindings,
"f-1",
{ centralStatement: "test" },
);
// Finding remains not_relevant
expect(result.findings[0].userDisposition).toBe("not_relevant");
await new Promise((r) => setTimeout(r, 10));
// Previous CU preserved — no fallback append
expect(capturedCU).toBe("previous understanding");
// synthesis called exactly once (no automatic retry)
expect(global.fetch).toHaveBeenCalledTimes(1);
});
/* ── Case 6: Restore remains unwired for this increment ─────────────────────── */
it("Case 6 — clicking Restore on not_relevant Finding does NOT trigger synthesis", 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,
);
// No synthesis call for Restore in this increment
expect(capturedFetchCalls).toHaveLength(0);
// Verify disposition changed correctly
expect(nextFindings[0].userDisposition).toBeNull();
});
});