DocUA commited on
Commit
cc02af3
·
1 Parent(s): 28cbc96

feat: help, document viewer, knowledge graph; empty start + language-matched demo

Browse files
Dockerfile CHANGED
@@ -4,11 +4,12 @@
4
  # — реальний OCR через хмарний ZeroGPU Lab (NuExtract3), лише синтетичні документи.
5
  FROM python:3.12-slim
6
 
 
 
7
  ENV PYTHONUNBUFFERED=1 \
8
  PIP_NO_CACHE_DIR=1 \
9
  LMC_EXTRACTOR=stub \
10
- LMC_IMAGE_OCR=ocrlab \
11
- LMC_SEED_SAMPLES=1
12
 
13
  WORKDIR /app
14
 
 
4
  # — реальний OCR через хмарний ZeroGPU Lab (NuExtract3), лише синтетичні документи.
5
  FROM python:3.12-slim
6
 
7
+ # Картка стартує ПОРОЖНЬОЮ (без LMC_SEED_SAMPLES): демо-документи вантажаться лише
8
+ # кнопкою — і набором тієї мови, якою відкрито інтерфейс.
9
  ENV PYTHONUNBUFFERED=1 \
10
  PIP_NO_CACHE_DIR=1 \
11
  LMC_EXTRACTOR=stub \
12
+ LMC_IMAGE_OCR=ocrlab
 
13
 
14
  WORKDIR /app
15
 
livemedcard/api.py CHANGED
@@ -264,8 +264,9 @@ def ingest_image(req: ImageIngestRequest, state: _State = Depends(_get_state)):
264
 
265
 
266
  @app.post("/ingest/samples")
267
- def ingest_samples(state: _State = Depends(_get_state)):
268
- docs = [Document(**d) for d in load_sample_docs()]
 
269
  with state.lock:
270
  reports = state.card.ingest_all(docs)
271
  state.autosave()
@@ -349,6 +350,31 @@ def document(doc_id: str, state: _State = Depends(_get_state)):
349
  return {"id": doc_id, "text": node["text"]}
350
 
351
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  @app.get("/state")
353
  def get_state_endpoint(lang: str = "uk", state: _State = Depends(_get_state)):
354
  """Агрегований знімок картки для інтерфейсу (`lang=uk|en` — мова текстів сигналів)."""
@@ -389,14 +415,19 @@ def get_state_endpoint(lang: str = "uk", state: _State = Depends(_get_state)):
389
  for f in card.factstore.uncoded()
390
  ]
391
 
392
- # Мова документа з його ТЕКСТУ (detect_lang), а не з назви файлу. Якщо
393
- # тексту немає, документ не потрапляє сюди взагалі: інтерфейс краще
394
- # покаже відсутність бейджа, ніж навмання «UA» (detect_lang("") == "uk").
395
  docs = {}
396
- for n in card.graph.nodes_of_type("Document"):
397
- text = (card.graph.g.nodes[n].get("text") or "").strip()
398
- if text:
399
- docs[n.split("/", 1)[1]] = {"lang": detect_lang(text)}
 
 
 
 
 
400
 
401
  seen: dict[str, None] = {}
402
  for n in card.graph.nodes_of_type("MedicationRequest"):
 
264
 
265
 
266
  @app.post("/ingest/samples")
267
+ def ingest_samples(lang: str = "uk", state: _State = Depends(_get_state)):
268
+ """Демо-документи мовою `lang` (`uk`|`en`) набори дзеркальні за значеннями."""
269
+ docs = [Document(**d) for d in load_sample_docs(lang)]
270
  with state.lock:
271
  reports = state.card.ingest_all(docs)
272
  state.autosave()
 
350
  return {"id": doc_id, "text": node["text"]}
351
 
352
 
353
+ @app.get("/graph")
354
+ def graph(state: _State = Depends(_get_state)):
355
+ """Граф контексту як вузли/ребра — для візуалізації провенансу в інтерфейсі.
356
+
357
+ Віддає те, що вже є в networkx-графі; нічого не рахує. Вузол `Document` несе
358
+ лише мітку (сам текст — за `/document/{id}`), щоб відповідь лишалась легкою.
359
+ """
360
+ with state.lock:
361
+ g = state.card.graph.g
362
+ nodes = [
363
+ {
364
+ "id": n,
365
+ "type": d.get("type", "?"),
366
+ # у вузла-документа мітки немає — показуємо його id
367
+ "label": d.get("label") or n.split("/", 1)[-1],
368
+ }
369
+ for n, d in g.nodes(data=True)
370
+ ]
371
+ edges = [
372
+ {"source": u, "target": v, "rel": d.get("rel", "")}
373
+ for u, v, d in g.edges(data=True)
374
+ ]
375
+ return {"nodes": nodes, "edges": edges}
376
+
377
+
378
  @app.get("/state")
379
  def get_state_endpoint(lang: str = "uk", state: _State = Depends(_get_state)):
380
  """Агрегований знімок картки для інтерфейсу (`lang=uk|en` — мова текстів сигналів)."""
 
415
  for f in card.factstore.uncoded()
416
  ]
417
 
418
+ # Подані документи: тип, дата, мова, розмір. Мова — з ТЕКСТУ (detect_lang),
419
+ # а не з назви файлу; без тексту lang=None, і інтерфейс краще не покаже
420
+ # бейджа взагалі, ніж навмання «UA» (detect_lang("") == "uk").
421
  docs = {}
422
+ for d in card.document_references:
423
+ did = d.provenance.source_doc_id if d.provenance else d.id
424
+ text = d.content.data or ""
425
+ docs[did] = {
426
+ "lang": detect_lang(text) if text.strip() else None,
427
+ "kind": d.type.text if d.type else None,
428
+ "date": d.date.date().isoformat() if d.date else None,
429
+ "chars": len(text),
430
+ }
431
 
432
  seen: dict[str, None] = {}
433
  for n in card.graph.nodes_of_type("MedicationRequest"):
