Spaces:
Sleeping
Sleeping
DocUA commited on
Commit ·
cc02af3
1
Parent(s): 28cbc96
feat: help, document viewer, knowledge graph; empty start + language-matched demo
Browse files- Dockerfile +3 -2
- livemedcard/api.py +40 -9
- livemedcard/data/sample_docs.json +28 -2
- livemedcard/layers/l2b_extractor.py +15 -3
- livemedcard/reference.py +8 -3
- livemedcard/static/index.html +291 -5
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 |
-
|
|
|
|
| 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 |
-
#
|
| 393 |
-
#
|
| 394 |
-
#
|
| 395 |
docs = {}
|
| 396 |
-
for
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 3 |
-
"
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""Демо-документи
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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
|
| 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 & Longevity.',
|
|
@@ -553,7 +689,7 @@ const I18N = {
|
|
| 553 |
errorPrefix:'Помилка: ', selected:name=>`Вибрано: ${name}`,
|
| 554 |
chips:['Що з нирками за останній рік?','Який був калій?',
|
| 555 |
'Які ліки приймає і чи є взаємодії?','Що з HbA1c?'],
|
| 556 |
-
story:(docs,pair)=>`Один таймлайн із ${docs} документів
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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 & 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');
|