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 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(ts: object, fallback: Optional[datetime]) -> Optional[datetime]:
152
- """Дата факту з різних форматів; для періоду «A - B» береться КІНЕЦЬ (B)."""
153
- s = str(ts).strip()
154
- if not s or s.lower() == "not stated":
155
- return fallback
 
 
 
 
 
 
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, extracted_by=extracted_by, confidence=None
 
 
 
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
- function langOf(src){ return src && /(-|_)en\b|_en$|-en$/.test(src) ? 'en' : 'ua'; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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&&langs.add(langOf(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))].map(l=>`<span class="lang ${l}">${l.toUpperCase()}</span>`).join('');
 
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) — зв'язки + провенанс."""