fix(magazine): drop hallucinated vision-OCR refusal text tiled over unreadable images

TODO A-E marked done for this pass; comparison report saved as evidence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-28 23:26:59 +02:00
parent d06f990379
commit 1fb5528112
6 changed files with 6679 additions and 40 deletions

View File

@ -13,6 +13,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- Spelling agent and acronym resolver call the language model with thinking off (about 4 s instead of about 27 s per call) and retry without the option if the server rejects it.
- The spelling agent treats run-together words (`dasich`) as errors and may split them at a space when both halves are real words and the letters are unchanged.
### Fixed
- Vision OCR: a refusal sentence the engine hallucinates over an unreadable photo or screenshot ("The image is too blurry to recognize...") is no longer narrated as page text -- discarded when the same sentence repeats across 3+ regions, confirmed live on c't KI Wissen 2026 pages 82, 87 and 124 (29 and 122 hallucinated regions dropped on re-OCR).
### Added
- **Paragraphs are fused across columns and pages for narration.** A paragraph that continues into the next column or onto a later page is now one block in the reader and the exported MP3, so a sentence is no longer cut in two at the seam. Words split at the seam are closed up (`jähr-` + `lich`); real compound hyphens are kept; headings, interview turns and finished paragraphs are never glued together. Highlighting still follows the region each sentence began in.

53
TODO.md
View File

