Files
confidence-engine/lib/graph/current-understanding-synthesis.js
T

248 lines
9.0 KiB
JavaScript

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