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,29 @@
1
+ """AI Medical Coding — recover missing clinical codes from CCDA/FHIR documents.
2
+
3
+ Detects observations whose LOINC / SNOMED CT / RxNorm code is missing, local,
4
+ ICD/NDC-only, malformed, or blank, and prepares each as a target-tagged "gap"
5
+ record for the (DB + LLM) recovery layer.
6
+
7
+ Quick start::
8
+
9
+ from aimedicalcoding import extract_gaps
10
+
11
+ result = extract_gaps("patient.xml") # checks LOINC + SNOMED + RxNorm
12
+ print(result.by_target) # {"LOINC": 5, "SNOMED-CT": 6, ...}
13
+ print(result.to_json())
14
+
15
+ See docs/GAP_SCHEMA.md for the output contract.
16
+ """
17
+
18
+ from .api import extract_gaps, collect_gaps, GapResult, EXTRACTORS
19
+
20
+ try: # version from installed package metadata
21
+ from importlib.metadata import version, PackageNotFoundError
22
+ try:
23
+ __version__ = version("aimedicalcoding")
24
+ except PackageNotFoundError: # running from source, not installed
25
+ __version__ = "0+unknown"
26
+ except Exception: # pragma: no cover
27
+ __version__ = "0+unknown"
28
+
29
+ __all__ = ["extract_gaps", "collect_gaps", "GapResult", "EXTRACTORS", "__version__"]
@@ -0,0 +1,313 @@
1
+ """CLI: extract terminology gaps from a CCDA (or FHIR) document.
2
+
3
+ Usage:
4
+ python -m aimedicalcoding <path-to-document> [--system loinc] [--json] [--summary]
5
+
6
+ Outputs only the codes that need correcting/filling (gaps-only), each with its
7
+ pattern, composite match key, and recovery evidence packet.
8
+
9
+ Only LOINC is implemented today; --system is the extension point for the
10
+ SNOMED / RxNorm / ICD-10 extractors to plug into next.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import dataclasses
17
+ import json
18
+ import os
19
+ import sys
20
+
21
+ from . import serialize
22
+ from .api import EXTRACTORS as _EXTRACTORS, PLANNED as _PLANNED, collect_gaps
23
+ from .pipeline import parse_document
24
+
25
+
26
+ def _to_dict(record) -> dict:
27
+ d = dataclasses.asdict(record)
28
+ # System is a str-Enum so it JSON-serializes as its value already.
29
+ return d
30
+
31
+
32
+ def main(argv: list[str] | None = None) -> int:
33
+ p = argparse.ArgumentParser(
34
+ prog="aimedicalcoding",
35
+ description="Extract codes needing correction/filling from a clinical document.",
36
+ )
37
+ p.add_argument("path", help="Path to a CCDA XML (or FHIR JSON) document")
38
+ p.add_argument("--system", default="all",
39
+ help="loinc | snomed | rxnorm | icd10cm | cvx | all (default: all). Filters targets.")
40
+ p.add_argument("--contexts", default=None,
41
+ help="Comma-separated statement contexts to include (default: all)")
42
+ p.add_argument("--include-narrative", action="store_true",
43
+ help="Also report NARRATIVE_ONLY gaps (narrative-table text). Off by default.")
44
+ p.add_argument("--apply-dict", action="store_true",
45
+ help="Expand abbreviations (BUN→urea nitrogen) in search terms. Off by default.")
46
+ p.add_argument("--table", action="store_true", help="Compact tabular output")
47
+ p.add_argument("--json", action="store_true", help="Emit the target-tagged JSON")
48
+ p.add_argument("--json-full", action="store_true", help="Emit the complete internal IR (verbose)")
49
+ p.add_argument("-o", "--out", metavar="FILE", default=None,
50
+ help="Write the JSON report to FILE as UTF-8 (implies --json). "
51
+ "Prefer this over shell redirection: PowerShell '>' "
52
+ "re-encodes output as UTF-16.")
53
+ p.add_argument("--summary", action="store_true", help="Print only the counts")
54
+ args = p.parse_args(argv)
55
+
56
+ contexts = ({c.strip() for c in args.contexts.split(",") if c.strip()}
57
+ if args.contexts else None)
58
+
59
+ if args.system == "all":
60
+ system_keys = list(_EXTRACTORS)
61
+ elif args.system in _EXTRACTORS:
62
+ system_keys = [args.system]
63
+ else:
64
+ print(f"error: system '{args.system}' not implemented yet "
65
+ f"(planned: {', '.join(_PLANNED)})", file=sys.stderr)
66
+ return 5
67
+
68
+ try:
69
+ with open(args.path, "rb") as fh:
70
+ document = fh.read()
71
+ except OSError as e:
72
+ print(f"error: cannot read {args.path}: {e}", file=sys.stderr)
73
+ return 2
74
+
75
+ try:
76
+ statements = parse_document(document)
77
+ except ValueError as e:
78
+ print(f"error: {e}", file=sys.stderr)
79
+ return 3
80
+ except NotImplementedError as e:
81
+ print(f"error: {e}", file=sys.stderr)
82
+ return 4
83
+
84
+ tgaps = collect_gaps(statements, system_keys, contexts,
85
+ args.include_narrative, args.apply_dict)
86
+
87
+ by_pattern, by_target = {}, {}
88
+ for r, sys_, _slot in tgaps:
89
+ by_pattern[r.gap.type] = by_pattern.get(r.gap.type, 0) + 1
90
+ by_target[sys_] = by_target.get(sys_, 0) + 1
91
+
92
+ targets = [_EXTRACTORS[k][1] for k in system_keys]
93
+ src_fmt = statements[0].source_format if statements else None
94
+
95
+ if args.json or args.json_full or args.out:
96
+ if args.json_full:
97
+ gaps = [_to_dict(r) for r, _, _ in tgaps]
98
+ else:
99
+ gaps = [serialize.build_record(r, i, sys_, slot)
100
+ for i, (r, sys_, slot) in enumerate(tgaps, 1)]
101
+ payload = {
102
+ "source": os.path.basename(args.path),
103
+ "source_format": src_fmt,
104
+ "targets_checked": targets,
105
+ "gap_count": len(tgaps),
106
+ "by_pattern": by_pattern,
107
+ "by_target": by_target,
108
+ "gaps": gaps,
109
+ }
110
+ text = json.dumps(payload, indent=2, default=str)
111
+ if args.out:
112
+ with open(args.out, "w", encoding="utf-8") as fh:
113
+ fh.write(text + "\n")
114
+ print(f"{len(tgaps)} gap(s) written to {args.out}")
115
+ else:
116
+ print(text)
117
+ return 0
118
+
119
+ if args.table:
120
+ _print_table(tgaps, by_pattern, by_target, args.path, args.summary)
121
+ else:
122
+ _print_report(tgaps, by_pattern, by_target, args.path, targets, args.summary)
123
+ return 0
124
+
125
+
126
+ # --------------------------------------------------------------------------
127
+ # Pretty terminal report
128
+ # --------------------------------------------------------------------------
129
+ _W = 74
130
+
131
+ _ACTION = {
132
+ "LOCAL_CODE_ONLY": "crosswalk local code → LOINC; else text + axis search",
133
+ "NDC_ONLY": "use the NDC↔RxNorm crosswalk; LLM only if 1→many RxCUIs",
134
+ "MULTUM_ONLY": "crosswalk Multum → RxNorm via UMLS CUI; LLM only if ambiguous",
135
+ "FDB_ONLY": "crosswalk FDB/NDDF → RxNorm via UMLS CUI; LLM only if ambiguous",
136
+ "MEDISPAN_ONLY": "crosswalk Medi-Span (GPI/DDID) → RxNorm via UMLS CUI; LLM only if ambiguous",
137
+ "RXNORM_ONLY": "use the CDC/NLM CVX↔RxNorm map; LLM only if 1→many valences",
138
+ "NULLFLAVOR": "map recovered label → LOINC via text + axis search",
139
+ "DISPLAY_ONLY": "map displayName → LOINC via text + axis search",
140
+ "NARRATIVE_ONLY": "narrative row; resolve label → LOINC (low priority)",
141
+ "BAD_CODE": "apply check-digit correction (confirm against text + axis)",
142
+ }
143
+
144
+
145
+ def _rule(ch="─"):
146
+ return ch * _W
147
+
148
+
149
+ def _kv(label, value):
150
+ return f" {label:<12}: {value}"
151
+
152
+
153
+ def _concept(record) -> str:
154
+ """Human label for a gap. Prefers the resolved concept term (SNOMED reads it
155
+ from the value, not the question code); falls back to a unit/context hint when
156
+ there is no code AND no display name (e.g. an unlabeled vital)."""
157
+ ev = record.gap.evidence
158
+ term = ev.get("term") or record.statement.raw_text
159
+ if term:
160
+ return term
161
+ unit = ev.get("unit") or "no unit"
162
+ return f"(unlabeled · {unit} · {record.statement.context})"
163
+
164
+
165
+ def _print_report(tgaps, by_pattern, by_target, source, targets, summary_only):
166
+ print()
167
+ print("═" * _W)
168
+ print(" GAP REPORT".ljust(_W))
169
+ print("═" * _W)
170
+ print(_kv("Document", os.path.basename(source)))
171
+ print(_kv("Targets", ", ".join(targets)))
172
+ print(_kv("Gaps", len(tgaps)))
173
+ if by_pattern:
174
+ print(_kv("Patterns", " ".join(f"{k}={v}" for k, v in sorted(by_pattern.items()))))
175
+ if by_target:
176
+ print(_kv("By target", " ".join(f"{k}={v}" for k, v in sorted(by_target.items()))))
177
+ print("═" * _W)
178
+
179
+ if summary_only:
180
+ return
181
+ if not tgaps:
182
+ print("\n No codes need correction or filling. ✓\n")
183
+ return
184
+
185
+ for i, (r, sys_, slot) in enumerate(tgaps, 1):
186
+ ev = r.gap.evidence
187
+ print()
188
+ print(f" {i}. {r.gap.type} → {sys_} ({slot} slot)")
189
+ print(_rule())
190
+ print(_kv("Concept", _concept(r)))
191
+ print(_kv("Section", f"{r.statement.section_code or '-'} {r.statement.section_name or ''}".strip()))
192
+
193
+ # value + unit + reference range, compactly
194
+ val = ev.get("value")
195
+ if val is not None:
196
+ vline = str(val)
197
+ if ev.get("unit") and ev["unit"] not in str(val):
198
+ vline += f" {ev['unit']}"
199
+ if ev.get("reference_range"):
200
+ vline += f" (ref {ev['reference_range']})"
201
+ print(_kv("Value", vline))
202
+
203
+ # specimen with its provenance/confidence (panel-derived is low trust)
204
+ if ev.get("specimen_hint"):
205
+ print(_kv("Specimen", f"{ev['specimen_hint']} "
206
+ f"({ev.get('specimen_source')}, "
207
+ f"{ev.get('specimen_confidence')} confidence)"))
208
+
209
+ # SNOMED: target hierarchy (the candidate constraint)
210
+ if ev.get("target_hierarchy"):
211
+ print(_kv("Hierarchy", ev["target_hierarchy"]))
212
+
213
+ # pattern-specific evidence
214
+ if ev.get("local_code"):
215
+ print(_kv("Local code", f"{ev['local_code']} [{ev.get('local_system') or '?'}]"))
216
+ if ev.get("icd_code"):
217
+ print(_kv("ICD code", f"{ev['icd_code']} [{ev.get('icd_system') or '?'}]"))
218
+ if ev.get("ndc_code"):
219
+ print(_kv("NDC code", f"{ev['ndc_code']} [{ev.get('ndc_system') or '?'}]"))
220
+ if ev.get("snomed_code"):
221
+ print(_kv("SNOMED code", f"{ev['snomed_code']} [{ev.get('snomed_system') or '?'}]"))
222
+ if ev.get("null_flavor"):
223
+ print(_kv("nullFlavor", ev["null_flavor"]))
224
+ if ev.get("malformed_code"):
225
+ fix = ev.get("checkdigit_correction")
226
+ print(_kv("Bad code", f"{ev['malformed_code']}" +
227
+ (f" → suggested {fix}" if fix else "")))
228
+
229
+ print(_kv("Match key", r.gap.match_key))
230
+ print(_kv("Suggested", r.gap.note or _ACTION.get(r.gap.type, "resolve via DB + LLM")))
231
+ print(_kv("Location", r.statement.source_ref))
232
+
233
+ print()
234
+ print(_rule("═"))
235
+ print(f" {len(tgaps)} gap(s) ready for the recovery step "
236
+ f"(crosswalk → candidate search → LLM).")
237
+ print(_rule("═"))
238
+ print()
239
+
240
+
241
+ def _trunc(s, n):
242
+ s = "" if s is None else str(s)
243
+ return s if len(s) <= n else s[: n - 1] + "…"
244
+
245
+
246
+ def _detail(ev: dict, pattern: str) -> str:
247
+ if pattern == "BAD_CODE" and ev.get("malformed_code"):
248
+ return f"{ev['malformed_code']}→{ev.get('checkdigit_correction') or '?'}"
249
+ if pattern == "ICD_ONLY" and ev.get("icd_code"):
250
+ return f"icd:{ev['icd_code']}"
251
+ if pattern == "NDC_ONLY" and ev.get("ndc_code"):
252
+ return f"ndc:{ev['ndc_code']}"
253
+ if pattern == "RXNORM_ONLY" and ev.get("rxnorm_code"):
254
+ return f"rxcui:{ev['rxnorm_code']}"
255
+ if pattern == "SNOMED_ONLY" and ev.get("snomed_code"):
256
+ return f"sct:{ev['snomed_code']}"
257
+ if pattern in ("MULTUM_ONLY", "FDB_ONLY", "MEDISPAN_ONLY") and ev.get("source_code"):
258
+ return f"{ev.get('source_vocab', 'src')}:{ev['source_code']}"
259
+ if pattern == "LOCAL_CODE_ONLY" and ev.get("local_code"):
260
+ return f"local:{ev['local_code']}"
261
+ if pattern == "NULLFLAVOR" and ev.get("null_flavor"):
262
+ return f"nullFlavor:{ev['null_flavor']}"
263
+ return ""
264
+
265
+
266
+ def _print_table(tgaps, by_pattern, by_target, source, summary_only):
267
+ breakdown = " ".join(f"{k}={v}" for k, v in sorted(by_pattern.items())) or "none"
268
+ tgt = " ".join(f"{k}={v}" for k, v in sorted(by_target.items())) or "none"
269
+ print(f"\nGaps in {os.path.basename(source)}: {len(tgaps)} "
270
+ f"patterns({breakdown}) targets({tgt})\n")
271
+ if summary_only:
272
+ return
273
+ if not tgaps:
274
+ print(" No codes need correction or filling. ✓\n")
275
+ return
276
+
277
+ headers = ["#", "TARGET", "PATTERN", "CONCEPT", "CTX", "DETAIL", "MATCH KEY"]
278
+ caps = [3, 14, 16, 24, 8, 16, 72]
279
+ rows = []
280
+ for i, (r, sys_, slot) in enumerate(tgaps, 1):
281
+ ev = r.gap.evidence
282
+ rows.append([
283
+ str(i),
284
+ f"{sys_}/{slot}",
285
+ r.gap.type,
286
+ _concept(r),
287
+ r.statement.context,
288
+ _detail(ev, r.gap.type),
289
+ r.gap.match_key,
290
+ ])
291
+
292
+ # All columns except the last are capped/padded for alignment. The last
293
+ # column (MATCH KEY) is printed in full — no truncation, no trailing pad.
294
+ last = len(headers) - 1
295
+ widths = [
296
+ min(caps[c], max(len(headers[c]), *(len(_trunc(row[c], caps[c])) for row in rows)))
297
+ for c in range(last)
298
+ ]
299
+
300
+ def _fmt(cells):
301
+ fixed = " ".join(_trunc(cells[c], caps[c]).ljust(widths[c]) for c in range(last))
302
+ return f" {fixed} {cells[last] or ''}"
303
+
304
+ print(_fmt(headers))
305
+ sep = ["-" * widths[c] for c in range(last)] + ["-" * len(headers[last])]
306
+ print(" " + " ".join(sep))
307
+ for row in rows:
308
+ print(_fmt(row))
309
+ print()
310
+
311
+
312
+ if __name__ == "__main__":
313
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ from .base import FormatAdapter
2
+
3
+ __all__ = ["FormatAdapter"]
@@ -0,0 +1,25 @@
1
+ """FormatAdapter contract.
2
+
3
+ An adapter knows ONE input format (CCDA, FHIR, custom JSON) and nothing about
4
+ terminologies. It turns a raw document into an iterable of ``ClinicalStatement``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from typing import Iterable
11
+
12
+ from ..core.models import ClinicalStatement
13
+
14
+
15
+ class FormatAdapter(ABC):
16
+ #: short format key written onto each statement's ``source_format``
17
+ format_key: str = "unknown"
18
+
19
+ @abstractmethod
20
+ def detect(self, document: str | bytes) -> bool:
21
+ """Return True if ``document`` is this adapter's format."""
22
+
23
+ @abstractmethod
24
+ def parse(self, document: str | bytes) -> Iterable[ClinicalStatement]:
25
+ """Yield format-agnostic clinical statements."""
@@ -0,0 +1,3 @@
1
+ from .adapter import CcdaAdapter
2
+
3
+ __all__ = ["CcdaAdapter"]
@@ -0,0 +1,294 @@
1
+ """CCDA (HL7 V3 / C-CDA) format adapter.
2
+
3
+ Walks the document at all three altitudes (document type, section codes, entry
4
+ observations), runs scan-and-identify over each <code>, resolves originalText,
5
+ and falls back to narrative-table parsing for sections without structured
6
+ entries. Emits terminology-agnostic ``ClinicalStatement`` objects.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Iterable, Optional
12
+
13
+ from lxml import etree
14
+
15
+ from ...core.models import (
16
+ ClinicalStatement,
17
+ CodingCandidate,
18
+ System,
19
+ ValueInfo,
20
+ NAR,
21
+ )
22
+ from ...core.system_registry import canonicalize
23
+ from ..base import FormatAdapter
24
+ from . import codes as C
25
+ from . import narrative as N
26
+ from . import sections as S
27
+ from .sections import route_section
28
+
29
+
30
+ class CcdaAdapter(FormatAdapter):
31
+ format_key = "ccda"
32
+
33
+ # ------------------------------------------------------------------ #
34
+ def detect(self, document: str | bytes) -> bool:
35
+ try:
36
+ root = self._root(document)
37
+ except etree.XMLSyntaxError:
38
+ return False
39
+ return etree.QName(root).localname == "ClinicalDocument"
40
+
41
+ def parse(self, document: str | bytes) -> Iterable[ClinicalStatement]:
42
+ root = self._root(document)
43
+ self._tree = root.getroottree()
44
+ statements: list[ClinicalStatement] = []
45
+
46
+ # 1. document type code (top altitude)
47
+ doc_code = root.find(C.q("code"))
48
+ if doc_code is not None:
49
+ codings = C.scan_codings(doc_code)
50
+ if codings:
51
+ statements.append(ClinicalStatement(
52
+ source_format=self.format_key,
53
+ source_ref=self._path(doc_code),
54
+ context="document_type",
55
+ target_system_hint=System.LOINC,
56
+ raw_text=doc_code.get("displayName"),
57
+ codings=codings,
58
+ ))
59
+
60
+ # 2. sections
61
+ for section in root.iter(C.q("section")):
62
+ statements.extend(self._parse_section(section))
63
+
64
+ return statements
65
+
66
+ # ------------------------------------------------------------------ #
67
+ def _parse_section(self, section: etree._Element) -> list[ClinicalStatement]:
68
+ out: list[ClinicalStatement] = []
69
+
70
+ sec_code_el = section.find(C.q("code"))
71
+ sec_code = sec_code_el.get("code") if sec_code_el is not None else None
72
+ sec_name = sec_code_el.get("displayName") if sec_code_el is not None else None
73
+ sec_system = (canonicalize(sec_code_el.get("codeSystem")).value
74
+ if sec_code_el is not None and canonicalize(sec_code_el.get("codeSystem"))
75
+ else None)
76
+ context, hint = route_section(sec_code)
77
+ id_map = N.build_id_map(section)
78
+
79
+ # section-level statement (the section's own LOINC label)
80
+ if sec_code_el is not None:
81
+ out.append(ClinicalStatement(
82
+ source_format=self.format_key,
83
+ source_ref=self._path(sec_code_el),
84
+ context="section",
85
+ section_code=sec_code,
86
+ section_name=sec_name,
87
+ section_system=sec_system,
88
+ target_system_hint=System.LOINC,
89
+ raw_text=sec_name,
90
+ codings=C.scan_codings(sec_code_el),
91
+ ))
92
+
93
+ # 2a. organizers (panels) -> child observations carry the panel.
94
+ # NB: lxml elements have no stable Python identity across iter()/findall(),
95
+ # so we dedup structurally (by ancestry) rather than by id().
96
+ sec = (sec_code, sec_name, sec_system)
97
+ for org in section.iter(C.q("organizer")):
98
+ panel = self._panel_coding(org)
99
+ org_specimen = C.extract_specimen(org) # organizer-level specimen
100
+ for obs in org.findall(f"{C.q('component')}/{C.q('observation')}"):
101
+ out.append(self._observation(obs, context, hint, id_map, panel,
102
+ org_specimen, sec))
103
+
104
+ # 2b. standalone observations: those NOT nested under an organizer or
105
+ # another observation (entryRelationship sub-observations are skipped).
106
+ for obs in section.iter(C.q("observation")):
107
+ if self._has_ancestor(obs, ("organizer", "observation")):
108
+ continue
109
+ out.append(self._observation(obs, context, hint, id_map, panel=None,
110
+ section=sec))
111
+
112
+ # 2b-2. Procedures: <procedure> acts carry the code directly (SNOMED/CPT).
113
+ for proc in section.iter(C.q("procedure")):
114
+ code_el = proc.find(C.q("code"))
115
+ if code_el is not None and (code_el.get("code") or code_el.get("nullFlavor")):
116
+ out.append(self._observation(proc, context, hint, id_map,
117
+ panel=None, section=sec))
118
+
119
+ # 2b-2b. Medications / immunizations: the product code lives on
120
+ # manufacturedMaterial (RxNorm/NDC for drugs; CVX/RxNorm/NDC for vaccines),
121
+ # nested in substanceAdministration → consumable → manufacturedProduct.
122
+ if context in ("medication", "immunization"):
123
+ for mm in section.iter(C.q("manufacturedMaterial")):
124
+ if mm.find(C.q("code")) is not None:
125
+ out.append(self._observation(mm, context, hint, id_map,
126
+ panel=None, section=sec))
127
+
128
+ # 2b-3. Allergy slots: the allergen substance (participant) and the
129
+ # reaction manifestation (nested observation) — only in allergy sections.
130
+ if context == "allergy":
131
+ for pe in section.iter(C.q("playingEntity")):
132
+ if pe.find(C.q("code")) is not None:
133
+ out.append(self._observation(pe, "allergy_substance", hint,
134
+ id_map, panel=None, section=sec))
135
+ for obs in section.iter(C.q("observation")):
136
+ if self._has_templateid(obs, S.TEMPLATE_ALLERGY_REACTION):
137
+ out.append(self._observation(obs, "allergy_reaction", hint,
138
+ id_map, panel=None, section=sec))
139
+
140
+ structured = [s for s in out if s.context not in ("section",)]
141
+
142
+ # 2c. narrative-only fallback: no structured observations -> parse table
143
+ if not structured:
144
+ for row in N.parse_narrative_table(section):
145
+ out.append(self._narrative_row(section, row, context, hint, sec))
146
+
147
+ return out
148
+
149
+ # ------------------------------------------------------------------ #
150
+ def _observation(
151
+ self,
152
+ obs: etree._Element,
153
+ context: str,
154
+ hint: Optional[System],
155
+ id_map: dict,
156
+ panel: Optional[CodingCandidate],
157
+ org_specimen: Optional[dict] = None,
158
+ section: tuple = (None, None, None),
159
+ ) -> ClinicalStatement:
160
+ code_el = obs.find(C.q("code"))
161
+ codings = C.scan_codings(code_el)
162
+
163
+ # resolved <originalText> for the root <code> (inline or #reference)
164
+ ot = N.original_text(code_el, id_map)
165
+ if ot:
166
+ root = next((c for c in codings if not c.from_translation), None)
167
+ if root is not None:
168
+ root.original_text = ot
169
+
170
+ # raw_text: prefer root displayName, else originalText, else any display
171
+ raw_text = None
172
+ if code_el is not None:
173
+ raw_text = code_el.get("displayName")
174
+ if not raw_text:
175
+ raw_text = ot
176
+ if not raw_text:
177
+ raw_text = next((c.display for c in codings if c.display), None)
178
+
179
+ companion = C.extract_companion(obs)
180
+ # negationInd="true" means the act is NEGATED (condition absent / med or
181
+ # vaccine NOT given) — must not be coded as present. Structural attribute,
182
+ # more reliable than text cues.
183
+ if obs.get("negationInd") == "true":
184
+ companion["negation"] = True
185
+ # explicit specimen: observation-level wins, else inherit organizer's
186
+ specimen = C.extract_specimen(obs) or org_specimen
187
+ if specimen:
188
+ companion["specimen"] = specimen.get("display")
189
+ companion["specimen_code"] = specimen.get("code")
190
+
191
+ # value: resolve the VALUE's own originalText / nullFlavor (the coded
192
+ # answer's label, e.g. a SNOMED problem recovered from narrative).
193
+ value = C.extract_value(obs)
194
+ val_el = obs.find(C.q("value"))
195
+ if value is not None and val_el is not None:
196
+ v_ot = N.original_text(val_el, id_map)
197
+ if v_ot:
198
+ value.original_text = v_ot
199
+ v_nf = val_el.get("nullFlavor")
200
+ if v_nf and (value.coded_value is None or not value.coded_value.code):
201
+ value.coded_value = CodingCandidate(system=System.NULLFLAVOR,
202
+ raw_system=v_nf)
203
+
204
+ # explicit labels for THIS node's code (the question/observation code).
205
+ # These describe the CODE slot; the SNOMED handler overrides with the
206
+ # value's labels for value-slot concepts (see systems/snomed/patterns.py).
207
+ display_name = code_el.get("displayName") if code_el is not None else None
208
+ original_text = ot
209
+
210
+ return ClinicalStatement(
211
+ source_format=self.format_key,
212
+ source_ref=self._path(obs),
213
+ context=context,
214
+ section_code=section[0],
215
+ section_name=section[1],
216
+ section_system=section[2],
217
+ role="value",
218
+ target_system_hint=hint,
219
+ raw_text=raw_text,
220
+ display_name=display_name,
221
+ original_text=original_text,
222
+ codings=codings,
223
+ value=value,
224
+ effective_time=C.extract_effective_time(obs),
225
+ panel=panel,
226
+ companion=companion,
227
+ )
228
+
229
+ def _narrative_row(
230
+ self, section: etree._Element, row: dict, context: str,
231
+ hint: Optional[System], sec: tuple = (None, None, None),
232
+ ) -> ClinicalStatement:
233
+ value = None
234
+ if row.get("value_raw") is not None:
235
+ value = ValueInfo(
236
+ type="Qn" if row.get("num") is not None else NAR,
237
+ raw=row.get("value_raw"),
238
+ num=row.get("num"),
239
+ unit=row.get("unit"),
240
+ unit_system="UCUM" if row.get("unit") else None,
241
+ )
242
+ companion = {}
243
+ if row.get("reference_range"):
244
+ companion["reference_range"] = row["reference_range"]
245
+ return ClinicalStatement(
246
+ source_format=self.format_key,
247
+ source_ref=f"{self._path(section)}/text//tr[{row['row_index']}]",
248
+ context=context,
249
+ section_code=sec[0],
250
+ section_name=sec[1],
251
+ section_system=sec[2],
252
+ role="value",
253
+ target_system_hint=hint,
254
+ raw_text=row["label"],
255
+ original_text=row["label"], # narrative-table text
256
+ codings=[], # no code in narrative -> recovery candidate
257
+ value=value,
258
+ companion=companion,
259
+ )
260
+
261
+ def _panel_coding(self, org: etree._Element) -> Optional[CodingCandidate]:
262
+ code_el = org.find(C.q("code"))
263
+ codings = C.scan_codings(code_el)
264
+ if not codings:
265
+ return None
266
+ loinc = C.best_loinc(codings)
267
+ if loinc:
268
+ return loinc
269
+ return next((c for c in codings if c.code), codings[0])
270
+
271
+ # ------------------------------------------------------------------ #
272
+ @staticmethod
273
+ def _has_templateid(el: etree._Element, root: str) -> bool:
274
+ return any(t.get("root") == root for t in el.findall(C.q("templateId")))
275
+
276
+ @staticmethod
277
+ def _has_ancestor(el: etree._Element, localnames: tuple[str, ...]) -> bool:
278
+ parent = el.getparent()
279
+ while parent is not None:
280
+ if etree.QName(parent).localname in localnames:
281
+ return True
282
+ parent = parent.getparent()
283
+ return False
284
+
285
+ @staticmethod
286
+ def _root(document: str | bytes) -> etree._Element:
287
+ data = document.encode("utf-8") if isinstance(document, str) else document
288
+ return etree.fromstring(data)
289
+
290
+ def _path(self, el: etree._Element) -> str:
291
+ try:
292
+ return self._tree.getpath(el)
293
+ except Exception:
294
+ return "?"