From 037f3dd74d9dd8b314a0e5875e0c3a5acf41c51b Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Wed, 9 Sep 2026 07:51:59 +0200 Subject: [PATCH] fix: stabilize OCR and character sheet casting --- CHANGELOG.md | 17 ++++++++++++++ VERSION | 2 +- core/magazine.py | 16 +++++++++++++ static/js/character-sheets.js | 43 ++++++++++++++++++++++++++++++----- tests/test_magazine.py | 8 +++++++ 5 files changed, 79 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6715618..174afa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.26.3] — 2026-09-09 + +### Fixed +- **Poisoned Vision-OCR Responses Became Article Text.** A malformed vision response that echoed its own formatting + instruction and repeated literal `\\n equation` tokens was being accepted as a plausible full-page transcription. + It is now rejected so the fallback OCR engine can run and invalid text is never saved to a magazine page. +- **Character-Sheet Casting Could Stall and Ignore Cancel.** The configured llama-swap gateway now uses its reliable + non-streaming completion path for character sheets instead of malformed pseudo-stream responses. Cancel now aborts + the active browser request immediately and prevents further passages from being scheduled. + +### Changed +- **Visual Character Details Are Always Visible.** Character sheets now retain their *Erscheinung* section while a + passage scan is still looking for visual evidence. It explicitly covers body/appearance (height, build, hair, eyes, + skin and marks), clothing, and signature items, rather than silently hiding the section while empty. + +--- + ## [1.26.2] — 2026-09-09 ### Fixed diff --git a/VERSION b/VERSION index c7c3f33..f8f7381 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.26.2 +1.26.3 diff --git a/core/magazine.py b/core/magazine.py index 2faf4d2..451109e 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -860,6 +860,22 @@ def _whole_page_region(content: str, png_path: Path) -> list[dict[str, Any]] | N other ambiguous region. """ text = (content or "").strip() + # A vision model can occasionally echo a formatting instruction instead + # of reading the page. The observed failure is a long sequence of literal + # ``\\n `equation` `` tokens (not real line breaks), often prefixed with + # "Do not use special symbols". It scores highly under the generic + # letter-ratio heuristic because "equation" is a normal word, so reject + # this distinct response shape before turning it into a page-sized text + # block. Returning None lets vision_ocr_page_chain try the configured + # fallback engine, then preserves the existing OCR if both fail. + escaped_newlines = len(re.findall(r"\\n", text)) + equation_tokens = len(re.findall(r"`?\s*\\?equation\b", text, re.IGNORECASE)) + prompt_echo = "do not use special symbols" in text.lower() + if (escaped_newlines >= 4 and equation_tokens >= 4) or ( + prompt_echo and escaped_newlines >= 2 and equation_tokens >= 2 + ): + logging.getLogger(__name__).warning("Vision OCR returned instruction-echo artifact; discarding response") + return None if len(text) < 20 or ocr_quality_score(text) < _VISION_QUALITY_THRESHOLD: return None try: diff --git a/static/js/character-sheets.js b/static/js/character-sheets.js index 6c980dc..309f2e7 100644 --- a/static/js/character-sheets.js +++ b/static/js/character-sheets.js @@ -7,7 +7,7 @@ (function (root) { const CS_CHUNK_CHARS = 4000; -const _cs = { running: false, cancel: false, cache: {} }; +const _cs = { running: false, cancel: false, abortController: null, cache: {} }; // Mirrors the hardcoded extraction-instructions/schema body in // routes/conversation.py's _charsheets_prepare(). Editable via the "Prompt" @@ -703,11 +703,21 @@ async function csGenerateStream(body, onDelta, outerSignal, idleTimeoutMs = 6000 } } +function csSupportsStreaming(llmUrl) { + // llama-swap (the configured gateway on port 28080) accepts `stream:true` + // but can return a non-SSE body after the model finishes. That leaves the + // live panel waiting and then starts a duplicate blocking request. Use its + // proven non-streaming completion path directly; other OpenAI-compatible + // backends keep the live-output behaviour. + return !/:28080(?:\/|$)/.test(String(llmUrl || '')); +} + async function csGenerate(text, cacheKey, initialRoster, opts = {}) { if (_cs.running) return null; if (!text) { toast('Nothing to analyse', 'error'); return null; } const chunks = (typeof splitTextIntoChunks === 'function') ? splitTextIntoChunks(text, CS_CHUNK_CHARS) : [text]; _cs.running = true; _cs.cancel = false; + _cs.abortController = new AbortController(); // Everything below used to run outside any try/finally that resets // _cs.running — a thrown error anywhere in setup (progress overlay // creation, DOM lookups, etc.) left _cs.running stuck true forever, so @@ -800,7 +810,8 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) { let data = null; let sawDelta = false; try { - data = await csGenerateStream(body, (delta) => { sawDelta = true; prog.thinking(delta); }); + if (!csSupportsStreaming(llm_url)) throw new Error('streaming disabled for this gateway'); + data = await csGenerateStream(body, (delta) => { sawDelta = true; prog.thinking(delta); }, _cs.abortController.signal); // The streaming request can succeed (no exception, valid final // result) while still never having delivered a single visible // delta — some models only send control/reasoning frames over SSE @@ -826,7 +837,7 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) { // behavior for the same non-streaming model. const r = await fetch('/api/character-sheets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + body: JSON.stringify(body), signal: _cs.abortController.signal, }); if (!r.ok) { let detail = `HTTP ${r.status}`; @@ -882,6 +893,11 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) { prog.update(i + 1, `Passage ${i + 1} / ${chunks.length}…`, newCharLabel, csProgressSnapshot(map, changed), [...map.values()]); break; } catch (e) { + // AbortController makes Cancel immediate in the browser, rather + // than leaving the UI frozen until a slow non-streaming completion + // returns. The outer passage loop then exits without scheduling + // another request. + if (_cs.cancel || e?.name === 'AbortError') break; const msg = e.message || String(e); const isRateLimit = /429|too many requests|rate.?limit/i.test(msg); // "Failed to fetch" (a dropped connection, a momentary proxy/DNS @@ -923,6 +939,7 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) { return null; } finally { _cs.running = false; + _cs.abortController = null; } } @@ -1010,7 +1027,13 @@ function csProgress(total, hostEl = null) { `; - root.querySelector('#cs-progress-cancel')?.addEventListener('click', () => { _cs.cancel = true; }); + root.querySelector('#cs-progress-cancel')?.addEventListener('click', (event) => { + _cs.cancel = true; + _cs.abortController?.abort(); + const button = event.currentTarget; + button.disabled = true; + button.textContent = 'Cancelling…'; + }); // Prompt panel — mirrors the audiobook casting "Prompt" button: toggle // visibility, load/save/delete named presets (localStorage), and persist @@ -1768,7 +1791,7 @@ function csLcdSection(icon, label, fieldsHtml, full = false) { function csCardHtml(s, meta = {}) { const inv = (s.inventory || []).filter(Boolean); const invHtml = inv.length - ? `
Signature Items
${inv.map(escHtml).join(', ')}
` + ? `
Prägende Gegenstände
${inv.map(escHtml).join(', ')}
` : ''; const attrs = (s.attribute_high || s.attribute_low) ? `
Core Attributes
▲ ${escHtml(s.attribute_high || '—')}  ·  ▼ ${escHtml(s.attribute_low || '—')}
` @@ -1834,9 +1857,18 @@ function csCardHtml(s, meta = {}) { csLcdField('Religion', s.religious_beliefs, s, 'religious_beliefs'), csLcdField('Ruf', s.reputation, s, 'reputation'), ]), + // Keep visual appearance visible even before the passage that + // describes it is reached. Previously this whole section quietly + // disappeared when physical/clothing/items were empty, making it + // look as though the sheet did not support height, build, hair, + // eyes, clothing or props at all. csLcdSection('mdi-account-outline', 'Erscheinung', [ csLcdField('Körperlich', s.physical, s, 'physical'), csLcdField('Kleidung', s.clothing, s, 'clothing'), + invHtml, + (!s.physical && !s.clothing && !inv.length) + ? `
Erscheinung wird ergänzt, sobald eine passende Textstelle verarbeitet wurde.
` + : '', ]), csLcdSection('mdi-drama-masks', 'Persönlichkeit', [ csLcdField('Eigenheiten', s.mannerisms, s, 'mannerisms'), @@ -1852,7 +1884,6 @@ function csCardHtml(s, meta = {}) { csLcdField('Fertigkeiten', s.skills, s, 'skills'), csLcdField('Besondere Fähigkeiten', s.capabilities, s, 'capabilities'), attrs, - invHtml, ]), csLcdSection('mdi-shield-sword-outline', 'Konflikt', [ csLcdField('Geheimnis / Fataler Fehler', s.secret, s, 'secret'), diff --git a/tests/test_magazine.py b/tests/test_magazine.py index 0dd8131..1c52f17 100644 --- a/tests/test_magazine.py +++ b/tests/test_magazine.py @@ -1086,6 +1086,14 @@ class WholePageVisionFallbackTests(unittest.TestCase): from core.magazine import _whole_page_region self.assertIsNone(_whole_page_region(". | @)X", pathlib.Path("/fake.png"))) + def test_instruction_echo_with_repeated_literal_equations_is_rejected(self): + """Don't save a model's formatting instruction as a whole-page OCR block.""" + from core.magazine import _whole_page_region + poisoned = "Do not use special symbols \\n `equation` \\n `equation` \\n `equation` \\n `equation`" + with mock.patch("PIL.Image.open") as mopen: + mopen.return_value.__enter__.return_value.size = (897, 1049) + self.assertIsNone(_whole_page_region(poisoned, pathlib.Path("/fake.png"))) + def test_too_short_text_is_rejected(self): from core.magazine import _whole_page_region self.assertIsNone(_whole_page_region("Hi.", pathlib.Path("/fake.png")))