fix(magazine): catch vision OCR decoding loops before they become page text
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
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:
parent
b458306ab9
commit
a78dac98fa
10
CHANGELOG.md
10
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user