feat(confidence-engine): synthesize not relevant findings
This commit is contained in:
@@ -265,9 +265,27 @@ export default function ScenarioForm() {
|
||||
}
|
||||
|
||||
function updateFindingDisposition(findingId, newDisposition) {
|
||||
setFindings((prev) =>
|
||||
prev.map((f) => (f.id === findingId ? { ...f, userDisposition: newDisposition } : f)),
|
||||
// Derive explicit next state — not a React-state reread.
|
||||
const nextFindings = (findings ?? []).map((f) =>
|
||||
f.id === findingId ? { ...f, userDisposition: newDisposition } : f,
|
||||
);
|
||||
|
||||
setFindings(() => nextFindings);
|
||||
|
||||
// ── Synthesis trigger: eligible Finding → not_relevant → one reconstruction ──
|
||||
if (newDisposition !== "not_relevant") return;
|
||||
|
||||
const currentGraph = result?.situationGraph;
|
||||
if (!currentGraph) return;
|
||||
|
||||
void synthesizeFromFindings(fetch, {
|
||||
situationGraph: currentGraph,
|
||||
findings: normalizeFindings(nextFindings),
|
||||
}).then((res) => {
|
||||
if (res.ok && res.data?.currentUnderstanding) {
|
||||
setCurrentUnderstanding(res.data.currentUnderstanding);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateFindingProposition(findingId, newProposition) {
|
||||
|
||||
@@ -2920,6 +2920,44 @@ The synthesis route now supplies the configured `OLLAMA_MODEL` and the domain re
|
||||
- Current Understanding visibly replaced (reconstruction incorporates corrected Finding)
|
||||
- Same Finding remains at same position, disposition returns to normal state
|
||||
|
||||
### THIRD synthesis trigger integrated (v0.50.3 — Not Relevant disposition)
|
||||
|
||||
- `components/scenario-form.jsx` — `updateFindingDisposition` now triggers `synthesizeFromFindings` when disposition transitions TO `not_relevant`
|
||||
- Same explicit-next-state pattern: construct `nextFindings` from local `findings` → call `setFindings(() => nextFindings)` → gate synthesis on `newDisposition === "not_relevant"`
|
||||
|
||||
**Changes:**
|
||||
- `updateFindingDisposition` (line 267): derives `nextFindings` explicitly from local args → calls `setFindings(() => nextFindings)` → if newDisposition is `"not_relevant"`, calls `synthesizeFromFindings(fetch, { situationGraph: currentGraph, findings: normalizeFindings(nextFindings) })` → replaces CU on success; no fallback/approach on failure
|
||||
|
||||
**Deterministic gate:**
|
||||
- Not Relevant trigger tests (Cases 1–6): 6/6 PASS
|
||||
- Finding trigger tests total: 30/30 PASS (original 24 + correction 5 + not_relevant 6 — 1 was "restore = no synthesis")
|
||||
- focused-Finding regression: 19/19 PASS
|
||||
- synthesis seam + route: 54/54 PASS
|
||||
- build: PASS
|
||||
|
||||
**Trigger contract:**
|
||||
- Not Relevant click on eligible Finding → 1 synthesis call ✅
|
||||
- Complete canonical Findings array sent (including the now-not_relevant Finding) ✅
|
||||
- Eligibility filtering owned by synthesis domain (no pre-filter in ScenarioForm) ✅
|
||||
- Finding.id preserved ✅
|
||||
- proposition preserved ✅
|
||||
- sourceObservation preserved ✅
|
||||
- contributionId preserved ✅
|
||||
- Other Findings unchanged in payload ✅
|
||||
- Previous CU NOT sent as synthesis input ✅
|
||||
- not_relevant state preserved on synthesis failure ✅
|
||||
- Previous CU preserved on synthesis failure ✅
|
||||
- No automatic retry ✅
|
||||
- No legacy append fallback ✅
|
||||
- Restore (not_relevant → null) → 0 synthesis calls for this increment ✅
|
||||
|
||||
**Live runtime (classification RUN-C):**
|
||||
- One eligible Finding → clicked "not relevant"
|
||||
- Exactly 1 `/api/cases/synthesis` → HTTP 200
|
||||
- Finding visibly became Not Relevant (button toggled from "not relevant" to "restore")
|
||||
- Current Understanding visibly replaced with new reconstruction text
|
||||
- Same Finding remains at same position, disposition shows as "Not Relevant"
|
||||
|
||||
### NEXT BOUNDED ISSUE (for future reference)
|
||||
|
||||
#### Name
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user