Files
confidence-engine/lib/graph/investigation-overview-synthesis.js
T

261 lines
9.5 KiB
JavaScript

/**
* 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;
}