File size: 11,032 Bytes
a0bd9a2
 
0657bc9
a0bd9a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0657bc9
 
 
a0bd9a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
"""Re-OCR the rendered page images to a cleaner ground-truth report text (#212).

The prepared TCGA corpus OCR (`data/raw/TCGA_Reports.csv`) is pervasively damaged: 90% of reports hold
some damage and half are heavily damaged, with tables shredded into period-separated fragments
(`benchmark.ocr_damage_density`). Extraction reads that text, so a damaged transcription produces damaged
drafts and evidence spans that will not match the page.

This module transcribes the page images directly with a vision model, preserving layout and tables, to
produce a report text that both the extraction pass and the evidence check can rely on. The model is
reached through the app's provider seam (`endopath.llm.get_client`), so the same call runs against the
hosted Anthropic API or a local, on-premise model with no change here.

`ClaudePageReader` implements the `textsource.PageReader` protocol, so it plugs into the existing
`VisionLlmTextSource`: registering it with `textsource.set_default_page_reader(ClaudePageReader())` makes
the ingest path read pages through it. `scripts/reocr_pages.py` uses it to re-OCR a selected set of cases
and persist the result as `reports.report_text` (`storage.set_report_text`).

Cost guard: every page transcription is cached by the page image's sha256 checksum
(`PageTranscriptCache`), so re-running the script never re-bills a page whose pixels are unchanged, and a
`--limit`/`--worst`/`--case` selection in the script bounds how many pages are sent. No page is skipped
silently: the reader reports per-run token counts and cache hits.
"""

from __future__ import annotations

import base64
import dataclasses
import hashlib
import json
import os
from pathlib import Path
from typing import Optional

# Consistent with every other LLM call site in the app (llm_extraction, induction, resolution, the chat
# co-pilots): the codebase standardizes on this model, and the vision extract in resolution.py already
# reads page images with it. Override with REOCR_MODEL to trade cost for capability on a bulk run;
# claude-opus-4-8 is the higher-capability, higher-cost alternative reachable through the same seam.
REOCR_MODEL = (os.environ.get("REOCR_MODEL") or "").strip() or "claude-sonnet-5"

# A single page of a pathology report transcribes to at most a few thousand tokens; this ceiling leaves
# ample headroom and stays well under the streaming threshold, so a plain non-streaming call is safe.
DEFAULT_MAX_TOKENS = 8000

DEFAULT_CACHE_PATH = Path("data") / "interim" / "reocr_cache.json"

_IMAGE_MEDIA_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg"}

# The pages of one report are joined with a blank line, matching the corpus's own paragraph spacing, so
# the concatenated text reads as one continuous document. No synthetic page markers are injected: a
# marker is text the model could later quote, and invariant 2 verifies an evidence quote as a verbatim
# substring of exactly this stored text.
PAGE_SEPARATOR = "\n\n"

TRANSCRIPTION_PROMPT = """\
You are transcribing a scanned pathology report page into plain text for a clinical dataset. Reproduce \
the page exactly as printed.

Rules:
- Transcribe every word, number, and punctuation mark verbatim. Do not paraphrase, summarize, correct, \
translate, re-order, or add commentary of your own.
- Preserve the reading order and the line breaks of the page.
- When the page contains a table, render it as a Markdown table: one row per printed row, columns in \
their printed order, so the rows stay readable instead of collapsing into a list of fragments.
- Keep a field label and its value on the same line, as printed ("Label: value").
- For a checkbox or a selectable option, write [x] for a filled or checked mark and [ ] for an empty one.
- If a word is genuinely illegible, write [illegible] in its place. Do not guess at it.
- Output only the transcription. No preamble, no explanation, no code fences.
"""


def page_checksum(path: Path | str) -> str:
    """sha256 of a page image's bytes: the cache key, so an unchanged page is never re-transcribed and a
    re-rendered page (different pixels) is."""
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()


class PageTranscriptCache:
    """A JSON-backed page-transcript cache keyed by `checksum` and validated against the model that
    produced it, so switching REOCR_MODEL re-transcribes rather than serving another model's text. The
    file is written atomically. `dirty` lets a caller batch a single save after a run instead of writing
    per page."""

    def __init__(self, path: Path | str = DEFAULT_CACHE_PATH) -> None:
        self.path = Path(path)
        self.dirty = False
        self._data: dict[str, dict] = {}
        if self.path.exists():
            try:
                self._data = json.loads(self.path.read_text(encoding="utf-8"))
            except (json.JSONDecodeError, OSError):
                self._data = {}

    def get(self, checksum: str, model: str) -> Optional[str]:
        entry = self._data.get(checksum)
        if entry is None or entry.get("model") != model:
            return None
        return entry.get("text")

    def put(self, checksum: str, model: str, text: str) -> None:
        self._data[checksum] = {"model": model, "text": text, "chars": len(text)}
        self.dirty = True

    def save(self) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        tmp = self.path.with_suffix(self.path.suffix + ".tmp")
        tmp.write_text(json.dumps(self._data, ensure_ascii=False, indent=0), encoding="utf-8")
        tmp.rename(self.path)
        self.dirty = False

    def __len__(self) -> int:
        return len(self._data)


