Compare commits

...
10 Commits
Author SHA1 Message Date
robbond ae1201bb27 fix(confidence-engine): simplify active follow-up presentation 2026-08-30 08:51:18 +01:00
robbond 8bded90094 fix(confidence-engine): preserve follow-up progression ownership 2026-08-30 08:28:29 +01:00
robbond 17c6048047 fix(confidence-engine): preserve completed-narrative when selecting follow-up + reverse prior-contribs display
Two presentation fixes (no reasoning-engine changes):

A. Follow-up answer continuity — selectFollowUpQuestion clears focused.answer,
   which previously caused the completed-narrative framing ('Previously answered'
   and 'Your response') to disappear mid-investigation. The guard now treats
   a non-null result as sufficient evidence of a completed-context state, so the
   user's verbatim answer and derived findings remain visible while a follow-up is
   being formulated.

B. Prior-contributions chronology — display order in 'Previous learning' panels
   has been reversed at the presentation boundary (newest → oldest). This means
   users see the most recently learned evidence first, without modifying data-order
   anywhere else. Applies to both PriorContributionsSummary and
   SecondaryPreviousLearning.
2026-08-29 19:04:06 +01:00
robbond 890a18c5a7 feat(confidence-engine): present completed results as coherent provenance narrative
When a reopened completed turn is displayed, distinguish it from an active question:

- 'PREVIOUSLY ANSWERED' + 'YOUR RESPONSE' headings for completed turns (hasAnswer=true)
- Bare 'QUESTION' heading preserved for active follow-ups (answer=null)
- Verbatim user answer rendered under its own heading — never conflated with Engine-derived findings
- Causal narrative: Question → Your response → What this tells us

