File size: 7,049 Bytes
eea689d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Pluggable text source: how the ingest path obtains a report's text.

The ingest path selects a report's text through a `TextSource`, never a
hardcoded reader (docs/prd.md section 7, "Two points plug out"). Three
implementations ship:

* `CorpusOcrTextSource` returns the TCGA corpus recognition row for a case.
* `PdfTextLayerTextSource` reads the report's own embedded PDF text layer (#45),
  for a folder source that carries no corpus OCR row.
* `VisionLlmTextSource` reads the rendered pages through a vision model, the MVP
  path for a report with no usable recognized text.

`VisionLlmTextSource` delegates the model call to a `PageReader`, a
provider-agnostic callable. No call site here names a specific provider: the
endpoint selects a source by id and calls `text_for`. A local, on-premise model
for privacy plugs in by implementing `PageReader` and registering it with
`set_default_page_reader`, changing nothing at the call site.
"""

from __future__ import annotations

import dataclasses
from pathlib import Path
from typing import Callable, Optional, Protocol, runtime_checkable

# Stable ids for each source, so a call site (the Intake endpoint) and a stored
# job record name a source by identity rather than by provider. The MVP default
# reads the rendered pages with a vision model.
CORPUS_OCR = "corpus_ocr"
PDF_TEXT_LAYER = "pdf_text_layer"
VISION_LLM = "vision_llm"

DEFAULT_SOURCE_ID = VISION_LLM

SOURCE_LABELS: dict[str, str] = {
    CORPUS_OCR: "TCGA corpus OCR",
    PDF_TEXT_LAYER: "Report PDF text layer",
    VISION_LLM: "Vision model read of rendered pages",
}


@dataclasses.dataclass(frozen=True)
class TextRequest:
    """One case's inputs, enough for any source to resolve its text without
    knowing which source will run. `corpus_text` is the recognition row when the
    case came from the prepared corpus; `page_images` are the rendered pages a
    PDF-text-layer or vision source reads; `pdf_path` is the original scan."""

    case_barcode: str
    patient_filename: Optional[str] = None
    corpus_text: Optional[str] = None
    page_images: tuple[Path, ...] = ()
    pdf_path: Optional[Path] = None


@runtime_checkable
class PageReader(Protocol):
    """Turns a report's rendered pages into text. The seam a local model plugs
    into: an on-premise reader implements this one method and never surfaces its
    provider to the call site."""

    def read(self, page_images: list[Path]) -> str: ...


@runtime_checkable
class TextSource(Protocol):
    """Resolves the text the extraction pass reads for one case. Returns None
    when this source cannot supply text for the case, so the caller records a
    miss rather than a fabricated value (CLAUDE.md invariant 2)."""

    id: str
    label: str

    def text_for(self, request: TextRequest) -> Optional[str]: ...


class CorpusOcrTextSource:
    """The prepared TCGA corpus recognition text, carried on the request. The
    default text source for a case ingested from the prepared corpus."""

    id = CORPUS_OCR
    label = SOURCE_LABELS[CORPUS_OCR]

    def text_for(self, request: TextRequest) -> Optional[str]:
        text = request.corpus_text
        if text is None:
            return None
        text = str(text).strip()
        return text or None


class PdfTextLayerTextSource:
    """The report's own embedded PDF text layer (#45). A folder source carries
    no corpus OCR row, so this reads the text a born-digital or recognition-
    embedded PDF already holds. Returns None when the scan has no text layer,
    which is the common case for an image-only scan and the reason the vision
    source exists."""

    id = PDF_TEXT_LAYER
    label = SOURCE_LABELS[PDF_TEXT_LAYER]

    def text_for(self, request: TextRequest) -> Optional[str]:
        if request.pdf_path is None or not Path(request.pdf_path).exists():
            return None
        import fitz  # PyMuPDF, already an ingestion dependency

        doc = fitz.open(request.pdf_path)
        try:
            pages = [doc.load_page(i).get_text().strip() for i in range(doc.page_count)]
        finally:
            doc.close()
        joined = "\n\n".join(page for page in pages if page).strip()
        return joined or None


class VisionLlmTextSource:
    """A vision model read of the rendered pages, the MVP path for a report with
    no usable recognized text. The model call goes through a `PageReader`, so
    this class names no provider. With no reader configured it returns None
    rather than raising, keeping ingestion robust until a reader (hosted or
    local) is wired."""

    id = VISION_LLM
    label = SOURCE_LABELS[VISION_LLM]

    def __init__(self, page_reader: Optional[PageReader] = None) -> None:
        self._page_reader = page_reader

    def text_for(self, request: TextRequest) -> Optional[str]:
        reader = self._page_reader or _default_page_reader
        if reader is None or not request.page_images:
            return None
        text = reader.read(list(request.page_images))
        text = str(text).strip() if text else ""
        return text or None


# The default reader a `VisionLlmTextSource` uses when none is passed. Left unset
# so the MVP degrades to "no text from this source" rather than crashing when no
# model is configured; an operator wires one (hosted or local) once, here.
_default_page_reader: Optional[PageReader] = None


def set_default_page_reader(reader: Optional[PageReader]) -> None:
    """Register the reader every `VisionLlmTextSource` uses by default. The one
    place a provider is named: a local on-premise model registers here and every
    call site keeps working unchanged."""
    global _default_page_reader
    _default_page_reader = reader


def select(source_id: str, *, page_reader: Optional[PageReader] = None) -> TextSource:
    """The text source for an id. `page_reader` overrides the vision default for
    this selection, which is how a caller injects a specific reader (a local
    model, or a test fake) without touching the registry."""
    if source_id == CORPUS_OCR:
        return CorpusOcrTextSource()
    if source_id == PDF_TEXT_LAYER:
        return PdfTextLayerTextSource()
    if source_id == VISION_LLM:
        return VisionLlmTextSource(page_reader=page_reader)
    raise ValueError(f"unknown text source id: {source_id!r} (known: {sorted(SOURCE_LABELS)})")


def available_sources() -> list[dict[str, str]]:
    """The selectable sources, id + label, in the order the Intake screen offers
    them: corpus OCR, PDF text layer, then the vision MVP default last."""
    order = (CORPUS_OCR, PDF_TEXT_LAYER, VISION_LLM)
    return [{"id": sid, "label": SOURCE_LABELS[sid]} for sid in order]


# A `PageReader` built from a plain callable, for wiring a function (or a test
# fake) without a class. `set_default_page_reader(callable_reader(fn))`.
def callable_reader(fn: Callable[[list[Path]], str]) -> PageReader:
    class _CallableReader:
        def read(self, page_images: list[Path]) -> str:
            return fn(page_images)

    return _CallableReader()