fix(magazine): catch vision OCR decoding loops before they become page text
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

Confirmed live: re-ingesting c't KI Wissen 2026 with a local Qwen VL
engine, page 18's chart transcription degenerated into "das Produkt,
das Produkt, das Produkt, ..." repeated hundreds of times, filling the
whole response budget -- and was accepted as real region text, which
would have been narrated verbatim.

The codebase already had two narrow signatures for specific observed
failures (a formatting-instruction echo, a fabricated LaTeX loop), both
scoped to the plain-text fallback path only. Added a general detector
(_looks_like_repetition_loop): any short phrase containing a real word,
repeated 8+ times immediately back-to-back. Deliberately excludes
patterns with no letters -- a dot-leader contents line, a repeated
number sequence -- which are legitimate and must not be flagged.

Applied at both call sites: vision_ocr_page (the labelled-region path
most engines use) and _whole_page_region (the plain-text fallback).
Treated as a failed call in both, so vision_ocr_page_chain's existing
fallback (secondary engine, then tesseract) gets a real chance instead
of garbage being accepted as the page's transcription.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-13 10:09:32 +02:00
parent b458306ab9
commit a78dac98fa
5 changed files with 108 additions and 4 deletions

View File

@ -5,6 +5,16 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.27.4] — 2026-09-13
### Fixed
- **Vision OCR decoding loops are now caught and treated as a failed call, not baked in as page text.**
- A local vision model can degenerate into repeating the same short phrase hundreds of times instead of transcribing the page — confirmed live: a Qwen VL engine transcribing a population chart returned "das Produkt, das Produkt, ..." until it filled its whole response budget. That text was previously accepted as a real region and would have been narrated verbatim.
- Added a general detector (any short phrase containing a real word, repeated 8+ times immediately back-to-back) alongside the two existing narrow signatures for specific LaTeX/equation-echo failures — the two existing checks only ever matched one engine's specific failure shape, this one is engine- and language-agnostic. Deliberately excludes patterns with no letters (a dot-leader contents line, a repeated number sequence), which are legitimate.
- Applied at both call sites: the labelled-region path (`vision_ocr_page`) and the plain-text fallback path (`_whole_page_region`). A detected loop is treated as a failed engine call, so the existing fallback chain (secondary vision engine, then tesseract) gets a real chance instead.
---
## [1.27.3] — 2026-09-12 ## [1.27.3] — 2026-09-12
### Fixed ### Fixed

View File

@ -1 +1 @@
1.27.3 1.27.4

View File

@ -305,9 +305,12 @@ engine's own labeled-region reply (`parse_vision_ocr_regions`). Escalation
triggers per page, not for the whole document: a page needing OCR at all triggers per page, not for the whole document: a page needing OCR at all
(`native_chars < 120`), or one whose already-extracted text — native (`native_chars < 120`), or one whose already-extracted text — native
*or* tesseract — scores below `ocr_quality_score`'s coarse garbage threshold. *or* tesseract — scores below `ocr_quality_score`'s coarse garbage threshold.
Any failure (unreachable engine, timeout, unparseable output) returns `None` Any failure (unreachable engine, timeout, unparseable output, or a decoding
and the page falls back to tesseract exactly as if vision OCR were not loop — `_looks_like_repetition_loop` treats a short phrase repeated 8+ times
configured; ingestion never fails because of it. back-to-back as a failed call, confirmed live against a Qwen VL engine that
transcribed a chart as "das Produkt, das Produkt, ..." for its entire
response budget) returns `None` and the page falls back to tesseract exactly
as if vision OCR were not configured; ingestion never fails because of it.
**Verified live** against `ocr-baidu-unlimited-ocr` on a real 28-page scanned **Verified live** against `ocr-baidu-unlimited-ocr` on a real 28-page scanned
c't magazine excerpt (Epson ES-580W, no scanner-side OCR) — every garbled c't magazine excerpt (Epson ES-580W, no scanner-side OCR) — every garbled

