aimedicalcoding 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. aimedicalcoding/__init__.py +29 -0
  2. aimedicalcoding/__main__.py +313 -0
  3. aimedicalcoding/adapters/__init__.py +3 -0
  4. aimedicalcoding/adapters/base.py +25 -0
  5. aimedicalcoding/adapters/ccda/__init__.py +3 -0
  6. aimedicalcoding/adapters/ccda/adapter.py +294 -0
  7. aimedicalcoding/adapters/ccda/codes.py +198 -0
  8. aimedicalcoding/adapters/ccda/narrative.py +121 -0
  9. aimedicalcoding/adapters/ccda/sections.py +44 -0
  10. aimedicalcoding/adapters/custom_json/__init__.py +3 -0
  11. aimedicalcoding/adapters/custom_json/adapter.py +30 -0
  12. aimedicalcoding/adapters/fhir/__init__.py +3 -0
  13. aimedicalcoding/adapters/fhir/adapter.py +326 -0
  14. aimedicalcoding/adapters/fhir/codes.py +211 -0
  15. aimedicalcoding/adapters/fhir/mapping.py +68 -0
  16. aimedicalcoding/api.py +163 -0
  17. aimedicalcoding/core/__init__.py +34 -0
  18. aimedicalcoding/core/checkdigits.py +103 -0
  19. aimedicalcoding/core/models.py +149 -0
  20. aimedicalcoding/core/system_registry.py +185 -0
  21. aimedicalcoding/pipeline/__init__.py +3 -0
  22. aimedicalcoding/pipeline/orchestrator.py +33 -0
  23. aimedicalcoding/recovery/__init__.py +3 -0
  24. aimedicalcoding/recovery/engine.py +16 -0
  25. aimedicalcoding/serialize.py +257 -0
  26. aimedicalcoding/systems/__init__.py +3 -0
  27. aimedicalcoding/systems/base.py +43 -0
  28. aimedicalcoding/systems/cvx/__init__.py +15 -0
  29. aimedicalcoding/systems/cvx/handler.py +55 -0
  30. aimedicalcoding/systems/cvx/normalize.py +75 -0
  31. aimedicalcoding/systems/cvx/patterns.py +134 -0
  32. aimedicalcoding/systems/icd10cm/__init__.py +16 -0
  33. aimedicalcoding/systems/icd10cm/handler.py +55 -0
  34. aimedicalcoding/systems/icd10cm/icdformat.py +43 -0
  35. aimedicalcoding/systems/icd10cm/patterns.py +133 -0
  36. aimedicalcoding/systems/loinc/__init__.py +14 -0
  37. aimedicalcoding/systems/loinc/axes.py +199 -0
  38. aimedicalcoding/systems/loinc/handler.py +80 -0
  39. aimedicalcoding/systems/loinc/normalize.py +81 -0
  40. aimedicalcoding/systems/loinc/patterns.py +166 -0
  41. aimedicalcoding/systems/rxnorm/__init__.py +13 -0
  42. aimedicalcoding/systems/rxnorm/handler.py +55 -0
  43. aimedicalcoding/systems/rxnorm/normalize.py +88 -0
  44. aimedicalcoding/systems/rxnorm/patterns.py +143 -0
  45. aimedicalcoding/systems/snomed/__init__.py +15 -0
  46. aimedicalcoding/systems/snomed/handler.py +60 -0
  47. aimedicalcoding/systems/snomed/hierarchy.py +96 -0
  48. aimedicalcoding/systems/snomed/patterns.py +166 -0
  49. aimedicalcoding/systems/snomed/qualifiers.py +90 -0
  50. aimedicalcoding/terminology/__init__.py +3 -0
  51. aimedicalcoding/terminology/service.py +31 -0
  52. aimedicalcoding-0.3.1.dist-info/METADATA +276 -0
  53. aimedicalcoding-0.3.1.dist-info/RECORD +57 -0
  54. aimedicalcoding-0.3.1.dist-info/WHEEL +5 -0
  55. aimedicalcoding-0.3.1.dist-info/entry_points.txt +2 -0
  56. aimedicalcoding-0.3.1.dist-info/licenses/LICENSE +21 -0
  57. aimedicalcoding-0.3.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,326 @@
