Spaces:
Sleeping
Sleeping
Investor UX: full EN localization, story ribbon, signal grouping, provenance drawer
Browse files- /state?lang=en localizes deterministic signal details and L3 decision
reasons (same finite catalogs: interactions detail_en, EN templates in
l1_safety/l2a_trends/l3_router); UI refetches on language toggle
- LOINC->label map localizes metric names client-side
- story ribbon (dismissible) narrates the caught warfarin+aspirin
interaction; headline alert + L1/L2a signal groups
- provenance drawer: click a source doc chip -> exact extracted span
highlighted in the source document (GET /document/{id} + span in /state)
- trust strip (no cloud LLM / deterministic rules / FHIR / local-first)
and footer links (GitHub, traces.solutions, TRACE arXiv)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- livemedcard/api.py +18 -5
- livemedcard/data/interactions.json +9 -5
- livemedcard/data/reference_ranges.json +9 -9
- livemedcard/layers/l1_safety.py +30 -19
- livemedcard/layers/l2a_trends.py +34 -13
- livemedcard/layers/l3_router.py +16 -2
- livemedcard/pipeline.py +4 -4
- livemedcard/refmodels.py +4 -1
- livemedcard/static/index.html +172 -16
livemedcard/api.py
CHANGED
|
@@ -266,14 +266,26 @@ def signals():
|
|
| 266 |
return _state.card.current_signals()
|
| 267 |
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
@app.get("/state")
|
| 270 |
-
def state():
|
| 271 |
-
"""Агрегований знімок картки для інтерфейсу."""
|
|
|
|
| 272 |
with _state.lock:
|
| 273 |
card = _state.card
|
| 274 |
-
sigs = card.current_signals()
|
| 275 |
# Рішення L3 над поточним станом (сигнали детерміновані → впевненість 1.0).
|
| 276 |
-
|
|
|
|
| 277 |
sex = card.patient.gender
|
| 278 |
|
| 279 |
series = []
|
|
@@ -287,7 +299,8 @@ def state():
|
|
| 287 |
"low": ref.low if ref else None,
|
| 288 |
"high": ref.high if ref else None,
|
| 289 |
"points": [
|
| 290 |
-
{"ts": p.ts.date().isoformat(), "value": p.value,
|
|
|
|
| 291 |
for p in pts
|
| 292 |
],
|
| 293 |
})
|
|
|
|
| 266 |
return _state.card.current_signals()
|
| 267 |
|
| 268 |
|
| 269 |
+
@app.get("/document/{doc_id}")
|
| 270 |
+
def document(doc_id: str):
|
| 271 |
+
"""Free-text іпостась документа (вузол графа) — для span-провенансу в UI."""
|
| 272 |
+
with _state.lock:
|
| 273 |
+
node = _state.card.graph.g.nodes.get(f"Document/{doc_id}")
|
| 274 |
+
if node is None or "text" not in node:
|
| 275 |
+
raise HTTPException(status_code=404, detail=f"Документ {doc_id} не знайдено")
|
| 276 |
+
return {"id": doc_id, "text": node["text"]}
|
| 277 |
+
|
| 278 |
+
|
| 279 |
@app.get("/state")
|
| 280 |
+
def state(lang: str = "uk"):
|
| 281 |
+
"""Агрегований знімок картки для інтерфейсу (`lang=uk|en` — мова текстів сигналів)."""
|
| 282 |
+
lang = "en" if lang == "en" else "uk"
|
| 283 |
with _state.lock:
|
| 284 |
card = _state.card
|
| 285 |
+
sigs = card.current_signals(lang)
|
| 286 |
# Рішення L3 над поточним станом (сигнали детерміновані → впевненість 1.0).
|
| 287 |
+
doc_kind = "card state" if lang == "en" else "стан картки"
|
| 288 |
+
decision = l3_router.route(sigs, 1.0, doc_kind, lang)
|
| 289 |
sex = card.patient.gender
|
| 290 |
|
| 291 |
series = []
|
|
|
|
| 299 |
"low": ref.low if ref else None,
|
| 300 |
"high": ref.high if ref else None,
|
| 301 |
"points": [
|
| 302 |
+
{"ts": p.ts.date().isoformat(), "value": p.value,
|
| 303 |
+
"source": p.source_doc_id, "span": p.source_span}
|
| 304 |
for p in pts
|
| 305 |
],
|
| 306 |
})
|
livemedcard/data/interactions.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
| 1 |
{
|
| 2 |
-
"_note": "Демо-підмножина взаємодій. У проді — відкрита БД RxNorm/NLM. Коди RxNorm. Пол
|
| 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 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"_note": "Демо-підмножина взаємодій. У проді — відкрита БД RxNorm/NLM. Коди RxNorm. Поля detail/detail_en — лише механізм (UA/EN); назви препаратів додає L1 з фактичних ресурсів.",
|
| 3 |
"pairs": [
|
| 4 |
{"a": "11289", "b": "1191", "severity": "major",
|
| 5 |
+
"detail": "сумація антитромботичного ефекту, підвищений ризик кровотечі",
|
| 6 |
+
"detail_en": "additive antithrombotic effect, increased bleeding risk"},
|
| 7 |
{"a": "11289", "b": "703", "severity": "major",
|
| 8 |
+
"detail": "аміодарон інгібує метаболізм варфарину, ризик кровотечі",
|
| 9 |
+
"detail_en": "amiodarone inhibits warfarin metabolism, bleeding risk"},
|
| 10 |
{"a": "11289", "b": "5640", "severity": "major",
|
| 11 |
+
"detail": "підвищений ризик шлунково-кишкової кровотечі",
|
| 12 |
+
"detail_en": "increased risk of gastrointestinal bleeding"},
|
| 13 |
{"a": "5640", "b": "29046", "severity": "moderate",
|
| 14 |
+
"detail": "зниження антигіпертензивного ефекту та ризик для нирок",
|
| 15 |
+
"detail_en": "reduced antihypertensive effect and risk to the kidneys"}
|
| 16 |
]
|
| 17 |
}
|
livemedcard/data/reference_ranges.json
CHANGED
|
@@ -1,21 +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 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"_note": "Референсні межі показників. Коди LOINC. keyword/keyword_en — назва UA/EN. default — загальні межі; by_sex — уточнення за статтю.",
|
| 3 |
"ranges": {
|
| 4 |
"2160-0": {
|
| 5 |
+
"keyword": "Креатинін", "keyword_en": "Creatinine", "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", "keyword_en": "HbA1c", "unit": "%", "default": {"low": 4.0, "high": 6.0}},
|
| 10 |
+
"2823-3": {"keyword": "Калій", "keyword_en": "Potassium", "unit": "mmol/L", "default": {"low": 3.5, "high": 5.1}},
|
| 11 |
+
"8480-6": {"keyword": "Систолічний тиск", "keyword_en": "Systolic BP", "unit": "mmHg", "default": {"low": 90, "high": 140}},
|
| 12 |
+
"2345-7": {"keyword": "Глюкоза", "keyword_en": "Glucose", "unit": "mmol/L", "default": {"low": 3.9, "high": 6.1}},
|
| 13 |
"718-7": {
|
| 14 |
+
"keyword": "Гемоглобін", "keyword_en": "Hemoglobin", "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": "АЛТ", "keyword_en": "ALT", "unit": "U/L", "default": {"low": 0, "high": 41}},
|
| 19 |
+
"2093-3": {"keyword": "Холестерин загальний", "keyword_en": "Total cholesterol", "unit": "mmol/L", "default": {"low": 0, "high": 5.2}}
|
| 20 |
}
|
| 21 |
}
|
livemedcard/layers/l1_safety.py
CHANGED
|
@@ -21,18 +21,20 @@ def _pair_map() -> dict[frozenset[str], Interaction]:
|
|
| 21 |
return {entry.pair(): entry for entry in load_interactions()}
|
| 22 |
|
| 23 |
|
| 24 |
-
def _display_name(m: MedicationRequest) -> str:
|
| 25 |
-
"""Назва препарату для тексту сигналу
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
cc = m.medicationCodeableConcept
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 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] = []
|
|
@@ -43,13 +45,14 @@ def check_interactions(meds: list[MedicationRequest]) -> list[Signal]:
|
|
| 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}: {
|
| 53 |
subjects=[f"MedicationRequest/{m1.id}", f"MedicationRequest/{m2.id}"],
|
| 54 |
confidence=1.0,
|
| 55 |
)
|
|
@@ -57,7 +60,7 @@ def check_interactions(meds: list[MedicationRequest]) -> list[Signal]:
|
|
| 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] = []
|
|
@@ -69,16 +72,24 @@ def check_dose_limits(meds: list[MedicationRequest]) -> list[Signal]:
|
|
| 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 |
)
|
|
@@ -86,6 +97,6 @@ def check_dose_limits(meds: list[MedicationRequest]) -> list[Signal]:
|
|
| 86 |
return signals
|
| 87 |
|
| 88 |
|
| 89 |
-
def run(meds: list[MedicationRequest]) -> list[Signal]:
|
| 90 |
"""Повний прогін L1: взаємодії + межі доз, у стабільному порядку."""
|
| 91 |
-
return check_interactions(meds) + check_dose_limits(meds)
|
|
|
|
| 21 |
return {entry.pair(): entry for entry in load_interactions()}
|
| 22 |
|
| 23 |
|
| 24 |
+
def _display_name(m: MedicationRequest, lang: str = "uk") -> str:
|
| 25 |
+
"""Назва препарату для тексту сигналу.
|
| 26 |
+
|
| 27 |
+
uk: text CodeableConcept (мова документа) → display coding → RxNorm-код.
|
| 28 |
+
en: display coding (у vocab він англомовний) → text → RxNorm-код.
|
| 29 |
+
"""
|
| 30 |
cc = m.medicationCodeableConcept
|
| 31 |
+
display = next((c.display for c in cc.coding if c.display), None)
|
| 32 |
+
if lang == "en":
|
| 33 |
+
return display or cc.text or rxnorm_of(m) or m.id
|
| 34 |
+
return cc.text or display or rxnorm_of(m) or m.id
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
+
def check_interactions(meds: list[MedicationRequest], lang: str = "uk") -> list[Signal]:
|
| 38 |
"""Перевірка кожної неупорядкованої пари ліків на відому взаємодію (RxNorm)."""
|
| 39 |
pair_map = _pair_map()
|
| 40 |
signals: list[Signal] = []
|
|
|
|
| 45 |
entry = pair_map.get(frozenset((rx1, rx2)))
|
| 46 |
if entry is None:
|
| 47 |
continue
|
| 48 |
+
name1, name2 = _display_name(m1, lang), _display_name(m2, lang)
|
| 49 |
+
mechanism = entry.detail_en if lang == "en" and entry.detail_en else entry.detail
|
| 50 |
signals.append(
|
| 51 |
Signal(
|
| 52 |
layer="L1",
|
| 53 |
kind="interaction",
|
| 54 |
severity=entry.severity,
|
| 55 |
+
detail=f"{name1} + {name2}: {mechanism}",
|
| 56 |
subjects=[f"MedicationRequest/{m1.id}", f"MedicationRequest/{m2.id}"],
|
| 57 |
confidence=1.0,
|
| 58 |
)
|
|
|
|
| 60 |
return signals
|
| 61 |
|
| 62 |
|
| 63 |
+
def check_dose_limits(meds: list[MedicationRequest], lang: str = "uk") -> list[Signal]:
|
| 64 |
"""Перевірка кожних ліків на перевищення максимальної добової дози (RxNorm)."""
|
| 65 |
limits = load_dose_limits()
|
| 66 |
signals: list[Signal] = []
|
|
|
|
| 72 |
continue
|
| 73 |
limit = limits[rx]
|
| 74 |
if m.dose_mg_per_day > limit.max_daily:
|
| 75 |
+
if lang == "en":
|
| 76 |
+
detail = (
|
| 77 |
+
f"{_display_name(m, 'en')}: daily dose {m.dose_mg_per_day:g} "
|
| 78 |
+
f"{limit.unit} exceeds the daily limit of "
|
| 79 |
+
f"{limit.max_daily:g} {limit.unit}"
|
| 80 |
+
)
|
| 81 |
+
else:
|
| 82 |
+
detail = (
|
| 83 |
+
f"{limit.name}: добова доза {m.dose_mg_per_day:g} "
|
| 84 |
+
f"{limit.unit} — перевищено добову межу "
|
| 85 |
+
f"{limit.max_daily:g} {limit.unit}"
|
| 86 |
+
)
|
| 87 |
signals.append(
|
| 88 |
Signal(
|
| 89 |
layer="L1",
|
| 90 |
kind="dose_limit",
|
| 91 |
severity="major",
|
| 92 |
+
detail=detail,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
subjects=[f"MedicationRequest/{m.id}"],
|
| 94 |
confidence=1.0,
|
| 95 |
)
|
|
|
|
| 97 |
return signals
|
| 98 |
|
| 99 |
|
| 100 |
+
def run(meds: list[MedicationRequest], lang: str = "uk") -> list[Signal]:
|
| 101 |
"""Повний прогін L1: взаємодії + межі доз, у стабільному порядку."""
|
| 102 |
+
return check_interactions(meds, lang) + check_dose_limits(meds, lang)
|
livemedcard/layers/l2a_trends.py
CHANGED
|
@@ -17,7 +17,16 @@ def _fmt(v: float) -> str:
|
|
| 17 |
return f"{v:g}"
|
| 18 |
|
| 19 |
|
| 20 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
"""Стійкий монотонний тренд (>=3 вимірювань, всі зростають або всі спадають)."""
|
| 22 |
signals: list[Signal] = []
|
| 23 |
for loinc in store.loincs():
|
|
@@ -31,13 +40,22 @@ def detect_trends(store: FactStore) -> list[Signal]:
|
|
| 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 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
signals.append(
|
| 42 |
Signal(
|
| 43 |
layer="L2a",
|
|
@@ -51,7 +69,9 @@ def detect_trends(store: FactStore) -> list[Signal]:
|
|
| 51 |
return signals
|
| 52 |
|
| 53 |
|
| 54 |
-
def detect_range_breaches(
|
|
|
|
|
|
|
| 55 |
"""Останнє значення показника поза референсною межею (з урахуванням статі)."""
|
| 56 |
signals: list[Signal] = []
|
| 57 |
for loinc in store.loincs():
|
|
@@ -65,14 +85,15 @@ def detect_range_breaches(store: FactStore, sex: Optional[str] = None) -> list[S
|
|
| 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"{
|
| 76 |
f"({_fmt(ref.low)}–{_fmt(ref.high)})"
|
| 77 |
)
|
| 78 |
signals.append(
|
|
@@ -88,6 +109,6 @@ def detect_range_breaches(store: FactStore, sex: Optional[str] = None) -> list[S
|
|
| 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)
|
|
|
|
| 17 |
return f"{v:g}"
|
| 18 |
|
| 19 |
|
| 20 |
+
def _keyword_for(loinc: str, fallback: str, lang: str) -> str:
|
| 21 |
+
"""Назва показника мовою відповіді: keyword_en з довідника меж, інакше — з факту."""
|
| 22 |
+
if lang == "en":
|
| 23 |
+
ref = range_for(loinc, None)
|
| 24 |
+
if ref is not None and ref.keyword_en:
|
| 25 |
+
return ref.keyword_en
|
| 26 |
+
return fallback
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def detect_trends(store: FactStore, lang: str = "uk") -> list[Signal]:
|
| 30 |
"""Стійкий монотонний тренд (>=3 вимірювань, всі зростають або всі спадають)."""
|
| 31 |
signals: list[Signal] = []
|
| 32 |
for loinc in store.loincs():
|
|
|
|
| 40 |
falling = all(a >= b for a, b in zip(values, values[1:])) and values[-1] < values[0]
|
| 41 |
if not (rising or falling):
|
| 42 |
continue
|
|
|
|
| 43 |
first, last = series[0], series[-1]
|
| 44 |
+
keyword = _keyword_for(loinc, first.keyword, lang)
|
| 45 |
+
if lang == "en":
|
| 46 |
+
direction = "rising" if rising else "falling"
|
| 47 |
+
detail = (
|
| 48 |
+
f"{keyword} {direction} {len(series)} measurements in a row: "
|
| 49 |
+
f"{_fmt(first.value)}→{_fmt(last.value)} "
|
| 50 |
+
f"({first.ts.date().isoformat()}..{last.ts.date().isoformat()})"
|
| 51 |
+
)
|
| 52 |
+
else:
|
| 53 |
+
direction = "зростає" if rising else "знижується"
|
| 54 |
+
detail = (
|
| 55 |
+
f"{keyword} {direction} {len(series)} вимірювань поспіль: "
|
| 56 |
+
f"{_fmt(first.value)}→{_fmt(last.value)} "
|
| 57 |
+
f"({first.ts.date().isoformat()}..{last.ts.date().isoformat()})"
|
| 58 |
+
)
|
| 59 |
signals.append(
|
| 60 |
Signal(
|
| 61 |
layer="L2a",
|
|
|
|
| 69 |
return signals
|
| 70 |
|
| 71 |
|
| 72 |
+
def detect_range_breaches(
|
| 73 |
+
store: FactStore, sex: Optional[str] = None, lang: str = "uk"
|
| 74 |
+
) -> list[Signal]:
|
| 75 |
"""Останнє значення показника поза референсною межею (з урахуванням статі)."""
|
| 76 |
signals: list[Signal] = []
|
| 77 |
for loinc in store.loincs():
|
|
|
|
| 85 |
# останній доданий), щоб L2a і Q&A узгоджено бачили те саме значення.
|
| 86 |
latest = series[-1]
|
| 87 |
if latest.value < ref.low:
|
| 88 |
+
direction = "below range" if lang == "en" else "нижче норми"
|
| 89 |
elif latest.value > ref.high:
|
| 90 |
+
direction = "above range" if lang == "en" else "вище норми"
|
| 91 |
else:
|
| 92 |
continue
|
| 93 |
unit = latest.unit or ref.unit or ""
|
| 94 |
+
keyword = (ref.keyword_en if lang == "en" and ref.keyword_en else ref.keyword)
|
| 95 |
detail = (
|
| 96 |
+
f"{keyword} {_fmt(latest.value)} {unit} {direction} "
|
| 97 |
f"({_fmt(ref.low)}–{_fmt(ref.high)})"
|
| 98 |
)
|
| 99 |
signals.append(
|
|
|
|
| 109 |
return signals
|
| 110 |
|
| 111 |
|
| 112 |
+
def run(store: FactStore, sex: Optional[str] = None, lang: str = "uk") -> list[Signal]:
|
| 113 |
"""Усі сигнали L2a: тренди + порушення референсних меж."""
|
| 114 |
+
return detect_trends(store, lang) + detect_range_breaches(store, sex, lang)
|
livemedcard/layers/l3_router.py
CHANGED
|
@@ -19,7 +19,12 @@ from ..dto import Decision, Severity, Signal, severity_rank
|
|
| 19 |
__all__ = ["route", "LOW_CONFIDENCE"]
|
| 20 |
|
| 21 |
|
| 22 |
-
def route(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
"""Приймає рішення про маршрут документа: "auto" чи "L4_human"."""
|
| 24 |
top: Optional[Signal] = max(
|
| 25 |
signals, key=lambda s: severity_rank(s.severity), default=None
|
|
@@ -30,22 +35,31 @@ def route(signals: list[Signal], extraction_confidence: float, doc_kind: str) ->
|
|
| 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",
|
|
|
|
| 19 |
__all__ = ["route", "LOW_CONFIDENCE"]
|
| 20 |
|
| 21 |
|
| 22 |
+
def route(
|
| 23 |
+
signals: list[Signal],
|
| 24 |
+
extraction_confidence: float,
|
| 25 |
+
doc_kind: str,
|
| 26 |
+
lang: str = "uk",
|
| 27 |
+
) -> Decision:
|
| 28 |
"""Приймає рішення про маршрут документа: "auto" чи "L4_human"."""
|
| 29 |
top: Optional[Signal] = max(
|
| 30 |
signals, key=lambda s: severity_rank(s.severity), default=None
|
|
|
|
| 35 |
low_confidence = extraction_confidence < LOW_CONFIDENCE
|
| 36 |
escalate = high_risk or low_confidence
|
| 37 |
|
| 38 |
+
en = lang == "en"
|
| 39 |
reasons: list[str] = []
|
| 40 |
if high_risk and top is not None:
|
| 41 |
reasons.append(
|
| 42 |
+
f"Deterministic “{top.severity}” signal ({top.kind}) — "
|
| 43 |
+
f"human decision required regardless of extraction confidence"
|
| 44 |
+
if en else
|
| 45 |
f"Детермінований сигнал рівня «{top.severity}» ({top.kind}) — "
|
| 46 |
f"рішення за людиною незалежно від впевненості витягу"
|
| 47 |
)
|
| 48 |
if low_confidence:
|
| 49 |
reasons.append(
|
| 50 |
+
f"Low extraction confidence ({extraction_confidence:.2f}) — "
|
| 51 |
+
f"human review required"
|
| 52 |
+
if en else
|
| 53 |
f"Низька впевненість витягу ({extraction_confidence:.2f}) — "
|
| 54 |
f"потрібна перевірка людиною"
|
| 55 |
)
|
| 56 |
if not escalate:
|
| 57 |
reasons.append(
|
| 58 |
+
"No high-risk signals, extraction confident — automatic processing"
|
| 59 |
+
if en else
|
| 60 |
"Немає сигналів високого ризику, витяг впевнений — автоматична обробка"
|
| 61 |
)
|
| 62 |
+
reasons.append(f"Document type: {doc_kind}" if en else f"Тип документа: {doc_kind}")
|
| 63 |
|
| 64 |
return Decision(
|
| 65 |
route_to="L4_human" if escalate else "auto",
|
livemedcard/pipeline.py
CHANGED
|
@@ -98,10 +98,10 @@ class LiveMedCard:
|
|
| 98 |
self._docref_ids.add(dref.id)
|
| 99 |
|
| 100 |
# ── L1 + L2a на поточному стані сховищ ────────────────────────────────────
|
| 101 |
-
def current_signals(self) -> list[Signal]:
|
| 102 |
-
"""Детерміновані сигнали над усім накопиченим станом."""
|
| 103 |
-
return l1_safety.run(self._medications) + l2a_trends.run(
|
| 104 |
-
self.factstore, self.patient.gender
|
| 105 |
)
|
| 106 |
|
| 107 |
def ingest(self, doc: Document) -> TraceReport:
|
|
|
|
| 98 |
self._docref_ids.add(dref.id)
|
| 99 |
|
| 100 |
# ── L1 + L2a на поточному стані сховищ ────────────────────────────────────
|
| 101 |
+
def current_signals(self, lang: str = "uk") -> list[Signal]:
|
| 102 |
+
"""Детерміновані сигнали над усім накопиченим станом (тексти — UA/EN)."""
|
| 103 |
+
return l1_safety.run(self._medications, lang) + l2a_trends.run(
|
| 104 |
+
self.factstore, self.patient.gender, lang
|
| 105 |
)
|
| 106 |
|
| 107 |
def ingest(self, doc: Document) -> TraceReport:
|
livemedcard/refmodels.py
CHANGED
|
@@ -25,6 +25,7 @@ class Interaction(FrozenModel):
|
|
| 25 |
b: str # RxNorm
|
| 26 |
severity: Severity
|
| 27 |
detail: str
|
|
|
|
| 28 |
|
| 29 |
def pair(self) -> frozenset[str]:
|
| 30 |
return frozenset((self.a, self.b))
|
|
@@ -46,6 +47,7 @@ class RangeSpec(FrozenModel):
|
|
| 46 |
"""Референсні межі показника з опціональними варіантами за статтю."""
|
| 47 |
|
| 48 |
keyword: str
|
|
|
|
| 49 |
unit: str
|
| 50 |
default: Bounds
|
| 51 |
by_sex: dict[str, Bounds] = {}
|
|
@@ -53,7 +55,7 @@ class RangeSpec(FrozenModel):
|
|
| 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 |
)
|
|
@@ -63,6 +65,7 @@ class RefRange(FrozenModel):
|
|
| 63 |
"""Розвʼязані межі для конкретного пацієнта (стать враховано)."""
|
| 64 |
|
| 65 |
keyword: str
|
|
|
|
| 66 |
unit: str
|
| 67 |
low: float
|
| 68 |
high: float
|
|
|
|
| 25 |
b: str # RxNorm
|
| 26 |
severity: Severity
|
| 27 |
detail: str
|
| 28 |
+
detail_en: str = "" # англомовний механізм (для /state?lang=en)
|
| 29 |
|
| 30 |
def pair(self) -> frozenset[str]:
|
| 31 |
return frozenset((self.a, self.b))
|
|
|
|
| 47 |
"""Референсні межі показника з опціональними варіантами за статтю."""
|
| 48 |
|
| 49 |
keyword: str
|
| 50 |
+
keyword_en: str = "" # англомовна назва показника (для /state?lang=en)
|
| 51 |
unit: str
|
| 52 |
default: Bounds
|
| 53 |
by_sex: dict[str, Bounds] = {}
|
|
|
|
| 55 |
def resolve(self, sex: Optional[str]) -> "RefRange":
|
| 56 |
bounds = self.by_sex.get(sex or "", self.default)
|
| 57 |
return RefRange(
|
| 58 |
+
keyword=self.keyword, keyword_en=self.keyword_en, unit=self.unit,
|
| 59 |
low=bounds.low, high=bounds.high,
|
| 60 |
sex=sex if (sex and sex in self.by_sex) else None,
|
| 61 |
)
|
|
|
|
| 65 |
"""Розвʼязані межі для конкретного пацієнта (стать враховано)."""
|
| 66 |
|
| 67 |
keyword: str
|
| 68 |
+
keyword_en: str = ""
|
| 69 |
unit: str
|
| 70 |
low: float
|
| 71 |
high: float
|
livemedcard/static/index.html
CHANGED
|
@@ -113,6 +113,23 @@
|
|
| 113 |
button.ghost{background:transparent}
|
| 114 |
button:disabled{opacity:.55;cursor:default;transform:none}
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
/* ── layout ─────────────────────────────────────────── */
|
| 117 |
.grid{display:grid;grid-template-columns:1fr;gap:18px}
|
| 118 |
@media(min-width:900px){.grid{grid-template-columns:1.18fr .82fr}}
|
|
@@ -145,6 +162,9 @@
|
|
| 145 |
.status{font-size:11.5px;font-weight:700}
|
| 146 |
.status.ok{color:var(--ok)} .status.high{color:var(--major)} .status.low{color:var(--moderate)}
|
| 147 |
.metric .src{color:var(--faint);font-size:11px;margin-top:7px;display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
|
|
|
|
|
|
|
|
|
| 148 |
.lang{font:700 10px/1 var(--mono);padding:2px 5px;border-radius:5px;border:1px solid var(--line)}
|
| 149 |
.lang.ua{color:var(--accent)} .lang.en{color:#5e9dff}
|
| 150 |
.spark{margin-top:9px}
|
|
@@ -163,8 +183,20 @@
|
|
| 163 |
font-size:12px;font-weight:700;color:#fff}
|
| 164 |
.decision .t{font:600 12.5px/1 var(--mono);color:var(--muted);margin-left:auto}
|
| 165 |
.reasons{margin:8px 0 0;padding-left:18px;color:var(--muted);font-size:12.5px;flex-basis:100%}
|
| 166 |
-
.sig{display:flex;gap:11px;padding:12px 0
|
| 167 |
-
.sig
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
.sig .dot{flex:0 0 auto;width:11px;height:11px;border-radius:50%;margin-top:4px;position:relative}
|
| 169 |
.sig.hot .dot::after{content:"";position:absolute;inset:-4px;border-radius:50%;
|
| 170 |
border:2px solid currentColor;opacity:.5;animation:ping 1.8s cubic-bezier(0,0,.2,1) infinite}
|
|
@@ -214,8 +246,31 @@
|
|
| 214 |
text-align:center;font-size:13px;color:var(--muted)}
|
| 215 |
.dropzone:hover,.dropzone.dragover{border-color:var(--accent);background:var(--accent-soft);color:var(--ink)}
|
| 216 |
|
| 217 |
-
footer{color:var(--faint);font-size:12px;margin-top:
|
| 218 |
footer b{color:var(--muted)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%) translateY(8px);
|
| 220 |
background:var(--ink);color:var(--bg);padding:11px 18px;border-radius:11px;font-weight:600;font-size:13.5px;
|
| 221 |
box-shadow:0 10px 30px rgba(0,0,0,.35);opacity:0;transition:.28s;pointer-events:none;z-index:50}
|
|
@@ -288,6 +343,11 @@
|
|
| 288 |
</div>
|
| 289 |
</div>
|
| 290 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
<div class="grid" style="margin-top:18px">
|
| 292 |
<section class="panel d1">
|
| 293 |
<h2><span id="h-series">Metrics timeline</span> <span class="tag" id="bi-tag" style="display:none">bilingual · UA + EN</span></h2>
|
|
@@ -317,9 +377,19 @@
|
|
| 317 |
<div class="chips" id="chips"></div>
|
| 318 |
</section>
|
| 319 |
|
|
|
|
| 320 |
<footer id="site-footer">Prototype for <b>Digital Future Hackathon</b> · Aging & Longevity track.<br>
|
| 321 |
The system structures facts and shows trends — <b>it does not diagnose or prescribe treatment</b>.</footer>
|
| 322 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
<div class="toast" id="toast"></div>
|
| 324 |
|
| 325 |
<script>
|
|
@@ -333,6 +403,19 @@ const LANG_KEY='lmc_lang';
|
|
| 333 |
let LANG = localStorage.getItem(LANG_KEY) || 'en';
|
| 334 |
let lastState = null;
|
| 335 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
const I18N = {
|
| 337 |
en: {
|
| 338 |
htmlLang:'en', title:'LiveMedCard — Living Medical Card',
|
|
@@ -369,6 +452,12 @@ const I18N = {
|
|
| 369 |
errorPrefix:'Error: ', selected:name=>`Selected: ${name}`,
|
| 370 |
chips:['What about kidneys over the last year?','What was potassium?',
|
| 371 |
'What medications and any interactions?','What about HbA1c?'],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
},
|
| 373 |
ua: {
|
| 374 |
htmlLang:'uk', title:'LiveMedCard — жива медична картка',
|
|
@@ -405,6 +494,12 @@ const I18N = {
|
|
| 405 |
errorPrefix:'Помилка: ', selected:name=>`Вибрано: ${name}`,
|
| 406 |
chips:['Що з нирками за останній рік?','Який був калій?',
|
| 407 |
'Які ліки приймає і чи є взаємодії?','Що з HbA1c?'],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
},
|
| 409 |
};
|
| 410 |
|
|
@@ -443,7 +538,11 @@ function applyLang(lang){
|
|
| 443 |
$('#qa-tag').textContent = t.qaTag;
|
| 444 |
$('#q').placeholder = t.qPlaceholder;
|
| 445 |
$('#ask').textContent = t.ask;
|
| 446 |
-
$('#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
buildChips();
|
| 448 |
if(lastState) render(lastState);
|
| 449 |
}
|
|
@@ -460,7 +559,7 @@ function applyLang(lang){
|
|
| 460 |
|
| 461 |
$('#langsw').addEventListener('click', e=>{
|
| 462 |
const btn=e.target.closest('button[data-lang]'); if(!btn) return;
|
| 463 |
-
if(btn.dataset.lang!==LANG)
|
| 464 |
});
|
| 465 |
|
| 466 |
function toast(msg){const t=$('#toast');t.textContent=msg;t.classList.add('show');
|
|
@@ -545,23 +644,39 @@ function render(st){
|
|
| 545 |
<div class="stat bi"><div class="n">${bilingual?'UA+EN':(langs.has('en')?'EN':'UA')}</div><div class="l">${t.statLangs}</div></div>
|
| 546 |
` : '';
|
| 547 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
// series
|
| 549 |
const sEl=$('#series');
|
| 550 |
if(!st.series.length){sEl.innerHTML=`<div class="empty">${t.seriesEmpty}</div>`;}
|
| 551 |
else{sEl.innerHTML=st.series.map((s,idx)=>{
|
| 552 |
const last=s.points.at(-1);const [txt,cls]=statusOf(s);const [ttxt,tcls]=trendOf(s);
|
|
|
|
| 553 |
const ref=(s.low!=null&&s.high!=null)?`${t.range} ${s.low}–${s.high} ${s.unit||''}`:'';
|
| 554 |
const srcs=[...new Set(s.points.map(p=>p.source).filter(Boolean))];
|
| 555 |
const langBadges=[...new Set(srcs.map(langOf))].map(l=>`<span class="lang ${l}">${l.toUpperCase()}</span>`).join('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
return `<div class="metric" style="animation-delay:${(idx*.05).toFixed(2)}s">
|
| 557 |
<div class="row">
|
| 558 |
-
<span class="name">${
|
| 559 |
<span class="big"><span class="val">${last.value}</span><span class="unit">${s.unit||''}</span>
|
| 560 |
${ttxt?`<span class="trend ${tcls}">${ttxt}</span>`:''}</span>
|
| 561 |
</div>
|
| 562 |
<div class="meta"><span class="ref">${ref}</span><span class="status ${cls}">${txt}</span></div>
|
| 563 |
${sparkline(s.points,s.low,s.high)}
|
| 564 |
-
<div class="src">${langBadges}<span>${t.meas(s.points.length)}
|
| 565 |
</div>`;}).join('');}
|
| 566 |
|
| 567 |
// decision
|
|
@@ -572,16 +687,25 @@ function render(st){
|
|
| 572 |
<span class="t">${d.route_to}</span>
|
| 573 |
<ul class="reasons">${d.reasons.map(r=>`<li>${r}</li>`).join('')}</ul>`;
|
| 574 |
|
| 575 |
-
// signals —
|
| 576 |
const sig=$('#signals');
|
| 577 |
if(!st.signals.length){sig.innerHTML=`<div class="empty">${t.signalsEmpty}</div>`;}
|
| 578 |
-
else{
|
| 579 |
-
const
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
<
|
| 583 |
-
|
| 584 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 585 |
|
| 586 |
// meds — flag those named in an interaction
|
| 587 |
const interText=st.signals.filter(x=>x.kind==='interaction').map(x=>x.detail.toLowerCase()).join(' ');
|
|
@@ -592,7 +716,39 @@ function render(st){
|
|
| 592 |
}).join(''):`<div class="empty">${t.medsEmpty}</div>`;
|
| 593 |
}
|
| 594 |
|
| 595 |
-
async function refresh(){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
|
| 597 |
async function ask(q){
|
| 598 |
q=(q||$('#q').value).trim(); if(!q)return;
|
|
|
|
| 113 |
button.ghost{background:transparent}
|
| 114 |
button:disabled{opacity:.55;cursor:default;transform:none}
|
| 115 |
|
| 116 |
+
/* ── story ribbon (investor orientation; shown only with an interaction alert) */
|
| 117 |
+
.story{display:none;align-items:flex-start;gap:12px;margin:0 0 4px;padding:13px 16px;
|
| 118 |
+
background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--major);
|
| 119 |
+
border-radius:13px;box-shadow:var(--shadow);animation:rise .5s .1s both}
|
| 120 |
+
.story.show{display:flex}
|
| 121 |
+
.story .txt{font-size:13.5px;line-height:1.55}
|
| 122 |
+
.story b{color:var(--major)}
|
| 123 |
+
.story .x{flex:0 0 auto;margin-left:auto;background:none;border:none;color:var(--faint);
|
| 124 |
+
font-size:17px;line-height:1;padding:2px 6px;cursor:pointer}
|
| 125 |
+
.story .x:hover{color:var(--ink);transform:none}
|
| 126 |
+
|
| 127 |
+
/* ── trust strip + footer links ─────────────────────── */
|
| 128 |
+
.trust{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:28px}
|
| 129 |
+
.trust span{font:600 11px/1 var(--mono);color:var(--muted);border:1px solid var(--line);
|
| 130 |
+
border-radius:999px;padding:6px 12px;background:var(--panel)}
|
| 131 |
+
.flinks{margin-top:10px;font-family:var(--mono);font-size:11.5px}
|
| 132 |
+
|
| 133 |
/* ── layout ─────────────────────────────────────────── */
|
| 134 |
.grid{display:grid;grid-template-columns:1fr;gap:18px}
|
| 135 |
@media(min-width:900px){.grid{grid-template-columns:1.18fr .82fr}}
|
|
|
|
| 162 |
.status{font-size:11.5px;font-weight:700}
|
| 163 |
.status.ok{color:var(--ok)} .status.high{color:var(--major)} .status.low{color:var(--moderate)}
|
| 164 |
.metric .src{color:var(--faint);font-size:11px;margin-top:7px;display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
| 165 |
+
.srcchip{font:600 10.5px/1 var(--mono);color:var(--muted);background:transparent;
|
| 166 |
+
border:1px solid var(--line);border-radius:999px;padding:3px 9px;cursor:pointer;transition:.14s}
|
| 167 |
+
.srcchip:hover{color:var(--accent);border-color:var(--accent);transform:none}
|
| 168 |
.lang{font:700 10px/1 var(--mono);padding:2px 5px;border-radius:5px;border:1px solid var(--line)}
|
| 169 |
.lang.ua{color:var(--accent)} .lang.en{color:#5e9dff}
|
| 170 |
.spark{margin-top:9px}
|
|
|
|
| 183 |
font-size:12px;font-weight:700;color:#fff}
|
| 184 |
.decision .t{font:600 12.5px/1 var(--mono);color:var(--muted);margin-left:auto}
|
| 185 |
.reasons{margin:8px 0 0;padding-left:18px;color:var(--muted);font-size:12.5px;flex-basis:100%}
|
| 186 |
+
.sig{display:flex;gap:11px;padding:12px 0}
|
| 187 |
+
.sig + .sig{border-top:1px solid var(--line)}
|
| 188 |
+
.siggroup{font:600 10px/1 var(--mono);color:var(--faint);text-transform:uppercase;
|
| 189 |
+
letter-spacing:.08em;margin:12px 0 3px;padding-top:10px;border-top:1px solid var(--line)}
|
| 190 |
+
.sig.headline{border:1px solid color-mix(in srgb,var(--major) 45%,var(--line));
|
| 191 |
+
border-left:4px solid var(--major);border-radius:12px;padding:13px 14px;margin-bottom:6px;
|
| 192 |
+
background:color-mix(in srgb,var(--major) 8%,transparent);
|
| 193 |
+
animation:rise .45s .5s both, alertland 1.4s 1s 1}
|
| 194 |
+
.sig.headline + .sig{border-top:none}
|
| 195 |
+
.sig.headline .d{font-size:14.5px;font-weight:600}
|
| 196 |
+
@keyframes alertland{
|
| 197 |
+
0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--major) 45%,transparent)}
|
| 198 |
+
60%{box-shadow:0 0 0 13px transparent}
|
| 199 |
+
100%{box-shadow:0 0 0 0 transparent}}
|
| 200 |
.sig .dot{flex:0 0 auto;width:11px;height:11px;border-radius:50%;margin-top:4px;position:relative}
|
| 201 |
.sig.hot .dot::after{content:"";position:absolute;inset:-4px;border-radius:50%;
|
| 202 |
border:2px solid currentColor;opacity:.5;animation:ping 1.8s cubic-bezier(0,0,.2,1) infinite}
|
|
|
|
| 246 |
text-align:center;font-size:13px;color:var(--muted)}
|
| 247 |
.dropzone:hover,.dropzone.dragover{border-color:var(--accent);background:var(--accent-soft);color:var(--ink)}
|
| 248 |
|
| 249 |
+
footer{color:var(--faint);font-size:12px;margin-top:14px;text-align:center;line-height:1.6}
|
| 250 |
footer b{color:var(--muted)}
|
| 251 |
+
|
| 252 |
+
/* ── provenance drawer (click a value → the exact sentence in its source) */
|
| 253 |
+
.drawer-back{position:fixed;inset:0;background:rgba(4,9,13,.5);opacity:0;pointer-events:none;
|
| 254 |
+
transition:.25s;z-index:60}
|
| 255 |
+
.drawer-back.show{opacity:1;pointer-events:auto}
|
| 256 |
+
.drawer{position:fixed;top:0;right:0;bottom:0;width:min(480px,94vw);background:var(--panel);
|
| 257 |
+
border-left:1px solid var(--line);box-shadow:-18px 0 50px rgba(0,0,0,.35);z-index:61;
|
| 258 |
+
transform:translateX(103%);transition:transform .3s cubic-bezier(.2,.7,.2,1);
|
| 259 |
+
display:flex;flex-direction:column}
|
| 260 |
+
.drawer.show{transform:none}
|
| 261 |
+
.drawer .head{display:flex;align-items:center;gap:10px;padding:15px 18px;
|
| 262 |
+
border-bottom:1px solid var(--line)}
|
| 263 |
+
.drawer .head h3{margin:0;font:600 13.5px/1.3 var(--mono);color:var(--ink);word-break:break-all}
|
| 264 |
+
.drawer .close{margin-left:auto;flex:0 0 auto;background:none;border:1px solid var(--line);
|
| 265 |
+
border-radius:9px;width:32px;height:32px;color:var(--muted);font-size:15px;line-height:1;
|
| 266 |
+
cursor:pointer;display:grid;place-items:center}
|
| 267 |
+
.drawer .close:hover{color:var(--ink);border-color:var(--accent);transform:none}
|
| 268 |
+
.drawer .cap{padding:10px 18px 0;color:var(--faint);font-size:11.5px}
|
| 269 |
+
.drawer .body{padding:14px 18px 20px;overflow:auto;white-space:pre-wrap;
|
| 270 |
+
font-size:13.5px;line-height:1.75;color:var(--muted)}
|
| 271 |
+
.drawer .body mark{background:color-mix(in srgb,var(--accent) 26%,transparent);color:var(--ink);
|
| 272 |
+
border-radius:4px;padding:1px 3px;font-weight:650;
|
| 273 |
+
box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 45%,transparent)}
|
| 274 |
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%) translateY(8px);
|
| 275 |
background:var(--ink);color:var(--bg);padding:11px 18px;border-radius:11px;font-weight:600;font-size:13.5px;
|
| 276 |
box-shadow:0 10px 30px rgba(0,0,0,.35);opacity:0;transition:.28s;pointer-events:none;z-index:50}
|
|
|
|
| 343 |
</div>
|
| 344 |
</div>
|
| 345 |
|
| 346 |
+
<div class="story" id="story" role="note">
|
| 347 |
+
<span class="txt" id="story-text"></span>
|
| 348 |
+
<button class="x" id="story-x" aria-label="Dismiss">×</button>
|
| 349 |
+
</div>
|
| 350 |
+
|
| 351 |
<div class="grid" style="margin-top:18px">
|
| 352 |
<section class="panel d1">
|
| 353 |
<h2><span id="h-series">Metrics timeline</span> <span class="tag" id="bi-tag" style="display:none">bilingual · UA + EN</span></h2>
|
|
|
|
| 377 |
<div class="chips" id="chips"></div>
|
| 378 |
</section>
|
| 379 |
|
| 380 |
+
<div class="trust" id="trust"></div>
|
| 381 |
<footer id="site-footer">Prototype for <b>Digital Future Hackathon</b> · Aging & Longevity track.<br>
|
| 382 |
The system structures facts and shows trends — <b>it does not diagnose or prescribe treatment</b>.</footer>
|
| 383 |
</div>
|
| 384 |
+
<div class="drawer-back" id="drawer-back"></div>
|
| 385 |
+
<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title">
|
| 386 |
+
<div class="head">
|
| 387 |
+
<h3 id="drawer-title"></h3>
|
| 388 |
+
<button class="close" id="drawer-close" aria-label="Close">×</button>
|
| 389 |
+
</div>
|
| 390 |
+
<div class="cap" id="drawer-cap"></div>
|
| 391 |
+
<div class="body" id="drawer-body"></div>
|
| 392 |
+
</aside>
|
| 393 |
<div class="toast" id="toast"></div>
|
| 394 |
|
| 395 |
<script>
|
|
|
|
| 403 |
let LANG = localStorage.getItem(LANG_KEY) || 'en';
|
| 404 |
let lastState = null;
|
| 405 |
|
| 406 |
+
/* Назви показників за LOINC (таймлайн локалізується на клієнті; /state віддає loinc) */
|
| 407 |
+
const LOINC_LABEL = {
|
| 408 |
+
'2160-0':{en:'Creatinine',ua:'Креатинін'},
|
| 409 |
+
'2823-3':{en:'Potassium',ua:'Калій'},
|
| 410 |
+
'4548-4':{en:'HbA1c',ua:'Глікований гемоглобін'},
|
| 411 |
+
'2345-7':{en:'Glucose',ua:'Глюкоза'},
|
| 412 |
+
'718-7':{en:'Hemoglobin',ua:'Гемоглобін'},
|
| 413 |
+
'2951-2':{en:'Sodium',ua:'Натрій'},
|
| 414 |
+
'8480-6':{en:'Systolic BP',ua:'Систолічний тиск'},
|
| 415 |
+
'1742-6':{en:'ALT',ua:'АЛТ'},
|
| 416 |
+
'2093-3':{en:'Total cholesterol',ua:'Холестерин загальний'},
|
| 417 |
+
};
|
| 418 |
+
|
| 419 |
const I18N = {
|
| 420 |
en: {
|
| 421 |
htmlLang:'en', title:'LiveMedCard — Living Medical Card',
|
|
|
|
| 452 |
errorPrefix:'Error: ', selected:name=>`Selected: ${name}`,
|
| 453 |
chips:['What about kidneys over the last year?','What was potassium?',
|
| 454 |
'What medications and any interactions?','What about HbA1c?'],
|
| 455 |
+
story:(docs,pair)=>`One timeline from ${docs} documents in two languages. The card flagged a dangerous combination — <b>${pair}</b> — deterministically, by safety rules, not by AI guess. See the red alert in the Safety panel.`,
|
| 456 |
+
groupL1:'L1 · deterministic rules', groupL2a:'L2a · trends & reference ranges',
|
| 457 |
+
trust:['No cloud LLM','Deterministic safety rules (L1)','FHIR export','Local-first by design'],
|
| 458 |
+
drawerTitle:id=>`Source · ${id}`,
|
| 459 |
+
drawerCap:'The extracted fact is highlighted in the source document.',
|
| 460 |
+
srcTitle:'Show source document',
|
| 461 |
},
|
| 462 |
ua: {
|
| 463 |
htmlLang:'uk', title:'LiveMedCard — жива медична картка',
|
|
|
|
| 494 |
errorPrefix:'Помилка: ', selected:name=>`Вибрано: ${name}`,
|
| 495 |
chips:['Що з нирками за останній рік?','Який був калій?',
|
| 496 |
'Які ліки приймає і чи є взаємодії?','Що з HbA1c?'],
|
| 497 |
+
story:(docs,pair)=>`Один таймлайн із ${docs} документів двома мовами. Картка помітила небезпечну комбінацію — <b>${pair}</b> — детерміновано, правилами безпеки, а не «на думку» ШІ. Дивіться червоний сигнал у панелі безпеки.`,
|
| 498 |
+
groupL1:'L1 · детерміновані правила', groupL2a:'L2a · тренди й референсні межі',
|
| 499 |
+
trust:['Без хмарної LLM','Детерміновані правила безпеки (L1)','Експорт FHIR','Local-first за дизайном'],
|
| 500 |
+
drawerTitle:id=>`Джерело · ${id}`,
|
| 501 |
+
drawerCap:'Витягнутий факт підсвічено в д��кументі-джерелі.',
|
| 502 |
+
srcTitle:'Показати документ-джерело',
|
| 503 |
},
|
| 504 |
};
|
| 505 |
|
|
|
|
| 538 |
$('#qa-tag').textContent = t.qaTag;
|
| 539 |
$('#q').placeholder = t.qPlaceholder;
|
| 540 |
$('#ask').textContent = t.ask;
|
| 541 |
+
$('#trust').innerHTML = t.trust.map(x=>`<span>${x}</span>`).join('');
|
| 542 |
+
$('#site-footer').innerHTML = `${t.footer1}<br>${t.footer2}
|
| 543 |
+
<div class="flinks"><a href="https://github.com/SZabolotnii/Digital_Future_Hackathon" target="_blank" rel="noopener">GitHub</a> ·
|
| 544 |
+
<a href="https://traces.solutions" target="_blank" rel="noopener">traces.solutions</a> ·
|
| 545 |
+
<a href="https://arxiv.org/abs/2605.03838" target="_blank" rel="noopener">TRACE (arXiv)</a></div>`;
|
| 546 |
buildChips();
|
| 547 |
if(lastState) render(lastState);
|
| 548 |
}
|
|
|
|
| 559 |
|
| 560 |
$('#langsw').addEventListener('click', e=>{
|
| 561 |
const btn=e.target.closest('button[data-lang]'); if(!btn) return;
|
| 562 |
+
if(btn.dataset.lang!==LANG){applyLang(btn.dataset.lang);refresh();}
|
| 563 |
});
|
| 564 |
|
| 565 |
function toast(msg){const t=$('#toast');t.textContent=msg;t.classList.add('show');
|
|
|
|
| 644 |
<div class="stat bi"><div class="n">${bilingual?'UA+EN':(langs.has('en')?'EN':'UA')}</div><div class="l">${t.statLangs}</div></div>
|
| 645 |
` : '';
|
| 646 |
|
| 647 |
+
// story ribbon — investor orientation, only when a drug interaction was caught
|
| 648 |
+
const inter=st.signals.filter(x=>x.kind==='interaction');
|
| 649 |
+
const storyEl=$('#story');
|
| 650 |
+
if(inter.length && !localStorage.getItem('lmc_story_off')){
|
| 651 |
+
const pair=inter[0].detail.split(':')[0];
|
| 652 |
+
$('#story-text').innerHTML=t.story(docs, pair);
|
| 653 |
+
storyEl.classList.add('show');
|
| 654 |
+
}else{storyEl.classList.remove('show');}
|
| 655 |
+
|
| 656 |
// series
|
| 657 |
const sEl=$('#series');
|
| 658 |
if(!st.series.length){sEl.innerHTML=`<div class="empty">${t.seriesEmpty}</div>`;}
|
| 659 |
else{sEl.innerHTML=st.series.map((s,idx)=>{
|
| 660 |
const last=s.points.at(-1);const [txt,cls]=statusOf(s);const [ttxt,tcls]=trendOf(s);
|
| 661 |
+
const name=(LOINC_LABEL[s.loinc]||{})[LANG]||s.keyword;
|
| 662 |
const ref=(s.low!=null&&s.high!=null)?`${t.range} ${s.low}–${s.high} ${s.unit||''}`:'';
|
| 663 |
const srcs=[...new Set(s.points.map(p=>p.source).filter(Boolean))];
|
| 664 |
const langBadges=[...new Set(srcs.map(langOf))].map(l=>`<span class="lang ${l}">${l.toUpperCase()}</span>`).join('');
|
| 665 |
+
const chips=srcs.map(src=>{
|
| 666 |
+
// span останньої точки з цього документа — якір для drawer-підсвітки
|
| 667 |
+
const pt=[...s.points].reverse().find(p=>p.source===src);
|
| 668 |
+
const span=pt&&pt.span?`${pt.span[0]}-${pt.span[1]}`:'';
|
| 669 |
+
return `<button class="srcchip" data-doc="${src}" data-span="${span}" title="${t.srcTitle}">${src}</button>`;
|
| 670 |
+
}).join('');
|
| 671 |
return `<div class="metric" style="animation-delay:${(idx*.05).toFixed(2)}s">
|
| 672 |
<div class="row">
|
| 673 |
+
<span class="name">${name}</span>
|
| 674 |
<span class="big"><span class="val">${last.value}</span><span class="unit">${s.unit||''}</span>
|
| 675 |
${ttxt?`<span class="trend ${tcls}">${ttxt}</span>`:''}</span>
|
| 676 |
</div>
|
| 677 |
<div class="meta"><span class="ref">${ref}</span><span class="status ${cls}">${txt}</span></div>
|
| 678 |
${sparkline(s.points,s.low,s.high)}
|
| 679 |
+
<div class="src">${langBadges}<span>${t.meas(s.points.length)}</span>${chips}</div>
|
| 680 |
</div>`;}).join('');}
|
| 681 |
|
| 682 |
// decision
|
|
|
|
| 687 |
<span class="t">${d.route_to}</span>
|
| 688 |
<ul class="reasons">${d.reasons.map(r=>`<li>${r}</li>`).join('')}</ul>`;
|
| 689 |
|
| 690 |
+
// signals — headline: спіймана взаємодія; далі групи за шаром TRACE (L1 / L2a)
|
| 691 |
const sig=$('#signals');
|
| 692 |
if(!st.signals.length){sig.innerHTML=`<div class="empty">${t.signalsEmpty}</div>`;}
|
| 693 |
+
else{
|
| 694 |
+
const row=(x,headline)=>{
|
| 695 |
+
const hot=(x.kind==='interaction'||x.severity==='critical'||x.severity==='major');
|
| 696 |
+
const color=SEV[x.severity]||'var(--info)';
|
| 697 |
+
return `<div class="sig ${hot?'hot':''} ${headline?'headline':''}">
|
| 698 |
+
<span class="dot" style="background:${color};color:${color}"></span>
|
| 699 |
+
<span><span class="k">${x.layer} · ${x.kind} · ${t.sevLabel[x.severity]||x.severity}</span>
|
| 700 |
+
<span class="d">${x.detail}</span></span></div>`;
|
| 701 |
+
};
|
| 702 |
+
const rest=st.signals.filter(x=>x.kind!=='interaction');
|
| 703 |
+
const l1=rest.filter(x=>x.layer==='L1'), l2a=rest.filter(x=>x.layer==='L2a');
|
| 704 |
+
sig.innerHTML =
|
| 705 |
+
inter.map(x=>row(x,true)).join('') +
|
| 706 |
+
(l1.length?`<div class="siggroup">${t.groupL1}</div>`+l1.map(x=>row(x)).join(''):'') +
|
| 707 |
+
(l2a.length?`<div class="siggroup">${t.groupL2a}</div>`+l2a.map(x=>row(x)).join(''):'');
|
| 708 |
+
}
|
| 709 |
|
| 710 |
// meds — flag those named in an interaction
|
| 711 |
const interText=st.signals.filter(x=>x.kind==='interaction').map(x=>x.detail.toLowerCase()).join(' ');
|
|
|
|
| 716 |
}).join(''):`<div class="empty">${t.medsEmpty}</div>`;
|
| 717 |
}
|
| 718 |
|
| 719 |
+
async function refresh(){
|
| 720 |
+
// lang → бекенд локалізує тексти сигналів/причин (той самий детермінований каталог)
|
| 721 |
+
try{render(await api('/state?lang='+(LANG==='ua'?'uk':'en')));}
|
| 722 |
+
catch(e){toast(I18N[LANG].errorPrefix+e.message);}
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
/* ── provenance drawer: клік по документу-джерелу → речення з фактом ── */
|
| 726 |
+
function escHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
| 727 |
+
async function openDoc(docId, span){
|
| 728 |
+
const t=I18N[LANG];
|
| 729 |
+
try{
|
| 730 |
+
const d=await api('/document/'+encodeURIComponent(docId));
|
| 731 |
+
$('#drawer-title').textContent=t.drawerTitle(docId);
|
| 732 |
+
$('#drawer-cap').textContent=span?t.drawerCap:'';
|
| 733 |
+
const body=$('#drawer-body');
|
| 734 |
+
body.innerHTML=span
|
| 735 |
+
? escHtml(d.text.slice(0,span[0]))+'<mark>'+escHtml(d.text.slice(span[0],span[1]))+'</mark>'+escHtml(d.text.slice(span[1]))
|
| 736 |
+
: escHtml(d.text);
|
| 737 |
+
$('#drawer').classList.add('show');$('#drawer-back').classList.add('show');
|
| 738 |
+
const mk=body.querySelector('mark');
|
| 739 |
+
if(mk)setTimeout(()=>mk.scrollIntoView({block:'center',behavior:'smooth'}),180);
|
| 740 |
+
}catch(e){toast(t.errorPrefix+e.message);}
|
| 741 |
+
}
|
| 742 |
+
function closeDoc(){$('#drawer').classList.remove('show');$('#drawer-back').classList.remove('show');}
|
| 743 |
+
$('#drawer-close').onclick=closeDoc;
|
| 744 |
+
$('#drawer-back').onclick=closeDoc;
|
| 745 |
+
addEventListener('keydown',e=>{if(e.key==='Escape')closeDoc();});
|
| 746 |
+
$('#series').addEventListener('click',e=>{
|
| 747 |
+
const chip=e.target.closest('.srcchip'); if(!chip)return;
|
| 748 |
+
const span=chip.dataset.span?chip.dataset.span.split('-').map(Number):null;
|
| 749 |
+
openDoc(chip.dataset.doc, span);
|
| 750 |
+
});
|
| 751 |
+
$('#story-x').onclick=()=>{localStorage.setItem('lmc_story_off','1');$('#story').classList.remove('show');};
|
| 752 |
|
| 753 |
async function ask(q){
|
| 754 |
q=(q||$('#q').value).trim(); if(!q)return;
|