Files
Hankan/test/domain/marking.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

250 lines
9.9 KiB
TypeScript

/* Which word each mark is for — against three real gpt-oss-20b sessions:
one that wrote ::result English prompt first, one that wrote no ::result
at all and marked in prose, and one that fenced every block in ``` and put
the right answer where the slip belongs. Captured verbatim through the
server. */
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { readFileSync } from "node:fs";
import type { Db } from "@app/db/types.js";
import { parseMessage } from "@app/domain/gloss.js";
import { readMarks, readResults } from "@app/domain/marking.js";
import { expectedFor } from "@app/domain/letters.js";
import { applyReply } from "@app/domain/turn.js";
import { readEvidence } from "@app/domain/evidence.js";
import { isExerciseAnswer, noteAnswer, readProgress } from "@app/domain/progress.js";
import { dictionaryDb } from "../helpers/dict-db.js";
const round = JSON.parse(
readFileSync(new URL("../fixtures/gpt-oss-20b-recall-round.json", import.meta.url), "utf8"),
) as { answered: string; answer: string; marking: string };
const answered = parseMessage(round.answered);
const marking = parseMessage(round.marking);
describe("reading the marks", () => {
it("attaches the model's English-first rows to the words they mark", () => {
expect(readResults(marking.results, answered)).toEqual([
{ item: "나", ok: true, mistakenFor: "" },
{ item: "너", ok: true, mistakenFor: "" },
{ item: "우리", ok: false, mistakenFor: "우라" },
{ item: "거기", ok: true, mistakenFor: "" },
{ item: "하나", ok: true, mistakenFor: "" },
]);
});
it("leaves rows written as the prompt asks exactly as they are", () => {
const rows = [
{ item: "닭", ok: true, mistakenFor: "" },
{ item: "여덟", ok: false, mistakenFor: "여덜" },
];
expect(readResults(rows, answered)).toEqual(rows);
});
it("takes the word out of quotation marks, and what he wrote out of an arrow", () => {
expect(readResults([{ item: "“우리”", ok: false, mistakenFor: "우라 → 우리" }], null)).toEqual([
{ item: "우리", ok: false, mistakenFor: "우라" },
]);
});
it("does not guess which word of a sentence a mark is for", () => {
const rows = [{ item: "머리 아파", ok: false, mistakenFor: "read 머리 as leg" }];
expect(readResults(rows, answered)).toEqual(rows);
});
it("does not attach an English row that is not one of the exercise's prompts", () => {
const rows = [{ item: "the sea", ok: false, mistakenFor: "" }];
expect(readResults(rows, answered)).toEqual(rows);
});
});
describe("the round, recorded", () => {
let db: Db;
beforeAll(async () => {
db = await dictionaryDb(1);
});
afterAll(async () => {
vi.unstubAllGlobals();
await db.close();
});
it("puts every mark from the live round into the evidence", async () => {
const applied = await applyReply(db, marking, { lookups: [], today: 20_000, answered });
expect(applied.results?.recorded).toEqual(["나", "너", "우리", "거기", "하나"]);
expect(applied.results?.ignored).toEqual([]);
const evidence = await readEvidence(db, ["우리", "나"]);
expect(evidence.get("우리")).toMatchObject({ ok: 0, wrong: 1 });
expect(evidence.get("나")).toMatchObject({ ok: 1, wrong: 0 });
expect(await db.get("SELECT mistook FROM confusion WHERE word = '우리'")).toEqual({ mistook: "우라" });
});
});
/* The second session: no ::result at all, marks in prose, and a ::progress
line on messages that answered nothing. The whole transcript, verbatim. */
const session = JSON.parse(
readFileSync(new URL("../fixtures/gpt-oss-20b-prose-marks.json", import.meta.url), "utf8"),
) as { turns: { role: "user" | "assistant"; body: string }[] };
const turn = (i: number) => parseMessage(session.turns[i]!.body);
describe("marks written in prose", () => {
it("reads ✓ and ✗ lines through the prompts of the exercise he answered", () => {
expect(turn(3).results).toBeNull();
expect(readMarks(turn(3), turn(1))).toEqual([
{ item: "나", ok: true, mistakenFor: "" },
{ item: "너", ok: true, mistakenFor: "" },
{ item: "이", ok: true, mistakenFor: "" },
{ item: "그", ok: true, mistakenFor: "" },
{ item: "저", ok: false, mistakenFor: "나" },
]);
});
it("tells the prompt a line is about from a shorter one inside it", () => {
// "I (humble)" contains "I"; the longer prompt is the one meant.
expect(readMarks(turn(7), turn(5))).toEqual([
{ item: "바다", ok: true, mistakenFor: "" },
{ item: "다리", ok: false, mistakenFor: "나" },
{ item: "저", ok: false, mistakenFor: "나" },
{ item: "너", ok: true, mistakenFor: "" },
{ item: "하나", ok: true, mistakenFor: "" },
]);
});
it("marks nothing the answered exercise did not ask for", () => {
const stray = parseMessage("✓ 바다 | the sea\n✗ 소리 | sound → 소라");
expect(readMarks(stray, turn(1))).toBeNull();
});
it("prefers the ::result block when there is one", () => {
expect(readMarks(marking, answered)?.map((r) => r.item)).toEqual(["나", "너", "우리", "거기", "하나"]);
});
});
describe("the second session, replayed", () => {
let db: Db;
beforeAll(async () => {
db = await dictionaryDb(1);
});
afterAll(async () => {
vi.unstubAllGlobals();
await db.close();
});
it("records every round, and moves progress only in reply to an answer", async () => {
const stored: number[] = [];
let exercise: ReturnType<typeof parseMessage> | null = null;
for (let i = 1; i < session.turns.length; i++) {
const { role, body } = session.turns[i]!;
if (role === "user") {
await noteAnswer(db, await readProgress(db), body);
continue;
}
const reply = parseMessage(body);
const asked = session.turns[i - 1]!;
await applyReply(db, reply, {
lookups: [],
today: 20_000,
answered: exercise,
afterAnswer: asked.role === "user" && isExerciseAnswer(asked.body),
});
if (reply.task) exercise = reply;
stored.push((await readProgress(db)).confidence["1.1"] ?? 0);
}
// The model reported 80, 60, 65, 70, 75, 80. Clamped, and read only
// after an answer: 80 → 25, 60 → 50, 70 → 70; the requests change nothing.
expect(stored).toEqual([0, 25, 25, 50, 50, 70, 70, 70]);
const evidence = await readEvidence(db, ["저", "거기", "나무"]);
expect(evidence.get("저")).toMatchObject({ ok: 0, wrong: 2 });
expect(evidence.get("거기")).toMatchObject({ wrong: 1 });
expect(evidence.get("나무")).toMatchObject({ ok: 1 });
expect(await db.get("SELECT mistook FROM confusion WHERE word = '거기'")).toEqual({ mistook: "여기" });
});
});
/* The third session: every block fenced in ```, the right answer where the
slip belongs, and qualified prompts — "I (humble)" beside "I". */
const fenced = JSON.parse(
readFileSync(new URL("../fixtures/gpt-oss-20b-fenced-blocks.json", import.meta.url), "utf8"),
) as { turns: { role: "user" | "assistant"; body: string }[] };
const fencedTurn = (i: number) => parseMessage(fenced.turns[i]!.body);
describe("fenced blocks and qualified prompts", () => {
it("reads the blocks inside the fences, and leaves no fence in the prose", () => {
const m = fencedTurn(3);
expect(m.results).toHaveLength(17);
expect(m.task?.type).toBe("recall");
expect(m.body).not.toContain("```");
});
it("tells I (humble) from I, and there from over there", () => {
const { words } = fencedTurn(3);
expect(expectedFor("I (humble)", words)).toBe("저");
expect(expectedFor("I", words)).toBe("나");
expect(expectedFor("There (there)", words)).toBe("거기");
});
it("drops every hint that is one of the message's own words", () => {
const task = fencedTurn(3).task;
if (task?.type !== "recall") throw new Error("expected recall");
expect(task.items.map((i) => i.hint)).toEqual(["", "", "", "", "", ""]);
});
it("takes what he mistook a word for from what he wrote, when the mark gives the right answer", () => {
expect(readMarks(fencedTurn(3), fencedTurn(1), fenced.turns[2]!.body)?.find((r) => r.item === "우리")).toEqual({
item: "우리",
ok: false,
mistakenFor: "우라",
});
});
});
describe("the third session, replayed", () => {
let db: Db;
beforeAll(async () => {
db = await dictionaryDb(1);
});
afterAll(async () => {
vi.unstubAllGlobals();
await db.close();
});
it("files every mark under the word it is for", async () => {
let exercise: ReturnType<typeof parseMessage> | null = null;
for (let i = 1; i < fenced.turns.length; i++) {
const { role, body } = fenced.turns[i]!;
if (role === "user") {
await noteAnswer(db, await readProgress(db), body);
continue;
}
const asked = fenced.turns[i - 1]!;
const reply = parseMessage(body);
await applyReply(db, reply, {
lookups: [],
today: 20_000,
answered: exercise,
answer: asked.body,
afterAnswer: asked.role === "user" && isExerciseAnswer(asked.body),
});
if (reply.task) exercise = reply;
}
const evidence = await readEvidence(db, ["저", "나", "거기", "우리"]);
// "I (humble) → 나" was marked wrong in round two and right in round
// three; it used to be filed under 나, and dropped as a repeat.
expect(evidence.get("저")).toMatchObject({ ok: 1, wrong: 1 });
expect(evidence.get("나")).toMatchObject({ ok: 2, wrong: 0 });
expect(evidence.get("거기")).toMatchObject({ ok: 1, wrong: 1 });
expect(evidence.get("우리")).toMatchObject({ ok: 0, wrong: 1 });
const confusions = await db.all<{ word: string; mistook: string }>(
"SELECT word, mistook FROM confusion ORDER BY word",
);
expect(confusions).toEqual([
{ word: "거기", mistook: "여기" },
{ word: "우리", mistook: "우라" },
{ word: "저", mistook: "나" },
]);
});
});