DocUA Claude Fable 5 commited on
Commit
5f6e063
·
1 Parent(s): 9b05136

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 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
- decision = l3_router.route(sigs, 1.0, "стан картки")
 
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, "source": p.source_doc_id}
 
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. Поле 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
  }
 
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
- """Назва препарату для тексту сигналу: 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] = []
@@ -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}: {entry.detail}",
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 detect_trends(store: FactStore) -> list[Signal]:
 
 
 
 
 
 
 
 
 
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
- 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",
@@ -51,7 +69,9 @@ def detect_trends(store: FactStore) -> list[Signal]:
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():
@@ -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"{ref.keyword} {_fmt(latest.value)} {unit} {direction} "
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(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
@@ -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;border-top:1px solid var(--line)}
167
- .sig:first-of-type{border-top:none;padding-top:2px}
 
 
 
 
 
 
 
 
 
 
 
 
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:30px;text-align:center;line-height:1.6}
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 &amp; 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
- $('#site-footer').innerHTML = `${t.footer1}<br>${t.footer2}`;
 
 
 
 
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) applyLang(btn.dataset.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">${s.keyword}</span>
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)} · ${srcs.join(', ')||'—'}</span></div>
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 — interaction/critical get a pulsing dot
576
  const sig=$('#signals');
577
  if(!st.signals.length){sig.innerHTML=`<div class="empty">${t.signalsEmpty}</div>`;}
578
- else{sig.innerHTML=st.signals.map(x=>{
579
- const hot=(x.kind==='interaction'||x.severity==='critical'||x.severity==='major');
580
- const color=SEV[x.severity]||'var(--info)';
581
- return `<div class="sig ${hot?'hot':''}">
582
- <span class="dot" style="background:${color};color:${color}"></span>
583
- <span><span class="k">${x.layer} · ${x.kind} · ${t.sevLabel[x.severity]||x.severity}</span>
584
- <span class="d">${x.detail}</span></span></div>`;}).join('');}
 
 
 
 
 
 
 
 
 
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(){try{render(await api('/state'));}catch(e){toast(I18N[LANG].errorPrefix+e.message);}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &amp; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
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;