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.
- aimedicalcoding/__init__.py +29 -0
- aimedicalcoding/__main__.py +313 -0
- aimedicalcoding/adapters/__init__.py +3 -0
- aimedicalcoding/adapters/base.py +25 -0
- aimedicalcoding/adapters/ccda/__init__.py +3 -0
- aimedicalcoding/adapters/ccda/adapter.py +294 -0
- aimedicalcoding/adapters/ccda/codes.py +198 -0
- aimedicalcoding/adapters/ccda/narrative.py +121 -0
- aimedicalcoding/adapters/ccda/sections.py +44 -0
- aimedicalcoding/adapters/custom_json/__init__.py +3 -0
- aimedicalcoding/adapters/custom_json/adapter.py +30 -0
- aimedicalcoding/adapters/fhir/__init__.py +3 -0
- aimedicalcoding/adapters/fhir/adapter.py +326 -0
- aimedicalcoding/adapters/fhir/codes.py +211 -0
- aimedicalcoding/adapters/fhir/mapping.py +68 -0
- aimedicalcoding/api.py +163 -0
- aimedicalcoding/core/__init__.py +34 -0
- aimedicalcoding/core/checkdigits.py +103 -0
- aimedicalcoding/core/models.py +149 -0
- aimedicalcoding/core/system_registry.py +185 -0
- aimedicalcoding/pipeline/__init__.py +3 -0
- aimedicalcoding/pipeline/orchestrator.py +33 -0
- aimedicalcoding/recovery/__init__.py +3 -0
- aimedicalcoding/recovery/engine.py +16 -0
- aimedicalcoding/serialize.py +257 -0
- aimedicalcoding/systems/__init__.py +3 -0
- aimedicalcoding/systems/base.py +43 -0
- aimedicalcoding/systems/cvx/__init__.py +15 -0
- aimedicalcoding/systems/cvx/handler.py +55 -0
- aimedicalcoding/systems/cvx/normalize.py +75 -0
- aimedicalcoding/systems/cvx/patterns.py +134 -0
- aimedicalcoding/systems/icd10cm/__init__.py +16 -0
- aimedicalcoding/systems/icd10cm/handler.py +55 -0
- aimedicalcoding/systems/icd10cm/icdformat.py +43 -0
- aimedicalcoding/systems/icd10cm/patterns.py +133 -0
- aimedicalcoding/systems/loinc/__init__.py +14 -0
- aimedicalcoding/systems/loinc/axes.py +199 -0
- aimedicalcoding/systems/loinc/handler.py +80 -0
- aimedicalcoding/systems/loinc/normalize.py +81 -0
- aimedicalcoding/systems/loinc/patterns.py +166 -0
- aimedicalcoding/systems/rxnorm/__init__.py +13 -0
- aimedicalcoding/systems/rxnorm/handler.py +55 -0
- aimedicalcoding/systems/rxnorm/normalize.py +88 -0
- aimedicalcoding/systems/rxnorm/patterns.py +143 -0
- aimedicalcoding/systems/snomed/__init__.py +15 -0
- aimedicalcoding/systems/snomed/handler.py +60 -0
- aimedicalcoding/systems/snomed/hierarchy.py +96 -0
- aimedicalcoding/systems/snomed/patterns.py +166 -0
- aimedicalcoding/systems/snomed/qualifiers.py +90 -0
- aimedicalcoding/terminology/__init__.py +3 -0
- aimedicalcoding/terminology/service.py +31 -0
- aimedicalcoding-0.3.1.dist-info/METADATA +276 -0
- aimedicalcoding-0.3.1.dist-info/RECORD +57 -0
- aimedicalcoding-0.3.1.dist-info/WHEEL +5 -0
- aimedicalcoding-0.3.1.dist-info/entry_points.txt +2 -0
- aimedicalcoding-0.3.1.dist-info/licenses/LICENSE +21 -0
- 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,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,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 "?"
|