feat(confidence-engine): support finding corrections

This commit is contained in:
2026-08-27 13:43:23 +01:00
parent 5c926154cd
commit 22e1d7484b
3 changed files with 292 additions and 3 deletions
+62 -3
View File
@@ -170,10 +170,35 @@ function FocusedQuestionBody({
focusedContributions,
currentFindings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error;
const hasResult = Boolean(focused?.result);
// ── Local correction state (FQB-owned, not propagated upward) ─
const [editingFindingId, setEditingFindingId] = useState(null);
const [draft, setDraft] = useState("");
function startEditing(id, proposition) {
setEditingFindingId(id);
setDraft(proposition ?? "");
}
function cancelEditing() {
setEditingFindingId(null);
setDraft("");
}
function saveEditing() {
const trimmed = (draft ?? "").trim();
if (!trimmed || !editingFindingId) {
cancelEditing();
return;
}
onUpdateFindingProposition?.(editingFindingId, trimmed);
cancelEditing();
}
return (
<>
{isFocused && hasContent && (
@@ -206,14 +231,40 @@ function FocusedQuestionBody({
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h3><ul className="list-disc pl-5 space-y-2">{(currentFindings?.length ? currentFindings : (focused.result.observations || [])).map((item, i) => {
const isFinding = typeof item === "object" && item !== null && "id" in item;
const disposition = isFinding ? item.userDisposition : null;
const isEditing = isFinding && editingFindingId === item.id;
if (!isFinding) {
return (
<li key={i} className="text-sm leading-relaxed text-gray-700">{item}</li>
);
}
if (isEditing) {
return (
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
<span className="flex-1">{currentFindings?.length ? item.proposition : item}</span>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={2}
data-testid="proposition-editor"
className="flex-1 rounded border border-blue-300 bg-blue-50/40 px-2 py-1 text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-300"
/>
<div className="flex gap-1 shrink-0 mt-[2px]">
<button onClick={(e) => { e.stopPropagation(); saveEditing(); }} data-testid="proposition-save" className="text-[10px] font-medium text-blue-600 underline shrink-0 hover:text-blue-700">Save</button>
<button onClick={(e) => { e.stopPropagation(); cancelEditing(); }} data-testid="proposition-cancel" className="text-[10px] font-medium text-gray-400 underline shrink-0 hover:text-gray-500">Cancel</button>
</div>
</li>
);
}
return (
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
<span className="flex-1">{item.proposition}</span>
{onUpdateFindingProposition && (
<button onClick={(e) => { e.stopPropagation(); startEditing(item.id, item.proposition); }} data-testid={`not-quite-${item.id}`} className="mt-[2px] text-[10px] font-medium text-amber-500 underline shrink-0 hover:text-amber-600">Not quite</button>
)}
{isFinding && onUpdateFindingDisposition && (
disposition === "not_relevant" ? (
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, null); }} className="mt-[2px] text-[10px] font-medium text-teal-600 underline shrink-0 hover:text-teal-700" title="Restore to understanding">restore</button>
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, null); }} data-testid={`restore-${item.id}`} className="mt-[2px] text-[10px] font-medium text-teal-600 underline shrink-0 hover:text-teal-700" title="Restore to understanding">restore</button>
) : (
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, "not_relevant"); }} className="mt-[2px] text-[10px] font-medium text-gray-400 underline shrink-0 hover:text-red-500" title="Remove from understanding">not relevant</button>
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, "not_relevant"); }} data-testid={`not-relevant-${item.id}`} className="mt-[2px] text-[10px] font-medium text-gray-400 underline shrink-0 hover:text-red-500" title="Remove from understanding">not relevant</button>
)
)}
</li>
@@ -1041,6 +1092,7 @@ function FocusedInvestigationWorkspace({
focusedContributions,
currentFindings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
const hasResult = Boolean(focused?.result);
@@ -1070,6 +1122,7 @@ function FocusedInvestigationWorkspace({
focusedContributions={hasResult ? [] : (focusedContributions || [])}
currentFindings={currentFindings || []}
onUpdateFindingDisposition={onUpdateFindingDisposition}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
</div>
@@ -1091,6 +1144,7 @@ function OpenQuestionsPanel({
setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds,
setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
const openNodes = (graph?.nodes || []).filter(
(n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id),
@@ -1169,6 +1223,7 @@ function OpenQuestionsPanel({
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={focusedContributions}
onUpdateFindingDisposition={onUpdateFindingDisposition}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
{/* Thread contributions for this node */}
@@ -1212,6 +1267,7 @@ export default function ReasoningWorkspace({
onFocusedContribution,
findings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
const [investigationHistory, setInvestigationHistory] = useState([]);
const turnCounter = useRef(0);
@@ -1816,6 +1872,7 @@ export default function ReasoningWorkspace({
focusedContributions={focusedContributions}
currentFindings={currentFindings || []}
onUpdateFindingDisposition={onUpdateFindingDisposition}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
);
})()}
@@ -1891,6 +1948,7 @@ export default function ReasoningWorkspace({
focusedContributions={focusedContributions}
focusedInvestigations={focusedInvestigations}
setIsFocusedWorkspaceOpen={setIsFocusedWorkspaceOpen}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
)}
@@ -2066,6 +2124,7 @@ export default function ReasoningWorkspace({
focusedContributions={focusedContributions}
currentFindings={currentFindings || []}
onUpdateFindingDisposition={onUpdateFindingDisposition}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
{/* Workspace navigation — hidden during formulation/loading states */}
{formulationStep !== "active" && (
+11
View File
@@ -276,6 +276,16 @@ export default function ScenarioForm() {
);
}
function updateFindingProposition(findingId, newProposition) {
setFindings((prev) =>
prev.map((f) =>
f.id === findingId
? { ...f, proposition: newProposition, userDisposition: null }
: f,
),
);
}
function appendFocusedContribution(contribution) {
// Derive a single stored contribution object and use it for BOTH
// contribution storage AND Finding derivation so the same identity
@@ -630,6 +640,7 @@ export default function ScenarioForm() {
onFocusedContribution={appendFocusedContribution}
findings={findings}
onUpdateFindingDisposition={updateFindingDisposition}
onUpdateFindingProposition={updateFindingProposition}
onRestart={() => {
clearSession();
setStatus("idle");
+219
View File
@@ -812,3 +812,222 @@ describe("applyFindingsToSummary — disposition-aware", () => {
expect(content).toContain("notQuiteTexts");
});
});
// ── updateFindingProposition mutation seam (ScenarioForm) ───
describe("updateFindingProposition — ScenarioForm mutation seam", () => {
function simulateUpdateFindingProposition(findings, findingId, newProposition) {
return findings.map((f) =>
f.id === findingId
? { ...f, proposition: newProposition, userDisposition: null }
: f,
);
}
const baseFindings = [
{
id: "f1",
proposition: "Original text A",
userDisposition: null,
sourceObservation: "obs-001",
contributionId: "contrib-0001",
originatingTargetNodeId: "node-A",
createdAt: "2024-01-01T00:00:00Z",
status: "confirmed",
},
{
id: "f2",
proposition: "Original text B",
userDisposition: null,
sourceObservation: "obs-002",
contributionId: "contrib-0001",
originatingTargetNodeId: "node-B",
createdAt: "2024-01-01T00:01:00Z",
status: "confirmed",
},
];
it("target finding.id is unchanged", () => {
const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
expect(result[0].id).toBe("f1");
});
it("target proposition changes to new value", () => {
const result = simulateUpdateFindingProposition(baseFindings, "f1", "Corrected text A");
expect(result[0].proposition).toBe("Corrected text A");
});
it("target userDisposition resets to null", () => {
const findingsWithNull = baseFindings.map((f) => f.id === "f2" ? { ...f, userDisposition: "not_relevant" } : f);
const result = simulateUpdateFindingProposition(findingsWithNull, "f2", "Corrected text B");
expect(result[1].userDisposition).toBeNull();
});
it("sourceObservation remains unchanged on target", () => {
const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
expect(result[0].sourceObservation).toBe("obs-001");
});
it("contributionId remains unchanged on target", () => {
const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
expect(result[0].contributionId).toBe("contrib-0001");
});
it("finding.id remains unchanged on target (reconfirmed)", () => {
const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
expect(result[0].id).toBe("f1");
});
it("another Finding (non-target) remains the exact existing object", () => {
const before = baseFindings.find((f) => f.id === "f2");
const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
const after = result.find((f) => f.id === "f2");
expect(after).toBe(before); // same object reference
});
it("no Finding is added or removed — count unchanged", () => {
const beforeCount = baseFindings.length;
const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
expect(result.length).toBe(beforeCount);
});
});
// ── onUpdateFindingProposition prop chain verification ───────────
describe("onUpdateFindingProposition — prop chain verification", () => {
it("ScenarioForm exposes updateFindingProposition callback with correct arity", async () => {
const testFindings = [{ id: "f1", proposition: "P", userDisposition: null }];
const updateFindingProposition = (findingId, newProposition) => {
return testFindings.map((f) =>
f.id === findingId ? { ...f, proposition: newProposition, userDisposition: null } : f,
);
};
const updated = updateFindingProposition("f1", "Corrected");
expect(updated[0].proposition).toBe("Corrected");
expect(updated[0].userDisposition).toBeNull();
});
it("ReasoningWorkspace accepts onUpdateFindingProposition as prop", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
const content = await fs.readFile(path, "utf-8");
expect(content).toContain("onUpdateFindingProposition");
});
it("ReasoningWorkspace passes onUpdateFindingProposition to FocusedInvestigationWorkspace", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
const content = await fs.readFile(path, "utf-8");
const fiwsCall = content.match(/<FocusedInvestigationWorkspace[\s\S]*?\/>/g);
expect(fiwsCall).not.toBeNull();
expect(fiwsCall[0]).toContain("onUpdateFindingProposition");
});
it("ScenarioForm passes onUpdateFindingProposition to ReasoningWorkspace", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "scenario-form.jsx");
const content = await fs.readFile(path, "utf-8");
expect(content).toContain("onUpdateFindingProposition={updateFindingProposition}");
});
it("no correction state introduced in ReasoningWorkspace — only FQB owns editingFindingId + draft", async () => {
const rwPath = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
const rwContent = await fs.readFile(rwPath, "utf-8");
// ReasoningWorkspace should NOT declare editingFindingId or draft as its own state
// (they are only declared inside FocusedQuestionBody)
const reasonFuncMatch = rwContent.match(/export default function ReasoningWorkspace\([\s\S]*?return \(/);
expect(reasonFuncMatch).not.toBeNull();
const funcBody = reasonFuncMatch[0];
expect(funcBody).not.toMatch(/editingFindingId/);
expect(funcBody).not.toMatch(/setEditingFindingId/);
});
it("Not quite invokes correction for exact Finding.id", async () => {
// Simulate: user clicks "Not quite" → startEditing(id, proposition)
let state = { editingFindingId: null, draft: "" };
const startEditing = (id, proposition) => {
state.editingFindingId = id;
state.draft = proposition ?? "";
};
startEditing("f1", "Original text A");
expect(state.editingFindingId).toBe("f1");
expect(state.draft).toBe("Original text A");
});
it("cancel does not invoke canonical mutation", async () => {
let mutations = [];
const originalMutation = (id, val) => { mutations.push({ id, val }); };
let state = { editingFindingId: "f1", draft: "changed" };
// Simulate cancel — clears state without calling mutation
state.editingFindingId = null;
state.draft = "";
expect(mutations.length).toBe(0);
expect(state.editingFindingId).toBeNull();
expect(state.draft).toBe("");
});
it("valid save invokes proposition mutation with exact Finding.id + trimmed text", () => {
let mutations = [];
const mockMutation = (id, val) => { mutations.push({ id, val }); };
let state = { editingFindingId: "f1", draft: " corrected text " };
const saveEditing = () => {
const trimmed = (state.draft ?? "").trim();
if (!trimmed || !state.editingFindingId) { return; }
mockMutation(state.editingFindingId, trimmed);
state.editingFindingId = null;
state.draft = "";
};
saveEditing();
expect(mutations).toEqual([{ id: "f1", val: "corrected text" }]);
expect(state.editingFindingId).toBeNull();
});
it("whitespace-only save does not invoke mutation", () => {
let mutations = [];
const mockMutation = (id, val) => { mutations.push({ id, val }); };
let state = { editingFindingId: "f1", draft: " \n\t " };
const saveEditing = () => {
const trimmed = (state.draft ?? "").trim();
if (!trimmed || !state.editingFindingId) { return; }
mockMutation(state.editingFindingId, trimmed);
state.editingFindingId = null;
state.draft = "";
};
saveEditing();
expect(mutations.length).toBe(0);
expect(state.editingFindingId).not.toBeNull(); // state preserved when no-op
});
it("Not quite and Not relevant coexist on same Finding", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
const content = await fs.readFile(path, "utf-8");
// Both buttons should appear in the same rendering block for findings
const findingLiBlock = content.match(/<ul className="list-disc pl-5 space-y-2"[\s\S]*?<\/ul>/g);
expect(findingLiBlock).not.toBeNull();
expect(findingLiBlock[0]).toContain("Not quite");
expect(findingLiBlock[0]).toContain("not relevant");
});
it("restore button still present after Not quite introduced", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
const content = await fs.readFile(path, "utf-8");
expect(content).toContain(">restore<");
});
});