DocUA commited on
Commit
18603d9
·
1 Parent(s): 8b3eee1

Unit guard before L2a, LOINC aliases, cloud consent, honest confidence

Browse files
livemedcard/api.py CHANGED
@@ -88,6 +88,20 @@ def _make_extractor() -> Extractor:
88
  return StubExtractor()
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  class _State:
92
  """Тримач картки одного пацієнта: доступ лише під `lock`."""
93
 
@@ -375,6 +389,9 @@ def get_state_endpoint(lang: str = "uk", state: _State = Depends(_get_state)):
375
  "medications": medications,
376
  "signals": [s.model_dump() for s in sigs],
377
  "decision": decision.model_dump(),
 
 
 
378
  }
379
 
380
 
 
88
  return StubExtractor()
89
 
90
 
91
+ def _photo_ocr_mode() -> str:
92
+ """Куди піде завантажене фото: 'cloud' | 'local' | 'none'.
93
+
94
+ Публічне демо тримає детермінований `stub` для тексту, але фото віддає в хмарний
95
+ ZeroGPU Lab — і UI мусить показувати саме це, а не загальне «без хмари».
96
+ """
97
+ backend = IMAGE_OCR or EXTRACTOR
98
+ if backend == "ocrlab":
99
+ return "cloud"
100
+ if backend in ("nuextract", "local", "two_stage", "two_stage_lora"):
101
+ return "local"
102
+ return "none"
103
+
104
+
105
  class _State:
106
  """Тримач картки одного пацієнта: доступ лише під `lock`."""
107
 
 
389
  "medications": medications,
390
  "signals": [s.model_dump() for s in sigs],
391
  "decision": decision.model_dump(),
392
+ # Куди піде ФОТО. Інтерфейс мусить казати про це правду: у публічному
393
+ # демо текст/сід/безпека лишаються на сервері, а фото їде в хмарний Lab.
394
+ "photo_ocr": _photo_ocr_mode(),
395
  }
396
 
397
 
livemedcard/config.py CHANGED
@@ -18,6 +18,13 @@ LOW_CONFIDENCE = float(os.getenv("LMC_LOW_CONFIDENCE", "0.6"))
18
  # Реальний екстрактор (MedGemma) має ставити модельну впевненість замість цієї.
19
  STUB_CONFIDENCE = float(os.getenv("LMC_STUB_CONFIDENCE", "0.95"))
20
 
 
 
 
 
 
 
 
21
  # Максимальний розмір тексту документа (символів) — валідація API.
22
  MAX_DOC_CHARS = int(os.getenv("LMC_MAX_DOC_CHARS", "20000"))
23
 
 
18
  # Реальний екстрактор (MedGemma) має ставити модельну впевненість замість цієї.
19
  STUB_CONFIDENCE = float(os.getenv("LMC_STUB_CONFIDENCE", "0.95"))
20
 
21
+ # Впевненість витягу, зробленого LLM (NuExtract/LoRA/хмарний Lab). Тримаємо НИЖЧЕ
22
+ # LOW_CONFIDENCE навмисно: калібрування в нас поки немає, а обірваний OCR виглядає
23
+ # рівно так само, як повний. Поки не вміємо міряти — чесне значення це «не
24
+ # перевірено», і L3 веде такий документ до людини. Детермінований `stub` (regex +
25
+ # vocab на сирому тексті) лишається при 0.95: там впевненість справді щось означає.
26
+ UNVERIFIED_CONFIDENCE = float(os.getenv("LMC_UNVERIFIED_CONFIDENCE", "0.5"))
27
+
28
  # Максимальний розмір тексту документа (символів) — валідація API.
29
  MAX_DOC_CHARS = int(os.getenv("LMC_MAX_DOC_CHARS", "20000"))
30
 
livemedcard/layers/l2a_trends.py CHANGED
@@ -10,6 +10,7 @@ from typing import Optional
10
  from ..dto import Signal
