fix: stabilize OCR and character sheet casting
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
This commit is contained in:
parent
ff827a74bd
commit
037f3dd74d
17
CHANGELOG.md
17
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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 || '—')} · ▼ ${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'),
|
||||
|
||||
@ -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")))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user