provinans / src /endopath /textsource.py
reversely's picture
Upload folder using huggingface_hub
eea689d verified
Raw
History Blame Contribute Delete
7.05 kB
"""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()