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 - ? `