@ -84,31 +84,34 @@ marked on the page for a human. Order of work:
The current issue (c't KI-Wissen 2026) is the test bed. The point is to measure whether the newest
rules beat what was built up by hand, and to feed every difference back into the rules. Do these in order.
- [ ] **A — Finish verifying this magazine.** Work the remaining flags (42 pages need a decision as of
2026-09-28: 26 with a clipped edge — most sampled ones are cosmetic, the box encloses its text —, 21 with
unboxed ink, 10 with overlaps), review and approve the 33 not-reviewed articles,
and run the spelling/acronym steps once the language model answers again. Done when the report lists no
page needing a decision and every article is approved or rejected.
- [ ] **B — Make a backup copy.** Copy the whole magazine folder (`reader_library/<id>/`: layout, articles,
approvals, dictionary, glossaries, audio, hand-set reading orders) to a dated read-only snapshot, and
record what it contains (page/article/approval counts) so the comparison in E has a fixed baseline.
- [ ] **C — Clone the PDF and re-import it.** Create a second magazine project from the same `source.pdf`
(no reuse of the reviewed layout), so the pipeline starts from nothing exactly as for a new issue.
- [ ] **D — Process the new import to the maximum.** Full ingest, then **Auto-fix all pages**, then every
automatic step (page linking, seam mending, overlap fixing, figures, pull quotes, spelling agent and
acronym glossary with the language model up, vision re-read with the OCR engine reachable). Record
wall-clock time per stage and the remaining manual queue.
- [ ] **E — Compare the new result with the backup.** Script the comparison (no manual eyeballing):
article boundaries and count, page-to-article assignment, block text (word-aligned, as in
`scripts/score_attribution.py`), reading order per page, boxes (IoU per region), figures/pull quotes,
approvals, spelling fixes, glossary entries. Output a machine-readable report plus per-page diffs.
- [ ] **F — Show the differences and decide what is better.** A review view listing every difference with
both versions side by side (page render, boxes, text) and a verdict per difference (new better / backup
better / equal), so the winner is a recorded human decision, not an impression.
- [ ] **G — Adjust the rules so the next magazine needs less work.** For each difference where the backup
won, find the rule that failed and fix it (with a regression test using that page); for each where the new
run won, keep the rule. Re-run D→E→F until the new import matches or beats the backup, then record the
final numbers (pages needing a decision, articles needing linking/approval) in `PROJECT_MAP.md`.
- [x] **A — Finish verifying this magazine (automatic part).** 2026-09-28: full Auto-fix with the language
model (thinking off, ~4s/call) and vision re-read ran to completion: 94 misspellings fixed, 77 marked,
52 blocks linked, 27 pages still need a human decision. Snapshot after this run is
`config/magazine_backups/bd871c7c22f0449984d3b0e3d603d6d6-after-autofix-20260928-1810`. **Still open:**
a human must work the remaining 27 flagged pages and approve/reject the not-reviewed articles.
- [x] **B — Make a backup copy.** Taken 2026-09-28 after A's automatic pass (see snapshot above; layout,
articles, dictionary, quality report; 62 articles). Audio stays in the earlier 17:33 snapshot.
- [x] **C — Clone the PDF and re-import it.** Cloned as doc `345b2a841f104b03b3cdf61faceb66cc` from the same
`source.pdf`, ingested from nothing (no reuse of the reviewed layout).
- [x] **D — Process the new import to the maximum.** Full ingest (~2h27m) + Auto-fix with LLM and vision
(~35m) on the clone. Result: 60 pages need a decision (vs. 27 for the hand-reviewed magazine) -- expected,
since the backup carries months of manual box/order/approval fixes a from-scratch run cannot reproduce.
- [x] **E — Compare the new result with the backup.** Ran `scripts/compare_magazines.py`; report saved at
`architecture/evidence/compare-345b2a84-vs-backup-20260928.{md,json}`. Headline: text similarity 0.937,
box recall/precision 0.88/0.85, reading-order agreement 0.967, 51/56 articles matched.
- [ ] **F — Show the differences and decide what is better.** Not built: a review view listing every
difference with both versions side by side (page render, boxes, text) and a verdict per difference (new
better / backup better / equal). The markdown report is a stand-in for now; a proper UI is still open.
- [~] **G — Adjust the rules so the next magazine needs less work.** First fix landed 2026-09-28: the worst
differing pages (82, 87, 124, 202) all traced to one root cause -- the vision OCR engine hallucinating a
refusal sentence ("The image is too blurry to recognize...", "The Ground Truth image displays...") tiled
across dozens of regions over a photo/screenshot it could not read, narrated as if it were real page text.
Fixed in `core/magazine.py` (`_drop_repeated_region_text`/`_looks_like_vision_refusal`): text repeated
verbatim across 3+ regions that also reads like a refusal is blanked before it becomes blocks. Verified
live: re-OCR of page 82 discarded 29 hallucinated regions, page 87 discarded 122. Regression tests in
`tests/test_magazine.py::VisionOcrRefusalHallucinationTests`. **Still open:** re-run D→E→F with this fix
and keep going through the remaining worst-page list (3, 44, 75, 124, 136, 148, 152, 164) until the new
import matches or beats the backup; then record final numbers in `PROJECT_MAP.md`.
### Voice Studio (github.com/debpalash/voicestudio) — evaluation, container, comparison

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,70 @@
# Magazine comparison
* baseline: `config/magazine_backups/bd871c7c22f0449984d3b0e3d603d6d6-after-autofix-20260928-1810`
* new: `config/reader_library/345b2a841f104b03b3cdf61faceb66cc`
| measure | value |
|---|---|
| pages | 204 |
| narratable words (baseline / new) | 65509 / 68680 |
| text similarity (mean per page) | 0.9366 |
| pages with text differences | 89 |
| box recall / precision | 0.8849 / 0.8465 |
| type agreement | 0.9487 |
| reading-order agreement | 0.9674 |
| figures (baseline / new) | 183 / 213 |
| articles (baseline / new / matched) | 62 / 56 / 51 |
| matched articles with the same page span | 39 |
| pages flagged | 136 |
## Quality report
| | baseline | new |
|---|---|---|
| pages needing a decision | 27 | 60 |
| unboxed areas | 14 | 35 |
| clipped edges | 39 | 127 |
| overlapping pairs | 22 | 188 |
| words to check | 77 | 157 |
## The 15 pages that differ most
| page | flags | text | boxes b/n | order |
|---|---|---|---|---|
| 82 | text, boxes_missing_in_new, extra_boxes_in_new, figures | 0.0 | 2/30 | None |
| 202 | text, extra_boxes_in_new | 0.0 | 0/15 | None |
| 87 | text, boxes_missing_in_new, extra_boxes_in_new, types, figures | 0.1047 | 6/34 | 1.0 |
| 184 | text, boxes_missing_in_new, extra_boxes_in_new, figures | 0.1135 | 9/6 | 1.0 |
| 124 | text, boxes_missing_in_new, extra_boxes_in_new, types, figures | 0.29 | 31/98 | 1.0 |
| 44 | text, boxes_missing_in_new, extra_boxes_in_new, figures | 0.5254 | 10/5 | None |
| 3 | text, boxes_missing_in_new, extra_boxes_in_new, types, order | 0.5743 | 6/11 | 0.9 |
| 152 | text, boxes_missing_in_new, extra_boxes_in_new | 0.5953 | 4/10 | 1.0 |
| 164 | text, boxes_missing_in_new, extra_boxes_in_new, types, figures | 0.7059 | 17/8 | 1.0 |
| 75 | text, boxes_missing_in_new, extra_boxes_in_new, types, order, figures | 0.7411 | 7/11 | 0.6667 |
| 42 | text, boxes_missing_in_new, extra_boxes_in_new, order | 0.7491 | 7/8 | 0.9 |
| 136 | text, boxes_missing_in_new, extra_boxes_in_new | 0.7744 | 7/10 | 1.0 |
| 31 | text, boxes_missing_in_new, extra_boxes_in_new | 0.7876 | 13/9 | 1.0 |
| 148 | text, figures | 0.7879 | 3/3 | 1.0 |
| 28 | text, boxes_missing_in_new, extra_boxes_in_new | 0.7909 | 5/5 | 1.0 |
## Articles only in the baseline (11)
* 'Wie Kl Enbwickler-Jobs verändert' pages [32] (623 words)
* 'Durchstarten, bis die KI-Blase platzt?' pages [53] (320 words)
* 'Verbraulicher Butler gesucht' pages [59] (405 words)
* 'Wie KI die Softwareentwicklung erobert' pages [65, 66, 67] (1277 words)
* 'Page 63' pages [64] (30 words)
* 'Page 34' pages [34] (5 words)
* 'KI nutzen und nicht verdummen' pages [34] (104 words)
* 'Bequeme Mitarbeiter ersetzen sich selbst' pages [38] (476 words)
* 'KI-Einkaufsagenten: Neue Risiken' pages [184] (151 words)
* 'Page 193' pages [193] (1 words)
* 'Wer haftet, wenn KI einkauft?' pages [190] (118 words)
## Articles only in the new import (5)
* 'Durchstarten, bis die KI-Blase platzt?' pages [48, 49, 50, 51, 52, 53] (1741 words)
* 'Wie die Plattformen die Kreisläufe kontrollieren' pages [152, 153, 154, 155] (1267 words)
* 'Wall Street Star' pages [161] (478 words)
* 'KI-Einkaufsagenten: Neue Risiken' pages [184, 185, 186] (1151 words)
* "Vorschau: c't Digital vorsorgen" pages [202] (226 words)

