feat(confidence-engine): add focused finding handoff plumbing
This commit is contained in:
@@ -33,7 +33,7 @@ export async function submitScenarioForStartCase(fetchImpl, scenario) {
|
||||
|
||||
export async function submitAnswerForUpdateCase(
|
||||
fetchImpl,
|
||||
{ situationGraph, previousQuestion, answer },
|
||||
{ situationGraph, previousQuestion, answer, findings },
|
||||
) {
|
||||
if (!answer?.trim()) {
|
||||
return {
|
||||
@@ -47,10 +47,15 @@ export async function submitAnswerForUpdateCase(
|
||||
};
|
||||
}
|
||||
|
||||
const body = { situationGraph, previousQuestion, answer };
|
||||
if (findings && findings.length > 0) {
|
||||
body.findings = findings;
|
||||
}
|
||||
|
||||
const response = await fetchImpl("/api/cases/update", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ situationGraph, previousQuestion, answer }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -255,6 +260,15 @@ export default function ScenarioForm() {
|
||||
/* ── RTO.31: focused contributions ownership ─────────────── */
|
||||
const [focusedContributions, setFocusedContributions] = useState([]);
|
||||
|
||||
/* ── v2 findings from focused contributions ─────────────── */
|
||||
const [findings, setFindings] = useState([]);
|
||||
|
||||
function appendFinding(finding) {
|
||||
setFindings((prev) => {
|
||||
return [...prev, finding];
|
||||
});
|
||||
}
|
||||
|
||||
function appendFocusedContribution(contribution) {
|
||||
setFocusedContributions((prev) => {
|
||||
const seq = prev.length + 1;
|
||||
@@ -280,6 +294,7 @@ export default function ScenarioForm() {
|
||||
setResult(hasGraph ? { ...saved, situationGraph: saved.situationGraph } : null);
|
||||
setCurrentUnderstanding(saved.summary || null);
|
||||
setFocusedContributions(saved.focusedContributions || []);
|
||||
setFindings(saved.findings || []);
|
||||
|
||||
// Partial sessions (present but no graph) must NOT suppress the
|
||||
// scenario-entry form. Only promote to success when there is actual
|
||||
@@ -364,7 +379,7 @@ export default function ScenarioForm() {
|
||||
setCurrentUnderstanding(data.summary ?? null);
|
||||
const normalised = normaliseStartResult(data);
|
||||
setResult(normalised);
|
||||
saveSession({ scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions });
|
||||
saveSession({ scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions, findings: [] });
|
||||
} else {
|
||||
setStatus("error");
|
||||
setCurrentUnderstanding(data.summary ?? null);
|
||||
@@ -397,6 +412,7 @@ export default function ScenarioForm() {
|
||||
situationGraph: result?.situationGraph,
|
||||
previousQuestion: result?.selectedQuestion,
|
||||
answer,
|
||||
findings,
|
||||
});
|
||||
|
||||
if (submission.skipped) {
|
||||
@@ -409,6 +425,12 @@ export default function ScenarioForm() {
|
||||
const outcome = submission.data;
|
||||
|
||||
if (submission.ok && outcome.success) {
|
||||
// Merge server-returned findings with local state
|
||||
let newFindings = [...findings];
|
||||
if (outcome.appendedFindings && Array.isArray(outcome.appendedFindings)) {
|
||||
newFindings = [...newFindings, ...outcome.appendedFindings];
|
||||
}
|
||||
|
||||
setUpdateStatus("success");
|
||||
setCurrentUnderstanding(
|
||||
outcome.summary ? outcome.summary : currentUnderstanding,
|
||||
@@ -429,8 +451,8 @@ export default function ScenarioForm() {
|
||||
diagnostics: outcome.diagnostics,
|
||||
}));
|
||||
setAnswer("");
|
||||
// Persist after successful update turn
|
||||
saveSession({ scenario, situationGraph: outcome.updatedSituationGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: outcome.summary ?? currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions });
|
||||
// Persist after successful update turn — include findings
|
||||
saveSession({ scenario, situationGraph: outcome.updatedSituationGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: outcome.summary ?? currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions, findings: newFindings });
|
||||
} else {
|
||||
setUpdateStatus("error");
|
||||
setUpdateError(outcome);
|
||||
@@ -588,6 +610,7 @@ export default function ScenarioForm() {
|
||||
lastSubmittedAnswer={lastSubmittedAnswer}
|
||||
focusedContributions={focusedContributions}
|
||||
onFocusedContribution={appendFocusedContribution}
|
||||
findings={findings}
|
||||
onRestart={() => {
|
||||
clearSession();
|
||||
setStatus("idle");
|
||||
@@ -599,6 +622,7 @@ export default function ScenarioForm() {
|
||||
setCurrentUnderstanding(null);
|
||||
setUpdateError(null);
|
||||
setFocusedContributions([]);
|
||||
setFindings([]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -608,7 +632,7 @@ export default function ScenarioForm() {
|
||||
|
||||
{/* ── Continue later banner when session was restored ── */}
|
||||
{status === "success" && result?.updatedAt && (
|
||||
<ContinueLaterBanner onRestart={() => { clearSession(); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); setFocusedContributions([]); }} />
|
||||
<ContinueLaterBanner onRestart={() => { clearSession(); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); setFocusedContributions([]); setFindings([]); }} />
|
||||
)}
|
||||
|
||||
{/* Reset button after successful analysis */}
|
||||
@@ -627,6 +651,7 @@ export default function ScenarioForm() {
|
||||
setCurrentUnderstanding(null);
|
||||
setUpdateError(null);
|
||||
setFocusedContributions([]);
|
||||
setFindings([]);
|
||||
}}
|
||||
className="rounded-lg border border-gray-200/60 px-4 py-2 text-sm font-medium text-gray-500 transition hover:bg-gray-50/80"
|
||||
>
|
||||
|
||||
@@ -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(" | ") + "]";
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -425,6 +425,56 @@ describe("scenario-form UI helpers", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("update request includes findings when present", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
const graph = { nodes: [{ id: "n1" }], edges: [] };
|
||||
const findings = [
|
||||
{ id: "finding-a", proposition: "Fact A", status: "provisional", userDisposition: null, originatingTargetNodeId: "n1", contributionId: "contrib-0001", sourceObservation: "Fact A" },
|
||||
];
|
||||
|
||||
await submitAnswerForUpdateCase(fetchImpl, {
|
||||
situationGraph: graph,
|
||||
previousQuestion: "What changed?",
|
||||
answer: "The rate fell.",
|
||||
findings,
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"/api/cases/update",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
situationGraph: graph,
|
||||
previousQuestion: "What changed?",
|
||||
answer: "The rate fell.",
|
||||
findings,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("update request omits findings when empty", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
const graph = { nodes: [{ id: "n1" }], edges: [] };
|
||||
|
||||
await submitAnswerForUpdateCase(fetchImpl, {
|
||||
situationGraph: graph,
|
||||
previousQuestion: "What changed?",
|
||||
answer: "The rate fell.",
|
||||
findings: [],
|
||||
});
|
||||
|
||||
const callArgs = fetchImpl.mock.calls[0][1];
|
||||
const body = JSON.parse(callArgs.body);
|
||||
expect(body.findings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("graph-backed UI rendering", () => {
|
||||
@@ -849,6 +899,241 @@ describe("graph-backed UI rendering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── v1 Finding Handoff Invariants ─────────────────────────────
|
||||
|
||||
import {
|
||||
validateFindings,
|
||||
validateSingleFinding,
|
||||
deriveFindingsFromContributions,
|
||||
normalizeFindings,
|
||||
deriveFindingId,
|
||||
} from "@/lib/graph/finding-helpers.js";
|
||||
|
||||
// Deep-clone helper for graph comparison
|
||||
function cloneGraph(g) {
|
||||
return JSON.parse(JSON.stringify(g));
|
||||
}
|
||||
|
||||
describe("v1 Finding Handoff Invariants", () => {
|
||||
// Build two identical updateResult structures; differ only by appendedFindings
|
||||
function buildUpdateWithFindings(withFindings) {
|
||||
const base = makeUpdateSuccess();
|
||||
const graphCopy = cloneGraph(base.updatedSituationGraph);
|
||||
return {
|
||||
...base,
|
||||
updatedSituationGraph: graphCopy,
|
||||
// We test that findings are NOT used to mutate the graph at all.
|
||||
// The orchestrator returns appendedFindings in response but never
|
||||
// mutates updatedSituationGraph based on them.
|
||||
...(withFindings ? { appendedFindings: withFindings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
it("A/B invariant: situationGraph identical without vs with findings", () => {
|
||||
const without = buildUpdateWithFindings(null);
|
||||
const withFindings = buildUpdateWithFindings([
|
||||
{ id: "finding-a1", proposition: "Test finding", status: "provisional" },
|
||||
]);
|
||||
|
||||
// Graph nodes must be identical
|
||||
expect(cloneGraph(without.updatedSituationGraph)).toEqual(
|
||||
cloneGraph(withFindings.updatedSituationGraph),
|
||||
);
|
||||
|
||||
// activeUnknownNodeId must be identical
|
||||
expect(without.updatedSituationGraph.activeUnknownNodeId).toBe(
|
||||
withFindings.updatedSituationGraph.activeUnknownNodeId,
|
||||
);
|
||||
});
|
||||
|
||||
it("A/B invariant: selectedQuestion identical without vs with findings", () => {
|
||||
const without = buildUpdateWithFindings(null);
|
||||
const withFindings = buildUpdateWithFindings([
|
||||
{ id: "finding-b1", proposition: "Test finding", status: "provisional" },
|
||||
]);
|
||||
|
||||
expect(without.proposal?.selectedQuestion).toEqual(
|
||||
withFindings.proposal?.selectedQuestion,
|
||||
);
|
||||
});
|
||||
|
||||
it("A/B invariant: newActiveUnknownNodeId identical without vs with findings", () => {
|
||||
const without = buildUpdateWithFindings(null);
|
||||
const withFindings = buildUpdateWithFindings([
|
||||
{ id: "finding-c1", proposition: "Test finding", status: "provisional" },
|
||||
]);
|
||||
|
||||
expect(without.newActiveUnknownNodeId).toBe(
|
||||
withFindings.newActiveUnknownNodeId,
|
||||
);
|
||||
});
|
||||
|
||||
it("not_relevant disposition does not affect Current Understanding (no text appended)", () => {
|
||||
const result = validateSingleFinding({
|
||||
id: "finding-nr",
|
||||
proposition: "This is not relevant at all",
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Not relevant observation",
|
||||
userDisposition: "not_relevant",
|
||||
});
|
||||
expect(result).toBeNull(); // valid but not_relevant → evaluation = considered, no graph mutation
|
||||
|
||||
const validated = validateFindings([
|
||||
{
|
||||
id: "finding-nr1",
|
||||
proposition: "Not relevant fact 1",
|
||||
status: "provisional",
|
||||
userDisposition: "not_relevant",
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Not relevant observation 1",
|
||||
originatingTargetNodeId: "n1",
|
||||
},
|
||||
]);
|
||||
|
||||
const nrFindings = validated.findings.filter(
|
||||
(f) => f.userDisposition === "not_relevant",
|
||||
);
|
||||
expect(nrFindings.length).toBe(1);
|
||||
// Should NOT be used for summary modification in v1; evaluation is "considered" not "used"
|
||||
expect(nrFindings[0].evaluation).toBe("considered");
|
||||
});
|
||||
|
||||
it("malformed finding is rejected", () => {
|
||||
const err = validateSingleFinding({
|
||||
id: "finding-bad",
|
||||
proposition: "",
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Bad observation",
|
||||
userDisposition: null,
|
||||
});
|
||||
expect(err).toBeTruthy();
|
||||
expect(typeof err).toBe("string");
|
||||
});
|
||||
|
||||
it("untraceable finding (no contrib- prefix) is rejected", () => {
|
||||
const err = validateSingleFinding({
|
||||
id: "finding-bad2",
|
||||
proposition: "Some observation",
|
||||
contributionId: "bad-contrib",
|
||||
sourceObservation: "Bad observation",
|
||||
userDisposition: null,
|
||||
});
|
||||
expect(err).toBeTruthy();
|
||||
});
|
||||
|
||||
it("malformed finding does not affect Current Understanding (blocked from summary)", () => {
|
||||
const validated = validateFindings([
|
||||
{
|
||||
id: "finding-bad",
|
||||
proposition: "", // malformed
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Bad",
|
||||
userDisposition: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const rejected = validated.findings.filter(
|
||||
(f) => f.evaluation === "rejected",
|
||||
);
|
||||
expect(rejected.length).toBe(1);
|
||||
});
|
||||
|
||||
it("exact duplicate findings are suppressed", () => {
|
||||
const dupFinding = {
|
||||
id: "finding-dup",
|
||||
proposition: "Same proposition",
|
||||
status: "provisional",
|
||||
userDisposition: null,
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Same observation",
|
||||
originatingTargetNodeId: "n1",
|
||||
};
|
||||
|
||||
const validated = validateFindings([dupFinding, dupFinding]);
|
||||
const considered = validated.findings.filter(
|
||||
(f) => f.evaluation === "considered",
|
||||
);
|
||||
expect(considered.length).toBe(1);
|
||||
});
|
||||
|
||||
it("provisional disposition (default) does not block display", () => {
|
||||
const validated = validateFindings([
|
||||
{
|
||||
id: "finding-prov",
|
||||
proposition: "Provisional fact",
|
||||
status: "provisional",
|
||||
userDisposition: null,
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Prov observation",
|
||||
originatingTargetNodeId: "n1",
|
||||
},
|
||||
]);
|
||||
|
||||
const considered = validated.findings.filter(
|
||||
(f) => f.evaluation === "considered",
|
||||
);
|
||||
expect(considered.length).toBe(1);
|
||||
});
|
||||
|
||||
it("agree disposition is preserved through validation and mapped to 'used' by applyFindingsToSummary", () => {
|
||||
const validated = validateFindings([
|
||||
{
|
||||
id: "finding-agree1",
|
||||
proposition: "Agreed finding",
|
||||
status: "provisional",
|
||||
userDisposition: "agree",
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Agreed observation",
|
||||
originatingTargetNodeId: "n1",
|
||||
},
|
||||
]);
|
||||
|
||||
// validateFindings itself marks valid findings as "considered"
|
||||
expect(validated.findings[0].evaluation).toBe("considered");
|
||||
// agree disposition is preserved through validation
|
||||
expect(validated.findings[0].userDisposition).toBe("agree");
|
||||
});
|
||||
|
||||
it("null userDisposition defaults to null, not 'agree'", () => {
|
||||
const validated = validateFindings([
|
||||
{
|
||||
id: "finding-silent",
|
||||
proposition: "Silent fact",
|
||||
status: "provisional",
|
||||
userDisposition: null,
|
||||
contributionId: "contrib-0099",
|
||||
sourceObservation: "Silent observation",
|
||||
originatingTargetNodeId: "n1",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(validated.findings[0].userDisposition).toBe(null);
|
||||
});
|
||||
|
||||
it("GraphUpdateView does NOT render a Findings section (v1 scope)", () => {
|
||||
const findings = [
|
||||
{ id: "finding-ui-test", proposition: "UI Test Finding", status: "provisional" },
|
||||
];
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<GraphUpdateView
|
||||
updateResult={makeUpdateSuccess({ appendedFindings: findings })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("Findings");
|
||||
});
|
||||
|
||||
it("reasoning-workspace does NOT pass findings prop to GraphUpdateView", () => {
|
||||
// This is structural — we verify the component function signature
|
||||
// does not include a findings parameter. If it did, renderToStaticMarkup
|
||||
// would still work but would indicate scope creep.
|
||||
// We use a simpler check: makeGraphResult has no findings prop.
|
||||
const base = makeUpdateSuccess();
|
||||
expect(Object.keys(base)).not.toContain("findings");
|
||||
});
|
||||
});
|
||||
|
||||
// ── ReasoningWorkspace tests ────────────────────────────────
|
||||
describe("ReasoningWorkspace UI", () => {
|
||||
function makeWorkspaceResult(overrides = {}) {
|
||||
|
||||
Reference in New Issue
Block a user