feat(confidence-engine): establish current understanding synthesis seam
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Dedicated Current Understanding synthesis API route.
|
||||||
|
*
|
||||||
|
* Route: POST /api/cases/synthesis
|
||||||
|
*
|
||||||
|
* Follows the thin route pattern established by cases/start and cases/update routes:
|
||||||
|
* parse request → invoke domain seam → return validated result → map failure status
|
||||||
|
*
|
||||||
|
* No synthesis business logic belongs in this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getProvider } from "@/lib/llm/provider.js";
|
||||||
|
import { synthesizeCurrentUnderstanding } from "@/lib/graph/current-understanding-synthesis.js";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// ── Parse / validate input contract ───────────────────────
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, stage: "request_validation", error: "Invalid request body" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { situationGraph, findings } = body;
|
||||||
|
|
||||||
|
if (!situationGraph) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, stage: "request_validation", error: "Missing situationGraph" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Invoke domain seam with provider resolution ───────────
|
||||||
|
const result = await synthesizeCurrentUnderstanding(
|
||||||
|
{ situationGraph, findings },
|
||||||
|
{ provider: getProvider() }
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response.json({ success: true, currentUnderstanding: result.currentUnderstanding }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, stage: "request_validation", error: "Invalid JSON request body" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.statusCode) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
stage: error.statusCode === 400 ? "request_validation" : "provider",
|
||||||
|
error: error.message ?? "Synthesis failed",
|
||||||
|
},
|
||||||
|
{ status: error.statusCode }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unexpected error
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, stage: "internal", error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-15
@@ -2761,27 +2761,63 @@ defect has been reduced to a defined reconstruction problem.
|
|||||||
|
|
||||||
Implementation of canonical Current Understanding reconstruction belongs to v0.50.
|
Implementation of canonical Current Understanding reconstruction belongs to v0.50.
|
||||||
|
|
||||||
### v0.50 — NEXT BOUNDARY
|
## v0.50 — STANDALONE SYNTHESIS SEAM (2026-08-30)
|
||||||
|
|
||||||
The first bounded question for the next branch:
|
### Closed boundaries
|
||||||
|
|
||||||
> **What minimal synthesis API/helper boundary should own the dedicated `SituationGraph + eligible Findings → Current Understanding` LLM operation?**
|
**Standalone synthesis seam implemented.**
|
||||||
|
|
||||||
Do not answer that question in this task.
|
- **Domain function:** `lib/graph/current-understanding-synthesis.js`
|
||||||
|
- `filterEligibleFindings()` — eligibility contract: null/agree eligible; not_quite/not_relevant/rejected excluded
|
||||||
|
- `buildSynthesisPrompt()` — full SituationGraph (nodes + edges + centralStatement) + eligible Findings
|
||||||
|
- `synthesizeCurrentUnderstanding()` — DI-driven provider call, narrative-only output
|
||||||
|
- `validateSynthesisResponse()` / `synthesisResponseSchema` — zod schema for `{ currentUnderstanding }`
|
||||||
|
|
||||||
Subsequent v0.50 work will need to resolve:
|
- **API route:** `app/api/cases/synthesis/route.js` — `POST /api/cases/synthesis`, thin parse → invoke → return pattern
|
||||||
|
|
||||||
1. synthesis API/helper ownership
|
- **Domain seam ownership:**
|
||||||
2. deterministic input normalization / eligible-Finding selection
|
- ScenarioForm → WHEN synthesis occurs (NOT wired; untouched in this increment)
|
||||||
3. synthesis prompt/schema contract
|
- This module → HOW canonical state becomes narrative
|
||||||
4. completed-transition integration points
|
- Provider → generation via DI (default provider + test injection pattern)
|
||||||
5. removal/replacement of append semantics
|
|
||||||
6. retirement of Done-for-now CU promotion
|
|
||||||
7. reload/cache freshness strategy
|
|
||||||
8. targeted deterministic + live behavioural verification
|
|
||||||
|
|
||||||
This is a roadmap, not permission to implement all of it at once.
|
- **Eligibility filtering inside domain seam:**
|
||||||
v0.50 must continue using bounded increments.
|
- `null` → eligible (accepted-by-default, provisional working interpretation)
|
||||||
|
- `agree` → eligible (confirmed evidence)
|
||||||
|
- `not_quite` → excluded until corrected proposition saved
|
||||||
|
- `not_relevant` → excluded (discounted from active reasoning)
|
||||||
|
- `evaluation: "rejected"` → excluded
|
||||||
|
|
||||||
|
- **Full SituationGraph + canonical Findings input** — not just centralStatement; nodes and edges included
|
||||||
|
|
||||||
|
- **narrative-only output** — `{ currentUnderstanding }` string; no graph/Finding mutation
|
||||||
|
|
||||||
|
- **52 targeted tests passing:**
|
||||||
|
- `tests/graph/current-understanding-synthesis.test.js` — 40/40
|
||||||
|
- `tests/app/api/current-understanding-synthesis-route.test.js` — 12/12
|
||||||
|
|
||||||
|
- **Build result:** clean production build
|
||||||
|
|
||||||
|
- **No ScenarioForm trigger integration yet** — wire-in in a future increment.
|
||||||
|
|
||||||
|
### Eligibility contract verification (all PASS)
|
||||||
|
|
||||||
|
| Scenario | Covered | Result |
|
||||||
|
|----------|---------|--------|
|
||||||
|
| null included | Yes — graph test line 47 + synthesis test line 215 | ✅ PASS |
|
||||||
|
| agree included | Yes — graph test line 54 + synthesis test line 227 | ✅ PASS |
|
||||||
|
| not_quite excluded | Yes — graph test line 61 + synthesis test line 236 | ✅ PASS |
|
||||||
|
| not_relevant excluded | Yes — graph test line 67 + synthesis test line 249 | ✅ PASS |
|
||||||
|
| rejected/invalid excluded | Yes — graph test line 73 + synthesis test line 259 | ✅ PASS |
|
||||||
|
| zero eligible Findings supported | Yes — graph test line 151/269 + mixed dispositions test | ✅ PASS |
|
||||||
|
|
||||||
|
### No changes to
|
||||||
|
|
||||||
|
- `components/scenario-form.jsx`
|
||||||
|
- `components/reasoning-workspace.jsx`
|
||||||
|
- `app/api/cases/update/route.js`
|
||||||
|
- Done-for-now code
|
||||||
|
- storage
|
||||||
|
- focused workspace
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Current Understanding synthesis seam — standalone domain function.
|
||||||
|
*
|
||||||
|
* Accepts: SituationGraph + all canonical Findings
|
||||||
|
* Outputs: narrative-only { currentUnderstanding }
|
||||||
|
*
|
||||||
|
* Ownership:
|
||||||
|
* ScenarioForm → WHEN synthesis occurs (untouched in this increment)
|
||||||
|
* This module → HOW canonical state becomes narrative
|
||||||
|
* Provider → generation (via dependency injection)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getProvider } from "../llm/provider.js";
|
||||||
|
|
||||||
|
// ── Eligibility normalization ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter findings to only eligible ones according to disposition contract:
|
||||||
|
* null → eligible (accepted-by-default, provisional working interpretation)
|
||||||
|
* "agree" → eligible (confirmed evidence)
|
||||||
|
* "not_quite" → ineligible until corrected proposition is saved
|
||||||
|
* "not_relevant" → ineligible (discounted from active reasoning)
|
||||||
|
* rejected → excluded (already failed structural validation)
|
||||||
|
*
|
||||||
|
* Also excludes any Finding that has evaluation === "rejected".
|
||||||
|
*/
|
||||||
|
export function filterEligibleFindings(findings) {
|
||||||
|
if (!findings || !Array.isArray(findings)) return [];
|
||||||
|
|
||||||
|
return findings.filter((f) => {
|
||||||
|
// Structural validation exclusion (already evaluated upstream)
|
||||||
|
if (f.evaluation === "rejected") return false;
|
||||||
|
|
||||||
|
const disposition = f.userDisposition;
|
||||||
|
|
||||||
|
// not_relevant → ineligible
|
||||||
|
if (disposition === "not_relevant") return false;
|
||||||
|
|
||||||
|
// not_quite → ineligible until corrected proposition is saved
|
||||||
|
if (disposition === "not_quite") return false;
|
||||||
|
|
||||||
|
// null, agree → eligible; anything else unexpected but let through
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Synthesis prompt construction ──────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the synthesis prompt from SituationGraph and eligible Findings.
|
||||||
|
* The prompt instructs the model to produce one coherent Current Understanding narrative
|
||||||
|
* from the provided inputs, without append semantics.
|
||||||
|
*/
|
||||||
|
export function buildSynthesisPrompt(situationGraph, findings) {
|
||||||
|
// Build structured graph representation for the prompt
|
||||||
|
const graphInfo = {
|
||||||
|
centralStatement: situationGraph.centralStatement ?? "",
|
||||||
|
nodes: (situationGraph.nodes ?? []).map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
proposition: n.proposition ?? "",
|
||||||
|
description: n.description ?? "",
|
||||||
|
status: n.status ?? null,
|
||||||
|
confidence: n.confidence ?? null,
|
||||||
|
})),
|
||||||
|
edges: (situationGraph.edges ?? []).map((e) => ({
|
||||||
|
from: e.from ?? null,
|
||||||
|
to: e.to ?? null,
|
||||||
|
type: e.type ?? "",
|
||||||
|
context: e.context ?? "",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a structured representation of eligible Findings for the prompt
|
||||||
|
const findingsSections = [];
|
||||||
|
|
||||||
|
if (findings.length > 0) {
|
||||||
|
const agreed = findings.filter((f) => f.userDisposition === "agree");
|
||||||
|
const provisional = findings.filter(
|
||||||
|
(f) => f.userDisposition === null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (agreed.length > 0) {
|
||||||
|
findingsSections.push({
|
||||||
|
label: "Confirmed Evidence",
|
||||||
|
items: agreed.map((f) => ({
|
||||||
|
proposition: f.proposition,
|
||||||
|
id: f.id ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provisional.length > 0) {
|
||||||
|
findingsSections.push({
|
||||||
|
label: "Provisional Findings (working interpretation)",
|
||||||
|
items: provisional.map((f) => ({
|
||||||
|
proposition: f.proposition,
|
||||||
|
id: f.id ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable node and edge representations for the prompt
|
||||||
|
const nodesSection = graphInfo.nodes.length > 0 ? `\nNodes:\n${graphInfo.nodes.map((n) => ` Node(${n.id}): ${n.proposition}${n.description ? ` — ${n.description}` : ""}${n.status ? ` [${n.status}]` : ""}`).join("\n")}` : "";
|
||||||
|
const edgesSection = graphInfo.edges.length > 0 ? `\nEdges:\n${graphInfo.edges.map((e) => ` Edge(${e.from} → ${e.to}, type=${e.type}): ${e.context || "(no context)"}`).join("\n")}` : "";
|
||||||
|
|
||||||
|
const prompt = `You are producing a Current Understanding narrative from investigation evidence.
|
||||||
|
|
||||||
|
Canonical Situation Graph:
|
||||||
|
${JSON.stringify(graphInfo, null, 2)}${nodesSection}${edgesSection}
|
||||||
|
|
||||||
|
Eligible Findings:
|
||||||
|
${findingsSections.length > 0
|
||||||
|
? JSON.stringify(findingsSections, null, 2)
|
||||||
|
: "(none)"}
|
||||||
|
|
||||||
|
Rules for this synthesis:
|
||||||
|
1. Produce exactly ONE coherent narrative paragraph (or short multi-sentence paragraph) that represents the Current Understanding of the situation.
|
||||||
|
2. Synthesize all provided evidence into a unified understanding — do not list or append findings. The result should read as a natural summary, not a bullet list.
|
||||||
|
3. This is a FRESH synthesis from the complete set of inputs above. Do NOT treat any previous Current Understanding as input or authority. Do NOT append to prior summaries.
|
||||||
|
4. Use only information present in the Situation context and Eligible Findings above.
|
||||||
|
5. If no eligible Findings are provided, synthesize from the Situation context alone.
|
||||||
|
6. Return ONLY a JSON object with this exact structure:
|
||||||
|
{"currentUnderstanding": "your narrative here"}
|
||||||
|
7. The currentUnderstanding value must be a non-empty string.
|
||||||
|
|
||||||
|
Return ONLY the JSON object. No markdown, no explanation, no preamble.`;
|
||||||
|
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Synthesis response schema ──────────────────────────────
|
||||||
|
|
||||||
|
export const synthesisResponseSchema = z.object({
|
||||||
|
currentUnderstanding: z
|
||||||
|
.string()
|
||||||
|
.min(1, "currentUnderstanding must be a non-empty string"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Validate raw provider output against synthesis response schema */
|
||||||
|
export function validateSynthesisResponse(raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return { valid: false, error: "Provider returned null/undefined" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return { valid: false, error: "Provider output is not valid JSON" };
|
||||||
|
}
|
||||||
|
} else if (typeof raw === "object") {
|
||||||
|
parsed = raw;
|
||||||
|
} else {
|
||||||
|
return { valid: false, error: "Provider output has unexpected type" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = synthesisResponseSchema.safeParse(parsed);
|
||||||
|
if (!result.success) {
|
||||||
|
const firstIssue = result.error.issues[0];
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: firstIssue?.message ?? "Invalid synthesis response",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, data: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main domain function ───────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone Current Understanding synthesis.
|
||||||
|
*
|
||||||
|
* @param {{ situationGraph: object, findings: Array<object> }} inputs
|
||||||
|
* - situationGraph: the authoritative SituationGraph object
|
||||||
|
* - findings: all canonical Findings (may be empty array)
|
||||||
|
* @param {{ provider?: object }} [dependencies={}]
|
||||||
|
* - provider: dependency-injected provider with generateReconstruction(prompt, modelName)
|
||||||
|
* @returns {Promise<{ currentUnderstanding: string }>} validated narrative-only result
|
||||||
|
*/
|
||||||
|
export async function synthesizeCurrentUnderstanding(
|
||||||
|
{ situationGraph, findings },
|
||||||
|
dependencies = {}
|
||||||
|
) {
|
||||||
|
// 1. Input validation
|
||||||
|
if (!situationGraph || typeof situationGraph !== "object") {
|
||||||
|
const err = new Error("Invalid input: situationGraph is required and must be an object");
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findings != null && !Array.isArray(findings)) {
|
||||||
|
const err = new Error("Invalid input: findings must be an array or null/undefined");
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize empty findings to empty array
|
||||||
|
const allFindings = findings ?? [];
|
||||||
|
|
||||||
|
// 2. Eligibility normalization (domain seam responsibility)
|
||||||
|
const eligibleFindings = filterEligibleFindings(allFindings);
|
||||||
|
|
||||||
|
// 3. Build synthesis prompt (uses full canonical graph, not just centralStatement)
|
||||||
|
const prompt = buildSynthesisPrompt(situationGraph, eligibleFindings);
|
||||||
|
|
||||||
|
// 4. Resolve provider — DI fallback to configured default
|
||||||
|
const provider = dependencies.provider ?? getProvider();
|
||||||
|
if (!provider || typeof provider.generateReconstruction !== "function") {
|
||||||
|
throw new Error("Invalid dependency: provider must have generateReconstruction");
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawResponse;
|
||||||
|
try {
|
||||||
|
rawResponse = await provider.generateReconstruction(
|
||||||
|
prompt,
|
||||||
|
dependencies.modelName ?? null
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const err = new Error(error.message ?? "Synthesis provider call failed");
|
||||||
|
err.statusCode = 502;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Validate response
|
||||||
|
const validated = validateSynthesisResponse(rawResponse);
|
||||||
|
if (!validated.valid) {
|
||||||
|
const err = new Error(`Synthesis validation failed: ${validated.error}`);
|
||||||
|
err.statusCode = 502;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Return narrative-only result — no graph/Finding mutation
|
||||||
|
return { currentUnderstanding: validated.data.currentUnderstanding };
|
||||||
|
}
|
||||||
@@ -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