feat(confidence-engine): add investigation overview synthesis seam
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user