DocUA commited on
Commit
38622c4
·
verified ·
1 Parent(s): 252b2a1

Deploy LiveMedCard demo (Docker, stub, seeded bilingual card)

Browse files
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: pink
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &amp; 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]