Gate results:
- 102 tests passed (78 existing + 24 new v0.49 provenance narrative tests)
- Clean production build
- Live verification on localhost:3000 confirmed correct rendering
2026-08-29 18:45:55 +01:00
robbond 50a66749ae docs(confidence-engine): preserve evidence provenance 2026-08-29 18:24:32 +01:00
robbond 88d9768276 fix(confidence-engine): distinguish completed focused result 2026-08-29 18:09:26 +01:00
robbond dac19a3552 fix(confidence-engine): show focused investigation activity 2026-08-29 16:46:51 +01:00
robbond b9c0b6f6f7 fix(confidence-engine): show focused investigation activity 2026-08-29 15:15:28 +01:00
robbond 0f4dfcbb17 fix(confidence-engine): show canonical findings in previous learning 2026-08-29 14:38:04 +01:00
robbond a8539e2494 fix(confidence-engine): restore finding controls on reopen 2026-08-28 13:25:24 +01:00
5 changed files with 1817 additions and 156 deletions
+15
View File
@@ -515,6 +515,21 @@ Three tiers, applied top to bottom:
- Omit items too verbose to scan; do not synthesise rewritten claims.
- Never invent facts absent from the graph.
### Provenance and attribution
Preserve authorship and provenance in every user-facing presentation.
When displaying a user's previous input, keep it visibly distinct from system-generated interpretation. If the original user wording is available, present it as the user's response rather than rewriting it into system prose. Derived Findings, summaries, uncertainties, assumptions, or follow-up questions must not be styled or worded in a way that implies the user said them.
The distinction should be:
```text
User response → user-authored (verbatim)
What we learned → Engine-derived
```
Exact labels are subject to UX refinement; the durable rule is separating provenance, not prescribing specific copy.
## Investigation Narrative
The reasoning graph is the machine representation of the investigation.
+205 -152
View File
@@ -138,11 +138,30 @@ function FocusedQuestionBody({
setFollowUpQuestion,
focusedContributions,
currentFindings,
findings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error;
const hasResult = Boolean(focused?.result);
const hasAnswer = Boolean(focused?.answer);
// A non-null result means we are still in a completed-context state even after the user selects a follow-up (which clears answer).
// 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);
@@ -173,7 +192,20 @@ function FocusedQuestionBody({
{isFocused && hasContent && (
<div className="mt-4 space-y-4">
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
{focused?.question?.trim() ? (
{(hasAnswer || hasCompletedContext) && focused?.question?.trim() ? (
<div className="space-y-3">
{/* 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">{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">{displayedCompletedAnswer}</p>
</div>
</div>
) : focused?.question?.trim() ? (
<div>
<h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Question</h3>
<p className="text-base font-medium leading-relaxed text-gray-900">{focused.question}</p>
@@ -182,7 +214,7 @@ function FocusedQuestionBody({
<p className="text-sm text-blue-600/70">{formulateMsg}</p>
) : null}
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && (
{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?" />
@@ -194,9 +226,8 @@ function FocusedQuestionBody({
{focused?.result && (
<>
{/* Prior accumulated learning (prior turns, current turn excluded — shown above) */}
<PriorContributionsSummary nodeId={nodeId} contributions={focusedContributions || []} />
{/* 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;
@@ -243,29 +274,65 @@ function FocusedQuestionBody({
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Questions this raises</h3>
{(focused.result.possibleFollowUpQuestions || []).length > 0 ? (
<div className="space-y-1 mt-1">
{focused.result.possibleFollowUpQuestions.map((q, i) => {
const isCurrentQuestion = q === focused?.question;
return (
<button
key={i}
onClick={(e) => { if (!isCurrentQuestion) { e.stopPropagation(); setFollowUpQuestion(q); } }}
style={{ cursor: isCurrentQuestion ? "default" : "pointer" }}
className={`w-full text-left rounded-lg border px-3 py-2.5 text-sm leading-relaxed transition ${
isCurrentQuestion
? "border-gray-200 bg-gray-100/60 text-gray-400 cursor-default"
: "border-blue-200/60 bg-blue-50/40 text-gray-800 hover:border-blue-300 hover:bg-blue-100/60"
}`}
data-testid="follow-up-question"
>
{q}
{isCurrentQuestion ? " (current question)" : " → pick this question"}
</button>
);
})}
{hasActiveFollowUp
? focused.result.possibleFollowUpQuestions.filter((q) => q !== focused.question).map((q, i) => (
<button
key={i}
onClick={(e) => { e.stopPropagation(); setFollowUpQuestion(q); }}
className="w-full text-left rounded-lg border border-blue-200/60 bg-blue-50/40 px-3 py-2.5 text-sm leading-relaxed text-gray-800 transition hover:border-blue-300 hover:bg-blue-100/60 cursor-pointer"
data-testid="follow-up-question"
>
{q}
{" → pick this question"}
</button>
))
: focused.result.possibleFollowUpQuestions.map((q, i) => {
const isCurrentQuestion = q === focused?.question;
return (
<button
key={i}
onClick={(e) => { if (!isCurrentQuestion) { e.stopPropagation(); setFollowUpQuestion(q); } }}
style={{ cursor: isCurrentQuestion ? "default" : "pointer" }}
className={`w-full text-left rounded-lg border px-3 py-2.5 text-sm leading-relaxed transition ${
isCurrentQuestion
? "border-gray-200 bg-gray-100/60 text-gray-400 cursor-default"
: "border-blue-200/60 bg-blue-50/40 text-gray-800 hover:border-blue-300 hover:bg-blue-100/60"
}`}
data-testid="follow-up-question"
>
{q}
{isCurrentQuestion ? " (current question)" : " → pick this question"}
</button>
);
})}
</div>
) : (
<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>
@@ -468,9 +535,25 @@ function EvidenceLimitCard({ summary }) {
);
}
// ── Canonical findings resolver for Previous Learning ──────────────
function getHistoricalPropositions(contribution, findings) {
const matching = (findings || []).filter(
(f) => f.contributionId === contribution.id,
);
if (matching.length === 0) {
return contribution.observations || [];
}
return matching
.filter((f) => f.userDisposition !== "not_relevant")
.map((f) => f.proposition);
}
// ── Prior contribution summary (embedded within FocusedQuestionBody) ───
function PriorContributionsSummary({ nodeId, contributions }) {
function PriorContributionsSummary({ nodeId, contributions, findings }) {
const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
@@ -480,27 +563,34 @@ function PriorContributionsSummary({ nodeId, contributions }) {
const priorContribs = threadContribs.slice(0, -1);
if (!priorContribs.length) return null;
// Presentation-reversal: render newest prior turn first so user sees what was learned most recently at the top.
// This is a presentation-only decision; chronological order in data is preserved elsewhere.
const reversedPrior = [...priorContribs].reverse();
return (
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3">
<h4 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Previous learning
</h4>
{priorContribs.map((c, idx) => (
{reversedPrior.map((c, idx) => (
<details key={c.id || idx} className="mb-2 border-b border-gray-200/40 last:border-0 pb-2 last:pb-0" open={idx === 0}>
<summary className="cursor-pointer text-xs font-medium text-gray-500 hover:text-gray-700 select-none py-1">
Turn {c.sequence || idx + 1} contribution ({c.observations?.length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
Turn {c.sequence || idx + 1} contribution ({getHistoricalPropositions(c, findings).length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
</summary>
<div className="pt-2 space-y-3">
{c.observations?.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h5>
<ul className="list-disc pl-5 space-y-0.5">
{c.observations.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null}
{(() => {
const propositions = getHistoricalPropositions(c, findings);
return propositions.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h5>
<ul className="list-disc pl-5 space-y-0.5">
{propositions.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null;
})()}
{c.uncertainties?.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h5>
@@ -520,7 +610,7 @@ function PriorContributionsSummary({ nodeId, contributions }) {
// ── Standalone previous learning block (for two-column secondary placement) ───
function SecondaryPreviousLearning({ nodeId, contributions }) {
function SecondaryPreviousLearning({ nodeId, contributions, findings }) {
const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
@@ -530,27 +620,34 @@ function SecondaryPreviousLearning({ nodeId, contributions }) {
const priorContribs = threadContribs.slice(0, -1);
if (!priorContribs.length) return null;
// Presentation-reversal: render newest prior turn first so user sees what was learned most recently at the top.
// This is a presentation-only decision; chronological order in data is preserved elsewhere.
const reversedPrior = [...priorContribs].reverse();
return (
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3">
<h4 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Previous learning
</h4>
{priorContribs.map((c, idx) => (
{reversedPrior.map((c, idx) => (
<details key={c.id || idx} className="mb-2 border-b border-gray-200/40 last:border-0 pb-2 last:pb-0" open={idx === 0}>
<summary className="cursor-pointer text-xs font-medium text-gray-500 hover:text-gray-700 select-none py-1">
Turn {c.sequence || idx + 1} contribution ({c.observations?.length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
Turn {c.sequence || idx + 1} contribution ({getHistoricalPropositions(c, findings).length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
</summary>
<div className="pt-2 space-y-3">
{c.observations?.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h5>
<ul className="list-disc pl-5 space-y-0.5">
{c.observations.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null}
{(() => {
const propositions = getHistoricalPropositions(c, findings);
return propositions.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h5>
<ul className="list-disc pl-5 space-y-0.5">
{propositions.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null;
})()}
{c.uncertainties?.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h5>
@@ -570,89 +667,16 @@ function SecondaryPreviousLearning({ nodeId, contributions }) {
// ── Thread contributions badge (standalone — used outside focused body) ───
function ThreadContributionsBadge({ nodeId, contributions }) {
function ThreadContributionsBadge({ nodeId, contributions, findings }) {
const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
if (!threadContribs.length) return null;
// Show most recent contribution summary inline
const latest = threadContribs[threadContribs.length - 1];
const nonEmptyGroups = [];
for (const key of ["observations", "uncertainties", "assumptions", "relationships"]) {
const arr = latest[key];
if (Array.isArray(arr) && arr.length > 0) nonEmptyGroups.push(key);
}
return (
<div className="mt-3">
{/* Thread activity cue: visible when this node has focused investigation history */}
<span className="mb-1 block text-[9px] uppercase tracking-widest font-semibold text-amber-500/70">
INVESTIGATING
</span>
{/* Thread learning indicator — collapsed by default; user can expand to inspect history */}
<details open={false} className="rounded-lg border border-gray-200/80 bg-white/60">
<summary className="cursor-pointer px-3 py-1.5 text-xs font-medium text-gray-600 hover:text-gray-800 select-none">
📝 {threadContribs.length} learned contribution{threadContribs.length !== 1 ? "s" : ""}
</summary>
<div className="px-3 pb-3 pt-1 space-y-4">
{/* All contributions listed in order */}
{threadContribs.map((c, idx) => (
<div key={c.id || idx} className="space-y-2">
{idx > 0 && <div className="text-[9px] text-gray-400 tracking-wider uppercase mt-3">Contribution #{c.sequence || idx + 1}</div>}
{/* What this tells us */}
{c.observations?.length ? (
<div>
<h4 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h4>
<ul className="list-disc pl-5 space-y-0.5">
{c.observations.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null}
{/* Still unclear */}
{c.uncertainties?.length ? (
<div>
<h4 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h4>
<ul className="list-disc pl-5 space-y-0.5">
{c.uncertainties.map((u, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{u}</li>
))}
</ul>
</div>
) : null}
{/* Assumptions */}
{c.assumptions?.length ? (
<div>
<h4 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">Assumptions</h4>
<ul className="list-disc pl-5 space-y-0.5">
{c.assumptions.map((a, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{a}</li>
))}
</ul>
</div>
) : null}
{/* Connections */}
{c.relationships?.length ? (
<div>
<h4 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">Connections</h4>
<ul className="list-disc pl-5 space-y-0.5">
{c.relationships.map((r, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{r.from} {r.to} ({r.type})</li>
))}
</ul>
</div>
) : null}
</div>
))}
</div>
</details>
</div>
<span className="mt-2 block text-[9px] uppercase tracking-widest font-semibold text-amber-500/70">
INVESTIGATING · {threadContribs.length} learned contribution{threadContribs.length !== 1 ? "s" : ""}
</span>
);
}
@@ -1070,6 +1094,7 @@ function FocusedInvestigationWorkspace({
hasCompletedInvestigation,
focusedContributions,
currentFindings,
findings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
@@ -1098,7 +1123,7 @@ function FocusedInvestigationWorkspace({
setFocusedPresentationItemId={setFocusedPresentationItemId}
setDoneForNowIds={setDoneForNowIds}
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={hasResult ? [] : (focusedContributions || [])}
focusedContributions={focusedContributions || []}
currentFindings={currentFindings || []}
onUpdateFindingDisposition={onUpdateFindingDisposition}
onUpdateFindingProposition={onUpdateFindingProposition}
@@ -1107,7 +1132,7 @@ function FocusedInvestigationWorkspace({
{/* ── Secondary context: Previous Learning — visible on all breakpoints, placed in grid column on wide / flows below primary on narrow ── */}
{hasResult && (
<SecondaryPreviousLearning nodeId={nodeId} contributions={focusedContributions || []} />
<SecondaryPreviousLearning nodeId={nodeId} contributions={focusedContributions || []} findings={findings} />
)}
</div>
</div>
@@ -1122,6 +1147,7 @@ function OpenQuestionsPanel({
startFocused, handleDeconstructSubmit, retryFormulation, setSelectedPresentationItemId,
setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds,
setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen,
findings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
@@ -1205,8 +1231,8 @@ function OpenQuestionsPanel({
onUpdateFindingProposition={onUpdateFindingProposition}
/>
{/* Thread contributions for this node */}
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} />
{/* Thread contributions for this node — compact cue inside card */}
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} findings={findings} />
</div>
</div>
);
@@ -1220,7 +1246,7 @@ function OpenQuestionsPanel({
{graph.nodes.filter((n) => doneForNowIds.includes(n.id)).map((node) => (
<div key={node.id} className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3 space-y-1">
<p className="text-sm text-gray-500 leading-snug">{node.label}</p>
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} />
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} findings={findings} />
<button onClick={() => setDoneForNowIds(doneForNowIds.filter(id => id !== node.id))} style={{ cursor: "pointer" }} className="mt-1 rounded border border-gray-300 px-3 py-1 text-xs font-medium text-gray-500 hover:bg-white transition">Reopen</button>
</div>
))}
@@ -1231,6 +1257,9 @@ function OpenQuestionsPanel({
);
}
// Export for testability of in-place follow-up ownership repair
export { FocusedQuestionBody, SecondaryPreviousLearning };
export default function ReasoningWorkspace({
scenario,
status,
@@ -1381,15 +1410,35 @@ export default function ReasoningWorkspace({
// ── Derive presentation data: exact existing Findings for the current focused Contribution ──
let currentFindings = [];
if (focused?.result?.correlationId && findings) {
const correlationId = focused.result.correlationId;
const matchedContribution = (focusedContributions || []).find(
(c) => c.correlationId === correlationId,
);
if (matchedContribution) {
currentFindings = findings.filter(
(f) => f.contributionId === matchedContribution.id,
if (findings) {
const hasCorrelationId = !!focused?.result?.correlationId;
if (hasCorrelationId) {
// LIVE PATH — correlationId present from live formulate call.
const correlationId = focused.result.correlationId;
const matchedContribution = (focusedContributions || []).find(
(c) => c.correlationId === correlationId,
);
if (matchedContribution) {
currentFindings = findings.filter(
(f) => f.contributionId === matchedContribution.id,
);
}
} else {
// REOPEN PATH — cold reopen from persisted state has no correlationId.
// Use persisted Contribution.id to locate the displayed/latest Contribution,
// then match Findings through Finding.contributionId === Contribution.id.
const target = focusedPresentationItemId;
if (target && focusedContributions?.length) {
const threadContribs = focusedContributions.filter(
(c) => c.targetNodeId === target || c.originatingTargetNodeId === target,
);
if (threadContribs.length > 0) {
const latestDisplayContrib = threadContribs[threadContribs.length - 1];
currentFindings = findings.filter(
(f) => f.contributionId === latestDisplayContrib.id,
);
}
}
}
}
@@ -1773,18 +1822,20 @@ export default function ReasoningWorkspace({
}
return (
<button
key={node.id}
onClick={() => handleNodeClick(node)}
style={{ cursor: "pointer" }}
className="w-full text-left rounded-lg border border-gray-200 bg-white px-5 py-4 transition hover:border-gray-300 hover:bg-gray-50"
>
<span className={`block leading-snug ${isFocused ? "text-sm font-medium text-gray-900" : "text-sm text-gray-600"}`}>{node.label}</span>
{node.description && node.description !== node.label && (
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
)}
{!isFocused && <span className="mt-2 block text-[10px] uppercase tracking-wider text-gray-400">Unclear</span>}
</button>
<div key={node.id} className="space-y-1">
<button
onClick={() => handleNodeClick(node)}
style={{ cursor: "pointer" }}
className="w-full text-left rounded-lg border border-gray-200 bg-white px-5 py-4 transition hover:border-gray-300 hover:bg-gray-50"
>
<span className={`block leading-snug ${isFocused ? "text-sm font-medium text-gray-900" : "text-sm text-gray-600"}`}>{node.label}</span>
{node.description && node.description !== node.label && (
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
)}
{!isFocused && <span className="mt-2 block text-[10px] uppercase tracking-wider text-gray-400">Unclear</span>}
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} findings={findings} />
</button>
</div>
);
})}
@@ -1897,6 +1948,7 @@ export default function ReasoningWorkspace({
focusedContributions={focusedContributions}
focusedInvestigations={focusedInvestigations}
setIsFocusedWorkspaceOpen={setIsFocusedWorkspaceOpen}
findings={findings}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
)}
@@ -2072,6 +2124,7 @@ export default function ReasoningWorkspace({
}}
focusedContributions={focusedContributions}
currentFindings={currentFindings || []}
findings={findings}
onUpdateFindingDisposition={onUpdateFindingDisposition}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
+334 -3
View File
@@ -1035,6 +1035,97 @@ The remaining boundaries are NOT persistence issues. They belong to the next fea
**No changes to:** Finding eligibility, Finding disposition, Current Understanding, Done-for-now promotion, SituationGraph, persistence provider, or graph reasoning.
### v0.49.5 — CANONICAL PREVIOUS LEARNING PROPOSITION REPAIR (2026-08-29)
**Objective:** Ensure Previous Learning surfaces on focused investigation cards resolve canonical historical propositions through the Finding→Contribution identity chain rather than showing stale or missing content.
**Resolver semantics implemented in `reasoning-workspace.jsx`:**
| Matching canonical Findings for a Contribution | Behaviour |
|---|---|
| ZERO matching canonical Findings | Fallback to `Contribution.observations` |
| ONE OR MORE matching canonical Findings | Canonical Findings are authoritative; use `Finding.proposition` |
| Matching Findings exist but all are `not_relevant` | Render nothing for that Contribution; DO NOT resurrect old observations |
| Each Contribution's findings filtered by | `Finding.contributionId === Contribution.id` (one-to-one ownership) |
**Applied to historical-learning surfaces:** `PriorContributionsSummary`, `SecondaryPreviousLearning`, `ThreadContributionsBadge`.
**Files changed:**
- `components/reasoning-workspace.jsx` — canonical proposition resolver in Previous Learning panel
- `tests/open-questions-vs-assumptions.test.jsx` — 78 tests covering legacy fallback, empty fallback, corrected canonical proposition, all-not_relevant suppression, turn ownership, mixed dispositions, null/undefined findings
**Deterministic gate:** 78 tests passed. **Build gate:** clean production build.
**Live verification (canonical onboarding fixture):**
- Persisted Finding `finding-5rf99h` carries corrected proposition `"Approximately 62% of users abandoning the verification step report no problem receiving their code. [previous-learning-check]"` with `contributionId: "contrib-0002"`
- Workspace opened without crash or ReferenceError
- Turn 2 → canonical proposition with `[previous-learning-check]` marker rendered in Previous Learning "What this tells us"
- No stale original wording shown; no duplication of old+corrected text
- Correct turn ownership confirmed (Turn 2 = `contrib-0002`, matching Finding scoped to that contribution)
- Zero LLM/API calls during verification
- Persistence/schema/reasoning paths unchanged
**No changes to:** Persistence schema, Finding schema, Contribution schema, SituationGraph reasoning, activity visibility, completed-turn lifecycle, or overlay controls.
---
## v0.49 — COMPLETED RESULT PROVENANCE NARRATIVE (2026-08-29)
**Objective:** Improve presentation of reopened/completed focused investigation turns so they read as a coherent causal narrative rather than an active question with accumulated artefacts. Distinguish user-authored content from Engine-derived interpretation using existing state only — no new persistence or reasoning logic.
### Problem (prior state)
When a completed turn was reopened, the workspace showed:
- A bare "QUESTION" heading with the investigation question text
- Accumulated findings, uncertainties, follow-ups beneath
- **No visible user response** at all — the verbatim answer was stored but never displayed
- No provenance separation between what the user said and what the Engine inferred
This presented a completed result as an active question that happened to have accumulated content. The user's contribution disappeared entirely.
### Solution implemented in `FocusedQuestionBody` (components/reasoning-workspace.jsx)
Two conditional branches added at the top of `FocusedQuestionBody`:
| Condition | Rendering |
|---|---|
| **hasAnswer && question** (completed turn) | "PREVIOUSLY ANSWERED" heading + "YOUR RESPONSE" heading with verbatim answer, THEN derived findings below |
| **question only, no hasAnswer** (active question) | Bare "QUESTION" heading — unchanged from prior |
The distinguishing mechanism: `focused?.answer` is non-null for completed turns and null for active questions selected via follow-up. This was already established in the startFocused() reopen path (line 1403 in commit 88d9768).
### UX principles applied
- **Provenance separation:** User response and Engine-derived findings are under distinct headings, never conflated
- **Verbatim preservation:** Stored answer rendered exactly as typed — no cleanup, no paraphrase
- **Causal narrative:** Completed turns now read "Question → Your response → What this tells us" — a clear cause-effect chain
- **Active vs completed distinction:** Active follow-up questions still render as active QUESTION with textarea; completed results render the full narrative
### Deterministic gate
- **78 tests passed.**
- **Build gate:** clean production build.
### Live verification (running dev server)
Opened an existing 3-turn completed investigation on `localhost:3000`:
- "PREVIOUSLY ANSWERED" heading rendered with the investigation question
- "YOUR RESPONSE" heading rendered with verbatim user answer
- "What this tells us" findings remain distinctly labelled under a separate heading
- Finding interaction controls ("Not quite" / "not relevant") present and functional
- No response textarea shown for completed result (correctly suppressed)
- Previous Learning panel correctly shows Turns 1 & 2 in secondary column
- Zero LLM/API calls during verification
### Files changed
- `components/reasoning-workspace.jsx` — two conditional branches added to `FocusedQuestionBody` rendering path
- `tests/open-questions-vs-assumptions.test.jsx` — v0.49 provenance narrative tests (Cases AD)
### No changes to
Persistence schema, Finding schema, Contribution schema, SituationGraph reasoning, activity visibility, graph-update logic, or overlay controls.
---
## RESTORE / PRESENTATION FINDINGS — MANUAL USER-PATH (2026-08-28)
@@ -1078,9 +1169,9 @@ After returning to the restored investigation and manually reopening the previou
This differs from the live current-turn focused surface where canonical Findings display those controls.
**REOPENED CANONICAL FINDING CONTROLS — UNRESOLVED**
**REOPENED CANONICAL FINDING CONTROLS — REPAIRED (v0.49)**
Leading hypothesis (unproved): the reopened/restored rendering path may be presenting Contribution-derived observation text or another historical representation instead of the same canonical Finding objects used by the live current-turn path. **This is NOT YET PROVED.**
Repaired `currentFindings` derivation in reasoning-workspace.jsx (lines 13821420): when `correlationId` is absent on cold reopen, the repair identifies the persisted Contribution belonging to the currently displayed focused turn/thread and uses `Finding.contributionId === Contribution.id` to recover the canonical Finding objects. correlationId is not required for cold reopen. Deterministic tests pass (70/70). Live Playwright verification deferred — see next constraint note.
---
@@ -1110,7 +1201,7 @@ The new manual observations are **presentation/lifecycle issues downstream of pe
| Cold-return can land on underlying surface rather than focused overlay | MANUALLY OBSERVED — unproven presentation/lifecycle gap |
| Saved-state banner + already-restored investigation signal | MANUALLY OBSERVED — semantic oddity of workspace state |
| Reopened Finding proposition survives | PROVED (text persists) |
| Reopened Not quite / not relevant controls absent | UNRESOLVED — hypothesis noted, root cause unproved |
| Reopened Not quite / not relevant controls absent | REPAIRED — Contribution.id → contributionId path verified; correlationId no longer required for cold reopen |
### NEXT BOUNDARY — REOPENED FOCUSED FINDING PRESENTATION / RESTORE WORKSPACE OWNERSHIP
@@ -1763,6 +1854,57 @@ State: 1 contribution targeting n58lwnx, 3 findings.
- Current Understanding narrative reconstruction (lower priority after integration is established)
- Any speculative cold-hydration/schema/provider fixes
### PHASE 7 — OPEN QUESTION INVESTIGATING CUE ON INITIAL REFLECTION SURFACE (v0.49)
#### Defect
The initial post-Analyse reflection surface renders Open Questions as plain buttons with only the epistemic Unclear tag. ThreadContributionsBadge was absent from ReasoningWorkspace's initial reflection surface button rendering. Investigated questions were visually indistinguishable from untouched questions on the normal Open Questions surface.
#### Repair
Added ThreadContributionsBadge as a sibling element after each Open Question button in ReasoningWorkspace's openUnknowns map, wrapping the button+badge in a shared div for vertical layout. The badge receives the same props it already uses in all other surfaces: nodeId, focusedContributions, and findings.
The existing filter inside ThreadContributionsBadge matches contributions via OR logic:
```
c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId
```
This captures both direct-target contributions and follow-up contributions whose origin anchors to a different Open Question.
#### What remains unchanged
- Unclear tag renders independently of activity — epistemic state is NOT derived from contribution history.
- The amber INVESTIGATING text above the learned-contributions summary is rendered by ThreadContributionsBadge, not redesigned.
- No new persisted state, store, or Finding source of truth introduced. Activity derives exclusively from canonical focusedContributions.
#### Deterministic tests (89 total in test file, 17 new for this phase)
All cases pass:
- Untouched question → Unclear only, no INVESTIGATING.
- Direct targeted contribution → INVESTIGATING visible.
- Follow-up/origin contribution (different immediate target) → INVESTIGATING visible via originatingTargetNodeId match.
- Question isolation — investigated shows cue, untouched does not.
- Multi-turn cold-return recovery via originatingTargetNodeId.
#### Live Playwright verification
URL: http://localhost:3000
Existing investigation reused: YES (onboarding funnel abandonment scenario)
LLM/API calls: 0
Return-to-overview (Phase 6):
- Originating question ("Which specific step of the onboarding funnel has the highest abandonment rate?"): Unclear + INVESTIGATING + learned contributions count visible.
- Untouched comparison ("Whether unclear instructions at account setup are causing users to stall."): Unclear only, no INVESTIGATING.
Cold reload (Phase 7):
- Same investigation state returned after normal browser reload.
- Originating question retains Unclear + INVESTIGATING + contributions count.
- Untouched questions remain without INVESTIGATING.
- Zero LLM/API calls performed.
#### Classification
A — REPAIR VERIFIED
### BUILD → BREAK → LEARN
Do not invent architecture ahead of evidence. Every architectural direction should emerge from live behaviour, not from design speculation. Use small bounded increments. Trace before changing unclear ownership paths. Do not broaden scope — keep focused on what the current evidence demands.
@@ -1863,3 +2005,192 @@ Target node: `n58lwnx`
- do not create a fresh scenario unless an experiment explicitly requires one;
- if this investigation is absent, report TEST-STATE MISSING rather than diagnosing persistence failure;
- structural/relative assertions only for LLM output — no exact prose dependency.
## v0.49.6 — OPEN QUESTION ACTIVITY VISIBILITY — VERIFIED
- **Status:** CLOSED / CHECKPOINTED
- `INVESTIGATING` is an activity/history cue independent from epistemic `Unclear`
- overview presentation is compact and non-expandable: `INVESTIGATING · N learned contribution(s)`
- cue lives inside the originating Open Question card
- activity derives from canonical focused Contributions using: `targetNodeId OR originatingTargetNodeId`
- investigated card was live verified after return from workspace
- untouched question remained without activity cue
- cue survived cold reload
- Open Question card remains the route into detailed investigation history
- zero LLM calls during verification
- targeted tests: 89 PASS
- build: PASS
### Unresolved boundaries (no diagnosis)
- completed-turn vs active-turn reconstruction
- empty response textarea against an already-completed historical question
- `Back to open questions` lifecycle
- top-right `Close investigation` wording / likely `Close workspace`
- `Done for now` remains a separate semantic action
**Next boundary:** FOCUSED WORKSPACE COMPLETED-TURN / ACTIVE-TURN LIFECYCLE
## v0.49.7 — NARROW COMPLETED-RESULT REPAIR (answer-affordance contradiction)
- **Status:** CLOSED / CHECKPOINTED
- Previous broader completed-turn lifecycle repair was discarded (restored to checkpoint dac19a3)
- Narrow repair: a reopened completed turn preserves existing current-result presentation but NO longer exposes a response textarea for the already-answered question
- Repair mechanism: added `!hasAnswer` (`Boolean(focused?.answer)`) to the textarea render condition in `FocusedQuestionBody` — line 186 of `reasoning-workspace.jsx`
- Explicit follow-up selection resets `answer` to null via `setFollowUpQuestion()`, creating unanswered state and exposing textarea
- Fresh investigation behaviour preserved (first-turn textarea still appears)
- Latest completed turn intentionally remains as current result (not moved into Previous Learning) — deferred to a later presentation/lifecycle decision
- No persistence changes, no new lifecycle enums, no new state fields added
- No LLM calls during verification or tests
- Pre-fix regression: OLD condition (`shouldShowResponseTextarea_OLD`) incorrectly returned `true` for completed turns (defect proved)
- Post-fix: 97/97 targeted tests PASS
- Build: PASS
- Playwright live reopen: no "Formulating your question…" regression; completed current result shows NO textarea
- Playwright explicit follow-up: selected follow-up becomes current QUESTION, fresh textarea with `What do you know about this?` placeholder appears
- Return to overview: UNCLEAR + INVESTIGATING · 3 learned contributions preserved
- Live fixture: reused existing 3-contribution onboarding investigation (no cold reload)
### Unresolved boundaries (deferred)
- latest-completed-turn → Previous Learning repartition (intentional defer — separate presentation/lifecycle decision)
- workspace control UX wording ("Back to open questions" / "Done for now" / "Close investigation")
---
### USER-AUTHORED EVIDENCE VS ENGINE INTERPRETATION — PRODUCT PRINCIPLE
**Status:** Durable product/UX principle
The Confidence Engine must preserve provenance at the presentation layer. When the user supplies evidence or an answer, their authored content and the engine's derived interpretation must remain visibly and linguistically distinct.
Core rule:
```text
User-authored content ≠ Engine-derived interpretation
```
The UI must not make system interpretation look like a quotation, rewrite, correction, or continuation of the user's own words. Where both appear together, the distinction should be obvious without requiring explanation.
**Verbatim preservation:** When replaying a prior user response, use the stored original response verbatim. Preserve its wording exactly. Identify it clearly as the user's response. Do not silently rewrite it into more polished system language. Do not present an Engine interpretation as though it is what the user said.
**Engine-derived material:** Findings, interpretations, uncertainties, assumptions and follow-up questions are Engine-derived. They must be labelled and presented separately from the user's original evidence.
**Why this matters:** This prevents the user from reasonably believing "The system has manipulated or rewritten my own words." It also preserves epistemic provenance:
```text
what the user supplied → what the Engine inferred from it
```
**Completed-turn narrative direction (future intent):**
```text
previous question
→ your response (verbatim)
→ from that we learned (derived Findings)
→ still unclear (remaining uncertainty)
→ questions this raises (follow-up candidates)
```
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
## v0.49 — ACTIVE FOLLOW-UP PRESENTATION SIMPLIFICATION
### Defect resolved
Selected follow-up candidate rendered twice inside "QUESTIONS THIS RAISES": once as a disabled `(current question)` row and again in the active continuation block above the textarea. Both rows contained the identical question text, creating visual redundancy.
### Fix summary
In `components/reasoning-workspace.jsx`, when `hasActiveFollowUp` is true (a follow-up has been selected), unselected candidates are still rendered with their existing selectable form (`→ pick this question`), but the candidate matching `focused.question` is filtered out from the candidate list entirely. The active continuation block already renders the selected question plus textarea + submit — no second rendering needed.
- Selected follow-up candidate now transforms into the active response block (single rendering).
- Duplicate `(current question)` / second-question rendering removed.
- Exactly one selected-question presentation.
- One textarea, Submit response visible.
- Completed Q/A provenance preserved.
- Previous Learning single-owner/newest-first preserved.
- Post-answer promotion preserved.
### Implementation detail
Production file changed: `components/reasoning-workspace.jsx` (candidate render boundary — lines ~275-300). No state added, no identity changes, no submit mechanics altered.
### Vitest config hygiene (from 8bded90)
vitest.config.js change classification: **A** — intentional and necessary (adds `environment: "jsdom"` required for React component testing in this repo).
### Tests
- Command: `npx vitest run tests/open-questions-vs-assumptions.test.jsx`
- Actual tests passed: **117** (up from 116 — one new regression test added)
- New regression assertion: verifies that after selecting a follow-up, the question text appears exactly once and no `(current question)` label is rendered.
### Build
- Result: PASS
File diff suppressed because it is too large Load Diff
+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, ".") } },
});