Detail card: per-session understanding notes next to the session list

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).
This commit is contained in:
amosblomqvist
2026-09-09 01:25:14 +03:00
parent 54f88f37e1
commit 0fccbbe2ad
4 changed files with 54 additions and 11 deletions
+20 -9
View File
@@ -625,15 +625,26 @@ export function GraphView() {
{selected.chatIds.length > 0 ? ( {selected.chatIds.length > 0 ? (
<div className="mt-3 flex flex-col gap-1"> <div className="mt-3 flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Sessions</div> <div className="text-xs text-muted-foreground">Sessions</div>
{selected.chatIds.map((chatId) => ( {selected.chatIds.map((chatId) => {
<Link // That session's understanding observation — the
key={chatId} // extractor's named-word note (confidence history).
href={`/chat/${chatId}`} const note = snapshot?.nodeObservations?.[selected.node.id]?.[chatId];
className="truncate rounded-md px-2 py-1 text-sm text-foreground transition-colors hover:bg-accent" return (
> <div key={chatId} className="flex items-center gap-1.5">
{snapshot?.sessions[chatId] ?? chatId} <Link
</Link> href={`/chat/${chatId}`}
))} className="min-w-0 flex-1 truncate rounded-md px-2 py-1 text-sm text-foreground transition-colors hover:bg-accent"
>
{snapshot?.sessions[chatId] ?? chatId}
</Link>
{note && (
<span className="shrink-0 rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
{note}
</span>
)}
</div>
);
})}
</div> </div>
) : ( ) : (
<div className="mt-3 text-xs text-muted-foreground">No sessions recorded.</div> <div className="mt-3 text-xs text-muted-foreground">No sessions recorded.</div>
+5
View File
@@ -229,6 +229,11 @@ export interface GraphSnapshot {
sessions: Record<string, string>; sessions: Record<string, string>;
/** nodeId → chatIds that touched the node (click-through provenance). */ /** nodeId → chatIds that touched the node (click-through provenance). */
nodeSessions: Record<string, string[]>; nodeSessions: Record<string, string[]>;
/** 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<string, Record<string, string>>;
} }
/** Derive a short chat title from the learner's first message (pure). /** Derive a short chat title from the learner's first message (pure).
+22 -1
View File
@@ -447,5 +447,26 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
if (ids.length > 0) nodeSessions[nodeId] = ids; 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<string, Record<string, string>> = {};
const latestObs = new Map<string, { at: string; note: string; index: number }>();
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 };
} }
+7 -1
View File
@@ -363,7 +363,7 @@ describe("the merger (mergeFindings)", () => {
assert.deepEqual(m, { chatId: chat2.id, sourceId: "q1", graphNodeId: alphaId }); 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 snap = graphSnapshot(graphDeps);
const idByStatement = new Map(snap.nodes.map((n) => [n.statement, n.id])); const idByStatement = new Map(snap.nodes.map((n) => [n.statement, n.id]));
// No node-state classes anymore (2026-09-08): the snapshot carries // 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. // Gamma was only ever touched by chat1.
assert.deepEqual(snap.nodeSessions[idByStatement.get("Gamma application")!], [chat1.id]); 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", () => { it("never string-matches without a verdict — an identical statement without linkTo is a NEW node", () => {