DocUA Claude Opus 4.8 commited on
Commit
97a786e
·
1 Parent(s): 5f6e063

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 CHANGED
@@ -1,16 +1,20 @@
1
- # LiveMedCard demo — Hugging Face Docker Space (CPU, детермінований stub-режим).
2
- # Публічний прототип для Digital Future Hackathon. Дані не покидають процес; без LLM/GPU.
 
 
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
- # Лише stub-залежності (без torch/transformers) легкий образ, швидкий cold-start.
13
- RUN pip install "pydantic>=2" "networkx>=3" "fastapi>=0.110" "uvicorn>=0.29"
 
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
- # У режимах nuextract/ocrlab зображення читає сам NuExtract (image→markdown),
151
- # тож окремий OCR не потрібен — уникаємо накопичення помилок OCR→summary→extract.
152
- # ocrlab робить image→markdown на хмарному ZeroGPU Space (opt-in).
 
 
 
 
153
  try:
154
- if EXTRACTOR == "ocrlab":
155
  text = l2b_ocrlab.image_to_markdown(req.image_base64)
156
- elif EXTRACTOR == "nuextract":
 
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
- with _state.lock:
187
- report = _state.card.ingest(doc)
188
- _state.autosave()
 
 
 
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
- # Хмарний OCR Lab (ZeroGPU) — окремий Gradio Space (Spec.md). Вмикається лише коли
45
- # EXTRACTOR == "ocrlab"; за замовчуванням не використовується (pitch-демо на stub).
 
 
 
 
 
 
 
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=o.effectiveDateTime,
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
  )