feat(confidence-engine): establish current understanding synthesis seam
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mock domain seam and provider at module level ───────────
|
||||
|
||||
const mockSynthesize = vi.fn();
|
||||
|
||||
vi.mock("@/lib/graph/current-understanding-synthesis.js", () => ({
|
||||
synthesizeCurrentUnderstanding: (...args) => mockSynthesize(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/llm/provider.js", () => ({
|
||||
getProvider: () => ({}),
|
||||
}));
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
function makeValidGraph() {
|
||||
return {
|
||||
centralStatement: "Test situation",
|
||||
nodes: [{ id: "n1", proposition: "Node prop" }],
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeRequest(body) {
|
||||
return new Request("http://localhost/api/cases/synthesis", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Route tests — valid POST ────────────────────────────────
|
||||
|
||||
describe("POST /api/cases/synthesis — valid request", () => {
|
||||
beforeEach(() => mockSynthesize.mockClear());
|
||||
|
||||
it("invokes synthesis domain seam with situationGraph + findings", async () => {
|
||||
mockSynthesize.mockResolvedValue({ currentUnderstanding: "Synthesized result" });
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
const res = await POST(makeRequest({ situationGraph: makeValidGraph(), findings: [] }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.currentUnderstanding).toBe("Synthesized result");
|
||||
expect(mockSynthesize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns narrative result on success", async () => {
|
||||
mockSynthesize.mockResolvedValue({ currentUnderstanding: "The revenue dropped because of X and Y." });
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(typeof data.currentUnderstanding).toBe("string");
|
||||
expect(data.currentUnderstanding.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("passes findings to domain seam for eligibility filtering", async () => {
|
||||
mockSynthesize.mockResolvedValue({ currentUnderstanding: "OK" });
|
||||
const findings = [
|
||||
{ id: "f1", proposition: "agree finding", userDisposition: "agree", evaluation: "considered" },
|
||||
{ id: "f2", proposition: "null finding", userDisposition: null, evaluation: "considered" },
|
||||
{ id: "f3", proposition: "not_quite finding", userDisposition: "not_quite", evaluation: "considered" },
|
||||
];
|
||||
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
await POST(makeRequest({ situationGraph: makeValidGraph(), findings }));
|
||||
|
||||
expect(mockSynthesize).toHaveBeenCalledTimes(1);
|
||||
expect(mockSynthesize.mock.calls[0][0].findings).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Route tests — error handling ────────────────────────────
|
||||
|
||||
describe("POST /api/cases/synthesis — error cases", () => {
|
||||
it("missing situationGraph → 400", async () => {
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
const res = await POST(makeRequest({ findings: [] }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.stage).toBe("request_validation");
|
||||
});
|
||||
|
||||
it("invalid JSON body → 400", async () => {
|
||||
const req = new Request("http://localhost/api/cases/synthesis", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
const res = await POST(req);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("null body → 400", async () => {
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
const res = await POST(makeRequest(null));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("domain seam throws with statusCode → mapped status", async () => {
|
||||
mockSynthesize.mockRejectedValue(new Error("Provider failed"));
|
||||
// Add statusCode property to the error object after creation
|
||||
const err = Object.assign(new Error("Provider failed"), { statusCode: 502 });
|
||||
mockSynthesize.mockRejectedValue(err);
|
||||
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
const data = await res.json();
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.stage).toBe("provider");
|
||||
});
|
||||
|
||||
it("domain seam throws without statusCode → 500", async () => {
|
||||
mockSynthesize.mockRejectedValue(new Error("unknown error"));
|
||||
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
const data = await res.json();
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.stage).toBe("internal");
|
||||
});
|
||||
|
||||
it("domain seam throws 400 → mapped to 400", async () => {
|
||||
const err = Object.assign(new Error("Invalid input"), { statusCode: 400 });
|
||||
mockSynthesize.mockRejectedValue(err);
|
||||
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.stage).toBe("request_validation");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Route thinness — no business logic in route ─────────────
|
||||
|
||||
describe("Route thinness", () => {
|
||||
beforeEach(() => mockSynthesize.mockClear());
|
||||
|
||||
it("route does not filter eligibility itself (domain seam owns it)", async () => {
|
||||
mockSynthesize.mockResolvedValue({ currentUnderstanding: "OK" });
|
||||
const findings = [
|
||||
{ id: "f1", proposition: "ineligible", userDisposition: "not_quite", evaluation: "considered" },
|
||||
];
|
||||
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
await POST(makeRequest({ situationGraph: makeValidGraph(), findings }));
|
||||
|
||||
// Route passes all findings through — domain seam filters
|
||||
expect(mockSynthesize.mock.calls[0][0].findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("route does not construct prompts", async () => {
|
||||
mockSynthesize.mockResolvedValue({ currentUnderstanding: "OK" });
|
||||
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
||||
|
||||
expect(mockSynthesize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("route does not contain provider logic (delegates to domain seam)", async () => {
|
||||
mockSynthesize.mockResolvedValue({ currentUnderstanding: "OK" });
|
||||
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
||||
|
||||
expect(mockSynthesize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
filterEligibleFindings,
|
||||
buildSynthesisPrompt,
|
||||
synthesizeCurrentUnderstanding,
|
||||
validateSynthesisResponse,
|
||||
} from "@/lib/graph/current-understanding-synthesis.js";
|
||||
|
||||
// ── Fixtures ────────────────────────────────────────────────
|
||||
|
||||
const canonicalGraph = {
|
||||
centralStatement: "Revenue dropped 30% in Q2 due to supply chain disruption.",
|
||||
nodes: [
|
||||
{ id: "n1", proposition: "Q1 revenue was stable", description: "Baseline metric", status: "confirmed", confidence: 0.95 },
|
||||
{ id: "n2", proposition: "Supplier A failed deliveries in May", description: "Primary cause", status: "active", confidence: 0.85 },
|
||||
{ id: "n3", proposition: "Customer churn increased by 12%", description: "Secondary effect", status: "active", confidence: 0.7 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "n2", to: "n3", type: "causal", context: "Supply failure led to customer dissatisfaction" },
|
||||
{ from: "n1", to: "n2", type: "temporal", context: "Preceding event in causal chain" },
|
||||
],
|
||||
};
|
||||
|
||||
const makeFinding = (overrides = {}) => ({
|
||||
id: `find-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
proposition: "Supplier delays caused production halts",
|
||||
evaluation: "considered",
|
||||
userDisposition: null,
|
||||
sourceObservation: "obs-1",
|
||||
contributionId: "contrib-001",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Fake provider factory for tests — always has generateReconstruction
|
||||
// If defaultResponse is passed, use it; otherwise default to success.
|
||||
const makeFakeProvider = (defaultResponse) => ({
|
||||
generateReconstruction: vi.fn(async () => {
|
||||
return typeof defaultResponse === "function"
|
||||
? defaultResponse()
|
||||
: JSON.stringify({ currentUnderstanding: "Synthesized output" });
|
||||
}),
|
||||
});
|
||||
|
||||
// ── Eligibility tests ───────────────────────────────────────
|
||||
|
||||
describe("filterEligibleFindings — eligibility contract", () => {
|
||||
it("includes null disposition → eligible", () => {
|
||||
const findings = [makeFinding({ userDisposition: null })];
|
||||
const result = filterEligibleFindings(findings);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].userDisposition).toBeNull();
|
||||
});
|
||||
|
||||
it("includes agree disposition → eligible", () => {
|
||||
const findings = [makeFinding({ userDisposition: "agree" })];
|
||||
const result = filterEligibleFindings(findings);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].userDisposition).toBe("agree");
|
||||
});
|
||||
|
||||
it("excludes not_quite disposition → ineligible", () => {
|
||||
const findings = [makeFinding({ userDisposition: "not_quite" })];
|
||||
const result = filterEligibleFindings(findings);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("excludes not_relevant disposition → ineligible", () => {
|
||||
const findings = [makeFinding({ userDisposition: "not_relevant" })];
|
||||
const result = filterEligibleFindings(findings);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("excludes rejected evaluation → excluded", () => {
|
||||
const findings = [makeFinding({ evaluation: "rejected" })];
|
||||
const result = filterEligibleFindings(findings);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("mixed dispositions — only eligible pass through", () => {
|
||||
const findings = [
|
||||
makeFinding({ userDisposition: null, id: "f1" }),
|
||||
makeFinding({ userDisposition: "agree", id: "f2" }),
|
||||
makeFinding({ userDisposition: "not_quite", id: "f3" }),
|
||||
makeFinding({ userDisposition: "not_relevant", id: "f4" }),
|
||||
makeFinding({ evaluation: "rejected", id: "f5" }),
|
||||
];
|
||||
const result = filterEligibleFindings(findings);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((f) => f.id)).toEqual(["f1", "f2"]);
|
||||
});
|
||||
|
||||
it("null input returns empty array", () => {
|
||||
expect(filterEligibleFindings(null)).toEqual([]);
|
||||
expect(filterEligibleFindings(undefined)).toEqual([]);
|
||||
expect(filterEligibleFindings([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Prompt content tests ────────────────────────────────────
|
||||
|
||||
describe("buildSynthesisPrompt — full graph input", () => {
|
||||
it("includes full canonical graph: nodes, edges, centralStatement (not just centralStatement)", () => {
|
||||
const prompt = buildSynthesisPrompt(canonicalGraph, []);
|
||||
expect(prompt).toContain("Canonical Situation Graph");
|
||||
expect(prompt).toContain("Revenue dropped 30%");
|
||||
|
||||
// Verify nodes are included with content beyond centralStatement
|
||||
expect(prompt).toContain('Node(n1)');
|
||||
expect(prompt).toContain("Q1 revenue was stable");
|
||||
expect(prompt).toContain('Node(n2)');
|
||||
expect(prompt).toContain("Supplier A failed deliveries in May");
|
||||
|
||||
// Verify edges are included
|
||||
expect(prompt).toContain("Edge(n2 → n3");
|
||||
expect(prompt).toContain("causal");
|
||||
expect(prompt).toContain("Edge(n1 → n2");
|
||||
expect(prompt).toContain("temporal");
|
||||
|
||||
// Verify centralStatement value is present (not just the key name)
|
||||
expect(prompt).toContain("supply chain disruption");
|
||||
});
|
||||
|
||||
it("includes eligible Finding propositions in prompt", () => {
|
||||
const findings = [makeFinding({ userDisposition: "agree" })];
|
||||
const prompt = buildSynthesisPrompt(canonicalGraph, findings);
|
||||
expect(prompt).toContain("Supplier delays caused production halts");
|
||||
});
|
||||
|
||||
it("excludes non-eligible Finding propositions from prompt", () => {
|
||||
const ineligibleFindings = [makeFinding({ userDisposition: "not_quite" })];
|
||||
const eligibleFindings = filterEligibleFindings(ineligibleFindings);
|
||||
const prompt = buildSynthesisPrompt(canonicalGraph, eligibleFindings);
|
||||
expect(prompt).toContain("Eligible Findings");
|
||||
// The excluded proposition must not appear because eligible findings is empty
|
||||
expect(eligibleFindings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes provisional (null disposition) findings", () => {
|
||||
const findings = [makeFinding({ userDisposition: null })];
|
||||
const prompt = buildSynthesisPrompt(canonicalGraph, findings);
|
||||
expect(prompt).toContain("Provisional Findings");
|
||||
expect(prompt).toContain("Supplier delays caused production halts");
|
||||
});
|
||||
|
||||
it("includes confirmed (agree) findings", () => {
|
||||
const findings = [makeFinding({ userDisposition: "agree" })];
|
||||
const prompt = buildSynthesisPrompt(canonicalGraph, findings);
|
||||
expect(prompt).toContain("Confirmed Evidence");
|
||||
});
|
||||
|
||||
it("handles zero eligible Findings — synthesis still proceeds from graph alone", () => {
|
||||
const prompt = buildSynthesisPrompt(canonicalGraph, []);
|
||||
expect(prompt).toContain("(none)");
|
||||
// Must still contain graph content
|
||||
expect(prompt).toContain("Canonical Situation Graph");
|
||||
});
|
||||
|
||||
it("prompt contains fresh-synthesis instructions (not append semantics)", () => {
|
||||
const prompt = buildSynthesisPrompt(canonicalGraph, []);
|
||||
expect(prompt).toContain("FRESH synthesis");
|
||||
expect(prompt).toContain("Do NOT treat any previous Current Understanding as input");
|
||||
expect(prompt).toContain("Do NOT append to prior summaries");
|
||||
});
|
||||
|
||||
it("prompt does NOT request graph mutations or Finding mutations", () => {
|
||||
const prompt = buildSynthesisPrompt(canonicalGraph, []);
|
||||
expect(prompt).not.toMatch(/change\s+selectedQuestion/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Output validation tests ─────────────────────────────────
|
||||
|
||||
describe("validateSynthesisResponse", () => {
|
||||
it("accepts valid narrative JSON object", () => {
|
||||
const result = validateSynthesisResponse({ currentUnderstanding: "The data shows X" });
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.data.currentUnderstanding).toBe("The data shows X");
|
||||
});
|
||||
|
||||
it("accepts valid narrative JSON string", () => {
|
||||
const result = validateSynthesisResponse('{"currentUnderstanding": "parsed"}');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.data.currentUnderstanding).toBe("parsed");
|
||||
});
|
||||
|
||||
it("rejects missing currentUnderstanding field", () => {
|
||||
const result = validateSynthesisResponse({ summary: "wrong field" });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty string narrative", () => {
|
||||
const result = validateSynthesisResponse({ currentUnderstanding: "" });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed JSON string", () => {
|
||||
const result = validateSynthesisResponse("not json at all [[[");
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects null input", () => {
|
||||
const result = validateSynthesisResponse(null);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects undefined input", () => {
|
||||
const result = validateSynthesisResponse(undefined);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Domain function tests ───────────────────────────────────
|
||||
|
||||
describe("synthesizeCurrentUnderstanding — full seam", () => {
|
||||
it("null disposition finding → included in synthesis", async () => {
|
||||
const fake = makeFakeProvider();
|
||||
const result = await synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [makeFinding({ userDisposition: null })] },
|
||||
{ provider: fake }
|
||||
);
|
||||
expect(result.currentUnderstanding).toBe("Synthesized output");
|
||||
expect(fake.generateReconstruction).toHaveBeenCalledTimes(1);
|
||||
const prompt = fake.generateReconstruction.mock.calls[0][0];
|
||||
expect(prompt).toContain("Supplier delays caused production halts");
|
||||
});
|
||||
|
||||
it("agree disposition finding → included in synthesis", async () => {
|
||||
const fake = makeFakeProvider();
|
||||
const result = await synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [makeFinding({ userDisposition: "agree" })] },
|
||||
{ provider: fake }
|
||||
);
|
||||
expect(result.currentUnderstanding).toBe("Synthesized output");
|
||||
});
|
||||
|
||||
it("not_quite disposition finding → excluded from synthesis", async () => {
|
||||
const fake = makeFakeProvider();
|
||||
const result = await synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [makeFinding({ userDisposition: "not_quite" })] },
|
||||
{ provider: fake }
|
||||
);
|
||||
expect(result.currentUnderstanding).toBe("Synthesized output");
|
||||
const prompt = fake.generateReconstruction.mock.calls[0][0];
|
||||
// The not_quite proposition must NOT appear because it was excluded by eligibility
|
||||
expect(prompt).toContain("(none)");
|
||||
expect(prompt).not.toContain("Supplier delays caused production halts");
|
||||
});
|
||||
|
||||
it("not_relevant disposition finding → excluded from synthesis", async () => {
|
||||
const fake = makeFakeProvider();
|
||||
await synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [makeFinding({ userDisposition: "not_relevant" })] },
|
||||
{ provider: fake }
|
||||
);
|
||||
const prompt = fake.generateReconstruction.mock.calls[0][0];
|
||||
expect(prompt).toContain("(none)");
|
||||
});
|
||||
|
||||
it("rejected evaluation → excluded from synthesis", async () => {
|
||||
const fake = makeFakeProvider();
|
||||
await synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [makeFinding({ evaluation: "rejected" })] },
|
||||
{ provider: fake }
|
||||
);
|
||||
const prompt = fake.generateReconstruction.mock.calls[0][0];
|
||||
expect(prompt).toContain("(none)");
|
||||
});
|
||||
|
||||
it("zero eligible Findings → synthesis succeeds from graph alone", async () => {
|
||||
const fake = makeFakeProvider();
|
||||
const result = await synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [] },
|
||||
{ provider: fake }
|
||||
);
|
||||
expect(result.currentUnderstanding).toBe("Synthesized output");
|
||||
const prompt = fake.generateReconstruction.mock.calls[0][0];
|
||||
// Must still contain graph content
|
||||
expect(prompt).toContain("Canonical Situation Graph");
|
||||
expect(prompt).toContain("Node(n1)");
|
||||
});
|
||||
|
||||
it("fake provider returns valid narrative → { currentUnderstanding }", async () => {
|
||||
const fake = makeFakeProvider();
|
||||
const result = await synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [makeFinding({ userDisposition: "agree" })] },
|
||||
{ provider: fake }
|
||||
);
|
||||
expect(result).toEqual({ currentUnderstanding: "Synthesized output" });
|
||||
});
|
||||
|
||||
it("fake provider returns empty narrative → rejected", async () => {
|
||||
const fake = makeFakeProvider(async () => JSON.stringify({ currentUnderstanding: "" }));
|
||||
await expect(
|
||||
synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [] },
|
||||
{ provider: fake }
|
||||
)
|
||||
).rejects.toThrow(/Synthesis validation failed/);
|
||||
});
|
||||
|
||||
it("fake provider returns malformed JSON → rejected", async () => {
|
||||
const fake = makeFakeProvider(async () => "{ not valid json");
|
||||
await expect(
|
||||
synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [] },
|
||||
{ provider: fake }
|
||||
)
|
||||
).rejects.toThrow(/Synthesis validation failed/);
|
||||
});
|
||||
|
||||
it("fake provider returns object without currentUnderstanding → rejected", async () => {
|
||||
const fake = makeFakeProvider(async () => JSON.stringify({ wrongField: "value" }));
|
||||
await expect(
|
||||
synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [] },
|
||||
{ provider: fake }
|
||||
)
|
||||
).rejects.toThrow(/Synthesis validation failed/);
|
||||
});
|
||||
|
||||
// ── Immutability tests ────────────────────────────────────
|
||||
|
||||
it("graph structurally unchanged after synthesis", async () => {
|
||||
const graphSnapshot = JSON.parse(JSON.stringify(canonicalGraph));
|
||||
const fake = makeFakeProvider();
|
||||
|
||||
await synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [] },
|
||||
{ provider: fake }
|
||||
);
|
||||
|
||||
expect(JSON.stringify(canonicalGraph)).toBe(JSON.stringify(graphSnapshot));
|
||||
});
|
||||
|
||||
it("findings structurally unchanged after synthesis", async () => {
|
||||
const findings = [makeFinding({ userDisposition: "agree" })];
|
||||
const findingsSnapshot = JSON.parse(JSON.stringify(findings));
|
||||
const fake = makeFakeProvider();
|
||||
|
||||
await synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings },
|
||||
{ provider: fake }
|
||||
);
|
||||
|
||||
expect(JSON.stringify(findings)).toBe(JSON.stringify(findingsSnapshot));
|
||||
});
|
||||
|
||||
// ── Input validation tests ────────────────────────────────
|
||||
|
||||
it("missing situationGraph → throws 400", async () => {
|
||||
await expect(
|
||||
synthesizeCurrentUnderstanding({}, { provider: makeFakeProvider() })
|
||||
).rejects.toThrow(/situationGraph is required/);
|
||||
});
|
||||
|
||||
it("non-object situationGraph → throws 400", async () => {
|
||||
await expect(
|
||||
synthesizeCurrentUnderstanding({ situationGraph: "not an object" }, { provider: makeFakeProvider() })
|
||||
).rejects.toThrow(/situationGraph is required/);
|
||||
});
|
||||
|
||||
it("findings as non-array → throws 400", async () => {
|
||||
await expect(
|
||||
synthesizeCurrentUnderstanding({ situationGraph: canonicalGraph, findings: "string" }, { provider: makeFakeProvider() })
|
||||
).rejects.toThrow(/findings must be an array/);
|
||||
});
|
||||
|
||||
it("provider generateReconstruction throws → statusCode 502", async () => {
|
||||
const fake = makeFakeProvider(async () => { throw new Error("provider down"); });
|
||||
await expect(
|
||||
synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [] },
|
||||
{ provider: fake }
|
||||
)
|
||||
).rejects.toThrow(/Synthesis provider call failed|provider down/);
|
||||
});
|
||||
|
||||
it("no provider provided — falls through to getProvider() which needs env vars", async () => {
|
||||
await expect(
|
||||
synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [] },
|
||||
{}
|
||||
)
|
||||
).rejects.toThrow(/OLLAMA_BASE_URL/);
|
||||
});
|
||||
|
||||
// ── Provider sees full graph, not just centralStatement ───
|
||||
|
||||
it("provider receives full canonical graph content (nodes + edges + centralStatement)", async () => {
|
||||
const fake = makeFakeProvider();
|
||||
await synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: canonicalGraph, findings: [] },
|
||||
{ provider: fake }
|
||||
);
|
||||
|
||||
const prompt = fake.generateReconstruction.mock.calls[0][0];
|
||||
// Must contain node content (not just centralStatement)
|
||||
expect(prompt).toContain("Node(n1)");
|
||||
expect(prompt).toContain("Q1 revenue was stable");
|
||||
expect(prompt).toContain("Node(n2)");
|
||||
expect(prompt).toContain("Supplier A failed deliveries in May");
|
||||
// Must contain edge content
|
||||
expect(prompt).toContain("Edge(n2 → n3");
|
||||
expect(prompt).toContain("causal");
|
||||
// Must contain centralStatement value
|
||||
expect(prompt).toContain("Revenue dropped 30%");
|
||||
// Must instruct about fresh synthesis (not previous CU)
|
||||
expect(prompt).toContain("Do NOT treat any previous Current Understanding as input");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user