169 lines
6.6 KiB
JavaScript
169 lines
6.6 KiB
JavaScript
/**
|
|
* 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(" | ") + "]";
|
|
}
|