Spaces:
Sleeping
Sleeping
Deploy LiveMedCard demo (Docker, stub, seeded bilingual card)
Browse files- Dockerfile +19 -0
- README.md +18 -4
- livemedcard/__init__.py +5 -0
- livemedcard/api.py +318 -0
- livemedcard/bundle.py +61 -0
- livemedcard/config.py +55 -0
- livemedcard/data/dose_limits.json +9 -0
- livemedcard/data/interactions.json +13 -0
- livemedcard/data/reference_ranges.json +21 -0
- livemedcard/data/sample_docs.json +29 -0
- livemedcard/data/vocab.json +55 -0
- livemedcard/demo.py +67 -0
- livemedcard/dto.py +94 -0
- livemedcard/fhir.py +165 -0
- livemedcard/lang.py +23 -0
- livemedcard/layers/__init__.py +1 -0
- livemedcard/layers/l1_safety.py +91 -0
- livemedcard/layers/l2a_trends.py +93 -0
- livemedcard/layers/l2b_extractor.py +406 -0
- livemedcard/layers/l2b_nuextract.py +65 -0
- livemedcard/layers/l2b_stage1.py +211 -0
- livemedcard/layers/l2b_stage2_lora.py +203 -0
- livemedcard/layers/l3_router.py +58 -0
- livemedcard/llm_client.py +98 -0
- livemedcard/ocr.py +80 -0
- livemedcard/pipeline.py +174 -0
- livemedcard/qa.py +245 -0
- livemedcard/reference.py +78 -0
- livemedcard/refmodels.py +91 -0
- livemedcard/repository.py +37 -0
- livemedcard/static/index.html +526 -0
- livemedcard/stores.py +191 -0
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 17 |
+
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
CMD ["uvicorn", "livemedcard.api:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,24 @@
|
|
| 1 |
---
|
| 2 |
title: LiveMedCard
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: LiveMedCard
|
| 3 |
+
emoji: 🫀
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
short_description: Жива медична картка · таймлайн · сигнали безпеки · Q&A
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# LiveMedCard
|
| 13 |
+
|
| 14 |
+
**Жива медична картка** для літньої людини — прототип для Digital Future Hackathon
|
| 15 |
+
(ніша Aging & Longevity). Структурує медичні документи (українські й англомовні) у
|
| 16 |
+
**лонгітюдний таймлайн**, детерміновано помічає **небезпечні взаємодії ліків і тренди
|
| 17 |
+
показників**, відповідає на питання природною мовою з посиланням на джерело.
|
| 18 |
+
|
| 19 |
+
Демо стартує із заповненою білінгвальною карткою (UA-виписки + англомовний
|
| 20 |
+
lab-report). Архітектура — **TRACE** (вимірювана межа довіри): сигнали безпеки
|
| 21 |
+
рахуються детерміновано, а не генеративною моделлю. Система **не ставить діагнозів**.
|
| 22 |
+
|
| 23 |
+
> Цей публічний демо — детермінований `stub`-режим (без хмарних LLM). У продукті
|
| 24 |
+
> обробка локальна, на пристрої (privacy by design).
|
livemedcard/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LiveMedCard — жива медична картка на архітектурі TRACE.
|
| 2 |
+
|
| 3 |
+
Публічний API конвеєра — див. :mod:`livemedcard.pipeline`.
|
| 4 |
+
"""
|
| 5 |
+
__version__ = "0.1.0"
|
livemedcard/api.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI-фасад над конвеєром LiveMedCard (демо-сервер, стан у пам'яті).
|
| 2 |
+
|
| 3 |
+
Ендпойнти:
|
| 4 |
+
GET / — веб-інтерфейс користувача (SPA)
|
| 5 |
+
POST /ingest — прогнати один документ
|
| 6 |
+
POST /ingest/samples — прогнати демо-таймлайн
|
| 7 |
+
POST /reset — очистити картку (новий пацієнт)
|
| 8 |
+
GET /timeline/{loinc} — часовий ряд показника
|
| 9 |
+
GET /signals — поточні детерміновані сигнали
|
| 10 |
+
GET /state — агрегований стан картки (для UI)
|
| 11 |
+
POST /ask — grounded Q&A
|
| 12 |
+
GET /export — експорт у FHIR Bundle
|
| 13 |
+
POST /import — імпорт з FHIR Bundle
|
| 14 |
+
GET /audit — журнал рішень L3
|
| 15 |
+
GET /metrics — агреговані метрики
|
| 16 |
+
|
| 17 |
+
Стан одного пацієнта інкапсульовано в `_State` і серіалізовано `threading.Lock`:
|
| 18 |
+
sync-ендпойнти FastAPI виконуються в пулі потоків, тож без лока конкурентні
|
| 19 |
+
запити (кілька вкладок) гонялися б за спільною карткою. Жодного `global`.
|
| 20 |
+
|
| 21 |
+
Опційна персистентність: env `LMC_PERSIST_PATH` → автозбереження/автозавантаження
|
| 22 |
+
знімка картки у локальний JSON-файл (local-first). Пошкоджений файл знімка валить
|
| 23 |
+
старт сервера явно (див. `repository.load_card`) — це захист від мовчазного
|
| 24 |
+
перезапису даних порожнім станом.
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import threading
|
| 29 |
+
from datetime import date
|
| 30 |
+
|
| 31 |
+
import hashlib
|
| 32 |
+
|
| 33 |
+
from fastapi import Body, FastAPI, HTTPException
|
| 34 |
+
from fastapi.responses import FileResponse
|
| 35 |
+
from pydantic import BaseModel
|
| 36 |
+
|
| 37 |
+
from .bundle import from_bundle, to_bundle
|
| 38 |
+
from .config import (
|
| 39 |
+
EXTRACTOR,
|
| 40 |
+
MAX_DOC_CHARS,
|
| 41 |
+
PERSIST_PATH,
|
| 42 |
+
SEED_SAMPLES,
|
| 43 |
+
STATIC_DIR,
|
| 44 |
+
)
|
| 45 |
+
from .dto import Document, DocKind
|
| 46 |
+
from .fhir import Patient
|
| 47 |
+
from .layers import l3_router
|
| 48 |
+
from .layers import l2b_nuextract
|
| 49 |
+
from .layers.l2b_extractor import (
|
| 50 |
+
Extractor,
|
| 51 |
+
LoraStage2Extractor,
|
| 52 |
+
NuExtractExtractor,
|
| 53 |
+
StubExtractor,
|
| 54 |
+
TwoStageExtractor,
|
| 55 |
+
)
|
| 56 |
+
from .llm_client import LLMError
|
| 57 |
+
from .pipeline import LiveMedCard
|
| 58 |
+
from .qa import Answer, QA
|
| 59 |
+
from .reference import load_sample_docs, range_for
|
| 60 |
+
from .repository import load_card, save_card
|
| 61 |
+
from .ocr import OcrService, OcrError
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
app = FastAPI(title="LiveMedCard", version="0.1.0")
|
| 65 |
+
|
| 66 |
+
_STATIC = STATIC_DIR
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _new_patient() -> Patient:
|
| 70 |
+
return Patient(id="mom-001", gender="female", birthDate=date(1954, 6, 1))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _make_extractor() -> Extractor:
|
| 74 |
+
"""Екстрактор L2b: 'nuextract' | 'two_stage_lora' | 'two_stage' | 'stub'."""
|
| 75 |
+
if EXTRACTOR == "nuextract":
|
| 76 |
+
return NuExtractExtractor()
|
| 77 |
+
if EXTRACTOR == "two_stage_lora":
|
| 78 |
+
return LoraStage2Extractor()
|
| 79 |
+
if EXTRACTOR == "two_stage":
|
| 80 |
+
return TwoStageExtractor()
|
| 81 |
+
return StubExtractor()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class _State:
|
| 85 |
+
"""Тримач картки одного пацієнта: доступ лише під `lock`."""
|
| 86 |
+
|
| 87 |
+
def __init__(self, card: LiveMedCard) -> None:
|
| 88 |
+
self.card = card
|
| 89 |
+
self.lock = threading.Lock()
|
| 90 |
+
|
| 91 |
+
def autosave(self) -> None:
|
| 92 |
+
"""Зберегти знімок, якщо ввімкнено персистентність. Кличеться під lock."""
|
| 93 |
+
if PERSIST_PATH:
|
| 94 |
+
save_card(self.card, PERSIST_PATH)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _initial_card() -> LiveMedCard:
|
| 98 |
+
if PERSIST_PATH:
|
| 99 |
+
loaded = load_card(PERSIST_PATH, _make_extractor())
|
| 100 |
+
if loaded is not None:
|
| 101 |
+
return loaded
|
| 102 |
+
card = LiveMedCard(_new_patient(), _make_extractor())
|
| 103 |
+
if SEED_SAMPLES: # публічне демо (HF Space) стартує з заповненою карткою
|
| 104 |
+
card.ingest_all([Document(**d) for d in load_sample_docs()])
|
| 105 |
+
return card
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
_state = _State(_initial_card())
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class AskRequest(BaseModel):
|
| 112 |
+
question: str
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@app.get("/")
|
| 116 |
+
def index() -> FileResponse:
|
| 117 |
+
return FileResponse(_STATIC / "index.html")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@app.post("/ingest")
|
| 121 |
+
def ingest(doc: Document):
|
| 122 |
+
if len(doc.text) > MAX_DOC_CHARS:
|
| 123 |
+
raise HTTPException(
|
| 124 |
+
status_code=413,
|
| 125 |
+
detail=f"Документ завеликий (> {MAX_DOC_CHARS} символів)",
|
| 126 |
+
)
|
| 127 |
+
with _state.lock:
|
| 128 |
+
report = _state.card.ingest(doc)
|
| 129 |
+
_state.autosave()
|
| 130 |
+
return report
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class ImageIngestRequest(BaseModel):
|
| 134 |
+
image_base64: str
|
| 135 |
+
kind: DocKind = "note"
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@app.post("/ingest/image")
|
| 139 |
+
def ingest_image(req: ImageIngestRequest):
|
| 140 |
+
if not req.image_base64.strip():
|
| 141 |
+
raise HTTPException(
|
| 142 |
+
status_code=422,
|
| 143 |
+
detail="Зображення порожнє або некоректне",
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
# У режимі nuextract зображення читає сам NuExtract (image→markdown), тож
|
| 147 |
+
# окремий OCR не потрібен — уникаємо накопичення помилок OCR→summary→extract.
|
| 148 |
+
try:
|
| 149 |
+
if EXTRACTOR == "nuextract":
|
| 150 |
+
text = l2b_nuextract.image_to_markdown(req.image_base64)
|
| 151 |
+
else:
|
| 152 |
+
text = OcrService.perform_ocr(req.image_base64)
|
| 153 |
+
except (OcrError, LLMError) as exc:
|
| 154 |
+
raise HTTPException(
|
| 155 |
+
status_code=503,
|
| 156 |
+
detail=str(exc),
|
| 157 |
+
)
|
| 158 |
+
except Exception as exc:
|
| 159 |
+
raise HTTPException(
|
| 160 |
+
status_code=500,
|
| 161 |
+
detail=f"Помилка під час OCR: {exc}",
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
if not text:
|
| 165 |
+
raise HTTPException(
|
| 166 |
+
status_code=422,
|
| 167 |
+
detail="Не вдалося розпізнати текст із зображення",
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
if len(text) > MAX_DOC_CHARS:
|
| 171 |
+
raise HTTPException(
|
| 172 |
+
status_code=413,
|
| 173 |
+
detail=f"Розпізнаний текст документа завеликий (> {MAX_DOC_CHARS} символів)",
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
doc_id = f"doc-ocr-{hashlib.sha1(text.encode('utf-8')).hexdigest()[:12]}"
|
| 177 |
+
doc = Document(id=doc_id, text=text, kind=req.kind)
|
| 178 |
+
|
| 179 |
+
with _state.lock:
|
| 180 |
+
report = _state.card.ingest(doc)
|
| 181 |
+
_state.autosave()
|
| 182 |
+
return report
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@app.post("/ingest/samples")
|
| 187 |
+
def ingest_samples():
|
| 188 |
+
docs = [Document(**d) for d in load_sample_docs()]
|
| 189 |
+
with _state.lock:
|
| 190 |
+
reports = _state.card.ingest_all(docs)
|
| 191 |
+
_state.autosave()
|
| 192 |
+
return reports
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@app.post("/reset")
|
| 196 |
+
def reset():
|
| 197 |
+
with _state.lock:
|
| 198 |
+
_state.card = LiveMedCard(_new_patient(), _make_extractor())
|
| 199 |
+
_state.autosave()
|
| 200 |
+
return {"status": "reset", "patient": _state.card.patient.id}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@app.get("/export")
|
| 204 |
+
def export_bundle():
|
| 205 |
+
"""Експорт картки як FHIR Bundle (для передачі лікарю / еЗдоров'ю)."""
|
| 206 |
+
with _state.lock:
|
| 207 |
+
return to_bundle(_state.card)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
@app.post("/import")
|
| 211 |
+
def import_bundle(bundle: dict = Body(...)):
|
| 212 |
+
"""Імпорт картки з FHIR Bundle (замінює поточний стан)."""
|
| 213 |
+
try:
|
| 214 |
+
card = from_bundle(bundle, _make_extractor())
|
| 215 |
+
except (ValueError, KeyError, TypeError) as exc:
|
| 216 |
+
raise HTTPException(status_code=422, detail=f"Некоректний Bundle: {exc}")
|
| 217 |
+
with _state.lock:
|
| 218 |
+
_state.card = card
|
| 219 |
+
_state.autosave()
|
| 220 |
+
return {"status": "imported", "documents": _state.card.document_count}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@app.get("/audit")
|
| 224 |
+
def audit():
|
| 225 |
+
"""Журнал рішень L3 (спостережуваність)."""
|
| 226 |
+
with _state.lock:
|
| 227 |
+
return _state.card.audit_log()
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@app.get("/metrics")
|
| 231 |
+
def metrics():
|
| 232 |
+
"""Агреговані метрики роботи конвеєра."""
|
| 233 |
+
with _state.lock:
|
| 234 |
+
card = _state.card
|
| 235 |
+
log = card.audit_log()
|
| 236 |
+
n = len(log)
|
| 237 |
+
escalated = sum(1 for a in log if a["escalate"])
|
| 238 |
+
return {
|
| 239 |
+
"documents": card.document_count,
|
| 240 |
+
"observations": len(card.observations),
|
| 241 |
+
"medications": len(card.medications),
|
| 242 |
+
"decisions": n,
|
| 243 |
+
"escalated": escalated,
|
| 244 |
+
"auto": n - escalated,
|
| 245 |
+
"escalation_rate": round(escalated / n, 3) if n else 0.0,
|
| 246 |
+
"active_signals": len(card.current_signals()),
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
@app.get("/timeline/{loinc}")
|
| 251 |
+
def timeline(loinc: str):
|
| 252 |
+
with _state.lock:
|
| 253 |
+
return _state.card.factstore.series(loinc)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
@app.get("/signals")
|
| 257 |
+
def signals():
|
| 258 |
+
with _state.lock:
|
| 259 |
+
return _state.card.current_signals()
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
@app.get("/state")
|
| 263 |
+
def state():
|
| 264 |
+
"""Агрегований знімок картки для інтерфейсу."""
|
| 265 |
+
with _state.lock:
|
| 266 |
+
card = _state.card
|
| 267 |
+
sigs = card.current_signals()
|
| 268 |
+
# Рішення L3 над поточним станом (сигнали детерміновані → впевненість 1.0).
|
| 269 |
+
decision = l3_router.route(sigs, 1.0, "стан картки")
|
| 270 |
+
sex = card.patient.gender
|
| 271 |
+
|
| 272 |
+
series = []
|
| 273 |
+
for loinc in card.factstore.loincs():
|
| 274 |
+
pts = card.factstore.series(loinc)
|
| 275 |
+
ref = range_for(loinc, sex)
|
| 276 |
+
series.append({
|
| 277 |
+
"loinc": loinc,
|
| 278 |
+
"keyword": pts[0].keyword,
|
| 279 |
+
"unit": pts[-1].unit,
|
| 280 |
+
"low": ref.low if ref else None,
|
| 281 |
+
"high": ref.high if ref else None,
|
| 282 |
+
"points": [
|
| 283 |
+
{"ts": p.ts.date().isoformat(), "value": p.value, "source": p.source_doc_id}
|
| 284 |
+
for p in pts
|
| 285 |
+
],
|
| 286 |
+
})
|
| 287 |
+
|
| 288 |
+
seen: dict[str, None] = {}
|
| 289 |
+
for n in card.graph.nodes_of_type("MedicationRequest"):
|
| 290 |
+
seen.setdefault(card.graph.g.nodes[n].get("label", n), None)
|
| 291 |
+
medications = list(seen)
|
| 292 |
+
|
| 293 |
+
return {
|
| 294 |
+
"patient": {"id": card.patient.id, "gender": card.patient.gender},
|
| 295 |
+
"document_count": card.document_count,
|
| 296 |
+
"series": series,
|
| 297 |
+
"medications": medications,
|
| 298 |
+
"signals": [s.model_dump() for s in sigs],
|
| 299 |
+
"decision": decision.model_dump(),
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
@app.post("/ask", response_model=Answer)
|
| 304 |
+
def ask(req: AskRequest) -> Answer:
|
| 305 |
+
with _state.lock:
|
| 306 |
+
card = _state.card
|
| 307 |
+
qa = QA(card.factstore, card.graph, card.current_signals(), sex=card.patient.gender)
|
| 308 |
+
return qa.ask(req.question)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
@app.get("/health")
|
| 312 |
+
def health():
|
| 313 |
+
with _state.lock:
|
| 314 |
+
return {
|
| 315 |
+
"status": "ok",
|
| 316 |
+
"patient": _state.card.patient.id,
|
| 317 |
+
"documents": _state.card.document_count,
|
| 318 |
+
}
|
livemedcard/bundle.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Експорт/імпорт картки як стандартного FHIR Bundle (type=collection).
|
| 2 |
+
|
| 3 |
+
Наслідок продуктовий: картку можна передати лікарняній системі чи еЗдоров'ю у
|
| 4 |
+
стандартному форматі, а не як скрін. Метадані (документи, аудит) — у полі `_meta`,
|
| 5 |
+
щоб не порушувати FHIR-структуру ресурсів.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
from .layers.l2b_extractor import Extractor
|
| 12 |
+
from .pipeline import LiveMedCard
|
| 13 |
+
|
| 14 |
+
_RT_TO_KEY = {
|
| 15 |
+
"Observation": "observations",
|
| 16 |
+
"Condition": "conditions",
|
| 17 |
+
"MedicationRequest": "medications",
|
| 18 |
+
"DocumentReference": "document_references",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def to_bundle(card: LiveMedCard) -> dict:
|
| 23 |
+
"""Серіалізувати картку у FHIR Bundle."""
|
| 24 |
+
snap = card.snapshot()
|
| 25 |
+
entries = [{"resource": snap["patient"]}]
|
| 26 |
+
for key in ("observations", "conditions", "medications", "document_references"):
|
| 27 |
+
entries.extend({"resource": r} for r in snap[key])
|
| 28 |
+
return {
|
| 29 |
+
"resourceType": "Bundle",
|
| 30 |
+
"type": "collection",
|
| 31 |
+
"entry": entries,
|
| 32 |
+
"_meta": {"documents": snap["documents"], "audit": snap["audit"]},
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def from_bundle(data: dict, extractor: Optional[Extractor] = None) -> LiveMedCard:
|
| 37 |
+
"""Відновити картку з FHIR Bundle."""
|
| 38 |
+
patient: Optional[dict] = None
|
| 39 |
+
buckets: dict[str, list] = {
|
| 40 |
+
"observations": [],
|
| 41 |
+
"conditions": [],
|
| 42 |
+
"medications": [],
|
| 43 |
+
"document_references": [],
|
| 44 |
+
}
|
| 45 |
+
for entry in data.get("entry", []):
|
| 46 |
+
resource = entry.get("resource", {})
|
| 47 |
+
rt = resource.get("resourceType")
|
| 48 |
+
if rt == "Patient":
|
| 49 |
+
patient = resource
|
| 50 |
+
elif rt in _RT_TO_KEY:
|
| 51 |
+
buckets[_RT_TO_KEY[rt]].append(resource)
|
| 52 |
+
if patient is None:
|
| 53 |
+
raise ValueError("Bundle не містить ресурсу Patient")
|
| 54 |
+
meta = data.get("_meta", {})
|
| 55 |
+
snapshot = {
|
| 56 |
+
"patient": patient,
|
| 57 |
+
**buckets,
|
| 58 |
+
"documents": meta.get("documents", []),
|
| 59 |
+
"audit": meta.get("audit", []),
|
| 60 |
+
}
|
| 61 |
+
return LiveMedCard.from_snapshot(snapshot, extractor)
|
livemedcard/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Централізовані налаштування застосунку (з можливістю override через env).
|
| 2 |
+
|
| 3 |
+
Виносить «магічні» пороги й шляхи з коду в одне місце — легше конфігурувати й
|
| 4 |
+
тестувати. Значення читаються один раз при імпорті.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
DATA_DIR = Path(__file__).parent / "data"
|
| 12 |
+
STATIC_DIR = Path(__file__).parent / "static"
|
| 13 |
+
|
| 14 |
+
# Поріг впевненості витягу L2b, нижче якого документ іде на перегляд людиною (L3).
|
| 15 |
+
LOW_CONFIDENCE = float(os.getenv("LMC_LOW_CONFIDENCE", "0.6"))
|
| 16 |
+
|
| 17 |
+
# Впевненість, яку StubExtractor приписує кожному словниковому збігу.
|
| 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 |
+
|
| 24 |
+
# Шлях до файлу персистентності (JSON). Порожньо → стан лише в пам'яті.
|
| 25 |
+
PERSIST_PATH = os.getenv("LMC_PERSIST_PATH", "")
|
| 26 |
+
|
| 27 |
+
# Засіяти демо-картку на старті (для публічного демо на HF Space — щоб картка була
|
| 28 |
+
# заповнена одразу). Порожньо → старт з порожньої картки (дефолт, тести/CI).
|
| 29 |
+
SEED_SAMPLES = os.getenv("LMC_SEED_SAMPLES", "")
|
| 30 |
+
|
| 31 |
+
# Налаштування локальної LLM/Vision моделі для OCR
|
| 32 |
+
LMSTUDIO_URL = os.getenv("LMC_LMSTUDIO_URL", "http://127.0.0.1:1234/v1")
|
| 33 |
+
OCR_MODEL = os.getenv("LMC_OCR_MODEL", "medgemma-1.5-4b-it")
|
| 34 |
+
|
| 35 |
+
# Екстрактор L2b:
|
| 36 |
+
# "stub" — детермінований offline (дефолт, тести/CI/демо);
|
| 37 |
+
# "two_stage" — Stage 1 (LLM SGR) → Stage 2 (детермінована vocab KV-T);
|
| 38 |
+
# "two_stage_lora" — Stage 1 (база) → Stage 2 (база+LoRA генерує KVT4);
|
| 39 |
+
# "nuextract" — NuExtract3 VLM: image→markdown + markdown→JSON за шаблоном.
|
| 40 |
+
EXTRACTOR = os.getenv("LMC_EXTRACTOR", "stub")
|
| 41 |
+
|
| 42 |
+
# Модель Stage 1 (SGR-стиснення). None → автовизначення завантаженої моделі.
|
| 43 |
+
STAGE1_MODEL = os.getenv("LMC_STAGE1_MODEL") or None
|
| 44 |
+
|
| 45 |
+
# Двосерверний LoRA-сетап (llama.cpp): Stage 1 — база, Stage 2 — база+LoRA-адаптер.
|
| 46 |
+
# Аліаси/порти за README FT-моделі DocUA/...-two-stage.
|
| 47 |
+
STAGE1_URL = os.getenv("LMC_STAGE1_URL", LMSTUDIO_URL)
|
| 48 |
+
STAGE2_URL = os.getenv("LMC_STAGE2_URL", "http://127.0.0.1:1246/v1")
|
| 49 |
+
STAGE2_MODEL = os.getenv("LMC_STAGE2_MODEL", "medgemma-ft-lora-adapters-q5_k_m")
|
| 50 |
+
|
| 51 |
+
# NuExtract (VLM structured extraction) — OpenAI-сумісний ендпойнт (llama-server
|
| 52 |
+
# --jinja з mmproj, або vLLM). Один рушій робить image→markdown і markdown→JSON.
|
| 53 |
+
NUEXTRACT_URL = os.getenv("LMC_NUEXTRACT_URL", "http://127.0.0.1:1299/v1")
|
| 54 |
+
NUEXTRACT_MODEL = os.getenv("LMC_NUEXTRACT_MODEL", "NuExtract3")
|
| 55 |
+
|
livemedcard/data/dose_limits.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_note": "Максимальна добова доза (спрощення MVP). Коди RxNorm, одиниця mg/добу.",
|
| 3 |
+
"limits": [
|
| 4 |
+
{"rxnorm": "6809", "name": "Метформін", "max_daily": 2550, "unit": "mg"},
|
| 5 |
+
{"rxnorm": "1191", "name": "Аспірин", "max_daily": 4000, "unit": "mg"},
|
| 6 |
+
{"rxnorm": "29046", "name": "Лізиноприл", "max_daily": 80, "unit": "mg"},
|
| 7 |
+
{"rxnorm": "11289", "name": "Варфарин", "max_daily": 10, "unit": "mg"}
|
| 8 |
+
]
|
| 9 |
+
}
|
livemedcard/data/interactions.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_note": "Демо-підмножина взаємодій. У проді — відкрита БД RxNorm/NLM. Коди RxNorm. Поле detail — лише механізм; назви препаратів додає L1 з фактичних ресурсів.",
|
| 3 |
+
"pairs": [
|
| 4 |
+
{"a": "11289", "b": "1191", "severity": "major",
|
| 5 |
+
"detail": "сумація антитромботичного ефекту, підвищений ризик кровотечі"},
|
| 6 |
+
{"a": "11289", "b": "703", "severity": "major",
|
| 7 |
+
"detail": "аміодарон інгібує метаболізм варфарину, ризик кровотечі"},
|
| 8 |
+
{"a": "11289", "b": "5640", "severity": "major",
|
| 9 |
+
"detail": "підвищений ризик шлунково-кишкової кровотечі"},
|
| 10 |
+
{"a": "5640", "b": "29046", "severity": "moderate",
|
| 11 |
+
"detail": "зниження антигіпертензивного ефекту та ризик для нирок"}
|
| 12 |
+
]
|
| 13 |
+
}
|
livemedcard/data/reference_ranges.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_note": "Референсні межі показників. Коди LOINC. default — загальні межі; by_sex — уточнення за статтю.",
|
| 3 |
+
"ranges": {
|
| 4 |
+
"2160-0": {
|
| 5 |
+
"keyword": "Креатинін", "unit": "µmol/L",
|
| 6 |
+
"default": {"low": 44, "high": 106},
|
| 7 |
+
"by_sex": {"female": {"low": 44, "high": 90}, "male": {"low": 62, "high": 106}}
|
| 8 |
+
},
|
| 9 |
+
"4548-4": {"keyword": "HbA1c", "unit": "%", "default": {"low": 4.0, "high": 6.0}},
|
| 10 |
+
"2823-3": {"keyword": "Калій", "unit": "mmol/L", "default": {"low": 3.5, "high": 5.1}},
|
| 11 |
+
"8480-6": {"keyword": "Систолічний тиск", "unit": "mmHg", "default": {"low": 90, "high": 140}},
|
| 12 |
+
"2345-7": {"keyword": "Глюкоза", "unit": "mmol/L", "default": {"low": 3.9, "high": 6.1}},
|
| 13 |
+
"718-7": {
|
| 14 |
+
"keyword": "Гемоглобін", "unit": "g/L",
|
| 15 |
+
"default": {"low": 120, "high": 160},
|
| 16 |
+
"by_sex": {"female": {"low": 120, "high": 150}, "male": {"low": 130, "high": 170}}
|
| 17 |
+
},
|
| 18 |
+
"1742-6": {"keyword": "АЛТ", "unit": "U/L", "default": {"low": 0, "high": 41}},
|
| 19 |
+
"2093-3": {"keyword": "Холестерин загальний", "unit": "mmol/L", "default": {"low": 0, "high": 5.2}}
|
| 20 |
+
}
|
| 21 |
+
}
|
livemedcard/data/sample_docs.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_note": "Білінгвальний демо-таймлайн однієї пацієнтки (mom-001): українські виписки + англомовний lab-report приватної лабораторії на ОДНОМУ таймлайні. Формат тексту імітує витяг фото документа.",
|
| 3 |
+
"documents": [
|
| 4 |
+
{
|
| 5 |
+
"id": "doc-2023-05",
|
| 6 |
+
"kind": "discharge_summary",
|
| 7 |
+
"received_at": "2023-05-01T10:00:00",
|
| 8 |
+
"text": "Виписний епікриз. Пацієнтка 69 років. Діагноз: фібриляція передсердь. Лабораторно: креатинін 88 µmol/L, калій 4.6 mmol/L. Призначено: варфарин 5 мг на добу під контролем МНВ."
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"id": "doc-2023-11",
|
| 12 |
+
"kind": "lab_report",
|
| 13 |
+
"received_at": "2023-11-03T09:30:00",
|
| 14 |
+
"text": "Біохімія крові. Креатинін 104 µmol/L. Калій 4.9 mmol/L. Глікований гемоглобін 6.4 %."
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"id": "doc-2024-04",
|
| 18 |
+
"kind": "discharge_summary",
|
| 19 |
+
"received_at": "2024-04-09T14:15:00",
|
| 20 |
+
"text": "Виписний епікриз. Скарги на набряки. Артеріальна гіпертензія. Креатинін 121 µmol/L, глікований гемоглобін 7.2 %. Призначено: аспірин 100 мг на добу, метформін 3000 мг на добу."
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"id": "doc-2024-10-en",
|
| 24 |
+
"kind": "lab_report",
|
| 25 |
+
"received_at": "2024-10-15T11:00:00",
|
| 26 |
+
"text": "Private Laboratory. Biochemistry panel. Creatinine 135 µmol/L. Potassium 5.1 mmol/L. Glucose 6.8 mmol/L. Glycated hemoglobin 7.6 %."
|
| 27 |
+
}
|
| 28 |
+
]
|
| 29 |
+
}
|
livemedcard/data/vocab.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_note": "Словник StubExtractor (білінгвальний UA/EN): термін → стандартний код. Ключі — у нижньому регістрі.",
|
| 3 |
+
"labs": {
|
| 4 |
+
"креатинін": {"loinc": "2160-0", "unit": "µmol/L", "display": "Creatinine"},
|
| 5 |
+
"creatinine": {"loinc": "2160-0", "unit": "µmol/L", "display": "Creatinine"},
|
| 6 |
+
"калій": {"loinc": "2823-3", "unit": "mmol/L", "display": "Potassium"},
|
| 7 |
+
"potassium": {"loinc": "2823-3", "unit": "mmol/L", "display": "Potassium"},
|
| 8 |
+
"натрій": {"loinc": "2951-2", "unit": "mmol/L", "display": "Sodium"},
|
| 9 |
+
"sodium": {"loinc": "2951-2", "unit": "mmol/L", "display": "Sodium"},
|
| 10 |
+
"глюкоза": {"loinc": "2345-7", "unit": "mmol/L", "display": "Glucose"},
|
| 11 |
+
"глюкоза крові": {"loinc": "2345-7", "unit": "mmol/L", "display": "Glucose"},
|
| 12 |
+
"glucose": {"loinc": "2345-7", "unit": "mmol/L", "display": "Glucose"},
|
| 13 |
+
"blood glucose": {"loinc": "2345-7", "unit": "mmol/L", "display": "Glucose"},
|
| 14 |
+
"гемоглобін": {"loinc": "718-7", "unit": "g/L", "display": "Hemoglobin"},
|
| 15 |
+
"hemoglobin": {"loinc": "718-7", "unit": "g/L", "display": "Hemoglobin"},
|
| 16 |
+
"глікований гемоглобін": {"loinc": "4548-4", "unit": "%", "display": "HbA1c"},
|
| 17 |
+
"glycated hemoglobin": {"loinc": "4548-4", "unit": "%", "display": "HbA1c"},
|
| 18 |
+
"hba1c": {"loinc": "4548-4", "unit": "%", "display": "HbA1c"},
|
| 19 |
+
"систолічний тиск": {"loinc": "8480-6", "unit": "mmHg", "display": "Systolic BP"},
|
| 20 |
+
"systolic bp": {"loinc": "8480-6", "unit": "mmHg", "display": "Systolic BP"},
|
| 21 |
+
"systolic blood pressure": {"loinc": "8480-6", "unit": "mmHg", "display": "Systolic BP"}
|
| 22 |
+
},
|
| 23 |
+
"medications": {
|
| 24 |
+
"варфарин": {"rxnorm": "11289", "display": "Warfarin"},
|
| 25 |
+
"warfarin": {"rxnorm": "11289", "display": "Warfarin"},
|
| 26 |
+
"аспірин": {"rxnorm": "1191", "display": "Aspirin"},
|
| 27 |
+
"aspirin": {"rxnorm": "1191", "display": "Aspirin"},
|
| 28 |
+
"ацетилсаліцилова кислота": {"rxnorm": "1191", "display": "Aspirin"},
|
| 29 |
+
"ібупрофен": {"rxnorm": "5640", "display": "Ibuprofen"},
|
| 30 |
+
"ibuprofen": {"rxnorm": "5640", "display": "Ibuprofen"},
|
| 31 |
+
"метформін": {"rxnorm": "6809", "display": "Metformin"},
|
| 32 |
+
"metformin": {"rxnorm": "6809", "display": "Metformin"},
|
| 33 |
+
"лізиноприл": {"rxnorm": "29046", "display": "Lisinopril"},
|
| 34 |
+
"lisinopril": {"rxnorm": "29046", "display": "Lisinopril"},
|
| 35 |
+
"аміодарон": {"rxnorm": "703", "display": "Amiodarone"},
|
| 36 |
+
"amiodarone": {"rxnorm": "703", "display": "Amiodarone"},
|
| 37 |
+
"кордарон": {"rxnorm": "703", "display": "Amiodarone"}
|
| 38 |
+
},
|
| 39 |
+
"conditions": {
|
| 40 |
+
"фібриляція передсердь": {"snomed": "49436004", "display": "Atrial fibrillation"},
|
| 41 |
+
"atrial fibrillation": {"snomed": "49436004", "display": "Atrial fibrillation"},
|
| 42 |
+
"цукровий діабет": {"snomed": "44054006", "display": "Diabetes mellitus type 2"},
|
| 43 |
+
"diabetes": {"snomed": "44054006", "display": "Diabetes mellitus type 2"},
|
| 44 |
+
"diabetes mellitus": {"snomed": "44054006", "display": "Diabetes mellitus type 2"},
|
| 45 |
+
"артеріальна гіпертензія": {"snomed": "38341003", "display": "Hypertension"},
|
| 46 |
+
"hypertension": {"snomed": "38341003", "display": "Hypertension"},
|
| 47 |
+
"гіпертонічна хвороба": {"snomed": "38341003", "display": "Hypertension"},
|
| 48 |
+
"belching": {"snomed": "77526002", "display": "Belching"},
|
| 49 |
+
"indigestion": {"snomed": "50289006", "display": "Indigestion"},
|
| 50 |
+
"gastroesophageal reflux disease without esophagitis": {"snomed": "366258000", "display": "Gastroesophageal reflux disease without esophagitis"},
|
| 51 |
+
"history of hiatal hernia": {"snomed": "395041006", "display": "History of hiatal hernia"},
|
| 52 |
+
"hiatal hernia": {"snomed": "58293003", "display": "Hiatal hernia"},
|
| 53 |
+
"ruq pain": {"snomed": "249511003", "display": "Right upper quadrant pain"}
|
| 54 |
+
}
|
| 55 |
+
}
|
livemedcard/demo.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""E2E демонстратор LiveMedCard у терміналі.
|
| 2 |
+
|
| 3 |
+
Прогін: демо-документи → витяг → таймлайн → детерміновані сигнали → рішення L3 →
|
| 4 |
+
grounded Q&A з посиланням на джерела.
|
| 5 |
+
|
| 6 |
+
Запуск: ``uv run python -m livemedcard.demo``
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from datetime import date
|
| 11 |
+
|
| 12 |
+
from .dto import Document, TraceReport
|
| 13 |
+
from .fhir import Patient
|
| 14 |
+
from .pipeline import LiveMedCard
|
| 15 |
+
from .qa import QA
|
| 16 |
+
from .reference import load_sample_docs
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _print_report(r: TraceReport) -> None:
|
| 20 |
+
e = r.extraction
|
| 21 |
+
print(f"\n=== Документ {r.document_id} ===")
|
| 22 |
+
print(
|
| 23 |
+
f" Витяг (L2b): observations={len(e.observations)} "
|
| 24 |
+
f"conditions={len(e.conditions)} medications={len(e.medications)} "
|
| 25 |
+
f"впевненість={e.mean_confidence:.2f}"
|
| 26 |
+
)
|
| 27 |
+
if r.signals:
|
| 28 |
+
print(" Сигнали (L1/L2a, детерміновано):")
|
| 29 |
+
for s in r.signals:
|
| 30 |
+
print(f" [{s.layer}/{s.kind}/{s.severity}] {s.detail}")
|
| 31 |
+
else:
|
| 32 |
+
print(" Сигналів немає.")
|
| 33 |
+
d = r.decision
|
| 34 |
+
mark = "⛳ ЕСКАЛАЦІЯ ДО ЛЮДИНИ (L4)" if d.escalate else "auto"
|
| 35 |
+
print(f" Рішення (L3): {d.route_to} — {mark}")
|
| 36 |
+
for reason in d.reasons:
|
| 37 |
+
print(f" · {reason}")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def main() -> None:
|
| 41 |
+
patient = Patient(id="mom-001", gender="female", birthDate=date(1954, 6, 1))
|
| 42 |
+
card = LiveMedCard(patient)
|
| 43 |
+
|
| 44 |
+
docs = [Document(**d) for d in load_sample_docs()]
|
| 45 |
+
print("LiveMedCard — E2E демо (TRACE)")
|
| 46 |
+
print(f"Пацієнт: {patient.id}, документів: {len(docs)}")
|
| 47 |
+
|
| 48 |
+
reports = card.ingest_all(docs)
|
| 49 |
+
for r in reports:
|
| 50 |
+
_print_report(r)
|
| 51 |
+
|
| 52 |
+
print("\n=== Grounded Q&A (з посиланням на документи-джерела) ===")
|
| 53 |
+
qa = QA(card.factstore, card.graph, card.current_signals(), sex=patient.gender)
|
| 54 |
+
for question in (
|
| 55 |
+
"Що в мами з нирками за останній рік?",
|
| 56 |
+
"Який був калій?",
|
| 57 |
+
"Які ліки приймає і чи є взаємодії?",
|
| 58 |
+
):
|
| 59 |
+
ans = qa.ask(question)
|
| 60 |
+
print(f"\nQ: {question}")
|
| 61 |
+
print(f"A: {ans.text}")
|
| 62 |
+
if ans.sources:
|
| 63 |
+
print(f" Джерела: {', '.join(ans.sources)}")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
if __name__ == "__main__":
|
| 67 |
+
main()
|
livemedcard/dto.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DTO конвеєра TRACE — спільні типи між шарами.
|
| 2 |
+
|
| 3 |
+
`Signal` — єдиний тип сигналу для L1 і L2a; це роз'єднує L3-роутер від реалізацій
|
| 4 |
+
шарів безпеки. Детерміновані сигнали мають ``confidence == 1.0``.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import Literal, Optional
|
| 10 |
+
|
| 11 |
+
from pydantic import BaseModel, Field
|
| 12 |
+
|
| 13 |
+
from .fhir import Condition, DocumentReference, MedicationRequest, Observation
|
| 14 |
+
|
| 15 |
+
Severity = Literal["info", "moderate", "major", "critical"]
|
| 16 |
+
DocKind = Literal["discharge_summary", "lab_report", "prescription", "note"]
|
| 17 |
+
|
| 18 |
+
_SEVERITY_ORDER: dict[str, int] = {"info": 0, "moderate": 1, "major": 2, "critical": 3}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def severity_rank(s: Severity) -> int:
|
| 22 |
+
"""Порядковий ранг тяжкості (більше = гірше)."""
|
| 23 |
+
return _SEVERITY_ORDER[s]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class Document(BaseModel):
|
| 27 |
+
"""Вхідний документ (у MVP — текст; у проді — фото → OCR/vision)."""
|
| 28 |
+
|
| 29 |
+
id: str
|
| 30 |
+
text: str
|
| 31 |
+
kind: DocKind = "note"
|
| 32 |
+
received_at: Optional[datetime] = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ExtractionResult(BaseModel):
|
| 36 |
+
"""Результат L2b: FHIR-ресурси + середня впевненість витягу.
|
| 37 |
+
|
| 38 |
+
``document_reference`` (free-text іпостась) заповнює лише двоетапний
|
| 39 |
+
екстрактор; детермінований stub лишає None.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
observations: list[Observation] = Field(default_factory=list)
|
| 43 |
+
conditions: list[Condition] = Field(default_factory=list)
|
| 44 |
+
medications: list[MedicationRequest] = Field(default_factory=list)
|
| 45 |
+
mean_confidence: float = 1.0
|
| 46 |
+
document_reference: Optional[DocumentReference] = None
|
| 47 |
+
|
| 48 |
+
def is_empty(self) -> bool:
|
| 49 |
+
return not (self.observations or self.conditions or self.medications)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class Signal(BaseModel):
|
| 53 |
+
"""Детермінований сигнал безпеки/тренду (L1 або L2a)."""
|
| 54 |
+
|
| 55 |
+
layer: Literal["L1", "L2a"]
|
| 56 |
+
kind: str # напр. "interaction", "dose_limit", "trend", "range_breach"
|
| 57 |
+
severity: Severity
|
| 58 |
+
detail: str
|
| 59 |
+
subjects: list[str] = Field(default_factory=list) # посилання на FHIR-ресурси
|
| 60 |
+
confidence: float = 1.0
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class Decision(BaseModel):
|
| 64 |
+
"""Рішення L3: авто чи ескалація до людини (L4)."""
|
| 65 |
+
|
| 66 |
+
route_to: Literal["auto", "L4_human"]
|
| 67 |
+
escalate: bool
|
| 68 |
+
reasons: list[str] = Field(default_factory=list)
|
| 69 |
+
signals: list[Signal] = Field(default_factory=list)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class TraceReport(BaseModel):
|
| 73 |
+
"""Повний результат прогону одного документа крізь конвеєр."""
|
| 74 |
+
|
| 75 |
+
document_id: str
|
| 76 |
+
extraction: ExtractionResult
|
| 77 |
+
signals: list[Signal]
|
| 78 |
+
decision: Decision
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class AuditEntry(BaseModel):
|
| 82 |
+
"""Запис аудиту одного рішення L3 (типізована схема журналу).
|
| 83 |
+
|
| 84 |
+
Гарантує стабільну структуру журналу між версіями: знімки зі сторонніми чи
|
| 85 |
+
неповними записами відхиляються валідацією при відновленні.
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
ts: datetime
|
| 89 |
+
document_id: str
|
| 90 |
+
kind: str
|
| 91 |
+
extraction_confidence: float
|
| 92 |
+
n_signals: int
|
| 93 |
+
route_to: Literal["auto", "L4_human"]
|
| 94 |
+
escalate: bool
|
livemedcard/fhir.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FHIR-вирівняні моделі даних (HL7 FHIR R4, мінімальний профіль для MVP).
|
| 2 |
+
|
| 3 |
+
Беремо СТРУКТУРИ РЕСУРСІВ як схему даних; серверний шар (REST/OAuth/Bundle) не беремо.
|
| 4 |
+
Cardinality й value sets — за fhir-developer-skill. `Provenance` — не-FHIR розширення,
|
| 5 |
+
основа метрики довіри (звідки взято факт).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from datetime import date, datetime, timezone
|
| 10 |
+
from typing import Literal, Optional
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel, Field
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _utcnow() -> datetime:
|
| 16 |
+
return datetime.now(timezone.utc)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Sys:
|
| 20 |
+
"""URL кодувальних систем."""
|
| 21 |
+
|
| 22 |
+
LOINC = "http://loinc.org"
|
| 23 |
+
SNOMED = "http://snomed.info/sct"
|
| 24 |
+
RXNORM = "http://www.nlm.nih.gov/research/umls/rxnorm"
|
| 25 |
+
ICD10 = "http://hl7.org/fhir/sid/icd-10"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ── datatypes ────────────────────────────────────────────────────────────────
|
| 29 |
+
class Coding(BaseModel):
|
| 30 |
+
system: str
|
| 31 |
+
code: str
|
| 32 |
+
display: Optional[str] = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class CodeableConcept(BaseModel):
|
| 36 |
+
coding: list[Coding] = Field(default_factory=list)
|
| 37 |
+
text: Optional[str] = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class Reference(BaseModel):
|
| 41 |
+
reference: str # напр. "Patient/mom-001"
|
| 42 |
+
display: Optional[str] = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class Quantity(BaseModel):
|
| 46 |
+
value: float
|
| 47 |
+
unit: Optional[str] = None
|
| 48 |
+
system: Optional[str] = None
|
| 49 |
+
code: Optional[str] = None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class Provenance(BaseModel):
|
| 53 |
+
"""Не-FHIR розширення: звідки взято факт. Живить провенанс-ребра графа."""
|
| 54 |
+
|
| 55 |
+
source_doc_id: str
|
| 56 |
+
extracted_by: str = "MedGemma-4B"
|
| 57 |
+
extracted_at: datetime = Field(default_factory=_utcnow)
|
| 58 |
+
confidence: Optional[float] = None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class Attachment(BaseModel):
|
| 62 |
+
"""Вміст документа (FHIR Attachment, мінімальний профіль).
|
| 63 |
+
|
| 64 |
+
Free-text іпостась зберігається у ``data`` (напр. Stage 1 SGR-summary).
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
contentType: str = "text/plain"
|
| 68 |
+
data: Optional[str] = None
|
| 69 |
+
title: Optional[str] = None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ── resources ─────────────────────────────────────────────────────────────────
|
| 73 |
+
class Patient(BaseModel):
|
| 74 |
+
resourceType: Literal["Patient"] = "Patient"
|
| 75 |
+
id: str
|
| 76 |
+
gender: Optional[Literal["male", "female", "other", "unknown"]] = None
|
| 77 |
+
birthDate: Optional[date] = None
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class Encounter(BaseModel):
|
| 81 |
+
resourceType: Literal["Encounter"] = "Encounter"
|
| 82 |
+
id: str
|
| 83 |
+
status: Literal[
|
| 84 |
+
"planned", "arrived", "triaged", "in-progress", "onleave",
|
| 85 |
+
"finished", "cancelled", "entered-in-error", "unknown",
|
| 86 |
+
]
|
| 87 |
+
class_: Coding = Field(alias="class") # 1..1 Coding (не CodeableConcept)
|
| 88 |
+
subject: Optional[Reference] = None
|
| 89 |
+
period_start: Optional[date] = None
|
| 90 |
+
model_config = {"populate_by_name": True}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class Observation(BaseModel):
|
| 94 |
+
resourceType: Literal["Observation"] = "Observation"
|
| 95 |
+
id: str
|
| 96 |
+
status: Literal[
|
| 97 |
+
"registered", "preliminary", "final", "amended", "corrected",
|
| 98 |
+
"cancelled", "entered-in-error", "unknown",
|
| 99 |
+
]
|
| 100 |
+
code: CodeableConcept # 1..1
|
| 101 |
+
subject: Optional[Reference] = None
|
| 102 |
+
effectiveDateTime: Optional[datetime] = None
|
| 103 |
+
valueQuantity: Optional[Quantity] = None
|
| 104 |
+
provenance: Optional[Provenance] = None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class Condition(BaseModel):
|
| 108 |
+
resourceType: Literal["Condition"] = "Condition"
|
| 109 |
+
id: str
|
| 110 |
+
subject: Reference # 1..1 — єдине обов'язкове
|
| 111 |
+
code: Optional[CodeableConcept] = None
|
| 112 |
+
clinicalStatus: Optional[
|
| 113 |
+
Literal["active", "recurrence", "relapse", "inactive", "remission", "resolved"]
|
| 114 |
+
] = None
|
| 115 |
+
onsetDateTime: Optional[datetime] = None
|
| 116 |
+
provenance: Optional[Provenance] = None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class MedicationRequest(BaseModel):
|
| 120 |
+
resourceType: Literal["MedicationRequest"] = "MedicationRequest"
|
| 121 |
+
id: str
|
| 122 |
+
status: Literal[
|
| 123 |
+
"active", "on-hold", "cancelled", "completed",
|
| 124 |
+
"entered-in-error", "stopped", "draft", "unknown",
|
| 125 |
+
]
|
| 126 |
+
intent: Literal[
|
| 127 |
+
"proposal", "plan", "order", "original-order", "reflex-order",
|
| 128 |
+
"filler-order", "instance-order", "option",
|
| 129 |
+
]
|
| 130 |
+
medicationCodeableConcept: CodeableConcept # medication[x] 1..1
|
| 131 |
+
subject: Reference # 1..1
|
| 132 |
+
reason: Optional[Reference] = None # → Condition (ребро indicated_for)
|
| 133 |
+
dose_mg_per_day: Optional[float] = None # спрощення для дозового чеку L1
|
| 134 |
+
provenance: Optional[Provenance] = None
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class DocumentReference(BaseModel):
|
| 138 |
+
"""FHIR DocumentReference (мінімальний R4-профіль) — free-text іпостась.
|
| 139 |
+
|
| 140 |
+
Тримає згорнутий Stage 1 SGR-summary документа поряд зі структурованими
|
| 141 |
+
фактами. ``id`` детермінований від ``source_doc_id`` (docref-<doc_id>), тож
|
| 142 |
+
повторний прогін того самого документа не дублює запис.
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
resourceType: Literal["DocumentReference"] = "DocumentReference"
|
| 146 |
+
id: str
|
| 147 |
+
status: Literal["current", "superseded", "entered-in-error"] = "current"
|
| 148 |
+
subject: Optional[Reference] = None
|
| 149 |
+
date: Optional[datetime] = None
|
| 150 |
+
type: Optional[CodeableConcept] = None # несе DocKind документа
|
| 151 |
+
content: Attachment
|
| 152 |
+
provenance: Optional[Provenance] = None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def rxnorm_of(m: MedicationRequest) -> Optional[str]:
|
| 156 |
+
"""RxNorm-код препарату або None."""
|
| 157 |
+
return next(
|
| 158 |
+
(c.code for c in m.medicationCodeableConcept.coding if c.system == Sys.RXNORM),
|
| 159 |
+
None,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def loinc_of(o: Observation) -> Optional[str]:
|
| 164 |
+
"""LOINC-код показника або None."""
|
| 165 |
+
return next((c.code for c in o.code.coding if c.system == Sys.LOINC), None)
|
livemedcard/lang.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Легкий детектор мови (UA/EN) для білінгвального режиму.
|
| 2 |
+
|
| 3 |
+
Евристика за абеткою: частка кириличних літер серед усіх літер. Достатньо для
|
| 4 |
+
вибору мови відповіді Q&A та адаптації промптів — без важких залежностей.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
from typing import Literal
|
| 10 |
+
|
| 11 |
+
Lang = Literal["uk", "en"]
|
| 12 |
+
|
| 13 |
+
_CYRILLIC_RE = re.compile(r"[Ѐ-ӿ]")
|
| 14 |
+
_LATIN_RE = re.compile(r"[A-Za-z]")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def detect_lang(text: str, *, default: Lang = "uk") -> Lang:
|
| 18 |
+
"""Повертає 'uk' чи 'en' за переважанням абетки; `default` — коли літер немає."""
|
| 19 |
+
cyr = len(_CYRILLIC_RE.findall(text))
|
| 20 |
+
lat = len(_LATIN_RE.findall(text))
|
| 21 |
+
if cyr == 0 and lat == 0:
|
| 22 |
+
return default
|
| 23 |
+
return "uk" if cyr >= lat else "en"
|
livemedcard/layers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Шари архітектури TRACE: L1 (безпека), L2a (тренди), L2b (екстракція), L3 (роутинг)."""
|
livemedcard/layers/l1_safety.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Шар TRACE L1: детермінований шар безпеки (взаємодії ліків, межі доз).
|
| 2 |
+
|
| 3 |
+
Це якір конвеєра: рішення про клінічну небезпеку приймається за перевіреними
|
| 4 |
+
правилами відкритих довідкових БД (RxNorm), а не мовною моделлю. Усі сигнали
|
| 5 |
+
L1 детерміновані — ``confidence == 1.0``.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from functools import lru_cache
|
| 10 |
+
from itertools import combinations
|
| 11 |
+
|
| 12 |
+
from ..dto import Signal
|
| 13 |
+
from ..fhir import MedicationRequest, rxnorm_of
|
| 14 |
+
from ..refmodels import Interaction
|
| 15 |
+
from ..reference import load_dose_limits, load_interactions
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@lru_cache(maxsize=None)
|
| 19 |
+
def _pair_map() -> dict[frozenset[str], Interaction]:
|
| 20 |
+
"""Індекс взаємодій за неупорядкованою парою RxNorm (будується один раз)."""
|
| 21 |
+
return {entry.pair(): entry for entry in load_interactions()}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _display_name(m: MedicationRequest) -> str:
|
| 25 |
+
"""Назва препарату для тексту сигналу: text CodeableConcept → display coding → RxNorm-код."""
|
| 26 |
+
cc = m.medicationCodeableConcept
|
| 27 |
+
if cc.text:
|
| 28 |
+
return cc.text
|
| 29 |
+
for c in cc.coding:
|
| 30 |
+
if c.display:
|
| 31 |
+
return c.display
|
| 32 |
+
return rxnorm_of(m) or m.id
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def check_interactions(meds: list[MedicationRequest]) -> list[Signal]:
|
| 36 |
+
"""Перевірка кожної неупорядкованої пари ліків на відому взаємодію (RxNorm)."""
|
| 37 |
+
pair_map = _pair_map()
|
| 38 |
+
signals: list[Signal] = []
|
| 39 |
+
for m1, m2 in combinations(meds, 2):
|
| 40 |
+
rx1, rx2 = rxnorm_of(m1), rxnorm_of(m2)
|
| 41 |
+
if rx1 is None or rx2 is None:
|
| 42 |
+
continue
|
| 43 |
+
entry = pair_map.get(frozenset((rx1, rx2)))
|
| 44 |
+
if entry is None:
|
| 45 |
+
continue
|
| 46 |
+
name1, name2 = _display_name(m1), _display_name(m2)
|
| 47 |
+
signals.append(
|
| 48 |
+
Signal(
|
| 49 |
+
layer="L1",
|
| 50 |
+
kind="interaction",
|
| 51 |
+
severity=entry.severity,
|
| 52 |
+
detail=f"{name1} + {name2}: {entry.detail}",
|
| 53 |
+
subjects=[f"MedicationRequest/{m1.id}", f"MedicationRequest/{m2.id}"],
|
| 54 |
+
confidence=1.0,
|
| 55 |
+
)
|
| 56 |
+
)
|
| 57 |
+
return signals
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def check_dose_limits(meds: list[MedicationRequest]) -> list[Signal]:
|
| 61 |
+
"""Перевірка кожних ліків на перевищення максимальної добової дози (RxNorm)."""
|
| 62 |
+
limits = load_dose_limits()
|
| 63 |
+
signals: list[Signal] = []
|
| 64 |
+
for m in meds:
|
| 65 |
+
rx = rxnorm_of(m)
|
| 66 |
+
if rx is None or rx not in limits:
|
| 67 |
+
continue
|
| 68 |
+
if m.dose_mg_per_day is None:
|
| 69 |
+
continue
|
| 70 |
+
limit = limits[rx]
|
| 71 |
+
if m.dose_mg_per_day > limit.max_daily:
|
| 72 |
+
signals.append(
|
| 73 |
+
Signal(
|
| 74 |
+
layer="L1",
|
| 75 |
+
kind="dose_limit",
|
| 76 |
+
severity="major",
|
| 77 |
+
detail=(
|
| 78 |
+
f"{limit.name}: добова доза {m.dose_mg_per_day:g} "
|
| 79 |
+
f"{limit.unit} — перевищено добову межу "
|
| 80 |
+
f"{limit.max_daily:g} {limit.unit}"
|
| 81 |
+
),
|
| 82 |
+
subjects=[f"MedicationRequest/{m.id}"],
|
| 83 |
+
confidence=1.0,
|
| 84 |
+
)
|
| 85 |
+
)
|
| 86 |
+
return signals
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def run(meds: list[MedicationRequest]) -> list[Signal]:
|
| 90 |
+
"""Повний прогін L1: взаємодії + межі доз, у стабільному порядку."""
|
| 91 |
+
return check_interactions(meds) + check_dose_limits(meds)
|
livemedcard/layers/l2a_trends.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""L2a — класичний детермінований аналіз на факт-сторі, не LLM.
|
| 2 |
+
|
| 3 |
+
Дві перевірки над `FactStore`: виявлення стійких трендів показника (монотонне
|
| 4 |
+
зростання/спадання) та порушення референсних меж за останнім виміром.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
from ..dto import Signal
|
| 11 |
+
from ..reference import range_for
|
| 12 |
+
from ..stores import FactStore
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _fmt(v: float) -> str:
|
| 16 |
+
"""Компактне форматування числа (без зайвих `.0`)."""
|
| 17 |
+
return f"{v:g}"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def detect_trends(store: FactStore) -> list[Signal]:
|
| 21 |
+
"""Стійкий монотонний тренд (>=3 вимірювань, всі зростають або всі спадають)."""
|
| 22 |
+
signals: list[Signal] = []
|
| 23 |
+
for loinc in store.loincs():
|
| 24 |
+
series = store.series(loinc)
|
| 25 |
+
if len(series) < 3:
|
| 26 |
+
continue
|
| 27 |
+
values = [f.value for f in series]
|
| 28 |
+
# Нестрога монотонність із чистою зміною: рівний крок (повторне значення)
|
| 29 |
+
# НЕ скасовує очевидний тренд, але суто рівний ряд трендом не вважається.
|
| 30 |
+
rising = all(a <= b for a, b in zip(values, values[1:])) and values[-1] > values[0]
|
| 31 |
+
falling = all(a >= b for a, b in zip(values, values[1:])) and values[-1] < values[0]
|
| 32 |
+
if not (rising or falling):
|
| 33 |
+
continue
|
| 34 |
+
direction = "зростає" if rising else "знижується"
|
| 35 |
+
first, last = series[0], series[-1]
|
| 36 |
+
detail = (
|
| 37 |
+
f"{first.keyword} {direction} {len(series)} вимірювань поспіль: "
|
| 38 |
+
f"{_fmt(first.value)}→{_fmt(last.value)} "
|
| 39 |
+
f"({first.ts.date().isoformat()}..{last.ts.date().isoformat()})"
|
| 40 |
+
)
|
| 41 |
+
signals.append(
|
| 42 |
+
Signal(
|
| 43 |
+
layer="L2a",
|
| 44 |
+
kind="trend",
|
| 45 |
+
severity="moderate",
|
| 46 |
+
detail=detail,
|
| 47 |
+
subjects=[loinc],
|
| 48 |
+
confidence=1.0,
|
| 49 |
+
)
|
| 50 |
+
)
|
| 51 |
+
return signals
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def detect_range_breaches(store: FactStore, sex: Optional[str] = None) -> list[Signal]:
|
| 55 |
+
"""Останнє значення показника поза референсною межею (з урахуванням статі)."""
|
| 56 |
+
signals: list[Signal] = []
|
| 57 |
+
for loinc in store.loincs():
|
| 58 |
+
ref = range_for(loinc, sex)
|
| 59 |
+
if ref is None:
|
| 60 |
+
continue
|
| 61 |
+
series = store.series(loinc)
|
| 62 |
+
if not series:
|
| 63 |
+
continue
|
| 64 |
+
# series відсортований за часом; беремо ОСТАННІЙ (при рівних мітках —
|
| 65 |
+
# останній доданий), щоб L2a і Q&A узгоджено бачили те саме значення.
|
| 66 |
+
latest = series[-1]
|
| 67 |
+
if latest.value < ref.low:
|
| 68 |
+
direction = "нижче норми"
|
| 69 |
+
elif latest.value > ref.high:
|
| 70 |
+
direction = "вище норми"
|
| 71 |
+
else:
|
| 72 |
+
continue
|
| 73 |
+
unit = latest.unit or ref.unit or ""
|
| 74 |
+
detail = (
|
| 75 |
+
f"{ref.keyword} {_fmt(latest.value)} {unit} {direction} "
|
| 76 |
+
f"({_fmt(ref.low)}–{_fmt(ref.high)})"
|
| 77 |
+
)
|
| 78 |
+
signals.append(
|
| 79 |
+
Signal(
|
| 80 |
+
layer="L2a",
|
| 81 |
+
kind="range_breach",
|
| 82 |
+
severity="major",
|
| 83 |
+
detail=detail,
|
| 84 |
+
subjects=[loinc],
|
| 85 |
+
confidence=1.0,
|
| 86 |
+
)
|
| 87 |
+
)
|
| 88 |
+
return signals
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def run(store: FactStore, sex: Optional[str] = None) -> list[Signal]:
|
| 92 |
+
"""Усі сигнали L2a: тренди + порушення референсних меж."""
|
| 93 |
+
return detect_trends(store) + detect_range_breaches(store, sex)
|
livemedcard/layers/l2b_extractor.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""L2b — розуміння вільного тексту.
|
| 2 |
+
|
| 3 |
+
У MVP детермінований stub замість MedGemma; екстрактор НЕ робить висновків
|
| 4 |
+
про безпеку, лише витягує факти (лабораторні показники, ліки, діагнози)
|
| 5 |
+
з українського вільного тексту у FHIR-ресурси.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
import re
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
from typing import Optional, Protocol, TypeVar
|
| 13 |
+
|
| 14 |
+
from ..config import (
|
| 15 |
+
STAGE1_MODEL,
|
| 16 |
+
STAGE1_URL,
|
| 17 |
+
STAGE2_MODEL,
|
| 18 |
+
STAGE2_URL,
|
| 19 |
+
STUB_CONFIDENCE,
|
| 20 |
+
)
|
| 21 |
+
from ..dto import Document, ExtractionResult
|
| 22 |
+
from ..fhir import (
|
| 23 |
+
Attachment,
|
| 24 |
+
CodeableConcept,
|
| 25 |
+
Coding,
|
| 26 |
+
Condition,
|
| 27 |
+
DocumentReference,
|
| 28 |
+
MedicationRequest,
|
| 29 |
+
Observation,
|
| 30 |
+
Provenance,
|
| 31 |
+
Quantity,
|
| 32 |
+
Reference,
|
| 33 |
+
Sys,
|
| 34 |
+
)
|
| 35 |
+
from ..config import NUEXTRACT_MODEL, NUEXTRACT_URL
|
| 36 |
+
from ..reference import load_vocab
|
| 37 |
+
from . import l2b_nuextract, l2b_stage1, l2b_stage2_lora
|
| 38 |
+
|
| 39 |
+
_T = TypeVar("_T")
|
| 40 |
+
|
| 41 |
+
# Значення показника і дозу шукаємо ТІЛЬКИ впритул після терміна (між ними —
|
| 42 |
+
# лише роздільники), а не пошуком уперед по всьому тексту. Інакше показник чи
|
| 43 |
+
# ліки без власного числа «крадуть» число наступного терміна (напр. систолічний
|
| 44 |
+
# тиск забирав би значення калію, а варфарин — дозу аспірину).
|
| 45 |
+
_SEP = r"[\s:=–—-]*"
|
| 46 |
+
_LAB_VALUE_RE = re.compile(_SEP + r"(\d+(?:\.\d+)?)")
|
| 47 |
+
_DOSE_MG_RE = re.compile(_SEP + r"(\d+(?:\.\d+)?)\s*мг")
|
| 48 |
+
# Кратність прийому: «×2», «x2», «2 рази», «двічі», «тричі» → множник за добу.
|
| 49 |
+
_FREQ_RE = re.compile(r"[×xх]\s*(\d+)|(\d+)\s*раз|(двіч)|(тріч)", re.IGNORECASE)
|
| 50 |
+
# Дата вимірювання на факт: ISO «2024-03-15» або «15.03.2024» у тому самому рядку.
|
| 51 |
+
_DATE_RE = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b|\b(\d{2})\.(\d{2})\.(\d{4})\b")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _stable_id(prefix: str, *parts: object) -> str:
|
| 55 |
+
"""Детермінований id ресурсу від його змісту, а не порядку знахідки.
|
| 56 |
+
|
| 57 |
+
Стабільний між запусками і версіями словника: зміна порядку матчингу не
|
| 58 |
+
змінює id, тож ідемпотентність FactStore переживає повторний витяг і
|
| 59 |
+
відновлення зі старих знімків. Ідентичний зміст (той самий документ, код,
|
| 60 |
+
значення, час) навмисно дає той самий id — це один факт.
|
| 61 |
+
"""
|
| 62 |
+
key = "|".join(str(p) for p in parts)
|
| 63 |
+
return f"{prefix}-{hashlib.sha1(key.encode('utf-8')).hexdigest()[:12]}"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _parse_effective_date(
|
| 67 |
+
text: str, pos: int, fallback: Optional[datetime]
|
| 68 |
+
) -> Optional[datetime]:
|
| 69 |
+
"""Дата факту з решти поточного рядка (після значення); інакше `fallback`.
|
| 70 |
+
|
| 71 |
+
Шукаємо ISO/dotted-дату лише до кінця рядка, щоб дата з наступного факту не
|
| 72 |
+
«перетікала» на попередній. Повертаємо tz-aware UTC-опівніч.
|
| 73 |
+
"""
|
| 74 |
+
eol = text.find("\n", pos)
|
| 75 |
+
segment = text[pos:] if eol == -1 else text[pos:eol]
|
| 76 |
+
m = _DATE_RE.search(segment)
|
| 77 |
+
if m is None:
|
| 78 |
+
return fallback
|
| 79 |
+
if m.group(1): # ISO YYYY-MM-DD
|
| 80 |
+
y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
| 81 |
+
else: # DD.MM.YYYY
|
| 82 |
+
d, mo, y = int(m.group(4)), int(m.group(5)), int(m.group(6))
|
| 83 |
+
try:
|
| 84 |
+
return datetime(y, mo, d, tzinfo=timezone.utc)
|
| 85 |
+
except ValueError: # напр. 2024-13-40 — ігноруємо, лишаємо fallback
|
| 86 |
+
return fallback
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _parse_daily_dose_mg(text: str, pos: int) -> Optional[float]:
|
| 90 |
+
"""Добова доза в мг: разова доза «N мг» × кратність прийому (за замовч. 1)."""
|
| 91 |
+
m = _DOSE_MG_RE.match(text, pos)
|
| 92 |
+
if m is None:
|
| 93 |
+
return None
|
| 94 |
+
single = float(m.group(1))
|
| 95 |
+
freq = 1
|
| 96 |
+
fm = _FREQ_RE.search(text, m.end(), m.end() + 25) # вікно тієї самої фрази
|
| 97 |
+
if fm:
|
| 98 |
+
if fm.group(1):
|
| 99 |
+
freq = int(fm.group(1))
|
| 100 |
+
elif fm.group(2):
|
| 101 |
+
freq = int(fm.group(2))
|
| 102 |
+
elif fm.group(3):
|
| 103 |
+
freq = 2 # двічі
|
| 104 |
+
elif fm.group(4):
|
| 105 |
+
freq = 3 # тричі
|
| 106 |
+
return single * freq
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class Extractor(Protocol):
|
| 110 |
+
"""Контракт L2b-екстрактора."""
|
| 111 |
+
|
| 112 |
+
def extract(self, doc: Document, patient_id: str) -> ExtractionResult: ...
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _find_vocab_terms(
|
| 116 |
+
text: str, vocab_terms: dict[str, _T]
|
| 117 |
+
) -> list[tuple[str, _T, re.Match[str]]]:
|
| 118 |
+
"""Знаходить УСІ входження кожного терміна в тексті.
|
| 119 |
+
|
| 120 |
+
Довші (багатослівні) терміни перевіряються першими, щоб виграти при
|
| 121 |
+
перекритті зі коротшими. Результат впорядкований за позицією входження
|
| 122 |
+
в тексті.
|
| 123 |
+
"""
|
| 124 |
+
terms_by_length_desc = sorted(vocab_terms.items(), key=lambda kv: -len(kv[0]))
|
| 125 |
+
claimed_spans: list[tuple[int, int]] = []
|
| 126 |
+
found: list[tuple[int, str, _T, re.Match[str]]] = []
|
| 127 |
+
for term, info in terms_by_length_desc:
|
| 128 |
+
pattern = re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE)
|
| 129 |
+
# Усі входження терміна, а не лише перше — щоб не губити повторні
|
| 130 |
+
# вимірювання одного показника в межах документа.
|
| 131 |
+
for match in pattern.finditer(text):
|
| 132 |
+
start, end = match.span()
|
| 133 |
+
if any(start < claimed_end and end > claimed_start for claimed_start, claimed_end in claimed_spans):
|
| 134 |
+
continue
|
| 135 |
+
claimed_spans.append((start, end))
|
| 136 |
+
found.append((start, term, info, match))
|
| 137 |
+
found.sort(key=lambda item: item[0])
|
| 138 |
+
return [(term, info, match) for _, term, info, match in found]
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def deterministic_extract(
|
| 142 |
+
text: str, doc: Document, patient_id: str, *, extracted_by: str = "StubExtractor"
|
| 143 |
+
) -> ExtractionResult:
|
| 144 |
+
"""Детермінований Stage 2: текст → FHIR-ресурси (LOINC/RxNorm/SNOMED).
|
| 145 |
+
|
| 146 |
+
Спільне ядро для `StubExtractor` (вхід — сирий `doc.text`) і `TwoStageExtractor`
|
| 147 |
+
(вхід — Stage 1 SGR-summary). Дата обзервації береться з рядка факту, інакше
|
| 148 |
+
fallback на `doc.received_at` — тож на тексті без дат id/час не змінюються.
|
| 149 |
+
"""
|
| 150 |
+
vocab = load_vocab()
|
| 151 |
+
subject = Reference(reference=f"Patient/{patient_id}")
|
| 152 |
+
|
| 153 |
+
observations: list[Observation] = []
|
| 154 |
+
seen_obs: set[str] = set()
|
| 155 |
+
for term, info, match in _find_vocab_terms(text, vocab.labs):
|
| 156 |
+
number_match = _LAB_VALUE_RE.match(text, match.end())
|
| 157 |
+
if number_match is None:
|
| 158 |
+
continue
|
| 159 |
+
eff_date = _parse_effective_date(text, number_match.end(), doc.received_at)
|
| 160 |
+
obs_id = _stable_id(
|
| 161 |
+
"obs", doc.id, info.loinc, number_match.group(1), eff_date
|
| 162 |
+
)
|
| 163 |
+
if obs_id in seen_obs: # ідентична повторна згадка — той самий факт
|
| 164 |
+
continue
|
| 165 |
+
seen_obs.add(obs_id)
|
| 166 |
+
observations.append(
|
| 167 |
+
Observation(
|
| 168 |
+
id=obs_id,
|
| 169 |
+
status="final",
|
| 170 |
+
code=CodeableConcept(
|
| 171 |
+
coding=[
|
| 172 |
+
Coding(
|
| 173 |
+
system=Sys.LOINC,
|
| 174 |
+
code=info.loinc,
|
| 175 |
+
display=info.display,
|
| 176 |
+
)
|
| 177 |
+
],
|
| 178 |
+
text=term.capitalize(),
|
| 179 |
+
),
|
| 180 |
+
subject=subject,
|
| 181 |
+
effectiveDateTime=eff_date,
|
| 182 |
+
valueQuantity=Quantity(
|
| 183 |
+
value=float(number_match.group(1)), unit=info.unit
|
| 184 |
+
),
|
| 185 |
+
provenance=Provenance(
|
| 186 |
+
source_doc_id=doc.id,
|
| 187 |
+
extracted_by=extracted_by,
|
| 188 |
+
confidence=STUB_CONFIDENCE,
|
| 189 |
+
),
|
| 190 |
+
)
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
medications: list[MedicationRequest] = []
|
| 194 |
+
seen_med: set[str] = set()
|
| 195 |
+
for term, info, match in _find_vocab_terms(text, vocab.medications):
|
| 196 |
+
dose_mg_per_day: Optional[float] = _parse_daily_dose_mg(text, match.end())
|
| 197 |
+
med_id = _stable_id("med", doc.id, info.rxnorm, dose_mg_per_day)
|
| 198 |
+
if med_id in seen_med:
|
| 199 |
+
continue
|
| 200 |
+
seen_med.add(med_id)
|
| 201 |
+
medications.append(
|
| 202 |
+
MedicationRequest(
|
| 203 |
+
id=med_id,
|
| 204 |
+
status="active",
|
| 205 |
+
intent="order",
|
| 206 |
+
medicationCodeableConcept=CodeableConcept(
|
| 207 |
+
coding=[
|
| 208 |
+
Coding(
|
| 209 |
+
system=Sys.RXNORM,
|
| 210 |
+
code=info.rxnorm,
|
| 211 |
+
display=info.display,
|
| 212 |
+
)
|
| 213 |
+
],
|
| 214 |
+
text=term.capitalize(),
|
| 215 |
+
),
|
| 216 |
+
subject=subject,
|
| 217 |
+
dose_mg_per_day=dose_mg_per_day,
|
| 218 |
+
provenance=Provenance(
|
| 219 |
+
source_doc_id=doc.id,
|
| 220 |
+
extracted_by=extracted_by,
|
| 221 |
+
confidence=STUB_CONFIDENCE,
|
| 222 |
+
),
|
| 223 |
+
)
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
conditions: list[Condition] = []
|
| 227 |
+
seen_cond: set[str] = set()
|
| 228 |
+
for term, info, _match in _find_vocab_terms(text, vocab.conditions):
|
| 229 |
+
cond_id = _stable_id("cond", doc.id, info.snomed)
|
| 230 |
+
if cond_id in seen_cond:
|
| 231 |
+
continue
|
| 232 |
+
seen_cond.add(cond_id)
|
| 233 |
+
conditions.append(
|
| 234 |
+
Condition(
|
| 235 |
+
id=cond_id,
|
| 236 |
+
subject=subject,
|
| 237 |
+
code=CodeableConcept(
|
| 238 |
+
coding=[
|
| 239 |
+
Coding(
|
| 240 |
+
system=Sys.SNOMED,
|
| 241 |
+
code=info.snomed,
|
| 242 |
+
display=info.display,
|
| 243 |
+
)
|
| 244 |
+
],
|
| 245 |
+
text=term,
|
| 246 |
+
),
|
| 247 |
+
clinicalStatus="active",
|
| 248 |
+
onsetDateTime=doc.received_at,
|
| 249 |
+
provenance=Provenance(
|
| 250 |
+
source_doc_id=doc.id,
|
| 251 |
+
extracted_by=extracted_by,
|
| 252 |
+
confidence=STUB_CONFIDENCE,
|
| 253 |
+
),
|
| 254 |
+
)
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
# Порожній витяг означає «нічого не розпізнали», а не «впевнені на 100%».
|
| 258 |
+
# Низька впевненість веде документ на перегляд людиною (L3), а не в auto.
|
| 259 |
+
mean_confidence = (
|
| 260 |
+
STUB_CONFIDENCE if (observations or medications or conditions) else 0.0
|
| 261 |
+
)
|
| 262 |
+
return ExtractionResult(
|
| 263 |
+
observations=observations,
|
| 264 |
+
conditions=conditions,
|
| 265 |
+
medications=medications,
|
| 266 |
+
mean_confidence=mean_confidence,
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
class StubExtractor:
|
| 271 |
+
"""Детермінований stub-екстрактор: Stage 2 напряму по сирому `doc.text`."""
|
| 272 |
+
|
| 273 |
+
def extract(self, doc: Document, patient_id: str) -> ExtractionResult:
|
| 274 |
+
return deterministic_extract(doc.text, doc, patient_id)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _build_docref(
|
| 278 |
+
doc: Document, summary_text: str, patient_id: str
|
| 279 |
+
) -> DocumentReference:
|
| 280 |
+
"""FHIR DocumentReference з Stage 1 SGR-summary (free-text іпостась)."""
|
| 281 |
+
return DocumentReference(
|
| 282 |
+
id=f"docref-{doc.id}",
|
| 283 |
+
status="current",
|
| 284 |
+
subject=Reference(reference=f"Patient/{patient_id}"),
|
| 285 |
+
date=doc.received_at,
|
| 286 |
+
type=CodeableConcept(text=doc.kind),
|
| 287 |
+
content=Attachment(
|
| 288 |
+
contentType="text/plain",
|
| 289 |
+
data=summary_text,
|
| 290 |
+
title=f"SGR summary: {doc.id}",
|
| 291 |
+
),
|
| 292 |
+
provenance=Provenance(
|
| 293 |
+
source_doc_id=doc.id,
|
| 294 |
+
extracted_by="MedGemma-Stage1",
|
| 295 |
+
confidence=STUB_CONFIDENCE,
|
| 296 |
+
),
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
class TwoStageExtractor:
|
| 301 |
+
"""Двоетапний L2b: Stage 1 (LLM SGR-стиснення) → Stage 2 (детермінована KV-T).
|
| 302 |
+
|
| 303 |
+
Дає дві іпостасі одного документа: `DocumentReference` (free-text summary) і
|
| 304 |
+
структуровані FHIR-факти. Потребує живого локального LLM-сервера (LM Studio).
|
| 305 |
+
"""
|
| 306 |
+
|
| 307 |
+
def __init__(self, *, model: str | None = None) -> None:
|
| 308 |
+
self._model = model
|
| 309 |
+
|
| 310 |
+
def extract(self, doc: Document, patient_id: str) -> ExtractionResult:
|
| 311 |
+
summary_text = l2b_stage1.summarize(doc.text, model=self._model)
|
| 312 |
+
ext = deterministic_extract(
|
| 313 |
+
summary_text, doc, patient_id, extracted_by="MedGemma-4B+Stage2"
|
| 314 |
+
)
|
| 315 |
+
ext.document_reference = _build_docref(doc, summary_text, patient_id)
|
| 316 |
+
return ext
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
class LoraStage2Extractor:
|
| 320 |
+
"""Двоетапний L2b з FT LoRA-Stage 2 (двосерверний llama.cpp-сетап).
|
| 321 |
+
|
| 322 |
+
Stage 1 — база (SGR-summary → `DocumentReference`); Stage 2 — база+LoRA
|
| 323 |
+
генерує KVT4, які парсяться у FHIR Observations (часті лаби мостяться у LOINC).
|
| 324 |
+
Потребує двох llama.cpp-серверів (`STAGE1_URL`, `STAGE2_URL`).
|
| 325 |
+
"""
|
| 326 |
+
|
| 327 |
+
def __init__(
|
| 328 |
+
self,
|
| 329 |
+
*,
|
| 330 |
+
stage1_model: str | None = STAGE1_MODEL,
|
| 331 |
+
stage1_url: str | None = STAGE1_URL,
|
| 332 |
+
stage2_model: str | None = STAGE2_MODEL,
|
| 333 |
+
stage2_url: str | None = STAGE2_URL,
|
| 334 |
+
) -> None:
|
| 335 |
+
self._s1_model = stage1_model
|
| 336 |
+
self._s1_url = stage1_url
|
| 337 |
+
self._s2_model = stage2_model
|
| 338 |
+
self._s2_url = stage2_url
|
| 339 |
+
|
| 340 |
+
def extract(self, doc: Document, patient_id: str) -> ExtractionResult:
|
| 341 |
+
summary_text = l2b_stage1.summarize(
|
| 342 |
+
doc.text, model=self._s1_model, base_url=self._s1_url
|
| 343 |
+
)
|
| 344 |
+
kvt = l2b_stage2_lora.extract_facts(
|
| 345 |
+
summary_text, url=self._s2_url, model=self._s2_model
|
| 346 |
+
)
|
| 347 |
+
observations = l2b_stage2_lora.to_observations(
|
| 348 |
+
kvt, doc, patient_id, extracted_by="MedGemma-LoRA-Stage2"
|
| 349 |
+
)
|
| 350 |
+
return ExtractionResult(
|
| 351 |
+
observations=observations,
|
| 352 |
+
mean_confidence=STUB_CONFIDENCE if observations else 0.0,
|
| 353 |
+
document_reference=_build_docref(doc, summary_text, patient_id),
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
class NuExtractExtractor:
|
| 358 |
+
"""L2b через NuExtract3 (VLM). Stage 2 (текст/markdown → KVT-факти за шаблоном)
|
| 359 |
+
→ Observations (міст keyword→LOINC). `doc.text` (markdown або звичайний текст)
|
| 360 |
+
зберігається як `DocumentReference` — розширена іпостась.
|
| 361 |
+
|
| 362 |
+
Для зображень Stage 1 (image→markdown) робить `/ingest/image` через
|
| 363 |
+
`l2b_nuextract.image_to_markdown`, тож сюди вже приходить markdown у `doc.text`.
|
| 364 |
+
"""
|
| 365 |
+
|
| 366 |
+
def __init__(self, *, url: str | None = NUEXTRACT_URL, model: str | None = NUEXTRACT_MODEL) -> None:
|
| 367 |
+
self._url = url
|
| 368 |
+
self._model = model
|
| 369 |
+
|
| 370 |
+
def extract(self, doc: Document, patient_id: str) -> ExtractionResult:
|
| 371 |
+
kvt = l2b_nuextract.extract_facts(doc.text, url=self._url, model=self._model)
|
| 372 |
+
observations = l2b_stage2_lora.to_observations(
|
| 373 |
+
kvt, doc, patient_id, extracted_by="NuExtract3"
|
| 374 |
+
)
|
| 375 |
+
return ExtractionResult(
|
| 376 |
+
observations=observations,
|
| 377 |
+
mean_confidence=STUB_CONFIDENCE if observations else 0.0,
|
| 378 |
+
document_reference=_build_docref(doc, doc.text, patient_id),
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def extract_documents(
|
| 383 |
+
docs: list[Document], patient_id: str, extractor: Extractor | None = None
|
| 384 |
+
) -> ExtractionResult:
|
| 385 |
+
"""Об'єднує результати L2b-екстракції з кількох документів."""
|
| 386 |
+
extractor = extractor if extractor is not None else StubExtractor()
|
| 387 |
+
|
| 388 |
+
observations: list[Observation] = []
|
| 389 |
+
conditions: list[Condition] = []
|
| 390 |
+
medications: list[MedicationRequest] = []
|
| 391 |
+
confidences: list[float] = []
|
| 392 |
+
|
| 393 |
+
for doc in docs:
|
| 394 |
+
result = extractor.extract(doc, patient_id)
|
| 395 |
+
observations.extend(result.observations)
|
| 396 |
+
conditions.extend(result.conditions)
|
| 397 |
+
medications.extend(result.medications)
|
| 398 |
+
confidences.append(result.mean_confidence)
|
| 399 |
+
|
| 400 |
+
mean_confidence = sum(confidences) / len(confidences) if confidences else 1.0
|
| 401 |
+
return ExtractionResult(
|
| 402 |
+
observations=observations,
|
| 403 |
+
conditions=conditions,
|
| 404 |
+
medications=medications,
|
| 405 |
+
mean_confidence=mean_confidence,
|
| 406 |
+
)
|
livemedcard/layers/l2b_nuextract.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""L2b через NuExtract3 (VLM structured extraction) — один рушій, дві іпостасі.
|
| 2 |
+
|
| 3 |
+
NuExtract читає документ (текст/зображення) + JSON-шаблон і повертає структурований
|
| 4 |
+
JSON; окремо вміє image→markdown. Використовуємо двоетапно (за результатами бенчмарку
|
| 5 |
+
найточніше): Stage 1 image→markdown (розширена іпостась), Stage 2 markdown→facts
|
| 6 |
+
(стисла). Шаблон передається через `chat_template_kwargs` (llama-server --jinja / vLLM).
|
| 7 |
+
|
| 8 |
+
Не медична модель → кодування в LOINC робимо мостом (`l2b_stage2_lora`).
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
|
| 14 |
+
from ..config import NUEXTRACT_MODEL, NUEXTRACT_URL
|
| 15 |
+
from ..llm_client import chat_completions
|
| 16 |
+
|
| 17 |
+
# Шаблон KVT-фактів (leaf-значення = типи NuExtract).
|
| 18 |
+
_FACTS_TEMPLATE = {
|
| 19 |
+
"facts": [
|
| 20 |
+
{"keyword": "verbatim-string", "value": "verbatim-string", "timestamp": "verbatim-string"}
|
| 21 |
+
]
|
| 22 |
+
}
|
| 23 |
+
_TEMPLATE_JSON = json.dumps(_FACTS_TEMPLATE, indent=2)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _ctk(**kw) -> dict:
|
| 27 |
+
return {"chat_template_kwargs": {"enable_thinking": False, **kw}}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def image_to_markdown(base64_image: str, *, url: str | None = None, model: str | None = None) -> str:
|
| 31 |
+
"""Stage 1: зображення → markdown (розширена іпостась). mode=markdown."""
|
| 32 |
+
if "," in base64_image:
|
| 33 |
+
base64_image = base64_image.split(",", 1)[1]
|
| 34 |
+
messages = [{
|
| 35 |
+
"role": "user",
|
| 36 |
+
"content": [{"type": "image_url",
|
| 37 |
+
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}],
|
| 38 |
+
}]
|
| 39 |
+
return chat_completions(
|
| 40 |
+
messages, model=model or NUEXTRACT_MODEL, base_url=url or NUEXTRACT_URL,
|
| 41 |
+
temperature=0.0, max_tokens=4096, timeout=180.0,
|
| 42 |
+
service_name="NuExtract", extra_body=_ctk(mode="markdown"),
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def extract_facts(text: str, *, url: str | None = None, model: str | None = None) -> list[dict]:
|
| 47 |
+
"""Stage 2: текст/markdown → KVT-факти за шаблоном. Повертає [{keyword,value,timestamp}]."""
|
| 48 |
+
messages = [{"role": "user", "content": [{"type": "text", "text": text}]}]
|
| 49 |
+
raw = chat_completions(
|
| 50 |
+
messages, model=model or NUEXTRACT_MODEL, base_url=url or NUEXTRACT_URL,
|
| 51 |
+
temperature=0.0, max_tokens=3072, timeout=120.0,
|
| 52 |
+
service_name="NuExtract", extra_body=_ctk(template=_TEMPLATE_JSON),
|
| 53 |
+
)
|
| 54 |
+
try:
|
| 55 |
+
data = json.loads(raw)
|
| 56 |
+
except json.JSONDecodeError:
|
| 57 |
+
start, end = raw.find("{"), raw.rfind("}")
|
| 58 |
+
if start == -1 or end <= start:
|
| 59 |
+
return []
|
| 60 |
+
try:
|
| 61 |
+
data = json.loads(raw[start:end + 1])
|
| 62 |
+
except json.JSONDecodeError:
|
| 63 |
+
return []
|
| 64 |
+
facts = data.get("facts", []) if isinstance(data, dict) else data
|
| 65 |
+
return [f for f in facts if isinstance(f, dict) and f.get("keyword")]
|
livemedcard/layers/l2b_stage1.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""L2b Stage 1 — SGR-стиснення вільного тексту в стабільний summary.
|
| 2 |
+
|
| 3 |
+
Локальна MedGemma читає сирий текст (OCR або введений) і повертає стислий,
|
| 4 |
+
формат-стабільний SGR-summary з 9 доменними ключами. Це **free-text іпостась**:
|
| 5 |
+
згорнута, клінічно структурована, але ще не канонізована. Детермінований Stage 2
|
| 6 |
+
(`l2b_extractor.deterministic_extract`) далі витягує з неї KV-T факти.
|
| 7 |
+
|
| 8 |
+
Метод і промпт-контракт — за статтею автора «LLM StructCore» (Schema-Guided
|
| 9 |
+
Reasoning Condensation): LLM лише стискає (стохастика впливає на recall), а
|
| 10 |
+
формат/словник гарантує детермінований Stage 2. Адаптовано під лонгітюдну
|
| 11 |
+
українську медкартку: без фіксованого CRF, зі збереженням сирих значень і
|
| 12 |
+
**дат на факт**.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import re
|
| 18 |
+
from typing import Iterable
|
| 19 |
+
|
| 20 |
+
from ..config import STAGE1_MODEL
|
| 21 |
+
from ..llm_client import chat_completions
|
| 22 |
+
|
| 23 |
+
STAGE1_KEYS_9: list[str] = [
|
| 24 |
+
"DEMOGRAPHICS",
|
| 25 |
+
"VITALS",
|
| 26 |
+
"LABS",
|
| 27 |
+
"PROBLEMS",
|
| 28 |
+
"SYMPTOMS",
|
| 29 |
+
"MEDICATIONS",
|
| 30 |
+
"PROCEDURES",
|
| 31 |
+
"UTILIZATION",
|
| 32 |
+
"DISPOSITION",
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def build_stage1_system_prompt() -> str:
|
| 37 |
+
keys_str = ", ".join(f'"{k}"' for k in STAGE1_KEYS_9)
|
| 38 |
+
return f"""You are an expert clinical information compression engine for a
|
| 39 |
+
longitudinal medical card. Convert ONE clinical document (in any language,
|
| 40 |
+
often Ukrainian) into a compact, reliable intermediate summary.
|
| 41 |
+
This is Stage 1 only — a downstream deterministic parser will extract structured facts.
|
| 42 |
+
|
| 43 |
+
OUTPUT CONTRACT (STRICT):
|
| 44 |
+
1) Output MUST be a single valid JSON object. The FIRST character MUST be "{{".
|
| 45 |
+
2) Output JSON only: no markdown, no code fences, no extra text.
|
| 46 |
+
3) Do NOT output analysis, chain-of-thought, or "thinking".
|
| 47 |
+
4) Use EXACTLY these keys (no extra, no missing):
|
| 48 |
+
{keys_str}
|
| 49 |
+
|
| 50 |
+
VALUE FORMAT (STRICT):
|
| 51 |
+
- Each value MUST be a string (never a nested object or array).
|
| 52 |
+
- If a cluster has no supported facts: use exactly "not stated".
|
| 53 |
+
- For multiple facts use "\\n" (two characters) between short "Key: Value" lines.
|
| 54 |
+
|
| 55 |
+
CONTENT RULES:
|
| 56 |
+
- Keep the document's original language and the EXACT raw values (numbers, units,
|
| 57 |
+
test names). Do NOT translate values, do NOT round, do NOT infer from absence.
|
| 58 |
+
- Include a fact ONLY if it is explicitly stated. If unsure, omit the line.
|
| 59 |
+
- MEASURED VALUES, NOT SCAFFOLDING: capture the patient's own measured/reported
|
| 60 |
+
values. Do NOT record reference ranges, target thresholds, or chart axis ticks
|
| 61 |
+
(e.g. "Target Range 70-180 mg/dL", "Above 250 mg/dL", axis labels like
|
| 62 |
+
"250 / 180 / 70") as facts — they are scale, not measurements.
|
| 63 |
+
- DATES PER FACT: if a measurement/prescription date is stated in the text, append
|
| 64 |
+
it in parentheses at the END of that line in ISO form: "(YYYY-MM-DD)".
|
| 65 |
+
Example: "креатинін: 120 (2024-03-15)". If no date is stated, omit the parentheses.
|
| 66 |
+
|
| 67 |
+
ANTI-DRIFT (MUST FOLLOW):
|
| 68 |
+
- NEVER repeat the same line. Deduplicate. Max ~12 lines and ~700 chars per field.
|
| 69 |
+
- If you start repeating, STOP and output the JSON object immediately.
|
| 70 |
+
|
| 71 |
+
CLUSTER GUIDANCE (9 clusters):
|
| 72 |
+
- DEMOGRAPHICS: patient context, past medical history, social/baseline.
|
| 73 |
+
- VITALS: BP/HR/RR/Temp/SpO2 and severity cues (raw values).
|
| 74 |
+
- LABS: lab/biomarker values (raw numbers + units).
|
| 75 |
+
- PROBLEMS: diagnoses and clinical problems (acute/chronic).
|
| 76 |
+
- SYMPTOMS: presenting symptoms and course.
|
| 77 |
+
- MEDICATIONS: medications with dose/frequency exactly as written.
|
| 78 |
+
- PROCEDURES: imaging/tests/procedures performed and findings.
|
| 79 |
+
- UTILIZATION: ED events, consults, monitoring, escalation.
|
| 80 |
+
- DISPOSITION: discharge outcome/follow-up if stated (else "not stated").
|
| 81 |
+
|
| 82 |
+
FORMAT SKELETON (structure only; do NOT copy these placeholder values):
|
| 83 |
+
{{
|
| 84 |
+
"DEMOGRAPHICS": "not stated",
|
| 85 |
+
"VITALS": "not stated",
|
| 86 |
+
"LABS": "not stated",
|
| 87 |
+
"PROBLEMS": "not stated",
|
| 88 |
+
"SYMPTOMS": "not stated",
|
| 89 |
+
"MEDICATIONS": "not stated",
|
| 90 |
+
"PROCEDURES": "not stated",
|
| 91 |
+
"UTILIZATION": "not stated",
|
| 92 |
+
"DISPOSITION": "not stated"
|
| 93 |
+
}}
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def build_stage1_user_prompt(note_text: str) -> str:
|
| 98 |
+
return f"Clinical document:\n{note_text}\n\nReturn the Stage 1 JSON now:"
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ── парсинг відповіді моделі ──────────────────────────────────────────────────
|
| 102 |
+
def _strip_thinking_wrappers(text: str) -> str:
|
| 103 |
+
# Gemma-family інколи емітить <unusedNN>thought…-обгортки навколо міркувань.
|
| 104 |
+
s = text.strip()
|
| 105 |
+
s = re.sub(r"<unused\d+>thought.*?(?:<unused\d+>|$)", "", s, flags=re.DOTALL | re.IGNORECASE)
|
| 106 |
+
s = re.sub(r"<unused\d+>", "", s, flags=re.IGNORECASE)
|
| 107 |
+
return s.strip()
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def extract_json_object(text: str) -> dict:
|
| 111 |
+
"""Витягти JSON-обʼєкт із відповіді моделі, з ремонтом обрізаного хвоста.
|
| 112 |
+
|
| 113 |
+
Якщо модель урвала вивід перед фінальною "}" (типово через повтори),
|
| 114 |
+
відкочуємось до найдовшого префікса, що парситься як валідний JSON.
|
| 115 |
+
"""
|
| 116 |
+
s = _strip_thinking_wrappers(text)
|
| 117 |
+
if "```json" in s:
|
| 118 |
+
s = s.split("```json", 1)[1].split("```", 1)[0].strip()
|
| 119 |
+
elif "```" in s:
|
| 120 |
+
s = s.split("```", 1)[1].split("```", 1)[0].strip()
|
| 121 |
+
|
| 122 |
+
if "{" not in s:
|
| 123 |
+
raise ValueError("No JSON object start '{' found.")
|
| 124 |
+
start = s.find("{")
|
| 125 |
+
end = s.rfind("}")
|
| 126 |
+
if end > start:
|
| 127 |
+
return json.loads(s[start : end + 1])
|
| 128 |
+
|
| 129 |
+
cand = s[start:]
|
| 130 |
+
max_backtrack = max(0, len(cand) - 20000) # обмежити роботу на дуже довгих
|
| 131 |
+
for cut in range(len(cand), max_backtrack, -1):
|
| 132 |
+
chunk = cand[:cut].strip()
|
| 133 |
+
if not chunk:
|
| 134 |
+
continue
|
| 135 |
+
chunk2 = chunk if chunk.endswith("}") else chunk + "}"
|
| 136 |
+
try:
|
| 137 |
+
obj = json.loads(chunk2)
|
| 138 |
+
if isinstance(obj, dict):
|
| 139 |
+
return obj
|
| 140 |
+
except Exception:
|
| 141 |
+
continue
|
| 142 |
+
raise ValueError("No JSON object end '}' found.")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def coerce_stage1_summary(obj: dict) -> dict[str, str]:
|
| 146 |
+
"""Зробити Stage 1 вивід безпечним і стабільним: усі 9 ключів, лише рядки.
|
| 147 |
+
|
| 148 |
+
Відсутні ключі → "not stated"; зайві відкидаються; вкладені dict/list
|
| 149 |
+
сплощуються у "Key: Value" рядки (контракт «лише рядки»).
|
| 150 |
+
"""
|
| 151 |
+
def _flatten(v: object) -> str:
|
| 152 |
+
if v is None:
|
| 153 |
+
return "not stated"
|
| 154 |
+
if isinstance(v, str):
|
| 155 |
+
s = v.strip()
|
| 156 |
+
return s or "not stated"
|
| 157 |
+
if isinstance(v, dict):
|
| 158 |
+
lines: list[str] = []
|
| 159 |
+
for kk, vv in v.items():
|
| 160 |
+
kks = str(kk).strip()
|
| 161 |
+
if not kks or vv is None:
|
| 162 |
+
continue
|
| 163 |
+
if isinstance(vv, (dict, list)):
|
| 164 |
+
inner = ", ".join(
|
| 165 |
+
str(x).strip()
|
| 166 |
+
for x in (vv.values() if isinstance(vv, dict) else vv)
|
| 167 |
+
if str(x).strip()
|
| 168 |
+
)
|
| 169 |
+
if inner:
|
| 170 |
+
lines.append(f"{kks}: {inner}")
|
| 171 |
+
continue
|
| 172 |
+
vvs = str(vv).strip()
|
| 173 |
+
if vvs:
|
| 174 |
+
lines.append(f"{kks}: {vvs}")
|
| 175 |
+
return "\n".join(lines) if lines else "not stated"
|
| 176 |
+
if isinstance(v, list):
|
| 177 |
+
lines = [str(x).strip() for x in v if str(x).strip()]
|
| 178 |
+
return "\n".join(lines) if lines else "not stated"
|
| 179 |
+
s = str(v).strip()
|
| 180 |
+
return s or "not stated"
|
| 181 |
+
|
| 182 |
+
return {k: _flatten(obj.get(k, "not stated")) for k in STAGE1_KEYS_9}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def stage1_summary_to_text(summary: dict[str, str], keys: Iterable[str] | None = None) -> str:
|
| 186 |
+
"""Компактний стабільний текстовий вигляд summary для Stage 2 та зберігання."""
|
| 187 |
+
parts: list[str] = []
|
| 188 |
+
for k in keys or STAGE1_KEYS_9:
|
| 189 |
+
parts.append(f"## {k}")
|
| 190 |
+
parts.append(summary.get(k, "not stated"))
|
| 191 |
+
return "\n".join(parts) + "\n"
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def summarize(
|
| 195 |
+
note_text: str, *, model: str | None = None, base_url: str | None = None
|
| 196 |
+
) -> str:
|
| 197 |
+
"""Stage 1: сирий текст → SGR-summary (плаский текст із заголовками ## KEY).
|
| 198 |
+
|
| 199 |
+
Викликає локальну MedGemma; ремонтує/нормалізує JSON; повертає текст, готовий
|
| 200 |
+
і для зберігання (DocumentReference), і для Stage 2. `base_url` — окремий
|
| 201 |
+
Stage 1-сервер (двосерверний LoRA-сетап).
|
| 202 |
+
"""
|
| 203 |
+
messages = [
|
| 204 |
+
{"role": "system", "content": build_stage1_system_prompt()},
|
| 205 |
+
{"role": "user", "content": build_stage1_user_prompt(note_text)},
|
| 206 |
+
]
|
| 207 |
+
raw = chat_completions(
|
| 208 |
+
messages, model=model or STAGE1_MODEL, timeout=120.0, base_url=base_url
|
| 209 |
+
)
|
| 210 |
+
summary = coerce_stage1_summary(extract_json_object(raw))
|
| 211 |
+
return stage1_summary_to_text(summary)
|
livemedcard/layers/l2b_stage2_lora.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""L2b Stage 2 (LoRA) — fine-tuned MedGemma генерує KVT4 факти.
|
| 2 |
+
|
| 3 |
+
Альтернатива детермінованому `deterministic_extract`: замість regex+vocab.json
|
| 4 |
+
тут працює FT-модель DocUA/medgemma-1.5-4b-it-...-two-stage (база+LoRA-адаптер),
|
| 5 |
+
натренована видавати KVT4 — `{cluster, keyword, value, timestamp}`. Знімає
|
| 6 |
+
обмеження словника, узагальнює на будь-який документ.
|
| 7 |
+
|
| 8 |
+
Межа безпеки збережена: LoRA лише **витягує факти**; сигнали L1/L2a рахуються
|
| 9 |
+
детерміновано на FactStore. Часті лабораторні keywords мостяться у LOINC (щоб
|
| 10 |
+
потрапляли в таймлайн і перевірки), решта лишаються free-form у summary.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import hashlib
|
| 15 |
+
import json
|
| 16 |
+
import re
|
| 17 |
+
from datetime import datetime, timezone
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
from ..config import STAGE2_MODEL, STAGE2_URL
|
| 21 |
+
from ..dto import Document
|
| 22 |
+
from ..fhir import (
|
| 23 |
+
CodeableConcept,
|
| 24 |
+
Coding,
|
| 25 |
+
Observation,
|
| 26 |
+
Provenance,
|
| 27 |
+
Quantity,
|
| 28 |
+
Reference,
|
| 29 |
+
Sys,
|
| 30 |
+
)
|
| 31 |
+
from ..llm_client import chat_completions
|
| 32 |
+
|
| 33 |
+
# ── keyword → LOINC міст (часті лаби readmission-домену) ──────────────────────
|
| 34 |
+
# Значення: (LOINC, людська назва). Одиниця береться з value LoRA (fallback — тут).
|
| 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"),
|
| 44 |
+
"натрій": ("2951-2", "Sodium"),
|
| 45 |
+
"hemoglobin": ("718-7", "Hemoglobin"),
|
| 46 |
+
"гемоглобін": ("718-7", "Hemoglobin"),
|
| 47 |
+
"hematocrit": ("4544-3", "Hematocrit"),
|
| 48 |
+
"гематокрит": ("4544-3", "Hematocrit"),
|
| 49 |
+
"wbc": ("6690-2", "Leukocytes"),
|
| 50 |
+
"leukocytes": ("6690-2", "Leukocytes"),
|
| 51 |
+
"лейкоцити": ("6690-2", "Leukocytes"),
|
| 52 |
+
"platelet": ("777-3", "Platelets"),
|
| 53 |
+
"тромбоцити": ("777-3", "Platelets"),
|
| 54 |
+
"bun": ("3094-0", "Urea nitrogen"),
|
| 55 |
+
"сечовина": ("3094-0", "Urea nitrogen"),
|
| 56 |
+
"bicarbonate": ("1963-8", "Bicarbonate"),
|
| 57 |
+
"бікарбонат": ("1963-8", "Bicarbonate"),
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
# CGM/AGP-метрики → стандартні LOINC CGM-панелі. Substring-матчинг, бо NuExtract
|
| 61 |
+
# дає варіативні формулювання («Glucose Management Indicator (GMI)», «CGM Average
|
| 62 |
+
# Glucose»). Даємо ЛИШЕ headline-коди, в яких упевнені; TIR-біни / variability /
|
| 63 |
+
# active-time свідомо НЕ мапимо (коди треба звірити з авторитетним LOINC —
|
| 64 |
+
# фабрикувати клінічні коди небезпечно). Порядок — специфічніше раніше.
|
| 65 |
+
# ⚠ Перед клінічним використанням підтвердити ці коди за офіційним LOINC.
|
| 66 |
+
_CGM_LOINC: list[tuple[tuple[str, ...], str, str]] = [
|
| 67 |
+
(("glucose management indicator", "gmi"), "97506-0", "Glucose Management Indicator"),
|
| 68 |
+
(("average glucose", "mean glucose"), "97507-8", "Mean glucose (CGM)"),
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _resolve_loinc(keyword: str) -> tuple[str, str] | None:
|
| 73 |
+
"""LOINC для keyword: точний збіг (лаби) → substring (CGM) → None (free-form)."""
|
| 74 |
+
low = keyword.lower()
|
| 75 |
+
exact = _KEYWORD_LOINC.get(low)
|
| 76 |
+
if exact is not None:
|
| 77 |
+
return exact
|
| 78 |
+
for toks, code, display in _CGM_LOINC:
|
| 79 |
+
if any(t in low for t in toks):
|
| 80 |
+
return code, display
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
_THOUGHT_TOKEN_RE = re.compile(r"<unused\d+>", re.IGNORECASE)
|
| 84 |
+
_VALUE_NUM_RE = re.compile(r"^\s*(-?\d+(?:[.,]\d+)?)\s*(.*)$")
|
| 85 |
+
_TS_DATE_FORMATS = ("%Y-%m-%d", "%d.%m.%Y", "%m/%d/%Y", "%B %d, %Y", "%b %d, %Y")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _stable_id(*parts: object) -> str:
|
| 89 |
+
key = "|".join(str(p) for p in parts)
|
| 90 |
+
return f"obs-{hashlib.sha1(key.encode('utf-8')).hexdigest()[:12]}"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def build_stage2_system_prompt() -> str:
|
| 94 |
+
return (
|
| 95 |
+
"You extract clinical facts from a structured Stage-1 summary. "
|
| 96 |
+
"Output ONLY a JSON array of facts, each an object with keys "
|
| 97 |
+
'"cluster", "keyword", "value", "timestamp". '
|
| 98 |
+
"Use the patient's own measured values (numbers with units where present). "
|
| 99 |
+
"If a date/period is stated for a fact, put it in \"timestamp\", else use "
|
| 100 |
+
'"not stated". Do NOT invent facts. Output JSON only, no prose.'
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _extract_json_array(text: str) -> list[dict]:
|
| 105 |
+
"""Витягти JSON-масив фактів із відповіді моделі (стрип thought-токенів)."""
|
| 106 |
+
s = _THOUGHT_TOKEN_RE.sub("", text).strip()
|
| 107 |
+
if "```json" in s:
|
| 108 |
+
s = s.split("```json", 1)[1].split("```", 1)[0].strip()
|
| 109 |
+
elif "```" in s:
|
| 110 |
+
s = s.split("```", 1)[1].split("```", 1)[0].strip()
|
| 111 |
+
start, end = s.find("["), s.rfind("]")
|
| 112 |
+
if start == -1 or end <= start:
|
| 113 |
+
return []
|
| 114 |
+
try:
|
| 115 |
+
data = json.loads(s[start : end + 1])
|
| 116 |
+
except json.JSONDecodeError:
|
| 117 |
+
return []
|
| 118 |
+
return [d for d in data if isinstance(d, dict)]
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _split_value(value: object) -> tuple[Optional[float], Optional[str]]:
|
| 122 |
+
"""'204 mg/dL' → (204.0, 'mg/dL'); '34%' → (34.0, '%'); 'Yes' → (None, None)."""
|
| 123 |
+
m = _VALUE_NUM_RE.match(str(value))
|
| 124 |
+
if m is None:
|
| 125 |
+
return None, None
|
| 126 |
+
num = float(m.group(1).replace(",", "."))
|
| 127 |
+
unit = m.group(2).strip() or None
|
| 128 |
+
return num, unit
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _parse_timestamp(ts: object, fallback: Optional[datetime]) -> Optional[datetime]:
|
| 132 |
+
"""Дата факту з різних форматів; для періоду «A - B» береться КІНЕЦЬ (B)."""
|
| 133 |
+
s = str(ts).strip()
|
| 134 |
+
if not s or s.lower() == "not stated":
|
| 135 |
+
return fallback
|
| 136 |
+
for sep in (" - ", " – ", " — ", " to "):
|
| 137 |
+
if sep in s:
|
| 138 |
+
s = s.split(sep)[-1].strip()
|
| 139 |
+
break
|
| 140 |
+
for fmt in _TS_DATE_FORMATS:
|
| 141 |
+
try:
|
| 142 |
+
return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
|
| 143 |
+
except ValueError:
|
| 144 |
+
continue
|
| 145 |
+
return fallback
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def extract_facts(summary_text: str, *, url: str | None = None, model: str | None = None) -> list[dict]:
|
| 149 |
+
"""Викликати LoRA-Stage 2 сервер і повернути сирі KVT4-обʼєкти."""
|
| 150 |
+
messages = [
|
| 151 |
+
{"role": "system", "content": build_stage2_system_prompt()},
|
| 152 |
+
{"role": "user", "content": summary_text},
|
| 153 |
+
]
|
| 154 |
+
raw = chat_completions(
|
| 155 |
+
messages,
|
| 156 |
+
model=model or STAGE2_MODEL,
|
| 157 |
+
base_url=url or STAGE2_URL,
|
| 158 |
+
temperature=0.0,
|
| 159 |
+
max_tokens=768,
|
| 160 |
+
frequency_penalty=0.1, # ≈ repetition-penalty 1.1 з README
|
| 161 |
+
service_name="LoRA Stage 2",
|
| 162 |
+
)
|
| 163 |
+
return _extract_json_array(raw)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def to_observations(
|
| 167 |
+
kvt: list[dict], doc: Document, patient_id: str, *, extracted_by: str
|
| 168 |
+
) -> list[Observation]:
|
| 169 |
+
"""KVT4 → FHIR Observations (лише числові; часті лаби мостяться у LOINC)."""
|
| 170 |
+
subject = Reference(reference=f"Patient/{patient_id}")
|
| 171 |
+
observations: list[Observation] = []
|
| 172 |
+
seen: set[str] = set()
|
| 173 |
+
for item in kvt:
|
| 174 |
+
keyword = str(item.get("keyword", "")).strip()
|
| 175 |
+
if not keyword:
|
| 176 |
+
continue
|
| 177 |
+
num, unit = _split_value(item.get("value"))
|
| 178 |
+
if num is None: # нечислові (Yes/No, назви) лишаються лише в summary
|
| 179 |
+
continue
|
| 180 |
+
eff = _parse_timestamp(item.get("timestamp"), doc.received_at)
|
| 181 |
+
bridge = _resolve_loinc(keyword)
|
| 182 |
+
coding = (
|
| 183 |
+
[Coding(system=Sys.LOINC, code=bridge[0], display=bridge[1])]
|
| 184 |
+
if bridge else []
|
| 185 |
+
)
|
| 186 |
+
obs_id = _stable_id(doc.id, keyword.lower(), num, eff)
|
| 187 |
+
if obs_id in seen:
|
| 188 |
+
continue
|
| 189 |
+
seen.add(obs_id)
|
| 190 |
+
observations.append(
|
| 191 |
+
Observation(
|
| 192 |
+
id=obs_id,
|
| 193 |
+
status="final",
|
| 194 |
+
code=CodeableConcept(coding=coding, text=keyword),
|
| 195 |
+
subject=subject,
|
| 196 |
+
effectiveDateTime=eff,
|
| 197 |
+
valueQuantity=Quantity(value=num, unit=unit),
|
| 198 |
+
provenance=Provenance(
|
| 199 |
+
source_doc_id=doc.id, extracted_by=extracted_by, confidence=None
|
| 200 |
+
),
|
| 201 |
+
)
|
| 202 |
+
)
|
| 203 |
+
return observations
|
livemedcard/layers/l3_router.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Шар TRACE L3: stateful політика роутингу/ескалації.
|
| 2 |
+
|
| 3 |
+
Два незалежні приводи ескалації до людини (L4):
|
| 4 |
+
|
| 5 |
+
1. **Детермінований сигнал високого ризику** (major/critical з L1/L2a). Такі
|
| 6 |
+
сигнали самі по собі достовірні (confidence = 1.0), тому їхня передача людині
|
| 7 |
+
НЕ залежить від впевненості текстового витягу L2b — реальна небезпека завжди
|
| 8 |
+
потребує рішення людини.
|
| 9 |
+
2. **Низька впевненість витягу L2b** — документ погано розпізнано, факти
|
| 10 |
+
ненадійні, тому потрібна перевірка людиною навіть без сигналів.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
from ..config import LOW_CONFIDENCE
|
| 17 |
+
from ..dto import Decision, Severity, Signal, severity_rank
|
| 18 |
+
|
| 19 |
+
__all__ = ["route", "LOW_CONFIDENCE"]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def route(signals: list[Signal], extraction_confidence: float, doc_kind: str) -> Decision:
|
| 23 |
+
"""Приймає рішення про маршрут документа: "auto" чи "L4_human"."""
|
| 24 |
+
top: Optional[Signal] = max(
|
| 25 |
+
signals, key=lambda s: severity_rank(s.severity), default=None
|
| 26 |
+
)
|
| 27 |
+
high_risk = top is not None and severity_rank(top.severity) >= severity_rank(
|
| 28 |
+
_MAJOR
|
| 29 |
+
)
|
| 30 |
+
low_confidence = extraction_confidence < LOW_CONFIDENCE
|
| 31 |
+
escalate = high_risk or low_confidence
|
| 32 |
+
|
| 33 |
+
reasons: list[str] = []
|
| 34 |
+
if high_risk and top is not None:
|
| 35 |
+
reasons.append(
|
| 36 |
+
f"Детермінований сигнал рівня «{top.severity}» ({top.kind}) — "
|
| 37 |
+
f"рішення за людиною незалежно від впевненості витягу"
|
| 38 |
+
)
|
| 39 |
+
if low_confidence:
|
| 40 |
+
reasons.append(
|
| 41 |
+
f"Низька впевненість витягу ({extraction_confidence:.2f}) — "
|
| 42 |
+
f"потрібна перевірка людиною"
|
| 43 |
+
)
|
| 44 |
+
if not escalate:
|
| 45 |
+
reasons.append(
|
| 46 |
+
"Немає сигналів високого ризику, витяг впевнений — автоматична обробка"
|
| 47 |
+
)
|
| 48 |
+
reasons.append(f"Тип документа: {doc_kind}")
|
| 49 |
+
|
| 50 |
+
return Decision(
|
| 51 |
+
route_to="L4_human" if escalate else "auto",
|
| 52 |
+
escalate=escalate,
|
| 53 |
+
reasons=reasons,
|
| 54 |
+
signals=signals,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
_MAJOR: Severity = "major"
|
livemedcard/llm_client.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Спільний клієнт до локального OpenAI-сумісного сервера (LM Studio, llama.cpp).
|
| 2 |
+
|
| 3 |
+
Один канал доступу до on-device MedGemma для обох споживачів: OCR (`ocr.py`) і
|
| 4 |
+
Stage 1 SGR-стиснення (`layers/l2b_stage1.py`). Використовує лише stdlib
|
| 5 |
+
(`urllib`), щоб тести могли мокати `urllib.request.urlopen` так само, як для OCR.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import urllib.error
|
| 11 |
+
import urllib.request
|
| 12 |
+
|
| 13 |
+
from .config import LMSTUDIO_URL, OCR_MODEL
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class LLMError(Exception):
|
| 17 |
+
"""Помилка виклику локальної LLM (недоступний сервер, порожня відповідь тощо)."""
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def detect_model(base_url: str | None = None) -> str:
|
| 21 |
+
"""Назва завантаженої моделі з `/models`; fallback на `OCR_MODEL`."""
|
| 22 |
+
url = f"{(base_url or LMSTUDIO_URL).rstrip('/')}/models"
|
| 23 |
+
try:
|
| 24 |
+
req = urllib.request.Request(url, method="GET")
|
| 25 |
+
with urllib.request.urlopen(req, timeout=5.0) as response:
|
| 26 |
+
data = json.loads(response.read().decode("utf-8"))
|
| 27 |
+
if data.get("data"):
|
| 28 |
+
model_id = data["data"][0].get("id")
|
| 29 |
+
if model_id:
|
| 30 |
+
return model_id
|
| 31 |
+
except Exception:
|
| 32 |
+
pass
|
| 33 |
+
return OCR_MODEL
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def chat_completions(
|
| 37 |
+
messages: list[dict],
|
| 38 |
+
*,
|
| 39 |
+
model: str | None = None,
|
| 40 |
+
temperature: float = 0.0,
|
| 41 |
+
max_tokens: int = 2048,
|
| 42 |
+
timeout: float = 120.0,
|
| 43 |
+
service_name: str = "локальною LLM",
|
| 44 |
+
frequency_penalty: float | None = None,
|
| 45 |
+
presence_penalty: float | None = None,
|
| 46 |
+
base_url: str | None = None,
|
| 47 |
+
extra_body: dict | None = None,
|
| 48 |
+
) -> str:
|
| 49 |
+
"""Викликати `/chat/completions` і повернути текст першого choice.
|
| 50 |
+
|
| 51 |
+
`messages` — у форматі OpenAI (роль + content; content може бути рядком або
|
| 52 |
+
списком частин для vision). `model=None` → автовизначення через `detect_model()`.
|
| 53 |
+
`service_name` — назва сервісу у повідомленні про помилку з'єднання (напр.
|
| 54 |
+
«LM Studio» для OCR). `frequency_penalty`/`presence_penalty` (коли задані) —
|
| 55 |
+
придушення повторів (напр. зациклення на підписах осей при OCR інфографіки).
|
| 56 |
+
`base_url` — override ендпойнта (напр. окремий llama.cpp-сервер LoRA-Stage 2).
|
| 57 |
+
"""
|
| 58 |
+
endpoint = base_url or LMSTUDIO_URL
|
| 59 |
+
model_name = model or detect_model(endpoint)
|
| 60 |
+
url = f"{endpoint.rstrip('/')}/chat/completions"
|
| 61 |
+
payload = {
|
| 62 |
+
"model": model_name,
|
| 63 |
+
"messages": messages,
|
| 64 |
+
"temperature": temperature,
|
| 65 |
+
"max_tokens": max_tokens,
|
| 66 |
+
"stream": False,
|
| 67 |
+
}
|
| 68 |
+
if frequency_penalty is not None:
|
| 69 |
+
payload["frequency_penalty"] = frequency_penalty
|
| 70 |
+
if presence_penalty is not None:
|
| 71 |
+
payload["presence_penalty"] = presence_penalty
|
| 72 |
+
if extra_body: # напр. NuExtract chat_template_kwargs (template / mode)
|
| 73 |
+
payload.update(extra_body)
|
| 74 |
+
req = urllib.request.Request(
|
| 75 |
+
url,
|
| 76 |
+
data=json.dumps(payload).encode("utf-8"),
|
| 77 |
+
headers={"Content-Type": "application/json"},
|
| 78 |
+
method="POST",
|
| 79 |
+
)
|
| 80 |
+
try:
|
| 81 |
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
| 82 |
+
res_data = json.loads(response.read().decode("utf-8"))
|
| 83 |
+
except urllib.error.URLError as exc:
|
| 84 |
+
raise LLMError(
|
| 85 |
+
f"Не вдалося з'єднатися з {service_name} за адресою {endpoint}. "
|
| 86 |
+
f"Переконайтеся, що сервер запущено та завантажено модель. "
|
| 87 |
+
f"Помилка: {exc.reason if hasattr(exc, 'reason') else exc}"
|
| 88 |
+
) from exc
|
| 89 |
+
except Exception as exc:
|
| 90 |
+
raise LLMError(f"Помилка виклику локальної LLM: {exc}") from exc
|
| 91 |
+
|
| 92 |
+
choices = res_data.get("choices", [])
|
| 93 |
+
if not choices:
|
| 94 |
+
raise LLMError("LLM повернула порожню відповідь (choices порожній).")
|
| 95 |
+
content = choices[0].get("message", {}).get("content", "")
|
| 96 |
+
if not content:
|
| 97 |
+
raise LLMError("У відповіді моделі відсутній вміст (content).")
|
| 98 |
+
return content.strip()
|
livemedcard/ocr.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OCR Service Layer.
|
| 2 |
+
|
| 3 |
+
Uses local LM Studio API (with medgemma-1.5-4b-it or other loaded vision model)
|
| 4 |
+
to perform OCR on medical documents using base64 image data. HTTP-виклик
|
| 5 |
+
делеговано спільному `llm_client`; тут лишається лише vision-специфіка (формат
|
| 6 |
+
повідомлення із зображенням і промпт транскрипції).
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from .config import OCR_MODEL
|
| 11 |
+
from .llm_client import LLMError, chat_completions, detect_model
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class OcrError(Exception):
|
| 15 |
+
"""Custom exception for OCR failures."""
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
_OCR_PROMPT = (
|
| 20 |
+
"You transcribe a medical document image into plain text. Follow these rules:\n"
|
| 21 |
+
"1. LANGUAGE: keep the original language. Do NOT translate. No comments, "
|
| 22 |
+
"headings, or explanations — output only the transcription.\n"
|
| 23 |
+
"2. VALUES ARE CRITICAL: transcribe every number, unit and date exactly "
|
| 24 |
+
"(e.g. '204 mg/dL', '8.2%', '06/13/2025'). Never omit a value.\n"
|
| 25 |
+
"3. PAIR LABELS WITH THEIR VALUES: this may be a multi-column report where a "
|
| 26 |
+
"metric name and its value are separated horizontally or placed in different "
|
| 27 |
+
"columns. Read the layout and write each on one line as 'Label: value unit' "
|
| 28 |
+
"(e.g. 'Average Glucose: 204 mg/dL', 'Time CGM Active: 34%').\n"
|
| 29 |
+
"4. CHARTS: for plots/graphs, transcribe axis titles and the legend ONCE. Do "
|
| 30 |
+
"NOT repeat tick labels or time-axis labels (e.g. '12am 3am 6am'); write such a "
|
| 31 |
+
"sequence at most once. Never loop or repeat a line.\n"
|
| 32 |
+
"5. If a value is unreadable, write the label with '[unreadable]' — do NOT "
|
| 33 |
+
"invent a number.\n"
|
| 34 |
+
"Output the transcription now."
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class OcrService:
|
| 39 |
+
"""Service to handle OCR operations with local LLM server."""
|
| 40 |
+
|
| 41 |
+
@staticmethod
|
| 42 |
+
def detect_model() -> str:
|
| 43 |
+
"""Query LM Studio to detect loaded model name, fallback to config."""
|
| 44 |
+
return detect_model()
|
| 45 |
+
|
| 46 |
+
@classmethod
|
| 47 |
+
def perform_ocr(cls, base64_image: str) -> str:
|
| 48 |
+
"""Send base64 image to local vision LLM for transcription."""
|
| 49 |
+
# Strip potential data URL prefix, e.g. "data:image/png;base64,"
|
| 50 |
+
if "," in base64_image:
|
| 51 |
+
base64_image = base64_image.split(",", 1)[1]
|
| 52 |
+
|
| 53 |
+
messages = [
|
| 54 |
+
{
|
| 55 |
+
"role": "user",
|
| 56 |
+
"content": [
|
| 57 |
+
{"type": "text", "text": _OCR_PROMPT},
|
| 58 |
+
{
|
| 59 |
+
"type": "image_url",
|
| 60 |
+
"image_url": {
|
| 61 |
+
"url": f"data:image/jpeg;base64,{base64_image}"
|
| 62 |
+
},
|
| 63 |
+
},
|
| 64 |
+
],
|
| 65 |
+
}
|
| 66 |
+
]
|
| 67 |
+
try:
|
| 68 |
+
return chat_completions(
|
| 69 |
+
messages,
|
| 70 |
+
# OCR мусить цілитись у КОНКРЕТНУ vision-модель (OCR_MODEL), а не в
|
| 71 |
+
# «першу завантажену»: інакше text-only модель (напр. 27B) перехопить
|
| 72 |
+
# запит і поверне 500 на зображення. Auto-detect тут недоречний.
|
| 73 |
+
model=OCR_MODEL,
|
| 74 |
+
max_tokens=4096, # щільні звіти не влазять у 2048
|
| 75 |
+
timeout=180.0, # vision inference is slow
|
| 76 |
+
service_name="LM Studio",
|
| 77 |
+
frequency_penalty=0.8, # проти зациклення на підписах осей
|
| 78 |
+
)
|
| 79 |
+
except LLMError as exc:
|
| 80 |
+
raise OcrError(str(exc)) from exc
|
livemedcard/pipeline.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Оркестрація конвеєра TRACE: документ → витяг → сховища → сигнали → рішення.
|
| 2 |
+
|
| 3 |
+
Головний інваріант: сигнали безпеки (L1/L2a) рахуються детерміновано на факт-сторі,
|
| 4 |
+
а L2b (екстрактор) лише витягує факти й не робить висновків про безпеку.
|
| 5 |
+
|
| 6 |
+
`LiveMedCard` — джерело істини для FHIR-ресурсів пацієнта; `FactStore` і
|
| 7 |
+
`KnowledgeGraph` — похідні проєкції над ними. Це дає знімок стану (snapshot),
|
| 8 |
+
експорт у FHIR Bundle і аудит рішень.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from .dto import AuditEntry, Document, ExtractionResult, Signal, TraceReport
|
| 16 |
+
from .fhir import (
|
| 17 |
+
Condition,
|
| 18 |
+
DocumentReference,
|
| 19 |
+
MedicationRequest,
|
| 20 |
+
Observation,
|
| 21 |
+
Patient,
|
| 22 |
+
)
|
| 23 |
+
from .layers import l1_safety, l2a_trends, l3_router
|
| 24 |
+
from .layers.l2b_extractor import Extractor, StubExtractor
|
| 25 |
+
from .stores import FactStore, KnowledgeGraph
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class LiveMedCard:
|
| 29 |
+
"""Стан однієї медичної картки пацієнта + прогін документів крізь TRACE."""
|
| 30 |
+
|
| 31 |
+
def __init__(self, patient: Patient, extractor: Optional[Extractor] = None) -> None:
|
| 32 |
+
self.patient = patient
|
| 33 |
+
self.extractor: Extractor = extractor or StubExtractor()
|
| 34 |
+
self.factstore = FactStore()
|
| 35 |
+
self.graph = KnowledgeGraph()
|
| 36 |
+
self.graph.add_patient(patient)
|
| 37 |
+
# Джерело істини — утримувані FHIR-ресурси (дедуплікація за id).
|
| 38 |
+
self._observations: list[Observation] = []
|
| 39 |
+
self._conditions: list[Condition] = []
|
| 40 |
+
self._medications: list[MedicationRequest] = []
|
| 41 |
+
self._doc_refs: list[DocumentReference] = []
|
| 42 |
+
self._obs_ids: set[str] = set()
|
| 43 |
+
self._cond_ids: set[str] = set()
|
| 44 |
+
self._med_ids: set[str] = set()
|
| 45 |
+
self._docref_ids: set[str] = set()
|
| 46 |
+
self._document_ids: set[str] = set()
|
| 47 |
+
self._audit: list[AuditEntry] = []
|
| 48 |
+
|
| 49 |
+
# ── L2b → сховища ───────────────────────────────────────────────────────
|
| 50 |
+
def _absorb(self, ext: ExtractionResult) -> None:
|
| 51 |
+
for o in ext.observations:
|
| 52 |
+
self.factstore.add_observation(o)
|
| 53 |
+
self.graph.add_observation(o, self.patient.id)
|
| 54 |
+
if o.id not in self._obs_ids:
|
| 55 |
+
self._observations.append(o)
|
| 56 |
+
self._obs_ids.add(o.id)
|
| 57 |
+
for c in ext.conditions:
|
| 58 |
+
self.graph.add_condition(c, self.patient.id)
|
| 59 |
+
if c.id not in self._cond_ids:
|
| 60 |
+
self._conditions.append(c)
|
| 61 |
+
self._cond_ids.add(c.id)
|
| 62 |
+
for m in ext.medications:
|
| 63 |
+
self.graph.add_medication(m, self.patient.id)
|
| 64 |
+
if m.id not in self._med_ids:
|
| 65 |
+
self._medications.append(m)
|
| 66 |
+
self._med_ids.add(m.id)
|
| 67 |
+
if ext.document_reference is not None:
|
| 68 |
+
self._absorb_docref(ext.document_reference)
|
| 69 |
+
|
| 70 |
+
def _absorb_docref(self, dref: DocumentReference) -> None:
|
| 71 |
+
"""Зберегти free-text іпостась + додати текст у вузол-документ графа."""
|
| 72 |
+
if dref.provenance is not None:
|
| 73 |
+
self.graph.add_document(
|
| 74 |
+
dref.provenance.source_doc_id, dref.content.data or ""
|
| 75 |
+
)
|
| 76 |
+
if dref.id not in self._docref_ids:
|
| 77 |
+
self._doc_refs.append(dref)
|
| 78 |
+
self._docref_ids.add(dref.id)
|
| 79 |
+
|
| 80 |
+
# ── L1 + L2a на поточному стані сховищ ────────────────────────────────────
|
| 81 |
+
def current_signals(self) -> list[Signal]:
|
| 82 |
+
"""Детерміновані сигнали над усім накопиченим станом."""
|
| 83 |
+
return l1_safety.run(self._medications) + l2a_trends.run(
|
| 84 |
+
self.factstore, self.patient.gender
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
def ingest(self, doc: Document) -> TraceReport:
|
| 88 |
+
"""Прогнати один документ і повернути повний TRACE-звіт поточного стану."""
|
| 89 |
+
# Без мітки часу обзервації не потрапляють у факт-стор і зникають з
|
| 90 |
+
# таймлайну (і з перевірок безпеки). Проставляємо час прийому документа.
|
| 91 |
+
if doc.received_at is None:
|
| 92 |
+
doc = doc.model_copy(update={"received_at": datetime.now(timezone.utc)})
|
| 93 |
+
self._document_ids.add(doc.id)
|
| 94 |
+
ext = self.extractor.extract(doc, self.patient.id)
|
| 95 |
+
self._absorb(ext)
|
| 96 |
+
signals = self.current_signals()
|
| 97 |
+
decision = l3_router.route(signals, ext.mean_confidence, doc.kind)
|
| 98 |
+
self._record_audit(doc, ext, signals, decision)
|
| 99 |
+
return TraceReport(
|
| 100 |
+
document_id=doc.id, extraction=ext, signals=signals, decision=decision
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
def ingest_all(self, docs: list[Document]) -> list[TraceReport]:
|
| 104 |
+
return [self.ingest(d) for d in docs]
|
| 105 |
+
|
| 106 |
+
# ── аудит рішень L3 ───────────────────────────────────────────────────────
|
| 107 |
+
def _record_audit(self, doc, ext, signals, decision) -> None:
|
| 108 |
+
self._audit.append(AuditEntry(
|
| 109 |
+
ts=datetime.now(timezone.utc),
|
| 110 |
+
document_id=doc.id,
|
| 111 |
+
kind=doc.kind,
|
| 112 |
+
extraction_confidence=ext.mean_confidence,
|
| 113 |
+
n_signals=len(signals),
|
| 114 |
+
route_to=decision.route_to,
|
| 115 |
+
escalate=decision.escalate,
|
| 116 |
+
))
|
| 117 |
+
|
| 118 |
+
def audit_log(self) -> list[dict]:
|
| 119 |
+
"""Журнал рішень L3 як JSON-сумісні dict (для API/метрик)."""
|
| 120 |
+
return [a.model_dump(mode="json") for a in self._audit]
|
| 121 |
+
|
| 122 |
+
# ── знімок / відновлення (персистентність, FHIR Bundle) ───────────────────
|
| 123 |
+
def snapshot(self) -> dict:
|
| 124 |
+
"""Серіалізований стан картки (FHIR-ресурси + метадані)."""
|
| 125 |
+
return {
|
| 126 |
+
"patient": self.patient.model_dump(mode="json", by_alias=True),
|
| 127 |
+
"observations": [o.model_dump(mode="json") for o in self._observations],
|
| 128 |
+
"conditions": [c.model_dump(mode="json") for c in self._conditions],
|
| 129 |
+
"medications": [m.model_dump(mode="json") for m in self._medications],
|
| 130 |
+
"document_references": [
|
| 131 |
+
d.model_dump(mode="json") for d in self._doc_refs
|
| 132 |
+
],
|
| 133 |
+
"documents": sorted(self._document_ids),
|
| 134 |
+
"audit": [a.model_dump(mode="json") for a in self._audit],
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
@classmethod
|
| 138 |
+
def from_snapshot(
|
| 139 |
+
cls, data: dict, extractor: Optional[Extractor] = None
|
| 140 |
+
) -> "LiveMedCard":
|
| 141 |
+
"""Відновити картку зі знімка (`snapshot`)."""
|
| 142 |
+
card = cls(Patient(**data["patient"]), extractor)
|
| 143 |
+
card._absorb(ExtractionResult(
|
| 144 |
+
observations=[Observation(**o) for o in data.get("observations", [])],
|
| 145 |
+
conditions=[Condition(**c) for c in data.get("conditions", [])],
|
| 146 |
+
medications=[MedicationRequest(**m) for m in data.get("medications", [])],
|
| 147 |
+
))
|
| 148 |
+
for d in data.get("document_references", []):
|
| 149 |
+
card._absorb_docref(DocumentReference(**d))
|
| 150 |
+
card._document_ids.update(data.get("documents", []))
|
| 151 |
+
# Валідація журналу при відновленні: биті записи → явна помилка, не сміття.
|
| 152 |
+
card._audit.extend(AuditEntry(**a) for a in data.get("audit", []))
|
| 153 |
+
return card
|
| 154 |
+
|
| 155 |
+
# ── зручні акселератори для API/Q&A ───────────────────────────────────────
|
| 156 |
+
@property
|
| 157 |
+
def observations(self) -> list[Observation]:
|
| 158 |
+
return list(self._observations)
|
| 159 |
+
|
| 160 |
+
@property
|
| 161 |
+
def conditions(self) -> list[Condition]:
|
| 162 |
+
return list(self._conditions)
|
| 163 |
+
|
| 164 |
+
@property
|
| 165 |
+
def medications(self) -> list[MedicationRequest]:
|
| 166 |
+
return list(self._medications)
|
| 167 |
+
|
| 168 |
+
@property
|
| 169 |
+
def document_references(self) -> list[DocumentReference]:
|
| 170 |
+
return list(self._doc_refs)
|
| 171 |
+
|
| 172 |
+
@property
|
| 173 |
+
def document_count(self) -> int:
|
| 174 |
+
return len(self._document_ids)
|
livemedcard/qa.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Grounded Q&A: детермінований ретрив по FactStore + графу з посиланням на джерела.
|
| 2 |
+
|
| 3 |
+
LLM тут НЕ робить висновків про безпеку — відповіді будуються з фактів і провенансу.
|
| 4 |
+
Інтерфейс лишає місце під майбутню генеративну модель (L2b) для формулювання,
|
| 5 |
+
але фактаж і посилання завжди детерміновані.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel, Field
|
| 13 |
+
|
| 14 |
+
from .dto import Signal
|
| 15 |
+
from .lang import Lang, detect_lang
|
| 16 |
+
from .reference import range_for
|
| 17 |
+
from .stores import FactStore, KnowledgeGraph
|
| 18 |
+
|
| 19 |
+
# Ключові слова питання → LOINC показника. Кожен термін мапиться на СВІЙ аналіт:
|
| 20 |
+
# «глікований»/«hba1c» → HbA1c; «гемоглобін» → звичайний Hb; «цукор»/«глюкоза» →
|
| 21 |
+
# глюкоза. Порядок важливий: специфічні терміни («глікован») перед загальними
|
| 22 |
+
# («гемоглобін»), бо перший збіг виграє.
|
| 23 |
+
_KEYWORD_TO_LOINC: dict[str, str] = {
|
| 24 |
+
"нирк": "2160-0",
|
| 25 |
+
"креатинін": "2160-0",
|
| 26 |
+
"hba1c": "4548-4",
|
| 27 |
+
"глікован": "4548-4",
|
| 28 |
+
"діабет": "4548-4",
|
| 29 |
+
"гемоглобін": "718-7",
|
| 30 |
+
"цукор": "2345-7",
|
| 31 |
+
"глюкоз": "2345-7",
|
| 32 |
+
"калій": "2823-3",
|
| 33 |
+
"тиск": "8480-6",
|
| 34 |
+
"холестерин": "2093-3",
|
| 35 |
+
"алт": "1742-6",
|
| 36 |
+
# EN-аліаси (специфічні — раніше за загальні, щоб «glycated hemoglobin»
|
| 37 |
+
# не перехопився «hemoglobin»)
|
| 38 |
+
"glycated hemoglobin": "4548-4",
|
| 39 |
+
"a1c": "4548-4",
|
| 40 |
+
"diabetes": "4548-4",
|
| 41 |
+
"kidney": "2160-0",
|
| 42 |
+
"creatinine": "2160-0",
|
| 43 |
+
"hemoglobin": "718-7",
|
| 44 |
+
"glucose": "2345-7",
|
| 45 |
+
"sugar": "2345-7",
|
| 46 |
+
"potassium": "2823-3",
|
| 47 |
+
"blood pressure": "8480-6",
|
| 48 |
+
"pressure": "8480-6",
|
| 49 |
+
"cholesterol": "2093-3",
|
| 50 |
+
"alt": "1742-6",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
_MED_KEYWORDS = (
|
| 54 |
+
"ліки", "препарат", "таблет", "взаємод", "приймає", "приймати",
|
| 55 |
+
"medication", "medicine", "drug", "pill", "interaction", "taking",
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# Каталог фраз відповіді за мовою питання (автовибір через detect_lang).
|
| 59 |
+
_MSG: dict[Lang, dict[str, str]] = {
|
| 60 |
+
"uk": {
|
| 61 |
+
"lab_none": "У картці немає вимірювань показника «{name}».",
|
| 62 |
+
"lab_latest": "{name}: останнє значення {v} {u} ({d}).",
|
| 63 |
+
"lab_trend": "Динаміка за {n} вимірювань: {a}→{b} ({d1}..{d2}), {dir}.",
|
| 64 |
+
"dir_up": "стабільно зростає",
|
| 65 |
+
"dir_down": "стабільно знижується",
|
| 66 |
+
"dir_flat": "змінюється нерівномірно",
|
| 67 |
+
"above": "Останнє значення ВИЩЕ норми ({lo}–{hi} {u}).",
|
| 68 |
+
"within": "Останнє значення в межах норми ({lo}–{hi} {u}).",
|
| 69 |
+
"below": "Останнє значення НИЖЧЕ норми ({lo}–{hi} {u}).",
|
| 70 |
+
"meds_list": "Призначені препарати: {list}.",
|
| 71 |
+
"meds_none": "У картці немає призначених препаратів.",
|
| 72 |
+
"interactions": "⚠ Виявлені взаємодії:",
|
| 73 |
+
"no_interactions": "Небезпечних взаємодій між препаратами не виявлено.",
|
| 74 |
+
"freetext_header": "За текстом виписок:",
|
| 75 |
+
"fallback": (
|
| 76 |
+
"Не вдалося зіставити питання з наявними даними. Спробуйте спитати "
|
| 77 |
+
"про показник (нирки/креатинін, HbA1c, калій, тиск) або про ліки та взаємодії."
|
| 78 |
+
),
|
| 79 |
+
},
|
| 80 |
+
"en": {
|
| 81 |
+
"lab_none": "No measurements of «{name}» in the record.",
|
| 82 |
+
"lab_latest": "{name}: latest value {v} {u} ({d}).",
|
| 83 |
+
"lab_trend": "Trend over {n} measurements: {a}→{b} ({d1}..{d2}), {dir}.",
|
| 84 |
+
"dir_up": "steadily increasing",
|
| 85 |
+
"dir_down": "steadily decreasing",
|
| 86 |
+
"dir_flat": "changing unevenly",
|
| 87 |
+
"above": "Latest value is ABOVE the reference range ({lo}–{hi} {u}).",
|
| 88 |
+
"within": "Latest value is within the reference range ({lo}–{hi} {u}).",
|
| 89 |
+
"below": "Latest value is BELOW the reference range ({lo}–{hi} {u}).",
|
| 90 |
+
"meds_list": "Prescribed medications: {list}.",
|
| 91 |
+
"meds_none": "No prescribed medications in the record.",
|
| 92 |
+
"interactions": "⚠ Detected interactions:",
|
| 93 |
+
"no_interactions": "No dangerous drug interactions detected.",
|
| 94 |
+
"freetext_header": "Per document text:",
|
| 95 |
+
"fallback": (
|
| 96 |
+
"Could not match the question to available data. Try asking about a lab "
|
| 97 |
+
"value (kidney/creatinine, HbA1c, potassium, blood pressure) or about "
|
| 98 |
+
"medications and interactions."
|
| 99 |
+
),
|
| 100 |
+
},
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class Answer(BaseModel):
|
| 105 |
+
text: str
|
| 106 |
+
sources: list[str] = Field(default_factory=list) # id документів-джерел
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class QA:
|
| 110 |
+
def __init__(
|
| 111 |
+
self,
|
| 112 |
+
factstore: FactStore,
|
| 113 |
+
graph: KnowledgeGraph,
|
| 114 |
+
signals: Optional[list[Signal]] = None,
|
| 115 |
+
sex: Optional[str] = None,
|
| 116 |
+
) -> None:
|
| 117 |
+
self.factstore = factstore
|
| 118 |
+
self.graph = graph
|
| 119 |
+
self.signals = signals or []
|
| 120 |
+
self.sex = sex
|
| 121 |
+
|
| 122 |
+
def ask(self, question: str) -> Answer:
|
| 123 |
+
q = question.lower()
|
| 124 |
+
lang = detect_lang(question) # мова відповіді = мова питання
|
| 125 |
+
|
| 126 |
+
# Наміри про ліки/взаємодії перевіряємо ПЕРШИМИ: інакше питання про ліки,
|
| 127 |
+
# що містить лабораторну підстроку (напр. «...при тиску»), помилково
|
| 128 |
+
# перехоплювалося б лабораторною гілкою.
|
| 129 |
+
if any(k in q for k in _MED_KEYWORDS):
|
| 130 |
+
return self._answer_medications(lang)
|
| 131 |
+
|
| 132 |
+
loinc = self._match_loinc(q)
|
| 133 |
+
if loinc is not None:
|
| 134 |
+
return self._answer_lab(loinc, lang)
|
| 135 |
+
|
| 136 |
+
# Fallback на free-text іпостась (Stage 1 summary у вузлах-документах):
|
| 137 |
+
# структурного збігу немає, але відповідь може бути в тексті виписки.
|
| 138 |
+
freetext = self._answer_freetext(q, lang)
|
| 139 |
+
if freetext is not None:
|
| 140 |
+
return freetext
|
| 141 |
+
|
| 142 |
+
return Answer(text=_MSG[lang]["fallback"])
|
| 143 |
+
|
| 144 |
+
# ── маршрутизація ─────────────────────────────────────────────────────────
|
| 145 |
+
def _match_loinc(self, q: str) -> Optional[str]:
|
| 146 |
+
for kw, loinc in _KEYWORD_TO_LOINC.items():
|
| 147 |
+
if kw in q:
|
| 148 |
+
return loinc
|
| 149 |
+
return None
|
| 150 |
+
|
| 151 |
+
def _answer_lab(self, loinc: str, lang: Lang) -> Answer:
|
| 152 |
+
m = _MSG[lang]
|
| 153 |
+
series = self.factstore.series(loinc)
|
| 154 |
+
ref = range_for(loinc, self.sex)
|
| 155 |
+
name = ref.keyword if ref else (series[0].keyword if series else loinc)
|
| 156 |
+
if not series:
|
| 157 |
+
return Answer(text=m["lab_none"].format(name=name))
|
| 158 |
+
|
| 159 |
+
latest = series[-1]
|
| 160 |
+
first = series[0]
|
| 161 |
+
unit = latest.unit or (ref.unit if ref else "")
|
| 162 |
+
parts = [m["lab_latest"].format(name=name, v=latest.value, u=unit, d=latest.ts.date())]
|
| 163 |
+
if len(series) >= 2:
|
| 164 |
+
# Напрям — за монотонністю всього ряду (узгоджено з L2a), а не лише
|
| 165 |
+
# за крайніми точками, щоб не називати «трендом» немонотонну динаміку.
|
| 166 |
+
vals = [f.value for f in series]
|
| 167 |
+
if all(a <= b for a, b in zip(vals, vals[1:])) and vals[-1] > vals[0]:
|
| 168 |
+
direction = m["dir_up"]
|
| 169 |
+
elif all(a >= b for a, b in zip(vals, vals[1:])) and vals[-1] < vals[0]:
|
| 170 |
+
direction = m["dir_down"]
|
| 171 |
+
else:
|
| 172 |
+
direction = m["dir_flat"]
|
| 173 |
+
parts.append(m["lab_trend"].format(
|
| 174 |
+
n=len(series), a=first.value, b=latest.value,
|
| 175 |
+
d1=first.ts.date(), d2=latest.ts.date(), dir=direction,
|
| 176 |
+
))
|
| 177 |
+
if ref is not None:
|
| 178 |
+
key = "above" if latest.value > ref.high else "below" if latest.value < ref.low else "within"
|
| 179 |
+
parts.append(m[key].format(lo=ref.low, hi=ref.high, u=unit))
|
| 180 |
+
|
| 181 |
+
sources = _unique(f.source_doc_id for f in series if f.source_doc_id)
|
| 182 |
+
return Answer(text=" ".join(parts), sources=sources)
|
| 183 |
+
|
| 184 |
+
def _answer_freetext(self, q: str, lang: Lang) -> Optional[Answer]:
|
| 185 |
+
"""Пошук у Stage 1 summary (free-text іпостась) за токенами питання.
|
| 186 |
+
|
| 187 |
+
Повертає рядки summary, що містять значущі слова питання, з посиланням на
|
| 188 |
+
документ-джерело. None, якщо збігів немає (тоді — загальний fallback).
|
| 189 |
+
"""
|
| 190 |
+
tokens = [w for w in re.findall(r"\w+", q.lower()) if len(w) >= 4]
|
| 191 |
+
if not tokens:
|
| 192 |
+
return None
|
| 193 |
+
hits: list[str] = []
|
| 194 |
+
sources: list[str] = []
|
| 195 |
+
for nid in self.graph.nodes_of_type("Document"):
|
| 196 |
+
text = self.graph.g.nodes[nid].get("text") or ""
|
| 197 |
+
doc_id = nid.split("/", 1)[-1]
|
| 198 |
+
for line in text.splitlines():
|
| 199 |
+
stripped = line.strip()
|
| 200 |
+
if not stripped or stripped.startswith("##") or stripped == "not stated":
|
| 201 |
+
continue
|
| 202 |
+
low = stripped.lower()
|
| 203 |
+
if any(t in low for t in tokens):
|
| 204 |
+
hits.append(stripped)
|
| 205 |
+
sources.append(doc_id)
|
| 206 |
+
if len(hits) >= 5: # не заливати відповідь
|
| 207 |
+
break
|
| 208 |
+
if len(hits) >= 5:
|
| 209 |
+
break
|
| 210 |
+
if not hits:
|
| 211 |
+
return None
|
| 212 |
+
text = _MSG[lang]["freetext_header"] + "\n" + "\n".join(f"— {h}" for h in hits)
|
| 213 |
+
return Answer(text=text, sources=_unique(sources))
|
| 214 |
+
|
| 215 |
+
def _answer_medications(self, lang: Lang) -> Answer:
|
| 216 |
+
m = _MSG[lang]
|
| 217 |
+
med_nodes = self.graph.nodes_of_type("MedicationRequest")
|
| 218 |
+
names = [self.graph.g.nodes[n].get("label", n) for n in med_nodes]
|
| 219 |
+
parts = []
|
| 220 |
+
if names:
|
| 221 |
+
parts.append(m["meds_list"].format(list=", ".join(sorted(set(names)))))
|
| 222 |
+
else:
|
| 223 |
+
parts.append(m["meds_none"])
|
| 224 |
+
|
| 225 |
+
interactions = [s for s in self.signals if s.kind == "interaction"]
|
| 226 |
+
if interactions:
|
| 227 |
+
parts.append(m["interactions"])
|
| 228 |
+
parts.extend(f"— {s.detail} ({s.severity})" for s in interactions)
|
| 229 |
+
else:
|
| 230 |
+
parts.append(m["no_interactions"])
|
| 231 |
+
|
| 232 |
+
sources = _unique(
|
| 233 |
+
self.graph.provenance_of(n)[0].split("/", 1)[-1]
|
| 234 |
+
for n in med_nodes
|
| 235 |
+
if self.graph.provenance_of(n)
|
| 236 |
+
)
|
| 237 |
+
return Answer(text="\n".join(parts), sources=sources)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _unique(items) -> list[str]:
|
| 241 |
+
seen: dict[str, None] = {}
|
| 242 |
+
for it in items:
|
| 243 |
+
if it:
|
| 244 |
+
seen.setdefault(it, None)
|
| 245 |
+
return list(seen)
|
livemedcard/reference.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Завантаження відкритих довідкових БД (детерміноване джерело для L1/L2a/L2b).
|
| 2 |
+
|
| 3 |
+
Усі коди узгоджені між файлами: RxNorm (ліки), LOINC (аналізи), SNOMED (діагнози).
|
| 4 |
+
|
| 5 |
+
Loader-и кешуються (`lru_cache`) і повертають **незмінні** структури: заморожені
|
| 6 |
+
Pydantic-моделі (`refmodels.FrozenModel`) у tuple/MappingProxyType. Це водночас
|
| 7 |
+
прибирає перебудову об'єктів на кожен виклик і унеможливлює псування спільного
|
| 8 |
+
довідника випадковою мутацією (раніше від цього захищали свіжі копії).
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
from collections.abc import Mapping
|
| 14 |
+
from functools import lru_cache
|
| 15 |
+
from types import MappingProxyType
|
| 16 |
+
from typing import Any, Optional
|
| 17 |
+
|
| 18 |
+
from .config import DATA_DIR
|
| 19 |
+
from .refmodels import (
|
| 20 |
+
CondTerm,
|
| 21 |
+
DoseLimit,
|
| 22 |
+
Interaction,
|
| 23 |
+
LabTerm,
|
| 24 |
+
MedTerm,
|
| 25 |
+
RangeSpec,
|
| 26 |
+
RefRange,
|
| 27 |
+
Vocab,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@lru_cache(maxsize=None)
|
| 32 |
+
def _raw(name: str) -> Any:
|
| 33 |
+
return json.loads((DATA_DIR / name).read_text(encoding="utf-8"))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@lru_cache(maxsize=None)
|
| 37 |
+
def load_interactions() -> tuple[Interaction, ...]:
|
| 38 |
+
"""Пари RxNorm-кодів із тяжкістю взаємодії."""
|
| 39 |
+
return tuple(Interaction(**p) for p in _raw("interactions.json")["pairs"])
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@lru_cache(maxsize=None)
|
| 43 |
+
def load_dose_limits() -> Mapping[str, DoseLimit]:
|
| 44 |
+
"""RxNorm → DoseLimit."""
|
| 45 |
+
return MappingProxyType(
|
| 46 |
+
{d["rxnorm"]: DoseLimit(**d) for d in _raw("dose_limits.json")["limits"]}
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@lru_cache(maxsize=None)
|
| 51 |
+
def load_range_specs() -> Mapping[str, RangeSpec]:
|
| 52 |
+
"""LOINC → RangeSpec (з опціональними варіантами за статтю)."""
|
| 53 |
+
return MappingProxyType({
|
| 54 |
+
loinc: RangeSpec(**spec)
|
| 55 |
+
for loinc, spec in _raw("reference_ranges.json")["ranges"].items()
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def range_for(loinc: str, sex: Optional[str] = None) -> Optional[RefRange]:
|
| 60 |
+
"""Референсні межі показника, розвʼязані під стать пацієнта (або None)."""
|
| 61 |
+
spec = load_range_specs().get(loinc)
|
| 62 |
+
return spec.resolve(sex) if spec is not None else None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@lru_cache(maxsize=None)
|
| 66 |
+
def load_vocab() -> Vocab:
|
| 67 |
+
"""Словник для StubExtractor: labs / medications / conditions."""
|
| 68 |
+
raw = _raw("vocab.json")
|
| 69 |
+
return Vocab(
|
| 70 |
+
labs={k: LabTerm(**v) for k, v in raw.get("labs", {}).items()},
|
| 71 |
+
medications={k: MedTerm(**v) for k, v in raw.get("medications", {}).items()},
|
| 72 |
+
conditions={k: CondTerm(**v) for k, v in raw.get("conditions", {}).items()},
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def load_sample_docs() -> list[dict]:
|
| 77 |
+
"""Демо-документи (український текст) для E2E. Щоразу свіжа копія."""
|
| 78 |
+
return json.loads(json.dumps(_raw("sample_docs.json")["documents"]))
|
livemedcard/refmodels.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Типізовані моделі довідкових даних (замість «сирих» dict).
|
| 2 |
+
|
| 3 |
+
Дає валідацію при завантаженні, автодоповнення й чіткий контракт для L1/L2a/L2b.
|
| 4 |
+
|
| 5 |
+
Усі моделі **заморожені** (frozen): loader-и в `reference.py` кешують екземпляри,
|
| 6 |
+
тож незмінність — гарантія, що жоден викликач не зіпсує спільний довідник.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel, ConfigDict
|
| 13 |
+
|
| 14 |
+
from .dto import Severity
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class FrozenModel(BaseModel):
|
| 18 |
+
"""База довідкових моделей: незмінні після створення (безпечно кешувати)."""
|
| 19 |
+
|
| 20 |
+
model_config = ConfigDict(frozen=True)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class Interaction(FrozenModel):
|
| 24 |
+
a: str # RxNorm
|
| 25 |
+
b: str # RxNorm
|
| 26 |
+
severity: Severity
|
| 27 |
+
detail: str
|
| 28 |
+
|
| 29 |
+
def pair(self) -> frozenset[str]:
|
| 30 |
+
return frozenset((self.a, self.b))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class DoseLimit(FrozenModel):
|
| 34 |
+
rxnorm: str
|
| 35 |
+
name: str
|
| 36 |
+
max_daily: float
|
| 37 |
+
unit: str
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class Bounds(FrozenModel):
|
| 41 |
+
low: float
|
| 42 |
+
high: float
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class RangeSpec(FrozenModel):
|
| 46 |
+
"""Референсні межі показника з опціональними варіантами за статтю."""
|
| 47 |
+
|
| 48 |
+
keyword: str
|
| 49 |
+
unit: str
|
| 50 |
+
default: Bounds
|
| 51 |
+
by_sex: dict[str, Bounds] = {}
|
| 52 |
+
|
| 53 |
+
def resolve(self, sex: Optional[str]) -> "RefRange":
|
| 54 |
+
bounds = self.by_sex.get(sex or "", self.default)
|
| 55 |
+
return RefRange(
|
| 56 |
+
keyword=self.keyword, unit=self.unit,
|
| 57 |
+
low=bounds.low, high=bounds.high,
|
| 58 |
+
sex=sex if (sex and sex in self.by_sex) else None,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class RefRange(FrozenModel):
|
| 63 |
+
"""Розвʼязані межі для конкретного пацієнта (стать враховано)."""
|
| 64 |
+
|
| 65 |
+
keyword: str
|
| 66 |
+
unit: str
|
| 67 |
+
low: float
|
| 68 |
+
high: float
|
| 69 |
+
sex: Optional[str] = None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class LabTerm(FrozenModel):
|
| 73 |
+
loinc: str
|
| 74 |
+
unit: str
|
| 75 |
+
display: str
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class MedTerm(FrozenModel):
|
| 79 |
+
rxnorm: str
|
| 80 |
+
display: str
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class CondTerm(FrozenModel):
|
| 84 |
+
snomed: str
|
| 85 |
+
display: str
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class Vocab(FrozenModel):
|
| 89 |
+
labs: dict[str, LabTerm] = {}
|
| 90 |
+
medications: dict[str, MedTerm] = {}
|
| 91 |
+
conditions: dict[str, CondTerm] = {}
|
livemedcard/repository.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Персистентність картки у локальний JSON-файл (local-first).
|
| 2 |
+
|
| 3 |
+
Дані не залишають пристрій — це умова довіри для медичних даних. Формат — знімок
|
| 4 |
+
(`LiveMedCard.snapshot`), тож зберігання й відновлення симетричні.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from .layers.l2b_extractor import Extractor
|
| 13 |
+
from .pipeline import LiveMedCard
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def save_card(card: LiveMedCard, path: str) -> None:
|
| 17 |
+
p = Path(path)
|
| 18 |
+
p.parent.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
p.write_text(
|
| 20 |
+
json.dumps(card.snapshot(), ensure_ascii=False, indent=2), encoding="utf-8"
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_card(path: str, extractor: Optional[Extractor] = None) -> Optional[LiveMedCard]:
|
| 25 |
+
"""Відновити картку зі знімка; None, якщо файлу ще немає.
|
| 26 |
+
|
| 27 |
+
Пошкоджений файл — ЯВНА помилка, а не «стартуємо порожнім»: інакше наступне
|
| 28 |
+
автозбереження перезаписало б знімок порожнім станом (втрата медичних даних).
|
| 29 |
+
"""
|
| 30 |
+
p = Path(path)
|
| 31 |
+
if not p.exists():
|
| 32 |
+
return None
|
| 33 |
+
try:
|
| 34 |
+
data = json.loads(p.read_text(encoding="utf-8"))
|
| 35 |
+
except json.JSONDecodeError as exc:
|
| 36 |
+
raise ValueError(f"Пошкоджений файл знімка картки: {path} ({exc})") from exc
|
| 37 |
+
return LiveMedCard.from_snapshot(data, extractor)
|
livemedcard/static/index.html
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="uk">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>LiveMedCard — жива медична картка</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root{
|
| 9 |
+
/* dark clinical (default) */
|
| 10 |
+
--bg:#080d12; --bg-grad:radial-gradient(1200px 600px at 78% -8%, rgba(45,212,191,.10), transparent 60%),
|
| 11 |
+
radial-gradient(900px 500px at 10% 0%, rgba(56,140,255,.07), transparent 55%);
|
| 12 |
+
--panel:#101923; --panel-2:#0d151d; --panel-hi:#16232f;
|
| 13 |
+
--ink:#e9f1f5; --muted:#8ba1ae; --faint:#5f7583; --line:#1d2b37;
|
| 14 |
+
--accent:#2dd4bf; --accent-ink:#062b26; --accent-soft:#0f2622;
|
| 15 |
+
--critical:#ff6b6b; --major:#ff7d6b; --moderate:#f0b429; --ok:#34d399; --info:#8ba1ae;
|
| 16 |
+
--band:rgba(45,212,191,.10);
|
| 17 |
+
--shadow:0 1px 0 rgba(255,255,255,.02) inset, 0 10px 30px rgba(0,0,0,.45);
|
| 18 |
+
--glow:0 0 0 1px rgba(45,212,191,.25), 0 8px 30px rgba(45,212,191,.12);
|
| 19 |
+
--mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;
|
| 20 |
+
--sans:system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,sans-serif;
|
| 21 |
+
}
|
| 22 |
+
@media (prefers-color-scheme:light){
|
| 23 |
+
:root{
|
| 24 |
+
--bg:#eef3f6; --bg-grad:radial-gradient(1100px 560px at 80% -10%, rgba(13,148,136,.10), transparent 60%),
|
| 25 |
+
radial-gradient(800px 460px at 8% 0%, rgba(37,99,235,.06), transparent 55%);
|
| 26 |
+
--panel:#ffffff; --panel-2:#f5f8fa; --panel-hi:#eef3f6;
|
| 27 |
+
--ink:#0d1b24; --muted:#4f6774; --faint:#7d94a1; --line:#dde6ec;
|
| 28 |
+
--accent:#0d9488; --accent-ink:#ffffff; --accent-soft:#e2f2ef;
|
| 29 |
+
--critical:#dc2626; --major:#e0483c; --moderate:#b45309; --ok:#0f9d58; --info:#4f6774;
|
| 30 |
+
--band:rgba(13,148,136,.12);
|
| 31 |
+
--shadow:0 1px 2px rgba(13,27,36,.05), 0 10px 26px rgba(13,27,36,.07);
|
| 32 |
+
--glow:0 0 0 1px rgba(13,148,136,.25), 0 8px 26px rgba(13,148,136,.12);
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
:root[data-theme="light"]{
|
| 36 |
+
--bg:#eef3f6; --bg-grad:radial-gradient(1100px 560px at 80% -10%, rgba(13,148,136,.10), transparent 60%),
|
| 37 |
+
radial-gradient(800px 460px at 8% 0%, rgba(37,99,235,.06), transparent 55%);
|
| 38 |
+
--panel:#ffffff; --panel-2:#f5f8fa; --panel-hi:#eef3f6;
|
| 39 |
+
--ink:#0d1b24; --muted:#4f6774; --faint:#7d94a1; --line:#dde6ec;
|
| 40 |
+
--accent:#0d9488; --accent-ink:#ffffff; --accent-soft:#e2f2ef;
|
| 41 |
+
--critical:#dc2626; --major:#e0483c; --moderate:#b45309; --ok:#0f9d58; --info:#4f6774;
|
| 42 |
+
--band:rgba(13,148,136,.12);
|
| 43 |
+
--shadow:0 1px 2px rgba(13,27,36,.05), 0 10px 26px rgba(13,27,36,.07);
|
| 44 |
+
--glow:0 0 0 1px rgba(13,148,136,.25), 0 8px 26px rgba(13,148,136,.12);
|
| 45 |
+
}
|
| 46 |
+
:root[data-theme="dark"]{
|
| 47 |
+
--bg:#080d12; --panel:#101923; --panel-2:#0d151d; --panel-hi:#16232f;
|
| 48 |
+
--ink:#e9f1f5; --muted:#8ba1ae; --faint:#5f7583; --line:#1d2b37;
|
| 49 |
+
--accent:#2dd4bf; --accent-ink:#062b26; --accent-soft:#0f2622;
|
| 50 |
+
--critical:#ff6b6b; --major:#ff7d6b; --moderate:#f0b429; --ok:#34d399; --info:#8ba1ae;
|
| 51 |
+
--band:rgba(45,212,191,.10);
|
| 52 |
+
--shadow:0 1px 0 rgba(255,255,255,.02) inset, 0 10px 30px rgba(0,0,0,.45);
|
| 53 |
+
--glow:0 0 0 1px rgba(45,212,191,.25), 0 8px 30px rgba(45,212,191,.12);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
*{box-sizing:border-box}
|
| 57 |
+
html{-webkit-text-size-adjust:100%}
|
| 58 |
+
body{margin:0;color:var(--ink);font:15px/1.55 var(--sans);
|
| 59 |
+
background:var(--bg-grad),var(--bg);background-attachment:fixed;min-height:100vh;
|
| 60 |
+
-webkit-font-smoothing:antialiased;}
|
| 61 |
+
a{color:var(--accent);text-underline-offset:2px}
|
| 62 |
+
.wrap{max-width:1160px;margin:0 auto;padding:26px 20px 72px}
|
| 63 |
+
|
| 64 |
+
/* ── header ─────────────────────────────────────────── */
|
| 65 |
+
header.top{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin-bottom:4px}
|
| 66 |
+
.brand{display:flex;align-items:center;gap:12px}
|
| 67 |
+
.logo{width:40px;height:40px;border-radius:11px;display:grid;place-items:center;flex:0 0 auto;
|
| 68 |
+
background:linear-gradient(150deg,var(--accent),color-mix(in srgb,var(--accent) 55%, #2563eb));
|
| 69 |
+
box-shadow:var(--glow)}
|
| 70 |
+
.logo svg{width:24px;height:24px;color:var(--accent-ink)}
|
| 71 |
+
.brand h1{font-size:23px;margin:0;letter-spacing:-.02em;font-weight:750}
|
| 72 |
+
.brand .sub{display:block;color:var(--muted);font-size:12.5px;margin-top:-1px;letter-spacing:.01em}
|
| 73 |
+
.top .spacer{flex:1}
|
| 74 |
+
.patient{font:12.5px/1.3 var(--mono);color:var(--muted);text-align:right;
|
| 75 |
+
background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:7px 11px}
|
| 76 |
+
.patient b{color:var(--ink)}
|
| 77 |
+
.theme-btn{width:38px;height:38px;border-radius:10px;border:1px solid var(--line);background:var(--panel);
|
| 78 |
+
color:var(--muted);cursor:pointer;display:grid;place-items:center;transition:.15s}
|
| 79 |
+
.theme-btn:hover{color:var(--accent);border-color:var(--accent)}
|
| 80 |
+
|
| 81 |
+
/* pulse line */
|
| 82 |
+
.pulse{height:34px;margin:12px 0 2px;overflow:hidden;opacity:.9}
|
| 83 |
+
.pulse svg{width:100%;height:100%}
|
| 84 |
+
.pulse .lead{stroke:var(--accent);stroke-width:2;fill:none;
|
| 85 |
+
stroke-dasharray:1400;stroke-dashoffset:1400;animation:trace 4.5s linear infinite}
|
| 86 |
+
.pulse .ghost{stroke:var(--line);stroke-width:2;fill:none}
|
| 87 |
+
@keyframes trace{to{stroke-dashoffset:0}}
|
| 88 |
+
|
| 89 |
+
/* ── stat strip ─────────────────────────────────────── */
|
| 90 |
+
.stats{display:flex;flex-wrap:wrap;gap:10px;margin:14px 0 18px}
|
| 91 |
+
.stat{flex:1 1 130px;background:var(--panel);border:1px solid var(--line);border-radius:13px;
|
| 92 |
+
padding:13px 15px;box-shadow:var(--shadow);position:relative;overflow:hidden}
|
| 93 |
+
.stat .n{font:700 26px/1 var(--mono);letter-spacing:-.02em;font-variant-numeric:tabular-nums}
|
| 94 |
+
.stat .l{font-size:12px;color:var(--muted);margin-top:5px}
|
| 95 |
+
.stat.bi .n{color:var(--accent)}
|
| 96 |
+
.stat.alert .n{color:var(--major)}
|
| 97 |
+
|
| 98 |
+
/* ── toolbar ────────────────────────────────────────── */
|
| 99 |
+
.toolbar{display:flex;flex-wrap:wrap;gap:9px;margin:0 0 20px}
|
| 100 |
+
button{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(--panel);
|
| 101 |
+
color:var(--ink);border-radius:10px;padding:9px 15px;transition:.14s;font-weight:550}
|
| 102 |
+
button:hover{border-color:var(--accent);transform:translateY(-1px)}
|
| 103 |
+
button:active{transform:translateY(0)}
|
| 104 |
+
button.primary{background:linear-gradient(150deg,var(--accent),color-mix(in srgb,var(--accent) 62%, #2563eb));
|
| 105 |
+
border-color:transparent;color:var(--accent-ink);font-weight:650;box-shadow:var(--glow)}
|
| 106 |
+
button.primary:hover{filter:brightness(1.05)}
|
| 107 |
+
button.ghost{background:transparent}
|
| 108 |
+
button:disabled{opacity:.55;cursor:default;transform:none}
|
| 109 |
+
|
| 110 |
+
/* ── layout ─────────────────────────────────────────── */
|
| 111 |
+
.grid{display:grid;grid-template-columns:1fr;gap:18px}
|
| 112 |
+
@media(min-width:900px){.grid{grid-template-columns:1.18fr .82fr}}
|
| 113 |
+
.panel{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:18px;
|
| 114 |
+
box-shadow:var(--shadow);animation:rise .5s cubic-bezier(.2,.7,.2,1) both}
|
| 115 |
+
.panel.d1{animation-delay:.05s} .panel.d2{animation-delay:.12s} .panel.d3{animation-delay:.19s}
|
| 116 |
+
@keyframes rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
|
| 117 |
+
.panel h2{font:600 11.5px/1 var(--sans);text-transform:uppercase;letter-spacing:.09em;color:var(--faint);
|
| 118 |
+
margin:0 0 15px;display:flex;align-items:center;gap:8px}
|
| 119 |
+
.panel h2 .tag{margin-left:auto;text-transform:none;letter-spacing:0;font-weight:600;font-size:11px;
|
| 120 |
+
color:var(--accent);background:var(--accent-soft);border-radius:999px;padding:3px 9px}
|
| 121 |
+
.right{display:grid;gap:18px}
|
| 122 |
+
|
| 123 |
+
/* ── metrics / timeline ─────────────────────────────── */
|
| 124 |
+
.series{display:grid;gap:13px}
|
| 125 |
+
.metric{border:1px solid var(--line);border-radius:13px;padding:14px 14px 10px;background:var(--panel-2);
|
| 126 |
+
transition:.16s;animation:rise .45s cubic-bezier(.2,.7,.2,1) both}
|
| 127 |
+
.metric:hover{border-color:color-mix(in srgb,var(--accent) 45%,var(--line));background:var(--panel-hi)}
|
| 128 |
+
.metric .row{display:flex;justify-content:space-between;align-items:flex-end;gap:10px}
|
| 129 |
+
.metric .name{font-weight:600;font-size:14.5px}
|
| 130 |
+
.metric .big{display:flex;align-items:baseline;gap:6px}
|
| 131 |
+
.metric .val{font:750 25px/1 var(--mono);letter-spacing:-.02em;font-variant-numeric:tabular-nums}
|
| 132 |
+
.metric .unit{font-size:12.5px;color:var(--muted);font-weight:600}
|
| 133 |
+
.metric .trend{font:700 12px/1 var(--mono);padding:2px 7px;border-radius:999px;white-space:nowrap}
|
| 134 |
+
.trend.up{color:var(--major);background:color-mix(in srgb,var(--major) 15%,transparent)}
|
| 135 |
+
.trend.down{color:var(--ok);background:color-mix(in srgb,var(--ok) 15%,transparent)}
|
| 136 |
+
.trend.flat{color:var(--muted);background:color-mix(in srgb,var(--muted) 14%,transparent)}
|
| 137 |
+
.metric .meta{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-top:3px}
|
| 138 |
+
.metric .ref{color:var(--faint);font-size:11.5px;font-family:var(--mono)}
|
| 139 |
+
.status{font-size:11.5px;font-weight:700}
|
| 140 |
+
.status.ok{color:var(--ok)} .status.high{color:var(--major)} .status.low{color:var(--moderate)}
|
| 141 |
+
.metric .src{color:var(--faint);font-size:11px;margin-top:7px;display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
| 142 |
+
.lang{font:700 10px/1 var(--mono);padding:2px 5px;border-radius:5px;border:1px solid var(--line)}
|
| 143 |
+
.lang.ua{color:var(--accent)} .lang.en{color:#5e9dff}
|
| 144 |
+
.spark{margin-top:9px}
|
| 145 |
+
.spark path.line{stroke:var(--accent);stroke-width:2.2;fill:none;stroke-linejoin:round;stroke-linecap:round;
|
| 146 |
+
stroke-dasharray:var(--len);stroke-dashoffset:var(--len);animation:draw 1.1s .1s ease-out forwards}
|
| 147 |
+
@keyframes draw{to{stroke-dashoffset:0}}
|
| 148 |
+
|
| 149 |
+
/* ── safety ─────────────────────────────────────────── */
|
| 150 |
+
.decision{display:flex;align-items:center;gap:10px;margin-bottom:14px;padding:11px 13px;
|
| 151 |
+
border-radius:12px;border:1px solid var(--line);flex-wrap:wrap}
|
| 152 |
+
.decision.escalate{background:color-mix(in srgb,var(--major) 12%,transparent);
|
| 153 |
+
border-color:color-mix(in srgb,var(--major) 45%,var(--line))}
|
| 154 |
+
.decision.auto{background:color-mix(in srgb,var(--ok) 12%,transparent);
|
| 155 |
+
border-color:color-mix(in srgb,var(--ok) 40%,var(--line))}
|
| 156 |
+
.badge{display:inline-flex;align-items:center;gap:6px;padding:4px 11px;border-radius:999px;
|
| 157 |
+
font-size:12px;font-weight:700;color:#fff}
|
| 158 |
+
.decision .t{font:600 12.5px/1 var(--mono);color:var(--muted);margin-left:auto}
|
| 159 |
+
.reasons{margin:8px 0 0;padding-left:18px;color:var(--muted);font-size:12.5px;flex-basis:100%}
|
| 160 |
+
.sig{display:flex;gap:11px;padding:12px 0;border-top:1px solid var(--line)}
|
| 161 |
+
.sig:first-of-type{border-top:none;padding-top:2px}
|
| 162 |
+
.sig .dot{flex:0 0 auto;width:11px;height:11px;border-radius:50%;margin-top:4px;position:relative}
|
| 163 |
+
.sig.hot .dot::after{content:"";position:absolute;inset:-4px;border-radius:50%;
|
| 164 |
+
border:2px solid currentColor;opacity:.5;animation:ping 1.8s cubic-bezier(0,0,.2,1) infinite}
|
| 165 |
+
@keyframes ping{0%{transform:scale(.9);opacity:.6}70%,100%{transform:scale(2.1);opacity:0}}
|
| 166 |
+
.sig .k{font:600 10.5px/1.3 var(--mono);color:var(--faint);text-transform:uppercase;letter-spacing:.05em}
|
| 167 |
+
.sig .d{font-size:13.5px;margin-top:3px;display:block}
|
| 168 |
+
|
| 169 |
+
.meds{display:flex;flex-wrap:wrap;gap:8px}
|
| 170 |
+
.pill{background:var(--panel-2);border:1px solid var(--line);color:var(--ink);border-radius:999px;
|
| 171 |
+
padding:6px 13px;font-size:13px;font-weight:600;display:inline-flex;align-items:center;gap:7px}
|
| 172 |
+
.pill.flag{border-color:color-mix(in srgb,var(--major) 55%,var(--line));
|
| 173 |
+
color:var(--major);background:color-mix(in srgb,var(--major) 10%,transparent)}
|
| 174 |
+
.pill .warn{font-size:11px}
|
| 175 |
+
.empty{color:var(--muted);font-size:13px;padding:8px 0}
|
| 176 |
+
|
| 177 |
+
/* ── Q&A ────────────────────────────────────────────── */
|
| 178 |
+
.qa .log{display:grid;gap:10px;margin-bottom:14px;max-height:320px;overflow:auto;padding-right:4px}
|
| 179 |
+
.bubble{padding:11px 14px;border-radius:14px;max-width:90%;white-space:pre-wrap;
|
| 180 |
+
animation:pop .28s cubic-bezier(.2,.7,.2,1) both;font-size:14px}
|
| 181 |
+
@keyframes pop{from{opacity:0;transform:translateY(6px) scale(.98)}to{opacity:1;transform:none}}
|
| 182 |
+
.bubble.q{justify-self:end;background:linear-gradient(150deg,var(--accent),color-mix(in srgb,var(--accent) 62%, #2563eb));
|
| 183 |
+
color:var(--accent-ink);border-bottom-right-radius:5px;font-weight:550}
|
| 184 |
+
.bubble.a{justify-self:start;background:var(--panel-2);border:1px solid var(--line);border-bottom-left-radius:5px}
|
| 185 |
+
.bubble .src{display:block;margin-top:7px;font:11px/1.4 var(--mono);color:var(--faint)}
|
| 186 |
+
.askrow{display:flex;gap:9px}
|
| 187 |
+
.askrow input{flex:1;font:inherit;padding:11px 14px;border-radius:11px;border:1px solid var(--line);
|
| 188 |
+
background:var(--panel-2);color:var(--ink)}
|
| 189 |
+
.askrow input:focus{outline:none;border-color:var(--accent);box-shadow:var(--glow)}
|
| 190 |
+
.chips{display:flex;flex-wrap:wrap;gap:7px;margin-top:10px}
|
| 191 |
+
.chip{font-size:12.5px;color:var(--muted);background:var(--panel-2);border:1px solid var(--line);
|
| 192 |
+
border-radius:999px;padding:5px 12px;cursor:pointer;transition:.14s}
|
| 193 |
+
.chip:hover{color:var(--accent);border-color:var(--accent)}
|
| 194 |
+
|
| 195 |
+
/* add doc */
|
| 196 |
+
.add{margin:14px 0 0;display:none;gap:9px;grid-template-columns:1fr;background:var(--panel-2);
|
| 197 |
+
border:1px dashed var(--line);border-radius:13px;padding:14px}
|
| 198 |
+
.add.show{display:grid;animation:rise .3s ease both}
|
| 199 |
+
.add textarea{font:inherit;min-height:78px;border-radius:10px;border:1px solid var(--line);
|
| 200 |
+
padding:10px;background:var(--panel);color:var(--ink);resize:vertical}
|
| 201 |
+
.add textarea:focus,.add select:focus{outline:none;border-color:var(--accent)}
|
| 202 |
+
.add select{font:inherit;padding:9px;border-radius:10px;border:1px solid var(--line);
|
| 203 |
+
background:var(--panel);color:var(--ink)}
|
| 204 |
+
.or{margin:2px 0;font:600 11px/1 var(--mono);color:var(--faint);text-transform:uppercase;
|
| 205 |
+
letter-spacing:.06em;text-align:center}
|
| 206 |
+
.dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:16px;
|
| 207 |
+
border:2px dashed var(--line);border-radius:11px;background:var(--panel);cursor:pointer;transition:.15s;
|
| 208 |
+
text-align:center;font-size:13px;color:var(--muted)}
|
| 209 |
+
.dropzone:hover,.dropzone.dragover{border-color:var(--accent);background:var(--accent-soft);color:var(--ink)}
|
| 210 |
+
|
| 211 |
+
footer{color:var(--faint);font-size:12px;margin-top:30px;text-align:center;line-height:1.6}
|
| 212 |
+
footer b{color:var(--muted)}
|
| 213 |
+
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%) translateY(8px);
|
| 214 |
+
background:var(--ink);color:var(--bg);padding:11px 18px;border-radius:11px;font-weight:600;font-size:13.5px;
|
| 215 |
+
box-shadow:0 10px 30px rgba(0,0,0,.35);opacity:0;transition:.28s;pointer-events:none;z-index:50}
|
| 216 |
+
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
| 217 |
+
|
| 218 |
+
@media (prefers-reduced-motion:reduce){
|
| 219 |
+
*,*::before,*::after{animation-duration:.001s!important;animation-iteration-count:1!important;
|
| 220 |
+
transition-duration:.001s!important}
|
| 221 |
+
.spark path.line{stroke-dashoffset:0}
|
| 222 |
+
}
|
| 223 |
+
</style>
|
| 224 |
+
</head>
|
| 225 |
+
<body>
|
| 226 |
+
<div class="wrap">
|
| 227 |
+
<header class="top">
|
| 228 |
+
<div class="brand">
|
| 229 |
+
<div class="logo" aria-hidden="true">
|
| 230 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
| 231 |
+
<path d="M3 12h3l2-5 4 10 2-5h5"/>
|
| 232 |
+
</svg>
|
| 233 |
+
</div>
|
| 234 |
+
<div>
|
| 235 |
+
<h1>LiveMedCard</h1>
|
| 236 |
+
<span class="sub">жива медична картка · архітектура TRACE</span>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
<div class="spacer"></div>
|
| 240 |
+
<div class="patient" id="patient">картка не завантажена</div>
|
| 241 |
+
<button class="theme-btn" id="theme" title="Тема" aria-label="Перемкнути тему">
|
| 242 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4.5"/><path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5L19 19M19 5l-1.5 1.5M6.5 17.5L5 19"/></svg>
|
| 243 |
+
</button>
|
| 244 |
+
</header>
|
| 245 |
+
|
| 246 |
+
<div class="pulse" aria-hidden="true">
|
| 247 |
+
<svg viewBox="0 0 1400 34" preserveAspectRatio="none">
|
| 248 |
+
<path class="ghost" d="M0,17 H520 l14,-13 l12,26 l16,-30 l14,17 H760 l16,-9 l10,9 H1400"/>
|
| 249 |
+
<path class="lead" d="M0,17 H520 l14,-13 l12,26 l16,-30 l14,17 H760 l16,-9 l10,9 H1400"/>
|
| 250 |
+
</svg>
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
<div class="stats" id="stats"></div>
|
| 254 |
+
|
| 255 |
+
<div class="toolbar">
|
| 256 |
+
<button class="primary" id="btn-samples">Завантажити демо-картку</button>
|
| 257 |
+
<button class="ghost" id="btn-add">+ Додати документ</button>
|
| 258 |
+
<button class="ghost" id="btn-export">Експорт FHIR</button>
|
| 259 |
+
<button class="ghost" id="btn-reset">Скинути</button>
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
<div class="add" id="add-form">
|
| 263 |
+
<textarea id="add-text" placeholder="Вставте текст документа, напр.: «Креатинін 121 µmol/L. Призначено: аспірин 100 мг на добу.»"></textarea>
|
| 264 |
+
<div class="or">— або завантажте фото (OCR) —</div>
|
| 265 |
+
<label class="dropzone" id="dropzone">
|
| 266 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:5px;color:var(--accent)"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
| 267 |
+
<span id="dropzone-text">Перетягніть фото сюди або натисніть для вибору</span>
|
| 268 |
+
<input type="file" id="add-image" accept="image/*" style="display:none">
|
| 269 |
+
</label>
|
| 270 |
+
<div style="display:flex;gap:9px;flex-wrap:wrap">
|
| 271 |
+
<select id="add-kind">
|
| 272 |
+
<option value="discharge_summary">Виписний епікриз</option>
|
| 273 |
+
<option value="lab_report">Лабораторний звіт</option>
|
| 274 |
+
<option value="prescription">Рецепт</option>
|
| 275 |
+
<option value="note">Нотатка</option>
|
| 276 |
+
</select>
|
| 277 |
+
<button class="primary" id="add-submit">Обробити документ</button>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
|
| 281 |
+
<div class="grid" style="margin-top:18px">
|
| 282 |
+
<section class="panel d1">
|
| 283 |
+
<h2>Таймлайн показників <span class="tag" id="bi-tag" style="display:none">білінгвально · UA + EN</span></h2>
|
| 284 |
+
<div class="series" id="series"><div class="empty">Даних ще немає. Натисніть «Завантажити демо-картку».</div></div>
|
| 285 |
+
</section>
|
| 286 |
+
|
| 287 |
+
<div class="right">
|
| 288 |
+
<section class="panel d2">
|
| 289 |
+
<h2>Безпека — шар TRACE</h2>
|
| 290 |
+
<div id="decision"></div>
|
| 291 |
+
<div id="signals"><div class="empty">Сигналів немає.</div></div>
|
| 292 |
+
</section>
|
| 293 |
+
<section class="panel d3">
|
| 294 |
+
<h2>Призначені ліки</h2>
|
| 295 |
+
<div class="meds" id="meds"><div class="empty">Ліків немає.</div></div>
|
| 296 |
+
</section>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
<section class="panel qa" style="margin-top:18px">
|
| 301 |
+
<h2>Запитати картку <span class="tag">grounded · з посиланням на джерело</span></h2>
|
| 302 |
+
<div class="log" id="log"></div>
|
| 303 |
+
<div class="askrow">
|
| 304 |
+
<input id="q" placeholder="Напр.: Що з нирками за останній рік?" autocomplete="off">
|
| 305 |
+
<button class="primary" id="ask">Спитати</button>
|
| 306 |
+
</div>
|
| 307 |
+
<div class="chips" id="chips"></div>
|
| 308 |
+
</section>
|
| 309 |
+
|
| 310 |
+
<footer>Прототип для <b>Digital Future Hackathon</b> · ніша Aging & Longevity.<br>
|
| 311 |
+
Система структурує факти й показує динаміку — <b>не ставить діагнозів і не призначає лікування</b>.</footer>
|
| 312 |
+
</div>
|
| 313 |
+
<div class="toast" id="toast"></div>
|
| 314 |
+
|
| 315 |
+
<script>
|
| 316 |
+
const $ = s => document.querySelector(s);
|
| 317 |
+
const SEV = {critical:'var(--critical)',major:'var(--major)',moderate:'var(--moderate)',info:'var(--info)'};
|
| 318 |
+
const SEV_UK = {critical:'критичний',major:'високий',moderate:'помірний',info:'інфо'};
|
| 319 |
+
|
| 320 |
+
/* theme toggle (respects OS by default; stamps data-theme on click) */
|
| 321 |
+
(function(){
|
| 322 |
+
const root=document.documentElement;
|
| 323 |
+
$('#theme').onclick=()=>{
|
| 324 |
+
const cur=root.getAttribute('data-theme')
|
| 325 |
+
|| (matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');
|
| 326 |
+
root.setAttribute('data-theme', cur==='dark'?'light':'dark');
|
| 327 |
+
};
|
| 328 |
+
})();
|
| 329 |
+
|
| 330 |
+
function toast(msg){const t=$('#toast');t.textContent=msg;t.classList.add('show');
|
| 331 |
+
clearTimeout(t._t);t._t=setTimeout(()=>t.classList.remove('show'),2600);}
|
| 332 |
+
|
| 333 |
+
async function api(path, opts){
|
| 334 |
+
const r=await fetch(path,opts);
|
| 335 |
+
if(!r.ok){
|
| 336 |
+
let msg=path+' '+r.status;
|
| 337 |
+
try{const err=await r.json(); if(err&&err.detail) msg=err.detail;}catch(e){}
|
| 338 |
+
throw new Error(msg);
|
| 339 |
+
}
|
| 340 |
+
return r.status===204?null:r.json();
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
function langOf(src){ return src && /(-|_)en\b|_en$|-en$/.test(src) ? 'en' : 'ua'; }
|
| 344 |
+
|
| 345 |
+
function sparkline(points, low, high){
|
| 346 |
+
const w=300,h=64,pad=9;
|
| 347 |
+
const vals=points.map(p=>p.value);
|
| 348 |
+
let min=Math.min(...vals), max=Math.max(...vals);
|
| 349 |
+
if(low!=null)min=Math.min(min,low); if(high!=null)max=Math.max(max,high);
|
| 350 |
+
if(min===max){min-=1;max+=1;}
|
| 351 |
+
const X=i=>points.length===1?w/2:pad+i*(w-2*pad)/(points.length-1);
|
| 352 |
+
const Y=v=>h-pad-(v-min)/(max-min)*(h-2*pad);
|
| 353 |
+
let band='';
|
| 354 |
+
if(low!=null&&high!=null){const y1=Y(high),y2=Y(low);
|
| 355 |
+
band=`<rect x="0" y="${y1.toFixed(1)}" width="${w}" height="${Math.max(0,y2-y1).toFixed(1)}" fill="var(--band)"/>`;}
|
| 356 |
+
const dLine=points.map((p,i)=>`${i?'L':'M'}${X(i).toFixed(1)},${Y(p.value).toFixed(1)}`).join(' ');
|
| 357 |
+
const area=`${dLine} L${X(points.length-1).toFixed(1)},${h} L${X(0).toFixed(1)},${h} Z`;
|
| 358 |
+
const last=points[points.length-1];
|
| 359 |
+
const breach=(low!=null&&last.value<low)||(high!=null&&last.value>high);
|
| 360 |
+
const dots=points.map((p,i)=>{const isLast=i===points.length-1;
|
| 361 |
+
return `<circle cx="${X(i).toFixed(1)}" cy="${Y(p.value).toFixed(1)}" r="${isLast?4:2.3}"
|
| 362 |
+
fill="${isLast&&breach?'var(--major)':'var(--accent)'}"
|
| 363 |
+
${isLast?'stroke="var(--panel-2)" stroke-width="2"':''}/>`;}).join('');
|
| 364 |
+
const gid='g'+Math.random().toString(36).slice(2,7);
|
| 365 |
+
// approx path length for draw animation
|
| 366 |
+
let len=0; for(let i=1;i<points.length;i++){const dx=X(i)-X(i-1),dy=Y(points[i].value)-Y(points[i-1].value);len+=Math.hypot(dx,dy);}
|
| 367 |
+
return `<svg class="spark" viewBox="0 0 ${w} ${h}" width="100%" height="${h}" preserveAspectRatio="none" style="display:block">
|
| 368 |
+
<defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1">
|
| 369 |
+
<stop offset="0" stop-color="var(--accent)" stop-opacity=".22"/>
|
| 370 |
+
<stop offset="1" stop-color="var(--accent)" stop-opacity="0"/></linearGradient></defs>
|
| 371 |
+
${band}<path d="${area}" fill="url(#${gid})"/>
|
| 372 |
+
<path class="line" style="--len:${len.toFixed(0)}" d="${dLine}"/>${dots}</svg>`;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
function statusOf(s){
|
| 376 |
+
const v=s.points.at(-1).value;
|
| 377 |
+
if(s.high!=null && v>s.high) return ['вище норми','high'];
|
| 378 |
+
if(s.low!=null && v<s.low) return ['нижче норми','low'];
|
| 379 |
+
if(s.low!=null||s.high!=null) return ['у нормі','ok'];
|
| 380 |
+
return ['',''];
|
| 381 |
+
}
|
| 382 |
+
function trendOf(s){
|
| 383 |
+
if(s.points.length<2) return ['','flat'];
|
| 384 |
+
const a=s.points[0].value, b=s.points.at(-1).value;
|
| 385 |
+
const pct=a?Math.round((b-a)/Math.abs(a)*100):0;
|
| 386 |
+
if(b>a) return [`↑ ${pct>0?'+':''}${pct}%`,'up'];
|
| 387 |
+
if(b<a) return [`↓ ${pct}%`,'down'];
|
| 388 |
+
return ['→ 0%','flat'];
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
function render(st){
|
| 392 |
+
const docs=st.document_count||0;
|
| 393 |
+
$('#patient').innerHTML = docs
|
| 394 |
+
? `пацієнт <b>${st.patient.id}</b> · документів <b>${docs}</b>`
|
| 395 |
+
: 'картка не завантажена';
|
| 396 |
+
|
| 397 |
+
// stat strip
|
| 398 |
+
const nMeas=st.series.reduce((a,s)=>a+s.points.length,0);
|
| 399 |
+
const langs=new Set();
|
| 400 |
+
st.series.forEach(s=>s.points.forEach(p=>p.source&&langs.add(langOf(p.source))));
|
| 401 |
+
const bilingual=langs.has('en')&&langs.has('ua');
|
| 402 |
+
$('#bi-tag').style.display = bilingual?'inline-block':'none';
|
| 403 |
+
const nAlerts=st.signals.length;
|
| 404 |
+
$('#stats').innerHTML = docs ? `
|
| 405 |
+
<div class="stat"><div class="n">${st.series.length}</div><div class="l">показників у динаміці</div></div>
|
| 406 |
+
<div class="stat"><div class="n">${nMeas}</div><div class="l">вимірювань на таймлайні</div></div>
|
| 407 |
+
<div class="stat alert"><div class="n">${nAlerts}</div><div class="l">сигналів безпеки</div></div>
|
| 408 |
+
<div class="stat bi"><div class="n">${bilingual?'UA+EN':(langs.has('en')?'EN':'UA')}</div><div class="l">мови документів</div></div>
|
| 409 |
+
` : '';
|
| 410 |
+
|
| 411 |
+
// series
|
| 412 |
+
const sEl=$('#series');
|
| 413 |
+
if(!st.series.length){sEl.innerHTML='<div class="empty">Даних ще немає. Натисніть «Завантажити демо-картку».</div>';}
|
| 414 |
+
else{sEl.innerHTML=st.series.map((s,idx)=>{
|
| 415 |
+
const last=s.points.at(-1);const [txt,cls]=statusOf(s);const [ttxt,tcls]=trendOf(s);
|
| 416 |
+
const ref=(s.low!=null&&s.high!=null)?`норма ${s.low}–${s.high} ${s.unit||''}`:'';
|
| 417 |
+
const srcs=[...new Set(s.points.map(p=>p.source).filter(Boolean))];
|
| 418 |
+
const langBadges=[...new Set(srcs.map(langOf))].map(l=>`<span class="lang ${l}">${l.toUpperCase()}</span>`).join('');
|
| 419 |
+
return `<div class="metric" style="animation-delay:${(idx*.05).toFixed(2)}s">
|
| 420 |
+
<div class="row">
|
| 421 |
+
<span class="name">${s.keyword}</span>
|
| 422 |
+
<span class="big"><span class="val">${last.value}</span><span class="unit">${s.unit||''}</span>
|
| 423 |
+
${ttxt?`<span class="trend ${tcls}">${ttxt}</span>`:''}</span>
|
| 424 |
+
</div>
|
| 425 |
+
<div class="meta"><span class="ref">${ref}</span><span class="status ${cls}">${txt}</span></div>
|
| 426 |
+
${sparkline(s.points,s.low,s.high)}
|
| 427 |
+
<div class="src">${langBadges}<span>${s.points.length} вимір. · ${srcs.join(', ')||'—'}</span></div>
|
| 428 |
+
</div>`;}).join('');}
|
| 429 |
+
|
| 430 |
+
// decision
|
| 431 |
+
const d=st.decision;const dEl=$('#decision');
|
| 432 |
+
dEl.className='decision '+(d.escalate?'escalate':'auto');
|
| 433 |
+
dEl.innerHTML=`<span class="badge" style="background:${d.escalate?'var(--major)':'var(--ok)'}">
|
| 434 |
+
${d.escalate?'ескалація до людини (L4)':'автоматична обробка'}</span>
|
| 435 |
+
<span class="t">${d.route_to}</span>
|
| 436 |
+
<ul class="reasons">${d.reasons.map(r=>`<li>${r}</li>`).join('')}</ul>`;
|
| 437 |
+
|
| 438 |
+
// signals — interaction/critical get a pulsing dot
|
| 439 |
+
const sig=$('#signals');
|
| 440 |
+
if(!st.signals.length){sig.innerHTML='<div class="empty">Показники в межах, небезпечних взаємодій не виявлено.</div>';}
|
| 441 |
+
else{sig.innerHTML=st.signals.map(x=>{
|
| 442 |
+
const hot=(x.kind==='interaction'||x.severity==='critical'||x.severity==='major');
|
| 443 |
+
const color=SEV[x.severity]||'var(--info)';
|
| 444 |
+
return `<div class="sig ${hot?'hot':''}">
|
| 445 |
+
<span class="dot" style="background:${color};color:${color}"></span>
|
| 446 |
+
<span><span class="k">${x.layer} · ${x.kind} · ${SEV_UK[x.severity]||x.severity}</span>
|
| 447 |
+
<span class="d">${x.detail}</span></span></div>`;}).join('');}
|
| 448 |
+
|
| 449 |
+
// meds — flag those named in an interaction
|
| 450 |
+
const interText=st.signals.filter(x=>x.kind==='interaction').map(x=>x.detail.toLowerCase()).join(' ');
|
| 451 |
+
const m=$('#meds');
|
| 452 |
+
m.innerHTML=st.medications.length?st.medications.map(n=>{
|
| 453 |
+
const flagged=interText.includes(n.toLowerCase());
|
| 454 |
+
return `<span class="pill ${flagged?'flag':''}">${n}${flagged?'<span class="warn">⚠</span>':''}</span>`;
|
| 455 |
+
}).join(''):'<div class="empty">Ліків немає.</div>';
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
async function refresh(){try{render(await api('/state'));}catch(e){toast('Помилка: '+e.message);}}
|
| 459 |
+
|
| 460 |
+
async function ask(q){
|
| 461 |
+
q=(q||$('#q').value).trim(); if(!q)return;
|
| 462 |
+
const log=$('#log');
|
| 463 |
+
log.insertAdjacentHTML('beforeend',`<div class="bubble q">${q}</div>`);
|
| 464 |
+
$('#q').value='';log.scrollTop=log.scrollHeight;
|
| 465 |
+
try{const a=await api('/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q})});
|
| 466 |
+
const src=a.sources&&a.sources.length?`<span class="src">джерела: ${a.sources.join(', ')}</span>`:'';
|
| 467 |
+
log.insertAdjacentHTML('beforeend',`<div class="bubble a">${a.text}${src}</div>`);
|
| 468 |
+
log.scrollTop=log.scrollHeight;
|
| 469 |
+
}catch(e){toast('Помилка: '+e.message);}
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
$('#btn-samples').onclick=async()=>{await api('/ingest/samples',{method:'POST'});await refresh();toast('Демо-картку завантажено');};
|
| 473 |
+
$('#btn-reset').onclick=async()=>{await api('/reset',{method:'POST'});$('#log').innerHTML='';await refresh();toast('Картку скинуто');};
|
| 474 |
+
$('#btn-export').onclick=async()=>{
|
| 475 |
+
const b=await api('/export');
|
| 476 |
+
const blob=new Blob([JSON.stringify(b,null,2)],{type:'application/json'});
|
| 477 |
+
const url=URL.createObjectURL(blob);
|
| 478 |
+
const a=document.createElement('a');a.href=url;a.download='livemedcard_fhir_bundle.json';
|
| 479 |
+
document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url);
|
| 480 |
+
toast('FHIR Bundle завантажено');
|
| 481 |
+
};
|
| 482 |
+
$('#btn-add').onclick=()=>$('#add-form').classList.toggle('show');
|
| 483 |
+
|
| 484 |
+
const fileInput=$('#add-image'), dz=$('#dropzone');
|
| 485 |
+
fileInput.onchange=e=>{const f=e.target.files[0];if(f){$('#dropzone-text').textContent='Вибрано: '+f.name;$('#add-text').value='';}};
|
| 486 |
+
dz.addEventListener('dragover',e=>{e.preventDefault();dz.classList.add('dragover');});
|
| 487 |
+
dz.addEventListener('dragleave',()=>dz.classList.remove('dragover'));
|
| 488 |
+
dz.addEventListener('drop',e=>{e.preventDefault();dz.classList.remove('dragover');
|
| 489 |
+
if(e.dataTransfer.files.length){fileInput.files=e.dataTransfer.files;
|
| 490 |
+
$('#dropzone-text').textContent='Вибрано: '+e.dataTransfer.files[0].name;$('#add-text').value='';}});
|
| 491 |
+
|
| 492 |
+
$('#add-submit').onclick=async()=>{
|
| 493 |
+
const text=$('#add-text').value.trim(), file=fileInput.files[0];
|
| 494 |
+
if(!text&&!file){toast('Введіть текст або виберіть фото');return;}
|
| 495 |
+
const btn=$('#add-submit'), orig=btn.textContent; btn.disabled=true;
|
| 496 |
+
try{
|
| 497 |
+
let rep;
|
| 498 |
+
if(file){
|
| 499 |
+
btn.textContent='Зчитування (OCR)…';
|
| 500 |
+
const base64=await new Promise((res,rej)=>{const rd=new FileReader();rd.onload=()=>res(rd.result);rd.onerror=rej;rd.readAsDataURL(file);});
|
| 501 |
+
rep=await api('/ingest/image',{method:'POST',headers:{'Content-Type':'application/json'},
|
| 502 |
+
body:JSON.stringify({image_base64:base64,kind:$('#add-kind').value})});
|
| 503 |
+
}else{
|
| 504 |
+
btn.textContent='Опрацювання…';
|
| 505 |
+
rep=await api('/ingest',{method:'POST',headers:{'Content-Type':'application/json'},
|
| 506 |
+
body:JSON.stringify({id:'doc-'+Date.now(),text,kind:$('#add-kind').value})});
|
| 507 |
+
}
|
| 508 |
+
$('#add-text').value='';fileInput.value='';
|
| 509 |
+
$('#dropzone-text').textContent='Перетягніть фото сюди або натисніть для вибору';
|
| 510 |
+
$('#add-form').classList.remove('show');
|
| 511 |
+
await refresh();
|
| 512 |
+
toast(rep.decision.escalate?'Оброблено · ескалація до людини':'Оброблено · авто');
|
| 513 |
+
}catch(e){toast('Помилка: '+e.message);}
|
| 514 |
+
finally{btn.disabled=false;btn.textContent=orig;}
|
| 515 |
+
};
|
| 516 |
+
|
| 517 |
+
$('#ask').onclick=()=>ask();
|
| 518 |
+
$('#q').addEventListener('keydown',e=>{if(e.key==='Enter')ask();});
|
| 519 |
+
const CHIPS=['Що з нирками за останній рік?','Який був калій?','Які ліки приймає і чи є взаємодії?','Що з HbA1c?'];
|
| 520 |
+
$('#chips').innerHTML=CHIPS.map(c=>`<button class="chip">${c}</button>`).join('');
|
| 521 |
+
$('#chips').querySelectorAll('.chip').forEach(b=>b.onclick=()=>ask(b.textContent));
|
| 522 |
+
|
| 523 |
+
refresh();
|
| 524 |
+
</script>
|
| 525 |
+
</body>
|
| 526 |
+
</html>
|
livemedcard/stores.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Два сховища над спільними FHIR-ресурсами.
|
| 2 |
+
|
| 3 |
+
* ``FactStore`` (KV-T) — джерело істини для кількісного; на ньому детерміновано
|
| 4 |
+
рахуються сигнали L2a/L1.
|
| 5 |
+
* ``KnowledgeGraph`` — семантичні зв'язки та провенанс; живить grounded Q&A.
|
| 6 |
+
|
| 7 |
+
Межа: сигнали безпеки формуються на FactStore детерміновано, а не обходом графа
|
| 8 |
+
генеративною моделлю.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
import networkx as nx
|
| 16 |
+
from pydantic import BaseModel
|
| 17 |
+
|
| 18 |
+
from .fhir import (
|
| 19 |
+
CodeableConcept,
|
| 20 |
+
Condition,
|
| 21 |
+
Encounter,
|
| 22 |
+
MedicationRequest,
|
| 23 |
+
Observation,
|
| 24 |
+
Patient,
|
| 25 |
+
loinc_of,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class KVTFact(BaseModel):
|
| 30 |
+
"""Атомарний факт keyword–value–timestamp (проекція Observation)."""
|
| 31 |
+
|
| 32 |
+
obs_id: Optional[str] = None
|
| 33 |
+
keyword: str
|
| 34 |
+
loinc: Optional[str]
|
| 35 |
+
value: float
|
| 36 |
+
unit: Optional[str]
|
| 37 |
+
ts: datetime
|
| 38 |
+
source_doc_id: Optional[str] = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _concept_label(cc: Optional[CodeableConcept], fallback: str) -> str:
|
| 42 |
+
"""Людська назва з CodeableConcept: text → перший display → fallback.
|
| 43 |
+
|
| 44 |
+
Гарантує рядок навіть коли є лише коди без ``text`` (напр. з реального
|
| 45 |
+
екстрактора), уникаючи None-міток вузлів графа.
|
| 46 |
+
"""
|
| 47 |
+
if cc is not None:
|
| 48 |
+
if cc.text:
|
| 49 |
+
return cc.text
|
| 50 |
+
for c in cc.coding:
|
| 51 |
+
if c.display:
|
| 52 |
+
return c.display
|
| 53 |
+
return fallback
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def observation_to_kvt(o: Observation) -> Optional[KVTFact]:
|
| 57 |
+
"""Проекція Observation → KVTFact (None, якщо немає значення/часу)."""
|
| 58 |
+
if o.valueQuantity is None or o.effectiveDateTime is None:
|
| 59 |
+
return None
|
| 60 |
+
kw = o.code.text or (o.code.coding[0].display if o.code.coding else "obs")
|
| 61 |
+
return KVTFact(
|
| 62 |
+
obs_id=o.id,
|
| 63 |
+
keyword=kw,
|
| 64 |
+
loinc=loinc_of(o),
|
| 65 |
+
value=o.valueQuantity.value,
|
| 66 |
+
unit=o.valueQuantity.unit,
|
| 67 |
+
ts=o.effectiveDateTime,
|
| 68 |
+
source_doc_id=o.provenance.source_doc_id if o.provenance else None,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class FactStore:
|
| 73 |
+
"""KV-T факт-стор — джерело істини для кількісних показників.
|
| 74 |
+
|
| 75 |
+
Ідемпотентний за id обзервації: повторне додавання тієї самої обзервації
|
| 76 |
+
(напр. повторний прогін документа) не дублює факт.
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
def __init__(self) -> None:
|
| 80 |
+
self._facts: list[KVTFact] = []
|
| 81 |
+
self._seen_ids: set[str] = set()
|
| 82 |
+
# Індекс за LOINC: series()/loincs() без лінійного проходу всього стору.
|
| 83 |
+
self._by_loinc: dict[str, list[KVTFact]] = {}
|
| 84 |
+
|
| 85 |
+
def add_observation(self, o: Observation) -> Optional[KVTFact]:
|
| 86 |
+
if o.id in self._seen_ids:
|
| 87 |
+
return None
|
| 88 |
+
fact = observation_to_kvt(o)
|
| 89 |
+
if fact is not None:
|
| 90 |
+
self._facts.append(fact)
|
| 91 |
+
self._seen_ids.add(o.id)
|
| 92 |
+
if fact.loinc:
|
| 93 |
+
self._by_loinc.setdefault(fact.loinc, []).append(fact)
|
| 94 |
+
return fact
|
| 95 |
+
|
| 96 |
+
def add_observations(self, obs: list[Observation]) -> None:
|
| 97 |
+
for o in obs:
|
| 98 |
+
self.add_observation(o)
|
| 99 |
+
|
| 100 |
+
def all_facts(self) -> list[KVTFact]:
|
| 101 |
+
return list(self._facts)
|
| 102 |
+
|
| 103 |
+
def series(self, loinc: str) -> list[KVTFact]:
|
| 104 |
+
"""Часовий ряд одного показника, відсортований за часом (свіжа копія)."""
|
| 105 |
+
return sorted(self._by_loinc.get(loinc, []), key=lambda f: f.ts)
|
| 106 |
+
|
| 107 |
+
def loincs(self) -> list[str]:
|
| 108 |
+
"""Унікальні LOINC-коди, наявні у сторі (у порядку першої появи)."""
|
| 109 |
+
return list(self._by_loinc)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class KnowledgeGraph:
|
| 113 |
+
"""Граф контексту (networkx DiGraph) — зв'язки + провенанс."""
|
| 114 |
+
|
| 115 |
+
def __init__(self) -> None:
|
| 116 |
+
self.g = nx.DiGraph()
|
| 117 |
+
|
| 118 |
+
def add_patient(self, p: Patient) -> str:
|
| 119 |
+
nid = f"Patient/{p.id}"
|
| 120 |
+
self.g.add_node(nid, type="Patient", gender=p.gender)
|
| 121 |
+
return nid
|
| 122 |
+
|
| 123 |
+
def add_document(self, doc_id: str, summary_text: str) -> str:
|
| 124 |
+
"""Явний вузол документа з текстом summary — ціль ребер ``derived_from``.
|
| 125 |
+
|
| 126 |
+
Раніше вузол ``Document/{id}`` існував лише як порожня мішень ребра; тепер
|
| 127 |
+
несе free-text (Stage 1 SGR-summary), тож провенанс стає доказовим.
|
| 128 |
+
"""
|
| 129 |
+
nid = f"Document/{doc_id}"
|
| 130 |
+
self.g.add_node(nid, type="Document", text=summary_text)
|
| 131 |
+
return nid
|
| 132 |
+
|
| 133 |
+
def add_encounter(self, e: Encounter, patient_id: str) -> str:
|
| 134 |
+
nid = f"Encounter/{e.id}"
|
| 135 |
+
self.g.add_node(nid, type="Encounter", status=e.status)
|
| 136 |
+
self.g.add_edge(f"Patient/{patient_id}", nid, rel="has_encounter")
|
| 137 |
+
return nid
|
| 138 |
+
|
| 139 |
+
def add_observation(self, o: Observation, patient_id: str) -> str:
|
| 140 |
+
nid = f"Observation/{o.id}"
|
| 141 |
+
label = _concept_label(o.code, "obs")
|
| 142 |
+
self.g.add_node(nid, type="Observation", label=label)
|
| 143 |
+
self.g.add_edge(f"Patient/{patient_id}", nid, rel="has_observation")
|
| 144 |
+
if o.provenance:
|
| 145 |
+
self.g.add_edge(
|
| 146 |
+
nid, f"Document/{o.provenance.source_doc_id}", rel="derived_from"
|
| 147 |
+
)
|
| 148 |
+
return nid
|
| 149 |
+
|
| 150 |
+
def add_condition(self, c: Condition, patient_id: str) -> str:
|
| 151 |
+
nid = f"Condition/{c.id}"
|
| 152 |
+
label = _concept_label(c.code, "condition")
|
| 153 |
+
self.g.add_node(nid, type="Condition", label=label)
|
| 154 |
+
self.g.add_edge(f"Patient/{patient_id}", nid, rel="has_condition")
|
| 155 |
+
if c.provenance:
|
| 156 |
+
self.g.add_edge(
|
| 157 |
+
nid, f"Document/{c.provenance.source_doc_id}", rel="derived_from"
|
| 158 |
+
)
|
| 159 |
+
return nid
|
| 160 |
+
|
| 161 |
+
def add_medication(self, m: MedicationRequest, patient_id: str) -> str:
|
| 162 |
+
nid = f"MedicationRequest/{m.id}"
|
| 163 |
+
label = _concept_label(m.medicationCodeableConcept, "medication")
|
| 164 |
+
self.g.add_node(nid, type="MedicationRequest", label=label, status=m.status)
|
| 165 |
+
self.g.add_edge(f"Patient/{patient_id}", nid, rel="prescribed")
|
| 166 |
+
if m.reason:
|
| 167 |
+
self.g.add_edge(nid, m.reason.reference, rel="indicated_for")
|
| 168 |
+
if m.provenance:
|
| 169 |
+
self.g.add_edge(
|
| 170 |
+
nid, f"Document/{m.provenance.source_doc_id}", rel="derived_from"
|
| 171 |
+
)
|
| 172 |
+
return nid
|
| 173 |
+
|
| 174 |
+
# ── запити ────────────────────────────────────────────────────────────────
|
| 175 |
+
def provenance_of(self, node_id: str) -> list[str]:
|
| 176 |
+
"""Документи-джерела, з яких виведено вузол."""
|
| 177 |
+
return [
|
| 178 |
+
v
|
| 179 |
+
for _, v, d in self.g.out_edges(node_id, data=True)
|
| 180 |
+
if d.get("rel") == "derived_from"
|
| 181 |
+
]
|
| 182 |
+
|
| 183 |
+
def indication_of(self, medication_node_id: str) -> Optional[str]:
|
| 184 |
+
"""Стан (Condition), заради якого призначено препарат."""
|
| 185 |
+
for _, v, d in self.g.out_edges(medication_node_id, data=True):
|
| 186 |
+
if d.get("rel") == "indicated_for":
|
| 187 |
+
return v
|
| 188 |
+
return None
|
| 189 |
+
|
| 190 |
+
def nodes_of_type(self, node_type: str) -> list[str]:
|
| 191 |
+
return [n for n, d in self.g.nodes(data=True) if d.get("type") == node_type]
|