feat(confidence-engine): establish current understanding synthesis seam
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
let rawResponse;
|
||||
try {
|
||||
rawResponse = await provider.generateReconstruction(
|
||||
prompt,
|
||||
dependencies.modelName ?? null
|
||||
);
|
||||
} 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user