practice: the learner's creation note — optional free-form input carried to partitioner + generators (owner, 2026-09-29)

- dialog: optional 'Anything else?' textarea (typing-only, 2000-char mirror)
- API: body gains note? — trimmed, whitespace-only omitted, hard 400 over 2000
- pipeline: note persisted on the set and threaded to both agent stages
- agents: THE LEARNER'S NOTE prompt block (present only when set) + the
  weighed-never-owed stance pinned in both doctrines
- fix en route: doubled THE PARAMETERS heading in both prompts since slice 3
  (renderParams owns the heading; the literal blocks removed)
This commit is contained in:
amosblomqvist
2026-09-29 22:16:28 +03:00
parent cf0ec4a2f0
commit 06032769d2
15 changed files with 593 additions and 266 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ data/ chats/ · graph/ (six ledgers + positions/source
**The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees only the session, rendered as readable markdown by the shared `core/session-markdown.ts` engine — propositions as true statements, `follows`/`supports` edges with verbatim evidence; fully trusted, graph-blind) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools, stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create — and may draw bridging edges between existing nodes and the new batch) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes edges, checkpoints coverage) → layout settle. No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 4096, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read; the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection.
**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server.
+3 -2
View File
@@ -1,8 +1,9 @@
/**
* POST /api/practice/sets — create a problem set (202 {id}; generation
* runs in the background — the fire-and-forget pipeline). Body:
* {chatId, params} — everything this session touched
* {nodeIds, params} — a manual node selection
* {nodeIds, params, title?, note?} — a node selection, the cluster's
* label, and the learner's optional
* free-form note
* Validation + typed-error mapping live in the service (server/practice).
* GET — the set list (createdAt desc, the store's order).
*/
+333 -251
View File
@@ -33,282 +33,364 @@ import type { GraphClusterGroup, PracticeParams } from "@/src/core";
/** The creation defaults (owner, 2026-09-29): rung 2 Direct exercise,
* cue 1 Adjacent context, Theory. */
export const DEFAULT_PRACTICE_PARAMS: PracticeParams = {
ladderRung: 2,
cueDistance: 1,
presentation: "theory",
ladderRung: 2,
cueDistance: 1,
presentation: "theory",
};
export interface PracticeFailure {
status: number;
error: string;
code?: string;
status: number;
error: string;
code?: string;
}
/** The dialog's pure body — exported for SSR pins (the delete-session /
* settings body pattern). The wrapper owns state + fetch + portal. */
export function PracticeDialogBody({
params,
onParam,
clusters,
selectedKey,
onSelect,
busy,
failure,
onOpenSettings,
onClose,
onRun,
params,
onParam,
note,
onNote,
clusters,
selectedKey,
onSelect,
busy,
failure,
onOpenSettings,
onClose,
onRun,
}: {
params: PracticeParams;
onParam: (patch: Partial<PracticeParams>) => void;
clusters: GraphClusterGroup[];
selectedKey: string | null;
onSelect: (key: string) => void;
busy: boolean;
failure: PracticeFailure | null;
onOpenSettings: () => void;
onClose: () => void;
onRun: () => void;
params: PracticeParams;
onParam: (patch: Partial<PracticeParams>) => void;
/** The learner's free-form note (owner, 2026-09-29) — optional,
* typing-only; carried to the partitioner + generators as additional
* input they weigh but need not follow. */
note: string;
onNote: (note: string) => void;
clusters: GraphClusterGroup[];
selectedKey: string | null;
onSelect: (key: string) => void;
busy: boolean;
failure: PracticeFailure | null;
onOpenSettings: () => void;
onClose: () => void;
onRun: () => void;
}) {
const canRun = !busy && selectedKey !== null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="New problem set"
data-testid="practice-dialog"
onClick={(e) => e.stopPropagation()}
className="flex max-h-[85vh] w-[32rem] max-w-[calc(100vw-2rem)] flex-col rounded-xl border bg-card p-5 shadow-xl"
>
<h2 className="font-title text-2xl tracking-tight">New problem set</h2>
const canRun = !busy && selectedKey !== null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="New problem set"
data-testid="practice-dialog"
onClick={(e) => e.stopPropagation()}
className="flex max-h-[85vh] w-[32rem] max-w-[calc(100vw-2rem)] flex-col rounded-xl border bg-card p-5 shadow-xl"
>
<h2 className="font-title text-2xl tracking-tight">New problem set</h2>
{/* The cluster: a birth-session group of the graph, resolved to
{/* The cluster: a birth-session group of the graph, resolved to
node ids on create (graph-domain, owner 2026-09-29). */}
<div className="mt-4 flex flex-col gap-1">
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Cluster</span>
<div className="max-h-40 overflow-y-auto rounded-lg border" data-testid="practice-dialog-clusters">
{clusters.map((cluster) => (
<button
type="button"
key={cluster.key}
data-testid={`practice-cluster-${cluster.key}`}
onClick={() => onSelect(cluster.key)}
className={`flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
selectedKey === cluster.key ? "bg-accent" : ""
}`}
>
<span className="min-w-0 truncate">
{cluster.label ?? <span className="text-muted-foreground/60">unlabeled cluster</span>}
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{cluster.kind === "notes" ? "notes · " : ""}
{cluster.nodeCount} propositions
</span>
</button>
))}
{clusters.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">
No clusters yet — a cluster appears once a session births its first proposition.
</div>
)}
</div>
</div>
<div className="mt-4 flex flex-col gap-1">
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">
Cluster
</span>
<div
className="max-h-40 overflow-y-auto rounded-lg border"
data-testid="practice-dialog-clusters"
>
{clusters.map((cluster) => (
<button
type="button"
key={cluster.key}
data-testid={`practice-cluster-${cluster.key}`}
onClick={() => onSelect(cluster.key)}
className={`flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
selectedKey === cluster.key ? "bg-accent" : ""
}`}
>
<span className="min-w-0 truncate">
{cluster.label ?? (
<span className="text-muted-foreground/60">
unlabeled cluster
</span>
)}
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{cluster.kind === "notes" ? "notes · " : ""}
{cluster.nodeCount} propositions
</span>
</button>
))}
{clusters.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">
No clusters yet — a cluster appears once a session births its
first proposition.
</div>
)}
</div>
</div>
{/* The three parameters — named levels, never a number to
{/* The three parameters — named levels, never a number to
interpolate (owner, 2026-09-29). Stepped sliders snap to the
detents; each shows the level's NAME + its one-line
definition; presentation is a two-segment toggle. */}
<div className="mt-4 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
Ladder
<span className="text-muted-foreground/80">0 · 1 · 2 · 3 · 4</span>
</span>
<input
type="range"
min={0}
max={4}
step={1}
value={params.ladderRung}
data-testid="param-ladder"
onChange={(e) => onParam({ ladderRung: Number(e.target.value) as PracticeParams["ladderRung"] })}
className="h-1.5 w-full accent-primary"
/>
<span data-testid="param-ladder-level" className="text-xs">
<span className="font-medium">{LADDER_RUNGS[params.ladderRung].name}</span>
<span className="text-muted-foreground"> — {LADDER_RUNGS[params.ladderRung].definition}</span>
</span>
</div>
<div className="flex flex-col gap-1">
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
Cue distance
<span className="text-muted-foreground/80">0 · 1 · 2</span>
</span>
<input
type="range"
min={0}
max={2}
step={1}
value={params.cueDistance}
data-testid="param-cue"
onChange={(e) => onParam({ cueDistance: Number(e.target.value) as PracticeParams["cueDistance"] })}
className="h-1.5 w-full accent-primary"
/>
<span data-testid="param-cue-level" className="text-xs">
<span className="font-medium">{CUE_LEVELS[params.cueDistance].name}</span>
<span className="text-muted-foreground"> — {CUE_LEVELS[params.cueDistance].definition}</span>
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] text-muted-foreground">Presentation</span>
<div className="flex gap-1" data-testid="param-presentation">
{(["theory", "application"] as const).map((pres) => (
<button
type="button"
key={pres}
data-testid={`param-presentation-${pres}`}
onClick={() => onParam({ presentation: pres })}
className={`flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors ${
params.presentation === pres
? "border-primary bg-primary/10 font-medium text-foreground"
: "text-muted-foreground hover:bg-accent"
}`}
>
{PRESENTATIONS[pres].name}
</button>
))}
</div>
<span data-testid="param-presentation-level" className="text-xs text-muted-foreground">
{PRESENTATIONS[params.presentation].definition}
</span>
</div>
</div>
<div className="mt-4 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
Ladder
<span className="text-muted-foreground/80">0 · 1 · 2 · 3 · 4</span>
</span>
<input
type="range"
min={0}
max={4}
step={1}
value={params.ladderRung}
data-testid="param-ladder"
onChange={(e) =>
onParam({
ladderRung: Number(
e.target.value,
) as PracticeParams["ladderRung"],
})
}
className="h-1.5 w-full accent-primary"
/>
<span data-testid="param-ladder-level" className="text-xs">
<span className="font-medium">
{LADDER_RUNGS[params.ladderRung].name}
</span>
<span className="text-muted-foreground">
{" "}
— {LADDER_RUNGS[params.ladderRung].definition}
</span>
</span>
</div>
<div className="flex flex-col gap-1">
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
Cue distance
<span className="text-muted-foreground/80">0 · 1 · 2</span>
</span>
<input
type="range"
min={0}
max={2}
step={1}
value={params.cueDistance}
data-testid="param-cue"
onChange={(e) =>
onParam({
cueDistance: Number(
e.target.value,
) as PracticeParams["cueDistance"],
})
}
className="h-1.5 w-full accent-primary"
/>
<span data-testid="param-cue-level" className="text-xs">
<span className="font-medium">
{CUE_LEVELS[params.cueDistance].name}
</span>
<span className="text-muted-foreground">
{" "}
— {CUE_LEVELS[params.cueDistance].definition}
</span>
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] text-muted-foreground">
Presentation
</span>
<div className="flex gap-1" data-testid="param-presentation">
{(["theory", "application"] as const).map((pres) => (
<button
type="button"
key={pres}
data-testid={`param-presentation-${pres}`}
onClick={() => onParam({ presentation: pres })}
className={`flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors ${
params.presentation === pres
? "border-primary bg-primary/10 font-medium text-foreground"
: "text-muted-foreground hover:bg-accent"
}`}
>
{PRESENTATIONS[pres].name}
</button>
))}
</div>
<span
data-testid="param-presentation-level"
className="text-xs text-muted-foreground"
>
{PRESENTATIONS[params.presentation].definition}
</span>
</div>
{/* The learner's free-form note — the last word before create:
wishes for the problems, weighed by the agents, nothing owed. */}
<div className="flex flex-col gap-1">
<span className="text-[11px] text-muted-foreground">
Anything else? (optional)
</span>
<textarea
value={note}
data-testid="practice-note"
onChange={(e) => onNote(e.target.value)}
rows={3}
maxLength={2000}
placeholder="Anything you want the problems to lean toward — situations, emphasis, context about you. The agents weigh it; nothing is promised."
className="w-full resize-y rounded-lg border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
</div>
{failure && (
<div data-testid="practice-dialog-error" className="mt-3 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{failure.code === "empty_selection" ? "Nothing practicable is left in that cluster — the graph changed." : failure.error}
{failure.status === 503 && (
<button
type="button"
data-testid="practice-dialog-open-settings"
onClick={onOpenSettings}
className="ml-2 underline underline-offset-2"
>
Open settings
</button>
)}
</div>
)}
{failure && (
<div
data-testid="practice-dialog-error"
className="mt-3 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{failure.code === "empty_selection"
? "Nothing practicable is left in that cluster — the graph changed."
: failure.error}
{failure.status === 503 && (
<button
type="button"
data-testid="practice-dialog-open-settings"
onClick={onOpenSettings}
className="ml-2 underline underline-offset-2"
>
Open settings
</button>
)}
</div>
)}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={busy}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button
type="button"
data-testid="practice-dialog-create"
onClick={onRun}
disabled={!canRun}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
>
{busy ? "Creating…" : "Create"}
</button>
</div>
</div>
);
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={busy}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button
type="button"
data-testid="practice-dialog-create"
onClick={onRun}
disabled={!canRun}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
>
{busy ? "Creating…" : "Create"}
</button>
</div>
</div>
);
}
/** The stateful wrapper: fetches the cluster list, runs the create POST
* (nodeIds + the cluster's label as title + params), navigates on 202. */
export function PracticeDialog({ onClose }: { onClose: () => void }) {
const router = useRouter();
const { open: openSettings } = useSettingsDialog();
const [params, setParams] = useState<PracticeParams>({ ...DEFAULT_PRACTICE_PARAMS });
const [clusters, setClusters] = useState<GraphClusterGroup[] | null>(null);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [failure, setFailure] = useState<PracticeFailure | null>(null);
const router = useRouter();
const { open: openSettings } = useSettingsDialog();
const [params, setParams] = useState<PracticeParams>({
...DEFAULT_PRACTICE_PARAMS,
});
const [note, setNote] = useState("");
const [clusters, setClusters] = useState<GraphClusterGroup[] | null>(null);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [failure, setFailure] = useState<PracticeFailure | null>(null);
// The picker's truth is the graph's clustering (fresh on open).
useEffect(() => {
let alive = true;
fetch("/api/graph/clusters", { cache: "no-store" })
.then((res) => res.json() as Promise<{ clusters: GraphClusterGroup[] }>)
.then((d) => {
if (alive) setClusters(d.clusters);
})
.catch(() => {
if (alive) setClusters([]);
});
return () => {
alive = false;
};
}, []);
// The picker's truth is the graph's clustering (fresh on open).
useEffect(() => {
let alive = true;
fetch("/api/graph/clusters", { cache: "no-store" })
.then((res) => res.json() as Promise<{ clusters: GraphClusterGroup[] }>)
.then((d) => {
if (alive) setClusters(d.clusters);
})
.catch(() => {
if (alive) setClusters([]);
});
return () => {
alive = false;
};
}, []);
// Esc closes (claimed, the delete-session discipline).
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
// Esc closes (claimed, the delete-session discipline).
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
const run = useCallback(async () => {
const picked = (clusters ?? []).find((c) => c.key === selectedKey);
if (!picked || busy) return;
setBusy(true);
setFailure(null);
try {
const res = await fetch("/api/practice/sets", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
nodeIds: picked.nodeIds,
params,
title: picked.label ?? undefined,
}),
});
const body = (await res.json()) as { id?: string; error?: string; code?: string };
if (!res.ok || !body.id) {
setFailure({ status: res.status, error: body.error ?? `Create failed (${res.status})`, code: body.code });
setBusy(false);
return;
}
router.push(`/practice/${body.id}`);
} catch {
setFailure({ status: 0, error: "Create failed — network error. Try again." });
setBusy(false);
}
}, [clusters, selectedKey, busy, params, router]);
const run = useCallback(async () => {
const picked = (clusters ?? []).find((c) => c.key === selectedKey);
if (!picked || busy) return;
setBusy(true);
setFailure(null);
try {
const res = await fetch("/api/practice/sets", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
nodeIds: picked.nodeIds,
params,
title: picked.label ?? undefined,
note: note.trim() || undefined,
}),
});
const body = (await res.json()) as {
id?: string;
error?: string;
code?: string;
};
if (!res.ok || !body.id) {
setFailure({
status: res.status,
error: body.error ?? `Create failed (${res.status})`,
code: body.code,
});
setBusy(false);
return;
}
router.push(`/practice/${body.id}`);
} catch {
setFailure({
status: 0,
error: "Create failed — network error. Try again.",
});
setBusy(false);
}
}, [clusters, selectedKey, busy, params, note, router]);
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
onClick={() => {
if (!busy) onClose();
}}
>
<PracticeDialogBody
params={params}
onParam={(patch) => setParams((prev) => ({ ...prev, ...patch }))}
clusters={clusters ?? []}
selectedKey={selectedKey}
onSelect={setSelectedKey}
busy={busy}
failure={failure}
onOpenSettings={() => openSettings()}
onClose={onClose}
onRun={() => void run()}
/>
</div>,
document.body,
);
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
onClick={() => {
if (!busy) onClose();
}}
>
<PracticeDialogBody
params={params}
onParam={(patch) => setParams((prev) => ({ ...prev, ...patch }))}
note={note}
onNote={setNote}
clusters={clusters ?? []}
selectedKey={selectedKey}
onSelect={setSelectedKey}
busy={busy}
failure={failure}
onOpenSettings={() => openSettings()}
onClose={onClose}
onRun={() => void run()}
/>
</div>,
document.body,
);
}
+1 -1
View File
@@ -382,7 +382,7 @@ Practice turns the graph into problem sets the learner reasons through out loud.
**The jobs pattern — registry, not JobRecords.** Practice runs are NOT Workbench jobs: no `JobStore` records, no steer messages, nothing ever wakes the teacher — a practice run isn't chat-scoped (a set outlives its birth session and has no lesson to re-enter; its result is itself, persisted in its own store and read by its own page). What it shares with the Workbench is the observability surface: a `globalThis` run registry (the module-instance idiom — `__lhPracticeRuns`) with PER-PROBLEM ROWS (owner ruling, 2026-09-29 — matching the extraction per-chunk pattern): `setId:partition` while the partitioner runs ("Partitioning — title"), then one `setId:gen:problemId` per problem ("queued"→"generating", label "Problem i/N — title"), and one `setId:grade:problemId` per graded answer ("queued"→"grading", "Grading i/N — title") — each row its OWN abort controller and its OWN usage accumulation; the row key IS the feed id (the dock dup-key law) and the abort target. ABORT ROUTES BY ROW SHAPE: a gen-row abort kills THAT problem only (siblings continue; the set still settles `ready` with one errored problem), a partition-row abort ends the whole run, a grade-row abort drops that grade (answer kept — one attempt, the problem stays done); set-level abort (delete) composes from row aborts plus the synchronous persisted marks. Model calls — partitioner included — run under the SHARED background-LLM semaphore (`bg-llm-semaphore.ts`): one process-wide pool of 3 permits for ALL background LLM jobs, extraction chunks included (total background LLM concurrency bounded at 3, whoever calls). A boot sweep marks orphaned `partitioning`/`generating` sets as `error "Interrupted (server restarted)"` — once per process, and only when the set's `updatedAt` heartbeat is >120s stale (a live run's 30s FILE watchdog keeps the heartbeat fresh even across dev's per-worker processes — the registry is not shared between them; the guard trio closed a live-stamping bug from the first live test) — the job runner's crash-honesty at set grain. Store writes serialize behind a per-set mutex; the pipeline is the sole writer of `data/practice`.
**API + UI.** Creation is GRAPH-DOMAIN, not chat-domain (owner ruling 2026-09-29: "problem sets always take in nodes — graph objects"): `POST /api/practice/sets` takes `{nodeIds, params, title?}` ONLY — no chatId, no clusterId; the UI resolves a picked cluster to its member node ids and passes the cluster's label as the title. `GET /api/graph/clusters` serves the picker: the birth-session partition itself (`birthSessionPartition`, same ordering — size desc, ties by id) — EVERY group regardless of size (owner ruling, same evening: MIN_CLUSTER_SIZE is a hull-drawing rule; practice availability must not inherit a visual threshold), each as `{key, kind: "chat"|"notes", label, nodeIds, nodeCount}`; a cluster whose naming record is gone keeps `label: null` — honest, never guessed. 202 `{id}` with generation fire-and-forget; 400 empty selection (empty intersection with live nodes — the vanished-nodes edge) / bad params; 503 model/key refusal. `GET /api/practice/sets` · `GET/DELETE /api/practice/sets/[id]` (delete aborts live runs first) · `POST /api/practice/sets/[id]/answers` (202; 409 when not ready; 400 re-answering a submitted problem — one attempt). Surfaces: `/practice` (the list — the chat-pattern top bar with the sidebar reopen toggle and the New action at the right edge, status/counts rows, delete confirm, the new-set dialog with the named-level controls (a 5-detent ladder slider, a 3-detent cue slider, a Theory|Application toggle — each showing the level's name + definition) and the cluster picker — the ONLY creation entry for now; the graph-view cluster entry comes later with the free-form selection UI) and `/practice/[id]` (the exam page — the chat-pattern top bar, a levels + status line, numbered problems, markdown statements with math healing at the display seam, a textarea + focus-gated dictation bar per problem with its OWN Submit button — one attempt per problem, instant solution/rubric reveal, the score landing live). Entry: the sidebar's Practice nav row.
**API + UI.** Creation is GRAPH-DOMAIN, not chat-domain (owner ruling 2026-09-29: "problem sets always take in nodes — graph objects"): `POST /api/practice/sets` takes `{nodeIds, params, title?, note?}` ONLY — no chatId, no clusterId; the UI resolves a picked cluster to its member node ids and passes the cluster's label as the title. The `note` is the learner's optional free-form creation input (owner, same evening: more learner control, no other purpose): trimmed, capped at 2000 characters, persisted on the set, and carried to the partitioner and every generator as `THE LEARNER'S NOTE` — an additional input the agents WEIGH but are never obliged to follow (the doctrine pins the stance: it never moves a problem off its rung or out of its band, and the partition laws always win). `GET /api/graph/clusters` serves the picker: the birth-session partition itself (`birthSessionPartition`, same ordering — size desc, ties by id) — EVERY group regardless of size (owner ruling, same evening: MIN_CLUSTER_SIZE is a hull-drawing rule; practice availability must not inherit a visual threshold), each as `{key, kind: "chat"|"notes", label, nodeIds, nodeCount}`; a cluster whose naming record is gone keeps `label: null` — honest, never guessed. 202 `{id}` with generation fire-and-forget; 400 empty selection (empty intersection with live nodes — the vanished-nodes edge) / bad params; 503 model/key refusal. `GET /api/practice/sets` · `GET/DELETE /api/practice/sets/[id]` (delete aborts live runs first) · `POST /api/practice/sets/[id]/answers` (202; 409 when not ready; 400 re-answering a submitted problem — one attempt). Surfaces: `/practice` (the list — the chat-pattern top bar with the sidebar reopen toggle and the New action at the right edge, status/counts rows, delete confirm, the new-set dialog with the named-level controls (a 5-detent ladder slider, a 3-detent cue slider, a Theory|Application toggle — each showing the level's name + definition), the cluster picker, and the optional free-form note textarea (typing-only) — the ONLY creation entry for now; the graph-view cluster entry comes later with the free-form selection UI) and `/practice/[id]` (the exam page — the chat-pattern top bar, a levels + status line, numbered problems, markdown statements with math healing at the display seam, a textarea + focus-gated dictation bar per problem with its OWN Submit button — one attempt per problem, instant solution/rubric reveal, the score landing live). Entry: the sidebar's Practice nav row.
**Model wiring.** Three roles — `partitioner`, `problemGenerator`, `grader` — all pinned to the **strong** slot (quality-critical, one settings edit from cheap) and in `BACKGROUND_MODEL_ROLES` (per-role overrides). The stage agents are single-submit loops on the shared `runSubmitAgent` seam (`submit-agent.ts`, extracted from extraction for exactly this): a `validate` hook turns a bad submit into a tool-input error the model retries against, within `maxTurns 4`; exhaustion fails the stage honestly (the set says so, the chains re-run cleanly on a new set).
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+6 -1
View File
@@ -275,6 +275,7 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
set: ProblemSet,
previousProblems: PreviousProblemRef[],
partitionRow: RunningPractice,
note?: string,
): Promise<void> {
const controller = partitionRow.controller;
@@ -308,6 +309,7 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
propositions: set.propositions,
params: set.params,
previousProblems,
note,
signal: controller.signal,
}),
);
@@ -370,6 +372,7 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
propositions: set.propositions,
params: set.params,
previousProblems,
note,
signal: row.controller.signal,
});
row.usage = generated.log;
@@ -431,6 +434,7 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
nodeIds: string[],
params: ProblemSet["params"],
titleHint?: string,
note?: string,
): Promise<{ set: ProblemSet; done: Promise<void> }> {
agents.refuseIfStagesUnready();
@@ -458,11 +462,12 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
chains,
status: "partitioning",
problems: [],
...(note ? { note } : {}),
});
const previousProblems = dedupInput(deps.practiceStore, selectedIds);
const row = registerRow(`${set.id}:partition`, set.id, "generation", "partitioning", `Partitioning — ${title}`);
const done = generateSet(set, previousProblems, row);
const done = generateSet(set, previousProblems, row, note);
return { set, done };
}
+22 -4
View File
@@ -188,6 +188,7 @@ INPUTS (in the user message):
THE CUE DISTANCE: 0 Lesson context · 1 Adjacent context · 2 Far context — how far the situation sits from the cues the learner has already met: the lesson's own examples AND every previous problem's situation. Far context is the edge of the recognizable band — the proposition must still genuinely apply, and the learner must be able to find it by looking.
THE PRESENTATION: Theory (the subject's own terms — symbols, formal objects, native vocabulary) or Application (a concrete worldly affair in which the proposition's structure appears in the wild). It dresses the situation; it never changes the rung or the cue distance.
Apply Part II step 2: degrade honestly if the target cannot support them — the degradation shows in the problem's own shape, never in meta-commentary.
- THE LEARNER'S NOTE (optional): free-form text the learner attached when creating the set — wishes, emphasis, context about themselves. Weigh it as preference, not instruction: take what serves the target and the parameters, ignore what conflicts with them, and never let it move a problem off its rung or out of its band. Its absence means nothing.
- FORMATTING: your statement, solution, and rubric are rendered as full Markdown — use everything it offers. Mathematics is always LaTeX: inline as \\(…\\), display as \\[…\\] on its own lines — a bare $ is literal currency and never starts math. Inside math, use LaTeX commands for symbols, not unicode stand-ins (they fail to render). Code, pseudocode, and formal systems live in fenced code blocks with a language tag; identifiers and symbols inline as \`code\`. Use tables for parallel structure and comparisons, lists for enumerations. Skip headers — at this size they are pointless decoration; structure with paragraphs, bold labels, and lists (a genuinely long derivation may earn one). Prose-only renderings of mathematical or structured content are a defect: if a sentence carries an equation, a derivation, or a piece of code, format it as what it is.
- PREVIOUS PROBLEMS: problems the learner has already answered, with the chains they exercised. Do not rebuild one; keep your situation distant from theirs — cue distance counts previous problems' situations (Part I, "Cue distance").
@@ -208,6 +209,7 @@ PARTITION DOCTRINE:
- READ THE MIX OFF THE SHAPE: many singletons and few multi-premise chains suggest recall + discrimination problems; long premise chains suggest composition and path-finding. Your partition is where that judgment lives.
- RESPECT THE PARAMETERS: the request names the ladder rung, the cue distance, and the presentation. Low rungs (0–2) mean many small assignments — single chains standing alone; high rungs (3–4) mean larger assignments — composition needs material to chain, path-finding needs distractor material to conceal the path among. Far cue distance makes grouping confusables together MORE important: the discrimination must travel into the far situation. Presentation does not shape your partition.
- DO NOT DUPLICATE: previous problems the learner has already seen are listed with their chains. If an existing problem already covers a group of chains, re-partition so the new set differs in what it groups and how.
- THE LEARNER'S NOTE (optional): free-form text the learner attached at creation — it may wish for grouping, count, or emphasis. Weigh it as preference; the partition laws above always win, and when the note conflicts with them, follow the laws silently.
OUTPUT — your entire deliverable is ONE submit_partition call: "partitions", a list of { chains: [ids] }, one entry per problem, together containing every input chain exactly once. No empty entries; no unknown ids; no problem without a real target relationship.`;
@@ -324,6 +326,8 @@ export interface PartitionerInput {
propositions: PropositionSnapshot;
params: PracticeParams;
previousProblems: PreviousProblemRef[];
/** The learner's creation note — optional; weighed, never obeyed. */
note?: string;
signal?: AbortSignal;
}
export interface PartitionerOutput {
@@ -339,6 +343,8 @@ export interface GeneratorInput {
propositions: PropositionSnapshot;
params: PracticeParams;
previousProblems: PreviousProblemRef[];
/** The learner's creation note — optional; weighed, never obeyed. */
note?: string;
signal?: AbortSignal;
}
export interface GeneratedProblem {
@@ -394,6 +400,14 @@ function validateProblem(value: ProblemSubmit, target: string[]): string | null
return null;
}
/** The learner's creation note as a prompt block — present ONLY when a
* note exists (owner, 2026-09-29: more learner control, nothing owed). */
function learnerNoteBlock(note: string | undefined): string | null {
return note
? `THE LEARNER'S NOTE (free-form additional input — weigh it; you are not obliged to follow it)\n${note}`
: null;
}
export function createPracticeAgents(deps: PracticeModels) {
const stageEngine = (role: ModelRole, override?: PiEngine): PiEngine | undefined =>
override ?? deps.engineFor?.(role);
@@ -433,11 +447,13 @@ export function createPracticeAgents(deps: PracticeModels) {
const log: PracticeStepLog = { model: null, steps: [] };
const prompt = [
renderChains(input.chains, input.propositions),
"THE PARAMETERS",
renderParams(input.params),
learnerNoteBlock(input.note),
"PREVIOUS PROBLEMS (already seen — do not duplicate)",
renderPreviousProblems(input.previousProblems),
].join("\n\n");
]
.filter((block): block is string => block !== null)
.join("\n\n");
const submitted = await runSubmitAgent({
role: "partitioner",
toolName: "submit_partition",
@@ -461,11 +477,13 @@ export function createPracticeAgents(deps: PracticeModels) {
const prompt = [
`YOUR TARGET (the chains this problem is built to strengthen)\n${targetChains.map(chainLine).join("\n")}`,
`THE FULL SET — propositions and chains of the entire selection (supporting material: minimal use, maximal focus on the target)\n${renderChains(input.fullChains, input.propositions)}`,
"THE PARAMETERS",
renderParams(input.params),
learnerNoteBlock(input.note),
"PREVIOUS PROBLEMS (already answered — do not rebuild one; keep your situation distant from theirs)",
renderPreviousProblems(input.previousProblems),
].join("\n\n");
]
.filter((block): block is string => block !== null)
.join("\n\n");
const problem = await runSubmitAgent({
role: "problemGenerator",
toolName: "submit_problem",
+7
View File
@@ -187,6 +187,13 @@ export interface ProblemSet {
status: ProblemSetStatus;
/** Set when the partition itself (or the run as a whole) failed. */
error?: string;
/** The learner's free-form note from the creation dialog (owner,
* 2026-09-29: more learner control, no other purpose). Carried to the
* partitioner and every generator as ADDITIONAL INPUT — weighed as
* preference, never an obligation, never overriding the laws. Trimmed,
* capped (2000 chars at the API); persisted so the set stays a
* self-contained document of what was asked for. */
note?: string;
/** Problem stubs appear here the moment the partitioner submits —
* numbered by position, "generating", propositions invisible. */
problems: Problem[];
+20 -4
View File
@@ -81,11 +81,16 @@ export function practiceErrorResponse(err: unknown): Response {
return Response.json({ error: "Internal error" }, { status: 500 });
}
/** The learner-note cap (creation dialog; the textarea's own maxLength
* matches — server-side is the law). */
const NOTE_MAX = 2000;
/** POST /api/practice/sets — 202 {id}; the set generates in the
* background (fire-and-forget pipeline). Body is {nodeIds, params,
* title?} ONLY (owner ruling 2026-09-29: problem sets take NODES —
* title?, note?} ONLY (owner ruling 2026-09-29: problem sets take NODES —
* graph objects; the UI resolves a picked cluster to its member ids,
* and may pass the cluster label as the title). */
* and may pass the cluster label as the title; note is the learner's
* free-form creation input, optional). */
export async function createPracticeSetResponse(body: unknown): Promise<Response> {
const b = (body ?? {}) as Record<string, unknown>;
const params = parseParams(b.params);
@@ -97,17 +102,28 @@ export async function createPracticeSetResponse(body: unknown): Promise<Response
}
const titleHint =
typeof b.title === "string" && b.title.trim() !== "" ? b.title.trim() : undefined;
let note: string | undefined;
if (b.note !== undefined) {
if (typeof b.note !== "string") {
return Response.json({ error: "note must be a string" }, { status: 400 });
}
const trimmed = b.note.trim();
if (trimmed.length > NOTE_MAX) {
return Response.json({ error: `note is over ${NOTE_MAX} characters` }, { status: 400 });
}
note = trimmed || undefined; // whitespace-only = no note
}
if (
!Array.isArray(b.nodeIds) ||
!b.nodeIds.every((id): id is string => typeof id === "string" && ID_OK.test(id))
) {
return Response.json({ error: "Body must be {nodeIds, params, title?}" }, { status: 400 });
return Response.json({ error: "Body must be {nodeIds, params, title?, note?}" }, { status: 400 });
}
if (b.nodeIds.length === 0) {
return Response.json({ error: "nodeIds is empty", code: "empty_selection" }, { status: 400 });
}
try {
const { set } = await practice.createProblemSet(b.nodeIds, params, titleHint);
const { set } = await practice.createProblemSet(b.nodeIds, params, titleHint, note);
return Response.json({ id: set.id }, { status: 202 });
} catch (err) {
return practiceErrorResponse(err);
+1
View File
@@ -111,6 +111,7 @@ export function isProblemSet(value: unknown): value is ProblemSet {
typeof v.id === "string" &&
v.id.startsWith("ps_") &&
typeof v.title === "string" &&
(v.note === undefined || typeof v.note === "string") &&
typeof v.createdAt === "string" &&
isParams(v.params) &&
isStringArray(v.nodeIds) &&
+75
View File
@@ -72,6 +72,25 @@ function silentModel(): PiEngine {
return fauxEngine([fauxAssistantMessage("I refuse to use tools"), fauxAssistantMessage("…")]);
}
/** A one-shot submitter that first records the LAST USER MESSAGE it saw —
* for pinning what actually rode the prompt. */
function capturingSubmitModel(name: string, input: unknown, seen: string[]): PiEngine {
return fauxEngine([
(context) => {
const messages = (context as { messages: Array<{ role: string; content: unknown }> }).messages;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
if (m.role !== "user") continue;
seen.push(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
break;
}
return fauxAssistantMessage([fauxToolCall(name, input as ToolCall["arguments"], { id: `${name}-call` })], {
stopReason: "toolUse",
});
},
]);
}
// ─── rendering ───────────────────────────────────────────────────────────────
describe("renderParams — named levels, never a number", () => {
@@ -164,6 +183,30 @@ describe("runPartitioner", () => {
/the partitioner never submitted a partition/,
);
});
it("the learner's note: rides the prompt as its own block when present, absent when unset", async () => {
const seen: string[] = [];
const agents = createPracticeAgents({
partitioner: capturingSubmitModel("submit_partition", { partitions: [{ chains: ["n3", "n2"] }] }, seen),
});
await agents.runPartitioner({
chains,
propositions,
params,
previousProblems: [],
note: "one big problem, please — and lean on pricing",
});
assert.equal(seen.length, 1);
assert.ok(seen[0]!.includes("THE LEARNER'S NOTE"), "the block title");
assert.ok(seen[0]!.includes("one big problem, please — and lean on pricing"), "the note text");
const seenBare: string[] = [];
const bare = createPracticeAgents({
partitioner: capturingSubmitModel("submit_partition", { partitions: [{ chains: ["n3", "n2"] }] }, seenBare),
});
await bare.runPartitioner({ chains, propositions, params, previousProblems: [] });
assert.ok(!seenBare[0]!.includes("THE LEARNER'S NOTE"), "no block when no note");
});
});
// ─── the problem generator ───────────────────────────────────────────────────
@@ -218,6 +261,31 @@ describe("runProblemGenerator", () => {
const agents = createPracticeAgents({ problemGenerator: silentModel() });
await assert.rejects(agents.runProblemGenerator(generatorInput), /the problem generator never submitted a problem/);
});
it("the learner's note: rides the prompt when present, absent when unset", async () => {
const seen: string[] = [];
const agents = createPracticeAgents({
problemGenerator: capturingSubmitModel(
"submit_problem",
{ chains: ["n3"], statement: "…", solution: "…", rubric: "…" },
seen,
),
});
await agents.runProblemGenerator({ ...generatorInput, note: "situations from central banking, if you can" });
assert.ok(seen[0]!.includes("THE LEARNER'S NOTE"));
assert.ok(seen[0]!.includes("situations from central banking, if you can"));
const seenBare: string[] = [];
const bare = createPracticeAgents({
problemGenerator: capturingSubmitModel(
"submit_problem",
{ chains: ["n3"], statement: "…", solution: "…", rubric: "…" },
seenBare,
),
});
await bare.runProblemGenerator(generatorInput);
assert.ok(!seenBare[0]!.includes("THE LEARNER'S NOTE"), "no block when no note");
});
});
// ─── the grader ──────────────────────────────────────────────────────────────
@@ -291,6 +359,13 @@ describe("refusal gates (BYOK honest refusal, extraction's pattern)", () => {
// ─── formatting doctrine (owner-approved 2026-09-29) ─────────────────────────
describe("formatting doctrine pins", () => {
it("GENERATOR_DOCTRINE pins the learner-note stance (weighed, never owed)", () => {
assert.ok(
GENERATOR_DOCTRINE.includes("- THE LEARNER'S NOTE (optional): free-form text the learner attached when creating the set"),
);
assert.ok(GENERATOR_DOCTRINE.includes("never let it move a problem off its rung or out of its band"));
});
it("GENERATOR_DOCTRINE carries the FORMATTING bullet verbatim (Markdown + LaTeX + fenced code + tables/lists)", () => {
assert.ok(
GENERATOR_DOCTRINE.includes(
+76
View File
@@ -183,6 +183,45 @@ function partitionModel(partitions: Array<{ chains: string[] }>): PiEngine {
]);
}
/** A partitioner that RECORDS the prompt it saw before submitting — the
* learner-note thread pin. */
function capturingPartitioner(partitions: Array<{ chains: string[] }>, seen: string[]): PiEngine {
return fauxEngine([
(context) => {
seen.push(lastUserText(context as never));
return fauxAssistantMessage(
[fauxToolCall("submit_partition", { partitions } as ToolCall["arguments"], { id: "p-call" })],
{ stopReason: "toolUse" },
);
},
]);
}
/** An echo generator that RECORDS each prompt before submitting. */
function capturingEchoGenerator(seen: string[], count: number): PiEngine {
const factory: FauxResponseStep = (context) => {
const prompt = lastUserText(context as never);
seen.push(prompt);
const target = targetOf(prompt);
return fauxAssistantMessage(
[
fauxToolCall(
"submit_problem",
{
chains: target,
statement: `Problem on ${target.join(", ")}`,
solution: "The model solution.",
rubric: "- names it",
} as ToolCall["arguments"],
{ id: "g-call" },
),
],
{ stopReason: "toolUse" },
);
};
return fauxEngine(Array.from({ length: count }, () => factory));
}
/** Hangs until the call's signal aborts (then resolves no submit call) —
* the extraction.test.ts cancellation-mimicking idiom. Queue one step
* per concurrent call (faux queues are finite). */
@@ -360,6 +399,43 @@ describe("createProblemSet (generation)", () => {
assert.equal(practiceStatuses().length, 0);
});
it("the learner's note threads to the partitioner + every generator and persists on the set (owner, 2026-09-29)", async () => {
const partSeen: string[] = [];
const genSeen: string[] = [];
const f = fixture({
partitioner: capturingPartitioner([{ chains: ["b"] }, { chains: ["e"] }], partSeen),
problemGenerator: capturingEchoGenerator(genSeen, 2),
grader: echoGrader(1),
});
const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet(["a", "b", "e"], PARAMS, undefined, "keep it geometric");
await done;
assert.equal(set.note, "keep it geometric", "persisted at creation");
assert.equal(f.practice.store.getSet(set.id)!.note, "keep it geometric", "survives the store round-trip");
assert.ok(partSeen[0]!.includes("THE LEARNER'S NOTE"), "rode the partitioner prompt");
assert.ok(partSeen[0]!.includes("keep it geometric"));
assert.equal(genSeen.length, 2);
for (const prompt of genSeen) {
assert.ok(prompt.includes("THE LEARNER'S NOTE"), "rode every generator prompt");
assert.ok(prompt.includes("keep it geometric"));
}
// No note → no block, anywhere (optional in every sense).
const partSeenBare: string[] = [];
const genSeenBare: string[] = [];
const f2 = fixture({
partitioner: capturingPartitioner([{ chains: ["b", "e"] }], partSeenBare),
problemGenerator: capturingEchoGenerator(genSeenBare, 1),
grader: echoGrader(1),
});
const s2 = await f2.make().createProblemSet(["a", "b", "e"], PARAMS);
await s2.done;
assert.equal(s2.set.note, undefined);
assert.ok(!partSeenBare[0]!.includes("THE LEARNER'S NOTE"));
assert.ok(!genSeenBare[0]!.includes("THE LEARNER'S NOTE"));
});
it("per-problem failure isolation: one generator fails, the set still completes", async () => {
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }, { chains: ["e"] }]), problemGenerator: echoGenerator(8, ["e"]), grader: echoGrader(1) });
const pipeline = f.make();
+26
View File
@@ -41,3 +41,29 @@ describe("createPracticeSetResponse — strict named-level params", () => {
assert.equal(await statusOf("high"), 400);
});
});
describe("createPracticeSetResponse — the learner's note (owner, 2026-09-29)", () => {
const PARAMS = { ladderRung: 2, cueDistance: 1, presentation: "theory" } as const;
it("non-string note → 400", async () => {
const res = await createPracticeSetResponse({ ...NODE_IDS, params: PARAMS, note: 42 });
assert.equal(res.status, 400);
});
it("over 2000 characters → 400 (the API is the law; the textarea's maxLength mirrors it)", async () => {
const res = await createPracticeSetResponse({ ...NODE_IDS, params: PARAMS, note: "x".repeat(2001) });
assert.equal(res.status, 400);
const body = (await res.json()) as { error: string };
assert.match(body.error, /over 2000 characters/);
});
it("exactly 2000 passes the note check (boundary — the next check, not the note, refuses)", async () => {
// Malformed nodeIds AFTER a max-length note: reaching the nodeIds
// error proves the note check passed (all checks are rejecting-path;
// the pipeline is never reached — this suite's doctrine).
const res = await createPracticeSetResponse({ nodeIds: 5, params: PARAMS, note: "x".repeat(2000) });
assert.equal(res.status, 400);
const body = (await res.json()) as { error: string };
assert.match(body.error, /Body must be/);
});
});
+7
View File
@@ -152,6 +152,13 @@ describe("isProblemSet", () => {
assert.ok(!isProblemSet({ ...init(), status: "cooked" }));
});
it("the learner's note: optional string, round-trips; non-string refused (owner, 2026-09-29)", () => {
assert.ok(isProblemSet({ ...init({ note: "keep it geometric" }), id: "ps_noted" }));
assert.ok(!isProblemSet({ ...init({ note: 42 as unknown as string }), id: "ps_badnote" }));
const created = practiceStore.createSet(init({ note: "lean toward pricing" }));
assert.equal(practiceStore.getSet(created.id)!.note, "lean toward pricing");
});
it("tolerates optional fields — a minimal stub (no error/answer/grade) reads", () => {
const minimal = { ...init({ problems: [{ id: "pp_1", chains: [], statement: "", solution: "", rubric: "", status: "generating" }] }), id: "ps_valid" };
assert.ok(isProblemSet(minimal));
+13
View File
@@ -87,6 +87,8 @@ describe("PracticeDialogBody — creation (cluster picker)", () => {
const base = {
params: { ...DEFAULT_PRACTICE_PARAMS },
onParam: () => {},
note: "",
onNote: () => {},
clusters: clusters(),
selectedKey: null,
onSelect: () => {},
@@ -126,6 +128,17 @@ describe("PracticeDialogBody — creation (cluster picker)", () => {
assert.match(moved, /param-presentation-application[^]*border-primary/, "application segment active");
});
it("the learner's note: optional textarea under the params, carries text, capped client-side", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} note="lean toward pricing applications" />);
assert.ok(html.includes('data-testid="practice-note"'));
assert.ok(html.includes("Anything else? (optional)"), "the label");
assert.ok(html.includes("lean toward pricing applications"), "controlled value renders");
assert.match(html, /maxlength="2000"/i, "client-side cap mirrors the API law");
// Empty default: present but empty — optional in every sense.
const bare = renderToStaticMarkup(<PracticeDialogBody {...base} />);
assert.match(bare, /data-testid="practice-note"[^>]*><\/textarea>/, "no value when unset");
});
it("cluster picker: label + proposition-count rows, notes kind marked, Create gated until picked", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
assert.ok(html.includes('data-testid="practice-dialog-clusters"'));