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 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
- img_extractor = OcrLabExtractor()
 
 
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__(self, *, space: str | None = OCRLAB_SPACE) -> None:
 
 
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
- facts = result.get("facts", []) if isinstance(result, dict) else result
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
- toast(rep.decision.escalate?t.toastEscalated:t.toastAuto);
 
 
 
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
  };