fix(confidence-engine): preserve follow-up progression ownership

This commit is contained in:
2026-08-30 08:28:29 +01:00
parent 17c6048047
commit 8bded90094
4 changed files with 408 additions and 9 deletions
+48 -8
View File
@@ -149,6 +149,20 @@ function FocusedQuestionBody({
// Without this guard, selecting a follow-up question would erase "Previously answered" + "Your response".
const hasCompletedContext = processingStep !== "active" && Boolean(focused?.result);
// ── Source of completed context: latest canonical Contribution when follow-up is active ──
// After setFollowUpQuestion() mutates focused.question/answer, derive from the
// latest completed Contribution so the narrative remains correct.
const hasActiveFollowUp = hasCompletedContext && !hasAnswer
&& (focused.result?.possibleFollowUpQuestions || []).some((q) => q === focused?.question);
const latestCompletedContrib = [...(focusedContributions || [])].reverse().find((c) => c?.question && c?.answer);
const displayedCompletedQuestion = hasActiveFollowUp
? (latestCompletedContrib?.question ?? focused?.question)
: focused?.question;
const displayedCompletedAnswer = hasActiveFollowUp
? (latestCompletedContrib?.answer ?? focused?.answer ?? "")
: focused?.answer;
// ── Local correction state (FQB-owned, not propagated upward) ─
const [editingFindingId, setEditingFindingId] = useState(null);
const [draft, setDraft] = useState("");
@@ -180,15 +194,15 @@ function FocusedQuestionBody({
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
{(hasAnswer || hasCompletedContext) && focused?.question?.trim() ? (
<div className="space-y-3">
{/* Previously answered question */}
{/* Previously answered question — sourced from contribution when follow-up is active */}
<div>
<h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Previously answered</h3>
<p className="text-base font-medium leading-relaxed text-gray-900">{focused.question}</p>
<p className="text-base font-medium leading-relaxed text-gray-900">{displayedCompletedQuestion}</p>
</div>
{/* User's verbatim response — distinct provenance from Engine-derived content */}
<div>
<h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Your response</h3>
<p className="text-sm leading-relaxed text-gray-800">{focused.answer}</p>
<p className="text-sm leading-relaxed text-gray-800">{displayedCompletedAnswer}</p>
</div>
</div>
) : focused?.question?.trim() ? (
@@ -200,7 +214,7 @@ function FocusedQuestionBody({
<p className="text-sm text-blue-600/70">{formulateMsg}</p>
) : null}
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && !hasAnswer && (
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && !hasAnswer && !hasActiveFollowUp && (
<div>
<label htmlFor={`rw-answer-${nodeId}`} className="mb-2 block text-sm font-medium text-gray-700">Your response</label>
<textarea id={`rw-answer-${nodeId}`} value={focusedAnswer} onChange={(e) => setFocusedAnswer(e.target.value)} rows={4} data-testid="response-textarea" className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60" placeholder="What do you know about this?" />
@@ -212,9 +226,8 @@ function FocusedQuestionBody({
{focused?.result && (
<>
{/* Prior accumulated learning (prior turns, current turn excluded — shown above) */}
<PriorContributionsSummary nodeId={nodeId} contributions={focusedContributions || []} findings={findings} />
{/* Prior accumulated learning removed from left pane — SecondaryPreviousLearning on the right owns historical Previous Learning exclusively */}
{/* PriorContributionsSummary was causing duplication in the two-column focused workspace */}
<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;
@@ -284,6 +297,30 @@ function FocusedQuestionBody({
) : (
<p className="text-xs text-gray-400">None yet</p>
)}
{/* In-place answer textarea for the active follow-up — renders only when a candidate is selected */}
{hasActiveFollowUp ? (
<div className="mt-3 space-y-2">
<p className="text-sm font-medium text-gray-900">{focused.question}</p>
<textarea
id={`rw-answer-fu-${nodeId}`}
value={focusedAnswer}
onChange={(e) => setFocusedAnswer(e.target.value)}
rows={4}
data-testid="follow-up-textarea"
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60"
placeholder="What do you know about this?"
/>
<button
onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }}
disabled={!focusedAnswer.trim() || processingStep === "active"}
style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }}
className="rounded-lg border border-green-600 bg-white px-4 py-2 text-sm font-medium text-green-700 hover:bg-green-50 transition disabled:opacity-50"
>
Submit response
</button>
</div>
) : null}
</div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Assumptions</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.assumptions || []).map((a, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{a}</li>))}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Connections</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.relationships || []).map((r, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{r.from} {r.to} ({r.type})</li>))}</ul></div>
@@ -1074,7 +1111,7 @@ function FocusedInvestigationWorkspace({
setFocusedPresentationItemId={setFocusedPresentationItemId}
setDoneForNowIds={setDoneForNowIds}
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={hasResult ? [] : (focusedContributions || [])}
focusedContributions={focusedContributions || []}
currentFindings={currentFindings || []}
onUpdateFindingDisposition={onUpdateFindingDisposition}
onUpdateFindingProposition={onUpdateFindingProposition}
@@ -1208,6 +1245,9 @@ function OpenQuestionsPanel({
);
}
// Export for testability of in-place follow-up ownership repair
export { FocusedQuestionBody, SecondaryPreviousLearning };
export default function ReasoningWorkspace({
scenario,
status,
+66
View File
@@ -2092,3 +2092,69 @@ previous question
```
Exact UI labels and wording remain subject to later UX refinement. This principle is the durable separation of provenance; it should guide the upcoming completed-result presentation work.
---
### v0.49 FOLLOW-UP PRESENTATION OWNERSHIP REPAIR
**Status:** VERIFIED (tests + build + Playwright live)
**Branch:** `feature/finding-informed-understanding-v0.49`
#### Repair scope
The in-place follow-up repair (completed turn provenance preservation) introduced an overloaded responsibility: passing the full historical `Contributions` array into `FocusedQuestionBody` served two purposes simultaneously — recovering the latest completed Q3/A3 for "Previously Answered / Your Response" AND rendering "Previous Learning" history via `PriorContributionsSummary`.
Those are distinct presentation responsibilities. Separating them exposes a duplication defect: "Previous Learning" appeared on BOTH the left current-progression pane AND the right historical column.
#### What changed
- **In-place follow-up repair retained** — canonical Q3/A3 provenance recovered from the latest completed Contribution via `latestCompletedContribution` derivation; top-textarea suppression via `hasActiveFollowUp`; in-place Q4 textarea under "Questions This Raises"; Previous Learning newest-first ordering.
- **Duplicate Previous Learning removed** — `PriorContributionsSummary` embedded rendering removed from `FocusedQuestionBody` inside the two-column focused workspace (`FocusedInvestigationWorkspace`).
- **Presentation ownership separated:**
- `latestCompletedContribution` (derived once inside `FocusedQuestionBody`) supplies provenance context for Q3/A3 independently of history presentation.
- Left pane now owns only current/latest progression: Previously Answered, Your Response, What This Tells Us, Still Unclear, Questions This Raises, active follow-up controls.
- Right-side `SecondaryPreviousLearning` exclusively owns older-turn history with "Previous Learning" heading and newest-first ordering.
- **Exactly one "Previous Learning" surface** across the focused workspace.
- **Post-answer promotion preserved:** After submitting Q4, it became the latest completed narrative; Turn 3 (the previous current turn) promoted to first item in Previous Learning; Turn 2 and Turn 1 follow in order.
#### Test results
- **Tests passed:** 116/116 (`tests/open-questions-vs-assumptions.test.jsx`)
- **Build:** PASS
- **Full Vitest:** NOT RUN (bounded scope)
#### Playwright live verification
- Correct Q3 retained under "Previously Answered": YES
- Correct A3 retained under "Your Response": YES
- Q4 remains in place as follow-up textarea: YES
- Visible textarea count: 1
- Top duplicate "Question" heading: ABSENT
- Left-side Previous Learning visible: ABSENT (removed)
- Right-side Previous Learning visible: YES (exactly one)
- Previous Learning heading count across workspace: 1
- Previous Learning order: newest-first (Turn 2 → Turn 1 before fix; Turn 3 → Turn 2 → Turn 1 after post-answer submission)
#### Post-answer verification
- Natural focused answers submitted: 1 (Q4 inline answer)
- New turn became latest narrative: YES (Q4 text under "Previously Answered")
- Previous turn became first historical item: YES (Turn 3 as first entry in Previous Learning on the right)
- Single Previous Learning surface preserved: YES
#### Critical regression boundaries
- Persistence changed: NO
- Contribution schema changed: NO
- Finding semantics changed: NO
- Graph reasoning changed: NO
- Current Understanding changed: NO
- Workspace controls changed: NO
#### Next position
- Completed-result provenance: CLOSED
- In-place follow-up continuation: CLOSED
- Previous Learning single-owner presentation: CLOSED
- Previous Learning newest-first: CLOSED
- Post-answer promotion: PRESERVED
@@ -1,4 +1,8 @@
import { describe, expect, it } from "vitest";
import "@testing-library/jest-dom/vitest";
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { FocusedQuestionBody, SecondaryPreviousLearning } from "@/components/reasoning-workspace";
// ── Simulated rendering logic extracted from reasoning-workspace.jsx
// Mirrors the exact filter + conditional structure used in the
@@ -1904,3 +1908,292 @@ describe("v0.49 Case A — completed turn provenance narrative", () => {
});
});
});
// ── v0.49 RENDERED REGRESSION — in-place follow-up ownership defect ──
// Proves: after selecting a follow-up under QUESTIONS THIS RAISES,
// the completed narrative sources question/answer from the canonical
// completed Contribution (not from the mutated active focused state),
// and exactly one textarea renders (in-place under Q4, not at top).
function makeContrib(question, answer, seq) {
return {
id: `contrib-t${seq}`,
targetNodeId: "node-test",
originatingTargetNodeId: "node-test",
question,
answer,
sequence: seq,
observations: [`Finding for turn ${seq}`],
possibleFollowUpQuestions: [],
};
}
function renderFQB(props) {
const defaultProps = {
nodeId: "node-test",
isFocused: true,
hasContent: true,
processingStep: "idle",
formulationStep: "active",
deconstructMsg: "Processing…",
focusedAnswer: "",
setFocusedAnswer: () => {},
handleDeconstructSubmit: () => {},
retryFormulation: () => {},
setFollowUpQuestion: () => {},
focusedContributions: [],
currentFindings: [],
findings: [],
onUpdateFindingDisposition: () => {},
onUpdateFindingProposition: () => {},
...props,
};
return render(<FocusedQuestionBody {...defaultProps} />);
}
describe("v0.49 RENDERED — in-place follow-up context ownership", () => {
describe("before selection — completed result with answer present", () => {
it("renders Q3 under Previously Answered and A3 non-empty under Your Response, no textarea", () => {
const contrib = makeContrib(
"Which specific step of the onboarding funnel has the highest abandonment rate?",
"Step 3 — account verification / phone confirmation.",
1,
);
renderFQB({
focused: {
question: contrib.question,
answer: contrib.answer,
status: "formulated",
result: {
observations: [contrib.observations[0]],
uncertainties: ["Is this a UX problem or trust issue?"],
possibleFollowUpQuestions: [
"What drives the Step 3 abandonment rate?",
"Can reducing friction at Step 3 reduce overall abandonment?",
],
assumptions: [],
relationships: [],
},
error: null,
},
focusedContributions: [contrib],
processingStep: "idle",
});
// Completed narrative correct
expect(screen.getByText("Previously answered")).toBeInTheDocument();
expect(screen.getByText(contrib.question)).toBeInTheDocument();
expect(screen.getByText("Your response")).toBeInTheDocument();
expect(screen.getByText(contrib.answer)).toBeInTheDocument();
// No textarea visible for completed result
const textareas = screen.queryAllByTestId("response-textarea");
expect(textareas).toHaveLength(0);
});
});
describe("after selecting follow-up Q4 under QUESTIONS THIS RAISES", () => {
it("completed narrative still shows Q3/A3 (not mutated), in-place textarea renders under Q4, exactly one textarea total", async () => {
const prevQ = "Which specific step of the onboarding funnel has the highest abandonment rate?";
const prevA = "Step 3 — account verification / phone confirmation.";
const followUpQ = "What drives the Step 3 abandonment rate?";
const contrib = makeContrib(prevQ, prevA, 1);
// Simulate post-selection state: focused.question mutated to follow-up, answer cleared, result preserved
const focusedPostSelection = {
question: followUpQ, // MUTATED — this is the defect we're testing against
answer: null, // CLEARED — this is the defect
status: "active", // follow-up selection changes status to active
result: {
observations: [contrib.observations[0]],
uncertainties: ["Is this a UX problem or trust issue?"],
possibleFollowUpQuestions: [followUpQ, "Can reducing friction at Step 3 reduce overall abandonment?"],
assumptions: [],
relationships: [],
},
error: null,
};
renderFQB({
focused: focusedPostSelection,
focusedContributions: [contrib],
processingStep: "idle", // idle = hasCompletedContext is true
});
// === COMPLETED NARRATIVE — must source from contribution, not mutated focused ===
// Previously answered shows Q3 (original), NOT the follow-up question
expect(screen.getByText("Previously answered")).toBeInTheDocument();
const pqText = screen.getByText(prevQ);
expect(pqText).toBeInTheDocument();
// Q4 should NOT appear under "Previously answered" — it should only appear under "Questions This Raises"
const prevSection = pqText.closest("div");
expect(prevSection?.textContent?.includes(followUpQ)).toBe(false);
// Your response shows A3 and is non-empty
expect(screen.getByText("Your response")).toBeInTheDocument();
const arText = screen.getByText(prevA);
expect(arText).toBeInTheDocument();
expect(arText.textContent?.trim().length).toBeGreaterThan(0);
// === QUESTIONS THIS RAISES — Q4 selected with in-place textarea ===
expect(screen.getByText("Questions this raises")).toBeInTheDocument();
// Q4 text should be visible under Questions This Raises
expect(screen.getByText(followUpQ)).toBeInTheDocument();
// Exactly one textarea (in-place, not top-level)
const allTextareas = screen.getAllByRole("textbox");
expect(allTextareas).toHaveLength(1);
// The single textarea has the correct placeholder
const fuTextarea = screen.getByPlaceholderText("What do you know about this?");
expect(fuTextarea).toBeInTheDocument();
// Submit button visible under in-place follow-up
expect(screen.getByRole("button", { name: /submit/i })).toBeInTheDocument();
});
it("top-level QUESTION Q4 does NOT appear as a separate section after selection", () => {
const prevQ = "Which specific step of the onboarding funnel has the highest abandonment rate?";
const followUpQ = "What drives the Step 3 abandonment rate?";
const contrib = makeContrib(prevQ, "Step 3 answer.", 1);
renderFQB({
focused: {
question: followUpQ,
answer: null,
status: "formulated",
result: {
observations: ["Finding"],
uncertainties: [],
possibleFollowUpQuestions: [followUpQ],
assumptions: [],
relationships: [],
},
error: null,
},
focusedContributions: [contrib],
processingStep: "idle",
});
// There should be NO "Question" heading (top-level) — only "Previously answered"
const questionHeadings = screen.queryAllByText("Question");
expect(questionHeadings).toHaveLength(0);
});
});
describe("v0.49 Previous Learning duplication — PriorContributionsSummary must not render in focused workspace", () => {
it("when multiple prior turns exist, Previous Learning heading appears ONLY on right panel (SecondaryPreviousLearning), NOT embedded in left FocusedQuestionBody", () => {
const turn1Q = "What is the primary user motivation for signing up?";
const turn1A = "To access premium features faster than free users.";
const turn2Q = "Which feature combination drives the most retention?";
const turn2A = "Analytics dashboard + automated reports at $29/mo tier.";
const currentQ = "What drives the Step 3 abandonment rate?";
const contribs = [
makeContrib(turn1Q, turn1A, 1),
makeContrib(turn2Q, turn2A, 2),
];
renderFQB({
focused: {
question: currentQ,
answer: null,
status: "active",
result: {
observations: [turn2A],
uncertainties: ["Is this causal or correlational?"],
possibleFollowUpQuestions: [currentQ, "How does retention vary by segment?"],
assumptions: [],
relationships: [],
},
error: null,
},
focusedContributions: contribs,
processingStep: "idle",
});
// === COMPLETED NARRATIVE — Q3/A3 from latest contribution preserved ===
expect(screen.getByText("Previously answered")).toBeInTheDocument();
const pqText = screen.getByText(turn2Q);
expect(pqText).toBeInTheDocument();
expect(screen.getByText("Your response")).toBeInTheDocument();
// Use the paragraph under "Your response" to avoid ambiguity with PriorContributionsSummary rendering
const yourResponseSection = screen.getByText("Your response").parentElement;
const arText = yourResponseSection?.querySelector("p");
expect(arText).toHaveTextContent(turn2A);
// === QUESTIONS THIS RAISES — current Q with textarea ===
expect(screen.getByText("Questions this raises")).toBeInTheDocument();
expect(screen.getByText(currentQ)).toBeInTheDocument();
// Exactly one textarea (in-place follow-up)
const allTextareas = screen.getAllByRole("textbox");
expect(allTextareas).toHaveLength(1);
// === CRITICAL: Previous Learning heading must NOT appear in left pane ===
// PriorContributionsSummary renders <h4> with text "Previous learning" (case-insensitive match)
const prevLearningHeadings = screen.queryAllByText(/previous learning/i);
expect(prevLearningHeadings.length).toBeLessThanOrEqual(1);
// If a Previous Learning heading exists in the full DOM, it should be on the right panel only
// In FocusedQuestionBody alone there is no SecondaryPreviousLearning, so with NO PriorContributionsSummary
// there should be zero "Previous learning" headings in this isolated render.
expect(prevLearningHeadings.length).toBe(0);
});
it("after SecondaryPreviousLearning is mounted on the right, exactly one Previous Learning heading total", () => {
const turn1Q = "Turn 1 question";
const turn1A = "Turn 1 answer.";
const turn2Q = "Turn 2 question";
const turn2A = "Turn 2 answer.";
const currentQ = "Current focused question?";
// Two prior contributions (the most recent will be excluded by slice(0,-1), Turn 1 remains)
const contribs = [makeContrib(turn1Q, turn1A, 1), makeContrib(turn2Q, turn2A, 2)];
// Render FQB + SecondaryPreviousLearning as the workspace does
render(
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
<FocusedQuestionBody
nodeId="node-test"
isFocused={true}
hasContent={true}
processingStep="idle"
formulationStep="active"
deconstructMsg=""
focusedAnswer=""
setFocusedAnswer={() => {}}
handleDeconstructSubmit={() => {}}
retryFormulation={() => {}}
setFollowUpQuestion={() => {}}
focused={
{
question: currentQ,
answer: null,
status: "active",
result: {
observations: ["finding"],
uncertainties: [],
possibleFollowUpQuestions: [currentQ],
assumptions: [],
relationships: [],
},
error: null,
}
}
focusedContributions={contribs}
/>
<SecondaryPreviousLearning nodeId="node-test" contributions={contribs} findings={[]} />
</div>,
);
// Exactly one Previous Learning heading across the two-column workspace
const prevLearningHeadings = screen.queryAllByText(/previous learning/i);
expect(prevLearningHeadings).toHaveLength(1);
});
});
});
+1 -1
View File
@@ -6,6 +6,6 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig({
test: { globals: true },
test: { globals: true, environment: "jsdom" },
resolve: { alias: { "@": path.resolve(__dirname, ".") } },
});