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)}
- ) : (
-
+ {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