11
  from ..reference import range_for
12
  from ..stores import FactStore
 
13
 
14
 
15
  def _fmt(v: float) -> str:
@@ -33,6 +34,10 @@ def detect_trends(store: FactStore, lang: str = "uk") -> list[Signal]:
33
  series = store.series(loinc)
34
  if len(series) < 3:
35
  continue
 
 
 
 
36
  values = [f.value for f in series]
37
  # Нестрога монотонність із чистою зміною: рівний крок (повторне значення)
38
  # НЕ скасовує очевидний тренд, але суто рівний ряд трендом не вважається.
@@ -84,6 +89,29 @@ def detect_range_breaches(
84
  # series відсортований за часом; беремо ОСТАННІЙ (при рівних мітках —
85
  # останній доданий), щоб L2a і Q&A узгоджено бачили те саме значення.
86
  latest = series[-1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  if latest.value < ref.low:
88
  direction = "below range" if lang == "en" else "нижче норми"
89
  elif latest.value > ref.high:
 
10
  from ..dto import Signal
11
  from ..reference import range_for
12
  from ..stores import FactStore
13
+ from ..units import canonical, same_unit
14
 
15
 
16
  def _fmt(v: float) -> str:
 
34
  series = store.series(loinc)
35
  if len(series) < 3:
36
  continue
37
+ # Ряд у різних одиницях — не ряд: 100 mg/dL і 5.5 mmol/L «зростають» лише
38
+ # на папері. Тренд рахуємо тільки на однорідних одиницях.
39
+ if len({canonical(f.unit) for f in series}) > 1:
40
+ continue
41
  values = [f.value for f in series]
42
  # Нестрога монотонність із чистою зміною: рівний крок (повторне значення)
43
  # НЕ скасовує очевидний тренд, але суто рівний ряд трендом не вважається.
 
89
  # series відсортований за часом; беремо ОСТАННІЙ (при рівних мітках —
90
  # останній доданий), щоб L2a і Q&A узгоджено бачили те саме значення.
91
  latest = series[-1]
92
+ # Порівнювати з межею можна лише в тих самих одиницях. Інакше — не мовчимо,
93
+ # а кажемо прямо: перевірку не виконано (мовчазний пропуск гірший за незгоду).
94
+ if not same_unit(latest.unit, ref.unit):
95
+ keyword = (ref.keyword_en if lang == "en" and ref.keyword_en else ref.keyword)
96
+ got = latest.unit or ("not stated" if lang == "en" else "не вказано")
97
+ detail = (
98
+ f"{keyword} {_fmt(latest.value)} {got}: units do not match the reference "
99
+ f"({ref.unit}) — range check not performed"
100
+ if lang == "en" else
101
+ f"{keyword} {_fmt(latest.value)} {got}: одиниці не збігаються з довідником "
102
+ f"({ref.unit}) — перевірку меж не виконано"
103
+ )
104
+ signals.append(
105
+ Signal(
106
+ layer="L2a",
107
+ kind="unit_mismatch",
108
+ severity="moderate",
109
+ detail=detail,
110
+ subjects=[loinc],
111
+ confidence=1.0,
112
+ )
113
+ )
114
+ continue
115
  if latest.value < ref.low:
116
  direction = "below range" if lang == "en" else "нижче норми"
117
  elif latest.value > ref.high:
livemedcard/layers/l2b_extractor.py CHANGED
@@ -17,6 +17,7 @@ from ..config import (
17
  STAGE2_MODEL,
18
  STAGE2_URL,
19
  STUB_CONFIDENCE,
 
20
  )
21
  from ..dto import Document, ExtractionResult
22
  from ..fhir import (
@@ -330,6 +331,9 @@ class TwoStageExtractor:
330
  ext = deterministic_extract(
331
  summary_text, doc, patient_id, extracted_by="MedGemma-4B+Stage2"
332
  )
 
 
 
333
  ext.document_reference = _build_docref(doc, summary_text, patient_id)
334
  return ext
335
 
@@ -367,7 +371,7 @@ class LoraStage2Extractor:
367
  )
368
  return ExtractionResult(
369
  observations=observations,
370
- mean_confidence=STUB_CONFIDENCE if observations else 0.0,
371
  document_reference=_build_docref(doc, summary_text, patient_id),
372
  )
373
 
@@ -392,7 +396,7 @@ class NuExtractExtractor:
392
  )
393
  return ExtractionResult(
394
  observations=observations,
395
- mean_confidence=STUB_CONFIDENCE if observations else 0.0,
396
  document_reference=_build_docref(doc, doc.text, patient_id),
397
  )
398
 
@@ -427,7 +431,7 @@ class OcrLabExtractor:
427
  )
