From 0fccbbe2ad9c43552c1b51ce7da596e3bd8325cf Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 9 Sep 2026 01:25:14 +0300 Subject: [PATCH] Detail card: per-session understanding notes next to the session list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extractor's per-proposition understanding observations (named-word scale: solid/shaky/accepted/...) were ledger data since the redesign, with no surface. This exposes them at read time and shows each session's note as a small chip on its session row in the node detail card — the per-session confidence history the user asked for, one observation per (node, chat), latest wins within a chat. - GraphSnapshot.nodeObservations: nodeId -> chatId -> note, built in graphSnapshot() from observation events (pure read-time join). - Sessions list rows: link + observation chip. - Session-relative judgments stay session-scoped: they render next to their session, never as node state (the state model stays pending). --- components/graph-view.tsx | 29 ++++++++++++++++++++--------- src/core/contracts/graph.ts | 5 +++++ src/core/graph.ts | 23 ++++++++++++++++++++++- tests/graph.test.ts | 8 +++++++- 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index b11145a..bb4e349 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -625,15 +625,26 @@ export function GraphView() { {selected.chatIds.length > 0 ? (
Sessions
- {selected.chatIds.map((chatId) => ( - - {snapshot?.sessions[chatId] ?? chatId} - - ))} + {selected.chatIds.map((chatId) => { + // That session's understanding observation — the + // extractor's named-word note (confidence history). + const note = snapshot?.nodeObservations?.[selected.node.id]?.[chatId]; + return ( +
+ + {snapshot?.sessions[chatId] ?? chatId} + + {note && ( + + {note} + + )} +
+ ); + })}
) : (
No sessions recorded.
diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index 6a2ad44..158b320 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -229,6 +229,11 @@ export interface GraphSnapshot { sessions: Record; /** nodeId → chatIds that touched the node (click-through provenance). */ nodeSessions: Record; + /** nodeId → chatId → that session's understanding observation (the + * extractor's per-session named-word note; session-relative data, + * shown next to the session list — the confidence history). Multiple + * notes for one (node, chat) collapse to the latest. */ + nodeObservations: Record>; } /** Derive a short chat title from the learner's first message (pure). diff --git a/src/core/graph.ts b/src/core/graph.ts index b2d2281..f40d684 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -447,5 +447,26 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot { if (ids.length > 0) nodeSessions[nodeId] = ids; } - return { nodes, edges, sessions, nodeSessions }; + // Understanding observations per (node, chat) — the extractor's + // session-relative note (latest wins within a chat). Read-time join for + // the detail card: the confidence history next to the session list. + const nodeObservations: Record> = {}; + const latestObs = new Map(); + events.forEach((event, index) => { + if (event.kind !== "observation" || event.nodeId === null) return; + if (!event.note) return; + const key = `${event.nodeId}\u0000${event.chatId}`; + const current = latestObs.get(key); + if (!current || event.at > current.at || (event.at === current.at && index > current.index)) { + latestObs.set(key, { at: event.at, note: event.note, index }); + } + }); + for (const [key, { note }] of latestObs) { + const sep = key.indexOf("\u0000"); + const nodeId = key.slice(0, sep); + const chatId = key.slice(sep + 1); + (nodeObservations[nodeId] ??= {})[chatId] = note; + } + + return { nodes, edges, sessions, nodeSessions, nodeObservations }; } diff --git a/tests/graph.test.ts b/tests/graph.test.ts index ccc2e18..f440ab6 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -363,7 +363,7 @@ describe("the merger (mergeFindings)", () => { assert.deepEqual(m, { chatId: chat2.id, sourceId: "q1", graphNodeId: alphaId }); }); - it("derives node states and sessions in graphSnapshot", () => { + it("derives sessions and per-session understanding observations in graphSnapshot", () => { const snap = graphSnapshot(graphDeps); const idByStatement = new Map(snap.nodes.map((n) => [n.statement, n.id])); // No node-state classes anymore (2026-09-08): the snapshot carries @@ -378,6 +378,12 @@ describe("the merger (mergeFindings)", () => { ); // Gamma was only ever touched by chat1. assert.deepEqual(snap.nodeSessions[idByStatement.get("Gamma application")!], [chat1.id]); + + // Understanding observations: (node → chat → the extractor's note). + // Alpha carries chat1's "solid after the check"; chat2's q1 has no note. + const alphaObs = snap.nodeObservations[idByStatement.get("Alpha intuition")!] ?? {}; + assert.equal(alphaObs[chat1.id], "solid after the check"); + assert.equal(alphaObs[chat2Id], undefined); }); it("never string-matches without a verdict — an identical statement without linkTo is a NEW node", () => {