Files
Hankan/test/domain/block-leak.test.ts
MechaCat02 467d9d1d7c fix(tutor): what live gpt-oss-20b lessons showed — marks lost, feedback swallowed, answers given away
Three unit-1.1 lessons with gpt-oss-20b through LM Studio and the server —
the first real model on the reworked turn. The letter-level check went out
right, and the +25 clamp held: a reported 80 on the first answer was stored
as 25. What failed was how the model wrote its blocks, a different way each
session. All three transcripts are in test/fixtures/, verbatim, and each
failure below is a test against them.

Marks lost. The prompt asks for `여덟 | wrong | 여덜`. The first session wrote
`we | wrong | 우라 → 우리`, English prompt first; the second wrote no ::result
at all and marked only in prose, `✗ 나 | I (humble) → 저`. evidence.ts keys on
the first field of a ::result row, so nothing was ever recorded — no
evidence, no schedule, no confusions, and a 다지기 review that could never
close. The artifact would have lost them the same way. domain/marking.ts
attaches each mark to its word only where that is unambiguous: one Korean
word first, or through a prompt of the exercise he answered, read via that
exercise's ::words as the letter check reads it. With no ::result block the
✓/✗ lines are read on the same terms, so a mark can never name a word the
exercise did not ask for; a mark on a whole sentence is still dropped. What
he mistook a word for is taken from what he actually wrote whenever the mark
itself gives no other word — the third session put the right answer there.
Its third session, marked through all of this: 20 evidence rows, 20 cards.

Progress on requests. The prompt allows marks, ::confirmed and ::progress
only in reply to an answer. The model wrote ::progress on every message, and
three requests for a new exercise took the unit from 50% to 80% with nothing
answered. A reply to anything but an answer now changes none of them.

