diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index b4a8139..6e284d6 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -138,6 +138,7 @@ function FocusedQuestionBody({ setFollowUpQuestion, focusedContributions, currentFindings, + findings, onUpdateFindingDisposition, onUpdateFindingProposition, }) { @@ -195,7 +196,7 @@ function FocusedQuestionBody({ {focused?.result && ( <> {/* Prior accumulated learning (prior turns, current turn excluded — shown above) */} - +

What this tells us

    {(currentFindings?.length ? currentFindings : (focused.result.observations || [])).map((item, i) => { const isFinding = typeof item === "object" && item !== null && "id" in item; @@ -468,9 +469,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, ); @@ -488,19 +505,22 @@ function PriorContributionsSummary({ nodeId, contributions }) { {priorContribs.map((c, idx) => (
    - 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)
    - {c.observations?.length ? ( -
    -
    What this tells us
    -
      - {c.observations.map((o, i) => ( -
    • {o}
    • - ))} -
    -
    - ) : null} + {(() => { + const propositions = getHistoricalPropositions(c, findings); + return propositions.length ? ( +
    +
    What this tells us
    +
      + {propositions.map((o, i) => ( +
    • {o}
    • + ))} +
    +
    + ) : null; + })()} {c.uncertainties?.length ? (
    Still unclear
    @@ -520,7 +540,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, ); @@ -538,19 +558,22 @@ function SecondaryPreviousLearning({ nodeId, contributions }) { {priorContribs.map((c, idx) => (
    - 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)
    - {c.observations?.length ? ( -
    -
    What this tells us
    -
      - {c.observations.map((o, i) => ( -
    • {o}
    • - ))} -
    -
    - ) : null} + {(() => { + const propositions = getHistoricalPropositions(c, findings); + return propositions.length ? ( +
    +
    What this tells us
    +
      + {propositions.map((o, i) => ( +
    • {o}
    • + ))} +
    +
    + ) : null; + })()} {c.uncertainties?.length ? (
    Still unclear
    @@ -570,7 +593,7 @@ 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, ); @@ -601,17 +624,20 @@ function ThreadContributionsBadge({ nodeId, contributions }) { {threadContribs.map((c, idx) => (
    {idx > 0 &&
    Contribution #{c.sequence || idx + 1}
    } - {/* What this tells us */} - {c.observations?.length ? ( -
    -

    What this tells us

    -
      - {c.observations.map((o, i) => ( -
    • {o}
    • - ))} -
    -
    - ) : null} + {/* What this tells us — via canonical Findings */} + {(() => { + const propositions = getHistoricalPropositions(c, findings); + return propositions.length ? ( +
    +

    What this tells us

    +
      + {propositions.map((o, i) => ( +
    • {o}
    • + ))} +
    +
    + ) : null; + })()} {/* Still unclear */} {c.uncertainties?.length ? ( @@ -1070,6 +1096,7 @@ function FocusedInvestigationWorkspace({ hasCompletedInvestigation, focusedContributions, currentFindings, + findings, onUpdateFindingDisposition, onUpdateFindingProposition, }) { @@ -1107,7 +1134,7 @@ function FocusedInvestigationWorkspace({ {/* ── Secondary context: Previous Learning — visible on all breakpoints, placed in grid column on wide / flows below primary on narrow ── */} {hasResult && ( - + )}
    @@ -1122,6 +1149,7 @@ function OpenQuestionsPanel({ startFocused, handleDeconstructSubmit, retryFormulation, setSelectedPresentationItemId, setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds, setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen, + findings, onUpdateFindingDisposition, onUpdateFindingProposition, }) { @@ -1206,7 +1234,7 @@ function OpenQuestionsPanel({ /> {/* Thread contributions for this node */} - +
    ); @@ -1220,7 +1248,7 @@ function OpenQuestionsPanel({ {graph.nodes.filter((n) => doneForNowIds.includes(n.id)).map((node) => (

    {node.label}

    - +
    ))} @@ -1917,6 +1945,7 @@ export default function ReasoningWorkspace({ focusedContributions={focusedContributions} focusedInvestigations={focusedInvestigations} setIsFocusedWorkspaceOpen={setIsFocusedWorkspaceOpen} + findings={findings} onUpdateFindingProposition={onUpdateFindingProposition} /> )} @@ -2092,6 +2121,7 @@ export default function ReasoningWorkspace({ }} focusedContributions={focusedContributions} currentFindings={currentFindings || []} + findings={findings} onUpdateFindingDisposition={onUpdateFindingDisposition} onUpdateFindingProposition={onUpdateFindingProposition} /> diff --git a/docs/current-handoff.md b/docs/current-handoff.md index cdb0cc7..9ab4955 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -1035,6 +1035,38 @@ 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. + --- ## RESTORE / PRESENTATION FINDINGS — MANUAL USER-PATH (2026-08-28) diff --git a/tests/open-questions-vs-assumptions.test.jsx b/tests/open-questions-vs-assumptions.test.jsx index 7d7f6c1..dc9b9a9 100644 --- a/tests/open-questions-vs-assumptions.test.jsx +++ b/tests/open-questions-vs-assumptions.test.jsx @@ -1140,3 +1140,109 @@ describe("Reopened Finding resolution via Contribution.id → contributionId", ( // Turn 1 Finding NOT merged into latest view. }); }); + +// ── getHistoricalPropositions: Previous Learning renders canonical Findings ─ + +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); +} + +describe("getHistoricalPropositions: Previous Learning uses canonical Findings", () => { + it("zero matching canonical Findings → fallback to Contribution.observations", () => { + const contrib = { + id: "contrib-01", + observations: ["Original observation from contribution"], + }; + const findings = []; + + const result = getHistoricalPropositions(contrib, findings); + expect(result).toEqual(["Original observation from contribution"]); + }); + + it("zero matching canonical Findings with null observations → empty array", () => { + const contrib = { id: "contrib-02" }; + const findings = []; + + const result = getHistoricalPropositions(contrib, findings); + expect(result).toEqual([]); + }); + + it("one or more matching canonical Findings → canonical Findings are authoritative", () => { + const contrib = { id: "contrib-03", observations: ["Original obs"] }; + const findings = [ + { contributionId: "contrib-03", proposition: "Canonical finding A", userDisposition: "agreed" }, + { contributionId: "contrib-03", proposition: "Canonical finding B", userDisposition: "agreed" }, + ]; + + const result = getHistoricalPropositions(contrib, findings); + expect(result).toEqual(["Canonical finding A", "Canonical finding B"]); + expect(result).not.toContain("Original obs"); + }); + + it("matching canonical Findings all have userDisposition not_relevant → render NO propositions, DO NOT fall back", () => { + const contrib = { id: "contrib-04", observations: ["Should not appear"] }; + const findings = [ + { contributionId: "contrib-04", proposition: "Dismissed finding 1", userDisposition: "not_relevant" }, + { contributionId: "contrib-04", proposition: "Dismissed finding 2", userDisposition: "not_relevant" }, + ]; + + const result = getHistoricalPropositions(contrib, findings); + expect(result).toEqual([]); + expect(result).not.toContain("Should not appear"); + }); + + it("turn ownership is strict — only Findings whose contributionId matches the Contribution.id render", () => { + const contribA = { id: "contrib-A", observations: ["A's original"] }; + const contribB = { id: "contrib-B", observations: ["B's original"] }; + + const contribBFinding = { contributionId: "contrib-B", proposition: "Finding from B", userDisposition: "agreed" }; + const contribAFinding = { contributionId: "contrib-A", proposition: "Finding from A", userDisposition: "agreed" }; + + const resultForB = getHistoricalPropositions(contribB, [contribBFinding]); + expect(resultForB).toEqual(["Finding from B"]); + + const resultMixed = getHistoricalPropositions(contribB, [contribAFinding, contribBFinding]); + expect(resultMixed).toEqual(["Finding from B"]); + expect(resultMixed).not.toContain("Finding from A"); + }); + + it("mixed dispositions: non-not_relevant Findings render, not_relevant are excluded", () => { + const contrib = { id: "contrib-05", observations: ["Original"] }; + const findings = [ + { contributionId: "contrib-05", proposition: "Kept finding", userDisposition: "agreed" }, + { contributionId: "contrib-05", proposition: "Dismissed finding", userDisposition: "not_relevant" }, + { contributionId: "contrib-05", proposition: "Neutral finding", userDisposition: "neutral" }, + ]; + + const result = getHistoricalPropositions(contrib, findings); + expect(result).toEqual(["Kept finding", "Neutral finding"]); + expect(result).not.toContain("Dismissed finding"); + }); + + it("null/undefined findings array handled safely — falls back to observations", () => { + const contrib = { id: "contrib-06", observations: ["Fallback safe"] }; + + expect(getHistoricalPropositions(contrib, null)).toEqual(["Fallback safe"]); + expect(getHistoricalPropositions(contrib, undefined)).toEqual(["Fallback safe"]); + }); + + it("Contribution with no observations and no matching Findings returns empty", () => { + const contrib = { id: "contrib-07" }; + const findings = [ + { contributionId: "contrib-other", proposition: "Wrong contribution", userDisposition: "agreed" }, + ]; + + const result = getHistoricalPropositions(contrib, findings); + expect(result).toEqual([]); + }); +});