Spaces:
Sleeping
Sleeping
DocUA commited on
Commit ·
e97fe1b
1
Parent(s): 90baed6
Photo OCR: fall back to image->facts when transcription yields none; surface empty extraction in UI
Browse files- livemedcard/api.py +3 -1
- livemedcard/layers/l2b_extractor.py +11 -1
- livemedcard/layers/l2b_ocrlab.py +33 -4
- livemedcard/static/index.html +6 -1
livemedcard/api.py
CHANGED
|
@@ -204,7 +204,9 @@ def ingest_image(req: ImageIngestRequest, state: _State = Depends(_get_state)):
|
|
| 204 |
try:
|
| 205 |
if img_backend == "ocrlab":
|
| 206 |
text = l2b_ocrlab.image_to_markdown(req.image_base64)
|
| 207 |
-
|
|
|
|
|
|
|
| 208 |
elif img_backend == "nuextract":
|
| 209 |
text = l2b_nuextract.image_to_markdown(req.image_base64)
|
| 210 |
img_extractor = NuExtractExtractor()
|
|
|
|
| 204 |
try:
|
| 205 |
if img_backend == "ocrlab":
|
| 206 |
text = l2b_ocrlab.image_to_markdown(req.image_base64)
|
| 207 |
+
# Зображення передаємо в екстрактор: якщо транскрипція вийшла неповною
|
| 208 |
+
# і фактів у ній нема, він добере їх прямо з фото (запасний шлях).
|
| 209 |
+
img_extractor = OcrLabExtractor(image_base64=req.image_base64)
|
| 210 |
elif img_backend == "nuextract":
|
| 211 |
text = l2b_nuextract.image_to_markdown(req.image_base64)
|
| 212 |
img_extractor = NuExtractExtractor()
|
livemedcard/layers/l2b_extractor.py
CHANGED
|
@@ -407,11 +407,21 @@ class OcrLabExtractor:
|
|
| 407 |
`to_observations` — межа детермінізму збережена.
|
| 408 |
"""
|
| 409 |
|
| 410 |
-
def __init__(
|
|
|
|
|
|
|
| 411 |
self._space = space
|
|
|
|
|
|
|
|
|
|
| 412 |
|
| 413 |
def extract(self, doc: Document, patient_id: str) -> ExtractionResult:
|
| 414 |
kvt = l2b_ocrlab.extract_facts(doc.text, space=self._space)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
observations = l2b_stage2_lora.to_observations(
|
| 416 |
kvt, doc, patient_id, extracted_by="NuExtract3/ZeroGPU"
|
| 417 |
)
|
|
|
|
| 407 |
`to_observations` — межа детермінізму збережена.
|
| 408 |
"""
|
| 409 |
|
| 410 |
+
def __init__(
|
| 411 |
+
self, *, space: str | None = OCRLAB_SPACE, image_base64: str | None = None
|
| 412 |
+
) -> None:
|
| 413 |
self._space = space
|
| 414 |
+
# Зображення-джерело (коли документ прийшов із фото): дає запасний шлях,
|
| 415 |
+
# якщо з markdown-транскрипції не вийшло витягти жодного факту.
|
| 416 |
+
self._image_base64 = image_base64
|
| 417 |
|
| 418 |
def extract(self, doc: Document, patient_id: str) -> ExtractionResult:
|
| 419 |
kvt = l2b_ocrlab.extract_facts(doc.text, space=self._space)
|
| 420 |
+
if not kvt and self._image_base64:
|
| 421 |
+
# Транскрипція буває неповною (модель може обірвати широку таблицю на
|
| 422 |
+
# пів-документі) — тоді фактів у тексті просто немає. Беремо їх прямо
|
| 423 |
+
# із зображення одним проходом; free-text іпостась лишається тією ж.
|
| 424 |
+
kvt = l2b_ocrlab.facts_from_image(self._image_base64, space=self._space)
|
| 425 |
observations = l2b_stage2_lora.to_observations(
|
| 426 |
kvt, doc, patient_id, extracted_by="NuExtract3/ZeroGPU"
|
| 427 |
)
|
livemedcard/layers/l2b_ocrlab.py
CHANGED
|
@@ -47,6 +47,14 @@ def _client(space: str):
|
|
| 47 |
raise OcrLabError(f"Не вдалося під'єднатися до OCR Lab '{space}': {last}")
|
| 48 |
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
def _decode_to_tempfile(image_base64: str) -> str:
|
| 51 |
if "," in image_base64:
|
| 52 |
image_base64 = image_base64.split(",", 1)[1]
|
|
@@ -75,6 +83,30 @@ def image_to_markdown(image_base64: str, *, space: str | None = None) -> str:
|
|
| 75 |
return str(result or "").strip()
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
def extract_facts(text: str, *, space: str | None = None) -> list[dict]:
|
| 79 |
"""Stage 2: текст/markdown → сирі KVT-факти (через ендпойнт /extract_facts)."""
|
| 80 |
client = _client(space or OCRLAB_SPACE)
|
|
@@ -82,7 +114,4 @@ def extract_facts(text: str, *, space: str | None = None) -> list[dict]:
|
|
| 82 |
result = client.predict(text, api_name="/extract_facts")
|
| 83 |
except Exception as exc:
|
| 84 |
raise OcrLabError(f"OCR Lab extract_facts недоступний: {exc}") from exc
|
| 85 |
-
|
| 86 |
-
if not isinstance(facts, list):
|
| 87 |
-
return []
|
| 88 |
-
return [f for f in facts if isinstance(f, dict) and f.get("keyword")]
|
|
|
|
| 47 |
raise OcrLabError(f"Не вдалося під'єднатися до OCR Lab '{space}': {last}")
|
| 48 |
|
| 49 |
|
| 50 |
+
def _facts_of(result) -> list[dict]:
|
| 51 |
+
"""Спільний розбір відповіді Lab: {"facts": [...]} або голий список."""
|
| 52 |
+
facts = result.get("facts", []) if isinstance(result, dict) else result
|
| 53 |
+
if not isinstance(facts, list):
|
| 54 |
+
return []
|
| 55 |
+
return [f for f in facts if isinstance(f, dict) and f.get("keyword")]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
def _decode_to_tempfile(image_base64: str) -> str:
|
| 59 |
if "," in image_base64:
|
| 60 |
image_base64 = image_base64.split(",", 1)[1]
|
|
|
|
| 83 |
return str(result or "").strip()
|
| 84 |
|
| 85 |
|
| 86 |
+
def facts_from_image(image_base64: str, *, space: str | None = None) -> list[dict]:
|
| 87 |
+
"""Зображення → сирі KVT-факти одним проходом (в обхід markdown-етапу).
|
| 88 |
+
|
| 89 |
+
Запасний шлях для випадку, коли транскрипція markdown вийшла неповною (модель
|
| 90 |
+
може зірватися в петлю на широких таблицях і обірвати документ) — тоді фактів
|
| 91 |
+
із тексту не витягти, а з самого зображення все ще можна.
|
| 92 |
+
"""
|
| 93 |
+
try:
|
| 94 |
+
from gradio_client import handle_file
|
| 95 |
+
except ImportError as exc: # pragma: no cover
|
| 96 |
+
raise OcrLabError(
|
| 97 |
+
"gradio_client не встановлено — постав extra 'cloud' для хмарного OCR Lab."
|
| 98 |
+
) from exc
|
| 99 |
+
client = _client(space or OCRLAB_SPACE)
|
| 100 |
+
path = _decode_to_tempfile(image_base64)
|
| 101 |
+
try:
|
| 102 |
+
result = client.predict(handle_file(path), api_name="/facts_from_image")
|
| 103 |
+
except Exception as exc:
|
| 104 |
+
raise OcrLabError(f"OCR Lab facts_from_image недоступний: {exc}") from exc
|
| 105 |
+
finally:
|
| 106 |
+
os.unlink(path)
|
| 107 |
+
return _facts_of(result)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
def extract_facts(text: str, *, space: str | None = None) -> list[dict]:
|
| 111 |
"""Stage 2: текст/markdown → сирі KVT-факти (через ендпойнт /extract_facts)."""
|
| 112 |
client = _client(space or OCRLAB_SPACE)
|
|
|
|
| 114 |
result = client.predict(text, api_name="/extract_facts")
|
| 115 |
except Exception as exc:
|
| 116 |
raise OcrLabError(f"OCR Lab extract_facts недоступний: {exc}") from exc
|
| 117 |
+
return _facts_of(result)
|
|
|
|
|
|
|
|
|
livemedcard/static/index.html
CHANGED
|
@@ -449,6 +449,7 @@ const I18N = {
|
|
| 449 |
toastExport:'FHIR bundle downloaded', toastNeedInput:'Enter text or choose a photo',
|
| 450 |
ocrReading:'Reading (OCR)…', processing:'Processing…',
|
| 451 |
toastEscalated:'Processed · escalated to human', toastAuto:'Processed · auto',
|
|
|
|
| 452 |
errorPrefix:'Error: ', selected:name=>`Selected: ${name}`,
|
| 453 |
chips:['What about kidneys over the last year?','What was potassium?',
|
| 454 |
'What medications and any interactions?','What about HbA1c?'],
|
|
@@ -491,6 +492,7 @@ const I18N = {
|
|
| 491 |
toastExport:'FHIR Bundle завантажено', toastNeedInput:'Введіть текст або виберіть фото',
|
| 492 |
ocrReading:'Зчитування (OCR)…', processing:'Опрацювання…',
|
| 493 |
toastEscalated:'Оброблено · ескалація до людини', toastAuto:'Оброблено · авто',
|
|
|
|
| 494 |
errorPrefix:'Помилка: ', selected:name=>`Вибрано: ${name}`,
|
| 495 |
chips:['Що з нирками за останній рік?','Який був калій?',
|
| 496 |
'Які ліки приймає і чи є взаємодії?','Що з HbA1c?'],
|
|
@@ -803,7 +805,10 @@ $('#add-submit').onclick=async()=>{
|
|
| 803 |
$('#dropzone-text').textContent=t.dropzoneDefault;
|
| 804 |
$('#add-form').classList.remove('show');
|
| 805 |
await refresh();
|
| 806 |
-
|
|
|
|
|
|
|
|
|
|
| 807 |
}catch(e){toast(t.errorPrefix+e.message);}
|
| 808 |
finally{btn.disabled=false;btn.textContent=orig;}
|
| 809 |
};
|
|
|
|
| 449 |
toastExport:'FHIR bundle downloaded', toastNeedInput:'Enter text or choose a photo',
|
| 450 |
ocrReading:'Reading (OCR)…', processing:'Processing…',
|
| 451 |
toastEscalated:'Processed · escalated to human', toastAuto:'Processed · auto',
|
| 452 |
+
toastNoFacts:'Document saved, but no measurements were extracted',
|
| 453 |
errorPrefix:'Error: ', selected:name=>`Selected: ${name}`,
|
| 454 |
chips:['What about kidneys over the last year?','What was potassium?',
|
| 455 |
'What medications and any interactions?','What about HbA1c?'],
|
|
|
|
| 492 |
toastExport:'FHIR Bundle завантажено', toastNeedInput:'Введіть текст або виберіть фото',
|
| 493 |
ocrReading:'Зчитування (OCR)…', processing:'Опрацювання…',
|
| 494 |
toastEscalated:'Оброблено · ескалація до людини', toastAuto:'Оброблено · авто',
|
| 495 |
+
toastNoFacts:'Документ збережено, але жодного показника не витягнуто',
|
| 496 |
errorPrefix:'Помилка: ', selected:name=>`Вибрано: ${name}`,
|
| 497 |
chips:['Що з нирками за останній рік?','Який був калій?',
|
| 498 |
'Які ліки приймає і чи є взаємодії?','Що з HbA1c?'],
|
|
|
|
| 805 |
$('#dropzone-text').textContent=t.dropzoneDefault;
|
| 806 |
$('#add-form').classList.remove('show');
|
| 807 |
await refresh();
|
| 808 |
+
// Нуль фактів — не помилка конвеєра (документ збережено як текст), але й не
|
| 809 |
+
// успіх: мовчати про це не можна, інакше «оброблено» приховує порожній витяг.
|
| 810 |
+
const got=(rep.extraction&&rep.extraction.observations||[]).length;
|
| 811 |
+
toast(got?(rep.decision.escalate?t.toastEscalated:t.toastAuto):t.toastNoFacts);
|
| 812 |
}catch(e){toast(t.errorPrefix+e.message);}
|
| 813 |
finally{btn.disabled=false;btn.textContent=orig;}
|
| 814 |
};
|