248 lines
9.0 KiB
JavaScript
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 };
|
|
}
|