fix: stabilize OCR and character sheet casting
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

This commit is contained in:
mARTin-B78 2026-09-09 07:51:59 +02:00
parent ff827a74bd
commit 037f3dd74d
5 changed files with 79 additions and 7 deletions

View File

@ -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

View File

@ -1 +1 @@
1.26.2
1.26.3

View File

@ -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:

View File

@ -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) {
<div class="audiobook-actions cs-progress-footer-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div>
</div>
</div>`;
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
? `<div class="lcd-field"><div class="lcd-field-label">Signature Items</div><div class="lcd-field-value">${inv.map(escHtml).join(', ')}</div></div>`
? `<div class="lcd-field"><div class="lcd-field-label">Prägende Gegenstände</div><div class="lcd-field-value">${inv.map(escHtml).join(', ')}</div></div>`
: '';
const attrs = (s.attribute_high || s.attribute_low)
? `<div class="lcd-field"><div class="lcd-field-label">Core Attributes</div><div class="lcd-field-value">▲ ${escHtml(s.attribute_high || '—')} &nbsp;·&nbsp; ▼ ${escHtml(s.attribute_low || '—')}</div></div>`
@ -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)
? `<div class="lcd-empty-note"><span class="mdi mdi-progress-clock"></span> Erscheinung wird ergänzt, sobald eine passende Textstelle verarbeitet wurde.</div>`
: '',
]),
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'),

View File

@ -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")))