feat(confidence-engine): add focused finding handoff plumbing

This commit is contained in:
2026-08-26 16:07:21 +01:00
parent d3015f63d8
commit 3235c35cf0
5 changed files with 526 additions and 7 deletions
+168
View File
@@ -0,0 +1,168 @@
/**
* Finding helpers — v1 minimum handoff from focused contributions.
*
* Deterministic (no LLM). Each observation in a focused contribution
* yields exactly one provisional Finding. Findings may influence
* Current Understanding only and must never touch SituationGraph,
* activeUnknownNodeId, selectedQuestion, or frontier selection.
*/
// ── Fixed disposition values ──────────────────────────────
export const FINDING_DISPOSITION_VALUES = ["agree", "not_quite", "not_relevant"];
// ── Deterministic id derivation ───────────────────────────
/**
* Derive a stable, collision-resistant Finding id from the source observation
* and its originating contribution reference.
*/
function hashString(str) {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = (Math.imul(31, h) + str.charCodeAt(i)) | 0;
}
return h;
}
export function deriveFindingId(sourceObservation, contributionId) {
const seed = `${contributionId}|${sourceObservation}`;
const hash = hashString(seed);
return "finding-" + Math.abs(hash).toString(36).slice(0, 7);
}
// ── Finding derivation (contribution → findings) ──────────
/**
* Given an array of contributions that each carry observations[],
* produce one provisional Finding per observation.
*
* Returns: { findings, evaluation } — pure result, no side effects.
*/
export function deriveFindingsFromContributions(contributions) {
const findings = [];
let contribIdx = 0;
for (const contrib of contributions) {
if (!contrib?.observations || !Array.isArray(contrib.observations)) continue;
const targetId = contrib.targetNodeId ?? "";
const contribSeq = contrib.sequence != null ? String(contrib.sequence) : String(++contribIdx);
const contribId = contrib.id ?? `contrib-${String(contribIdx).padStart(4, "0")}`;
for (const obs of contrib.observations) {
if (typeof obs !== "string" || !obs.trim()) continue;
findings.push({
id: deriveFindingId(obs, contribId),
proposition: obs, // Finding proposition = exact observation text
status: "provisional",
userDisposition: null, // default: silence ≠ agreement
originatingTargetNodeId: targetId,
contributionId: contribId,
sourceObservation: obs, // immutable provenance anchor
createdAt: new Date().toISOString(),
});
}
}
return { findings };
}
// ── Server-side validation ────────────────────────────────
/** Validate a single incoming finding. Returns null when valid or an error string. */
export function validateSingleFinding(finding) {
if (!finding || typeof finding !== "object") return "Finding is not an object";
if (typeof finding.proposition !== "string" || !finding.proposition.trim())
return "Malformed: empty or missing proposition";
if (typeof finding.contributionId !== "string" || !finding.contributionId)
return "Malformed: contributionId required";
if (!finding.sourceObservation || typeof finding.sourceObservation !== "string")
return "Malformed: sourceObservation required and must be string";
if (finding.userDisposition !== null && FINDING_DISPOSITION_VALUES.indexOf(finding.userDisposition) === -1)
return "Malformed: invalid userDisposition";
// No graph mutation fields allowed in findings
const forbidden = ["situationGraph", "nodes", "edges", "activeUnknownNodeId", "selectedQuestion"];
for (const key of forbidden) {
if (key in finding) return `Malformed: unexpected graph field "${key}"`;
}
// Must have a traceable contribution reference format
if (!finding.contributionId.startsWith("contrib-"))
return "Untraceable: contributionId must start with contrib-";
return null;
}
/** Validate an array of findings; return { valid, rejectedErrors }. */
export function validateFindings(findings) {
const results = [];
const idSet = new Set();
for (const f of findings) {
const err = validateSingleFinding(f);
if (err) {
results.push({ ...f, evaluation: "rejected", reason: err });
continue;
}
if (idSet.has(f.id)) {
results.push({ ...f, evaluation: "rejected", reason: "Duplicate finding id" });
continue;
}
idSet.add(f.id);
// Normalize disposition to canonical value
const disposition = f.userDisposition === null ? null : FINDING_DISPOSITION_VALUES.indexOf(f.userDisposition) !== -1 ? f.userDisposition : null;
results.push({ ...f, evaluation: "considered", userDisposition: disposition });
}
return { findings: results };
}
// ── Deduplicate + map dispositions (client helper) ─────────
/** Deduplicate by id and map userDisposition to canonical value. */
export function normalizeFindings(findings) {
const seen = new Set();
return findings.filter((f) => {
if (seen.has(f.id)) return false;
seen.add(f.id);
return true;
}).map((f) => ({
...f,
userDisposition: f.userDisposition === null ? null : FINDING_DISPOSITION_VALUES.indexOf(f.userDisposition) !== -1 ? f.userDisposition : null,
}));
}
// ── Apply validated findings to Current Understanding ─────
/** Return new summary string that appends valid finding propositions. */
export function applyFindingsToSummary(summary, validatedFindings) {
let text = summary;
const agreeTexts = [];
const notQuiteTexts = [];
const notRelevantTexts = [];
for (const f of validatedFindings) {
if (f.evaluation === "rejected") continue;
// Map disposition to evaluation state
switch (f.userDisposition) {
case "agree": f.evaluation = "used"; agreeTexts.push(f.proposition); break;
case "not_quite": f.evaluation = "not_used"; notQuiteTexts.push(f.proposition); break;
case "not_relevant": f.evaluation = "not_used"; notRelevantTexts.push(f.proposition); break;
default: /* null disposition → considered only */ break;
}
}
if (agreeTexts.length === 0 && notQuiteTexts.length === 0 && notRelevantTexts.length === 0) {
return summary; // no textual change
}
const parts = [];
if (agreeTexts.length > 0) {
parts.push(`Confirmed observation${agreeTexts.length > 1 ? "s" : ""}: ${agreeTexts.join("; ")}`);
}
if (notQuiteTexts.length > 0) {
parts.push(`Partial match${notQuiteTexts.length > 1 ? "es" : ""}: ${notQuiteTexts.join("; ")}`);
}
if (notRelevantTexts.length > 0) {
parts.push(`Noted as not directly relevant: ${notRelevantTexts.join("; ")}`);
}
return text + " [" + parts.join(" | ") + "]";
}
+31 -1
View File
@@ -30,6 +30,7 @@ import {
selectActiveUnknownCandidate,
validateGraphReferences,
} from "./utils.js";
import { validateFindings } from "./finding-helpers.js";
function toValidationErrors(error) {
return (
@@ -596,7 +597,7 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
};
}
const { situationGraph, previousQuestion, answer, promptVersion } =
const { situationGraph, previousQuestion, answer, promptVersion, findings: incomingFindings } =
parsedRequest.data;
const graphSchemaValidation = situationGraphSchema.safeParse(situationGraph);
@@ -825,6 +826,33 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
};
}
// ── v2: process incoming findings (no graph mutation) ────
let appendedFindings = [];
if (incomingFindings && Array.isArray(incomingFindings) && incomingFindings.length > 0) {
const validated = validateFindings(incomingFindings);
// Filter out rejected findings for display only — do NOT modify currentSummary.
// Direct concatenation of Finding text into Current Understanding would bypass
// the authoritative case/update reasoning that evaluates Finding context.
// Per v1 handoff contract: "Noted as not directly relevant" and similar markers
// are display-only; they must never be appended to summary or graph state.
const validForDisplay = validated.findings.filter((f) => f.evaluation !== "rejected");
// Normalize and include approved findings in the response (display passthrough)
const normalized = validForDisplay.map((f) => ({
id: f.id,
proposition: f.proposition,
status: f.status,
userDisposition: f.userDisposition,
originatingTargetNodeId: f.originatingTargetNodeId,
contributionId: f.contributionId,
sourceObservation: f.sourceObservation,
createdAt: f.createdAt,
}));
appendedFindings = normalized;
}
return {
success: true,
stage: "update_applied",
@@ -837,6 +865,8 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
applicationResult.previousActiveUnknownNodeId,
newActiveUnknownNodeId: applicationResult.newActiveUnknownNodeId,
changesApplied: applicationResult.changesApplied,
appendedFindings,
summary: applicationResult.updatedSituationGraph?.currentSummary ?? "",
diagnostics: buildUpdateDiagnostics({
promptVersion,
modelName,
+11
View File
@@ -209,6 +209,17 @@ export const updateCaseRequestSchema = z.object({
previousQuestion: z.string().min(1),
answer: z.string().min(1).max(5000),
promptVersion: z.string().optional(),
findings: z.array(
z.object({
id: z.string().min(1),
proposition: z.string().min(1),
status: z.literal("provisional"),
userDisposition: z.enum(["agree", "not_quite", "not_relevant"]).nullable(),
originatingTargetNodeId: z.string().min(1),
contributionId: z.string().min(1),
sourceObservation: z.string().min(1),
}),
).optional(),
});
// ── Helpers ──────────────────────────────────────────