livemedcard/data/sample_docs.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
- "_note": "Білінгвальний демо-таймлайн однієї пацієнтки (mom-001): українські виписки + англомовний lab-report приватної лабораторії на ОДНОМУ таймлайні. Формат тексту імітує витяг фото документа.",
3
- "documents": [
4
  {
5
  "id": "doc-2023-05",
6
  "kind": "discharge_summary",
@@ -19,6 +19,32 @@
19
  "received_at": "2024-04-09T14:15:00",
20
  "text": "Виписний епікриз. Скарги на набряки. Артеріальна гіпертензія. Креатинін 121 µmol/L, глікований гемоглобін 7.2 %. Призначено: аспірин 100 мг на добу, метформін 3000 мг на добу."
21
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  {
23
  "id": "doc-2024-10-en",
24
  "kind": "lab_report",
 
1
  {
2
+ "_note": "Демо-таймлайн однієї пацієнтки (mom-001) у двох дзеркальних наборах: 'uk' і 'en'. Набори несуть ТІ САМІ значення й ті самі призначення, тож сигнали безпеки збігаються — різниця лише в мові документів. Завантажується той набір, що відповідає мові інтерфейсу. Формат тексту імітує витяг фото документа.",
3
+ "uk": [
4
  {
5
  "id": "doc-2023-05",
6
  "kind": "discharge_summary",
 
19
  "received_at": "2024-04-09T14:15:00",
20
  "text": "Виписний епікриз. Скарги на набряки. Артеріальна гіпертензія. Креатинін 121 µmol/L, глікований гемоглобін 7.2 %. Призначено: аспірин 100 мг на добу, метформін 3000 мг на добу."
21
  },
22
+ {
23
+ "id": "doc-2024-10",
24
+ "kind": "lab_report",
25
+ "received_at": "2024-10-15T11:00:00",
26
+ "text": "Приватна лабораторія. Біохімічна панель. Креатинін 135 µmol/L. Калій 5.1 mmol/L. Глюкоза 6.8 mmol/L. Глікований гемоглобін 7.6 %."
27
+ }
28
+ ],
29
+ "en": [
30
+ {
31
+ "id": "doc-2023-05-en",
32
+ "kind": "discharge_summary",
33
+ "received_at": "2023-05-01T10:00:00",
34
+ "text": "Discharge summary. Female patient, 69 years old. Diagnosis: atrial fibrillation. Labs: creatinine 88 µmol/L, potassium 4.6 mmol/L. Prescribed: warfarin 5 mg per day under INR control."
35
+ },
36
+ {
37
+ "id": "doc-2023-11-en",
38
+ "kind": "lab_report",
39
+ "received_at": "2023-11-03T09:30:00",
40
+ "text": "Blood biochemistry. Creatinine 104 µmol/L. Potassium 4.9 mmol/L. Glycated hemoglobin 6.4 %."
41
+ },
42
+ {
43
+ "id": "doc-2024-04-en",
44
+ "kind": "discharge_summary",
45
+ "received_at": "2024-04-09T14:15:00",
46
+ "text": "Discharge summary. Complaints of oedema. Hypertension. Creatinine 121 µmol/L, glycated hemoglobin 7.2 %. Prescribed: aspirin 100 mg per day, metformin 3000 mg per day."
47
+ },
48
  {
49
  "id": "doc-2024-10-en",
50
  "kind": "lab_report",
livemedcard/layers/l2b_extractor.py CHANGED
@@ -45,9 +45,15 @@ _T = TypeVar("_T")
45
  # тиск забирав би значення калію, а варфарин — дозу аспірину).
46
  _SEP = r"[\s:=–—-]*"
47
  _LAB_VALUE_RE = re.compile(_SEP + r"(\d+(?:\.\d+)?)")
48
- _DOSE_MG_RE = re.compile(_SEP + r"(\d+(?:\.\d+)?)\s*мг")
49
- # Кратність прийому: «×2», «x2», «2 рази», «двічі», «тричі» множник за добу.
50
- _FREQ_RE = re.compile(r"[×xх]\s*(\d+)|(\d+)\s*раз|(двіч)|(тріч)", re.IGNORECASE)
 
 
 
 
 
 
51
  # Дата вимірювання на факт: ISO «2024-03-15» або «15.03.2024» у тому самому рядку.
52
  _DATE_RE = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b|\b(\d{2})\.(\d{2})\.(\d{4})\b")
53
 
@@ -104,6 +110,12 @@ def _parse_daily_dose_mg(text: str, pos: int) -> Optional[float]:
104
  freq = 2 # двічі
105
  elif fm.group(4):
106
  freq = 3 # тричі
 
 
 
 
 
 
107
  return single * freq
108
 
109
 
 
45
  # тиск забирав би значення калію, а варфарин — дозу аспірину).
46
  _SEP = r"[\s:=–—-]*"
47
  _LAB_VALUE_RE = re.compile(_SEP + r"(\d+(?:\.\d+)?)")
48
+ _DOSE_MG_RE = re.compile(_SEP + r"(\d+(?:\.\d+)?)\s*(?:мг|mg)\b", re.IGNORECASE)
49
+ # Кратність прийому: «×2», «x2», «2 рази», «двічі», «тричі», «twice», «3 times»
50
+ # множник за добу. Словник білінгвальний, тож і доза має читатися двома мовами:
51
+ # інакше в англомовному документі доза просто не знаходиться, і сигнал
52
+ # перевищення добової межі (L1) тихо не спрацьовує.
53
+ _FREQ_RE = re.compile(
54
+ r"[×xх]\s*(\d+)|(\d+)\s*раз|(двіч)|(тріч)|(\d+)\s*times|(twice)|(three times)",
55
+ re.IGNORECASE,
56
+ )
57
  # Дата вимірювання на факт: ISO «2024-03-15» або «15.03.2024» у тому самому рядку.
58
  _DATE_RE = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b|\b(\d{2})\.(\d{2})\.(\d{4})\b")
59
 
 
110
  freq = 2 # двічі
111
  elif fm.group(4):
112
  freq = 3 # тричі
113
+ elif fm.group(5):
114
+ freq = int(fm.group(5)) # «2 times»
115
+ elif fm.group(6):
116
+ freq = 2 # twice
117
+ elif fm.group(7):
118
+ freq = 3 # three times
119
  return single * freq
120
 
121
 
livemedcard/reference.py CHANGED
@@ -73,6 +73,11 @@ def load_vocab() -> Vocab:
73
  )
74
 
75
 
76
- def load_sample_docs() -> list[dict]:
77
- """Демо-документи (український текст) для E2E. Щоразу свіжа копія."""
78
- return json.loads(json.dumps(_raw("sample_docs.json")["documents"]))
 
 
 
 
 
 
73
  )
74
 
75
 
76
+ def load_sample_docs(lang: str = "uk") -> list[dict]:
77
+ """Демо-документи мовою ``lang`` (``uk``|``en``). Щоразу свіжа копія.
78
+
79
+ Набори дзеркальні: ті самі значення й ті самі призначення, тож сигнали
80
+ безпеки збігаються — демо не залежить від того, якою мовою його дивляться.
81
+ """
82
+ key = "en" if lang == "en" else "uk"
83
+ return json.loads(json.dumps(_raw("sample_docs.json")[key]))
livemedcard/static/index.html CHANGED
@@ -220,6 +220,19 @@
220
  .sig .k{font:600 10.5px/1.3 var(--mono);color:var(--faint);text-transform:uppercase;letter-spacing:.05em}
221
  .sig .d{font-size:13.5px;margin-top:3px;display:block}
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  .meds{display:flex;flex-wrap:wrap;gap:8px}
224
  .pill{background:var(--panel-2);border:1px solid var(--line);color:var(--ink);border-radius:999px;
225
  padding:6px 13px;font-size:13px;font-weight:600;display:inline-flex;align-items:center;gap:7px}
@@ -282,6 +295,51 @@
282
  cursor:pointer;display:grid;place-items:center}
283
  .drawer .close:hover{color:var(--ink);border-color:var(--accent);transform:none}
284
  .drawer .cap{padding:10px 18px 0;color:var(--faint);font-size:11.5px}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  .drawer .body{padding:14px 18px 20px;overflow:auto;white-space:pre-wrap;
286
  font-size:13.5px;line-height:1.75;color:var(--muted)}
287
  /* ── cloud consent (photo leaves the device — say so before it does) ── */
@@ -349,6 +407,7 @@
349
  <button class="ghost" id="btn-add">+ Add document</button>
350
  <button class="ghost" id="btn-export">Export FHIR</button>
351
  <button class="ghost" id="btn-reset">Reset</button>
 
352
  </div>
353
 
354
  <div class="add" id="add-form">
@@ -396,6 +455,11 @@
396
  <h2><span id="h-meds">Prescribed medications</span></h2>
397
  <div class="meds" id="meds"><div class="empty">No medications.</div></div>
398
  </section>
 
 
 
 
 
399
  </div>
400
  </div>
401
 
@@ -422,6 +486,28 @@
422
  <div class="cap" id="drawer-cap"></div>
423
  <div class="body" id="drawer-body"></div>
424
  </aside>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  <div class="drawer-back" id="consent-back"></div>
426
  <div class="consent" id="consent" role="dialog" aria-modal="true" aria-labelledby="consent-title">
427
  <h3 id="consent-title"></h3>
@@ -476,6 +562,13 @@ const I18N = {
476
  hSafety:'Safety — TRACE layer',
477
  signalsEmpty:'Values in range, no dangerous interactions found.',
478
  hMeds:'Prescribed medications', medsEmpty:'No medications.',
 
 
 
 
 
 
 
479
  hQa:'Ask the card', qaTag:'grounded · with source reference',
480
  qPlaceholder:'e.g.: What about kidneys over the last year?', ask:'Ask',
481
  footer1:'Prototype for <b>Digital Future Hackathon</b> · Aging &amp; Longevity track.',
@@ -499,7 +592,7 @@ const I18N = {
499
  errorPrefix:'Error: ', selected:name=>`Selected: ${name}`,
500
  chips:['What about kidneys over the last year?','What was potassium?',
501
  'What medications and any interactions?','What about HbA1c?'],
502
- 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.`,
503
  groupL1:'L1 · deterministic rules', groupL2a:'L2a · trends & reference ranges',
504
  trust:['No LLM in safety decisions','Deterministic safety rules (L1)','FHIR export','Local-first by design'],
505
  trustPhotoCloud:'Photo OCR → cloud lab',
@@ -511,6 +604,42 @@ const I18N = {
511
  consentBody:'To read the photo, this public demo sends it to a cloud OCR lab (Hugging Face ZeroGPU). Text you type, the demo card and every safety decision stay on the server and never go to an LLM.\n\nUpload synthetic or fully de-identified documents only — never real personal medical data.',
512
  consentOk:'Send the photo',
513
  consentCancel:'Cancel',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
  },
515
  ua: {
516
  htmlLang:'uk', title:'LiveMedCard — жива медична картка',
@@ -530,6 +659,13 @@ const I18N = {
530
  hSafety:'Безпека — шар TRACE',
531
  signalsEmpty:'Показники в межах, небезпечних взаємодій не виявлено.',
532
  hMeds:'Призначені ліки', medsEmpty:'Ліків немає.',
 
 
 
 
 
 
 
533
  hQa:'Запитати картку', qaTag:'grounded · з посиланням на джерело',
534
  qPlaceholder:'Напр.: Що з нирками за останній рік?', ask:'Спитати',
535
  footer1:'Прототип для <b>Digital Future Hackathon</b> · ніша Aging &amp; Longevity.',
@@ -553,7 +689,7 @@ const I18N = {
553
  errorPrefix:'Помилка: ', selected:name=>`Вибрано: ${name}`,
554
  chips:['Що з нирками за останній рік?','Який був калій?',
555
  'Які ліки приймає і чи є взаємодії?','Що з HbA1c?'],
556
- story:(docs,pair)=>`Один таймлайн із ${docs} документів двома мовами. Картка помітила небезпечну комбінацію — <b>${pair}</b> — детерміновано, правилами безпеки, а не «на думку» ШІ. Дивіться червоний сигнал у панелі безпеки.`,
557
  groupL1:'L1 · детерміновані правила', groupL2a:'L2a · тренди й референсні межі',
558
  trust:['Жодної LLM у рішеннях безпеки','Детерміновані правила безпеки (L1)','Експорт FHIR','Local-first за дизайном'],
559
  trustPhotoCloud:'OCR фото → хмарний лаб',
@@ -565,6 +701,42 @@ const I18N = {
565
  consentBody:'Щоб прочитати фото, це публічне демо надсилає його в хмарний OCR-лаб (Hugging Face ZeroGPU). Введений текст, демо-картка й усі рішення безпеки лишаються на сервері й до жодної LLM не потрапляють.\n\nЗавантажуйте лише синтетичні або повністю деідентифіковані документи — ніколи не справжні персональні медичні дані.',
566
  consentOk:'Надіслати фото',
567
  consentCancel:'Скасувати',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  },
569
  };
570
 
@@ -587,6 +759,10 @@ function applyLang(lang){
587
  $('#btn-add').textContent = t.btnAdd;
588
  $('#btn-export').textContent = t.btnExport;
589
  $('#btn-reset').textContent = t.btnReset;
 
 
 
 
590
  $('#add-text').placeholder = t.addPlaceholder;
591
  $('#or-photo').textContent = t.orPhoto;
592
  if(!fileInput.files.length) $('#dropzone-text').textContent = t.dropzoneDefault;
@@ -791,6 +967,18 @@ function render(st){
791
  }).join('');
792
  }
793
 
 
 
 
 
 
 
 
 
 
 
 
 
794
  // decision
795
  const d=st.decision;const dEl=$('#decision');
796
  dEl.className='decision '+(d.escalate?'escalate':'auto');
@@ -854,8 +1042,102 @@ async function openDoc(docId, span){
854
  function closeDoc(){$('#drawer').classList.remove('show');$('#drawer-back').classList.remove('show');}
855
  $('#drawer-close').onclick=closeDoc;
856
  $('#drawer-back').onclick=closeDoc;
857
- addEventListener('keydown',e=>{if(e.key==='Escape')closeDoc();});
858
- $('#series').addEventListener('click',e=>{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
  const chip=e.target.closest('.srcchip'); if(!chip)return;
860
  const span=chip.dataset.span?chip.dataset.span.split('-').map(Number):null;
861
  openDoc(chip.dataset.doc, span);
@@ -874,7 +1156,11 @@ async function ask(q){
874
  }catch(e){toast(I18N[LANG].errorPrefix+e.message);}
875
  }
876
 
877
- $('#btn-samples').onclick=async()=>{await api('/ingest/samples',{method:'POST'});await refresh();toast(I18N[LANG].toastSamples);};
 
 
 
 
878
  $('#btn-reset').onclick=async()=>{await api('/reset',{method:'POST'});$('#log').innerHTML='';await refresh();toast(I18N[LANG].toastReset);};
879
  $('#btn-export').onclick=async()=>{
880
  const b=await api('/export');
 
220
  .sig .k{font:600 10.5px/1.3 var(--mono);color:var(--faint);text-transform:uppercase;letter-spacing:.05em}
221
  .sig .d{font-size:13.5px;margin-top:3px;display:block}
222
 
223
+ /* ── подані документи ───────────────────────────────── */
224
+ .docs{display:flex;flex-direction:column}
225
+ .docrow{display:flex;align-items:center;gap:9px;width:100%;text-align:left;padding:10px 0;
226
+ background:none;border:0;border-bottom:1px solid var(--line);cursor:pointer;transition:.14s}
227
+ .docs .docrow:last-child{border-bottom:0}
228
+ .docrow:hover{transform:none}
229
+ .docrow:hover .id{color:var(--accent)}
230
+ .docrow .id{font:600 12px/1 var(--mono);color:var(--ink);transition:.14s}
231
+ .docrow .kind{font-size:12px;color:var(--muted)}
232
+ .docrow .d{margin-left:auto;font:11.5px/1 var(--mono);color:var(--faint);white-space:nowrap}
233
+ .graph-tag{cursor:pointer;border:1px solid var(--line);background:var(--panel-2)}
234
+ .graph-tag:hover{color:var(--accent);border-color:var(--accent);transform:none}
235
+
236
  .meds{display:flex;flex-wrap:wrap;gap:8px}
237
  .pill{background:var(--panel-2);border:1px solid var(--line);color:var(--ink);border-radius:999px;
238
  padding:6px 13px;font-size:13px;font-weight:600;display:inline-flex;align-items:center;gap:7px}
 
295
  cursor:pointer;display:grid;place-items:center}
296
  .drawer .close:hover{color:var(--ink);border-color:var(--accent);transform:none}
297
  .drawer .cap{padding:10px 18px 0;color:var(--faint);font-size:11.5px}
298
+
299
+ /* ── довідка ────────────────────────────────────────── */
300
+ .help-btn{margin-left:auto}
301
+ .help{position:fixed;z-index:61;top:50%;left:50%;transform:translate(-50%,-46%) scale(.98);
302
+ width:min(760px,94vw);max-height:86vh;background:var(--panel);border:1px solid var(--line);
303
+ border-radius:18px;box-shadow:0 24px 70px rgba(0,0,0,.4);display:flex;flex-direction:column;
304
+ opacity:0;pointer-events:none;transition:opacity .22s,transform .22s cubic-bezier(.2,.7,.2,1)}
305
+ .help.show{opacity:1;pointer-events:auto;transform:translate(-50%,-50%) scale(1)}
306
+ .help .head{display:flex;align-items:center;gap:10px;padding:16px 20px;border-bottom:1px solid var(--line)}
307
+ .help .head h3{margin:0;font-size:16px;font-weight:700;color:var(--ink)}
308
+ .help .close{margin-left:auto;flex:0 0 auto;background:none;border:1px solid var(--line);
309
+ border-radius:9px;width:32px;height:32px;color:var(--muted);font-size:15px;line-height:1;
310
+ cursor:pointer;display:grid;place-items:center}
311
+ .help .close:hover{color:var(--ink);border-color:var(--accent);transform:none}
312
+ .help .body{padding:6px 20px 22px;overflow:auto}
313
+ .help section{padding:16px 0;border-bottom:1px solid var(--line)}
314
+ .help section:last-child{border-bottom:0}
315
+ .help h4{margin:0 0 7px;font-size:14px;font-weight:700;color:var(--ink)}
316
+ .help p{margin:0 0 8px;font-size:13.5px;line-height:1.62;color:var(--muted)}
317
+ .help p:last-child{margin-bottom:0}
318
+ .help ul{margin:0;padding-left:18px;color:var(--muted);font-size:13.5px;line-height:1.62}
319
+ .help li + li{margin-top:5px}
320
+ .help b{color:var(--ink)}
321
+ .help code{font:600 12px/1 var(--mono);background:var(--panel-2);border:1px solid var(--line);
322
+ border-radius:5px;padding:2px 5px;color:var(--ink)}
323
+ @media (max-width:520px){ .help-btn{margin-left:0} }
324
+
325
+ /* ── граф знань ─────────────────────────────────────── */
326
+ .graph-modal{width:min(940px,96vw)}
327
+ .graph-modal .gcap{margin:14px 0 0;font-size:12.5px;line-height:1.6;color:var(--muted)}
328
+ .glegend{display:flex;flex-wrap:wrap;gap:12px;margin:12px 0 4px}
329
+ .glegend span{display:inline-flex;align-items:center;gap:6px;font:11px/1 var(--mono);color:var(--faint)}
330
+ .glegend i{width:9px;height:9px;border-radius:50%;display:inline-block}
331
+ .gwrap{overflow:auto;margin-top:6px}
332
+ .gwrap svg{display:block}
333
+ .gwrap .edge{stroke:var(--line);stroke-width:1.2;fill:none;transition:.16s}
334
+ .gwrap .edge.prov{stroke-dasharray:3 3}
335
+ .gwrap .node{cursor:pointer}
336
+ .gwrap .node text{font:11px/1 var(--mono);fill:var(--muted);transition:.16s}
337
+ .gwrap .node:hover text{fill:var(--ink)}
338
+ .gwrap.sel .edge{opacity:.12}
339
+ .gwrap.sel .edge.on{opacity:1;stroke:var(--accent);stroke-width:2}
340
+ .gwrap.sel .node{opacity:.3}
341
+ .gwrap.sel .node.on{opacity:1}
342
+ .gwrap.sel .node.on text{fill:var(--ink);font-weight:700}
343
  .drawer .body{padding:14px 18px 20px;overflow:auto;white-space:pre-wrap;
344
  font-size:13.5px;line-height:1.75;color:var(--muted)}
345
  /* ── cloud consent (photo leaves the device — say so before it does) ── */
 
407
  <button class="ghost" id="btn-add">+ Add document</button>
408
  <button class="ghost" id="btn-export">Export FHIR</button>
409
  <button class="ghost" id="btn-reset">Reset</button>
410
+ <button class="ghost help-btn" id="btn-help">Help</button>
411
  </div>
412
 
413
  <div class="add" id="add-form">
 
455
  <h2><span id="h-meds">Prescribed medications</span></h2>
456
  <div class="meds" id="meds"><div class="empty">No medications.</div></div>
457
  </section>
458
+ <section class="panel d3">
459
+ <h2><span id="h-docs">Submitted documents</span>
460
+ <button class="tag graph-tag" id="btn-graph" type="button">Knowledge graph</button></h2>
461
+ <div class="docs" id="docs"><div class="empty">No documents.</div></div>
462
+ </section>
463
  </div>
464
  </div>
465
 
 
486
  <div class="cap" id="drawer-cap"></div>
487
  <div class="body" id="drawer-body"></div>
488
  </aside>
489
+ <div class="drawer-back" id="graph-back"></div>
490
+ <aside class="help graph-modal" id="graphm" role="dialog" aria-modal="true" aria-labelledby="graph-title">
491
+ <div class="head">
492
+ <h3 id="graph-title"></h3>
493
+ <button class="close" id="graph-close" aria-label="Close">×</button>
494
+ </div>
495
+ <div class="body">
496
+ <p class="gcap" id="graph-cap"></p>
497
+ <div class="glegend" id="graph-legend"></div>
498
+ <div class="gwrap" id="graph-wrap"></div>
499
+ </div>
500
+ </aside>
501
+
502
+ <div class="drawer-back" id="help-back"></div>
503
+ <aside class="help" id="help" role="dialog" aria-modal="true" aria-labelledby="help-title">
504
+ <div class="head">
505
+ <h3 id="help-title"></h3>
506
+ <button class="close" id="help-close" aria-label="Close">×</button>
507
+ </div>
508
+ <div class="body" id="help-body"></div>
509
+ </aside>
510
+
511
  <div class="drawer-back" id="consent-back"></div>
512
  <div class="consent" id="consent" role="dialog" aria-modal="true" aria-labelledby="consent-title">
513
  <h3 id="consent-title"></h3>
 
562
  hSafety:'Safety — TRACE layer',
563
  signalsEmpty:'Values in range, no dangerous interactions found.',
564
  hMeds:'Prescribed medications', medsEmpty:'No medications.',
565
+ hDocs:'Submitted documents', docsEmpty:'No documents. Load the demo card or add one.',
566
+ docKind:{discharge_summary:'discharge summary',lab_report:'lab report',prescription:'prescription',note:'note'},
567
+ btnGraph:'Knowledge graph',
568
+ graphTitle:'Knowledge graph',
569
+ graphCap:'Every fact points back to the document it came from (<code>derived_from</code>). This is what makes an answer traceable — and what the safety layer computes on. Click a node to see its links.',
570
+ graphLegend:{Patient:'patient',Document:'document',Observation:'measurement',Condition:'condition',MedicationRequest:'medication'},
571
+ graphEmpty:'The card is empty — load documents first.',
572
  hQa:'Ask the card', qaTag:'grounded · with source reference',
573
  qPlaceholder:'e.g.: What about kidneys over the last year?', ask:'Ask',
574
  footer1:'Prototype for <b>Digital Future Hackathon</b> · Aging &amp; Longevity track.',
 
592
  errorPrefix:'Error: ', selected:name=>`Selected: ${name}`,
593
  chips:['What about kidneys over the last year?','What was potassium?',
594
  'What medications and any interactions?','What about HbA1c?'],
595
+ story:(docs,pair)=>`One timeline from ${docs} documents. 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.`,
596
  groupL1:'L1 · deterministic rules', groupL2a:'L2a · trends & reference ranges',
597
  trust:['No LLM in safety decisions','Deterministic safety rules (L1)','FHIR export','Local-first by design'],
598
  trustPhotoCloud:'Photo OCR → cloud lab',
 
604
  consentBody:'To read the photo, this public demo sends it to a cloud OCR lab (Hugging Face ZeroGPU). Text you type, the demo card and every safety decision stay on the server and never go to an LLM.\n\nUpload synthetic or fully de-identified documents only — never real personal medical data.',
605
  consentOk:'Send the photo',
606
  consentCancel:'Cancel',
607
+ btnHelp:'Help',
608
+ help:{
609
+ title:'How to use LiveMedCard',
610
+ sections:[
611
+ {h:'What this is',
612
+ p:['LiveMedCard turns scattered medical documents — discharge summaries, lab reports, prescriptions, in any mix of languages — into <b>one timeline</b>, watches it for danger, and answers questions about it. Every fact keeps a link back to the sentence it came from.',
613
+ 'It structures facts and shows trends. It <b>does not diagnose and does not prescribe treatment</b>.']},
614
+ {h:'Getting started',
615
+ li:['<b>Load demo card</b> — four documents (Ukrainian and English) of a 70-year-old patient. The fastest way to see everything at once.',
616
+ '<b>+ Add document</b> — paste the text of a document, or upload a photo of one. Pick the document type, then process it.',
617
+ '<b>Ask the card</b> — type a question in the field at the bottom, or click one of the suggested ones.',
618
+ '<b>Export FHIR</b> — download the whole card as a standard FHIR Bundle.',
619
+ '<b>Reset</b> — empty the card and start over. Your card is yours alone: every visitor gets a separate copy.']},
620
+ {h:'How a document is read',
621
+ p:['A language model reads the document and <b>extracts facts</b> — that is all it does. The safety layer then computes signals <b>deterministically</b>, by rules, on those facts: drug interactions and dose limits (L1), trends and reference ranges (L2a). A router (L3) decides whether the result can be processed automatically or must go to a human (L4).',
622
+ 'This boundary matters. The model <b>cannot invent</b> an interaction — interactions are checked by rules, not guessed. But if it <b>misses</b> a drug, the rules have no pair to check. That is why extraction from a photo is marked unverified and escalated to a human, instead of pretending to be confident.']},
623
+ {h:'Reading the timeline',
624
+ li:['Each card shows the <b>latest value</b>, its <b>measurement date</b>, the <b>reference range</b>, the trend, and whether the value is in range.',
625
+ 'The <b>UA / EN</b> badge is the language of the source document, detected from its text.',
626
+ 'Click a <b>document chip</b> under a metric to open the source and see the exact sentence the number came from, highlighted.',
627
+ '<b>“no date in document”</b> means the document never stated a date for that value, so the upload date is shown. A last-year report must not silently pose as today’s measurement.',
628
+ '<b>“Extracted · no standard code”</b> lists facts that were extracted but have no LOINC code — so they have no reference range and raise no automatic signal. They are shown rather than hidden: a fact that was extracted and made invisible looks exactly like one that was never extracted.']},
629
+ {h:'The safety panel',
630
+ li:['<b>interaction</b> — a dangerous drug pair (e.g. warfarin + aspirin).',
631
+ '<b>dose_limit</b> — the daily dose exceeds the limit for that drug.',
632
+ '<b>trend</b> — a value moves in one direction across several measurements.',
633
+ '<b>range_breach</b> — the latest value is outside the reference range.',
634
+ '<b>unit_mismatch</b> — the value’s units do not match the reference, so the range check was <b>not performed</b>. A wrong signal is worse than a missing one, so nothing is converted or assumed.'],
635
+ p:['At the top, the router states its decision: <b>automatic processing</b>, or <b>escalated to human (L4)</b> when a serious signal fired or the extraction is unverified.']},
636
+ {h:'Asking the card',
637
+ p:['Answers are grounded in the facts on the card and always cite their sources. Ask in English or Ukrainian — the answer comes back in the language of the question. If the card holds no fact to answer with, it says so instead of guessing.']},
638
+ {h:'Photos and privacy',
639
+ p:['In this public demo, the text you type, the card itself and <b>every safety decision</b> stay on the server and never reach a language model. A <b>photo</b> is different: it is sent to a cloud OCR lab, so the app asks for your consent first and reading takes up to ~90 seconds.',
640
+ '<b>Upload synthetic or fully de-identified documents only</b> — never real personal medical data. Run the project yourself and everything, including OCR, stays on your machine.']},
641
+ ],
642
+ },
643
  },
644
  ua: {
645
  htmlLang:'uk', title:'LiveMedCard — жива медична картка',
 
659
  hSafety:'Безпека — шар TRACE',
660
  signalsEmpty:'Показники в межах, небезпечних взаємодій не виявлено.',
661
  hMeds:'Призначені ліки', medsEmpty:'Ліків немає.',
662
+ hDocs:'Подані документи', docsEmpty:'Документів немає. Завантажте демо-картку або додайте свій.',
663
+ docKind:{discharge_summary:'виписний епікриз',lab_report:'лабораторний звіт',prescription:'рецепт',note:'нотатка'},
664
+ btnGraph:'Граф знань',
665
+ graphTitle:'Граф знань',
666
+ graphCap:'Кожен факт вказує на документ, з якого його взято (<code>derived_from</code>). Саме це робить відповідь простежуваною — і саме на цьому рахує шар безпеки. Натисніть вузол, щоб побачити його зв’язки.',
667
+ graphLegend:{Patient:'пацієнт',Document:'документ',Observation:'вимірювання',Condition:'стан',MedicationRequest:'препарат'},
668
+ graphEmpty:'Картка порожня — спершу завантажте документи.',
669
  hQa:'Запитати картку', qaTag:'grounded · з посиланням на джерело',
670
  qPlaceholder:'Напр.: Що з нирками за останній рік?', ask:'Спитати',
671
  footer1:'Прототип для <b>Digital Future Hackathon</b> · ніша Aging &amp; Longevity.',
 
689
  errorPrefix:'Помилка: ', selected:name=>`Вибрано: ${name}`,
690
  chips:['Що з нирками за останній рік?','Який був калій?',
691
  'Які ліки приймає і чи є взаємодії?','Що з HbA1c?'],
692
+ story:(docs,pair)=>`Один таймлайн із ${docs} документів. Картка помітила небезпечну комбінацію — <b>${pair}</b> — детерміновано, правилами безпеки, а не «на думку» ШІ. Дивіться червоний сигнал у панелі безпеки.`,
693
  groupL1:'L1 · детерміновані правила', groupL2a:'L2a · тренди й референсні межі',
694
  trust:['Жодної LLM у рішеннях безпеки','Детерміновані правила безпеки (L1)','Експорт FHIR','Local-first за дизайном'],
695
  trustPhotoCloud:'OCR фото → хмарний лаб',
 
701
  consentBody:'Щоб прочитати фото, це публічне демо надсилає його в хмарний OCR-лаб (Hugging Face ZeroGPU). Введений текст, демо-картка й усі рішення безпеки лишаються на сервері й до жодної LLM не потрапляють.\n\nЗавантажуйте лише синтетичні або повністю деідентифіковані документи — ніколи не справжні персональні медичні дані.',
702
  consentOk:'Надіслати фото',
703
  consentCancel:'Скасувати',
704
+ btnHelp:'Довідка',
705
+ help:{
706
+ title:'Як користуватися LiveMedCard',
707
+ sections:[
708
+ {h:'Що це',
709
+ p:['LiveMedCard зводить розкидані медичні документи — виписки, аналізи, рецепти, будь-якими мовами — в <b>один таймлайн</b>, стежить за небезпеками й відповідає на питання про нього. Кожен факт зберігає посилання на речення, з якого його взято.',
710
+ 'Система структурує факти й показує динаміку. Вона <b>не ставить діагнозів і не призначає лікування</b>.']},
711
+ {h:'З чого почати',
712
+ li:['<b>Завантажити демо-картку</b> — чотири документи (українські та англійські) пацієнтки 70 років. Найшвидший спосіб побачити все одразу.',
713
+ '<b>+ Додати документ</b> — вставте текст документа або завантажте його фото. Виберіть тип документа й обробіть.',
714
+ '<b>Запитати картку</b> — введіть питання в полі внизу або натисніть одну із запропонованих підказок.',
715
+ '<b>Експорт FHIR</b> — завантажити всю картку як стандартний FHIR Bundle.',
716
+ '<b>Скинути</b> — очистити картку й почати спочатку. Ваша картка лише ваша: кожен відвідувач отримує окрему копію.']},
717
+ {h:'Як читається документ',
718
+ p:['Мовна модель читає документ і <b>витягує факти</b> — і більше нічого. Далі шар безпеки рахує сигнали <b>детерміновано</b>, правилами, на цих фактах: взаємодії ліків і дозові межі (L1), тренди й референсні межі (L2a). Роутер (L3) вирішує, чи можна обробити результат автоматично, чи він має піти до людини (L4).',
719
+ 'Ця межа принципова. Модель <b>не може вигадати</b> взаємодію — взаємодії перевіряються правилами, а не «на думку» ШІ. Але якщо вона <b>не витягла</b> препарат, у правил просто немає пари для перевірки. Саме тому витяг із фото позначається як неперевірений і йде до людини, а не вдає впевненість.']},
720
+ {h:'Як читати таймлайн',
721
+ li:['На картці показника видно <b>останнє значення</b>, <b>дату вимірювання</b>, <b>референсні межі</b>, тренд і те, чи значення в нормі.',
722
+ 'Бейдж <b>UA / EN</b> — мова документа-джерела, визначена за його текстом.',
723
+ 'Натисніть <b>чип документа</b> під показником, щоб відкрити джерело й побачити підсвічене речення, з якого взято число.',
724
+ '<b>«дати немає в документі»</b> означає, що документ не подав дати для цього значення, тож показано дату завантаження. Торішній аналіз не має тихо видавати себе за сьогоднішнє вимірювання.',
725
+ '<b>«Витягнуто · без стандартного коду»</b> — факти, які витягнуто, але вони не мають LOINC-коду, а отже ані референсних меж, ані автоматичного сигналу. Ми їх показуємо, а не ховаємо: витягнутий і невидимий факт виглядає точно так само, як невитягнутий.']},
726
+ {h:'Панель безпеки',
727
+ li:['<b>interaction</b> — небезпечна пара ліків (напр. варфарин + аспірин).',
728
+ '<b>dose_limit</b> — добова доза перевищує межу для цього препарату.',
729
+ '<b>trend</b> — показник кілька вимірювань поспіль рухається в один бік.',
730
+ '<b>range_breach</b> — останнє значення поза референсними межами.',
731
+ '<b>unit_mismatch</b> — одиниці значення не збігаються з референсними, тож перевірку меж <b>не виконано</b>. Хибний сигнал гірший за відсутній, тому ми нічого не конвертуємо й не додумуємо.'],
732
+ p:['Угорі роутер називає своє рішення: <b>автоматична обробка</b> або <b>ескалація до людини (L4)</b> — коли спрацював серйозний сигнал чи витяг неперевірений.']},
733
+ {h:'Питання до картки',
734
+ p:['Відповіді заземлені на фактах картки й завжди супроводжуються джерелами. Питайте українською або англійською — відповідь буде мовою питання. Якщо в картці немає факту для відповіді, вона так і скаже, а не вигадає.']},
735
+ {h:'Фото і приватність',
736
+ p:['У цьому публічному демо введений текст, сама картка й <b>усі рішення безпеки</b> лишаються на сервері й до мовної моделі не потрапляють. З <b>фото</b> інакше: воно вирушає в хмарний OCR-лаб, тож застосунок спершу питає вашої згоди, а зчитування триває до ~90 секунд.',
737
+ '<b>Завантажуйте лише синтетичні або повністю деідентифіковані документи</b> — ніколи не справжні персональні медичні дані. Якщо запустити проєкт у себе, все, включно з OCR, лишається на вашій машині.']},
738
+ ],
739
+ },
740
  },
741
  };
742
 
 
759
  $('#btn-add').textContent = t.btnAdd;
760
  $('#btn-export').textContent = t.btnExport;
761
  $('#btn-reset').textContent = t.btnReset;
762
+ $('#btn-help').textContent = t.btnHelp;
763
+ $('#btn-graph').textContent = t.btnGraph;
764
+ $('#h-docs').textContent = t.hDocs;
765
+ renderHelp(); // довідка йде за мовою, навіть якщо відкрита
766
  $('#add-text').placeholder = t.addPlaceholder;
767
  $('#or-photo').textContent = t.orPhoto;
768
  if(!fileInput.files.length) $('#dropzone-text').textContent = t.dropzoneDefault;
 
967
  }).join('');
968
  }
969
 
970
+ // подані документи — оригінал за один клік (той самий drawer, що й для span-провенансу)
971
+ const dEntries=Object.entries(st.docs||{});
972
+ const dcEl=$('#docs');
973
+ if(!dEntries.length){dcEl.innerHTML=`<div class="empty">${t.docsEmpty}</div>`;}
974
+ else{dcEl.innerHTML=dEntries.map(([id,d])=>{
975
+ const lang=d.lang?`<span class="lang ${d.lang==='en'?'en':'ua'}">${d.lang.toUpperCase()}</span>`:'';
976
+ const kind=(t.docKind[d.kind]||d.kind||'');
977
+ return `<button class="docrow" data-doc="${id}">
978
+ ${lang}<span class="id">${id}</span><span class="kind">${kind}</span>
979
+ <span class="d">${d.date||''}</span></button>`;
980
+ }).join('');}
981
+
982
  // decision
983
  const d=st.decision;const dEl=$('#decision');
984
  dEl.className='decision '+(d.escalate?'escalate':'auto');
 
1042
  function closeDoc(){$('#drawer').classList.remove('show');$('#drawer-back').classList.remove('show');}
1043
  $('#drawer-close').onclick=closeDoc;
1044
  $('#drawer-back').onclick=closeDoc;
1045
+ addEventListener('keydown',e=>{if(e.key==='Escape'){closeDoc();closeHelp();closeGraph();}});
1046
+
1047
+ // ── довідка ───────────────────────────────────────────────
1048
+ function renderHelp(){
1049
+ const h=I18N[LANG].help;
1050
+ $('#help-title').textContent=h.title;
1051
+ $('#help-body').innerHTML=h.sections.map(s=>`<section>
1052
+ <h4>${s.h}</h4>
1053
+ ${(s.li?`<ul>${s.li.map(x=>`<li>${x}</li>`).join('')}</ul>`:'')}
1054
+ ${(s.p||[]).map(x=>`<p>${x}</p>`).join('')}
1055
+ </section>`).join('');
1056
+ }
1057
+ // ── граф знань ────────────────────────────────────────────
1058
+ // Шари зліва направо: пацієнт → факти → документи. Саме такий і сенс графа:
1059
+ // кожен факт зрештою впирається в документ, з якого його взято (derived_from).
1060
+ const GCOLOR={Patient:'var(--accent)',Observation:'#3aa6a0',Condition:'#e0a23c',
1061
+ MedicationRequest:'#5e9dff',Document:'#8a94a6'};
1062
+ const GORDER=['Observation','MedicationRequest','Condition'];
1063
+
1064
+ function drawGraph(g){
1065
+ const t=I18N[LANG], wrap=$('#graph-wrap');
1066
+ if(!g.nodes.length){wrap.innerHTML=`<div class="empty">${t.graphEmpty}</div>`;$('#graph-legend').innerHTML='';return;}
1067
+ $('#graph-legend').innerHTML=Object.entries(t.graphLegend)
1068
+ .map(([k,v])=>`<span><i style="background:${GCOLOR[k]}"></i>${v}</span>`).join('');
1069
+
1070
+ const byType=ty=>g.nodes.filter(n=>n.type===ty);
1071
+ const facts=GORDER.flatMap(byType);
1072
+ const docs=byType('Document'), pats=byType('Patient');
1073
+ const rows=Math.max(facts.length,docs.length,pats.length);
1074
+ const H=Math.max(240,rows*24+40), W=880, X=[70,380,700];
1075
+ const pos={};
1076
+ const place=(list,x)=>list.forEach((n,i)=>{
1077
+ pos[n.id]={x,y:(H/(list.length+1))*(i+1)};
1078
+ });
1079
+ place(pats,X[0]); place(facts,X[1]); place(docs,X[2]);
1080
+
1081
+ const edges=g.edges.filter(e=>pos[e.source]&&pos[e.target]).map((e,i)=>{
1082
+ const a=pos[e.source],b=pos[e.target],mx=(a.x+b.x)/2;
1083
+ return `<path class="edge ${e.rel==='derived_from'?'prov':''}" id="e${i}"
1084
+ data-a="${e.source}" data-b="${e.target}"
1085
+ d="M${a.x},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x},${b.y}"/>`;
1086
+ }).join('');
1087
+
1088
+ const nodes=g.nodes.filter(n=>pos[n.id]).map(n=>{
1089
+ const p=pos[n.id], right=p.x===X[2];
1090
+ const label=n.label.length>26?n.label.slice(0,25)+'…':n.label;
1091
+ return `<g class="node" data-id="${n.id}">
1092
+ <circle cx="${p.x}" cy="${p.y}" r="5.5" fill="${GCOLOR[n.type]||'#888'}"/>
1093
+ <text x="${right?p.x+11:p.x-11}" y="${p.y+4}" text-anchor="${right?'start':'end'}">${label}</text>
1094
+ </g>`;
1095
+ }).join('');
1096
+
1097
+ wrap.innerHTML=`<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${edges}${nodes}</svg>`;
1098
+ wrap.querySelectorAll('.node').forEach(el=>{
1099
+ el.onclick=()=>{
1100
+ const id=el.dataset.id, on=wrap.classList.contains('sel')&&el.classList.contains('on');
1101
+ wrap.classList.remove('sel');
1102
+ wrap.querySelectorAll('.on').forEach(x=>x.classList.remove('on'));
1103
+ if(on)return; // повторний клік — зняти виділення
1104
+ wrap.classList.add('sel'); el.classList.add('on');
1105
+ wrap.querySelectorAll('.edge').forEach(e=>{
1106
+ if(e.dataset.a===id||e.dataset.b===id){
1107
+ e.classList.add('on');
1108
+ const other=e.dataset.a===id?e.dataset.b:e.dataset.a;
1109
+ wrap.querySelector(`.node[data-id="${CSS.escape(other)}"]`)?.classList.add('on');
1110
+ }
1111
+ });
1112
+ };
1113
+ });
1114
+ }
1115
+
1116
+ async function openGraph(){
1117
+ const t=I18N[LANG];
1118
+ $('#graph-title').textContent=t.graphTitle;
1119
+ $('#graph-cap').innerHTML=t.graphCap;
1120
+ $('#graphm').classList.add('show');$('#graph-back').classList.add('show');
1121
+ try{drawGraph(await api('/graph'));}catch(e){toast(t.errorPrefix+e.message);}
1122
+ }
1123
+ function closeGraph(){$('#graphm').classList.remove('show');$('#graph-back').classList.remove('show');}
1124
+ $('#btn-graph').onclick=openGraph;
1125
+ $('#graph-close').onclick=closeGraph;
1126
+ $('#graph-back').onclick=closeGraph;
1127
+
1128
+ function openHelp(){renderHelp();$('#help').classList.add('show');$('#help-back').classList.add('show');$('#help-close').focus();}
1129
+ function closeHelp(){$('#help').classList.remove('show');$('#help-back').classList.remove('show');}
1130
+ $('#btn-help').onclick=openHelp;
1131
+ $('#help-close').onclick=closeHelp;
1132
+ $('#help-back').onclick=closeHelp;
1133
+ // Рядок у списку поданих документів → відкрити оригінал (без підсвітки span).
1134
+ document.addEventListener('click',e=>{
1135
+ const row=e.target.closest('.docrow'); if(!row)return;
1136
+ openDoc(row.dataset.doc, null);
1137
+ });
1138
+
1139
+ // Делегат на всю ліву колонку: чипи джерел є і в таймлайні, і серед некодованих фактів.
1140
+ document.addEventListener('click',e=>{
1141
  const chip=e.target.closest('.srcchip'); if(!chip)return;
1142
  const span=chip.dataset.span?chip.dataset.span.split('-').map(Number):null;
1143
  openDoc(chip.dataset.doc, span);
 
1156
  }catch(e){toast(I18N[LANG].errorPrefix+e.message);}
1157
  }
1158
 
1159
+ // Демо-документи — мовою інтерфейсу (набори дзеркальні: ті самі значення, ті самі сигнали).
1160
+ $('#btn-samples').onclick=async()=>{
1161
+ await api(`/ingest/samples?lang=${LANG==='ua'?'uk':'en'}`,{method:'POST'});
1162
+ await refresh();toast(I18N[LANG].toastSamples);
1163
+ };
1164
  $('#btn-reset').onclick=async()=>{await api('/reset',{method:'POST'});$('#log').innerHTML='';await refresh();toast(I18N[LANG].toastReset);};
1165
  $('#btn-export').onclick=async()=>{
1166
  const b=await api('/export');