diff --git a/src/agent/prompts/50-graph-context.md b/src/agent/prompts/50-graph-context.md new file mode 100644 index 0000000..85d3ac2 --- /dev/null +++ b/src/agent/prompts/50-graph-context.md @@ -0,0 +1,21 @@ +# The learner's knowledge graph + +After these instruction fragments you may see a fenced block titled **LEARNER KNOWLEDGE GRAPH (auto-generated)**. It is the accumulated record of what this learner has actually demonstrated, across every past session — rebuilt fresh from quiz outcomes and plan trees, every turn. Each line is a proposition in its current state (in brackets), and `a → b` under **Requires** means *a* must be understood before *b*. + +The states, exactly: + +- **[missed]** — answered wrong. Treat as a live misconception site until a check proves otherwise. +- **[dont-know]** — the learner declined to guess. Unexplored, not broken. +- **[known]** — answered correctly. Foundation you can build on and refer to by name. +- **[taught]** — a lesson covered it. Check that it stuck before stacking anything heavy on it. +- **[candidate]** — asserted into a plan but never probed. Assume nothing. + +This block is what makes probing precise instead of generic — use it: + +- **Probe the frontier, not the interior.** [known] and [taught] nodes near the session's topic are the learner's foundation: reference them by name and build on them — never re-teach what the graph says he holds. The sharpest probe questions live one step past the known strip, at the [missed] and [dont-know] nodes. +- **[missed] nodes are your first stops.** A wrong answer is a misconception until shown otherwise. Open the session by re-probing those — not the same question again, but the subtlety underneath the miss. +- **Read the Requires chains as scaffolding.** They show what the learner's knowledge rests on. If a prerequisite under the session's topic is [missed] or [dont-know], that is where teaching starts — not at the topic itself. + +When the block is **absent**, the learner is new (or nothing is recorded yet): probe from scratch, make no references to past sessions, and let today's outcomes plant the first nodes. + +The graph is evidence, not truth — a state is only as good as its last probe. Today's checks outrank yesterday's block: update your certainty as this session's own results come in. diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index 3b43a39..6687759 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -12,6 +12,10 @@ import { createTeacherTools } from "./tools.ts"; * The model is injectable so tests can pass a MockLanguageModelV4 and drive * deterministic scripted lessons (see tests/chat-flow.test.ts). * + * The optional graphContext (buildGraphContext() in src/core/graph-context.ts, + * wired by the chat-handler per request) is appended after the composed + * fragments; absent/empty means a new learner — nothing is injected. + * * The system prompt is COMPOSED: system-prompt.md is the base, then every * src/agent/prompts/*.md fragment in sorted filename order (workers add * fragments — nobody edits shared sections of the base prompt). Prefix @@ -36,7 +40,19 @@ function composeInstructions(): string { const instructions = composeInstructions(); -export function createTeacherAgent(model?: LanguageModel, tools?: ToolSet) { +/** + * Compose the full instruction string: base prompt + fragments, then — when + * the session carries graph context (W8, vision §1 "the graph as session + * context") — the auto-generated knowledge-graph block appended after them as + * a clearly-delimited section (the 50-graph-context.md fragment teaches the + * teacher how to read it). The fragment mechanism itself stays untouched. + */ +function withGraphContext(graphContext: string | null | undefined): string { + if (!graphContext) return instructions; + return `${instructions}\n\n---\n\n${graphContext}`; +} + +export function createTeacherAgent(model?: LanguageModel, tools?: ToolSet, graphContext?: string | null) { return new ToolLoopAgent({ model: model ?? @@ -45,7 +61,7 @@ export function createTeacherAgent(model?: LanguageModel, tools?: ToolSet) { // they stream as reasoning parts into the UI's collapsible block. reasoning: { enabled: true, effort: "medium" }, }), - instructions, + instructions: withGraphContext(graphContext), // Tools are per-request (plan tools close over chatId); chat-handler // passes createTeacherTools({ chatId }). Tests pass a mock model and get // the default question-only set. diff --git a/src/core/graph-context.ts b/src/core/graph-context.ts new file mode 100644 index 0000000..818fa11 --- /dev/null +++ b/src/core/graph-context.ts @@ -0,0 +1,171 @@ +/** + * Graph-as-context (W8; vision §1 "The graph as session context", DECIDED + * 2026-08-22). + * + * renderGraphContext() turns the derived graph into a compact markdown block + * the teacher sees as part of its instructions — this is what makes probing + * precise instead of generic: probe questions can target the boundary of + * what the learner actually holds, and personalization compounds across + * sessions. + * + * buildGraphContext() is the per-request entry point the chat-handler calls + * (stateless: it re-reads the ledgers, so it self-updates as the graph grows + * mid-session). An empty graph returns null — nothing is injected, and the + * prompt fragment (50-graph-context.md) tells the teacher how to behave when + * the block is absent (new learner). + * + * Selection strategy (§1 "the selection problem", simplest first): the WHOLE + * graph while it is small; past MAX_NODES, hotspots (missed / dont-know / + * misconception nodes) plus their 1-hop `requires` neighborhood — a live + * misconception is always relevant context, and the frontier is where the + * next session should probe. + */ + +import type { GraphEdge, GraphNode } from "./contracts.ts"; +import { deriveNodeStates, getGraph, type DerivedGraph, type NodeStateClass } from "./graph.ts"; + +/** The section header the teacher is taught to recognize (50-graph-context.md). */ +export const GRAPH_CONTEXT_HEADER = "LEARNER KNOWLEDGE GRAPH (auto-generated)"; + +/** Above this node count only hotspots + their 1-hop neighborhood are shown. */ +export const MAX_CONTEXT_NODES = 150; + +/** Group order for node lines: diagnostic value first (vision §1 hotspots). + * A misconception-kind node is a hotspot whatever its state — a live + * misconception is always relevant context. */ +const HOTSPOT_STATES: ReadonlySet = new Set(["missed", "dont-know"]); + +function groupOf(node: GraphNode, state: NodeStateClass): number { + if (HOTSPOT_STATES.has(state) || node.kind === "misconception") return 0; + if (state === "known") return 1; + if (state === "taught") return 2; + return 3; // candidate +} + +const GROUP_LABELS = [ + "Hotspots (missed / dont-know / misconceptions) — likely misconception sites and unexplored ground:", + "Known (answered correctly):", + "Taught (lessons covered them):", + "Candidates (never probed — assume nothing):", +] as const; + +/** A hotspot is a node worth showing even under truncation (§1: diagnostic hotspots). */ +function isHotspot(node: GraphNode, state: NodeStateClass): boolean { + return groupOf(node, state) === 0; +} + +/** + * Render the context block, or null when there is nothing worth injecting + * (no nodes; or a truncated graph whose selection came out empty). + * + * Pure: callers hand in exactly what getGraph() + deriveNodeStates() produce. + */ +export function renderGraphContext( + nodes: GraphNode[], + edges: GraphEdge[], + states: Record, +): string | null { + if (nodes.length === 0) return null; + + // ── selection ─────────────────────────────────────────────────────────── + const stateOf = (node: GraphNode): NodeStateClass => states[node.id] ?? "candidate"; + let selected = nodes; + let truncated = false; + if (nodes.length > MAX_CONTEXT_NODES) { + truncated = true; + const hotspots = nodes.filter((n) => isHotspot(n, stateOf(n))); + const keep = new Set(hotspots.map((n) => n.id)); + // 1-hop requires neighborhood: prerequisites AND dependents. + for (const edge of edges) { + if (edge.type !== "requires") continue; + if (keep.has(edge.from) || keep.has(edge.to)) { + keep.add(edge.from); + keep.add(edge.to); + } + } + selected = nodes.filter((n) => keep.has(n.id)); + if (selected.length === 0) return null; // nothing diagnostic to show + } + + // ── node lines, grouped/sorted by diagnostic priority ────────────────────────── + const lines: string[] = [ + `===== ${GRAPH_CONTEXT_HEADER} =====`, + `${truncated ? `showing ${selected.length} of ${nodes.length} nodes (hotspots + their 1-hop neighborhood)` : `${nodes.length} nodes`}, ${edges.filter((e) => e.type === "requires").length} requires edges`, + ]; + + for (const [group, label] of GROUP_LABELS.entries()) { + const groupNodes = selected + .filter((n) => groupOf(n, stateOf(n)) === group) + .sort((a, b) => (a.statement < b.statement ? -1 : a.statement > b.statement ? 1 : 0)); + if (groupNodes.length === 0) continue; + lines.push("", label); + for (const node of groupNodes) { + const kind = node.kind === "misconception" ? " · misconception" : ""; + lines.push(`• [${stateOf(node)}] ${node.statement} (${node.id})${kind}`); + } + } + + // ── requires edges, merged into chains ────────────────────────────────── + const byId = new Map(selected.map((n) => [n.id, n.statement])); + const requires = edges.filter( + (e) => e.type === "requires" && byId.has(e.from) && byId.has(e.to), + ); + if (requires.length > 0) { + lines.push("", "Requires (prerequisite → dependent):"); + for (const chain of edgeChains(requires)) { + lines.push(chain.map((id) => byId.get(id) ?? id).join(" → ")); + } + } + + lines.push("===== end of knowledge graph ====="); + return lines.join("\n"); +} + +/** + * Merge a set of requires edges into maximal simple chains: a→b, b→c, a→d + * renders as "a → b → c" and "a → d". Each edge appears in exactly one chain. + */ +function edgeChains(edges: GraphEdge[]): string[][] { + const chains: string[][] = []; + const used = new Set(); + + const sorted = [...edges].sort((a, b) => + `${a.from}\u0000${a.to}` < `${b.from}\u0000${b.to}` ? -1 : 1, + ); + for (const edge of sorted) { + if (used.has(edge)) continue; + used.add(edge); + let chain = [edge.from, edge.to]; + // Extend forward along unused outgoing edges (branching yields sibling + // chains; a cycle stops the walk). + for (;;) { + const tail = chain[chain.length - 1]!; + const next = sorted.find((e) => !used.has(e) && e.from === tail); + if (!next || chain.includes(next.to)) break; // branching or a cycle — stop + used.add(next); + chain.push(next.to); + } + // Extend backward along unused incoming edges, symmetrically. + for (;;) { + const head = chain[0]!; + const prev = sorted.find((e) => !used.has(e) && e.to === head); + if (!prev || chain.includes(prev.from)) break; + used.add(prev); + chain.unshift(prev.from); + } + chains.push(chain); + } + return chains; +} + +// ─── the per-request entry point ───────────────────────────────────────────── + +/** + * Build the graph context block for one request. Stateless — re-reads the + * ledgers every call, so it self-updates as the graph grows mid-session. + * Accepts an injected derived graph (tests); defaults to the live one. + */ +export function buildGraphContext(graph: DerivedGraph = getGraph()): string | null { + const states = deriveNodeStates(graph.nodes, graph.events); + return renderGraphContext(graph.nodes, graph.edges, states); +} diff --git a/src/core/graph.ts b/src/core/graph.ts index 5c88cd5..df7a2b8 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -4,7 +4,10 @@ * * emitChat(chatId) folds ONE chat into the global graph — naive union, * deterministic, no LLM: - * plan-tree nodes → GraphNodes (statement = title, aliases empty) + * plan-tree nodes → GraphNodes (statement = title, aliases empty; + * W8: a title exactly matching an existing statement + * or alias — case-insensitive, trimmed — maps onto the + * existing node instead of appending a duplicate) * plan-tree edges → `requires` GraphEdges (from = prerequisite) * answered quizzes → ledger events (probe-* before the first plan_init * output, check / dont-know after; ref = toolCallId) @@ -23,6 +26,11 @@ import type { QuestionAnswerData } from "./question-core.ts"; import { getChat, listChats } from "../store/chats.ts"; import { graphLedgers, newGraphNodeId } from "../store/graph.ts"; +/** The ledger surface emitChat needs (the real one is src/store/graph.ts). + * Injectable so tests can fold fixtures into an in-memory ledger instead of + * the live data/graph files. */ +export type GraphLedgers = typeof graphLedgers; + // ─── transcript scanning ───────────────────────────────────────────────────── /** A flattened, order-preserving view of every tool part in a transcript. */ @@ -91,8 +99,11 @@ export interface EmissionStats { * Fold one chat into the graph ledgers. Idempotent — safe to call after every * turn (chat-handler onFinish) and again on catch-up (GET /api/graph). Sync * and fast: a handful of small-file reads plus one append per new record. + * + * `ledgers` is injectable for tests (in-memory ledger); production always + * uses the live store. */ -export function emitChat(chatId: string): EmissionStats { +export function emitChat(chatId: string, ledgers: GraphLedgers = graphLedgers): EmissionStats { const record = getChat(chatId); if (!record) return { nodes: 0, edges: 0, events: 0 }; const stats: EmissionStats = { nodes: 0, edges: 0, events: 0 }; @@ -100,7 +111,7 @@ export function emitChat(chatId: string): EmissionStats { // 1. This chat's existing mappings — the identity map is per-chat (v1 is // naive union: two sessions' tree nodes never merge into one graph node). const mapping = new Map(); - for (const m of graphLedgers.readMappings()) { + for (const m of ledgers.readMappings()) { if (m.chatId === chatId) mapping.set(m.treeNodeId, m.graphNodeId); } @@ -112,10 +123,33 @@ export function emitChat(chatId: string): EmissionStats { const tree = record.tree && record.tree.nodes.length > 0 ? record.tree : replayFinalTree(record.messages); - // 3. Tree nodes without a mapping → GraphNode + mapping. + // 3. Tree nodes without a mapping → GraphNode + mapping. W8 merge + // improvement: a title that EXACTLY matches (case-insensitive, trimmed) an + // existing node's statement or alias maps onto that node instead of + // appending a duplicate — the mapping line is still appended so provenance + // stays honest. Fuzzy/embedding matching stays deferred (vision §1 + // consolidation pipeline). + const norm = (s: string): string => s.trim().toLowerCase(); + const existingByTitle = new Map(); + for (const node of ledgers.readNodes()) { + const statementKey = norm(node.statement); + if (statementKey && !existingByTitle.has(statementKey)) existingByTitle.set(statementKey, node.id); + for (const alias of node.aliases) { + const aliasKey = norm(alias); + if (aliasKey && !existingByTitle.has(aliasKey)) existingByTitle.set(aliasKey, node.id); + } + } const now = new Date().toISOString(); for (const node of tree.nodes) { if (mapping.has(node.id)) continue; + const titleKey = norm(node.title); + const existingId = titleKey ? existingByTitle.get(titleKey) : undefined; + if (existingId !== undefined) { + // Exact match: reuse the node, record the mapping, append nothing. + ledgers.appendMapping({ chatId, treeNodeId: node.id, graphNodeId: existingId }); + mapping.set(node.id, existingId); + continue; + } const graphNode: GraphNode = { id: newGraphNodeId(), // statement is the title; the summary stays out (contracts). @@ -124,8 +158,9 @@ export function emitChat(chatId: string): EmissionStats { kind: "truth", createdAt: now, }; - graphLedgers.appendNode(graphNode); - graphLedgers.appendMapping({ chatId, treeNodeId: node.id, graphNodeId: graphNode.id }); + ledgers.appendNode(graphNode); + ledgers.appendMapping({ chatId, treeNodeId: node.id, graphNodeId: graphNode.id }); + existingByTitle.set(titleKey, graphNode.id); mapping.set(node.id, graphNode.id); stats.nodes++; } @@ -134,7 +169,7 @@ export function emitChat(chatId: string): EmissionStats { // whole ledger: an existing assertion just gains this chatId as // provenance (appended as a new line; reads merge). const asserted = new Map>(); - for (const line of graphLedgers.readEdges()) { + for (const line of ledgers.readEdges()) { const key = `${line.from}\u0000${line.to}\u0000${line.type}`; let provenance = asserted.get(key); if (!provenance) { @@ -150,7 +185,7 @@ export function emitChat(chatId: string): EmissionStats { const key = `${from}\u0000${to}\u0000requires`; const provenance = asserted.get(key); if (provenance?.has(chatId)) continue; // already asserted by this chat - graphLedgers.appendEdge({ from, to, type: "requires", provenance: [chatId] }); + ledgers.appendEdge({ from, to, type: "requires", provenance: [chatId] }); if (provenance) provenance.add(chatId); else asserted.set(key, new Set([chatId])); stats.edges++; @@ -161,7 +196,7 @@ export function emitChat(chatId: string): EmissionStats { // chat-scoped (a quiz toolCallId or taught:) — a ref already // in the ledger means this turn was folded before. const seenRefs = new Set(); - for (const event of graphLedgers.readEvents()) { + for (const event of ledgers.readEvents()) { if (event.chatId === chatId && event.ref) seenRefs.add(event.ref); } let planSeen = false; @@ -173,7 +208,7 @@ export function emitChat(chatId: string): EmissionStats { if (part.name === "quiz" && part.state === "output-available") { const event = quizEvent(part, chatId, mapping, planSeen, now); if (event?.ref !== undefined && !seenRefs.has(event.ref)) { - graphLedgers.appendEvent(event); + ledgers.appendEvent(event); seenRefs.add(event.ref); stats.events++; } @@ -185,7 +220,7 @@ export function emitChat(chatId: string): EmissionStats { if (result?.ok && typeof input?.id === "string") { const ref = `taught:${input.id}`; if (!seenRefs.has(ref)) { - graphLedgers.appendEvent({ + ledgers.appendEvent({ nodeId: mapping.get(input.id) ?? null, kind: "taught", chatId, @@ -200,7 +235,7 @@ export function emitChat(chatId: string): EmissionStats { } // 6. Checkpoint: this chat is folded through its current updatedAt. - graphLedgers.appendCheckpoint({ chatId, foldedAt: record.updatedAt }); + ledgers.appendCheckpoint({ chatId, foldedAt: record.updatedAt }); return stats; } diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index ef3a7b6..d171224 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -3,6 +3,7 @@ import { createTeacherAgent, type TeacherAgent } from "../agent/teacher-agent.ts import { createTeacherTools } from "../agent/tools.ts"; import { spawnPendingJobs } from "../agent/subagents/runner.ts"; import { emitChat } from "../core/graph.ts"; +import { buildGraphContext } from "../core/graph-context.ts"; import { getChat, saveMessages } from "../store/chats.ts"; /** @@ -29,8 +30,20 @@ export async function handleChatRequest(req: Request, agent?: TeacherAgent): Pro return Response.json({ error: "messages must be an array" }, { status: 400 }); } + // W8 (vision §1 "the graph as session context"): the learner's knowledge + // graph rides along as part of the teacher's instructions. Built per + // request — stateless, and self-updating as the graph grows mid-session + // (the emission hook below folds each turn). A read failure must never + // take the lesson down: the block is context, not a dependency. + let graphContext: string | null = null; + try { + graphContext = buildGraphContext(); + } catch (err) { + console.error(`[chat ${chatId}] graph context build failed:`, err); + } + return createAgentUIStreamResponse({ - agent: agent ?? createTeacherAgent(undefined, createTeacherTools({ chatId })), + agent: agent ?? createTeacherAgent(undefined, createTeacherTools({ chatId }), graphContext), uiMessages: body.messages as UIMessage[], onFinish: ({ messages, isAborted }) => { // Persist even when aborted: the partial transcript (including any diff --git a/tests/graph-context.test.ts b/tests/graph-context.test.ts new file mode 100644 index 0000000..13ac07a --- /dev/null +++ b/tests/graph-context.test.ts @@ -0,0 +1,416 @@ +/** + * Graph-as-context tests (W8; vision §1 "the graph as session context"). + * + * renderGraphContext — pure: empty / small / cap-truncation rendering + * buildGraphContext — per-request entry point (derived-graph → block) + * injection plumbing — chat-flow style: the teacher's instructions carry + * the block (MockLanguageModelV4 captures the prompt) + * exact-match merge — emitChat dedupes case/whitespace-identical titles + * onto existing nodes (no fuzzy matching) + * + * Ledger isolation: NONE needed for the merge tests — emitChat's ledger is + * injectable (in-memory), so the live data/graph files are only ever READ + * here (tests/graph.test.ts owns the backup-and-clear dance; both suites run + * concurrently as separate processes). Chat fixtures are real ChatRecords, + * created and deleted by this suite like tests/chat-flow.test.ts does. + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { after, describe, it } from "node:test"; +import { MockLanguageModelV4, simulateReadableStream } from "ai/test"; +import type { LanguageModelV4StreamPart } from "@ai-sdk/provider"; +import type { GraphEdge, GraphNode, LedgerEvent } from "../src/core/contracts.ts"; +import type { NodeStateClass } from "../src/core/graph.ts"; +import { emitChat, type GraphLedgers } from "../src/core/graph.ts"; +import { + buildGraphContext, + GRAPH_CONTEXT_HEADER, + MAX_CONTEXT_NODES, + renderGraphContext, +} from "../src/core/graph-context.ts"; +import { createTeacherAgent } from "../src/agent/teacher-agent.ts"; +import { handleChatRequest } from "../src/server/chat-handler.ts"; +import { createChat, getChat, saveMessages, saveTree } from "../src/store/chats.ts"; +import { type NodeMapping, type SessionCheckpoint } from "../src/store/graph.ts"; +import { createTree } from "../src/core/plan-tree.ts"; +import { PATHS } from "../config.ts"; + +// ─── fixtures ──────────────────────────────────────────────────────────────── + +function node(id: string, statement: string, over: Partial = {}): GraphNode { + return { id, statement, aliases: [], kind: "truth", createdAt: "2026-08-22T00:00:00.000Z", ...over }; +} + +function edge(from: string, to: string): GraphEdge { + return { from, to, type: "requires", provenance: ["chat_x"] }; +} + +function states(...entries: [string, NodeStateClass][]): Record { + return Object.fromEntries(entries); +} + +// ─── renderGraphContext (pure) ─────────────────────────────────────────────── + +describe("renderGraphContext (pure)", () => { + it("returns null for an empty graph (inject nothing)", () => { + assert.equal(renderGraphContext([], [], {}), null); + }); + + it("renders a header, state-grouped node lines (hotspots first), and chained requires edges", () => { + const nodes = [ + node("n_a", "Chain rule intuition"), + node("n_b", "Quotient rule"), + node("n_c", "Product rule"), + node("n_d", "Composite functions"), + ]; + const edges = [edge("n_a", "n_b"), edge("n_b", "n_c"), edge("n_d", "n_a")]; + const st = states( + ["n_a", "known"], + ["n_b", "missed"], + ["n_c", "candidate"], + ["n_d", "taught"], + ); + const out = renderGraphContext(nodes, edges, st)!; + + assert.ok(out.includes(GRAPH_CONTEXT_HEADER), "header line present"); + assert.match(out, /4 nodes, 3 requires edges/); + + // Node lines: "• [state] statement (id)". + assert.ok(out.includes("• [missed] Quotient rule (n_b)")); + assert.ok(out.includes("• [known] Chain rule intuition (n_a)")); + assert.ok(out.includes("• [taught] Composite functions (n_d)")); + assert.ok(out.includes("• [candidate] Product rule (n_c)")); + + // Groups in the decided order: hotspots, known, taught, candidates. + const order = ["Hotspots", "Known", "Taught", "Candidates"].map((g) => out.indexOf(g)); + assert.deepEqual([...order].sort((a, b) => a - b), order, "groups appear in priority order"); + + // Edges merged into chains: d → a → b → c (one line, three arrows). + assert.ok(out.includes("Composite functions → Chain rule intuition → Quotient rule → Product rule")); + assert.ok(out.includes("Requires (prerequisite → dependent)")); + assert.ok(out.includes("===== end of knowledge graph ====="), "clear end delimiter"); + }); + + it("renders branching edges as sibling chains, not one false chain", () => { + const nodes = [node("n_a", "A"), node("n_b", "B"), node("n_c", "C")]; + const out = renderGraphContext(nodes, [edge("n_a", "n_b"), edge("n_a", "n_c")], states())!; + assert.ok(out.includes("A → B")); + assert.ok(out.includes("A → C")); + assert.ok(!out.includes("B → C"), "no edge was invented between siblings"); + }); + + it("marks misconception-kind nodes as hotspots even when their state is candidate", () => { + const nodes = [node("n_m", "Division by zero is fine", { kind: "misconception" })]; + const out = renderGraphContext(nodes, [], states(["n_m", "candidate"]))!; + assert.match(out, /• \[candidate\] Division by zero is fine \(n_m\) · misconception/); + assert.ok(out.includes("Hotspots"), "misconception nodes join the hotspot group"); + }); + + it("caps at MAX_CONTEXT_NODES: hotspots + their 1-hop requires neighborhood only, with a truncation note", () => { + // 3 hotspots, each with a prerequisite and a dependent (1-hop), plus a + // long tail of disconnected candidates that must drop out. + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const st: Record = {}; + const hotspot = (i: number) => { + const h = `n_h${i}`; + const pre = `n_p${i}`; + const dep = `n_d${i}`; + nodes.push(node(pre, `Prerequisite ${i}`), node(h, `Hotspot ${i}`), node(dep, `Dependent ${i}`)); + edges.push(edge(pre, h), edge(h, dep)); + st[pre] = "known"; + st[h] = "missed"; + st[dep] = "candidate"; + }; + hotspot(1); + hotspot(2); + hotspot(3); + for (let i = 0; i < MAX_CONTEXT_NODES; i++) { + const id = `n_f${i}`; + nodes.push(node(id, `Far away ${i}`)); + st[id] = "candidate"; + } + assert.ok(nodes.length > MAX_CONTEXT_NODES, "fixture exceeds the cap"); + + const out = renderGraphContext(nodes, edges, st)!; + assert.ok(out.includes("• [missed] Hotspot 1 (n_h1)"), "hotspots stay"); + assert.ok(out.includes("• [known] Prerequisite 1 (n_p1)"), "1-hop prerequisites stay"); + assert.ok(out.includes("• [candidate] Dependent 1 (n_d1)"), "1-hop dependents stay"); + assert.ok(!out.includes("Far away"), "disconnected nodes drop out"); + assert.match(out, new RegExp(`showing 9 of ${nodes.length} nodes`), "truncation is noted"); + assert.ok(out.includes(", 6 requires edges"), "edges among the selected nodes stay"); + }); + + it("keeps the full graph at exactly the cap (no truncation note)", () => { + const nodes = Array.from({ length: MAX_CONTEXT_NODES }, (_, i) => node(`n_${i}`, `Node ${i}`)); + const out = renderGraphContext(nodes, [], {})!; + assert.ok(out.includes(`• [candidate] Node ${MAX_CONTEXT_NODES - 1}`)); + assert.ok(!out.includes("showing"), "at the cap nothing is dropped"); + }); + + it("returns null when a too-large graph has no hotspots at all", () => { + const nodes = Array.from({ length: MAX_CONTEXT_NODES + 1 }, (_, i) => node(`n_${i}`, `Node ${i}`)); + assert.equal(renderGraphContext(nodes, [], {}), null); + }); +}); + +// ─── buildGraphContext (the per-request entry point) ──────────────────────── + +describe("buildGraphContext", () => { + it("returns null for an empty derived graph", () => { + assert.equal(buildGraphContext({ nodes: [], edges: [], events: [] }), null); + }); + + it("derives states from events and renders the block", () => { + const nodes = [node("n_a", "Solo node"), node("n_b", "Checked node")]; + const events: LedgerEvent[] = [ + { nodeId: "n_b", kind: "check-correct", chatId: "c1", at: "2026-08-22T00:00:00.000Z", ref: "r1" }, + ]; + const out = buildGraphContext({ nodes, edges: [], events })!; + assert.ok(out.includes(GRAPH_CONTEXT_HEADER)); + assert.ok(out.includes("• [known] Checked node (n_b)"), "check-correct → known"); + assert.ok(out.includes("• [candidate] Solo node (n_a)"), "no events → candidate"); + }); + + it("reads the live ledgers without throwing (read-only integration)", () => { + // Other suites (graph.test.ts) mutate data/graph concurrently, so only + // assert the shape of the result, never its content. Read-only is + // structural: buildGraphContext only ever calls getGraph(). + const out = buildGraphContext(); + assert.ok(out === null || out.includes(GRAPH_CONTEXT_HEADER)); + }); +}); + +// ─── injection plumbing (chat-flow style) ─────────────────────────────────── + +function usage() { + return { + inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 5, text: 5, reasoning: 0 }, + }; +} + +/** A model that records every prompt it is given and replies with plain text. */ +function capturingModel(prompts: string[]) { + return new MockLanguageModelV4({ + doStream: async (options) => { + prompts.push(JSON.stringify(options.prompt)); + const chunks: LanguageModelV4StreamPart[] = [ + { type: "stream-start", warnings: [] }, + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "Where shall we begin?" }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: usage() }, + ]; + return { stream: simulateReadableStream({ chunks, initialDelayInMs: 0, chunkDelayInMs: 0 }) }; + }, + }); +} + +async function drain(res: Response): Promise { + if (res.status !== 200) { + assert.fail(`expected 200, got ${res.status}: ${await res.text()}`); + } + await res.text(); // consumes the SSE stream to completion (fires onFinish) +} + +function post(chatId: string, messages: unknown[]): Request { + return new Request("http://test/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chatId, messages }), + }); +} + +const createdChats: string[] = []; +after(() => { + for (const id of createdChats) { + try { + fs.unlinkSync(path.join(PATHS.chats, `${id}.json`)); + } catch { + /* already gone */ + } + } +}); + +describe("graph-context injection (chat-flow style)", () => { + it("the teacher's instructions carry the block after the composed fragments", async () => { + const graph = { + nodes: [node("n_ctx", "Chain rule intuition"), node("n_ctx2", "Derivative as a limit")], + edges: [edge("n_ctx2", "n_ctx")], + events: [ + { nodeId: "n_ctx2", kind: "check-correct", chatId: "c1", at: "2026-08-22T00:00:00.000Z", ref: "r1" }, + ] as LedgerEvent[], + }; + const context = buildGraphContext(graph); + assert.ok(context, "fixture graph renders a block"); + + // Exactly what chat-handler does per request when no agent is passed: + // createTeacherAgent(model, tools, buildGraphContext()). + const prompts: string[] = []; + const agent = createTeacherAgent(capturingModel(prompts), undefined, context); + const chat = createChat("Test: graph context injection"); + createdChats.push(chat.id); + + await drain( + await handleChatRequest( + post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "teach me calculus" }] }]), + agent, + ), + ); + + assert.equal(prompts.length, 1, "one model call"); + const system = prompts[0]!; + assert.ok(system.includes(GRAPH_CONTEXT_HEADER), "the block reached the model"); + assert.ok(system.includes("Chain rule intuition"), "fixture node reached the model"); + // The block sits AFTER the composed fragments (the phases fragment is + // part of the static instructions), and the fragment teaching the + // teacher to read it is present too. + const fragmentIdx = system.indexOf("The two phases"); + const graphFragIdx = system.indexOf("The learner's knowledge graph"); + const blockIdx = system.indexOf(GRAPH_CONTEXT_HEADER); + assert.ok(fragmentIdx !== -1 && graphFragIdx !== -1, "prompt fragments composed"); + assert.ok(graphFragIdx > fragmentIdx, "50-graph-context.md sorts after 30-phases.md"); + assert.ok(blockIdx > graphFragIdx, "the block is appended after the fragments"); + }); + + it("no block is injected when the context is null (new learner)", async () => { + const prompts: string[] = []; + const agent = createTeacherAgent(capturingModel(prompts)); + const chat = createChat("Test: no graph context"); + createdChats.push(chat.id); + await drain( + await handleChatRequest( + post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] }]), + agent, + ), + ); + const system = prompts[0]!; + assert.ok(system.includes("The learner's knowledge graph"), "the fragment is always composed"); + assert.ok(!system.includes("===== LEARNER KNOWLEDGE GRAPH"), "no auto-generated block"); + }); +}); + +// ─── exact-match merge (emitChat dedup) ───────────────────────────────────── + +/** An in-memory stand-in for the five ledgers (see GraphLedgers in graph.ts). */ +function memoryLedgers() { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const events: LedgerEvent[] = []; + const mappings: NodeMapping[] = []; + const checkpoints: SessionCheckpoint[] = []; + return { + appendNode: (n: GraphNode) => void nodes.push(n), + readNodes: () => nodes.map((n) => ({ ...n })), + appendEdge: (e: GraphEdge) => void edges.push(e), + readEdges: () => edges.map((e) => ({ ...e })), + appendEvent: (e: LedgerEvent) => void events.push(e), + readEvents: () => events.map((e) => ({ ...e })), + appendMapping: (m: NodeMapping) => void mappings.push(m), + readMappings: () => mappings.map((m) => ({ ...m })), + appendCheckpoint: (c: SessionCheckpoint) => void checkpoints.push(c), + readCheckpoints: () => checkpoints.map((c) => ({ ...c })), + // Test accessors (not part of the GraphLedgers surface). + counts: { get nodes() { return nodes.length; }, get mappings() { return mappings.length; } }, + } satisfies GraphLedgers & { counts: { nodes: number; mappings: number } }; +} + +/** A real chat record with a stored tree (emitChat reads the real chat store). */ +function fixtureChat(title: string, tree: ReturnType) { + const chat = createChat(title); + createdChats.push(chat.id); + saveTree(chat.id, tree); + saveMessages(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "go" }] }]); + return getChat(chat.id)!; +} + +describe("exact-match merge (emitChat)", () => { + it("maps a case/whitespace-identical title onto the existing node instead of duplicating", () => { + const ledgers = memoryLedgers(); + const chat1 = fixtureChat("Merge A", createTree([{ id: "cr", title: "Chain rule intuition" }], [])); + let stats = emitChat(chat1.id, ledgers); + assert.deepEqual(stats, { nodes: 1, edges: 0, events: 0 }); + const firstId = ledgers.readNodes()[0]!.id; + + // Second session, same proposition: different casing + padding. + const chat2 = fixtureChat("Merge B", createTree([{ id: "cr2", title: " chain Rule INTUITION " }], [])); + stats = emitChat(chat2.id, ledgers); + assert.deepEqual(stats, { nodes: 0, edges: 0, events: 0 }, "no duplicate node appended"); + + assert.equal(ledgers.counts.nodes, 1, "still exactly one graph node"); + assert.equal(ledgers.readNodes()[0]!.id, firstId); + // Provenance stays honest: one mapping line per (chat, treeNodeId). + assert.equal(ledgers.counts.mappings, 2); + assert.deepEqual(ledgers.readMappings()[1], { chatId: chat2.id, treeNodeId: "cr2", graphNodeId: firstId }); + }); + + it("matches aliases too (exact, case-insensitive)", () => { + const ledgers = memoryLedgers(); + ledgers.appendNode({ + id: "n_alias", + statement: "Differentiation basics", + aliases: ["the derivative"], + kind: "truth", + createdAt: "2026-08-22T00:00:00.000Z", + }); + const chat = fixtureChat("Merge alias", createTree([{ id: "d", title: "The Derivative" }], [])); + const stats = emitChat(chat.id, ledgers); + assert.deepEqual(stats, { nodes: 0, edges: 0, events: 0 }, "alias match → no new node"); + assert.deepEqual(ledgers.readMappings(), [{ chatId: chat.id, treeNodeId: "d", graphNodeId: "n_alias" }]); + }); + + it("does NOT fuzzy-match near-identical titles (that stays deferred)", () => { + const ledgers = memoryLedgers(); + const chat1 = fixtureChat("Fuzzy A", createTree([{ id: "cr", title: "Chain rule intuition" }], [])); + emitChat(chat1.id, ledgers); + const chat2 = fixtureChat( + "Fuzzy B", + createTree([{ id: "cr2", title: "Chain rules intuition" }], []), + ); + const stats = emitChat(chat2.id, ledgers); + assert.equal(stats.nodes, 1, "near-miss titles stay separate nodes"); + assert.equal(ledgers.counts.nodes, 2); + }); + + it("merged nodes unify the second chat's edges onto the existing graph node", () => { + const ledgers = memoryLedgers(); + const chat1 = fixtureChat( + "Edge A", + createTree( + [ + { id: "base", title: "Derivative as a limit" }, + { id: "chain", title: "Chain rule intuition" }, + ], + [{ from: "base", to: "chain" }], + ), + ); + emitChat(chat1.id, ledgers); + + // Second session re-teaches the same two propositions (exact titles) + // and asserts the same edge — it must dedupe onto the existing nodes. + const chat2 = fixtureChat( + "Edge B", + createTree( + [ + { id: "base", title: "derivative as a limit" }, + { id: "chain", title: "chain rule intuition" }, + ], + [{ from: "base", to: "chain" }], + ), + ); + const stats = emitChat(chat2.id, ledgers); + // A new ASSERTION line is appended (provenance is honest) — reads merge + // by (from,to,type), so the derived graph still has one edge with both + // sessions in its provenance. + assert.deepEqual(stats, { nodes: 0, edges: 1, events: 0 }, "no new nodes, one re-assertion line"); + assert.equal(ledgers.counts.nodes, 2); + const lines = ledgers.readEdges(); + assert.equal(lines.length, 2, "two assertion lines for the same edge"); + assert.equal(lines[0]!.from, lines[1]!.from); + assert.equal(lines[0]!.to, lines[1]!.to); + assert.deepEqual(lines.map((l) => l.provenance), [[chat1.id], [chat2.id]]); + }); +});