1
+ """FHIR R4 Bundle adapter (Trove flavor).
2
+
3
+ Walks a single R4 Bundle in two passes: first build the reference index
4
+ (fullUrl + ``ResourceType/id``) and the ``hasMember``/``DiagnosticReport.result``
5
+ member->panel map, then emit one ``ClinicalStatement`` per coded clinical
6
+ resource. Slot rules mirror the CCDA adapter exactly:
7
+
8
+ Observation.code -> codings (question slot), value[x] -> value
9
+ Condition / FamilyMemberHistory concept -> value.codings (value-slot contexts)
10
+ Medication* / Immunization / Procedure / allergy substance -> codings
11
+ AllergyIntolerance reaction manifestations -> separate allergy_reaction
12
+ statements with the concept in value.codings
13
+
14
+ Trove's literal ``"unknown"`` null marker is normalized in ``codes.py``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from typing import Iterable, Optional
21
+
22
+ from ...core.models import (
23
+ ClinicalStatement,
24
+ CodingCandidate,
25
+ System,
26
+ ValueInfo,
27
+ NOM,
28
+ )
29
+ from ..base import FormatAdapter
30
+ from ..ccda.codes import best_loinc
31
+ from . import codes as C
32
+ from . import mapping as M
33
+
34
+
35
+ class FhirAdapter(FormatAdapter):
36
+ format_key = "fhir"
37
+
38
+ # ------------------------------------------------------------------ #
39
+ def detect(self, document: str | bytes) -> bool:
40
+ try:
41
+ obj = json.loads(document)
42
+ except (ValueError, TypeError):
43
+ return False
44
+ return isinstance(obj, dict) and obj.get("resourceType") == "Bundle"
45
+
46
+ def parse(self, document: str | bytes) -> Iterable[ClinicalStatement]:
47
+ bundle = json.loads(document)
48
+ entries = bundle.get("entry") or []
49
+
50
+ # pass 1: reference index + member->panel map
51
+ self._index: dict[str, dict] = {}
52
+ self._refs: list[tuple[str, dict]] = [] # (fullUrl, resource) for suffix match
53
+ for i, entry in enumerate(entries):
54
+ res = entry.get("resource") or {}
55
+ full_url = entry.get("fullUrl")
56
+ if full_url:
57
+ self._index[full_url] = res
58
+ self._refs.append((full_url, res))
59
+ rt, rid = res.get("resourceType"), res.get("id")
60
+ if rt and rid:
61
+ self._index[f"{rt}/{rid}"] = res
62
+
63
+ panels = self._build_panel_map(entries)
64
+
65
+ # pass 2: emit statements
66
+ statements: list[ClinicalStatement] = []
67
+ for i, entry in enumerate(entries):
68
+ res = entry.get("resource") or {}
69
+ ref = entry.get("fullUrl") or f"Bundle.entry[{i}]"
70
+ rt = res.get("resourceType")
71
+ if not rt or rt in M.SKIP_RESOURCES:
72
+ continue
73
+ if rt == "Observation":
74
+ statements.append(self._observation(res, ref, panels))
75
+ elif rt == "Condition":
76
+ statements.append(self._condition(res, ref))
77
+ elif rt in ("MedicationRequest", "MedicationAdministration",
78
+ "MedicationStatement", "MedicationDispense"):
79
+ statements.append(self._medication(res, ref, rt))
80
+ elif rt == "Immunization":
81
+ statements.append(self._immunization(res, ref))
82
+ elif rt == "Procedure":
83
+ statements.append(self._procedure(res, ref))
84
+ elif rt == "AllergyIntolerance":
85
+ statements.extend(self._allergy(res, ref))
86
+ elif rt == "FamilyMemberHistory":
87
+ statements.extend(self._family_history(res, ref))
88
+ return statements
89
+
90
+ # ------------------------------------------------------------------ #
91
+ # reference / panel plumbing
92
+ # ------------------------------------------------------------------ #
93
+ def _resolve(self, reference: Optional[dict]) -> Optional[dict]:
94
+ """Resolve a Reference across the Bundle: exact fullUrl or Type/id,
95
+ else a fullUrl whose path ends with the relative reference."""
96
+ if not reference:
97
+ return None
98
+ ref = reference.get("reference")
99
+ if not ref:
100
+ return None
101
+ hit = self._index.get(ref)
102
+ if hit is not None:
103
+ return hit
104
+ return next((r for url, r in self._refs if url.endswith("/" + ref)), None)
105
+
106
+ def _build_panel_map(self, entries: list) -> dict[str, dict]:
107
+ """member reference -> {panel, context, hint, specimen} from parent
108
+ Observations (hasMember) and DiagnosticReports (result[])."""
109
+ panels: dict[str, dict] = {}
110
+ for entry in entries:
111
+ res = entry.get("resource") or {}
112
+ rt = res.get("resourceType")
113
+ if rt == "Observation" and res.get("hasMember"):
114
+ members = res["hasMember"]
115
+ concept = res.get("code")
116
+ context, hint = M.route_observation(self._category_codes(res),
117
+ self._meta_tag(res)[0])
118
+ elif rt == "DiagnosticReport" and res.get("result"):
119
+ members = res["result"]
120
+ concept = res.get("code")
121
+ context, hint = ("result", System.LOINC)
122
+ else:
123
+ continue
124
+ codings, _ = C.concept_codings(concept)
125
+ panel = best_loinc(codings) or next(
126
+ (c for c in codings if c.code), None)
127
+ info = {
128
+ "panel": panel,
129
+ "context": context,
130
+ "hint": hint,
131
+ "specimen": self._specimen(res),
132
+ }
133
+ for member in members:
134
+ ref = member.get("reference")
135
+ if ref:
136
+ panels[ref] = info
137
+ # index rows are keyed by relative Type/id too
138
+ resolved = self._resolve(member)
139
+ if resolved and resolved.get("id"):
140
+ panels[f"{resolved['resourceType']}/{resolved['id']}"] = info
141
+ return panels
142
+
143
+ def _specimen(self, res: dict) -> Optional[dict]:
144
+ spec = self._resolve(res.get("specimen"))
145
+ if not spec:
146
+ return None
147
+ codings, text = C.concept_codings(spec.get("type"))
148
+ primary = next((c for c in codings if c.code), None)
149
+ display = C.best_label(codings, text)
150
+ if not primary and not display:
151
+ return None
152
+ return {"code": primary.code if primary else None, "display": display}
153
+
154
+ # ------------------------------------------------------------------ #
155
+ # shared statement assembly
156
+ # ------------------------------------------------------------------ #
157
+ @staticmethod
158
+ def _category_codes(res: dict) -> list[str]:
159
+ return [C.clean(c.get("code")) for cat in res.get("category") or []
160
+ for c in cat.get("coding") or [] if C.clean(c.get("code"))]
161
+
162
+ @staticmethod
163
+ def _meta_tag(res: dict) -> tuple[Optional[str], Optional[str]]:
164
+ """(code, display) of the CCDA-section LOINC Trove stamps in meta.tag."""
165
+ for tag in (res.get("meta") or {}).get("tag") or []:
166
+ code = C.clean(tag.get("code"))
167
+ if code:
168
+ return code, C.clean(tag.get("display"))
169
+ return None, None
170
+
171
+ def _section(self, res: dict) -> tuple[Optional[str], Optional[str], Optional[str]]:
172
+ """(section_code, section_name, section_system) from meta.tag, else the
173
+ Observation category as a lower-fidelity stand-in."""
174
+ code, display = self._meta_tag(res)
175
+ if code:
176
+ return code, display, "LOINC"
177
+ cats = self._category_codes(res)
178
+ if cats:
179
+ return cats[0], cats[0].replace("-", " ").title(), None
180
+ return None, None, None
181
+
182
+ def _statement(self, res: dict, ref: str, context: str,
183
+ hint: Optional[System], codings: list[CodingCandidate],
184
+ text: Optional[str], value: Optional[ValueInfo] = None,
185
+ companion: Optional[dict] = None,
186
+ panel: Optional[CodingCandidate] = None) -> ClinicalStatement:
187
+ sec_code, sec_name, sec_system = self._section(res)
188
+ display_name = next(
189
+ (c.display for c in codings if not c.from_translation and c.display), None)
190
+ return ClinicalStatement(
191
+ source_format=self.format_key,
192
+ source_ref=ref,
193
+ context=context,
194
+ section_code=sec_code,
195
+ section_name=sec_name,
196
+ section_system=sec_system,
197
+ role="value",
198
+ target_system_hint=hint,
199
+ raw_text=C.best_label(codings, text),
200
+ display_name=display_name,
201
+ original_text=text,
202
+ codings=codings,
203
+ value=value,
204
+ effective_time=C.extract_effective_time(res),
205
+ panel=panel,
206
+ companion=companion or {},
207
+ )
208
+
209
+ # ------------------------------------------------------------------ #
210
+ # per-resource emitters
211
+ # ------------------------------------------------------------------ #
212
+ def _observation(self, res: dict, ref: str,
213
+ panels: dict[str, dict]) -> ClinicalStatement:
214
+ parent = panels.get(f"Observation/{res.get('id')}") or panels.get(ref)
215
+ cats = self._category_codes(res)
216
+ if cats:
217
+ context, hint = M.route_observation(cats, self._meta_tag(res)[0])
218
+ elif parent:
219
+ # derived panel child (no category, no meta.tag): inherit the panel's
220
+ context, hint = parent["context"], parent["hint"]
221
+ else:
222
+ context, hint = M.route_observation([], self._meta_tag(res)[0])
223
+
224
+ codings, text = C.concept_codings(res.get("code"))
225
+ value = C.extract_value(res)
226
+ companion = C.extract_companion(res)
227
+ specimen = self._specimen(res) or (parent["specimen"] if parent else None)
228
+ if specimen:
229
+ companion["specimen"] = specimen.get("display")
230
+ companion["specimen_code"] = specimen.get("code")
231
+
232
+ return self._statement(res, ref, context, hint, codings, text,
233
+ value=value, companion=companion,
234
+ panel=parent["panel"] if parent else None)
235
+
236
+ def _condition(self, res: dict, ref: str) -> ClinicalStatement:
237
+ # value-slot context: the SNOMED/ICD concept goes into value.codings
238
+ codings, text = C.concept_codings(res.get("code"))
239
+ coded = next((c for c in codings if not c.from_translation),
240
+ codings[0] if codings else None)
241
+ value = ValueInfo(type=NOM, raw=C.best_label(codings, text),
242
+ coded_value=coded, codings=codings, original_text=text)
243
+ context, hint = M.RESOURCE_ROUTING["Condition"]
244
+ stmt = self._statement(res, ref, context, hint, codings=[], text=text,
245
+ value=value)
246
+ stmt.raw_text = value.raw # concept label lives on the value slot
247
+ return stmt
248
+
249
+ def _medication(self, res: dict, ref: str, rt: str) -> ClinicalStatement:
250
+ # drug concept: inline medicationCodeableConcept, or on the referenced
251
+ # Medication resource's code
252
+ concept = res.get("medicationCodeableConcept")
253
+ if concept is None:
254
+ med = self._resolve(res.get("medicationReference"))
255
+ concept = (med or {}).get("code")
256
+ if concept is None:
257
+ # display on the reference is the only label we have
258
+ mref = res.get("medicationReference") or {}
259
+ concept = {"text": mref.get("display")} if mref.get("display") else None
260
+ codings, text = C.concept_codings(concept)
261
+ companion = {}
262
+ if res.get("doNotPerform") or res.get("status") == "not-done":
263
+ companion["negation"] = True
264
+ context, hint = M.RESOURCE_ROUTING[rt]
265
+ return self._statement(res, ref, context, hint, codings, text,
266
+ companion=companion)
267
+
268
+ def _immunization(self, res: dict, ref: str) -> ClinicalStatement:
269
+ codings, text = C.concept_codings(res.get("vaccineCode"))
270
+ companion = {}
271
+ if res.get("status") == "not-done":
272
+ companion["negation"] = True
273
+ context, hint = M.RESOURCE_ROUTING["Immunization"]
274
+ return self._statement(res, ref, context, hint, codings, text,
275
+ companion=companion)
276
+
277
+ def _procedure(self, res: dict, ref: str) -> ClinicalStatement:
278
+ codings, text = C.concept_codings(res.get("code"))
279
+ context, hint = M.RESOURCE_ROUTING["Procedure"]
280
+ return self._statement(res, ref, context, hint, codings, text)
281
+
282
+ def _allergy(self, res: dict, ref: str) -> list[ClinicalStatement]:
283
+ out: list[ClinicalStatement] = []
284
+ codings, text = C.concept_codings(res.get("code"))
285
+ if codings or text:
286
+ out.append(self._statement(res, ref, "allergy_substance",
287
+ System.RXNORM, codings, text))
288
+ for i, reaction in enumerate(res.get("reaction") or []):
289
+ for j, manifestation in enumerate(reaction.get("manifestation") or []):
290
+ m_codings, m_text = C.concept_codings(manifestation)
291
+ if not m_codings and not m_text:
292
+ continue
293
+ coded = next((c for c in m_codings if not c.from_translation),
294
+ m_codings[0] if m_codings else None)
295
+ value = ValueInfo(type=NOM, raw=C.best_label(m_codings, m_text),
296
+ coded_value=coded, codings=m_codings,
297
+ original_text=m_text)
298
+ stmt = self._statement(
299
+ res, f"{ref}/reaction[{i}]/manifestation[{j}]",
300
+ "allergy_reaction", System.SNOMEDCT, codings=[],
301
+ text=m_text, value=value)
302
+ stmt.raw_text = value.raw
303
+ out.append(stmt)
304
+ return out
305
+
306
+ def _family_history(self, res: dict, ref: str) -> list[ClinicalStatement]:
307
+ out: list[ClinicalStatement] = []
308
+ rel_codings, rel_text = C.concept_codings(res.get("relationship"))
309
+ relationship = C.best_label(rel_codings, rel_text)
310
+ context, hint = M.RESOURCE_ROUTING["FamilyMemberHistory"]
311
+ for i, cond in enumerate(res.get("condition") or []):
312
+ codings, text = C.concept_codings(cond.get("code"))
313
+ if not codings and not text:
314
+ continue
315
+ coded = next((c for c in codings if not c.from_translation),
316
+ codings[0] if codings else None)
317
+ value = ValueInfo(type=NOM, raw=C.best_label(codings, text),
318
+ coded_value=coded, codings=codings,
319
+ original_text=text)
320
+ stmt = self._statement(res, f"{ref}/condition[{i}]", context, hint,
321
+ codings=[], text=text, value=value)
322
+ stmt.raw_text = value.raw
323
+ if relationship:
324
+ stmt.companion["relationship"] = relationship
325
+ out.append(stmt)
326
+ return out
@@ -0,0 +1,211 @@
1
+ """CodeableConcept / value[x] extraction for FHIR resources.
2
+
3
+ FHIR mirror of ``adapters/ccda/codes.py`` (scan-and-identify): enumerate every
4
+ ``coding[]`` entry, canonicalize each system, and let downstream pick.
5
+
6
+ THE Trove quirk handled here: the literal string ``"unknown"`` is Trove's null
7
+ marker — it appears in ``code``, ``display``, ``system`` and ``text`` wherever a
8
+ value is missing. Every ``"unknown"`` is normalized to ``None``; a CodeableConcept
9
+ left with no real code yields a ``System.NULLFLAVOR`` candidate (the CCDA
10
+ ``nullFlavor`` equivalent) so the gap extractors classify it correctly.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Optional
16
+
17
+ from ...core.checkdigits import is_valid_loinc, is_valid_sctid
18
+ from ...core.models import (
19
+ CodingCandidate,
20
+ System,
21
+ ValueInfo,
22
+ QN,
23
+ ORD,
24
+ NOM,
25
+ NAR,
26
+ )
27
+ from ...core.system_registry import canonicalize
28
+
29
+ _UNKNOWN = "unknown"
30
+
31
+ # same ordinal cue list the CCDA adapter uses for CD/CE values
32
+ _ORDINAL_WORDS = {"positive", "negative", "reactive", "non-reactive",
33
+ "detected", "not detected", "present", "absent"}
34
+
35
+
36
+ def clean(s: Optional[str]) -> Optional[str]:
37
+ """Normalize Trove's ``"unknown"`` null marker (and empty strings) to None."""
38
+ if not isinstance(s, str):
39
+ return s
40
+ s = s.strip()
41
+ if not s or s.lower() == _UNKNOWN:
42
+ return None
43
+ return s
44
+
45
+
46
+ def coding_to_candidate(coding: dict, from_translation: bool) -> CodingCandidate:
47
+ raw_system = clean(coding.get("system"))
48
+ system = canonicalize(raw_system)
49
+ code = clean(coding.get("code"))
50
+ cand = CodingCandidate(
51
+ raw_system=raw_system,
52
+ system=system,
53
+ code=code,
54
+ display=clean(coding.get("display")),
55
+ from_translation=from_translation,
56
+ user_selected=bool(coding.get("userSelected")),
57
+ )
58
+ if code:
59
+ if system == System.LOINC:
60
+ cand.check_digit_valid = is_valid_loinc(code)
61
+ elif system == System.SNOMEDCT:
62
+ cand.check_digit_valid = is_valid_sctid(code)
63
+ return cand
64
+
65
+
66
+ def concept_codings(cc: Optional[dict]) -> tuple[list[CodingCandidate], Optional[str]]:
67
+ """Return (codings, text) for a CodeableConcept.
68
+
69
+ The primary coding (``userSelected`` if any, else the first) gets
70
+ ``from_translation=False``; the rest are translations — the FHIR analog of
71
+ CCDA root ``<code>`` + ``<translation>`` children. If no coding carries a
72
+ real code but the concept explicitly said ``"unknown"``, a NULLFLAVOR
73
+ candidate is appended so the absence stays an explicit signal.
74
+ """
75
+ if not cc:
76
+ return [], None
77
+ text = clean(cc.get("text"))
78
+ raw_codings = cc.get("coding") or []
79
+
80
+ saw_unknown = (cc.get("text") == _UNKNOWN)
81
+ kept: list[tuple[CodingCandidate, bool]] = [] # (candidate, user_selected)
82
+ for coding in raw_codings:
83
+ if coding.get("code") == _UNKNOWN or coding.get("system") == _UNKNOWN \
84
+ or coding.get("display") == _UNKNOWN:
85
+ saw_unknown = True
86
+ cand = coding_to_candidate(coding, from_translation=True)
87
+ if cand.code is None and cand.display is None and cand.raw_system is None:
88
+ continue # fully-null coding ({"system":"unknown","code":"unknown"})
89
+ kept.append((cand, bool(coding.get("userSelected"))))
90
+
91
+ # primary = userSelected among the SURVIVING codings, else the first one
92
+ # (so a dropped all-"unknown" first coding doesn't leave only translations)
93
+ out = [cand for cand, _ in kept]
94
+ if out:
95
+ primary_idx = next((i for i, (_c, sel) in enumerate(kept) if sel), 0)
96
+ out[primary_idx].from_translation = False
97
+
98
+ if saw_unknown and not any(c.code for c in out):
99
+ out.append(CodingCandidate(system=System.NULLFLAVOR, raw_system=_UNKNOWN,
100
+ from_translation=False))
101
+ return out, text
102
+
103
+
104
+ def best_label(codings: list[CodingCandidate], text: Optional[str]) -> Optional[str]:
105
+ """raw_text priority, mirroring CCDA: primary display -> text -> any display."""
106
+ primary = next((c for c in codings if not c.from_translation and c.display), None)
107
+ if primary:
108
+ return primary.display
109
+ if text:
110
+ return text
111
+ return next((c.display for c in codings if c.display), None)
112
+
113
+
114
+ # --------------------------------------------------------------------------
115
+ # value[x] extraction
116
+ # --------------------------------------------------------------------------
117
+
118
+ def _quantity_value(qty: dict) -> ValueInfo:
119
+ raw = qty.get("value")
120
+ unit = clean(qty.get("code")) or clean(qty.get("unit"))
121
+ return ValueInfo(
122
+ type=QN,
123
+ raw=(str(raw) if raw is not None else None),
124
+ num=_to_float(raw),
125
+ unit=unit,
126
+ unit_system="UCUM" if unit else None,
127
+ )
128
+
129
+
130
+ def extract_value(resource: dict) -> Optional[ValueInfo]:
131
+ """Map FHIR ``value[x]`` (or ``dataAbsentReason``) to a ValueInfo."""
132
+ if "valueQuantity" in resource:
133
+ return _quantity_value(resource["valueQuantity"])
134
+ if "valueInteger" in resource:
135
+ raw = resource["valueInteger"]
136
+ return ValueInfo(type=QN, raw=str(raw), num=_to_float(raw))
137
+ if "valueCodeableConcept" in resource:
138
+ codings, text = concept_codings(resource["valueCodeableConcept"])
139
+ coded = next((c for c in codings if not c.from_translation),
140
+ codings[0] if codings else None)
141
+ label = best_label(codings, text)
142
+ vtype = ORD if (label or "").lower() in _ORDINAL_WORDS else NOM
143
+ return ValueInfo(type=vtype, raw=label, coded_value=coded,
144
+ codings=codings, original_text=text)
145
+ if "valueString" in resource:
146
+ return ValueInfo(type=NAR, raw=clean(resource["valueString"]))
147
+ if "valueBoolean" in resource:
148
+ return ValueInfo(type=ORD, raw=str(resource["valueBoolean"]))
149
+ if "valueDateTime" in resource:
150
+ return ValueInfo(type=NAR, raw=resource["valueDateTime"])
151
+ if "valueRange" in resource:
152
+ low = (resource["valueRange"].get("low") or {})
153
+ return _quantity_value(low) if low else None
154
+ # value explicitly absent — FHIR's nullFlavor for the value slot
155
+ dar = resource.get("dataAbsentReason")
156
+ if dar:
157
+ reason = None
158
+ for coding in dar.get("coding") or []:
159
+ reason = clean(coding.get("code")) or reason
160
+ reason = reason or clean(dar.get("text")) or "unknown"
161
+ return ValueInfo(type=None,
162
+ coded_value=CodingCandidate(system=System.NULLFLAVOR,
163
+ raw_system=reason))
164
+ return None
165
+
166
+
167
+ # --------------------------------------------------------------------------
168
+ # companion extraction
169
+ # --------------------------------------------------------------------------
170
+
171
+ def extract_companion(resource: dict) -> dict:
172
+ """interpretation + reference range (Observation-shaped resources)."""
173
+ out: dict[str, Any] = {}
174
+ for interp in resource.get("interpretation") or []:
175
+ code = next((clean(c.get("code")) for c in interp.get("coding") or []
176
+ if clean(c.get("code"))), None)
177
+ if code:
178
+ out["interpretation"] = code
179
+ break
180
+ for rr in resource.get("referenceRange") or []:
181
+ low, high = rr.get("low") or {}, rr.get("high") or {}
182
+ if low or high:
183
+ lo = low.get("value", "")
184
+ hi = high.get("value", "")
185
+ unit = clean(low.get("unit")) or clean(high.get("unit")) or \
186
+ clean(low.get("code")) or clean(high.get("code"))
187
+ out["reference_range"] = f"{lo}-{hi}" + (f" {unit}" if unit else "")
188
+ break
189
+ if clean(rr.get("text")):
190
+ out["reference_range"] = clean(rr.get("text"))
191
+ break
192
+ return out
193
+
194
+
195
+ def extract_effective_time(resource: dict) -> Optional[str]:
196
+ for key in ("effectiveDateTime", "occurrenceDateTime", "performedDateTime",
197
+ "onsetDateTime", "authoredOn", "recordedDate"):
198
+ if resource.get(key):
199
+ return resource[key]
200
+ for key in ("effectivePeriod", "performedPeriod"):
201
+ period = resource.get(key)
202
+ if period and period.get("start"):
203
+ return period["start"]
204
+ return None
205
+
206
+
207
+ def _to_float(v) -> Optional[float]:
208
+ try:
209
+ return float(v)
210
+ except (TypeError, ValueError):
211
+ return None
@@ -0,0 +1,68 @@
1
+ """FHIR routing: resource type / Observation.category / meta.tag -> context.
2
+
3
+ The FHIR analog of ``adapters/ccda/sections.py``. Trove bundles are generated
4
+ from CCDA, and every directly-extracted resource carries the *original CCDA
5
+ section LOINC code* in ``meta.tag`` — so we reuse the CCDA ``SECTION_ROUTING``
6
+ table verbatim as a fallback router. Primary routing is by resource type and
7
+ ``Observation.category`` (meta.tag is absent on derived resources such as
8
+ ``hasMember`` panel children).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ from ...core.models import System
16
+ from ..ccda.sections import route_section # keyed by section LOINC — shared knowledge
17
+
18
+ # Observation.category code -> (context, target_system_hint)
19
+ CATEGORY_ROUTING: dict[str, tuple[str, Optional[System]]] = {
20
+ "laboratory": ("result", System.LOINC),
21
+ "vital-signs": ("vital", System.LOINC),
22
+ "social-history": ("social_history", System.LOINC),
23
+ "survey": ("survey", System.LOINC),
24
+ "functional-status": ("functional_status", System.LOINC),
25
+ "exam": ("exam", None), # not a gap-extractor context; kept for provenance
26
+ "imaging": ("imaging", None),
27
+ "procedure": ("procedure_note", None), # Observation-flavored, not a Procedure act
28
+ "activity": ("activity", None),
29
+ "therapy": ("therapy", None),
30
+ }
31
+
32
+ # non-Observation resource type -> (context, target_system_hint)
33
+ RESOURCE_ROUTING: dict[str, tuple[str, Optional[System]]] = {
34
+ "Condition": ("problem", System.SNOMEDCT),
35
+ "MedicationRequest": ("medication", System.RXNORM),
36
+ "MedicationAdministration": ("medication", System.RXNORM),
37
+ "MedicationStatement": ("medication", System.RXNORM),
38
+ "MedicationDispense": ("medication", System.RXNORM),
39
+ "Immunization": ("immunization", System.CVX),
40
+ "Procedure": ("procedure", System.CPT),
41
+ "FamilyMemberHistory": ("family_history", System.SNOMEDCT),
42
+ # AllergyIntolerance handled specially: allergy_substance + allergy_reaction
43
+ }
44
+
45
+ # administrative / non-coded resources the adapter never emits statements for
46
+ SKIP_RESOURCES = frozenset({
47
+ "Patient", "Encounter", "Organization", "Practitioner", "PractitionerRole",
48
+ "Location", "Coverage", "RelatedPerson", "CareTeam", "CarePlan",
49
+ "ServiceRequest", "DocumentReference", "Provenance", "Communication",
50
+ "Specimen", "Medication", # consumed via references, not emitted directly
51
+ "DiagnosticReport", # used only to build the member->panel map
52
+ })
53
+
54
+
55
+ def route_observation(
56
+ category_codes: list[str],
57
+ meta_tag_code: Optional[str] = None,
58
+ ) -> tuple[str, Optional[System]]:
59
+ """Route an Observation: category first, else the CCDA section LOINC that
60
+ Trove stamps into meta.tag, else a neutral context no extractor touches."""
61
+ for code in category_codes:
62
+ if code in CATEGORY_ROUTING:
63
+ return CATEGORY_ROUTING[code]
64
+ if meta_tag_code:
65
+ context, hint = route_section(meta_tag_code)
66
+ if context != "section":
67
+ return (context, hint)
68
+ return ("observation", None)