428
  return ExtractionResult(
429
  observations=observations,
430
- mean_confidence=STUB_CONFIDENCE if observations else 0.0,
431
  document_reference=_build_docref(doc, doc.text, patient_id),
432
  )
433
 
 
17
  STAGE2_MODEL,
18
  STAGE2_URL,
19
  STUB_CONFIDENCE,
20
+ UNVERIFIED_CONFIDENCE,
21
  )
22
  from ..dto import Document, ExtractionResult
23
  from ..fhir import (
 
331
  ext = deterministic_extract(
332
  summary_text, doc, patient_id, extracted_by="MedGemma-4B+Stage2"
333
  )
334
+ # Stage 2 тут детермінований, але читає він LLM-summary: чого модель не
335
+ # внесла у стиснення, того регекс уже не поверне. Отже — «не перевірено».
336
+ ext.mean_confidence = UNVERIFIED_CONFIDENCE if ext.observations else 0.0
337
  ext.document_reference = _build_docref(doc, summary_text, patient_id)
338
  return ext
339
 
 
371
  )
372
  return ExtractionResult(
373
  observations=observations,
374
+ mean_confidence=UNVERIFIED_CONFIDENCE if observations else 0.0,
375
  document_reference=_build_docref(doc, summary_text, patient_id),
376
  )
377
 
 
396
  )
397
  return ExtractionResult(
398
  observations=observations,
399
+ mean_confidence=UNVERIFIED_CONFIDENCE if observations else 0.0,
400
  document_reference=_build_docref(doc, doc.text, patient_id),
401
  )
402
 
 
431
  )
432
  return ExtractionResult(
433
  observations=observations,
434
+ mean_confidence=UNVERIFIED_CONFIDENCE if observations else 0.0,
435
  document_reference=_build_docref(doc, doc.text, patient_id),
436
  )
437
 
