Files
Hankan/app/src/domain/gloss.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

246 lines
8.8 KiB
TypeScript

/* Where a tutor message's blocks END — a workaround for lib/blocks.js,
which ships unchanged.
The 16 Sep lib fixed the two defects this module used to work around: a
gloss block now closes at each "=" line, and every block is collected
rather than only the first. What remains is how a block ends, and every
one of these reaches the student.
── 1. Removing one block takes the next block's colons ────────────────
parse() builds `body` by deleting each block with a regex whose
terminator `(?:\n::|$)` is INSIDE the match. Deleting one block therefore
deletes the "::" that opens the block after it, which is then no longer
recognisable as a block at all and stays behind as prose:
::task translate -> words
나 가다 나 | I, me (casual) | pron
::words
나 | I, me (casual) | pron
Which block leaks depends on the order the model chose, and nothing in
the prompt fixes one. So the prose is read from the RAW text, where every
marker is still intact. That is not a repair of parse()'s body — it is
just reading.
── 2. A block runs to the next "::", or to the end ────────────────────
The prompt closes every block with "::". Every local model tested leaves
it off, and lib then reads whatever follows as rows of the block:
· after a gloss block, the paragraph that follows becomes gloss PARTS,
so two sentences of English render as Korean example text;
· after ::result, the tutor's own feedback — "you wrote an extra line",
"your score for this unit is about 5%" — became result rows, so the
student never saw it;
· a markdown rule, "---", became an exercise item, and he was asked to
write the 한글 for "---".
So every block's extent is decided here, from the raw text, and lib is
handed the blocks properly closed — the row formats stay entirely lib's.
A block ends where its rows end: at "::", at the next block, at a rule
line, at a blank line that nothing row-like follows, and — for the
blocks whose rows always carry a "|" — at the first line without one. A
gloss row is `한글 | role | gloss` and its translation starts with "=".
── 3. Rows that give the exercise away ────────────────────────────────
Measured on gpt-oss-20b: translate rows arrived with their meaning
appended ("나 | I"), and recall hints were the answer itself ("two | 이").
A translate row is one Korean line, so a "|" tail is not part of it; a
recall hint that is the expected word, or any word the message declares,
is dropped. Neither loosens anything — the exercise is only ever made
harder to cheat.
And in one session every block came wrapped in a markdown code fence,
which left empty "```" lines in the message. A fence line is dropped. */
import { parse } from "@lib/blocks.js";
import type { ParsedMessage, Task, WordEntry } from "@lib/blocks.js";
import { expectedFor } from "./letters.js";
/** A markdown rule: never a row, and never the start of one. */
const RULE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
/** A code fence. The prompt forbids them; one session wrapped every block
in one. Not prose, not a row: dropped, and it ends a block. */
const FENCE = /^\s*```/;
/** Blocks whose every row carries a "|". */
const PIPED = new Set(["words", "result", "task match", "task build", "task choice"]);
/**
* Is this a line of ::gloss content?
*
* A gloss row is `한글 | role | English` and a translation line starts with
* `=`. Nothing else belongs, so the first line that is neither ends the
* block whether or not the model closed it.
*/
function isGlossContent(line: string): boolean {
const t = line.trim();
return t === "" || t.includes("|") || t.startsWith("=");
}
/** Where a gloss block's content really ends, closed properly or not. */
export function glossContent(body: string): string {
const lines = body.split("\n");
const end = lines.findIndex((l) => !isGlossContent(l));
return (end === -1 ? lines : lines.slice(0, end)).join("\n");
}
interface Block {
/** The opening line, as written: "::task recall", "::progress 40 | note". */
head: string;
/** "gloss", "words", "task recall", "progress" … */
kind: string;
lines: string[];
}
interface Scanned {
prose: string[];
blocks: Block[];
}
function kindOf(head: string): string {
const [name = "", sub = ""] = head.slice(2).trim().split(/\s+/);
return name === "task" ? `task ${sub}` : name;
}
/**
* One pass over the raw text.
*
* A line of exactly "::" closes a block; any other line beginning "::"
* opens one (::gloss, ::task translate, ::words, ::progress 40 | note); a
* ::progress line is a whole block by itself. The rest of the rules are at
* the top of this file. Trailing whitespace is common — one model ends every
* line with markdown's two spaces — so nothing here compares exact strings.
*/
function scan(raw: string): Scanned {
const prose: string[] = [];
const blocks: Block[] = [];
const lines = raw.split("\n");
let block: Block | null = null;
const close = () => {
if (block) blocks.push(block);
block = null;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const t = line.trim();
if (FENCE.test(line)) {
close();
continue;
}
if (t === "::") {
close();
continue;
}
if (t.startsWith("::")) {
close();
const opened: Block = { head: t, kind: kindOf(t), lines: [] };
if (opened.kind === "progress") blocks.push(opened);
else block = opened;
continue;
}
const open = block as Block | null;
if (open) {
if (open.kind === "gloss") {
// An unclosed gloss ends where its rows do; what follows is prose.
if (!isGlossContent(line)) close();
} else if (RULE.test(line)) {
close();
} else if (t === "") {
// A blank line inside a block is only a gap if a row comes next.
const next = lines.slice(i + 1).find((l) => l.trim() !== "")?.trim() ?? "";
if (!next.startsWith("::") && !(next.includes("|") && !RULE.test(next))) close();
} else if (PIPED.has(open.kind) && !t.includes("|")) {
close();
}
}
const current = block as Block | null;
if (current) {
if (t !== "") current.lines.push(line);
} else {
prose.push(line);
}
}
close();
return { prose, blocks };
}
/** The prose of a message: every line that is not inside a block. */
export function proseOf(raw: string): string {
return tidyProse(scan(raw).prose);
}
/**
* The prose, without the separators a closed block leaves behind: runs of
* blank lines, and "---" rules with nothing left to separate at either end.
*/
function tidyProse(lines: string[]): string {
const out = [...lines];
const edge = (l: string | undefined) => l !== undefined && (l.trim() === "" || RULE.test(l));
while (edge(out[0])) out.shift();
while (edge(out[out.length - 1])) out.pop();
return out.join("\n").replace(/\n{3,}/g, "\n\n");
}
/** Has at least one letter — Hangul or Latin — and so can be an item. */
const hasLetter = (s: string) => /\p{L}/u.test(s);
/** Section 3 of the header: rows that give the exercise away, and non-rows. */
function tidyTask(task: Task | null, words: WordEntry[] | null): Task | null {
if (!task) return task;
const rows = task.rows?.filter(hasLetter);
switch (task.type) {
case "translate":
return {
...task,
rows,
items: task.items.map((it) => ({ q: it.q.split("|")[0]!.trim() })).filter((it) => hasLetter(it.q)),
};
case "recall":
return {
...task,
rows,
items: task.items
.filter((it) => hasLetter(it.q))
.map((it) => {
// A hint holding the answer, or any word the message declares,
// is the answer given away. Real hints are about the word:
// "double batchim".
const answer = expectedFor(it.q, words);
const hintWords = it.hint.match(/[가-힣]+/g) ?? [];
const gives =
(answer && it.hint.includes(answer)) || hintWords.some((h) => words?.some((w) => w.ko === h));
return gives ? { ...it, hint: "" } : it;
}),
};
default:
return { ...task, rows };
}
}
/**
* parse(), with every block's extent and the prose decided from the raw
* text. Use this everywhere instead of calling parse() directly.
*/
export function parseMessage(text: string): ParsedMessage {
const { prose, blocks } = scan(text);
const closed = blocks.map((b) => [b.head, ...b.lines, "::"].join("\n")).join("\n");
const parsed = parse(closed);
return {
...parsed,
body: tidyProse(prose),
task: tidyTask(parsed.task, parsed.words),
};
}
export type { ParsedMessage };