| """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 |
|
|
| |
| |
| |
| 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 |
|
|
| 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 |
|
|
|
|
| |
| |
| |
| _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] |
|
|
|
|
| |
| |
| 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() |
|
|