@dataclasses.dataclass
class PageTranscript:
    """One page's transcription and the tokens it cost. A cache hit reports zero tokens; the caller sums
    these across a run for a real cost figure rather than an estimate."""

    text: str
    checksum: str
    cached: bool
    input_tokens: int = 0
    output_tokens: int = 0


def _image_block(path: Path) -> dict:
    media_type = _IMAGE_MEDIA_TYPES.get(path.suffix.lower())
    if media_type is None:
        raise ValueError(f"unsupported image type for re-OCR: {path}")
    return {
        "type": "image",
        "source": {
            "type": "base64",
            "media_type": media_type,
            "data": base64.standard_b64encode(path.read_bytes()).decode("ascii"),
        },
    }


def _strip_code_fences(text: str) -> str:
    """Drop a wrapping ``` fence if the model added one despite the instruction, so the stored text is
    the transcription and not a fenced block. A fence that is not on the first/last line is left alone
    (it could be part of the page content)."""
    lines = text.strip().splitlines()
    if len(lines) >= 2 and lines[0].startswith("```") and lines[-1].strip() == "```":
        return "\n".join(lines[1:-1]).strip()
    return text.strip()


def transcribe_page(
    path: Path | str,
    *,
    client=None,
    model: str = REOCR_MODEL,
    cache: Optional[PageTranscriptCache] = None,
    max_tokens: int = DEFAULT_MAX_TOKENS,
) -> PageTranscript:
    """Transcribe one page image, returning its text and token cost. Uses `cache` when the page's
    checksum + model already produced text, so a re-run pays nothing for an unchanged page. The model is
    reached through the provider seam (`endopath.llm.get_client`), so a local model swaps in with no
    change here."""
    path = Path(path)
    checksum = page_checksum(path)
    if cache is not None:
        hit = cache.get(checksum, model)
        if hit is not None:
            return PageTranscript(text=hit, checksum=checksum, cached=True)

    if client is None:
        from endopath import llm

        # The transcription stage's own client: a backend serving one model for both stages returns the
        # same client extraction uses, and the GPU backend returns its vision model (#83).
        client = llm.get_reocr_client()

    response = client.messages.create(
        model=model,
        max_tokens=max_tokens,
        messages=[
            {
                "role": "user",
                "content": [_image_block(path), {"type": "text", "text": TRANSCRIPTION_PROMPT}],
            }
        ],
    )
    if getattr(response, "stop_reason", None) == "max_tokens":
        raise RuntimeError(
            f"re-OCR of {path} hit max_tokens before the page finished; raise max_tokens (currently "
            f"{max_tokens})."
        )
    text = "".join(block.text for block in response.content if getattr(block, "type", None) == "text")
    text = _strip_code_fences(text)

    usage = getattr(response, "usage", None)
    input_tokens = getattr(usage, "input_tokens", None) or 0
    output_tokens = getattr(usage, "output_tokens", None) or 0

    if cache is not None:
        cache.put(checksum, model, text)

    return PageTranscript(
        text=text,
        checksum=checksum,
        cached=False,
        input_tokens=input_tokens,
        output_tokens=output_tokens,
    )


@dataclasses.dataclass
class RunStats:
    pages: int = 0
    cached_pages: int = 0
    input_tokens: int = 0
    output_tokens: int = 0

    @property
    def billed_pages(self) -> int:
        return self.pages - self.cached_pages


class ClaudePageReader:
    """A `textsource.PageReader` that transcribes a report's rendered pages with a vision model and joins
    them into one report text. Registering it with `textsource.set_default_page_reader(...)` makes the
    ingest path read pages through it; `scripts/reocr_pages.py` uses it directly. Token cost and cache
    hits from the most recent `read` are on `last_run` for a caller that reports cost."""

    def __init__(
        self,
        *,
        client=None,
        model: str = REOCR_MODEL,
        cache: Optional[PageTranscriptCache] = None,
        max_tokens: int = DEFAULT_MAX_TOKENS,
        page_separator: str = PAGE_SEPARATOR,
    ) -> None:
        self._client = client
        self._model = model
        self._cache = cache
        self._max_tokens = max_tokens
        self._page_separator = page_separator
        self.last_run = RunStats()

    def read(self, page_images: list[Path]) -> str:
        self.last_run = RunStats()
        parts: list[str] = []
        for path in page_images:
            transcript = transcribe_page(
                path,
                client=self._client,
                model=self._model,
                cache=self._cache,
                max_tokens=self._max_tokens,
            )
            self.last_run.pages += 1
            if transcript.cached:
                self.last_run.cached_pages += 1
            self.last_run.input_tokens += transcript.input_tokens
            self.last_run.output_tokens += transcript.output_tokens
            if transcript.text:
                parts.append(transcript.text)
        if self._cache is not None and self._cache.dirty:
            self._cache.save()
        return self._page_separator.join(parts)