W8: graph-as-context injection + exact-match merge
This commit is contained in:
@@ -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.
|
||||||
@@ -12,6 +12,10 @@ import { createTeacherTools } from "./tools.ts";
|
|||||||
* The model is injectable so tests can pass a MockLanguageModelV4 and drive
|
* The model is injectable so tests can pass a MockLanguageModelV4 and drive
|
||||||
* deterministic scripted lessons (see tests/chat-flow.test.ts).
|
* 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
|
* The system prompt is COMPOSED: system-prompt.md is the base, then every
|
||||||
* src/agent/prompts/*.md fragment in sorted filename order (workers add
|
* src/agent/prompts/*.md fragment in sorted filename order (workers add
|
||||||
* fragments — nobody edits shared sections of the base prompt). Prefix
|
* fragments — nobody edits shared sections of the base prompt). Prefix
|
||||||
@@ -36,7 +40,19 @@ function composeInstructions(): string {
|
|||||||
|
|
||||||
const instructions = composeInstructions();
|
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({
|
return new ToolLoopAgent({
|
||||||
model:
|
model:
|
||||||
model ??
|
model ??
|
||||||
@@ -45,7 +61,7 @@ export function createTeacherAgent(model?: LanguageModel, tools?: ToolSet) {
|
|||||||
// they stream as reasoning parts into the UI's collapsible block.
|
// they stream as reasoning parts into the UI's collapsible block.
|
||||||
reasoning: { enabled: true, effort: "medium" },
|
reasoning: { enabled: true, effort: "medium" },
|
||||||
}),
|
}),
|
||||||
instructions,
|
instructions: withGraphContext(graphContext),
|
||||||
// Tools are per-request (plan tools close over chatId); chat-handler
|
// Tools are per-request (plan tools close over chatId); chat-handler
|
||||||
// passes createTeacherTools({ chatId }). Tests pass a mock model and get
|
// passes createTeacherTools({ chatId }). Tests pass a mock model and get
|
||||||
// the default question-only set.
|
// the default question-only set.
|
||||||
|
|||||||
@@ -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<NodeStateClass> = 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, NodeStateClass>,
|
||||||
|
): 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<GraphEdge>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
+47
-12
@@ -4,7 +4,10 @@
|
|||||||
*
|
*
|
||||||
* emitChat(chatId) folds ONE chat into the global graph — naive union,
|
* emitChat(chatId) folds ONE chat into the global graph — naive union,
|
||||||
* deterministic, no LLM:
|
* 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)
|
* plan-tree edges → `requires` GraphEdges (from = prerequisite)
|
||||||
* answered quizzes → ledger events (probe-* before the first plan_init
|
* answered quizzes → ledger events (probe-* before the first plan_init
|
||||||
* output, check / dont-know after; ref = toolCallId)
|
* 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 { getChat, listChats } from "../store/chats.ts";
|
||||||
import { graphLedgers, newGraphNodeId } from "../store/graph.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 ─────────────────────────────────────────────────────
|
// ─── transcript scanning ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** A flattened, order-preserving view of every tool part in a transcript. */
|
/** 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
|
* 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
|
* 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.
|
* 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);
|
const record = getChat(chatId);
|
||||||
if (!record) return { nodes: 0, edges: 0, events: 0 };
|
if (!record) return { nodes: 0, edges: 0, events: 0 };
|
||||||
const stats: EmissionStats = { 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
|
// 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).
|
// naive union: two sessions' tree nodes never merge into one graph node).
|
||||||
const mapping = new Map<string, string>();
|
const mapping = new Map<string, string>();
|
||||||
for (const m of graphLedgers.readMappings()) {
|
for (const m of ledgers.readMappings()) {
|
||||||
if (m.chatId === chatId) mapping.set(m.treeNodeId, m.graphNodeId);
|
if (m.chatId === chatId) mapping.set(m.treeNodeId, m.graphNodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,10 +123,33 @@ export function emitChat(chatId: string): EmissionStats {
|
|||||||
const tree =
|
const tree =
|
||||||
record.tree && record.tree.nodes.length > 0 ? record.tree : replayFinalTree(record.messages);
|
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<string, string>();
|
||||||
|
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();
|
const now = new Date().toISOString();
|
||||||
for (const node of tree.nodes) {
|
for (const node of tree.nodes) {
|
||||||
if (mapping.has(node.id)) continue;
|
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 = {
|
const graphNode: GraphNode = {
|
||||||
id: newGraphNodeId(),
|
id: newGraphNodeId(),
|
||||||
// statement is the title; the summary stays out (contracts).
|
// statement is the title; the summary stays out (contracts).
|
||||||
@@ -124,8 +158,9 @@ export function emitChat(chatId: string): EmissionStats {
|
|||||||
kind: "truth",
|
kind: "truth",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
graphLedgers.appendNode(graphNode);
|
ledgers.appendNode(graphNode);
|
||||||
graphLedgers.appendMapping({ chatId, treeNodeId: node.id, graphNodeId: graphNode.id });
|
ledgers.appendMapping({ chatId, treeNodeId: node.id, graphNodeId: graphNode.id });
|
||||||
|
existingByTitle.set(titleKey, graphNode.id);
|
||||||
mapping.set(node.id, graphNode.id);
|
mapping.set(node.id, graphNode.id);
|
||||||
stats.nodes++;
|
stats.nodes++;
|
||||||
}
|
}
|
||||||
@@ -134,7 +169,7 @@ export function emitChat(chatId: string): EmissionStats {
|
|||||||
// whole ledger: an existing assertion just gains this chatId as
|
// whole ledger: an existing assertion just gains this chatId as
|
||||||
// provenance (appended as a new line; reads merge).
|
// provenance (appended as a new line; reads merge).
|
||||||
const asserted = new Map<string, Set<string>>();
|
const asserted = new Map<string, Set<string>>();
|
||||||
for (const line of graphLedgers.readEdges()) {
|
for (const line of ledgers.readEdges()) {
|
||||||
const key = `${line.from}\u0000${line.to}\u0000${line.type}`;
|
const key = `${line.from}\u0000${line.to}\u0000${line.type}`;
|
||||||
let provenance = asserted.get(key);
|
let provenance = asserted.get(key);
|
||||||
if (!provenance) {
|
if (!provenance) {
|
||||||
@@ -150,7 +185,7 @@ export function emitChat(chatId: string): EmissionStats {
|
|||||||
const key = `${from}\u0000${to}\u0000requires`;
|
const key = `${from}\u0000${to}\u0000requires`;
|
||||||
const provenance = asserted.get(key);
|
const provenance = asserted.get(key);
|
||||||
if (provenance?.has(chatId)) continue; // already asserted by this chat
|
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);
|
if (provenance) provenance.add(chatId);
|
||||||
else asserted.set(key, new Set([chatId]));
|
else asserted.set(key, new Set([chatId]));
|
||||||
stats.edges++;
|
stats.edges++;
|
||||||
@@ -161,7 +196,7 @@ export function emitChat(chatId: string): EmissionStats {
|
|||||||
// chat-scoped (a quiz toolCallId or taught:<treeNodeId>) — a ref already
|
// chat-scoped (a quiz toolCallId or taught:<treeNodeId>) — a ref already
|
||||||
// in the ledger means this turn was folded before.
|
// in the ledger means this turn was folded before.
|
||||||
const seenRefs = new Set<string>();
|
const seenRefs = new Set<string>();
|
||||||
for (const event of graphLedgers.readEvents()) {
|
for (const event of ledgers.readEvents()) {
|
||||||
if (event.chatId === chatId && event.ref) seenRefs.add(event.ref);
|
if (event.chatId === chatId && event.ref) seenRefs.add(event.ref);
|
||||||
}
|
}
|
||||||
let planSeen = false;
|
let planSeen = false;
|
||||||
@@ -173,7 +208,7 @@ export function emitChat(chatId: string): EmissionStats {
|
|||||||
if (part.name === "quiz" && part.state === "output-available") {
|
if (part.name === "quiz" && part.state === "output-available") {
|
||||||
const event = quizEvent(part, chatId, mapping, planSeen, now);
|
const event = quizEvent(part, chatId, mapping, planSeen, now);
|
||||||
if (event?.ref !== undefined && !seenRefs.has(event.ref)) {
|
if (event?.ref !== undefined && !seenRefs.has(event.ref)) {
|
||||||
graphLedgers.appendEvent(event);
|
ledgers.appendEvent(event);
|
||||||
seenRefs.add(event.ref);
|
seenRefs.add(event.ref);
|
||||||
stats.events++;
|
stats.events++;
|
||||||
}
|
}
|
||||||
@@ -185,7 +220,7 @@ export function emitChat(chatId: string): EmissionStats {
|
|||||||
if (result?.ok && typeof input?.id === "string") {
|
if (result?.ok && typeof input?.id === "string") {
|
||||||
const ref = `taught:${input.id}`;
|
const ref = `taught:${input.id}`;
|
||||||
if (!seenRefs.has(ref)) {
|
if (!seenRefs.has(ref)) {
|
||||||
graphLedgers.appendEvent({
|
ledgers.appendEvent({
|
||||||
nodeId: mapping.get(input.id) ?? null,
|
nodeId: mapping.get(input.id) ?? null,
|
||||||
kind: "taught",
|
kind: "taught",
|
||||||
chatId,
|
chatId,
|
||||||
@@ -200,7 +235,7 @@ export function emitChat(chatId: string): EmissionStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6. Checkpoint: this chat is folded through its current updatedAt.
|
// 6. Checkpoint: this chat is folded through its current updatedAt.
|
||||||
graphLedgers.appendCheckpoint({ chatId, foldedAt: record.updatedAt });
|
ledgers.appendCheckpoint({ chatId, foldedAt: record.updatedAt });
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createTeacherAgent, type TeacherAgent } from "../agent/teacher-agent.ts
|
|||||||
import { createTeacherTools } from "../agent/tools.ts";
|
import { createTeacherTools } from "../agent/tools.ts";
|
||||||
import { spawnPendingJobs } from "../agent/subagents/runner.ts";
|
import { spawnPendingJobs } from "../agent/subagents/runner.ts";
|
||||||
import { emitChat } from "../core/graph.ts";
|
import { emitChat } from "../core/graph.ts";
|
||||||
|
import { buildGraphContext } from "../core/graph-context.ts";
|
||||||
import { getChat, saveMessages } from "../store/chats.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 });
|
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({
|
return createAgentUIStreamResponse({
|
||||||
agent: agent ?? createTeacherAgent(undefined, createTeacherTools({ chatId })),
|
agent: agent ?? createTeacherAgent(undefined, createTeacherTools({ chatId }), graphContext),
|
||||||
uiMessages: body.messages as UIMessage[],
|
uiMessages: body.messages as UIMessage[],
|
||||||
onFinish: ({ messages, isAborted }) => {
|
onFinish: ({ messages, isAborted }) => {
|
||||||
// Persist even when aborted: the partial transcript (including any
|
// Persist even when aborted: the partial transcript (including any
|
||||||
|
|||||||
@@ -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> = {}): 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<string, NodeStateClass> {
|
||||||
|
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<string, NodeStateClass> = {};
|
||||||
|
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<void> {
|
||||||
|
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<typeof createTree>) {
|
||||||
|
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]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user