Feedback swallowed. The model closed no blocks, so lib read what followed
each one as rows: "your score is about 5%" became a result row the student
never saw, and a "---" became a recall item he was asked to write in 한글.
Another session fenced every block in ```. gloss.ts now decides every
block's extent from the raw text — at "::", the next block, a rule or fence
line, a blank line with no row after it, or for the piped blocks the first
line without a "|" — and hands lib the blocks properly closed. The gate
audit, now run through the parser the lesson uses, still flags 7 and 2.

Answers given away. Translate rows came with their meanings ("나 | I") and
recall hints were the answers ("two | 이"). A translate row keeps only its
Korean line, and a recall hint that is the expected word, or any word the
message declares, is dropped.

Also: the spelling a recall prompt expects now keeps its qualifiers. With 나
"I, me (casual)" and 저 "I, me (humble)" in one list, "I (humble)" matched
나: no letter check was sent, and the mark for 저 was filed under 나.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:50:33 +02:00

280 lines
12 KiB
TypeScript

/* Blocks leaking into the message body.
Reported from the app, twice: raw exercise markup appeared as prose above
the exercise it had already been rendered into.
It is lib/blocks.js, not the model. parse() deletes each block with a
regex whose terminator (?:\n::|$) is part of the match, so deleting one
block also deletes the "::" that opens the next. That block is then no
longer a block, and stays in the body as a bare word plus its rows. The
16 Sep lib changed which block survives — it now strips every kind in
turn instead of truncating at ::words — but not the defect.
Order is what decides it. The stub tutor emits ::words before ::task and
is therefore fine; a real model emitted ::task first. Nothing in the
prompt requires either order, so this was always reachable.
The fixture is a verbatim capture from gpt-oss-20b through the server. */
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { parse } from "@lib/blocks.js";
import { parseMessage, proseOf, glossContent } from "@app/domain/gloss.js";
import type { ParsedMessage } from "@lib/blocks.js";
/** Narrow the Task union; only translate tasks appear in these fixtures. */
function translateItems(task: ParsedMessage["task"]): { q: string }[] {
if (!task || task.type !== "translate") throw new Error(`expected a translate task, got ${task?.type}`);
return task.items;
}
const CAPTURED = "::task translate\n\ub098 \uac00\ub2e4 \n\ub108 \uba39\ub2e4 \n\uc6b0\ub9ac \ub9c8\uc2dc\ub2e4 \n\uc774 \uc790\ub2e4 \n\uadf8 \ub098\ubb34 \uc11c\ub2e4 \n\n::words\n\ub098 | I, me (casual) | pron \n\uac00\ub2e4 | to go | verb (plain) \n\ub108 | you (casual) | pron \n\uba39\ub2e4 | to eat | verb (plain) \n\uc6b0\ub9ac | we, our | pron \n\ub9c8\uc2dc\ub2e4 | to drink | verb (plain) \n\uc774 | this | det \n\uc790\ub2e4 | to sleep | verb (plain) \n\uadf8 | that (near you) | det \n\ub098\ubb34 | tree | noun \n\uc11c\ub2e4 | to stand | verb (plain) \n\n::progress 20";
describe("lib/blocks.js — the leak, pinned as it is", () => {
it("leaves the de-coloned ::words block in body when ::task precedes ::words", () => {
const r = parse(CAPTURED);
// The blocks themselves parse correctly...
expect(r.task?.type).toBe("translate");
expect(translateItems(r.task)).toHaveLength(5);
expect(r.words).toHaveLength(11);
// ...and the body carries the words block, its "::" eaten by the task's
// terminator: not even recognisable as markup any more.
expect(r.body).toMatch(/^words\n/);
expect(r.body).toContain("나 | I, me (casual) | pron");
expect(r.body).not.toContain("::");
});
it("is fine in the other order, which is why the stub never showed it", () => {
const swapped = "Here you go.\n\n::words\n나 | I | pron\n\n::task translate\n나 가다\n";
expect(parse(swapped).body).not.toContain("::task");
});
});
describe("parseMessage — the workaround", () => {
it("keeps the blocks and drops the leaked markup", () => {
const r = parseMessage(CAPTURED);
expect(r.body).not.toContain("::");
expect(r.body).not.toContain("나 가다");
// Nothing the blocks needed was lost.
expect(r.task?.type).toBe("translate");
expect(translateItems(r.task)).toHaveLength(5);
expect(r.words).toHaveLength(11);
expect(r.progress?.score).toBe(20);
});
it("keeps prose that comes before the blocks", () => {
const r = parseMessage("좋아요. Try these.\n\n::task translate\n나 가다\n\n::words\n나 | I | pron\n");
expect(r.body).toBe("좋아요. Try these.");
expect(translateItems(r.task)).toHaveLength(1);
});
it("still handles the order the stub uses", () => {
const r = parseMessage("Here you go.\n\n::words\n나 | I | pron\n\n::task translate\n나 가다\n");
expect(r.body).toBe("Here you go.");
expect(translateItems(r.task)).toHaveLength(1);
});
it("leaves an ordinary message alone", () => {
const r = parseMessage("좋아요!\n\nThat is the shape of it.");
expect(r.body).toBe("좋아요!\n\nThat is the shape of it.");
});
});
describe("proseOf", () => {
it("does nothing without a directive", () => {
expect(proseOf("a\n\nb")).toBe("a\n\nb");
});
it("drops every block, not merely from the first one on", () => {
expect(proseOf("keep\n::task translate\ndrop\n::words\ndrop | x")).toBe("keep");
});
it("resumes the prose after a block closes — gloss blocks sit inline", () => {
expect(proseOf("before\n::gloss\n물 | N | water |\n::\nafter")).toBe("before\nafter");
});
it("does not trip on a colon that is not at the start of a line", () => {
expect(proseOf("see:: this")).toBe("see:: this");
});
it("ignores the trailing whitespace this model puts on every line", () => {
// markdown hard line breaks: "::task translate "
expect(proseOf("keep\n::task translate \n나 바다 \n::words \n물 | water")).toBe("keep");
});
it("treats ::progress, which has no closing ::, as running to the end", () => {
expect(proseOf("keep\n::progress 40 | going well")).toBe("keep");
});
});
/* A verbatim capture of gpt-oss-20b answering "Start unit 1.1." against the
real assembled prompt — the reply behind the second report of raw markup
showing above the exercise.
It differs from the first capture in the way that mattered: here ::gloss
comes first, and removing it took the "::" off the ::task that followed,
so what leaked was the bare word "task translate" with its five sentences
under it. A rule that looked for lines beginning "::" could not see that,
which is why the body is now read from the raw text instead. */
describe("gpt-oss-20b, unit 1.1 — the reported reply", () => {
const RAW = readFileSync(
new URL("../fixtures/gpt-oss-20b-unit-1.1.txt", import.meta.url),
"utf8",
);
it("puts the blocks in the order that breaks lib/blocks.js", () => {
const order = RAW.split("\n")
.filter((l) => /^\s*::\w/.test(l))
.map((l) => l.trim().split(/\s/)[0]);
expect(order).toEqual(["::gloss", "::task", "::words", "::progress"]);
});
it("renders the exercise", () => {
expect(parseMessage(RAW).task?.type).toBe("translate");
});
it("leaves no directive, and no de-colonised directive, in the prose", () => {
const body = parseMessage(RAW).body;
expect(body).not.toMatch(/(^|\n)\s*::/);
// The exact shape of this leak: the marker stripped, the word left.
expect(body).not.toMatch(/(^|\n)\s*(task|words|gloss|progress)\b/);
});
it("does not repeat the exercise as prose above it", () => {
const parsed = parseMessage(RAW);
const task = parsed.task;
// Task is a union; only the non-match kinds carry `items`.
const raw = task && "items" in task ? (task.items as { q?: string }[]) : [];
const items = raw.map((i) => i.q).filter((q): q is string => Boolean(q));
expect(items.length).toBeGreaterThan(1);
/* Not "no item appears": this reply uses 나 바다 as a worked example in
the prose AND as the first exercise line, which is legitimate. A leak
drags in every line, so that is what to measure. */
const echoed = items.filter((q) => parsed.body.includes(q));
expect(echoed.length).toBeLessThan(items.length);
});
it("keeps the teaching prose that came before the blocks", () => {
expect(parseMessage(RAW).body).toContain("Korean syllables are written as blocks");
});
});
/* A gloss block the model forgot to close. Every local model tested does
this, and lib/blocks.js then reads the following paragraph as gloss
parts — two sentences of English rendered as Korean example text inside
the card, at example-line size. */
describe("an unclosed ::gloss block", () => {
const RAW = [
"Here is an example.",
"::gloss",
"나 | S | I",
"바다 | O | sea",
"= I sea",
"",
"(Here 바다 is two blocks.)",
"Now practice these.",
"::task translate",
"나 바다",
].join("\n");
it("lib alone reads the following prose as gloss parts — pinned as it is", () => {
const parts = (parse(RAW).gloss ?? []).flatMap((g) => g.parts.map((p) => p.ko));
expect(parts).toContain("Now practice these.");
});
it("ends the block where its rows end", () => {
expect(glossContent("나 | S | I\n= I sea\n\nprose here\nmore prose")).toBe("나 | S | I\n= I sea\n");
});
it("keeps only the real gloss rows in the card", () => {
const gloss = parseMessage(RAW).gloss ?? [];
expect(gloss).toHaveLength(1);
expect(gloss[0]!.parts.map((p) => p.ko)).toEqual(["나", "바다"]);
expect(gloss[0]!.en).toBe("I sea");
});
it("returns the stranded prose to the message", () => {
const body = parseMessage(RAW).body;
expect(body).toContain("Here is an example.");
expect(body).toContain("(Here 바다 is two blocks.)");
expect(body).toContain("Now practice these.");
// and still no exercise text or markup
expect(body).not.toContain("나 바다");
expect(body).not.toMatch(/::/);
});
it("finds a second gloss block written straight after the first", () => {
// Both blocks share one terminator, so lib alone never sees the second.
const src = "::gloss\n나 | S | I\n= I.\n::gloss\n밥 | O | rice\n= Rice.\n::";
expect(parse(src).gloss).toHaveLength(1);
expect(parseMessage(src).gloss!.map((g) => g.en)).toEqual(["I.", "Rice."]);
expect(parseMessage(src).body).toBe("");
});
it("leaves a properly closed block exactly as lib parses it", () => {
const closed = "::gloss\n물 | N | water\n= Water.\n::\nAfter.";
const g = parseMessage(closed).gloss ?? [];
expect(g).toHaveLength(1);
expect(g[0]!.parts.map((p) => p.ko)).toEqual(["물"]);
expect(parseMessage(closed).body).toBe("After.");
});
});
/* A recall round with gpt-oss-20b, captured verbatim through the server:
the exercise, his answer, and the marking. The model closed none of its
blocks, and each unclosed block swallowed what followed it. */
describe("gpt-oss-20b, a recall round — blocks it never closed", () => {
const round = JSON.parse(
readFileSync(new URL("../fixtures/gpt-oss-20b-recall-round.json", import.meta.url), "utf8"),
) as { answered: string; marking: string; reply: string };
const recall = (task: ParsedMessage["task"]) => {
if (!task || task.type !== "recall") throw new Error(`expected a recall task, got ${task?.type}`);
return task.items;
};
it("shows the feedback written after ::result, instead of reading it as marks", () => {
const m = parseMessage(round.marking);
expect(m.body).toContain("You wrote an extra line");
expect(m.body).toContain("your score for this unit is about");
expect(m.body).not.toMatch(/\n{3,}|---$/);
expect(m.results?.map((r) => r.item)).toEqual(["I", "you", "we", "there", "one"]);
});
it("never makes a markdown rule an exercise item", () => {
expect(recall(parseMessage(round.answered).task).map((i) => i.q)).toEqual([
"I",
"you",
"we",
"there",
"one",
]);
expect(parse(round.answered).task?.rows).toContain("---"); // lib, unaided
});
it("drops a recall hint that is the answer itself", () => {
const items = recall(parseMessage(round.marking).task);
expect(items.map((i) => [i.q, i.hint])).toEqual([
["two", ""],
["that", ""],
["I (humble)", ""],
]);
});
it("keeps only the Korean line of a translate row", () => {
expect(translateItems(parseMessage(round.reply).task).map((i) => i.q)).toEqual([
"나",
"너",
"우리",
"거기",
"하나",
]);
});
it("keeps the closing remark after ::words as prose", () => {
expect(parseMessage(round.answered).body).toContain("You have just started");
});
});