306 lines
11 KiB
JavaScript
306 lines
11 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.
|
|
*/
|
|
|
|
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<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 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 };
|
|
}
|