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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user