diff --git a/app/src/domain/gloss.ts b/app/src/domain/gloss.ts index 1c59d7b..9e40b79 100644 --- a/app/src/domain/gloss.ts +++ b/app/src/domain/gloss.ts @@ -69,27 +69,92 @@ function splitSentences(body: string): string[] { * Use this everywhere instead of calling parse() directly. */ /** - * Cut the body at the first directive line that survived parse(). + * The prose of a message: every line that is not inside a block. * - * Exported for the test that pins the lib bug this exists for. + * Computed from the RAW text, not from parse()'s body. Repairing that body + * cannot be made to work, because parse() removes each block by string + * surgery and the removals damage each other — a block's terminator + * `(?:\n::|$)` is inside its own match, so removing one block takes the + * two colons belonging to the NEXT one with it. What is left behind is not + * even recognisable as a directive any more: + * + * **Example sentence** + * *나 바다* + * task translate ← the "::" is gone; the items follow as prose + * 나 바다 + * + * An earlier version of this dropped everything from the first line + * starting with "::", which cannot see that. The raw text always has its + * markers, so scanning it is not a repair at all — it is just reading. + * + * The grammar is small: a line of exactly "::" closes a block, any other + * line beginning "::" opens one (::gloss, ::task translate, ::words, + * ::progress 40 | note), and a block runs until it is closed, until the + * next block opens, or to the end. Trailing whitespace is common — this + * model ends every line with markdown's two spaces — so nothing here + * compares exact strings. */ -export function stripLeakedBlocks(body: string): string { +/** + * Is this a line of ::gloss content? + * + * A gloss row is `한글 | English | note` and a translation line starts with + * `=`. Nothing else belongs. The check matters because a model that forgets + * the closing `::` leaves its next paragraph inside the block, and lib then + * reads that prose as gloss parts — which is how two sentences of English + * ended up rendered as Korean example text inside a gloss card. + */ +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 at = lines.findIndex((l) => l.trimStart().startsWith("::")); - return at === -1 ? body : lines.slice(0, at).join("\n").trimEnd(); + const end = lines.findIndex((l) => !isGlossContent(l)); + return (end === -1 ? lines : lines.slice(0, end)).join("\n"); +} + +export function proseOf(raw: string): string { + const out: string[] = []; + let block: string | null = null; + + for (const line of raw.split("\n")) { + const t = line.trim(); + if (t === "::") { + block = null; + continue; + } + if (t.startsWith("::")) { + block = t.slice(2).split(/\s/)[0] ?? null; + continue; + } + // An unclosed gloss ends where its rows do; what follows is prose again, + // and belongs in the message rather than swallowed into the card. + if (block === "gloss" && !isGlossContent(line)) block = null; + if (!block) out.push(line); + } + + return out.join("\n").trim(); } export function parseMessage(text: string): ParsedMessage { const parsed = parse(text); - const body = stripLeakedBlocks(parsed.body); + // lib/ owns extracting the blocks; the prose around them is ours. + const body = proseOf(text); if (!parsed.gloss) return { ...parsed, body }; const match = text.match(GLOSS_BLOCK); if (!match?.[1]) return { ...parsed, body }; - const sentences = splitSentences(match[1]); - if (sentences.length < 2) return { ...parsed, body }; // common case + /* Rebuilt unconditionally, not only when there are several sentences. + The single-sentence case is where an unclosed block hurts: lib has + already absorbed the following paragraph into it, and returning its + gloss untouched would keep that prose inside the card. Re-parsing from + glossContent() drops it, and proseOf() has put it back in the message. + For a well-formed single-sentence block this reproduces lib exactly. */ + const sentences = splitSentences(glossContent(match[1])); const blocks: GlossBlock[] = []; for (const s of sentences) { diff --git a/app/src/ui/tutor/MessageBody.tsx b/app/src/ui/tutor/MessageBody.tsx index f1886f8..298da06 100644 --- a/app/src/ui/tutor/MessageBody.tsx +++ b/app/src/ui/tutor/MessageBody.tsx @@ -21,17 +21,32 @@ function isKoreanLine(line: string): boolean { return korean / bare.length > 0.55; } -/** **bold** is the only inline markup the prompt permits. */ +/* The prompt permits **bold** and nothing else, and says so outright: "No + headings, tables, code fences or bullet characters." A model that obeys + never exercises anything below. + + Rendering them anyway is not an invitation. Now that the backend is + pluggable, the tutor may be a local model that ignores the rule — and the + failure was ugly and confusing rather than merely untidy: a bulleted list + of the ten consonants arrived as lines beginning with a hyphen, "*What we + learn:*" kept its asterisks, and a "---" divider showed as three dashes. + Degrading gracefully costs little and keeps a lesson readable. */ + +/** **bold**, and *italic* as a concession to models that use it. */ function inline(text: string) { - return text.split(/(\*\*[^*]+\*\*)/g).map((part, i) => - part.startsWith("**") && part.endsWith("**") && part.length > 4 ? ( - {part.slice(2, -2)} - ) : ( - {part} - ), - ); + return text.split(/(\*\*[^*]+\*\*|\*[^*\n]+\*)/g).map((part, i) => { + if (part.startsWith("**") && part.endsWith("**") && part.length > 4) + return {part.slice(2, -2)}; + if (part.startsWith("*") && part.endsWith("*") && part.length > 2) + return {part.slice(1, -1)}; + return {part}; + }); } +const RULE = /^(?:-{3,}|\*{3,}|_{3,})$/; +const HEADING = /^#{1,6}\s+(.*)$/; +const BULLET = /^[-*•]\s+(.*)$/; + export function MessageBody({ text }: { text: string }) { const lines = text.split("\n"); @@ -41,6 +56,27 @@ export function MessageBody({ text }: { text: string }) { const trimmed = line.trim(); if (!trimmed) return
; + if (RULE.test(trimmed)) return
; + + const heading = HEADING.exec(trimmed); + if (heading) { + return ( +

+ {inline(heading[1]!)} +

+ ); + } + + const bullet = BULLET.exec(trimmed); + if (bullet) { + const item = bullet[1]!; + return ( +

+ {inline(item)} +

+ ); + } + const mark = trimmed.startsWith("✓") ? "ok" : trimmed.startsWith("✗") ? "no" : null; const rest = mark ? trimmed.slice(1).trimStart() : trimmed; diff --git a/app/src/ui/tutor/tutor.css b/app/src/ui/tutor/tutor.css index f137d60..401109e 100644 --- a/app/src/ui/tutor/tutor.css +++ b/app/src/ui/tutor/tutor.css @@ -162,3 +162,30 @@ padding: 3px 7px; font-size: 12.5px; } + +/* Markdown a non-compliant model emits. The prompt forbids all of it; these + rules exist so it degrades into something readable instead of showing as + literal hyphens, hashes and asterisks. Deliberately quiet — this is not a + style to encourage. */ +.bubble .mhead { + font-size: 14.5px; + margin-top: 8px; +} + +.bubble .mbullet { + padding-left: 15px; + position: relative; +} + +.bubble .mbullet::before { + content: "·"; + position: absolute; + left: 4px; + color: var(--ink3); +} + +.bubble .mrule { + border: 0; + border-top: 1px solid var(--line); + margin: 10px 0; +} diff --git a/test/domain/block-leak.test.ts b/test/domain/block-leak.test.ts index af2fc88..65343c1 100644 --- a/test/domain/block-leak.test.ts +++ b/test/domain/block-leak.test.ts @@ -18,8 +18,9 @@ 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, stripLeakedBlocks } from "@app/domain/gloss.js"; +import { parseMessage, proseOf, glossContent } from "@app/domain/gloss.js"; import type { ParsedMessage } from "@lib/blocks.js"; @@ -79,14 +80,130 @@ describe("parseMessage — the workaround", () => { }); }); -describe("stripLeakedBlocks", () => { +describe("proseOf", () => { it("does nothing without a directive", () => { - expect(stripLeakedBlocks("a\n\nb")).toBe("a\n\nb"); + expect(proseOf("a\n\nb")).toBe("a\n\nb"); }); - it("cuts from the first directive line", () => { - expect(stripLeakedBlocks("keep\n::task translate\ndrop\n::words\ndrop")).toBe("keep"); + + it("drops every block, not merely from the first one on", () => { + expect(proseOf("keep\n::task translate\ndrop\n::words\ndrop")).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(stripLeakedBlocks("see:: this")).toBe("see:: this"); + 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("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("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."); }); }); diff --git a/test/fixtures/gpt-oss-20b-unit-1.1.txt b/test/fixtures/gpt-oss-20b-unit-1.1.txt new file mode 100644 index 0000000..69a2349 --- /dev/null +++ b/test/fixtures/gpt-oss-20b-unit-1.1.txt @@ -0,0 +1,57 @@ +Korean syllables are written as blocks that combine an **initial consonant** and a **vowel**. +In this unit we only use the plain consonants **ㄱ ㄴ ㄷ ㄹㅁㅂㅅㅇㅈㅎ** and the ten basic vowels **ㅏ ㅑ ㅓ ㅕ ㅗ ㅛ ㅜㅠㅡㅣ**. + +Each block has no final consonant, so a word is simply a sequence of such blocks. +For example: + +- ㄱㅏ (가) +- ㄴㅏ (나) +- ㄷㅏ (다) +- ㄹㅏ (라) +- ㅁㅏ (마) +- ㅂㅏ (바) +- ㅅㅏ (사) +- ㅇㅏ (아) +- ㅈㅏ (자) +- ㅎㅏ (하) + +A word is read by looking at each block in order. +The learner’s mistake often is to treat the whole word as a single unit and miss the internal blocks. + +**Example sentence** +*나 바다* +::gloss +나 | S | I +바다 | O | sea += I sea + +(Here “바다” is two blocks: 바 (ㅂ+아) + 다 (ㄷ+아).) + +Now practice reading and translating these short phrases. + +::task translate +나 바다 +그 나무 +어디 소리 +하나 바다 + +::words +ㄱㅏ | ㄱ + ㅏ +ㄴㅏ | ㄴ + ㅏ +ㄷㅏ | ㄷ + ㅏ +ㄹㅏ | ㄹ + ㅏ +ㅁㅏ | ㅁ + ㅏ +ㅂㅏ | ㅂ + ㅏ +ㅅㅏ | ㅅ + ㅏ +ㅇㅏ | ㅇ + ㅏ +ㅈㅏ | ㅈ + ㅏ +ㅎㅏ | ㅎ + ㅏ +나 | I +바다 | sea +그 | he +나무 | tree +어디 | where +소리 | sound +하나 | one + +::progress 0-100 | starting unit, basics to be mastered. \ No newline at end of file