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
+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) &&