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:
@@ -625,15 +625,26 @@ export function GraphView() {
|
||||
{selected.chatIds.length > 0 ? (
|
||||
<div className="mt-3 flex flex-col gap-1">
|
||||
<div className="text-xs text-muted-foreground">Sessions</div>
|
||||
{selected.chatIds.map((chatId) => (
|
||||
<Link
|
||||
key={chatId}
|
||||
href={`/chat/${chatId}`}
|
||||
className="truncate rounded-md px-2 py-1 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
{snapshot?.sessions[chatId] ?? chatId}
|
||||
</Link>
|
||||
))}
|
||||
{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 (
|
||||
<div key={chatId} className="flex items-center gap-1.5">
|
||||
<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 className="mt-3 text-xs text-muted-foreground">No sessions recorded.</div>
|
||||
|
||||
@@ -229,6 +229,11 @@ export interface GraphSnapshot {
|
||||
sessions: Record<string, string>;
|
||||
/** nodeId → chatIds that touched the node (click-through provenance). */
|
||||
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).
|
||||
|
||||
+22
-1
@@ -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<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
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user