Spaces:
Sleeping
Sleeping
DocUA commited on
Commit ·
18603d9
1
Parent(s): 8b3eee1
Unit guard before L2a, LOINC aliases, cloud consent, honest confidence
Browse files- livemedcard/api.py +17 -0
- livemedcard/config.py +7 -0
- livemedcard/layers/l2a_trends.py +28 -0
- livemedcard/layers/l2b_extractor.py +7 -3
- livemedcard/layers/l2b_stage2_lora.py +12 -0
- livemedcard/static/index.html +74 -3
- livemedcard/units.py +48 -0
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=
|
| 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=
|
| 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=
|
| 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
|
|
|
|
|
|
|
| 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:['
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|