From a78dac98fa8292e3a72d2ff21e1ab31bfba08221 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 13 Sep 2026 10:09:32 +0200 Subject: [PATCH] fix(magazine): catch vision OCR decoding loops before they become page text 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 --- CHANGELOG.md | 10 ++++++ VERSION | 2 +- architecture/magazine-ingestion.md | 9 +++-- core/magazine.py | 35 +++++++++++++++++++ tests/test_magazine.py | 56 ++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a6314..f158457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Fixed diff --git a/VERSION b/VERSION index 551d4f5..d8bc596 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.27.3 \ No newline at end of file +1.27.4 \ No newline at end of file diff --git a/architecture/magazine-ingestion.md b/architecture/magazine-ingestion.md index 11522bf..f435cc3 100644 --- a/architecture/magazine-ingestion.md +++ b/architecture/magazine-ingestion.md @@ -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 (`native_chars < 120`), or one whose already-extracted text — native *or* tesseract — scores below `ocr_quality_score`'s coarse garbage threshold. -Any failure (unreachable engine, timeout, unparseable output) returns `None` -and the page falls back to tesseract exactly as if vision OCR were not -configured; ingestion never fails because of it. +Any failure (unreachable engine, timeout, unparseable output, or a decoding +loop — `_looks_like_repetition_loop` treats a short phrase repeated 8+ times +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 c't magazine excerpt (Epson ES-580W, no scanner-side OCR) — every garbled diff --git a/core/magazine.py b/core/magazine.py index fa3cb72..4552088 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -976,6 +976,12 @@ def _whole_page_region(content: str, png_path: Path) -> list[dict[str, Any]] | N ) or latex_loop: logging.getLogger(__name__).warning("Vision OCR returned a repeated formatting artifact; discarding response") 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: return None 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}] +# 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, timeout: float = 150.0) -> list[dict[str, Any]] | None: """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 if not isinstance(content, str): 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) if regions and unlimited_ocr: diff --git a/tests/test_magazine.py b/tests/test_magazine.py index b2b3d8c..5b41d4a 100644 --- a/tests/test_magazine.py +++ b/tests/test_magazine.py @@ -20,6 +20,7 @@ from core.magazine import ( ocr_page, cluster_running_furniture, snap_visual_bboxes, + _looks_like_repetition_loop, page_ink_grid, BLOCK_FIGURE, looks_like_real_title, @@ -848,6 +849,19 @@ class VisionOcrPageCallTests(unittest.TestCase): self.assertNotIn("ngram_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): # 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 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): """The primary engine (labeled regions, best quality) is tried first; a