Author-once plan protocol: single mermaid plan_init + derived statuses + live quiz nodeId validation

- plan_init is now the ONLY plan tool: a mermaid 'graph TD' block parsed by a
  constrained-subset parser (mermaid-plan.ts) with line-precise errors; the
  seven mutation/tracking tools are gone by design (2026-09-03 design pass).
- Re-call plan_init = sanctioned wholesale rewrite; slugs are permanent.
- derivePlanStatuses (contracts/plan.ts): done = passed check tagged with a
  quiz nodeId; current = open check's node else first topo-ready node — the
  tracking the teacher no longer declares by hand.
- questionToolsFor(ctx) (tools.ts): the quiz schema chains a dynamic plan-
  nodeId validation against the LIVE record — an invalid nodeId is rejected
  at the call boundary and the teacher retries mid-turn, before any UI.
  Static questionTools stay validator-less for the default path.
- Prompts rewritten for mermaid authoring + derived position.
- Out-of-lane surgical touches (flagged for master review):
  src/core/question-core.ts (quizInputObject export + nodeId description),
  src/core/contracts/plan.ts (derivePlanStatuses + doc).
This commit is contained in:
amosblomqvist
2026-09-09 03:04:16 +03:00
parent fce477dd24
commit 67fbc0575f
10 changed files with 691 additions and 297 deletions
+132
View File
@@ -0,0 +1,132 @@
/**
* Mermaid → PlanTree authoring format (the author-once protocol, 2026-09-03).
*
* The teacher writes the session map as a mermaid `graph TD` block — the
* format models are most fluent in — and plan_init canonicalizes it into the
* structured PlanTree. A CONSTRAINED SUBSET, parsed by hand: deterministic,
* dependency-free, precise line-numbered errors the teacher can act on (the
* same validate-and-retry loop as the question schemas). Not the mermaid
* runtime — the server never renders, it only needs the graph.
*
* Accepted syntax (anything else → an error naming the line):
* graph TD | graph TB | flowchart TD | flowchart TB (case-insensitive)
* %% comment lines
* node — bare id (title falls back to the id)
* id[title] — square-bracket label
* id("title") — paren label
* a --> b — requires edge (from = prerequisite); bare endpoints
* register as nodes; forward references are fine
* a[title] --> b[title]
*
* Node ids are permanent slugs: ^[a-z0-9]+(-[a-z0-9]+)*$ (checks reference
* them via the quiz nodeId). Labels are single-line titles.
*/
import type { PlanEdge, PlanNode } from "@/src/core";
export interface ParsedPlan {
nodes: PlanNode[];
edges: PlanEdge[];
}
export interface PlanParseResult {
ok: boolean;
/** All problems, each prefixed with its 1-based line number. */
problems: string[];
plan?: ParsedPlan;
}
const HEADER_RE = /^(graph|flowchart)\s+(td|tb)\s*$/i;
const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
/** One edge endpoint: `id`, `id[title]`, or `id("title")`. */
// Case-tolerant on the id: register() then enforces the lowercase slug convention
// with a SPECIFIC message (an "UPPER" id reaches the slug check, not a generic
// parse error — the most common authoring mistake).
const ENDPOINT_RE = /^([A-Za-z0-9][A-Za-z0-9-]*?)\s*(?:\[([^\[\]]*)\]|\(\s*"([^"]*)"\s*\)|\(([^()]*)\))?$/;
interface Endpoint {
id: string;
label?: string;
}
function parseEndpoint(text: string): Endpoint | null {
const m = text.trim().match(ENDPOINT_RE);
if (!m || !m[1]) return null;
return { id: m[1], label: (m[2] ?? m[3] ?? m[4] ?? "").trim() };
}
/** Parse the mermaid subset. Collects ALL problems (never just the first). */
export function parseMermaidPlan(mermaid: string): PlanParseResult {
const problems: string[] = [];
const lines = mermaid.split(/\r?\n/);
// Header: required, first non-empty non-comment line.
const first = lines.findIndex((l) => l.trim().length > 0 && !l.trim().startsWith("%%"));
if (first === -1) {
return { ok: false, problems: ["The plan is empty — expected a `graph TD` block with nodes and `-->` requires-edges."] };
}
const header = lines[first]?.trim() ?? "";
if (!HEADER_RE.test(header)) {
problems.push(`line ${first + 1}: expected the header \`graph TD\` (or flowchart TD/TB) first, got \`${header}\`.`);
}
const nodes = new Map<string, string>(); // id → title (first label wins)
const edges: PlanEdge[] = [];
const seenEdges = new Set<string>();
const register = ({ id, label }: Endpoint, lineNo: number): void => {
if (!SLUG_RE.test(id)) {
problems.push(
`line ${lineNo}: node id \`${id}\` must be a lowercase slug (a-z, 0-9, hyphens — e.g. \`chain-rule-intuition\`); ids are permanent and checks reference them.`,
);
return;
}
if (!nodes.has(id)) nodes.set(id, label && label.length > 0 ? label : id);
else if (label && label.length > 0 && nodes.get(id) === id) nodes.set(id, label);
};
for (let i = first + 1; i < lines.length; i++) {
const line = lines[i]?.trim() ?? "";
const no = i + 1;
if (line.length === 0 || line.startsWith("%%")) continue;
const arrows = line.split("-->");
if (arrows.length === 2) {
const from = parseEndpoint(arrows[0] ?? "");
const to = parseEndpoint(arrows[1] ?? "");
if (!from || !to) {
problems.push(`line ${no}: can't read the edge \`${line}\` — expected \`a[title] --> b[title]\`.`);
continue;
}
register(from, no);
register(to, no);
if (from.id === to.id) {
problems.push(`line ${no}: self-loop \`${from.id} --> ${to.id}\` — a node can't require itself.`);
continue;
}
const key = `${from.id}->${to.id}`;
if (!seenEdges.has(key)) {
seenEdges.add(key);
edges.push({ from: from.id, to: to.id });
}
} else if (arrows.length > 2) {
problems.push(`line ${no}: chained edges \`${line}\` — write one edge per line (the plan is a map, not a sentence).`);
} else {
const node = parseEndpoint(line);
if (!node) {
problems.push(
`line ${no}: can't parse \`${line}\` — the plan accepts node lines (\`slug[title]\` — ids are lowercase slugs: a-z, 0-9, hyphens) and requires-edges (\`a --> b\`) only; subgraphs, styles, classes, and other mermaid features are not part of the subset.`,
);
continue;
}
register(node, no);
}
}
const plan: ParsedPlan = {
nodes: [...nodes.entries()].map(([id, title]) => ({ id, title })),
edges,
};
if (problems.length > 0) return { ok: false, problems };
if (plan.nodes.length === 0) return { ok: false, problems: ["No nodes found — the plan needs at least one."] };
return { ok: true, problems: [], plan };
}
+53 -158
View File
@@ -2,18 +2,8 @@ import { tool, type ToolSet } from "ai";
import { z } from "zod";
import type { PlanToolResult, PlanTree } from "../../core/contracts.ts";
import type { ChatStore } from "../../core/ports.ts";
import {
addEdge,
addNode,
createTree,
emptyTree,
markDone,
PlanTreeError,
removeEdge,
removeNode,
setCurrent,
updateNode,
} from "../../core/plan-tree.ts";
import { createTree, emptyTree, PlanTreeError } from "../../core/plan-tree.ts";
import { parseMermaidPlan } from "./mermaid-plan.ts";
/** What the plan tools close over (the plan-tree half of the tool context). */
export interface PlanToolContext {
@@ -23,161 +13,66 @@ export interface PlanToolContext {
}
/**
* Plan-tree tools — W1 workstream (docs/plan.md Wave 1; src/core/contracts.ts
* "Plan tree"). SERVER-SIDE tools WITH execute: read the chat record, apply a
* pure validated op from src/core/plan-tree.ts, persist via saveTree, and
* return PlanToolResult — the FULL updated tree every time, so the UI tree
* panel renders the latest state straight from the transcript's tool outputs.
* The plan tool — AUTHOR-ONCE protocol (2026-09-03 design pass): the teacher
* WRITES the map and never tracks progress. One tool, plan_init, takes the
* whole dependency map as a mermaid `graph TD` block:
*
* Edges are `requires` edges: from = prerequisite, to = dependent
* ("from must be understood before to").
* - call it ONCE at the probe→teach seam (the call fires the phase divider)
* - statuses (done / current / upcoming) are DERIVED from check outcomes
* (contracts.ts derivePlanStatuses) — the teacher never declares them
* - re-call it any time the map is wrong: the new block replaces the tree
* wholesale. Node slugs are permanent — reused ids keep their derived
* done status, so a mid-lesson rewrite never resets honest progress
* - checks name the node they assess (quiz nodeId, validated against the
* live tree at the call boundary) — that tag is the only thing statuses
* derive from
*
* The former mutation tools (add/update/remove node/edge, set_current,
* mark_done) are gone by design: restructuring is a rewrite, tracking is
* derived. SERVER-SIDE with execute: parse → validate → persist → return
* the FULL canonicalized tree (PlanToolResult), so the tree panel renders the
* latest state straight from the transcript's tool outputs.
*/
const nodeId = z
.string()
.min(1)
.describe("Stable node slug, e.g. 'chain-rule-intuition'. Ids are permanent — never reuse or rename one mid-session.");
const nodeSchema = z.object({
id: nodeId.describe("Stable node slug, e.g. 'chain-rule-intuition'. Ids are permanent — never reuse or rename one mid-session."),
title: z.string().min(1).describe("Short label for the tree panel — at most ~6 words."),
summary: z.string().optional().describe("1-2 sentences: what this node covers."),
});
const edgeSchema = z.object({
from: nodeId.describe("Prerequisite node id (must be understood before `to`)."),
to: nodeId.describe("Node id that depends on `from`."),
});
/**
* Shared execute wrapper: load record → apply pure op → persist → PlanToolResult.
* PlanTreeError and missing chats become {ok:false} with the current (or
* empty) tree, so the model can see what it's working with and fix the call.
*/
async function runPlanOp(
ctx: PlanToolContext,
op: (tree: PlanTree) => PlanTree,
): Promise<PlanToolResult> {
const { chatId, chatStore } = ctx;
const record = chatStore.getChat(chatId);
if (!record) return { ok: false, error: `Chat not found: ${chatId}`, tree: emptyTree() };
const current = record.tree ?? emptyTree();
try {
const next = op(current);
chatStore.saveTree(chatId, next);
return { ok: true, tree: next };
} catch (err) {
if (err instanceof PlanTreeError) return { ok: false, error: err.message, tree: current };
throw err;
}
}
export function planTools(ctx: PlanToolContext): ToolSet {
return {
plan_init: tool({
description:
"Initialize this session's plan tree — the dependency map of the lesson, which the learner sees live in a side panel. " +
"Call ONCE, after probing, before teaching begins: pass the full initial node set and the `requires` edges between them " +
"(edge direction: from = prerequisite, to = dependent — from must be understood before to). The tree must be a DAG (no cycles). " +
"Refuses to run if a tree already exists — after that, use the other plan_* tools to evolve it.",
"Write or REWRITE this session's plan map — the dependency graph the learner sees live in a side panel. " +
"Call once after probing, before teaching begins; re-call whenever the map is wrong (the new block replaces the old tree). " +
"A mermaid `graph TD` block: one node per line (`slug[Short Title]` — slugs are permanent lowercase ids), `-->` edges meaning " +
"'from must be understood before to'. 5–15 nodes: a map, not a syllabus — one reasoning step per node, compress. " +
"Every check you fire names the node it assesses via `nodeId`; where the lesson stands derives from those outcomes.",
inputSchema: z.object({
nodes: z.array(nodeSchema).min(1).describe("The initial nodes. A map, not a syllabus: one reasoning step per node."),
edges: z
.array(edgeSchema)
.optional()
.describe("Initial `requires` edges between the nodes. Edge direction: from = prerequisite, to = dependent."),
mermaid: z
.string()
.min(1)
.describe(
"A mermaid `graph TD` block, e.g.:\ngraph TD\n primitives[Number primitives] --> operators[Arithmetic operators]\n operators --> expressions[Evaluating expressions]\n" +
"Node lines: `slug[Title]`. Edge lines: `slug --> slug` (from = prerequisite). No subgraphs, styles, classes, or other mermaid features.",
),
}),
execute: async ({ nodes, edges }): Promise<PlanToolResult> => {
const record = ctx.chatStore.getChat(ctx.chatId);
if (!record) return { ok: false, error: `Chat not found: ${ctx.chatId}`, tree: emptyTree() };
const existing = record.tree;
if (existing && existing.nodes.length > 0) {
return {
ok: false,
error: "Tree already initialized — use the mutation tools",
tree: existing,
};
execute: async ({ mermaid }): Promise<PlanToolResult> => {
const { chatId, chatStore } = ctx;
const record = chatStore.getChat(chatId);
if (!record) return { ok: false, error: `Chat not found: ${chatId}`, tree: emptyTree() };
const current = record.tree ?? emptyTree();
const parsed = parseMermaidPlan(mermaid);
if (!parsed.ok || !parsed.plan) {
// The parser collects every problem; the teacher fixes and retries.
return { ok: false, error: parsed.problems.join("\n"), tree: current };
}
try {
// createTree validates the structure (duplicate ids, missing
// edge endpoints, self-loops, cycles → PlanTreeError).
const next = createTree(parsed.plan.nodes, parsed.plan.edges);
chatStore.saveTree(chatId, next);
return { ok: true, tree: next };
} catch (err) {
if (err instanceof PlanTreeError) return { ok: false, error: err.message, tree: current };
throw err;
}
return runPlanOp(ctx, () => createTree(nodes, edges ?? []));
},
}),
plan_add_node: tool({
description:
"Add one node to the plan tree. Optionally pass `parents`: node ids this new node depends on (a `requires` edge parent → this node is " +
"created for each; a parent must be understood before this node). Parents must already exist; the result must stay a DAG.",
inputSchema: z.object({
id: nodeId,
title: z.string().min(1).describe("Short label for the tree panel — at most ~6 words."),
summary: z.string().optional().describe("1-2 sentences: what this node covers."),
parents: z
.array(nodeId)
.optional()
.describe("Ids of prerequisite nodes — each gets a `requires` edge pointing to this node. All must already exist."),
}),
execute: async ({ id, title, summary, parents }): Promise<PlanToolResult> =>
runPlanOp(ctx, (tree) => addNode(tree, { id, title, ...(summary !== undefined ? { summary } : {}) }, parents)),
}),
plan_update_node: tool({
description:
"Update a node's `title` and/or `summary`. The node must exist; the id never changes. Use this to sharpen a vague node, not to change its meaning.",
inputSchema: z
.object({
id: nodeId,
title: z.string().min(1).optional().describe("New short label (at most ~6 words)."),
summary: z.string().optional().describe("New 1-2 sentence summary."),
})
.refine((v) => v.title !== undefined || v.summary !== undefined, {
message: "Pass at least one of `title` or `summary` — an update with neither changes nothing.",
}),
execute: async ({ id, title, summary }): Promise<PlanToolResult> =>
runPlanOp(ctx, (tree) => updateNode(tree, id, { ...(title !== undefined ? { title } : {}), ...(summary !== undefined ? { summary } : {}) })),
}),
plan_remove_node: tool({
description:
"Remove a node from the plan tree — prune what proves unnecessary. Cascades: every edge touching the node (both directions), its current " +
"status, and its done status are removed with it.",
inputSchema: z.object({ id: nodeId }),
execute: async ({ id }): Promise<PlanToolResult> => runPlanOp(ctx, (tree) => removeNode(tree, id)),
}),
plan_add_edge: tool({
description:
"Add a `requires` edge to the plan tree. Direction: from = prerequisite, to = dependent — `from` must be understood before `to`. " +
"Both nodes must exist; self-loops, duplicate edges, and anything that would create a cycle are rejected.",
inputSchema: z.object({
from: nodeId.describe("Prerequisite node id (must be understood before `to`)."),
to: nodeId.describe("Dependent node id (requires `from`)."),
}),
execute: async ({ from, to }): Promise<PlanToolResult> => runPlanOp(ctx, (tree) => addEdge(tree, from, to)),
}),
plan_remove_edge: tool({
description:
"Remove an existing `requires` edge from the plan tree (the nodes stay). Use when a dependency turns out to be wrong or unnecessary.",
inputSchema: z.object({
from: nodeId.describe("Prerequisite end of the edge to remove."),
to: nodeId.describe("Dependent end of the edge to remove."),
}),
execute: async ({ from, to }): Promise<PlanToolResult> => runPlanOp(ctx, (tree) => removeEdge(tree, from, to)),
}),
plan_set_current: tool({
description:
"Set the CURRENT node — where the lesson is right now. Call it the moment you START teaching a node, so the learner sees their position " +
"on the map. The node must exist. This marks position only; it does not mark anything done.",
inputSchema: z.object({ id: nodeId.describe("The node you are starting to teach now.") }),
execute: async ({ id }): Promise<PlanToolResult> => runPlanOp(ctx, (tree) => setCurrent(tree, id)),
}),
plan_mark_done: tool({
description:
"Mark a node DONE — and only when the learner has DEMONSTRATED it (a correct check), not when you finished explaining it. " +
"The node must exist. Idempotent.",
inputSchema: z.object({ id: nodeId.describe("The node the learner just demonstrated.") }),
execute: async ({ id }): Promise<PlanToolResult> => runPlanOp(ctx, (tree) => markDone(tree, id)),
}),
};
}
}