View File

@ -976,6 +976,12 @@ def _whole_page_region(content: str, png_path: Path) -> list[dict[str, Any]] | N
) or latex_loop: ) or latex_loop:
logging.getLogger(__name__).warning("Vision OCR returned a repeated formatting artifact; discarding response") logging.getLogger(__name__).warning("Vision OCR returned a repeated formatting artifact; discarding response")
return None return None
# The two signatures above are narrow matches for specific observed
# failures; this is the general case -- any short phrase repeated many
# times back-to-back (see vision_ocr_page's docstring on the same check).
if _looks_like_repetition_loop(text):
logging.getLogger(__name__).warning("Vision OCR returned a repeated phrase loop; discarding response")
return None
if len(text) < 20 or ocr_quality_score(text) < _VISION_QUALITY_THRESHOLD: if len(text) < 20 or ocr_quality_score(text) < _VISION_QUALITY_THRESHOLD:
return None return None
try: try:
@ -987,6 +993,28 @@ def _whole_page_region(content: str, png_path: Path) -> list[dict[str, Any]] | N
return [{"label": "text", "bbox": [0.0, 0.0, float(width), float(height)], "text": text}] return [{"label": "text", "bbox": [0.0, 0.0, float(width), float(height)], "text": text}]
# A short phrase repeating many times back-to-back, unlike the two narrow
# formatting-echo signatures above, is not specific to one prompt or one
# engine -- it is the generic decoding-loop failure any local vision model
# can fall into on a busy chart or graphic. Confirmed live: a Qwen VL engine
# transcribing a population chart produced "das Produkt, das Produkt, das
# Produkt, ..." several hundred times over, filling the whole response
# budget. Genuine text essentially never repeats an exact phrase this many
# times consecutively, so this is a safe, general trigger rather than
# another one-off signature -- but a leader-dot table of contents line
# ("......................") legitimately repeats a short unit too, which is
# why the repeated unit must contain a real word, not just punctuation.
_REPETITION_RUN = re.compile(r"(.{2,80}?)\1{7,}", re.DOTALL)
_REPETITION_WORD = re.compile(r"[A-Za-zÀ-ÖØ-öø-ÿ]{2,}")
def _looks_like_repetition_loop(text: str) -> bool:
"""True when `text` degenerates into the same short phrase repeated
immediately back-to-back many times -- see vision_ocr_page."""
return any(_REPETITION_WORD.search(m.group(1))
for m in _REPETITION_RUN.finditer(text or ""))
def vision_ocr_page(png_path: Path, base_url: str, model: str, def vision_ocr_page(png_path: Path, base_url: str, model: str,
timeout: float = 150.0) -> list[dict[str, Any]] | None: timeout: float = 150.0) -> list[dict[str, Any]] | None:
"""OCR one rendered page with a connected vision-capable OCR engine. """OCR one rendered page with a connected vision-capable OCR engine.
@ -1067,6 +1095,13 @@ def vision_ocr_page(png_path: Path, base_url: str, model: str,
return None return None
if not isinstance(content, str): if not isinstance(content, str):
return None return None
if _looks_like_repetition_loop(content):
# Worse than no response at all: a repeated phrase baked into a
# block's text would be narrated verbatim. Treating it as a failed
# call gives the existing fallback chain (vision_ocr_page_chain) a
# real shot instead.
logging.getLogger(__name__).warning("Vision OCR returned a repeated phrase loop; discarding response")
return None
regions = parse_unlimited_ocr_regions(content) if unlimited_ocr else parse_vision_ocr_regions(content) regions = parse_unlimited_ocr_regions(content) if unlimited_ocr else parse_vision_ocr_regions(content)
if regions and unlimited_ocr: if regions and unlimited_ocr:

View File

@ -20,6 +20,7 @@ from core.magazine import (
ocr_page, ocr_page,
cluster_running_furniture, cluster_running_furniture,
snap_visual_bboxes, snap_visual_bboxes,
_looks_like_repetition_loop,
page_ink_grid, page_ink_grid,
BLOCK_FIGURE, BLOCK_FIGURE,
looks_like_real_title, looks_like_real_title,
@ -848,6 +849,19 @@ class VisionOcrPageCallTests(unittest.TestCase):
self.assertNotIn("ngram_size", payload) self.assertNotIn("ngram_size", payload)
self.assertNotIn("window_size", payload) self.assertNotIn("window_size", payload)
def test_a_repeated_phrase_loop_is_treated_as_a_failed_call(self):
"""Confirmed live against a Qwen VL engine on a busy chart page: the
response degenerated into "das Produkt, " repeated hundreds of
times, filling the whole response budget. Baking that into a
block's text would narrate it verbatim, so it must come back as a
failure -- giving vision_ocr_page_chain's fallback engine, then
tesseract, a real chance -- not as a labelled region."""
body = {"choices": [{"message": {"content":
"text [10,10,880,900]Das Weitere Produkt, " + "das Produkt, " * 300}}]}
with mock.patch("core.magazine.request_with_retries",
return_value=self._resp(json_body=body)):
self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "qwen-vl"))
class UnlimitedOcrRegionTests(unittest.TestCase): class UnlimitedOcrRegionTests(unittest.TestCase):
# The URL/error-path tests below remain deliberately adjacent to the # The URL/error-path tests below remain deliberately adjacent to the
@ -1163,6 +1177,48 @@ class WholePageVisionFallbackTests(unittest.TestCase):
from core.magazine import _whole_page_region from core.magazine import _whole_page_region
self.assertIsNone(_whole_page_region("", pathlib.Path("/fake.png"))) self.assertIsNone(_whole_page_region("", pathlib.Path("/fake.png")))
def test_a_repeated_phrase_loop_with_no_known_signature_is_rejected(self):
"""Confirmed live: a Qwen VL engine transcribing a population chart
produced "das Produkt, das Produkt, ..." several hundred times --
a generic decoding loop, not one of the two specific LaTeX/equation
signatures above."""
from core.magazine import _whole_page_region
poisoned = "Das Weitere Produkt, " + "das Produkt, " * 60
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")))
class RepetitionLoopDetectionTests(unittest.TestCase):
"""The general "any short phrase repeated many times back-to-back"
check backing the two call sites above -- must catch a decoding loop in
any language or engine without a specific per-failure signature, while
never flagging patterns real OCR output legitimately produces."""
def test_repeated_phrase_is_detected(self):
self.assertTrue(_looks_like_repetition_loop("das Produkt, " * 20))
def test_repeated_bare_word_is_detected(self):
self.assertTrue(_looks_like_repetition_loop("Produkt " * 20))
def test_a_sentence_repeated_a_few_times_is_not_flagged(self):
"""Genuine text can restate a short phrase a handful of times --
only a long run is a decoding loop, not three occurrences."""
self.assertFalse(_looks_like_repetition_loop(
"Wichtig: Bitte prüfen. " * 3 + "Der Rest des Artikels folgt hier normal."))
def test_a_dot_leader_table_of_contents_line_is_not_flagged(self):
self.assertFalse(_looks_like_repetition_loop(
"Studien widerlegen Job-Killer-Mythos" + "." * 60 + "6"))
def test_a_repeated_numeric_sequence_is_not_flagged(self):
"""No letters in the repeated unit -- a legitimate table/axis run,
not a language-model decoding loop."""
self.assertFalse(_looks_like_repetition_loop("1 2 3 4 5 6 7 8 9 10 " * 5))
def test_empty_text_is_not_flagged(self):
self.assertFalse(_looks_like_repetition_loop(""))
class VisionEngineChainTests(unittest.TestCase): class VisionEngineChainTests(unittest.TestCase):
"""The primary engine (labeled regions, best quality) is tried first; a """The primary engine (labeled regions, best quality) is tried first; a