Spaces:
Sleeping
Sleeping
Real OCR on image upload via cloud ZeroGPU Lab (text/seed stay deterministic)
Browse files- LMC_IMAGE_OCR decouples photo OCR from the text extractor: /ingest/image
routes to the ZeroGPU NuExtract3 Lab (real image->markdown + KVT4 facts),
while seeded card + text paste + safety stay on deterministic stub
(instant load, zero cloud unless a photo is explicitly uploaded)
- pipeline.ingest gains a per-doc extractor override (no change to card default)
- normalize fact timestamps to tz-aware UTC in observation_to_kvt (fixes
naive-vs-aware crash when cloud-OCR facts merge into stub-seeded series)
- Dockerfile: add gradio_client, set LMC_IMAGE_OCR=ocrlab
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Dockerfile +8 -4
- livemedcard/api.py +18 -8
- livemedcard/config.py +9 -2
- livemedcard/pipeline.py +8 -3
- livemedcard/stores.py +8 -2
Dockerfile
CHANGED
|
@@ -1,16 +1,20 @@
|
|
| 1 |
-
# LiveMedCard demo — Hugging Face Docker Space (CPU
|
| 2 |
-
# Публічний прототип для Digital Future Hackathon.
|
|
|
|
|
|
|
| 3 |
FROM python:3.12-slim
|
| 4 |
|
| 5 |
ENV PYTHONUNBUFFERED=1 \
|
| 6 |
PIP_NO_CACHE_DIR=1 \
|
| 7 |
LMC_EXTRACTOR=stub \
|
|
|
|
| 8 |
LMC_SEED_SAMPLES=1
|
| 9 |
|
| 10 |
WORKDIR /app
|
| 11 |
|
| 12 |
-
#
|
| 13 |
-
|
|
|
|
| 14 |
|
| 15 |
# Пакет разом зі static/ (SPA) і data/ (демо-документи, довідники).
|
| 16 |
COPY livemedcard ./livemedcard
|
|
|
|
| 1 |
+
# LiveMedCard demo — Hugging Face Docker Space (CPU).
|
| 2 |
+
# Публічний прототип для Digital Future Hackathon. Текст/сід/безпека — детермінований
|
| 3 |
+
# `stub` (миттєвий старт, дані не покидають процес). Завантаження ФОТО (`/ingest/image`)
|
| 4 |
+
# — реальний OCR через хмарний ZeroGPU Lab (NuExtract3), лише синтетичні документи.
|
| 5 |
FROM python:3.12-slim
|
| 6 |
|
| 7 |
ENV PYTHONUNBUFFERED=1 \
|
| 8 |
PIP_NO_CACHE_DIR=1 \
|
| 9 |
LMC_EXTRACTOR=stub \
|
| 10 |
+
LMC_IMAGE_OCR=ocrlab \
|
| 11 |
LMC_SEED_SAMPLES=1
|
| 12 |
|
| 13 |
WORKDIR /app
|
| 14 |
|
| 15 |
+
# stub-залежності (без torch/transformers) + gradio_client для виклику хмарного
|
| 16 |
+
# OCR Lab із фото. Легкий образ, швидкий cold-start; GPU лишається на боці Lab.
|
| 17 |
+
RUN pip install "pydantic>=2" "networkx>=3" "fastapi>=0.110" "uvicorn>=0.29" "gradio_client>=1.3"
|
| 18 |
|
| 19 |
# Пакет разом зі static/ (SPA) і data/ (демо-документи, довідники).
|
| 20 |
COPY livemedcard ./livemedcard
|
livemedcard/api.py
CHANGED
|
@@ -37,6 +37,7 @@ from pydantic import BaseModel
|
|
| 37 |
from .bundle import from_bundle, to_bundle
|
| 38 |
from .config import (
|
| 39 |
EXTRACTOR,
|
|
|
|
| 40 |
MAX_DOC_CHARS,
|
| 41 |
PERSIST_PATH,
|
| 42 |
SEED_SAMPLES,
|
|
@@ -147,14 +148,20 @@ def ingest_image(req: ImageIngestRequest):
|
|
| 147 |
detail="Зображення порожнє або некоректне",
|
| 148 |
)
|
| 149 |
|
| 150 |
-
#
|
| 151 |
-
#
|
| 152 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
try:
|
| 154 |
-
if
|
| 155 |
text = l2b_ocrlab.image_to_markdown(req.image_base64)
|
| 156 |
-
|
|
|
|
| 157 |
text = l2b_nuextract.image_to_markdown(req.image_base64)
|
|
|
|
| 158 |
else:
|
| 159 |
text = OcrService.perform_ocr(req.image_base64)
|
| 160 |
except (OcrError, LLMError, OcrLabError) as exc:
|
|
@@ -183,9 +190,12 @@ def ingest_image(req: ImageIngestRequest):
|
|
| 183 |
doc_id = f"doc-ocr-{hashlib.sha1(text.encode('utf-8')).hexdigest()[:12]}"
|
| 184 |
doc = Document(id=doc_id, text=text, kind=req.kind)
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
| 189 |
return report
|
| 190 |
|
| 191 |
|
|
|
|
| 37 |
from .bundle import from_bundle, to_bundle
|
| 38 |
from .config import (
|
| 39 |
EXTRACTOR,
|
| 40 |
+
IMAGE_OCR,
|
| 41 |
MAX_DOC_CHARS,
|
| 42 |
PERSIST_PATH,
|
| 43 |
SEED_SAMPLES,
|
|
|
|
| 148 |
detail="Зображення порожнє або некоректне",
|
| 149 |
)
|
| 150 |
|
| 151 |
+
# Бекенд OCR для фото відокремлений від текстового EXTRACTOR (config.IMAGE_OCR):
|
| 152 |
+
# публічне демо тримає `stub` для сіду/тексту (миттєво, детерміновано, 0 хмари),
|
| 153 |
+
# а фото — реальний OCR через хмарний Lab. `nuextract`/`ocrlab` самі роблять
|
| 154 |
+
# image→markdown, тож окремий OCR не потрібен. Витяг фактів для цього документа
|
| 155 |
+
# робить відповідний екстрактор (override), не змінюючи дефолт картки.
|
| 156 |
+
img_backend = IMAGE_OCR or EXTRACTOR
|
| 157 |
+
img_extractor: Extractor | None = None
|
| 158 |
try:
|
| 159 |
+
if img_backend == "ocrlab":
|
| 160 |
text = l2b_ocrlab.image_to_markdown(req.image_base64)
|
| 161 |
+
img_extractor = OcrLabExtractor()
|
| 162 |
+
elif img_backend == "nuextract":
|
| 163 |
text = l2b_nuextract.image_to_markdown(req.image_base64)
|
| 164 |
+
img_extractor = NuExtractExtractor()
|
| 165 |
else:
|
| 166 |
text = OcrService.perform_ocr(req.image_base64)
|
| 167 |
except (OcrError, LLMError, OcrLabError) as exc:
|
|
|
|
| 190 |
doc_id = f"doc-ocr-{hashlib.sha1(text.encode('utf-8')).hexdigest()[:12]}"
|
| 191 |
doc = Document(id=doc_id, text=text, kind=req.kind)
|
| 192 |
|
| 193 |
+
try:
|
| 194 |
+
with _state.lock:
|
| 195 |
+
report = _state.card.ingest(doc, extractor=img_extractor)
|
| 196 |
+
_state.autosave()
|
| 197 |
+
except OcrLabError as exc: # витяг фактів Stage 2 у хмарному Lab
|
| 198 |
+
raise HTTPException(status_code=503, detail=str(exc))
|
| 199 |
return report
|
| 200 |
|
| 201 |
|
livemedcard/config.py
CHANGED
|
@@ -41,8 +41,15 @@ OCR_MODEL = os.getenv("LMC_OCR_MODEL", "medgemma-1.5-4b-it")
|
|
| 41 |
# (opt-in cloud; лише синтетичні/деідентифіковані документи).
|
| 42 |
EXTRACTOR = os.getenv("LMC_EXTRACTOR", "stub")
|
| 43 |
|
| 44 |
-
#
|
| 45 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
OCRLAB_SPACE = os.getenv("LMC_OCRLAB_SPACE", "DocUA/livemedcard-ocr-lab")
|
| 47 |
OCRLAB_TOKEN = os.getenv("LMC_OCRLAB_TOKEN") or os.getenv("HF_TOKEN") or None
|
| 48 |
|
|
|
|
| 41 |
# (opt-in cloud; лише синтетичні/деідентифіковані документи).
|
| 42 |
EXTRACTOR = os.getenv("LMC_EXTRACTOR", "stub")
|
| 43 |
|
| 44 |
+
# Бекенд OCR саме для завантаження ФОТО (`/ingest/image`), відокремлений від
|
| 45 |
+
# текстового екстрактора. Дозволяє публічному демо тримати детермінований `stub`
|
| 46 |
+
# для сіду/тексту/безпеки (миттєвий старт, 0 хмари), але для явного завантаження
|
| 47 |
+
# фото — робити РЕАЛЬНИЙ OCR через хмарний Lab. Порожньо → успадковує EXTRACTOR.
|
| 48 |
+
# "ocrlab" — хмарний ZeroGPU NuExtract3; "nuextract" — локальний; "local" — vision-LLM (OcrService).
|
| 49 |
+
IMAGE_OCR = os.getenv("LMC_IMAGE_OCR", "")
|
| 50 |
+
|
| 51 |
+
# Хмарний OCR Lab (ZeroGPU) — окремий Gradio Space (Spec.md). Вмикається, коли
|
| 52 |
+
# EXTRACTOR == "ocrlab" АБО IMAGE_OCR == "ocrlab" (лише синтетичні/деідентифіковані документи).
|
| 53 |
OCRLAB_SPACE = os.getenv("LMC_OCRLAB_SPACE", "DocUA/livemedcard-ocr-lab")
|
| 54 |
OCRLAB_TOKEN = os.getenv("LMC_OCRLAB_TOKEN") or os.getenv("HF_TOKEN") or None
|
| 55 |
|
livemedcard/pipeline.py
CHANGED
|
@@ -104,14 +104,19 @@ class LiveMedCard:
|
|
| 104 |
self.factstore, self.patient.gender, lang
|
| 105 |
)
|
| 106 |
|
| 107 |
-
def ingest(self, doc: Document) -> TraceReport:
|
| 108 |
-
"""Прогнати один документ і повернути повний TRACE-звіт поточного стану.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
# Без мітки часу обзервації не потрапляють у факт-стор і зникають з
|
| 110 |
# таймлайну (і з перевірок безпеки). Проставляємо час прийому документа.
|
| 111 |
if doc.received_at is None:
|
| 112 |
doc = doc.model_copy(update={"received_at": datetime.now(timezone.utc)})
|
| 113 |
self._document_ids.add(doc.id)
|
| 114 |
-
ext = self.extractor.extract(doc, self.patient.id)
|
| 115 |
_assert_provenance(ext)
|
| 116 |
self._absorb(ext)
|
| 117 |
signals = self.current_signals()
|
|
|
|
| 104 |
self.factstore, self.patient.gender, lang
|
| 105 |
)
|
| 106 |
|
| 107 |
+
def ingest(self, doc: Document, *, extractor: "Extractor | None" = None) -> TraceReport:
|
| 108 |
+
"""Прогнати один документ і повернути повний TRACE-звіт поточного стану.
|
| 109 |
+
|
| 110 |
+
`extractor` — разовий override L2b для цього документа (не змінює дефолт
|
| 111 |
+
картки). Використовується для завантаження фото через хмарний OCR Lab,
|
| 112 |
+
коли текстовий шлях лишається детермінованим `stub`.
|
| 113 |
+
"""
|
| 114 |
# Без мітки часу обзервації не потрапляють у факт-стор і зникають з
|
| 115 |
# таймлайну (і з перевірок безпеки). Проставляємо час прийому документа.
|
| 116 |
if doc.received_at is None:
|
| 117 |
doc = doc.model_copy(update={"received_at": datetime.now(timezone.utc)})
|
| 118 |
self._document_ids.add(doc.id)
|
| 119 |
+
ext = (extractor or self.extractor).extract(doc, self.patient.id)
|
| 120 |
_assert_provenance(ext)
|
| 121 |
self._absorb(ext)
|
| 122 |
signals = self.current_signals()
|
livemedcard/stores.py
CHANGED
|
@@ -9,7 +9,7 @@
|
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
-
from datetime import datetime
|
| 13 |
from typing import Optional
|
| 14 |
|
| 15 |
import networkx as nx
|
|
@@ -61,13 +61,19 @@ def observation_to_kvt(o: Observation) -> Optional[KVTFact]:
|
|
| 61 |
if o.valueQuantity is None or o.effectiveDateTime is None:
|
| 62 |
return None
|
| 63 |
kw = o.code.text or (o.code.coding[0].display if o.code.coding else "obs")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
return KVTFact(
|
| 65 |
obs_id=o.id,
|
| 66 |
keyword=kw,
|
| 67 |
loinc=loinc_of(o),
|
| 68 |
value=o.valueQuantity.value,
|
| 69 |
unit=o.valueQuantity.unit,
|
| 70 |
-
ts=
|
| 71 |
source_doc_id=o.provenance.source_doc_id if o.provenance else None,
|
| 72 |
source_span=o.provenance.source_span if o.provenance else None,
|
| 73 |
)
|
|
|
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
from typing import Optional
|
| 14 |
|
| 15 |
import networkx as nx
|
|
|
|
| 61 |
if o.valueQuantity is None or o.effectiveDateTime is None:
|
| 62 |
return None
|
| 63 |
kw = o.code.text or (o.code.coding[0].display if o.code.coding else "obs")
|
| 64 |
+
ts = o.effectiveDateTime
|
| 65 |
+
# Нормалізуємо до tz-aware UTC: різні джерела дають naive (демо-семпли) чи
|
| 66 |
+
# aware (екстрактори) мітки; без цього series() падає на порівнянні при їх
|
| 67 |
+
# змішуванні (напр. stub-сід + хмарний OCR фото в одному LOINC-ряді).
|
| 68 |
+
if ts.tzinfo is None:
|
| 69 |
+
ts = ts.replace(tzinfo=timezone.utc)
|
| 70 |
return KVTFact(
|
| 71 |
obs_id=o.id,
|
| 72 |
keyword=kw,
|
| 73 |
loinc=loinc_of(o),
|
| 74 |
value=o.valueQuantity.value,
|
| 75 |
unit=o.valueQuantity.unit,
|
| 76 |
+
ts=ts,
|
| 77 |
source_doc_id=o.provenance.source_doc_id if o.provenance else None,
|
| 78 |
source_span=o.provenance.source_span if o.provenance else None,
|
| 79 |
)
|