Spaces:
Sleeping
Sleeping
DocUA commited on
Commit ·
28cbc96
1
Parent(s): 18603d9
fix: show every extracted fact, its date, and the document's real language
Browse files- livemedcard/api.py +24 -1
- livemedcard/fhir.py +5 -0
- livemedcard/layers/l2b_stage2_lora.py +18 -9
- livemedcard/static/index.html +69 -5
- livemedcard/stores.py +12 -0
livemedcard/api.py
CHANGED
|
@@ -47,6 +47,7 @@ from .config import (
|
|
| 47 |
)
|
| 48 |
from .dto import Document, DocKind
|
| 49 |
from .fhir import Patient
|
|
|
|
| 50 |
from .layers import l3_router
|
| 51 |
from .layers import l2b_nuextract, l2b_ocrlab
|
| 52 |
from .layers.l2b_ocrlab import OcrLabError
|
|
@@ -372,11 +373,31 @@ def get_state_endpoint(lang: str = "uk", state: _State = Depends(_get_state)):
|
|
| 372 |
"high": ref.high if ref else None,
|
| 373 |
"points": [
|
| 374 |
{"ts": p.ts.date().isoformat(), "value": p.value,
|
| 375 |
-
"source": p.source_doc_id, "span": p.source_span
|
|
|
|
| 376 |
for p in pts
|
| 377 |
],
|
| 378 |
})
|
| 379 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
seen: dict[str, None] = {}
|
| 381 |
for n in card.graph.nodes_of_type("MedicationRequest"):
|
| 382 |
seen.setdefault(card.graph.g.nodes[n].get("label", n), None)
|
|
@@ -386,6 +407,8 @@ def get_state_endpoint(lang: str = "uk", state: _State = Depends(_get_state)):
|
|
| 386 |
"patient": {"id": card.patient.id, "gender": card.patient.gender},
|
| 387 |
"document_count": card.document_count,
|
| 388 |
"series": series,
|
|
|
|
|
|
|
| 389 |
"medications": medications,
|
| 390 |
"signals": [s.model_dump() for s in sigs],
|
| 391 |
"decision": decision.model_dump(),
|
|
|
|
| 47 |
)
|
| 48 |
from .dto import Document, DocKind
|
| 49 |
from .fhir import Patient
|
| 50 |
+
from .lang import detect_lang
|
| 51 |
from .layers import l3_router
|
| 52 |
from .layers import l2b_nuextract, l2b_ocrlab
|
| 53 |
from .layers.l2b_ocrlab import OcrLabError
|
|
|
|
| 373 |
"high": ref.high if ref else None,
|
| 374 |
"points": [
|
| 375 |
{"ts": p.ts.date().isoformat(), "value": p.value,
|
| 376 |
+
"source": p.source_doc_id, "span": p.source_span,
|
| 377 |
+
"dated": p.date_stated}
|
| 378 |
for p in pts
|
| 379 |
],
|
| 380 |
})
|
| 381 |
|
| 382 |
+
# Факти без LOINC: витягнуті, але некодовані (напр. TIR-бари AGP-звіту).
|
| 383 |
+
# Сигналів не дають — межі порівнювати нема з чим. Показуємо їх окремо:
|
| 384 |
+
# мовчки з'їдений факт неможливо відрізнити від невитягнутого.
|
| 385 |
+
uncoded = [
|
| 386 |
+
{"keyword": f.keyword, "value": f.value, "unit": f.unit,
|
| 387 |
+
"ts": f.ts.date().isoformat(), "source": f.source_doc_id,
|
| 388 |
+
"span": f.source_span, "dated": f.date_stated}
|
| 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"):
|
| 403 |
seen.setdefault(card.graph.g.nodes[n].get("label", n), None)
|
|
|
|
| 407 |
"patient": {"id": card.patient.id, "gender": card.patient.gender},
|
| 408 |
"document_count": card.document_count,
|
| 409 |
"series": series,
|
| 410 |
+
"uncoded": uncoded,
|
| 411 |
+
"docs": docs,
|
| 412 |
"medications": medications,
|
| 413 |
"signals": [s.model_dump() for s in sigs],
|
| 414 |
"decision": decision.model_dump(),
|
livemedcard/fhir.py
CHANGED
|
@@ -64,6 +64,11 @@ class Provenance(BaseModel):
|
|
| 64 |
extracted_at: datetime = Field(default_factory=_utcnow)
|
| 65 |
confidence: Optional[float] = None
|
| 66 |
source_span: Optional[tuple[int, int]] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
class Attachment(BaseModel):
|
|
|
|
| 64 |
extracted_at: datetime = Field(default_factory=_utcnow)
|
| 65 |
confidence: Optional[float] = None
|
| 66 |
source_span: Optional[tuple[int, int]] = None
|
| 67 |
+
# Чи дата факту справді стоїть у документі. False → ``effectiveDateTime`` це
|
| 68 |
+
# дата завантаження (fallback), а не дата вимірювання. Ряду потрібна якась
|
| 69 |
+
# мітка часу, але інтерфейс мусить показати різницю: датувати торішній аналіз
|
| 70 |
+
# сьогоднішнім днем — тиха помилка, яка ламає і тренд, і довіру.
|
| 71 |
+
date_stated: bool = True
|
| 72 |
|
| 73 |
|
| 74 |
class Attachment(BaseModel):
|
livemedcard/layers/l2b_stage2_lora.py
CHANGED
|
@@ -148,21 +148,27 @@ def _split_value(value: object) -> tuple[Optional[float], Optional[str]]:
|
|
| 148 |
return num, unit
|
| 149 |
|
| 150 |
|
| 151 |
-
def _parse_timestamp(
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
for sep in (" - ", " – ", " — ", " to "):
|
| 157 |
if sep in s:
|
| 158 |
s = s.split(sep)[-1].strip()
|
| 159 |
break
|
| 160 |
for fmt in _TS_DATE_FORMATS:
|
| 161 |
try:
|
| 162 |
-
return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
|
| 163 |
except ValueError:
|
| 164 |
continue
|
| 165 |
-
return fallback
|
| 166 |
|
| 167 |
|
| 168 |
def extract_facts(summary_text: str, *, url: str | None = None, model: str | None = None) -> list[dict]:
|
|
@@ -203,7 +209,7 @@ def to_observations(
|
|
| 203 |
if unit is None:
|
| 204 |
raw_unit = str(item.get("unit") or "").strip()
|
| 205 |
unit = raw_unit or None
|
| 206 |
-
eff = _parse_timestamp(item.get("timestamp"), doc.received_at)
|
| 207 |
bridge = _resolve_loinc(keyword)
|
| 208 |
coding = (
|
| 209 |
[Coding(system=Sys.LOINC, code=bridge[0], display=bridge[1])]
|
|
@@ -222,7 +228,10 @@ def to_observations(
|
|
| 222 |
effectiveDateTime=eff,
|
| 223 |
valueQuantity=Quantity(value=num, unit=unit),
|
| 224 |
provenance=Provenance(
|
| 225 |
-
source_doc_id=doc.id,
|
|
|
|
|
|
|
|
|
|
| 226 |
),
|
| 227 |
)
|
| 228 |
)
|
|
|
|
| 148 |
return num, unit
|
| 149 |
|
| 150 |
|
| 151 |
+
def _parse_timestamp(
|
| 152 |
+
ts: object, fallback: Optional[datetime]
|
| 153 |
+
) -> tuple[Optional[datetime], bool]:
|
| 154 |
+
"""Дата факту з різних форматів; для періоду «A - B» береться КІНЕЦЬ (B).
|
| 155 |
+
|
| 156 |
+
Другий елемент — чи дата справді прочитана з документа. False → повернуто
|
| 157 |
+
fallback (дата завантаження), і це треба показати, а не видати за виміряну.
|
| 158 |
+
"""
|
| 159 |
+
s = "" if ts is None else str(ts).strip()
|
| 160 |
+
if not s or s.lower() in ("not stated", "none", "null"):
|
| 161 |
+
return fallback, False
|
| 162 |
for sep in (" - ", " – ", " — ", " to "):
|
| 163 |
if sep in s:
|
| 164 |
s = s.split(sep)[-1].strip()
|
| 165 |
break
|
| 166 |
for fmt in _TS_DATE_FORMATS:
|
| 167 |
try:
|
| 168 |
+
return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc), True
|
| 169 |
except ValueError:
|
| 170 |
continue
|
| 171 |
+
return fallback, False
|
| 172 |
|
| 173 |
|
| 174 |
def extract_facts(summary_text: str, *, url: str | None = None, model: str | None = None) -> list[dict]:
|
|
|
|
| 209 |
if unit is None:
|
| 210 |
raw_unit = str(item.get("unit") or "").strip()
|
| 211 |
unit = raw_unit or None
|
| 212 |
+
eff, date_stated = _parse_timestamp(item.get("timestamp"), doc.received_at)
|
| 213 |
bridge = _resolve_loinc(keyword)
|
| 214 |
coding = (
|
| 215 |
[Coding(system=Sys.LOINC, code=bridge[0], display=bridge[1])]
|
|
|
|
| 228 |
effectiveDateTime=eff,
|
| 229 |
valueQuantity=Quantity(value=num, unit=unit),
|
| 230 |
provenance=Provenance(
|
| 231 |
+
source_doc_id=doc.id,
|
| 232 |
+
extracted_by=extracted_by,
|
| 233 |
+
confidence=None,
|
| 234 |
+
date_stated=date_stated,
|
| 235 |
),
|
| 236 |
)
|
| 237 |
)
|
livemedcard/static/index.html
CHANGED
|
@@ -159,6 +159,8 @@
|
|
| 159 |
.trend.flat{color:var(--muted);background:color-mix(in srgb,var(--muted) 14%,transparent)}
|
| 160 |
.metric .meta{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-top:3px}
|
| 161 |
.metric .ref{color:var(--faint);font-size:11.5px;font-family:var(--mono)}
|
|
|
|
|
|
|
| 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}
|
|
@@ -167,6 +169,20 @@
|
|
| 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}
|
| 171 |
.spark path.line{stroke:var(--accent);stroke-width:2.2;fill:none;stroke-linejoin:round;stroke-linecap:round;
|
| 172 |
stroke-dasharray:var(--len);stroke-dashoffset:var(--len);animation:draw 1.1s .1s ease-out forwards}
|
|
@@ -363,6 +379,11 @@
|
|
| 363 |
<section class="panel d1">
|
| 364 |
<h2><span id="h-series">Metrics timeline</span> <span class="tag" id="bi-tag" style="display:none">bilingual · UA + EN</span></h2>
|
| 365 |
<div class="series" id="series"><div class="empty">No data yet. Click “Load demo card”.</div></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
</section>
|
| 367 |
|
| 368 |
<div class="right">
|
|
@@ -463,6 +484,10 @@ const I18N = {
|
|
| 463 |
statSignals:'safety signals', statLangs:'document languages',
|
| 464 |
statusOk:'normal', statusHigh:'above range', statusLow:'below range',
|
| 465 |
range:'range', meas:n=>`${n} meas.`,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
escalate:'escalated to human (L4)', auto:'automatic processing',
|
| 467 |
sevLabel:{critical:'critical',major:'high',moderate:'moderate',info:'info'},
|
| 468 |
toastSamples:'Demo card loaded', toastReset:'Card reset',
|
|
@@ -513,6 +538,10 @@ const I18N = {
|
|
| 513 |
statSignals:'сигналів безпеки', statLangs:'мови документів',
|
| 514 |
statusOk:'у нормі', statusHigh:'вище норми', statusLow:'нижче норми',
|
| 515 |
range:'норма', meas:n=>`${n} вимір.`,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
escalate:'ескалація до людини (L4)', auto:'автоматична обробка',
|
| 517 |
sevLabel:{critical:'критичний',major:'високий',moderate:'помірний',info:'інфо'},
|
| 518 |
toastSamples:'Демо-картку завантажено', toastReset:'Картку скинуто',
|
|
@@ -611,7 +640,24 @@ async function api(path, opts){
|
|
| 611 |
return r.status===204?null:r.json();
|
| 612 |
}
|
| 613 |
|
| 614 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
|
| 616 |
function sparkline(points, low, high){
|
| 617 |
const w=300,h=64,pad=9;
|
|
@@ -681,7 +727,7 @@ function render(st){
|
|
| 681 |
// stat strip
|
| 682 |
const nMeas=st.series.reduce((a,s)=>a+s.points.length,0);
|
| 683 |
const langs=new Set();
|
| 684 |
-
st.series.forEach(s=>s.points.forEach(p=>p.source&&
|
| 685 |
const bilingual=langs.has('en')&&langs.has('ua');
|
| 686 |
$('#bi-tag').style.display = bilingual?'inline-block':'none';
|
| 687 |
const nAlerts=st.signals.length;
|
|
@@ -689,7 +735,7 @@ function render(st){
|
|
| 689 |
<div class="stat"><div class="n">${st.series.length}</div><div class="l">${t.statMetrics}</div></div>
|
| 690 |
<div class="stat"><div class="n">${nMeas}</div><div class="l">${t.statMeasurements}</div></div>
|
| 691 |
<div class="stat alert"><div class="n">${nAlerts}</div><div class="l">${t.statSignals}</div></div>
|
| 692 |
-
<div class="stat bi"><div class="n">${bilingual?'UA+EN':(langs.has('en')?'EN':'UA')}</div><div class="l">${t.statLangs}</div></div>
|
| 693 |
` : '';
|
| 694 |
|
| 695 |
// story ribbon — investor orientation, only when a drug interaction was caught
|
|
@@ -709,7 +755,8 @@ function render(st){
|
|
| 709 |
const name=(LOINC_LABEL[s.loinc]||{})[LANG]||s.keyword;
|
| 710 |
const ref=(s.low!=null&&s.high!=null)?`${t.range} ${s.low}–${s.high} ${s.unit||''}`:'';
|
| 711 |
const srcs=[...new Set(s.points.map(p=>p.source).filter(Boolean))];
|
| 712 |
-
const langBadges=[...new Set(srcs.map(langOf)
|
|
|
|
| 713 |
const chips=srcs.map(src=>{
|
| 714 |
// span останньої точки з цього документа — якір для drawer-підсвітки
|
| 715 |
const pt=[...s.points].reverse().find(p=>p.source===src);
|
|
@@ -722,11 +769,28 @@ function render(st){
|
|
| 722 |
<span class="big"><span class="val">${last.value}</span><span class="unit">${s.unit||''}</span>
|
| 723 |
${ttxt?`<span class="trend ${tcls}">${ttxt}</span>`:''}</span>
|
| 724 |
</div>
|
| 725 |
-
<div class="meta"><span class="ref">${ref}</span><span class="status ${cls}">${txt}</span></div>
|
| 726 |
${sparkline(s.points,s.low,s.high)}
|
| 727 |
<div class="src">${langBadges}<span>${t.meas(s.points.length)}</span>${chips}</div>
|
| 728 |
</div>`;}).join('');}
|
| 729 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 730 |
// decision
|
| 731 |
const d=st.decision;const dEl=$('#decision');
|
| 732 |
dEl.className='decision '+(d.escalate?'escalate':'auto');
|
|
|
|
| 159 |
.trend.flat{color:var(--muted);background:color-mix(in srgb,var(--muted) 14%,transparent)}
|
| 160 |
.metric .meta{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-top:3px}
|
| 161 |
.metric .ref{color:var(--faint);font-size:11.5px;font-family:var(--mono)}
|
| 162 |
+
.when{font-family:var(--mono);font-size:11.5px;color:var(--muted)}
|
| 163 |
+
.when.nodate{color:var(--moderate);cursor:help}
|
| 164 |
.status{font-size:11.5px;font-weight:700}
|
| 165 |
.status.ok{color:var(--ok)} .status.high{color:var(--major)} .status.low{color:var(--moderate)}
|
| 166 |
.metric .src{color:var(--faint);font-size:11px;margin-top:7px;display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
|
|
|
| 169 |
.srcchip:hover{color:var(--accent);border-color:var(--accent);transform:none}
|
| 170 |
.lang{font:700 10px/1 var(--mono);padding:2px 5px;border-radius:5px;border:1px solid var(--line)}
|
| 171 |
.lang.ua{color:var(--accent)} .lang.en{color:#5e9dff}
|
| 172 |
+
/* Факти без LOINC: показані, але візуально відділені від таймлайну — вони не
|
| 173 |
+
мають меж і не дають сигналів, тож не мусять виглядати як перевірені. */
|
| 174 |
+
.uncoded{margin-top:16px;padding-top:14px;border-top:1px dashed var(--line)}
|
| 175 |
+
.uncoded h3{font:700 11px/1 var(--mono);letter-spacing:.09em;text-transform:uppercase;
|
| 176 |
+
color:var(--faint);margin:0 0 10px}
|
| 177 |
+
.uncoded .rows{display:flex;flex-direction:column;gap:1px}
|
| 178 |
+
.urow{display:flex;justify-content:space-between;align-items:baseline;gap:10px;
|
| 179 |
+
padding:7px 0;border-bottom:1px solid var(--line)}
|
| 180 |
+
.urow:last-child{border-bottom:0}
|
| 181 |
+
.urow .k{display:flex;flex-direction:column;font-size:13px;color:var(--muted);min-width:0}
|
| 182 |
+
.umeta{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:4px}
|
| 183 |
+
.urow .v{font:700 13.5px/1 var(--mono);color:var(--text);white-space:nowrap}
|
| 184 |
+
.urow .v .u{font-weight:400;font-size:11px;color:var(--faint);margin-left:3px}
|
| 185 |
+
.uncoded .note{margin:11px 0 0;font-size:11.5px;line-height:1.55;color:var(--faint)}
|
| 186 |
.spark{margin-top:9px}
|
| 187 |
.spark path.line{stroke:var(--accent);stroke-width:2.2;fill:none;stroke-linejoin:round;stroke-linecap:round;
|
| 188 |
stroke-dasharray:var(--len);stroke-dashoffset:var(--len);animation:draw 1.1s .1s ease-out forwards}
|
|
|
|
| 379 |
<section class="panel d1">
|
| 380 |
<h2><span id="h-series">Metrics timeline</span> <span class="tag" id="bi-tag" style="display:none">bilingual · UA + EN</span></h2>
|
| 381 |
<div class="series" id="series"><div class="empty">No data yet. Click “Load demo card”.</div></div>
|
| 382 |
+
<div class="uncoded" id="uncoded" style="display:none">
|
| 383 |
+
<h3 id="h-uncoded">Extracted · no standard code</h3>
|
| 384 |
+
<div class="rows" id="uncoded-rows"></div>
|
| 385 |
+
<p class="note" id="uncoded-note"></p>
|
| 386 |
+
</div>
|
| 387 |
</section>
|
| 388 |
|
| 389 |
<div class="right">
|
|
|
|
| 484 |
statSignals:'safety signals', statLangs:'document languages',
|
| 485 |
statusOk:'normal', statusHigh:'above range', statusLow:'below range',
|
| 486 |
range:'range', meas:n=>`${n} meas.`,
|
| 487 |
+
noDate:'no date in document',
|
| 488 |
+
noDateTitle:'The document does not state a date for this value — the upload date is shown.',
|
| 489 |
+
hUncoded:'Extracted · no standard code',
|
| 490 |
+
uncodedNote:'These values were extracted but have no LOINC code, so they have no reference range and raise no automatic signal. They are shown, not hidden — an extracted fact that is invisible looks exactly like one that was never extracted.',
|
| 491 |
escalate:'escalated to human (L4)', auto:'automatic processing',
|
| 492 |
sevLabel:{critical:'critical',major:'high',moderate:'moderate',info:'info'},
|
| 493 |
toastSamples:'Demo card loaded', toastReset:'Card reset',
|
|
|
|
| 538 |
statSignals:'сигналів безпеки', statLangs:'мови документів',
|
| 539 |
statusOk:'у нормі', statusHigh:'вище норми', statusLow:'нижче норми',
|
| 540 |
range:'норма', meas:n=>`${n} вимір.`,
|
| 541 |
+
noDate:'дати немає в документі',
|
| 542 |
+
noDateTitle:'Документ не подає дати для цього значення — показано дату завантаження.',
|
| 543 |
+
hUncoded:'Витягнуто · без стандартного коду',
|
| 544 |
+
uncodedNote:'Ці значення витягнуто, але вони не мають LOINC-коду — отже, ані референсних меж, ані автоматичного сигналу. Ми їх показуємо, а не ховаємо: витягнутий і невидимий факт виглядає точно так само, як невитягнутий.',
|
| 545 |
escalate:'ескалація до людини (L4)', auto:'автоматична обробка',
|
| 546 |
sevLabel:{critical:'критичний',major:'високий',moderate:'помірний',info:'інфо'},
|
| 547 |
toastSamples:'Демо-картку завантажено', toastReset:'Картку скинуто',
|
|
|
|
| 640 |
return r.status===204?null:r.json();
|
| 641 |
}
|
| 642 |
|
| 643 |
+
// Мову документа визначає бекенд за його ТЕКСТОМ (`/state.docs`). Вгадувати її з
|
| 644 |
+
// назви файлу не можна: `doc-ocr-e75f8cd4` — це англомовний бланк, завантажений
|
| 645 |
+
// як фото, і суфікса «-en» у нього немає взагалі.
|
| 646 |
+
// null → мова невідома (немає free-text іпостасі). Тоді бейджа просто немає:
|
| 647 |
+
// краще нічого, ніж навмання.
|
| 648 |
+
function langOf(src){
|
| 649 |
+
const d = lastState && lastState.docs && lastState.docs[src];
|
| 650 |
+
return d ? (d.lang === 'en' ? 'en' : 'ua') : null;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
// Дата вимірювання. Якщо в документі її не було — ми поставили дату завантаження,
|
| 654 |
+
// і мусимо це сказати: датувати торішній аналіз сьогоднішнім днем тихо — гірше,
|
| 655 |
+
// ніж не датувати взагалі (ламається і тренд, і довіра до таймлайну).
|
| 656 |
+
function whenOf(p, t){
|
| 657 |
+
return p.dated === false
|
| 658 |
+
? `<span class="when nodate" title="${t.noDateTitle}">${p.ts} · ${t.noDate}</span>`
|
| 659 |
+
: `<span class="when">${p.ts}</span>`;
|
| 660 |
+
}
|
| 661 |
|
| 662 |
function sparkline(points, low, high){
|
| 663 |
const w=300,h=64,pad=9;
|
|
|
|
| 727 |
// stat strip
|
| 728 |
const nMeas=st.series.reduce((a,s)=>a+s.points.length,0);
|
| 729 |
const langs=new Set();
|
| 730 |
+
st.series.forEach(s=>s.points.forEach(p=>{const l=p.source&&langOf(p.source); if(l)langs.add(l);}));
|
| 731 |
const bilingual=langs.has('en')&&langs.has('ua');
|
| 732 |
$('#bi-tag').style.display = bilingual?'inline-block':'none';
|
| 733 |
const nAlerts=st.signals.length;
|
|
|
|
| 735 |
<div class="stat"><div class="n">${st.series.length}</div><div class="l">${t.statMetrics}</div></div>
|
| 736 |
<div class="stat"><div class="n">${nMeas}</div><div class="l">${t.statMeasurements}</div></div>
|
| 737 |
<div class="stat alert"><div class="n">${nAlerts}</div><div class="l">${t.statSignals}</div></div>
|
| 738 |
+
<div class="stat bi"><div class="n">${!langs.size?'—':bilingual?'UA+EN':(langs.has('en')?'EN':'UA')}</div><div class="l">${t.statLangs}</div></div>
|
| 739 |
` : '';
|
| 740 |
|
| 741 |
// story ribbon — investor orientation, only when a drug interaction was caught
|
|
|
|
| 755 |
const name=(LOINC_LABEL[s.loinc]||{})[LANG]||s.keyword;
|
| 756 |
const ref=(s.low!=null&&s.high!=null)?`${t.range} ${s.low}–${s.high} ${s.unit||''}`:'';
|
| 757 |
const srcs=[...new Set(s.points.map(p=>p.source).filter(Boolean))];
|
| 758 |
+
const langBadges=[...new Set(srcs.map(langOf).filter(Boolean))]
|
| 759 |
+
.map(l=>`<span class="lang ${l}">${l.toUpperCase()}</span>`).join('');
|
| 760 |
const chips=srcs.map(src=>{
|
| 761 |
// span останньої точки з цього документа — якір для drawer-підсвітки
|
| 762 |
const pt=[...s.points].reverse().find(p=>p.source===src);
|
|
|
|
| 769 |
<span class="big"><span class="val">${last.value}</span><span class="unit">${s.unit||''}</span>
|
| 770 |
${ttxt?`<span class="trend ${tcls}">${ttxt}</span>`:''}</span>
|
| 771 |
</div>
|
| 772 |
+
<div class="meta"><span class="ref">${whenOf(last,t)}${ref?` · ${ref}`:''}</span><span class="status ${cls}">${txt}</span></div>
|
| 773 |
${sparkline(s.points,s.low,s.high)}
|
| 774 |
<div class="src">${langBadges}<span>${t.meas(s.points.length)}</span>${chips}</div>
|
| 775 |
</div>`;}).join('');}
|
| 776 |
|
| 777 |
+
// uncoded — витягнуто, але без LOINC: ні меж, ні сигналів. Показуємо окремо.
|
| 778 |
+
const unc=st.uncoded||[];
|
| 779 |
+
const uEl=$('#uncoded');
|
| 780 |
+
uEl.style.display = unc.length ? 'block' : 'none';
|
| 781 |
+
if(unc.length){
|
| 782 |
+
$('#h-uncoded').textContent=t.hUncoded;
|
| 783 |
+
$('#uncoded-note').textContent=t.uncodedNote;
|
| 784 |
+
$('#uncoded-rows').innerHTML=unc.map(f=>{
|
| 785 |
+
const chip=f.source?`<button class="srcchip" data-doc="${f.source}"
|
| 786 |
+
data-span="${f.span?`${f.span[0]}-${f.span[1]}`:''}" title="${t.srcTitle}">${f.source}</button>`:'';
|
| 787 |
+
return `<div class="urow">
|
| 788 |
+
<span class="k">${f.keyword}<span class="umeta">${whenOf(f,t)}${chip}</span></span>
|
| 789 |
+
<span class="v">${f.value}<span class="u">${f.unit||''}</span></span>
|
| 790 |
+
</div>`;
|
| 791 |
+
}).join('');
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
// decision
|
| 795 |
const d=st.decision;const dEl=$('#decision');
|
| 796 |
dEl.className='decision '+(d.escalate?'escalate':'auto');
|
livemedcard/stores.py
CHANGED
|
@@ -39,6 +39,8 @@ class KVTFact(BaseModel):
|
|
| 39 |
# (start, end) offset у free-text іпостасі документа-джерела, звідки витягнуто
|
| 40 |
# факт (span-рівнева прив'язка для точного grounding). None — коли невідомо.
|
| 41 |
source_span: Optional[tuple[int, int]] = None
|
|
|
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
def _concept_label(cc: Optional[CodeableConcept], fallback: str) -> str:
|
|
@@ -76,6 +78,7 @@ def observation_to_kvt(o: Observation) -> Optional[KVTFact]:
|
|
| 76 |
ts=ts,
|
| 77 |
source_doc_id=o.provenance.source_doc_id if o.provenance else None,
|
| 78 |
source_span=o.provenance.source_span if o.provenance else None,
|
|
|
|
| 79 |
)
|
| 80 |
|
| 81 |
|
|
@@ -118,6 +121,15 @@ class FactStore:
|
|
| 118 |
"""Унікальні LOINC-коди, наявні у сторі (у порядку першої появи)."""
|
| 119 |
return list(self._by_loinc)
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
class KnowledgeGraph:
|
| 123 |
"""Граф контексту (networkx DiGraph) — зв'язки + провенанс."""
|
|
|
|
| 39 |
# (start, end) offset у free-text іпостасі документа-джерела, звідки витягнуто
|
| 40 |
# факт (span-рівнева прив'язка для точного grounding). None — коли невідомо.
|
| 41 |
source_span: Optional[tuple[int, int]] = None
|
| 42 |
+
# False → ts це дата завантаження документа, а не дата вимірювання з нього.
|
| 43 |
+
date_stated: bool = True
|
| 44 |
|
| 45 |
|
| 46 |
def _concept_label(cc: Optional[CodeableConcept], fallback: str) -> str:
|
|
|
|
| 78 |
ts=ts,
|
| 79 |
source_doc_id=o.provenance.source_doc_id if o.provenance else None,
|
| 80 |
source_span=o.provenance.source_span if o.provenance else None,
|
| 81 |
+
date_stated=o.provenance.date_stated if o.provenance else True,
|
| 82 |
)
|
| 83 |
|
| 84 |
|
|
|
|
| 121 |
"""Унікальні LOINC-коди, наявні у сторі (у порядку першої появи)."""
|
| 122 |
return list(self._by_loinc)
|
| 123 |
|
| 124 |
+
def uncoded(self) -> list[KVTFact]:
|
| 125 |
+
"""Факти без LOINC — витягнуті, але без стандартного коду.
|
| 126 |
+
|
| 127 |
+
Вони не мають ані таймлайну, ані референсних меж, тож не породжують
|
| 128 |
+
сигналів. Але й ховати їх не можна: витягнутий і невидимий факт виглядає
|
| 129 |
+
точно так само, як не витягнутий — а це різні речі.
|
| 130 |
+
"""
|
| 131 |
+
return [f for f in self._facts if not f.loinc]
|
| 132 |
+
|
| 133 |
|
| 134 |
class KnowledgeGraph:
|
| 135 |
"""Граф контексту (networkx DiGraph) — зв'язки + провенанс."""
|