/** * Investigation Overview synthesis seam — standalone domain function. * * Purpose: produce a structurally distinct two-part overview that keeps * (A) evidence-backed understanding and * (B) remaining plausible interpretations * epistemically separate. * * Input contract: * { situationGraph, findings, plausibleInterpretations } * * Output contract: * { * "understanding": "evidence-backed synthesis", * "plausibleInterpretations": "qualified synthesis of remaining interpretations" * } * * Does NOT produce: recommendation, decision, confidence score, next action, priority, readiness. */ import { z } from "zod"; import { getProvider } from "../llm/provider.js"; import { filterEligibleFindings } from "./current-understanding-synthesis.js"; export { filterEligibleFindings }; // ── Overview-specific output validation schema ──────────────── const overviewResponseSchema = z.object({ understanding: z.string().min(1), plausibleInterpretations: z.string().min(1), }); const FORBIDDEN_FIELD_NAMES = new Set([ "recommendation", "decision", "confidenceScore", "nextAction", "priority", "readiness", ]); /** * Validate that the raw overview response has exactly two semantic fields: * - understanding (string) * - plausibleInterpretations (string) * and no decision/recommendation/priority/readiness/next-action leakage. */ export function validateOverviewResponse(raw) { if (raw == null) { return { valid: false, reason: "Provider returned null/undefined" }; } let parsed; if (typeof raw === "string") { try { parsed = JSON.parse(raw); } catch { return { valid: false, reason: "Provider output is not valid JSON" }; } } else if (typeof raw === "object") { parsed = raw; } else { return { valid: false, reason: "Provider output has unexpected type" }; } // Reject any forbidden epistemic fields for (const key of Object.keys(parsed)) { if (FORBIDDEN_FIELD_NAMES.has(key)) { return { valid: false, reason: `forbidden_field: ${key}` }; } } const result = overviewResponseSchema.safeParse(parsed); if (!result.success) { return { valid: false, reason: "Missing or invalid required fields" }; } return { valid: true, data: result.data }; } // ── Overview-specific prompt construction ───────────────────── const KNOWN_SUPPORTED_STATUSES = new Set(["known", "supported"]); function safeDesc(value) { return (value && typeof value === "string") ? value : null; } /** * Build the overview synthesis prompt from SituationGraph, eligible Findings, * and plausible interpretations. * * Produces three evidence sections: * 1. Evidence-backed understanding inputs (known + supported nodes + eligible Findings) * 2. Plausible interpretations inputs (kept separate from evidence) * 3. Epistemic boundary rules */ export function buildOverviewSynthesisPrompt(situationGraph, findings, plausibleInterpretations) { // Evidence-backed projection: reuse the existing known+supported logic const knownNodes = (situationGraph.nodes ?? []) .filter((n) => KNOWN_SUPPORTED_STATUSES.has(n.status)) .filter((n) => n.status === "known") .map((n) => ({ kind: n.kind ?? null, label: safeDesc(n.label), description: safeDesc(n.description), value: n.value ?? null, unit: n.unit ?? null, status: n.status ?? null, })); const supportedNodes = (situationGraph.nodes ?? []) .filter((n) => KNOWN_SUPPORTED_STATUSES.has(n.status)) .filter((n) => n.status !== "known") .map((n) => ({ kind: n.kind ?? null, label: safeDesc(n.label), description: safeDesc(n.description), value: n.value ?? null, unit: n.unit ?? null, status: n.status ?? null, })); const centralStatement = safeDesc(situationGraph.centralStatement) || ""; // Eligible Findings (reuse existing filter) const eligibleFindings = filterEligibleFindings(findings); const agreedFindings = eligibleFindings.filter((f) => f.userDisposition === "agree"); const workingFindings = eligibleFindings.filter((f) => f.userDisposition === null); // Format nodes for prompt display function formatNodes(nodes, title) { if (!nodes || nodes.length === 0) return ""; return nodes.map( (n) => ` ${title}: kind=${n.kind}, label="${n.label}", value=${n.value ? n.value + (n.unit ? " (" + n.unit + ")" : "") : null} — ${n.description ?? "(no description)"} [${n.status}]` ).join("\n"); } const knownSection = formatNodes(knownNodes, "Known"); const supportedSection = formatNodes(supportedNodes, "Supported"); // Format plausible interpretations (kept separate from evidence) const interpSections = []; if (plausibleInterpretations && Array.isArray(plausibleInterpretations)) { for (const interp of plausibleInterpretations) { interpSections.push({ id: interp.id ?? null, description: safeDesc(interp.description) || "Unlabelled interpretation", confidence: interp.confidence ?? "unknown", supportingEvidenceIds: interp.supportingEvidenceIds ?? [], }); } } const findingsSections = []; if (agreedFindings.length > 0) { findingsSections.push({ label: "Confirmed Evidence", items: agreedFindings.map((f) => ({ proposition: f.proposition, id: f.id ?? null })), }); } if (workingFindings.length > 0) { findingsSections.push({ label: "Working Premises", items: workingFindings.map((f) => ({ proposition: f.proposition, id: f.id ?? null })), }); } const prompt = `You are producing an investigation overview with two structurally distinct sections. Situation Framing: ${centralStatement ? " Central Statement: " + centralStatement : "(none)"} === SECTION A INPUTS — Evidence-Backed Understanding === Provider-Active Evidence: Known Facts:${knownSection || " (none)"} Supported Inferences:${supportedSection || " (none)"} Eligible Findings: ${findingsSections.length > 0 ? JSON.stringify(findingsSections, null, 2) : "(none)"} === SECTION B INPUTS — Plausible Interpretations (NOT evidence-backed) === Plausible Interpretations: ${interpSections.length > 0 ? JSON.stringify(interpSections, null, 2) : "(none)"} === EPISTEMIC BOUNDARY RULES === 1. Section A (understanding) MUST contain only established or supported understanding from the evidence in SECTION A INPUTS above. 2. Section A MUST NOT include open questions, unresolved uncertainties, assumptions, provisional hypotheses, speculative explanations, or future investigation needs. 3. Plausible interpretations from SECTION B INPUTS MUST remain explicitly qualified as interpretations — never promoted into Section A (understanding). 4. Plausible interpretations must not be presented as established evidence or confirmed facts. 5. Produce exactly ONE coherent narrative paragraph for "understanding" from SECTION A inputs only. 6. Produce exactly ONE coherent narrative paragraph for "plausibleInterpretations" from SECTION B inputs only. Each interpretation should be clearly qualified as an interpretation. 7. Do NOT introduce any new facts not present in the supplied evidence. 8. Return ONLY a JSON object with this exact structure: {"understanding": "...", "plausibleInterpretations": "..."} 9. Neither field may contain recommendations, decisions, confidence scores, next actions, priorities, or readiness assessments. 10. This is a FRESH synthesis — do NOT treat any previous overview or Current Understanding as input. Both fields must be non-empty strings.`; return prompt; } // ── Overview domain function ────────────────────────────────── /** * Synthesize an investigation overview with structurally distinct sections: * - understanding: evidence-backed synthesis * - plausibleInterpretations: qualified remaining interpretations * * @param {{ situationGraph, findings, plausibleInterpretations }} params * @param {{ provider, modelName }} deps * @returns {Promise<{ understanding: string, plausibleInterpretations: string }>} */ export async function synthesizeInvestigationOverview(params, deps) { const { situationGraph, findings = [], plausibleInterpretations = [] } = params; if (!situationGraph || typeof situationGraph !== "object") { throw new Error("situationGraph is required"); } if (!Array.isArray(findings)) { throw new Error("findings must be an array"); } if (!Array.isArray(plausibleInterpretations)) { throw new Error("plausibleInterpretations must be an array"); } // Provider acquisition (reuse existing pattern) const provider = deps?.provider ?? getProvider(); const modelName = deps?.modelName ?? process.env.OLLAMA_MODEL; if (!provider || typeof provider.generateReconstruction !== "function") { throw new Error("Invalid dependency: provider must have generateReconstruction"); } // Build overview-specific prompt (keeps evidence/interpretation separate) const prompt = buildOverviewSynthesisPrompt(situationGraph, findings, plausibleInterpretations); let rawResponse; try { rawResponse = await provider.generateReconstruction( prompt, modelName, ); } catch (err) { throw new Error(`Overview synthesis provider call failed: ${err.message}`); } // Validate against overview-specific contract const validated = validateOverviewResponse(rawResponse); if (!validated.valid) { throw new Error(`Overview synthesis validation failed: ${validated.reason}`); } return validated.data; }