From 8236ea3504be913a07aa376cf1c8d36cd6e4832f Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 29 Oct 2026 21:55:48 +0100 Subject: [PATCH] End the hint prompt in a full stop: an unterminated list made Whisper drop punctuation and loop Co-Authored-By: Claude Opus 5.5 --- server-py/sidecar.py | 10 ++++++++-- src/lib/hint.ts | 17 ++++++++++++----- tests/hint.test.ts | 25 +++++++++++++++++-------- tests/prompt.test.ts | 4 ++-- 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/server-py/sidecar.py b/server-py/sidecar.py index 349c135..65810f2 100644 --- a/server-py/sidecar.py +++ b/server-py/sidecar.py @@ -65,9 +65,15 @@ def normalize_hint(raw): return ", ".join(part for part in parts if part) +def _prompt_sentence(text): + return " " + text if re.search(r"[.?!]$", text) else " " + text + "." + + def fit_prompt_ids(hint, encode, budget=PROMPT_TOKEN_BUDGET): """Prompt token ids for `hint`, cut to `budget` tokens at a whole word. + The prompt is a space-led sentence ending in a full stop (unless it ends + in . ? ! already) - an unterminated list drops punctuation and loops. The start of the list is kept and the end dropped; a dangling ","/";" is removed; a first word longer than the budget is cut at the budget. `encode` must add no special tokens. @@ -75,13 +81,13 @@ def fit_prompt_ids(hint, encode, budget=PROMPT_TOKEN_BUDGET): text = normalize_hint(hint) if not text: return [] - full = encode(" " + text) + full = encode(_prompt_sentence(text)) if len(full) <= budget: return full words = text.split(" ") def encode_prefix(count): - return encode(" " + re.sub(r"[,;]+$", "", " ".join(words[:count]))) + return encode(_prompt_sentence(re.sub(r"[,;]+$", "", " ".join(words[:count])))) low, high = 0, len(words) - 1 while low < high: diff --git a/src/lib/hint.ts b/src/lib/hint.ts index f66a6fe..ec41312 100644 --- a/src/lib/hint.ts +++ b/src/lib/hint.ts @@ -72,10 +72,13 @@ function defaultStorage(): StorageLike | null { * Token ids for the hint as a Whisper prompt (without `<|startofprev|>`), * truncated predictably to `budget` tokens (D-WS2a): * - * 1. Encode `" " + normalizeHint(hint)` (Whisper prompts start with a space). + * 1. Prompt text = `" " + normalizeHint(hint)` (Whisper prompts start with a + * space), ending in a full stop unless it already ends in . ? or ! — + * measured: an unterminated list makes Whisper drop punctuation and, on + * smaller models, fall into repetition loops (D-WS2a). * 2. Over budget → keep the longest prefix of whole space-separated words that - * fits, with a dangling "," / ";" removed: the start of the list is kept, - * the end dropped, never mid-word. + * fits (full stop included), a dangling "," / ";" removed: the start of the + * list is kept, the end dropped, never mid-word. * 3. Not even the first word fits → its first `budget` tokens. * * `encode` must add no special tokens. Mirrored by `fit_prompt_ids` in @@ -88,12 +91,12 @@ export function fitHintToBudget( ): number[] { const text = normalizeHint(hint); if (text.length === 0) return []; - const full = encode(` ${text}`); + const full = encode(promptSentence(text)); if (full.length <= budget) return full; const words = text.split(" "); const encodePrefix = (count: number) => - encode(` ${words.slice(0, count).join(" ").replace(/[,;]+$/, "")}`); + encode(promptSentence(words.slice(0, count).join(" ").replace(/[,;]+$/, ""))); // Largest word count whose encoding fits; token count grows with the // prefix, so binary search needs O(log n) encodes even for huge pastes. let low = 0; @@ -106,3 +109,7 @@ export function fitHintToBudget( if (low === 0) return encode(` ${words[0]}`).slice(0, budget); return encodePrefix(low); } + +function promptSentence(text: string): string { + return /[.?!]$/.test(text) ? ` ${text}` : ` ${text}.`; +} diff --git a/tests/hint.test.ts b/tests/hint.test.ts index f818adc..6c5726a 100644 --- a/tests/hint.test.ts +++ b/tests/hint.test.ts @@ -72,14 +72,22 @@ describe("fitHintToBudget (D-WS2a truncation rule)", () => { expect(fitHintToBudget(" \n", charEncode)).toEqual([]); }); - test("a hint within budget is encoded with Whisper's leading space", () => { + test("a hint within budget is encoded as a space-led sentence ending in a full stop", () => { + // Measured (D-WS2a): an unterminated list prompt suppresses punctuation + // and triggers repetition loops on smaller models; a full stop does not. expect(fitHintToBudget("Maxwell\nVoices of the Void", charEncode)).toEqual( - charEncode(" Maxwell, Voices of the Void"), + charEncode(" Maxwell, Voices of the Void."), ); }); - test("over budget: keeps whole leading words and drops a dangling comma", () => { - expect(charDecode(fitHintToBudget("alpha, beta gamma", charEncode, 10))).toBe(" alpha"); + test("a hint already ending in . ? or ! gets no extra full stop", () => { + expect(charDecode(fitHintToBudget("Is it Maxwell?", charEncode))).toBe(" Is it Maxwell?"); + expect(charDecode(fitHintToBudget("Dr. Bao.", charEncode))).toBe(" Dr. Bao."); + }); + + test("over budget: keeps whole leading words, drops a dangling comma, ends in a full stop", () => { + expect(charDecode(fitHintToBudget("alpha, beta gamma", charEncode, 10))).toBe(" alpha."); + expect(charDecode(fitHintToBudget("alpha, beta gamma", charEncode, 13))).toBe(" alpha, beta."); }); test("a single word longer than the budget is cut at the budget", () => { @@ -96,10 +104,11 @@ describe("fitHintToBudget (D-WS2a truncation rule)", () => { expect(ids.length).toBeLessThanOrEqual(223); expect(ids.length).toBeGreaterThan(200); const kept = charDecode(ids); - expect(` ${hint}`.startsWith(kept)).toBe(true); - // Ends on a whole term: the next character of the hint is a separator. - expect(kept.endsWith(",")).toBe(false); - expect([",", " "]).toContain(` ${hint}`.charAt(kept.length)); + // Ends on a whole term plus the full stop: the hint continues with a separator there. + expect(kept.endsWith(".")).toBe(true); + const body = kept.slice(0, -1); + expect(` ${hint}`.startsWith(body)).toBe(true); + expect([",", " "]).toContain(` ${hint}`.charAt(body.length)); }); test("is deterministic", () => { diff --git a/tests/prompt.test.ts b/tests/prompt.test.ts index a91981e..34d5d44 100644 --- a/tests/prompt.test.ts +++ b/tests/prompt.test.ts @@ -110,10 +110,10 @@ describe("buildPromptPrefix", () => { expect(buildPromptPrefix(tokenizer, " \n ")).toEqual([]); }); - test("a hint → <|startofprev|> then the space-led hint tokens", () => { + test("a hint → <|startofprev|> then the space-led, full-stopped hint tokens", () => { expect(buildPromptPrefix(tokenizer, "Max\nVoid")).toEqual([ PREV, - ...[..." Max, Void"].map((c) => c.charCodeAt(0)), + ...[..." Max, Void."].map((c) => c.charCodeAt(0)), ]); });