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

fix(l2b): units + document date from photo extraction; drop date/id-shaped values

Browse files
livemedcard/layers/l2b_nuextract.py CHANGED
@@ -14,10 +14,17 @@ import json
14
  from ..config import NUEXTRACT_MODEL, NUEXTRACT_URL
15
  from ..llm_client import chat_completions
16
 
17
- # Шаблон KVT-фактів (leaf-значення = типи NuExtract).
 
 
18
  _FACTS_TEMPLATE = {
19
  "facts": [
20
- {"keyword": "verbatim-string", "value": "verbatim-string", "timestamp": "verbatim-string"}
 
 
 
 
 
21
  ]
22
  }
23
  _TEMPLATE_JSON = json.dumps(_FACTS_TEMPLATE, indent=2)
 
14
  from ..config import NUEXTRACT_MODEL, NUEXTRACT_URL
15
  from ..llm_client import chat_completions
16
 
17
+ # Шаблон KVT-фактів (leaf-значення = типи NuExtract). Одиниця — ОКРЕМЕ поле, як у
18
+ # хмарного Lab: без неї значення приходить голим числом, і шар L2a не може
19
+ # порівняти його з референсними межами (натомість чесно піднімає unit_mismatch).
20
  _FACTS_TEMPLATE = {
21
  "facts": [
22
+ {
23
+ "keyword": "verbatim-string",
24
+ "value": "verbatim-string",
25
+ "unit": "verbatim-string",
26
+ "timestamp": "verbatim-string",
27
+ }
28
  ]
29
  }
30
  _TEMPLATE_JSON = json.dumps(_FACTS_TEMPLATE, indent=2)
livemedcard/layers/l2b_stage2_lora.py CHANGED
@@ -102,6 +102,8 @@ def _resolve_loinc(keyword: str) -> tuple[str, str] | None:
102
 
103
  _THOUGHT_TOKEN_RE = re.compile(r"<unused\d+>", re.IGNORECASE)
104
  _VALUE_NUM_RE = re.compile(r"^\s*(-?\d+(?:[.,]\d+)?)\s*(.*)$")
 
 
105
  _TS_DATE_FORMATS = ("%Y-%m-%d", "%d.%m.%Y", "%m/%d/%Y", "%B %d, %Y", "%b %d, %Y")
106
 
107
 
@@ -139,8 +141,15 @@ def _extract_json_array(text: str) -> list[dict]:
139
 
140
 
141
  def _split_value(value: object) -> tuple[Optional[float], Optional[str]]:
142
- """'204 mg/dL' → (204.0, 'mg/dL'); '34%' → (34.0, '%'); 'Yes' → (None, None)."""
143
- m = _VALUE_NUM_RE.match(str(value))
 
 
 
 
 
 
 
144
  if m is None:
145
  return None, None
146
  num = float(m.group(1).replace(",", "."))
@@ -189,11 +198,36 @@ def extract_facts(summary_text: str, *, url: str | None = None, model: str | Non
189
  return _extract_json_array(raw)
190
 
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  def to_observations(
193
  kvt: list[dict], doc: Document, patient_id: str, *, extracted_by: str
194
  ) -> list[Observation]:
195
  """KVT4 → FHIR Observations (лише числові; часті лаби мостяться у LOINC)."""
196
  subject = Reference(reference=f"Patient/{patient_id}")
 
197
  observations: list[Observation] = []
198
  seen: set[str] = set()
199
  for item in kvt:
@@ -209,7 +243,11 @@ def to_observations(
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])]
 
102
 
103
  _THOUGHT_TOKEN_RE = re.compile(r"<unused\d+>", re.IGNORECASE)
104
  _VALUE_NUM_RE = re.compile(r"^\s*(-?\d+(?:[.,]\d+)?)\s*(.*)$")
105
+ # Дата або ідентифікатор: два розділювачі поспіль («2024-10-15», «06/13/2025»).
106
+ _DATE_LIKE_RE = re.compile(r"^\s*\d{1,4}[-/.]\d{1,2}[-/.]\d{2,4}")
107
  _TS_DATE_FORMATS = ("%Y-%m-%d", "%d.%m.%Y", "%m/%d/%Y", "%B %d, %Y", "%b %d, %Y")
108
 
109
 
 
141
 
142
 
143
  def _split_value(value: object) -> tuple[Optional[float], Optional[str]]:
144
+ """'204 mg/dL' → (204.0, 'mg/dL'); '34%' → (34.0, '%'); 'Yes' → (None, None).
145
+
146
+ Дати й ідентифікатори — НЕ вимірювання: «2024-10-15» інакше стало б фактом
147
+ «2024.0 -10-15», а «2024-10-15/0412» — номером бланка в таймлайні.
148
+ """
149
+ s = str(value)
150
+ if _DATE_LIKE_RE.match(s):
151
+ return None, None
152
+ m = _VALUE_NUM_RE.match(s)
153
  if m is None:
154
  return None, None
155
  num = float(m.group(1).replace(",", "."))
 
198
  return _extract_json_array(raw)
199
 
200
 
201
+ # Keywords, під якими модель віддає дату ЗАБОРУ матеріалу — тобто дату, на яку
202
+ # виміряні значення документа. Свідомо вузько: «reported»/«issued» — це коли
203
+ # лабораторія видала бланк, а не коли взяли кров, і датувати ним показник хибно.
204
+ _DOC_DATE_KEYWORDS = frozenset({
205
+ "collected", "collection date", "date collected", "specimen collected",
206
+ "дата забору", "забір матеріалу", "дата взяття",
207
+ })
208
+
209
+
210
+ def _document_date(kvt: list[dict]) -> Optional[datetime]:
211
+ """Дата документа з ЙОГО Ж витягу (напр. «Collected: 2024-10-15»).
212
+
213
+ Табличні бланки не повторюють дату в кожному рядку — вона стоїть у шапці, і
214
+ модель віддає її окремим фактом. Тоді значення документа датуються нею: це
215
+ дата, ЗАЯВЛЕНА в документі, а не здогад (на відміну від дати завантаження).
216
+ """
217
+ for item in kvt:
218
+ if str(item.get("keyword", "")).strip().lower() in _DOC_DATE_KEYWORDS:
219
+ ts, ok = _parse_timestamp(item.get("value"), None)
220
+ if ok:
221
+ return ts
222
+ return None
223
+
224
+
225
  def to_observations(
226
  kvt: list[dict], doc: Document, patient_id: str, *, extracted_by: str
227
  ) -> list[Observation]:
228
  """KVT4 → FHIR Observations (лише числові; часті лаби мостяться у LOINC)."""
229
  subject = Reference(reference=f"Patient/{patient_id}")
230
+ doc_date = _document_date(kvt)
231
  observations: list[Observation] = []
232
  seen: set[str] = set()
233
  for item in kvt:
 
243
  if unit is None:
244
  raw_unit = str(item.get("unit") or "").strip()
245
  unit = raw_unit or None
246
+ eff, date_stated = _parse_timestamp(item.get("timestamp"), None)
247
+ if not date_stated:
248
+ # дата на факт відсутня → дата документа; лише потім — дата завантаження
249
+ eff = doc_date or doc.received_at
250
+ date_stated = doc_date is not None
251
  bridge = _resolve_loinc(keyword)
252
  coding = (
253
  [Coding(system=Sys.LOINC, code=bridge[0], display=bridge[1])]