W8: graph-as-context injection + exact-match merge

This commit is contained in:
Amos
2026-08-22 11:41:50 +03:00
parent 53be8a2834
commit 806e1405d5
6 changed files with 687 additions and 15 deletions
+21
View File
@@ -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.
+18 -2
View File
@@ -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.
+171
View File
@@ -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
View File
@@ -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<string, string>();
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<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();
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<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}`;
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:<treeNodeId>) — a ref already
// in the ledger means this turn was folded before.
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);
}
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;
}
+14 -1
View File
@@ -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