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:
parent
d06f990379
commit
1fb5528112
@ -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
53
TODO.md
@ -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
|
||||
|
||||
|
||||
6460
architecture/evidence/compare-345b2a84-vs-backup-20260928.json
Normal file
6460
architecture/evidence/compare-345b2a84-vs-backup-20260928.json
Normal file
File diff suppressed because it is too large
Load Diff
70
architecture/evidence/compare-345b2a84-vs-backup-20260928.md
Normal file
70
architecture/evidence/compare-345b2a84-vs-backup-20260928.md
Normal 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)
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user