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
|
## [1.27.3] — 2026-09-12
|
||||||
|
|
||||||
### Fixed
|
### 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
|
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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user