View File

@ -1032,10 +1032,65 @@ def estimate_font_size_from_area(width: float, height: float, text_length: int,
return (width * height / denom) ** 0.5 if denom > 0 else 0.0
# A vision engine asked to transcribe a photo or screenshot it cannot read
# sometimes answers with a description of its own failure instead of leaving
# the region empty -- confirmed live on c't KI Wissen 2026 pages 82, 87 and
# 124, where a terminal screenshot and two photos each came back tiled into
# dozens of tiny regions, all carrying one of these two sentences verbatim.
# Downstream this reads as a wall of near-duplicate narration text laid over
# an image, which _whole_page_region's repetition check does not catch
# because each *region* carries the sentence once -- the loop is across
# regions, not within one string. Matching the wording is a second signal
# on top of exact repetition (see _drop_repeated_region_text below); either
# alone risks a false positive, together they do not.
_VISION_REFUSAL_PHRASES = (
"too blurry to recognize", "too blurry to read", "cannot recognize the text",
"cannot read the text", "unable to recognize the text", "unable to read the text",
"ground truth image", "the image is too", "not clear enough to",
)
def _looks_like_vision_refusal(text: str) -> bool:
lowered = (text or "").strip().lower()
return len(lowered) >= 15 and any(phrase in lowered for phrase in _VISION_REFUSAL_PHRASES)
def _drop_repeated_region_text(regions: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Blank out text that is repeated verbatim across several regions and
reads like the engine describing its own failure rather than the page.
Legitimate page text essentially never repeats a whole sentence across
three or more distinct boxes; a hallucinated refusal, tiled once per
fragment of an unreadable image, does exactly that. Requiring both the
repetition and the refusal wording (see _looks_like_vision_refusal)
keeps a genuinely repeated short phrase, such as a running header,
from being caught by this.
"""
counts: dict[str, int] = {}
for region in regions:
key = (region.get("text") or "").strip().lower()
if key:
counts[key] = counts.get(key, 0) + 1
noisy = {text for text, count in counts.items() if count >= 3 and _looks_like_vision_refusal(text)}
if not noisy:
return regions
logging.getLogger(__name__).warning(
"Vision OCR repeated a refusal sentence across %d regions; discarding its text",
sum(counts[t] for t in noisy),
)
out = []
for region in regions:
if (region.get("text") or "").strip().lower() in noisy:
region = dict(region)
region["text"] = ""
out.append(region)
return out
def parse_vision_ocr_regions(raw: str) -> list[dict[str, Any]]:
"""Parse a vision OCR engine's labeled-region transcription."""
matches = list(_VISION_REGION.finditer(raw or ""))
regions = []
candidates = []
for i, m in enumerate(matches):
gd = m.groupdict()
raw_label = (gd.get("ref") or gd.get("label") or "text").strip().strip("[]")
@ -1044,23 +1099,24 @@ def parse_vision_ocr_regions(raw: str) -> list[dict[str, Any]]:
x0, y0, x1, y1 = (float(gd[k]) for k in ("x0", "y0", "x1", "y1"))
except (ValueError, TypeError):
continue
if x1 <= x0 or y1 <= y0:
continue
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(raw or "")
text = (raw or "")[start:end].strip()
if (not text and _VISION_LABEL_TYPES.get(label) not in VISUAL_TYPES) or x1 <= x0 or y1 <= y0:
candidates.append({"label": label, "bbox": [x0, y0, x1 - x0, y1 - y0], "text": text})
regions = []
for region in _drop_repeated_region_text(candidates):
if not region["text"] and _VISION_LABEL_TYPES.get(region["label"]) not in VISUAL_TYPES:
continue
regions.append({
"label": label,
"bbox": [x0, y0, x1 - x0, y1 - y0],
"text": text,
})
regions.append(region)
return regions
def parse_unlimited_ocr_regions(raw: str) -> list[dict[str, Any]]:
"""Parse Unlimited-OCR's native ``<|det|>`` document-layout markers."""
matches = list(_UNLIMITED_OCR_REGION.finditer(raw or ""))
regions = []
candidates = []
for i, match in enumerate(matches):
gd = match.groupdict()
raw_label = (gd.get("ref") or gd.get("label") or "").strip().strip("[]")
@ -1078,14 +1134,13 @@ def parse_unlimited_ocr_regions(raw: str) -> list[dict[str, Any]]:
label = "image"
elif label in {"map", "graphic", "infographic", "diagram", "treemap", "statistics"}:
label = "chart"
if not text and _VISION_LABEL_TYPES.get(label) not in VISUAL_TYPES:
candidates.append({"label": label, "bbox": [x0, y0, x1 - x0, y1 - y0], "text": text,
"native_normalized_bbox": True})
regions = []
for region in _drop_repeated_region_text(candidates):
if not region["text"] and _VISION_LABEL_TYPES.get(region["label"]) not in VISUAL_TYPES:
continue
regions.append({
"label": label,
"bbox": [x0, y0, x1 - x0, y1 - y0],
"text": text,
"native_normalized_bbox": True,
})
regions.append(region)
return regions

View File

@ -863,6 +863,54 @@ class VisionOcrRegionParsingTests(unittest.TestCase):
self.assertEqual(parse_vision_ocr_regions("text [10,10,10,10]junk"), [])
class VisionOcrRefusalHallucinationTests(unittest.TestCase):
"""Grounded in a real response for c't KI Wissen 2026 pages 82/87/124:
a terminal screenshot and two photos the engine could not read came back
tiled into dozens of tiny regions, each carrying the same refusal
sentence verbatim -- narrated as if it were the page's own text unless
filtered out."""
REFUSAL = "The image is too blurry to recognize the text within it."
def _raw(self, n, phrase=None):
phrase = phrase or self.REFUSAL
parts = [f"heading [{10 * i},{10 * i},{10 * i + 8},{10 * i + 8}]{phrase}" for i in range(n)]
parts.append("title [50,600,400,620]Die echte Überschrift dieser Seite")
return "\n".join(parts)
def test_a_refusal_sentence_repeated_across_regions_is_dropped(self):
"""Blanking a non-visual region's text leaves it with nothing to
narrate, so it is dropped outright rather than kept as an empty box."""
regions = parse_vision_ocr_regions(self._raw(3))
self.assertEqual([r for r in regions if r["label"] == "heading"], [])
def test_the_real_page_text_alongside_it_survives(self):
regions = parse_vision_ocr_regions(self._raw(3))
self.assertEqual(len(regions), 1)
self.assertEqual(regions[-1]["text"], "Die echte Überschrift dieser Seite")
def test_a_single_occurrence_is_left_alone(self):
"""One region saying this is plausibly real (a caption quoting an
error message) -- only the repeated-tiling pattern is discarded."""
regions = parse_vision_ocr_regions(self._raw(1))
self.assertEqual(regions[0]["text"], self.REFUSAL)
def test_a_repeated_phrase_that_is_not_a_refusal_is_left_alone(self):
"""Repetition alone (e.g. a running header appearing on several
cropped fragments) must not trip this -- only refusal wording does."""
regions = parse_vision_ocr_regions(self._raw(3, phrase="c't KI Wissen 2026"))
self.assertEqual([r["text"] for r in regions if r["label"] == "heading"],
["c't KI Wissen 2026"] * 3)
def test_same_hallucination_is_filtered_from_unlimited_ocr_regions(self):
raw = "\n".join(
f"<|ref|>heading<|/ref|><|det|>[{10*i},{10*i},{10*i+8},{10*i+8}]<|/det|>{self.REFUSAL}"
for i in range(3)
)
regions = parse_unlimited_ocr_regions(raw)
self.assertEqual(regions, [])
class VisionBlockBuildingTests(unittest.TestCase):
"""An explicit engine label (footer/title/page_number/caption) is trusted
outright rather than re-derived from font size, which vision OCR gives no