bd26e9ce6e
Two faults found by running the pipeline inside the Docker image for the first time, both of which let a 25-page run finish "successfully" having stored nothing. The image declares ENV DASHSCOPE_API_KEY= so the variable is visible to docker inspect and overridable at docker run. That leaves it set-but-empty in every container, and load_dotenv used os.environ.setdefault, so the empty string won and the mounted .env was ignored. An empty value now counts as unset; a real value still wins, because overriding on the command line is the point. The consequence was worse than a missing key. Every page failed, the per-page fallback kept the font-mangled embedded text -- exactly the text OCR is there to replace -- and the run went on to spend a full detection pass on it. Tolerating one unreadable page is right; tolerating every page is a configuration error wearing a success message, so Document now raises OcrUnavailable when OCR was attempted and not one page came back, quoting the first real failure. A page range that needed no OCR at all is still not an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ci5T3gWDhhmqDec3e1XtYZ
287 lines
11 KiB
Python
287 lines
11 KiB
Python
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from leanbench.document import Document, OcrUnavailable
|
||
from leanbench.ocr import (
|
||
OcrCache,
|
||
OcrReader,
|
||
VisionLLMBackend,
|
||
looks_broken,
|
||
text_quality,
|
||
)
|
||
|
||
# Real extraction of page 90 of the project's test book: the maths font carries a
|
||
# custom encoding, so every operator arrives as a stray accent or ligature glyph.
|
||
BROKEN = """Proposition 1.11.7 (Chinese remainder theorem). Let a and b be relatively
|
||
prime natural numbers. There is an isomorphism of rings
|
||
Zab Š Za ˚ Zb;
|
||
defined by Œxab 7! .Œxa; Œxb/.
|
||
"""
|
||
|
||
# What the page actually says, as the vision model reads it.
|
||
CLEAN = """Proposition 1.11.7 (Chinese remainder theorem). Let $a$ and $b$ be relatively
|
||
prime natural numbers. There is an isomorphism of rings
|
||
$$\\mathbb{Z}_{ab} \\cong \\mathbb{Z}_a \\oplus \\mathbb{Z}_b,$$
|
||
defined by $[x]_{ab} \\mapsto ([x]_a, [x]_b)$.
|
||
"""
|
||
|
||
# The other corruption mode, from page 300 of the same book: the maths is made of
|
||
# ordinary ASCII, so a glyph-based check scores it a perfect 1.0.
|
||
OPERATORS_AS_LETTERS = """6.2.18. Let I and J be two ideals in a ring R. Show that
|
||
I C J D fa C b W a 2 I and b 2 J g
|
||
is an ideal in R.
|
||
6.2.19. Let I and J be two ideals in a ring R. Show that
|
||
IJ D fa1b1 C a2b2 C C asbs W s 1; ai 2 I; bi 2 J g
|
||
is an ideal in R, and IJ I \\ J .
|
||
"""
|
||
|
||
PROSE = """The chapter closes with a discussion of why the theorem matters, and a
|
||
remark on its history, with no displayed formulas at all.
|
||
"""
|
||
|
||
|
||
class FakeBackend:
|
||
"""Returns a canned transcription and counts how often it was asked."""
|
||
|
||
name = "fake:v1"
|
||
|
||
def __init__(self, text: str = CLEAN) -> None:
|
||
self.text = text
|
||
self.calls = 0
|
||
|
||
def transcribe(self, image: bytes) -> str:
|
||
self.calls += 1
|
||
return self.text
|
||
|
||
|
||
class ExplodingBackend:
|
||
name = "boom:v1"
|
||
|
||
def transcribe(self, image: bytes) -> str:
|
||
raise RuntimeError("model refused")
|
||
|
||
|
||
class FlakyBackend:
|
||
"""Fails on chosen pages only -- the ordinary case of one unreadable page."""
|
||
|
||
name = "flaky:v1"
|
||
|
||
def __init__(self, fail_on: set[int]) -> None:
|
||
self.fail_on = fail_on
|
||
self.seen = 0
|
||
|
||
def transcribe(self, image: bytes) -> str:
|
||
self.seen += 1
|
||
if self.seen in self.fail_on:
|
||
raise RuntimeError(f"page {self.seen} refused")
|
||
return CLEAN
|
||
|
||
|
||
# -- the quality heuristic -------------------------------------------------
|
||
|
||
|
||
def test_mangled_maths_scores_far_below_clean_text() -> None:
|
||
assert text_quality(BROKEN) < 0.75
|
||
assert text_quality(CLEAN) > 0.9
|
||
assert text_quality(PROSE) > 0.9
|
||
|
||
|
||
def test_operators_disguised_as_letters_are_caught() -> None:
|
||
"""The failure mode that a glyph-only check scores 1.0 and waves through."""
|
||
assert text_quality(OPERATORS_AS_LETTERS) < 0.75
|
||
assert looks_broken(OPERATORS_AS_LETTERS)
|
||
|
||
|
||
def test_ordinary_prose_with_numerals_survives() -> None:
|
||
assert not looks_broken(
|
||
"In chapter 2 we saw that the group has order 2 and index 2 in G.")
|
||
|
||
|
||
def test_looks_broken_separates_the_two() -> None:
|
||
assert looks_broken(BROKEN)
|
||
assert not looks_broken(CLEAN)
|
||
assert not looks_broken(PROSE)
|
||
|
||
|
||
def test_empty_text_is_the_worst_case() -> None:
|
||
assert text_quality("") == 0.0
|
||
assert text_quality(" \n ") == 0.0
|
||
|
||
|
||
def test_a_single_accented_word_is_not_damning() -> None:
|
||
# Real prose does contain the odd accent; only a pile of them is evidence.
|
||
assert not looks_broken("Consider the Poincare´ conjecture and its history.")
|
||
|
||
|
||
# -- the cache -------------------------------------------------------------
|
||
|
||
|
||
def test_cache_round_trips(tmp_path: Path) -> None:
|
||
cache = OcrCache(tmp_path)
|
||
assert cache.get("hash", 3, 200, "fake:v1") is None
|
||
cache.put("hash", 3, 200, "fake:v1", "hello")
|
||
assert cache.get("hash", 3, 200, "fake:v1") == "hello"
|
||
assert cache.hits == 1 and cache.misses == 1
|
||
|
||
|
||
def test_cache_keys_on_dpi_and_backend(tmp_path: Path) -> None:
|
||
cache = OcrCache(tmp_path)
|
||
cache.put("hash", 3, 200, "fake:v1", "at 200")
|
||
assert cache.get("hash", 3, 400, "fake:v1") is None, "different dpi is a different read"
|
||
assert cache.get("hash", 3, 200, "other:v1") is None, "different backend too"
|
||
|
||
|
||
def test_cache_can_be_switched_off(tmp_path: Path) -> None:
|
||
cache = OcrCache(tmp_path, enabled=False)
|
||
cache.put("hash", 1, 200, "fake:v1", "x")
|
||
assert cache.get("hash", 1, 200, "fake:v1") is None
|
||
|
||
|
||
# -- the reader ------------------------------------------------------------
|
||
|
||
|
||
def test_reader_pays_for_a_page_once(tmp_path: Path, monkeypatch) -> None:
|
||
monkeypatch.setattr("leanbench.ocr.render_page", lambda *a, **k: b"png")
|
||
backend = FakeBackend()
|
||
reader = OcrReader(backend=backend, cache=OcrCache(tmp_path))
|
||
first = reader.page_text("book.pdf", "hash", 7)
|
||
second = reader.page_text("book.pdf", "hash", 7)
|
||
assert first == second
|
||
assert backend.calls == 1, "the second read must come from cache"
|
||
|
||
|
||
def test_reader_records_failures_instead_of_raising(tmp_path: Path, monkeypatch) -> None:
|
||
monkeypatch.setattr("leanbench.ocr.render_page", lambda *a, **k: b"png")
|
||
reader = OcrReader(backend=ExplodingBackend(), cache=OcrCache(tmp_path))
|
||
assert reader.page_text("book.pdf", "hash", 4) is None
|
||
assert "model refused" in reader.failures[4]
|
||
|
||
|
||
def test_reader_strips_a_markdown_fence(tmp_path: Path, monkeypatch) -> None:
|
||
monkeypatch.setattr("leanbench.ocr.render_page", lambda *a, **k: b"png")
|
||
reader = OcrReader(backend=FakeBackend("```\nTheorem 1.\n```"), cache=None)
|
||
assert reader.page_text("book.pdf", "hash", 1) == "Theorem 1."
|
||
|
||
|
||
# -- the vision backend's request shape ------------------------------------
|
||
|
||
|
||
def test_vision_backend_sends_an_image_part() -> None:
|
||
captured: dict = {}
|
||
|
||
class Client:
|
||
def complete(self, messages, **kwargs):
|
||
captured["messages"] = messages
|
||
captured["kwargs"] = kwargs
|
||
return " transcribed "
|
||
|
||
backend = VisionLLMBackend(client=Client(), model="qwen-vl-max")
|
||
assert backend.transcribe(b"\x89PNG") == "transcribed"
|
||
parts = captured["messages"][0]["content"]
|
||
assert parts[0]["type"] == "text"
|
||
assert parts[1]["type"] == "image_url"
|
||
assert parts[1]["image_url"]["url"].startswith("data:image/png;base64,")
|
||
assert captured["kwargs"]["model"] == "qwen-vl-max"
|
||
assert backend.name == "vision:qwen-vl-max"
|
||
|
||
|
||
# -- Document integration --------------------------------------------------
|
||
|
||
|
||
@pytest.fixture()
|
||
def mixed_book(tmp_path: Path) -> Path:
|
||
"""Page 1 is fine, page 2 is font-mangled."""
|
||
path = tmp_path / "mixed.txt"
|
||
path.write_text(PROSE + "\f" + BROKEN, encoding="utf-8")
|
||
return path
|
||
|
||
|
||
def test_auto_mode_only_pays_for_the_broken_page(mixed_book: Path, monkeypatch) -> None:
|
||
monkeypatch.setattr("leanbench.ocr.render_page", lambda *a, **k: b"png")
|
||
backend = FakeBackend()
|
||
reader = OcrReader(backend=backend, cache=None)
|
||
document = Document.load(mixed_book, mode="auto", ocr=reader, threshold=0.75)
|
||
|
||
assert backend.calls == 1, "the clean page must not be sent to the model"
|
||
assert document.meta["ocr"]["pages_replaced"] == [2]
|
||
assert document.page(1).text.startswith("The chapter closes")
|
||
assert "\\cong" in document.page(2).text
|
||
assert "fake:v1" in document.extractor
|
||
|
||
|
||
def test_ocr_mode_reads_every_page(mixed_book: Path, monkeypatch) -> None:
|
||
monkeypatch.setattr("leanbench.ocr.render_page", lambda *a, **k: b"png")
|
||
backend = FakeBackend()
|
||
Document.load(mixed_book, mode="ocr", ocr=reader_for(backend))
|
||
assert backend.calls == 2
|
||
|
||
|
||
def reader_for(backend) -> OcrReader:
|
||
return OcrReader(backend=backend, cache=None)
|
||
|
||
|
||
def test_page_range_bounds_what_gets_read(mixed_book: Path, monkeypatch) -> None:
|
||
monkeypatch.setattr("leanbench.ocr.render_page", lambda *a, **k: b"png")
|
||
backend = FakeBackend()
|
||
document = Document.load(mixed_book, mode="ocr", ocr=reader_for(backend),
|
||
first_page=2, last_page=2)
|
||
assert backend.calls == 1, "a 25-page trial of a 600-page book costs 25 calls"
|
||
assert document.meta["ocr"]["pages_replaced"] == [2]
|
||
|
||
|
||
def test_text_mode_never_touches_the_model(mixed_book: Path) -> None:
|
||
backend = FakeBackend()
|
||
document = Document.load(mixed_book, mode="text", ocr=reader_for(backend))
|
||
assert backend.calls == 0
|
||
assert "Š" in document.page(2).text, "embedded text is left exactly as found"
|
||
assert "ocr" not in document.meta
|
||
|
||
|
||
def test_a_failed_page_keeps_its_embedded_text(mixed_book: Path, monkeypatch) -> None:
|
||
"""One bad page falls back; the rest of the book is unaffected."""
|
||
monkeypatch.setattr("leanbench.ocr.render_page", lambda *a, **k: b"png")
|
||
reader = OcrReader(backend=FlakyBackend(fail_on={2}), cache=None)
|
||
document = Document.load(mixed_book, mode="ocr", ocr=reader)
|
||
assert document.meta["ocr"]["pages_failed"] == [2]
|
||
assert document.meta["ocr"]["pages_replaced"] == [1]
|
||
assert "Š" in document.page(2).text, "we fall back, we do not lose the page"
|
||
|
||
|
||
def test_total_ocr_failure_is_loud(mixed_book: Path, monkeypatch) -> None:
|
||
"""Every page failing is a broken configuration, not a run to complete.
|
||
|
||
Silently falling back here is the expensive failure: the fallback text is the
|
||
mangled text OCR was called in to replace, so the run costs a full detection
|
||
pass and stores nothing.
|
||
"""
|
||
monkeypatch.setattr("leanbench.ocr.render_page", lambda *a, **k: b"png")
|
||
reader = OcrReader(backend=ExplodingBackend(), cache=None)
|
||
with pytest.raises(OcrUnavailable) as caught:
|
||
Document.load(mixed_book, mode="ocr", ocr=reader)
|
||
assert "model refused" in str(caught.value), "the real cause must reach the operator"
|
||
assert "boom:v1" in str(caught.value)
|
||
|
||
|
||
def test_auto_mode_with_nothing_to_do_does_not_raise(tmp_path: Path) -> None:
|
||
"""No page needed OCR, so nothing failed -- that is success, not an outage."""
|
||
path = tmp_path / "clean.txt"
|
||
path.write_text(PROSE, encoding="utf-8")
|
||
document = Document.load(path, mode="auto", ocr=OcrReader(backend=ExplodingBackend()))
|
||
assert document.meta["ocr"]["pages_replaced"] == []
|
||
assert document.meta["ocr"]["pages_failed"] == []
|
||
|
||
|
||
def test_offsets_stay_usable_after_ocr(mixed_book: Path, monkeypatch) -> None:
|
||
"""The span contract must hold whichever extractor produced the text."""
|
||
monkeypatch.setattr("leanbench.ocr.render_page", lambda *a, **k: b"png")
|
||
document = Document.load(mixed_book, mode="ocr", ocr=reader_for(FakeBackend()))
|
||
page = document.page(2)
|
||
start = page.text.index("Proposition")
|
||
end = page.text.index("rings") + len("rings")
|
||
assert document.slice(2, start, 2, end).startswith("Proposition 1.11.7")
|
||
# The extractor is part of the offset contract, so it is recorded on the source.
|
||
assert "fake:v1" in document.extractor
|