/** * 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. */ const KNOWN_SUPPORTED_STATUSES = new Set(["known", "supported"]); function safeDesc(value) { return (value && typeof value === "string") ? value : null; } /** * Evidence-authority projection for CU synthesis. * * Includes only: * - centralStatement (framing context — not independent evidence) * - known nodes (provider-active graph evidence) * - supported nodes (provider-active graph evidence) * - eligible Findings (from the findings parameter) * * Explicitly excludes from provider-active synthesis input: * provisional nodes, unknown nodes, resolved nodes, edges, * activeUnknownNodeId, resolvedNodeIds, currentSummary, * reasoningState, confidence/control metadata, dependency/control fields. */ function buildGraphEvidenceProjection(graphInfo) { const knownNodes = (graphInfo.nodes ?? []) .filter((n) => KNOWN_SUPPORTED_STATUSES.has(n.status)) .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 = (graphInfo.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(graphInfo.centralStatement) || ""; return { centralStatement, knownNodes, supportedNodes }; } export function buildSynthesisPrompt(situationGraph, findings) { // Structured evidence projection for the model const evidence = buildGraphEvidenceProjection(situationGraph); // Eligible Finding sections (preserve existing agree vs null distinction) const findingsSections = []; if (findings.length > 0) { const agreed = findings.filter((f) => f.userDisposition === "agree"); const working = 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 (working.length > 0) { findingsSections.push({ label: "Working Premises", items: working.map((f) => ({ proposition: f.proposition, id: f.id ?? null, })), }); } } // Human-readable node display for the prompt function formatNodeSection(title, nodes) { if (!nodes || nodes.length === 0) return ""; const items = 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}]` ); return "\n" + items.join("\n"); } const knownSection = formatNodeSection("Known", evidence.knownNodes); const supportedItem = formatNodeSection("Supported", evidence.supportedNodes); const prompt = `You are producing a Current Understanding narrative from investigation evidence. Situation Framing: ${evidence.centralStatement ? " Central Statement: " + evidence.centralStatement : "(none)"} Provider-Active Evidence: Known Facts:${knownSection} Supported Inferences:${supportedItem} Eligible Findings: ${findingsSections.length > 0 ? JSON.stringify(findingsSections, null, 2) : "(none)"} Rules for this synthesis: 1. Current Understanding describes only established or supported understanding from the evidence supplied here. 2. Do not introduce or describe open questions, unresolved uncertainties, assumptions, provisional hypotheses, speculative explanations, or future investigation needs. 3. Produce exactly ONE coherent narrative paragraph (or short multi-sentence paragraph) that represents the Current Understanding of the situation. 4. 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. 5. 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. 6. Use only information present in the evidence above. The centralStatement is framing context, not independent evidence. 7. Return ONLY a JSON object with this exact structure: {"currentUnderstanding": "your narrative here"} 8. 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"), }); export const synthesisOutputSchema = { type: "object", properties: { currentUnderstanding: { type: "string" }, }, required: ["currentUnderstanding"], additionalProperties: false, }; /** 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 }} 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 evidence-authority projection, not raw graph) 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"); } // Resolve configured model: explicit dep > config dep (assertConfig) > process.env > null let modelName = dependencies.modelName; if (modelName == null && dependencies.config?.OLLAMA_MODEL != null) { modelName = dependencies.config.OLLAMA_MODEL; } if (modelName == null) { modelName = process.env.OLLAMA_MODEL ?? null; } let rawResponse; try { rawResponse = await provider.generateReconstruction( prompt, modelName, synthesisOutputSchema, ); } 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 }; }