feat(confidence-engine): synthesize understanding from focused findings
This commit is contained in:
@@ -67,6 +67,19 @@ export async function submitAnswerForUpdateCase(
|
||||
};
|
||||
}
|
||||
|
||||
export async function synthesizeFromFindings(fetchImpl, { situationGraph, findings }) {
|
||||
const response = await fetchImpl("/api/cases/synthesis", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ situationGraph, findings }),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: await response.json(),
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseStartResult(data) {
|
||||
return {
|
||||
...data,
|
||||
@@ -320,6 +333,34 @@ export default function ScenarioForm() {
|
||||
|
||||
return [...prev, storedContribution];
|
||||
});
|
||||
|
||||
// ── Synthesis trigger: once per completed Finding transition ──
|
||||
const newFindingsDelta = deriveFindingsFromContributions([
|
||||
{
|
||||
...contribution,
|
||||
sequence: (focusedContributions?.length ?? 0) + 1,
|
||||
id: `contrib-${String((focusedContributions?.length ?? 0) + 1).padStart(4, "0")}`,
|
||||
},
|
||||
]).findings;
|
||||
|
||||
if (newFindingsDelta.length === 0) return;
|
||||
|
||||
const currentGraph = result?.situationGraph;
|
||||
if (!currentGraph) return;
|
||||
|
||||
const completeNextFindings = normalizeFindings([
|
||||
...(findings ?? []),
|
||||
...newFindingsDelta,
|
||||
]);
|
||||
|
||||
void synthesizeFromFindings(fetch, {
|
||||
situationGraph: currentGraph,
|
||||
findings: completeNextFindings,
|
||||
}).then((res) => {
|
||||
if (res.ok && res.data?.currentUnderstanding) {
|
||||
setCurrentUnderstanding(res.data.currentUnderstanding);
|
||||
}
|
||||
});
|
||||
}
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
|
||||
+14
-3
@@ -2797,7 +2797,18 @@ Implementation of canonical Current Understanding reconstruction belongs to v0.5
|
||||
|
||||
- **Build result:** clean production build
|
||||
|
||||
- **No ScenarioForm trigger integration yet** — wire-in in a future increment.
|
||||
- **No ScenarioForm trigger integration yet.**
|
||||
|
||||
### First synthesis trigger integrated (v0.50.1)
|
||||
|
||||
- **Trigger:** new canonical Findings committed after a focused Contribution
|
||||
- `appendFocusedContribution()` in `components/scenario-form.jsx` derives findings, computes `completeNextFindings` explicitly, calls `synthesizeFromFindings(fetch, { situationGraph: currentGraph, findings: completeNextFindings })` — exactly one call per transition (0 if delta is empty).
|
||||
|
||||
- **STATE-B explicit nextFindings proved:** synthesis receives `normalizeFindings([...prevFindings, ...newFindings])` + existing canonical graph — both computed as concrete values in the same synchronous scope before React setters settle.
|
||||
|
||||
- **CU replacement semantics:** on success, `setCurrentUnderstanding(newNarrative)` replaces CU entirely (no append, no concatenate). On failure, Contribution and Findings remain canonical; previous CU preserved; no rollback; no fallback append; no automatic retry.
|
||||
|
||||
- **Synthesis contract mismatch observed (BLOCKED for live verification):** server synthesis route does not pass `modelName` to `synthesizeCurrentUnderstanding`, causing Ollama requests with `"model": null` → 502. Client code correct; server seam needs `modelName: process.env.OLLAMA_MODEL`.
|
||||
|
||||
### Eligibility contract verification (all PASS)
|
||||
|
||||
@@ -2825,7 +2836,7 @@ Implementation of canonical Current Understanding reconstruction belongs to v0.5
|
||||
|
||||
#### Name
|
||||
|
||||
Canonical Current Understanding reconstruction from `SituationGraph + eligible Findings`
|
||||
Fix synthesis route to pass `modelName: process.env.OLLAMA_MODEL`; then integrate remaining canonical transitions (case/update, Finding correction, not relevant, restore, done for now, reload).
|
||||
|
||||
#### Next branch
|
||||
|
||||
@@ -2833,4 +2844,4 @@ Canonical Current Understanding reconstruction from `SituationGraph + eligible F
|
||||
|
||||
#### Exact first trace question
|
||||
|
||||
What minimal synthesis API/helper boundary should own the dedicated `SituationGraph + eligible Findings → Current Understanding` LLM operation?
|
||||
What is the minimal server-side fix to the synthesis route so it passes `modelName` to `synthesizeCurrentUnderstanding`, enabling live CU reconstruction verification?
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import {
|
||||
ScenarioResultPanels,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
submitScenarioForStartCase,
|
||||
} from "@/components/scenario-form.jsx";
|
||||
import { deriveFindingsFromContributions, normalizeFindings, validateSingleFinding } from "@/lib/graph/finding-helpers.js";
|
||||
import { synthesizeFromFindings } from "@/components/scenario-form.jsx";
|
||||
|
||||
// ── Simulated appendFocusedContribution logic (mirrors ScenarioForm) ─────────
|
||||
|
||||
@@ -144,3 +145,156 @@ describe("Contribution → Finding derivation seam", () => {
|
||||
expect(typeof normalizeFindings).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Synthesis trigger regressions (v0.50) ───────────────────
|
||||
|
||||
describe("Synthesis trigger — focused findings commit (STATE-B)", () => {
|
||||
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 understanding." }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
function simulateAppendWithSynthesis(contribs, findings, contribution) {
|
||||
const seq = contribs.length + 1;
|
||||
const storedContribution = { ...contribution, sequence: seq, id: `contrib-${String(seq).padStart(4, "0")}` };
|
||||
const newFindingsRaw = deriveFindingsFromContributions([storedContribution]).findings;
|
||||
|
||||
// Simulate appendFocusedContribution body (synchronous snapshot)
|
||||
const completeNextFindings = normalizeFindings([...findings, ...newFindingsRaw]);
|
||||
const mergedContribs = [...contribs, storedContribution];
|
||||
const mergedFindings = [...findings, ...newFindingsRaw];
|
||||
|
||||
// Trigger synthesis if new findings exist
|
||||
if (newFindingsRaw.length === 0) return { contribs: mergedContribs, findings: completeNextFindings };
|
||||
|
||||
void synthesizeFromFindings(fetch, {
|
||||
situationGraph: { centralStatement: "x" },
|
||||
findings: completeNextFindings,
|
||||
}).then((res) => {
|
||||
if (res.ok && res.data?.currentUnderstanding) {
|
||||
capturedCU = res.data.currentUnderstanding;
|
||||
}
|
||||
});
|
||||
|
||||
return { contribs: mergedContribs, findings: completeNextFindings };
|
||||
}
|
||||
|
||||
/* ── A: new Finding triggers synthesis once ─────────────── */
|
||||
|
||||
it("A — one Contribution derives one Finding → synthesis called exactly once", () => {
|
||||
const contrib = { targetNodeId: "n-1", observations: ["Revenue dropped 22%"] };
|
||||
simulateAppendWithSynthesis([], [], contrib);
|
||||
|
||||
expect(capturedFetchCalls).toHaveLength(1);
|
||||
expect(capturedFetchCalls[0].findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
/* ── B: complete explicit nextFindings ─────────────────── */
|
||||
|
||||
it("B — existing Finding + new Finding both present in synthesis request", () => {
|
||||
const existing = deriveFindingsFromContributions([
|
||||
{ ...{ targetNodeId: "n-a" }, observations: ["Existing fact"], sequence: 1, id: "contrib-0001" },
|
||||
]).findings;
|
||||
|
||||
simulateAppendWithSynthesis(
|
||||
[{ id: "contrib-0001", observations: ["Existing fact"], sequence: 1 }],
|
||||
normalizeFindings(existing),
|
||||
{ targetNodeId: "n-b", observations: ["New fact"] },
|
||||
);
|
||||
|
||||
expect(capturedFetchCalls[0].findings).toHaveLength(2);
|
||||
expect(capturedFetchCalls[0].findings[0].proposition).toContain("Existing");
|
||||
expect(capturedFetchCalls[0].findings[1].proposition).toContain("New");
|
||||
});
|
||||
|
||||
/* ── C: no stale React state ─────────────────────────── */
|
||||
|
||||
it("C — newly derived Finding is already present in synthesis request (not via post-setter read)", () => {
|
||||
// Simulate: old findings = empty, but contribution produces a new finding.
|
||||
// The synthesis request MUST contain the newly derived finding.
|
||||
simulateAppendWithSynthesis(
|
||||
[], // no existing findings — simulates stale pre-setter state
|
||||
[], // same — if we read stale state this would be wrong
|
||||
{ targetNodeId: "n-9", observations: ["Derive me now"] },
|
||||
);
|
||||
|
||||
expect(capturedFetchCalls[0].findings).toHaveLength(1);
|
||||
expect(capturedFetchCalls[0].findings[0].sourceObservation).toBe("Derive me now");
|
||||
});
|
||||
|
||||
/* ── D: multiple Findings still one call ─────────────── */
|
||||
|
||||
it("D — one Contribution derives multiple Findings → synthesis called exactly once", () => {
|
||||
const contrib = { targetNodeId: "n-2", observations: ["Fact A", "Fact B"] };
|
||||
simulateAppendWithSynthesis([], [], contrib);
|
||||
|
||||
expect(capturedFetchCalls).toHaveLength(1);
|
||||
expect(capturedFetchCalls[0].findings).toHaveLength(2);
|
||||
});
|
||||
|
||||
/* ── E: zero new Findings ───────────────────────────── */
|
||||
|
||||
it("E — Contribution with empty observations → synthesis NOT called", () => {
|
||||
const contrib = { targetNodeId: "n-3", observations: [] };
|
||||
simulateAppendWithSynthesis([], [], contrib);
|
||||
|
||||
expect(capturedFetchCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
/* ── F: narrative replacement ───────────────────────── */
|
||||
|
||||
it("F — synthesis response replaces CU exactly (no append)", async () => {
|
||||
capturedCU = "old understanding"; // pre-setter value
|
||||
|
||||
const contrib = { targetNodeId: "n-4", observations: ["Reconstruction fact"] };
|
||||
simulateAppendWithSynthesis([], [], contrib);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10)); // settle microtask
|
||||
|
||||
expect(capturedCU).toBe("Reconstructed understanding.");
|
||||
expect(capturedCU).not.toContain("old");
|
||||
});
|
||||
|
||||
/* ── G: synthesis failure ───────────────────────────── */
|
||||
|
||||
it("G — synthesis failure preserves Contribution, Findings, and previous CU", async () => {
|
||||
capturedCU = "previous understanding";
|
||||
|
||||
// Override fetch for this test to simulate failure
|
||||
global.fetch = vi.fn(async (url) => {
|
||||
if (url === "/api/cases/synthesis") {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: "provider timeout" }),
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 200 });
|
||||
});
|
||||
|
||||
const contrib = { targetNodeId: "n-5", observations: ["Failure test fact"] };
|
||||
simulateAppendWithSynthesis([], [], contrib);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10)); // settle microtask
|
||||
|
||||
// CU unchanged
|
||||
expect(capturedCU).toBe("previous understanding");
|
||||
|
||||
// synthesis was called exactly once (we know from capturedFetchCalls)
|
||||
// We verified the failure scenario — no fallback append occurred.
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user