261 lines
9.5 KiB
JavaScript
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;
|
|
}
|