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,198 @@
1
+ """Scan-and-identify code extraction for CCDA <code> elements.
2
+
3
+ Implements LOINC_pattern_analysis.md §3.4: never predict which slot holds a
4
+ code; enumerate the root <code> + all <translation> children, canonicalize each
5
+ by system, and let downstream pick. Also extracts <value>, effectiveTime,
6
+ interpretation and reference ranges.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+ from lxml import etree
14
+
15
+ from ...core.checkdigits import is_valid_loinc, is_valid_sctid
16
+ from ...core.models import (
17
+ CodingCandidate,
18
+ System,
19
+ ValueInfo,
20
+ QN,
21
+ ORD,
22
+ NOM,
23
+ NAR,
24
+ )
25
+ from ...core.system_registry import canonicalize
26
+
27
+ V3 = "urn:hl7-org:v3"
28
+ XSI = "http://www.w3.org/2001/XMLSchema-instance"
29
+
30
+
31
+ def q(tag: str) -> str:
32
+ """Qualify an unprefixed tag with the HL7 V3 default namespace."""
33
+ return f"{{{V3}}}{tag}"
34
+
35
+
36
+ def xsi_type(el: etree._Element) -> Optional[str]:
37
+ t = el.get(f"{{{XSI}}}type")
38
+ if t and ":" in t:
39
+ t = t.split(":", 1)[1]
40
+ return t
41
+
42
+
43
+ def _coding_from_code_el(el: etree._Element, from_translation: bool) -> CodingCandidate:
44
+ raw_system = el.get("codeSystem") or el.get("codeSystemName")
45
+ system = canonicalize(raw_system)
46
+ code = el.get("code")
47
+ cand = CodingCandidate(
48
+ raw_system=raw_system,
49
+ system=system,
50
+ code=code,
51
+ display=el.get("displayName"),
52
+ from_translation=from_translation,
53
+ )
54
+ if code:
55
+ if system == System.LOINC:
56
+ cand.check_digit_valid = is_valid_loinc(code)
57
+ elif system == System.SNOMEDCT:
58
+ cand.check_digit_valid = is_valid_sctid(code)
59
+ return cand
60
+
61
+
62
+ def scan_codings(code_el: Optional[etree._Element]) -> list[CodingCandidate]:
63
+ """Return all codings on a <code> element: root + every <translation>.
64
+
65
+ A nullFlavor code with no code attribute still yields a candidate (so the
66
+ handler can classify it as a NULLFLAVOR gap), but only if it carries a
67
+ nullFlavor or displayName/originalText signal.
68
+ """
69
+ if code_el is None:
70
+ return []
71
+ codings: list[CodingCandidate] = []
72
+
73
+ null_flavor = code_el.get("nullFlavor")
74
+ if code_el.get("code") or code_el.get("codeSystem") or null_flavor:
75
+ root = _coding_from_code_el(code_el, from_translation=False)
76
+ if null_flavor and not root.code:
77
+ root.system = System.NULLFLAVOR
78
+ root.raw_system = null_flavor # the flavor itself: UNK, OTH, NA, NI, …
79
+ codings.append(root)
80
+
81
+ for tr in code_el.findall(q("translation")):
82
+ codings.append(_coding_from_code_el(tr, from_translation=True))
83
+
84
+ return codings
85
+
86
+
87
+ def best_loinc(codings: list[CodingCandidate]) -> Optional[CodingCandidate]:
88
+ """Select the canonical LOINC coding (root preferred, must validate)."""
89
+ hits = [
90
+ c
91
+ for c in codings
92
+ if c.system == System.LOINC and c.code and c.check_digit_valid
93
+ ]
94
+ if not hits:
95
+ return None
96
+ roots = [c for c in hits if not c.from_translation]
97
+ return roots[0] if roots else hits[0]
98
+
99
+
100
+ # --------------------------------------------------------------------------
101
+ # Value extraction
102
+ # --------------------------------------------------------------------------
103
+
104
+ def extract_value(obs_el: etree._Element) -> Optional[ValueInfo]:
105
+ val = obs_el.find(q("value"))
106
+ if val is None:
107
+ return None
108
+ t = xsi_type(val)
109
+
110
+ if t in ("PQ", "REAL", "INT"):
111
+ raw = val.get("value")
112
+ num = _to_float(raw)
113
+ return ValueInfo(
114
+ type=QN, raw=raw, num=num, unit=val.get("unit"),
115
+ unit_system="UCUM" if val.get("unit") else None,
116
+ )
117
+ if t in ("CD", "CE", "CO"):
118
+ # scan the value's root <code> + any <translation> children (e.g. a SNOMED
119
+ # value with an ICD-10-CM translation, or vice-versa) — same structure as
120
+ # a <code> element, so reuse scan_codings.
121
+ codings = scan_codings(val)
122
+ coded = next((c for c in codings if not c.from_translation),
123
+ codings[0] if codings else None)
124
+ vtype = ORD if (val.get("displayName") or "").lower() in _ORDINAL_WORDS else NOM
125
+ return ValueInfo(type=vtype, raw=val.get("displayName") or val.get("code"),
126
+ coded_value=coded, codings=codings)
127
+ if t in ("ST", "ED"):
128
+ return ValueInfo(type=NAR, raw=(val.text or "").strip() or None)
129
+ if t == "IVL_PQ":
130
+ low = val.find(q("low"))
131
+ raw = low.get("value") if low is not None else None
132
+ return ValueInfo(type=QN, raw=raw, num=_to_float(raw),
133
+ unit=low.get("unit") if low is not None else None,
134
+ unit_system="UCUM")
135
+ # unknown type: keep whatever value attr exists
136
+ raw = val.get("value") or (val.text or "").strip() or None
137
+ return ValueInfo(type=None, raw=raw, num=_to_float(val.get("value")),
138
+ unit=val.get("unit"))
139
+
140
+
141
+ _ORDINAL_WORDS = {"positive", "negative", "reactive", "non-reactive",
142
+ "detected", "not detected", "present", "absent"}
143
+
144
+
145
+ def extract_effective_time(obs_el: etree._Element) -> Optional[str]:
146
+ et = obs_el.find(q("effectiveTime"))
147
+ if et is None:
148
+ return None
149
+ if et.get("value"):
150
+ return et.get("value")
151
+ low = et.find(q("low"))
152
+ return low.get("value") if low is not None else None
153
+
154
+
155
+ def extract_specimen(el: etree._Element) -> Optional[dict]:
156
+ """Explicit specimen from an observation or organizer:
157
+ specimen/specimenRole/specimenPlayingEntity/code (usually SNOMED).
158
+ Returns {code, system, display} or None.
159
+ """
160
+ sp = el.find(
161
+ f"{q('specimen')}/{q('specimenRole')}/{q('specimenPlayingEntity')}/{q('code')}"
162
+ )
163
+ if sp is None:
164
+ return None
165
+ return {
166
+ "code": sp.get("code"),
167
+ "system": canonicalize(sp.get("codeSystem")),
168
+ "display": sp.get("displayName"),
169
+ }
170
+
171
+
172
+ def extract_companion(obs_el: etree._Element) -> dict:
173
+ out: dict = {}
174
+ interp = obs_el.find(q("interpretationCode"))
175
+ if interp is not None and interp.get("code"):
176
+ out["interpretation"] = interp.get("code")
177
+
178
+ # reference range: observationRange/value (IVL_PQ low-high) or text
179
+ rng = obs_el.find(f"{q('referenceRange')}/{q('observationRange')}/{q('value')}")
180
+ if rng is not None:
181
+ low = rng.find(q("low"))
182
+ high = rng.find(q("high"))
183
+ if low is not None or high is not None:
184
+ lo = low.get("value") if low is not None else ""
185
+ hi = high.get("value") if high is not None else ""
186
+ unit = (low.get("unit") if low is not None else None) or (
187
+ high.get("unit") if high is not None else None)
188
+ out["reference_range"] = f"{lo}-{hi}" + (f" {unit}" if unit else "")
189
+ return out
190
+
191
+
192
+ def _to_float(s: Optional[str]) -> Optional[float]:
193
+ if s is None:
194
+ return None
195
+ try:
196
+ return float(s)
197
+ except (ValueError, TypeError):
198
+ return None
@@ -0,0 +1,121 @@
1
+ """CCDA narrative handling.
2
+
3
+ Two jobs (LOINC_pattern_analysis.md §3.5):
4
+ 1. originalText/<reference value="#id"/> resolution against the section's
5
+ human-readable <text> block.
6
+ 2. Narrative-table extraction for sections/rows that have NO structured
7
+ <entry> — the highest-yield "display without code" recovery path.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Optional
14
+
15
+ from lxml import etree
16
+
17
+ from .codes import q
18
+
19
+ _NUM_UNIT = re.compile(r"^\s*([<>]?=?\s*-?\d+(?:\.\d+)?)\s*([A-Za-z%/µ°\[\]\d\.\^*-]*)\s*$")
20
+ _RANGE = re.compile(r"^\s*-?\d+(?:\.\d+)?\s*[-–]\s*-?\d+(?:\.\d+)?")
21
+
22
+
23
+ def text_of(el: etree._Element) -> str:
24
+ """Flattened text content of a narrative element."""
25
+ return re.sub(r"\s+", " ", "".join(el.itertext())).strip()
26
+
27
+
28
+ def build_id_map(section_el: etree._Element) -> dict[str, str]:
29
+ """Map every narrative element carrying an ID/id attribute to its text.
30
+
31
+ Used to resolve <reference value="#id"/> from originalText.
32
+ """
33
+ text_el = section_el.find(q("text"))
34
+ if text_el is None:
35
+ return {}
36
+ id_map: dict[str, str] = {}
37
+ for el in text_el.iter():
38
+ for attr in ("ID", "id"):
39
+ ref_id = el.get(attr)
40
+ if ref_id:
41
+ id_map[ref_id] = text_of(el)
42
+ return id_map
43
+
44
+
45
+ def resolve_reference(id_map: dict[str, str], ref: Optional[str]) -> Optional[str]:
46
+ if not ref:
47
+ return None
48
+ return id_map.get(ref.lstrip("#"))
49
+
50
+
51
+ def original_text(code_el: Optional[etree._Element], id_map: dict[str, str]) -> Optional[str]:
52
+ """Return originalText for a <code>: inline text or resolved #reference."""
53
+ if code_el is None:
54
+ return None
55
+ ot = code_el.find(q("originalText"))
56
+ if ot is None:
57
+ return None
58
+ ref = ot.find(q("reference"))
59
+ if ref is not None and ref.get("value"):
60
+ resolved = resolve_reference(id_map, ref.get("value"))
61
+ if resolved:
62
+ return resolved
63
+ inline = text_of(ot)
64
+ return inline or None
65
+
66
+
67
+ def parse_narrative_table(section_el: etree._Element) -> list[dict]:
68
+ """Extract rows from a section's narrative <table>(s).
69
+
70
+ Heuristic, source-tolerant: first cell is the analyte label; we then scan
71
+ remaining cells for a value+unit and a reference range. Returns a list of
72
+ {"label", "value_raw", "num", "unit", "reference_range", "row_index"}.
73
+ """
74
+ text_el = section_el.find(q("text"))
75
+ if text_el is None:
76
+ return []
77
+
78
+ rows: list[dict] = []
79
+ for table in text_el.iter(q("table")):
80
+ headers = _header_labels(table)
81
+ body_rows = list(table.iter(q("tr")))
82
+ for ri, tr in enumerate(body_rows):
83
+ cells = [text_of(td) for td in tr.iter(q("td"))]
84
+ if not cells:
85
+ continue # header row (th) or empty
86
+ label = cells[0].strip()
87
+ if not label:
88
+ continue
89
+ row = {
90
+ "label": label,
91
+ "value_raw": None,
92
+ "num": None,
93
+ "unit": None,
94
+ "reference_range": None,
95
+ "row_index": ri,
96
+ "headers": headers,
97
+ }
98
+ for c in cells[1:]:
99
+ c = c.strip()
100
+ if not c:
101
+ continue
102
+ if row["reference_range"] is None and _RANGE.match(c):
103
+ row["reference_range"] = c
104
+ continue
105
+ m = _NUM_UNIT.match(c)
106
+ if m and row["value_raw"] is None:
107
+ row["value_raw"] = c
108
+ try:
109
+ row["num"] = float(m.group(1).replace(" ", "").lstrip("<>=~"))
110
+ except ValueError:
111
+ pass
112
+ row["unit"] = m.group(2) or None
113
+ rows.append(row)
114
+ return rows
115
+
116
+
117
+ def _header_labels(table: etree._Element) -> list[str]:
118
+ thead = table.find(q("thead"))
119
+ if thead is None:
120
+ return []
121
+ return [text_of(th) for tr in thead.iter(q("tr")) for th in tr.iter(q("th"))]
@@ -0,0 +1,44 @@
1
+ """CCDA section routing.
2
+
3
+ Maps a section's LOINC code to (context, target_system_hint) so downstream
4
+ handlers receive correctly-framed statements. This is *routing* knowledge, not
5
+ terminology logic (ARCHITECTURE.md §1 subtlety): the adapter tags context/role,
6
+ the handler stays format-agnostic.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ...core.models import System
12
+
13
+ # section LOINC code -> (context, entry-level target system hint)
14
+ SECTION_ROUTING: dict[str, tuple[str, System | None]] = {
15
+ "30954-2": ("result", System.LOINC), # Results
16
+ "8716-3": ("vital", System.LOINC), # Vital signs
17
+ "11450-4": ("problem", System.SNOMEDCT), # Problem list
18
+ "10160-0": ("medication", System.RXNORM), # Medications
19
+ "11369-6": ("immunization", System.CVX), # History of immunizations
20
+ "48765-2": ("allergy", System.RXNORM), # Allergies
21
+ "47519-4": ("procedure", System.CPT), # Procedures
22
+ "29762-2": ("social_history", System.LOINC), # Social history (question slot)
23
+ "10157-6": ("family_history", System.SNOMEDCT), # Family history
24
+ "47420-5": ("functional_status", System.LOINC),
25
+ "8648-8": ("hospital_course", None),
26
+ "18776-5": ("plan_of_treatment", None),
27
+ "46240-8": ("encounters", None),
28
+ "51848-0": ("assessment", System.LOINC),
29
+ # PHQ-9 / survey panels surface as LOINC inside assessment-style sections
30
+ }
31
+
32
+ # Result/observation organizer & observation templateIds (entries-level routing)
33
+ TEMPLATE_RESULT_ORGANIZER = "2.16.840.1.113883.10.20.22.4.1"
34
+ TEMPLATE_RESULT_OBSERVATION = "2.16.840.1.113883.10.20.22.4.2"
35
+ TEMPLATE_VITALS_ORGANIZER = "2.16.840.1.113883.10.20.22.4.26"
36
+ TEMPLATE_VITALS_OBSERVATION = "2.16.840.1.113883.10.20.22.4.27"
37
+ TEMPLATE_ALLERGY_REACTION = "2.16.840.1.113883.10.20.22.4.9" # Reaction Observation
38
+
39
+
40
+ def route_section(section_code: str | None) -> tuple[str, System | None]:
41
+ """Return (context, target_system_hint) for a section LOINC code."""
42
+ if section_code and section_code in SECTION_ROUTING:
43
+ return SECTION_ROUTING[section_code]
44
+ return ("section", None)
@@ -0,0 +1,3 @@
1
+ from .adapter import CustomJsonAdapter
2
+
3
+ __all__ = ["CustomJsonAdapter"]
@@ -0,0 +1,30 @@
1
+ """Custom JSON adapter — PLACEHOLDER.
2
+
3
+ Planned: config-driven field mapping per source (mappings/<source>.yaml),
4
+ validate any 'loinc'-named key before trusting it, split combined "value unit"
5
+ strings. See ARCHITECTURE.md §5.1 and LOINC_pattern_analysis.md §5.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Iterable
11
+
12
+ from ...core.models import ClinicalStatement
13
+ from ..base import FormatAdapter
14
+
15
+
16
+ class CustomJsonAdapter(FormatAdapter):
17
+ format_key = "custom"
18
+
19
+ def __init__(self, mapping: dict | None = None):
20
+ self.mapping = mapping or {}
21
+
22
+ def detect(self, document: str | bytes) -> bool:
23
+ # Custom JSON has no universal signature; the orchestrator selects it
24
+ # explicitly (by source config), not by detection.
25
+ return False
26
+
27
+ def parse(self, document: str | bytes) -> Iterable[ClinicalStatement]:
28
+ raise NotImplementedError(
29
+ "Custom JSON adapter is a placeholder; supply a source mapping later."
30
+ )
@@ -0,0 +1,3 @@
1
+ from .adapter import FhirAdapter
2
+
3
+ __all__ = ["FhirAdapter"]