livemedcard/layers/l2b_stage2_lora.py CHANGED
@@ -35,9 +35,13 @@ from ..llm_client import chat_completions
35
  _KEYWORD_LOINC: dict[str, tuple[str, str]] = {
36
  # EN (readmission-домен FT) + UA — білінгвальний міст
37
  "creatinine": ("2160-0", "Creatinine"),
 
38
  "креатинін": ("2160-0", "Creatinine"),
39
  "glucose": ("2345-7", "Glucose"),
 
 
40
  "глюкоза": ("2345-7", "Glucose"),
 
41
  "potassium": ("2823-3", "Potassium"),
42
  "калій": ("2823-3", "Potassium"),
43
  "sodium": ("2951-2", "Sodium"),
@@ -45,6 +49,8 @@ _KEYWORD_LOINC: dict[str, tuple[str, str]] = {
45
  "hemoglobin": ("718-7", "Hemoglobin"),
46
  "гемоглобін": ("718-7", "Hemoglobin"),
47
  "hba1c": ("4548-4", "Hemoglobin A1c"),
 
 
48
  "глікований гемоглобін": ("4548-4", "Hemoglobin A1c"),
49
  "hematocrit": ("4544-3", "Hematocrit"),
50
  "гематокрит": ("4544-3", "Hematocrit"),
@@ -191,6 +197,12 @@ def to_observations(
191
  num, unit = _split_value(item.get("value"))
192
  if num is None: # нечислові (Yes/No, назви) лишаються лише в summary
193
  continue
 
 
 
 
 
 
194
  eff = _parse_timestamp(item.get("timestamp"), doc.received_at)
195
  bridge = _resolve_loinc(keyword)
196
  coding = (
 
35
  _KEYWORD_LOINC: dict[str, tuple[str, str]] = {
36
  # EN (readmission-домен FT) + UA — білінгвальний міст
37
  "creatinine": ("2160-0", "Creatinine"),
38
+ "serum creatinine": ("2160-0", "Creatinine"),
39
  "креатинін": ("2160-0", "Creatinine"),
40
  "glucose": ("2345-7", "Glucose"),
41
+ "fasting glucose": ("2345-7", "Glucose"),
42
+ "blood glucose": ("2345-7", "Glucose"),
43
  "глюкоза": ("2345-7", "Glucose"),
44
+ "глюкоза натще": ("2345-7", "Glucose"),
45
  "potassium": ("2823-3", "Potassium"),
46
  "калій": ("2823-3", "Potassium"),
47
  "sodium": ("2951-2", "Sodium"),
 
49
  "hemoglobin": ("718-7", "Hemoglobin"),
50
  "гемоглобін": ("718-7", "Hemoglobin"),
51
  "hba1c": ("4548-4", "Hemoglobin A1c"),
52
+ "hemoglobin a1c": ("4548-4", "Hemoglobin A1c"),
53
+ "glycated hemoglobin": ("4548-4", "Hemoglobin A1c"),
54
  "глікований гемоглобін": ("4548-4", "Hemoglobin A1c"),
55
  "hematocrit": ("4544-3", "Hematocrit"),
56
  "гематокрит": ("4544-3", "Hematocrit"),
 
197
  num, unit = _split_value(item.get("value"))
198
  if num is None: # нечислові (Yes/No, назви) лишаються лише в summary
199
  continue
200
+ # KVT4 з хмарного Lab несе одиницю ОКРЕМИМ полем ({"value":"135","unit":"µmol/L"}),
201
+ # тоді як локальний шлях вкладає її в рядок значення ("135 мкмоль/л"). Без цього
202
+ # рядка кожен факт із фото приходив без одиниці — і псував FHIR-експорт.
203
+ if unit is None:
204
+ raw_unit = str(item.get("unit") or "").strip()
205
+ unit = raw_unit or None
206
  eff = _parse_timestamp(item.get("timestamp"), doc.received_at)
207
  bridge = _resolve_loinc(keyword)
208
  coding = (
livemedcard/static/index.html CHANGED
@@ -268,6 +268,17 @@
268
  .drawer .cap{padding:10px 18px 0;color:var(--faint);font-size:11.5px}
269
  .drawer .body{padding:14px 18px 20px;overflow:auto;white-space:pre-wrap;
270
  font-size:13.5px;line-height:1.75;color:var(--muted)}
 
 
 
 
 
 
 
 
 
 
 
271
  .drawer .body mark{background:color-mix(in srgb,var(--accent) 26%,transparent);color:var(--ink);
272
  border-radius:4px;padding:1px 3px;font-weight:650;
273
  box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 45%,transparent)}
@@ -390,6 +401,15 @@
390
  <div class="cap" id="drawer-cap"></div>
391
  <div class="body" id="drawer-body"></div>
392
  </aside>
 
 
 
 
 
 
 
 
 
393
  <div class="toast" id="toast"></div>
394
 
395
  <script>
@@ -456,10 +476,16 @@ const I18N = {
456
  'What medications and any interactions?','What about HbA1c?'],
457
  story:(docs,pair)=>`One timeline from ${docs} documents in two languages. The card flagged a dangerous combination — <b>${pair}</b> — deterministically, by safety rules, not by AI guess. See the red alert in the Safety panel.`,
458
  groupL1:'L1 · deterministic rules', groupL2a:'L2a · trends & reference ranges',
459
- trust:['No cloud LLM','Deterministic safety rules (L1)','FHIR export','Local-first by design'],
 
 
460
  drawerTitle:id=>`Source · ${id}`,
461
  drawerCap:'The extracted fact is highlighted in the source document.',
462
  srcTitle:'Show source document',
 
 
 
 
463
  },
464
  ua: {
465
  htmlLang:'uk', title:'LiveMedCard — жива медична картка',
@@ -500,10 +526,16 @@ const I18N = {
500
  'Які ліки приймає і чи є взаємодії?','Що з HbA1c?'],
501
  story:(docs,pair)=>`Один таймлайн із ${docs} документів двома мовами. Картка помітила небезпечну комбінацію — <b>${pair}</b> — детерміновано, правилами безпеки, а не «на думку» ШІ. Дивіться червоний сигнал у панелі безпеки.`,
502
  groupL1:'L1 · детерміновані правила', groupL2a:'L2a · тренди й референсні межі',
503
- trust:['Без хмарної LLM','Детерміновані правила безпеки (L1)','Експорт FHIR','Local-first за дизайном'],
 
 
504
  drawerTitle:id=>`Джерело · ${id}`,
505
  drawerCap:'Витягнутий факт підсвічено в документі-джерелі.',
506
  srcTitle:'Показати документ-джерело',
 
 
 
 
507
  },
508
  };
509
 
@@ -542,7 +574,7 @@ function applyLang(lang){
542
  $('#qa-tag').textContent = t.qaTag;
543
  $('#q').placeholder = t.qPlaceholder;
544
  $('#ask').textContent = t.ask;
545
- $('#trust').innerHTML = t.trust.map(x=>`<span>${x}</span>`).join('');
546
  $('#site-footer').innerHTML = `${t.footer1}<br>${t.footer2}
547
  <div class="flinks"><a href="https://github.com/SZabolotnii/Digital_Future_Hackathon" target="_blank" rel="noopener">GitHub</a> ·
548
  <a href="https://traces.solutions" target="_blank" rel="noopener">traces.solutions</a> ·
@@ -628,8 +660,20 @@ function trendOf(s){
628
  return ['→ 0%','flat'];
629
  }
630
 
 
 
 
 
 
 
 
 
 
 
 
631
  function render(st){
632
  lastState = st;
 
633
  const t=I18N[LANG];
634
  const docs=st.document_count||0;
635
  $('#patient').innerHTML = docs ? t.patient(st.patient.id, docs) : t.patientNone;
@@ -778,6 +822,32 @@ $('#btn-export').onclick=async()=>{
778
  };
779
  $('#btn-add').onclick=()=>$('#add-form').classList.toggle('show');
780
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
781
  const fileInput=$('#add-image'), dz=$('#dropzone');
782
  fileInput.onchange=e=>{const f=e.target.files[0];if(f){$('#dropzone-text').textContent=I18N[LANG].selected(f.name);$('#add-text').value='';}};
783
  dz.addEventListener('dragover',e=>{e.preventDefault();dz.classList.add('dragover');});
@@ -794,6 +864,7 @@ $('#add-submit').onclick=async()=>{
794
  try{
795
  let rep;
796
  if(file){
 
797
  btn.textContent=t.ocrReading;
798
  // Хмарний OCR — це ~70–90 с. Без попередження мовчазне очікування читається
799
  // як зависання (і саме так виглядає для журі, що клікне демо самостійно).
 
268
  .drawer .cap{padding:10px 18px 0;color:var(--faint);font-size:11.5px}
269
  .drawer .body{padding:14px 18px 20px;overflow:auto;white-space:pre-wrap;
270
  font-size:13.5px;line-height:1.75;color:var(--muted)}
271
+ /* ── cloud consent (photo leaves the device — say so before it does) ── */
272
+ .consent{position:fixed;left:50%;top:50%;transform:translate(-50%,-46%) scale(.98);
273
+ width:min(470px,92vw);background:var(--panel);border:1px solid var(--line);border-radius:14px;
274
+ padding:22px 24px 18px;z-index:60;opacity:0;pointer-events:none;
275
+ box-shadow:0 24px 60px rgba(4,9,13,.28);transition:opacity .16s,transform .16s}
276
+ .consent.show{opacity:1;pointer-events:auto;transform:translate(-50%,-50%) scale(1)}
277
+ .consent h3{margin:0 0 10px;font-size:15.5px;color:var(--critical)}
278
+ .consent p{margin:0 0 16px;font-size:13px;line-height:1.6;color:var(--muted);white-space:pre-line}
279
+ .consent .row{display:flex;gap:9px;justify-content:flex-end}
280
+ .consent .ghost{background:none;border:1px solid var(--line);color:var(--muted)}
281
+ .consent .ghost:hover{color:var(--ink);border-color:var(--accent)}
282
  .drawer .body mark{background:color-mix(in srgb,var(--accent) 26%,transparent);color:var(--ink);
283
  border-radius:4px;padding:1px 3px;font-weight:650;
284
  box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 45%,transparent)}
 
401
  <div class="cap" id="drawer-cap"></div>
402
  <div class="body" id="drawer-body"></div>
403
  </aside>
404
+ <div class="drawer-back" id="consent-back"></div>
405
+ <div class="consent" id="consent" role="dialog" aria-modal="true" aria-labelledby="consent-title">
406
+ <h3 id="consent-title"></h3>
407
+ <p id="consent-body"></p>
408
+ <div class="row">
409
+ <button class="ghost" id="consent-cancel"></button>
410
+ <button id="consent-ok"></button>
411
+ </div>
412
+ </div>
413
  <div class="toast" id="toast"></div>
414
 
415
  <script>
 
476
  'What medications and any interactions?','What about HbA1c?'],
477
  story:(docs,pair)=>`One timeline from ${docs} documents in two languages. The card flagged a dangerous combination — <b>${pair}</b> — deterministically, by safety rules, not by AI guess. See the red alert in the Safety panel.`,
478
  groupL1:'L1 · deterministic rules', groupL2a:'L2a · trends & reference ranges',
479
+ trust:['No LLM in safety decisions','Deterministic safety rules (L1)','FHIR export','Local-first by design'],
480
+ trustPhotoCloud:'Photo OCR → cloud lab',
481
+ trustPhotoLocal:'Photo OCR → on-device',
482
  drawerTitle:id=>`Source · ${id}`,
483
  drawerCap:'The extracted fact is highlighted in the source document.',
484
  srcTitle:'Show source document',
485
+ consentTitle:'This photo will leave your device',
486
+ consentBody:'To read the photo, this public demo sends it to a cloud OCR lab (Hugging Face ZeroGPU). Text you type, the demo card and every safety decision stay on the server and never go to an LLM.\n\nUpload synthetic or fully de-identified documents only — never real personal medical data.',
487
+ consentOk:'Send the photo',
488
+ consentCancel:'Cancel',
489
  },
490
  ua: {
491
  htmlLang:'uk', title:'LiveMedCard — жива медична картка',
 
526
  'Які ліки приймає і чи є взаємодії?','Що з HbA1c?'],
527
  story:(docs,pair)=>`Один таймлайн із ${docs} документів двома мовами. Картка помітила небезпечну комбінацію — <b>${pair}</b> — детерміновано, правилами безпеки, а не «на думку» ШІ. Дивіться червоний сигнал у панелі безпеки.`,
528
  groupL1:'L1 · детерміновані правила', groupL2a:'L2a · тренди й референсні межі',
529
+ trust:['Жодної LLM у рішеннях безпеки','Детерміновані правила безпеки (L1)','Експорт FHIR','Local-first за дизайном'],
530
+ trustPhotoCloud:'OCR фото → хмарний лаб',
531
+ trustPhotoLocal:'OCR фото → на пристрої',
532
  drawerTitle:id=>`Джерело · ${id}`,
533
  drawerCap:'Витягнутий факт підсвічено в документі-джерелі.',
534
  srcTitle:'Показати документ-джерело',
535
+ consentTitle:'Це фото покине ваш пристрій',
536
+ consentBody:'Щоб прочитати фото, це публічне демо надсилає його в хмарний OCR-лаб (Hugging Face ZeroGPU). Введений текст, демо-картка й усі рішення безпеки лишаються на сервері й до жодної LLM не потрапляють.\n\nЗавантажуйте лише синтетичні або повністю деідентифіковані документи — ніколи не справжні персональні медичні дані.',
537
+ consentOk:'Надіслати фото',
538
+ consentCancel:'Скасувати',
539
  },
540
  };
541
 
 
574
  $('#qa-tag').textContent = t.qaTag;
575
  $('#q').placeholder = t.qPlaceholder;
576
  $('#ask').textContent = t.ask;
577
+ renderTrust();
578
  $('#site-footer').innerHTML = `${t.footer1}<br>${t.footer2}
579
  <div class="flinks"><a href="https://github.com/SZabolotnii/Digital_Future_Hackathon" target="_blank" rel="noopener">GitHub</a> ·
580
  <a href="https://traces.solutions" target="_blank" rel="noopener">traces.solutions</a> ·
 
660
  return ['→ 0%','flat'];
661
  }
662
 
663
+ /* Чипи довіри мають відповідати тому, що система РОБИТЬ: якщо фото їде в хмару,
664
+ мовчати про це не можна — інакше «local-first» на екрані стає неправдою. */
665
+ function renderTrust(){
666
+ const t=I18N[LANG];
667
+ const mode = lastState && lastState.photo_ocr;
668
+ const chips = t.trust.slice();
669
+ if(mode==='cloud') chips.push(t.trustPhotoCloud);
670
+ else if(mode==='local') chips.push(t.trustPhotoLocal);
671
+ $('#trust').innerHTML = chips.map(x=>`<span>${x}</span>`).join('');
672
+ }
673
+
674
  function render(st){
675
  lastState = st;
676
+ renderTrust();
677
  const t=I18N[LANG];
678
  const docs=st.document_count||0;
679
  $('#patient').innerHTML = docs ? t.patient(st.patient.id, docs) : t.patientNone;
 
822
  };
823
  $('#btn-add').onclick=()=>$('#add-form').classList.toggle('show');
824
 
825
+ /* Згода на відправку фото в хмару. Питаємо ПЕРЕД тим, як файл покине пристрій —
826
+ згода постфактум згодою не є. Локальний бекенд нічого не питає. */
827
+ function askCloudConsent(){
828
+ if(!lastState || lastState.photo_ocr !== 'cloud') return Promise.resolve(true);
829
+ const t=I18N[LANG];
830
+ $('#consent-title').textContent=t.consentTitle;
831
+ $('#consent-body').textContent=t.consentBody;
832
+ $('#consent-ok').textContent=t.consentOk;
833
+ $('#consent-cancel').textContent=t.consentCancel;
834
+ $('#consent').classList.add('show'); $('#consent-back').classList.add('show');
835
+ $('#consent-ok').focus();
836
+ return new Promise(res=>{
837
+ const done=v=>{
838
+ $('#consent').classList.remove('show'); $('#consent-back').classList.remove('show');
839
+ $('#consent-ok').onclick=null; $('#consent-cancel').onclick=null;
840
+ $('#consent-back').onclick=null; document.removeEventListener('keydown',onKey);
841
+ res(v);
842
+ };
843
+ const onKey=e=>{ if(e.key==='Escape') done(false); };
844
+ $('#consent-ok').onclick=()=>done(true);
845
+ $('#consent-cancel').onclick=()=>done(false);
846
+ $('#consent-back').onclick=()=>done(false);
847
+ document.addEventListener('keydown',onKey);
848
+ });
849
+ }
850
+
851
  const fileInput=$('#add-image'), dz=$('#dropzone');
852
  fileInput.onchange=e=>{const f=e.target.files[0];if(f){$('#dropzone-text').textContent=I18N[LANG].selected(f.name);$('#add-text').value='';}};
853
  dz.addEventListener('dragover',e=>{e.preventDefault();dz.classList.add('dragover');});
 
864
  try{
865
  let rep;
866
  if(file){
867
+ if(!await askCloudConsent()){ btn.disabled=false; btn.textContent=orig; return; }
868
  btn.textContent=t.ocrReading;
869
  // Хмарний OCR — це ~70–90 с. Без попередження мовчазне очікування читається
870
  // як зависання (і саме так виглядає для журі, що клікне демо самостійно).
livemedcard/units.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Нормалізація одиниць виміру — запобіжник перед детермінованими сигналами L2a.
2
+
3
+ Значення без узгодженої одиниці порівнювати з референсною межею НЕ можна: глюкоза
4
+ 100 mg/dL проти меж 3.9–6.1 mmol/L дасть хибне «вище норми». Тому L2a спершу питає
5
+ `same_unit`, і якщо одиниці не збігаються (або одиниці факту немає) — сигнал не
6
+ формується, а натомість піднімається `unit_mismatch` (мовчазний пропуск гірший за
7
+ явну незгоду).
8
+
9
+ Свідомо БЕЗ конверсій: перерахунок mg/dL → mmol/L потребує коефіцієнта на кожен
10
+ аналіт, і помилка в ньому — це хибний клінічний сигнал. Канонічний UCUM-шар із
11
+ таблицею конверсій — у роадмапі; тут лише чесне «одиниці не збігаються».
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+ # Написання однієї й тієї ж одиниці різняться за джерелом (UA-бланк, EN-бланк,
18
+ # OCR-модель): зводимо до спільного ключа.
19
+ _ALIASES: dict[str, str] = {
20
+ "umol/l": "umol/l", "мкмоль/л": "umol/l",
21
+ "mmol/l": "mmol/l", "ммоль/л": "mmol/l",
22
+ "mg/dl": "mg/dl", "мг/дл": "mg/dl",
23
+ "g/l": "g/l", "г/л": "g/l",
24
+ "mmhg": "mmhg", "мм рт.ст.": "mmhg", "мм рт. ст.": "mmhg",
25
+ "%": "%",
26
+ }
27
+
28
+
29
+ def canonical(unit: Optional[str]) -> Optional[str]:
30
+ """Одиниця у канонічному вигляді; None — коли одиниці немає."""
31
+ if not unit:
32
+ return None
33
+ u = unit.strip().lower()
34
+ # µ (micro sign) і μ (грецька мю) — різні кодпойнти, обидва означають «мікро».
35
+ u = u.replace("µ", "u").replace("μ", "u")
36
+ u = " ".join(u.split())
37
+ return _ALIASES.get(u, u)
38
+
39
+
40
+ def same_unit(a: Optional[str], b: Optional[str]) -> bool:
41
+ """Чи можна порівнювати два значення напряму.
42
+
43
+ Відсутня одиниця — НЕ привід вважати збіг: невідоме не дорівнює очікуваному.
44
+ """
45
+ ca, cb = canonical(a), canonical(b)
46
+ if ca is None or cb is None:
47
+ return False
48
+ return ca == cb