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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"'));
|
||||
|
||||
Reference in New Issue
Block a user