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
aimedicalcoding/api.py ADDED
@@ -0,0 +1,163 @@
1
+ """Public Python API.
2
+
3
+ from aimedicalcoding import extract_gaps
4
+
5
+ result = extract_gaps("patient.xml", systems=("loinc", "snomed", "rxnorm"))
6
+ result.gaps # list[dict] — target-tagged gap records
7
+ result.by_target # {"LOINC": 5, ...}
8
+ result.to_json()
9
+ result.to_dict()
10
+
11
+ One pass checks every requested terminology; each gap is tagged with its
12
+ ``target`` (system + slot). See docs/GAP_SCHEMA.md for the record shape.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Iterable, Optional, Sequence, Union
21
+
22
+ from . import serialize
23
+ from .core.models import ClinicalStatement, NormalizedRecord
24
+ from .pipeline import parse_document
25
+ from .systems.loinc import LoincGapExtractor
26
+ from .systems.snomed import SnomedGapExtractor
27
+ from .systems.snomed.patterns import VALUE_SLOT_CONTEXTS as _SN_VALUE_SLOTS
28
+ from .systems.rxnorm import RxnormGapExtractor
29
+ from .systems.icd10cm import Icd10cmGapExtractor
30
+ from .systems.icd10cm.patterns import ICD_CONTEXTS as _ICD_CONTEXTS
31
+ from .systems.cvx import CvxGapExtractor
32
+
33
+ # system key -> (extractor factory, canonical name). Register new systems here.
34
+ EXTRACTORS = {
35
+ "loinc": (LoincGapExtractor, "LOINC"),
36
+ "snomed": (SnomedGapExtractor, "SNOMED-CT"),
37
+ "rxnorm": (RxnormGapExtractor, "RxNorm"),
38
+ "icd10cm": (Icd10cmGapExtractor, "ICD-10-CM"),
39
+ "cvx": (CvxGapExtractor, "CVX"),
40
+ }
41
+ PLANNED = ("cpt", "hcpcs", "icd10pcs")
42
+
43
+ Source = Union[str, bytes, os.PathLike, "os.PathLike[str]"]
44
+
45
+
46
+ def _read(source: Source) -> bytes:
47
+ """Accept a path, raw bytes/str, or a file-like object."""
48
+ if isinstance(source, bytes):
49
+ return source
50
+ if hasattr(source, "read"): # file-like
51
+ data = source.read()
52
+ return data.encode() if isinstance(data, str) else data
53
+ if isinstance(source, str) and ("<" in source or "{" in source):
54
+ return source.encode() # inline document content
55
+ with open(source, "rb") as fh: # path
56
+ return fh.read()
57
+
58
+
59
+ def _slot_for(system_key: str, stmt: ClinicalStatement) -> str:
60
+ if system_key == "snomed":
61
+ return "value" if stmt.context in _SN_VALUE_SLOTS else "code"
62
+ if system_key == "icd10cm":
63
+ return "value" # diagnoses live in the value slot
64
+ return "code"
65
+
66
+
67
+ def collect_gaps(
68
+ statements: Sequence[ClinicalStatement],
69
+ system_keys: Iterable[str],
70
+ contexts: Optional[set[str]] = None,
71
+ include_narrative: bool = False,
72
+ apply_dict: bool = False,
73
+ ) -> list[tuple[NormalizedRecord, str, str]]:
74
+ """Run each system over the SAME statements (one pass); tag with target.
75
+ Returns (record, canonical_system, slot) tuples."""
76
+ out: list[tuple[NormalizedRecord, str, str]] = []
77
+ for key in system_keys:
78
+ factory, canon = EXTRACTORS[key]
79
+ recs = factory().extract(statements, contexts=contexts,
80
+ include_narrative=include_narrative,
81
+ apply_dict=apply_dict)
82
+ for r in recs:
83
+ out.append((r, canon, _slot_for(key, r.statement)))
84
+ return out
85
+
86
+
87
+ @dataclass
88
+ class GapResult:
89
+ """Result of :func:`extract_gaps`."""
90
+ source_format: Optional[str]
91
+ targets_checked: list[str]
92
+ gaps: list[dict] = field(default_factory=list)
93
+ by_pattern: dict[str, int] = field(default_factory=dict)
94
+ by_target: dict[str, int] = field(default_factory=dict)
95
+
96
+ def to_dict(self) -> dict[str, Any]:
97
+ return {
98
+ "source_format": self.source_format,
99
+ "targets_checked": self.targets_checked,
100
+ "gap_count": len(self.gaps),
101
+ "by_pattern": self.by_pattern,
102
+ "by_target": self.by_target,
103
+ "gaps": self.gaps,
104
+ }
105
+
106
+ def to_json(self, indent: int = 2) -> str:
107
+ return json.dumps(self.to_dict(), indent=indent, default=str)
108
+
109
+ def __len__(self) -> int:
110
+ return len(self.gaps)
111
+
112
+ def __iter__(self):
113
+ return iter(self.gaps)
114
+
115
+
116
+ def extract_gaps(
117
+ source: Source,
118
+ *,
119
+ input_format: str = "auto",
120
+ systems: Sequence[str] = ("loinc", "snomed", "rxnorm"),
121
+ contexts: Optional[Iterable[str]] = None,
122
+ include_narrative: bool = False,
123
+ apply_dict: bool = False,
124
+ ) -> GapResult:
125
+ """Extract terminology-code gaps from a clinical document.
126
+
127
+ Args:
128
+ source: path, raw bytes/str (document content), or a file-like object.
129
+ input_format: ``"auto"`` (detect), ``"ccda"``, ``"fhir"``, or ``"custom"``.
130
+ systems: which terminologies to check (subset of ``EXTRACTORS``).
131
+ contexts: optional set of statement contexts to restrict to.
132
+ include_narrative: also report NARRATIVE_ONLY gaps.
133
+ apply_dict: expand abbreviations in search terms (LOINC).
134
+
135
+ Returns:
136
+ A :class:`GapResult`.
137
+ """
138
+ unknown = [s for s in systems if s not in EXTRACTORS]
139
+ if unknown:
140
+ raise ValueError(f"unknown system(s): {unknown}; available: {sorted(EXTRACTORS)}")
141
+ if input_format not in ("auto", "ccda", "fhir", "custom"):
142
+ raise ValueError(f"invalid input_format: {input_format!r}")
143
+
144
+ statements = parse_document(_read(source)) # format auto-detected by the pipeline
145
+ ctx = set(contexts) if contexts is not None else None
146
+ tgaps = collect_gaps(statements, systems, ctx, include_narrative, apply_dict)
147
+
148
+ by_pattern: dict[str, int] = {}
149
+ by_target: dict[str, int] = {}
150
+ for r, sys_, _slot in tgaps:
151
+ by_pattern[r.gap.type] = by_pattern.get(r.gap.type, 0) + 1
152
+ by_target[sys_] = by_target.get(sys_, 0) + 1
153
+
154
+ gaps = [serialize.build_record(r, i, sys_, slot)
155
+ for i, (r, sys_, slot) in enumerate(tgaps, 1)]
156
+
157
+ return GapResult(
158
+ source_format=(statements[0].source_format if statements else None),
159
+ targets_checked=[EXTRACTORS[k][1] for k in systems],
160
+ gaps=gaps,
161
+ by_pattern=by_pattern,
162
+ by_target=by_target,
163
+ )
@@ -0,0 +1,34 @@
1
+ from .models import (
2
+ System,
3
+ CodingCandidate,
4
+ ValueInfo,
5
+ ClinicalStatement,
6
+ Gap,
7
+ NormalizedRecord,
8
+ )
9
+ from .system_registry import canonicalize, system_aliases
10
+ from .checkdigits import (
11
+ loinc_check_digit,
12
+ is_valid_loinc,
13
+ correct_loinc_check_digit,
14
+ verhoeff_ok,
15
+ is_valid_sctid,
16
+ sctid_partition,
17
+ )
18
+
19
+ __all__ = [
20
+ "System",
21
+ "CodingCandidate",
22
+ "ValueInfo",
23
+ "ClinicalStatement",
24
+ "Gap",
25
+ "NormalizedRecord",
26
+ "canonicalize",
27
+ "system_aliases",
28
+ "loinc_check_digit",
29
+ "is_valid_loinc",
30
+ "correct_loinc_check_digit",
31
+ "verhoeff_ok",
32
+ "is_valid_sctid",
33
+ "sctid_partition",
34
+ ]
@@ -0,0 +1,103 @@
1
+ """Check-digit & shape validation for code systems that support it.
2
+
3
+ - LOINC: Luhn / Mod-10 over the numeric payload (verified vs 2160-0, 718-7, …).
4
+ - SNOMED CT: Verhoeff check digit over the SCTID (verified vs 22298006, 73211009,
5
+ 386661006, 44054006, 80146002, 387517004).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ _LOINC_SHAPE = re.compile(r"^(\d{1,7})-(\d)$")
13
+ _SCTID_SHAPE = re.compile(r"^\d{6,18}$")
14
+
15
+ # Verhoeff tables (dihedral group D5) — for SNOMED SCTID check digits
16
+ _V_D = [
17
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
18
+ [2, 3, 4, 0, 1, 7, 8, 9, 5, 6], [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
19
+ [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
20
+ [6, 5, 9, 8, 7, 1, 0, 4, 3, 2], [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
21
+ [8, 7, 6, 5, 9, 3, 2, 1, 0, 4], [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
22
+ ]
23
+ _V_P = [
24
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
25
+ [5, 8, 0, 3, 7, 9, 6, 1, 4, 2], [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
26
+ [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
27
+ [2, 7, 9, 3, 8, 0, 6, 4, 1, 5], [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
28
+ ]
29
+
30
+
31
+ def loinc_check_digit(payload: str) -> int:
32
+ """Luhn / Mod-10 check digit for the numeric payload (no hyphen)."""
33
+ total = 0
34
+ for i, ch in enumerate(reversed(payload)):
35
+ d = int(ch)
36
+ if i % 2 == 0: # rightmost payload digit is doubled
37
+ d *= 2
38
+ if d > 9:
39
+ d -= 9
40
+ total += d
41
+ return (10 - (total % 10)) % 10
42
+
43
+
44
+ def is_valid_loinc(code: str) -> bool:
45
+ """Shape + check-digit validation (does NOT confirm DB existence)."""
46
+ if not code:
47
+ return False
48
+ m = _LOINC_SHAPE.match(code.strip())
49
+ if not m:
50
+ return False
51
+ payload, check = m.group(1), int(m.group(2))
52
+ return loinc_check_digit(payload) == check
53
+
54
+
55
+ def correct_loinc_check_digit(code: str) -> str | None:
56
+ """If ``code`` is LOINC-shaped but has a wrong check digit, return the
57
+ code with the corrected check digit (assuming the payload is right).
58
+ Returns None if the shape doesn't match at all.
59
+ """
60
+ if not code:
61
+ return None
62
+ m = _LOINC_SHAPE.match(code.strip())
63
+ if not m:
64
+ return None
65
+ payload = m.group(1)
66
+ return f"{payload}-{loinc_check_digit(payload)}"
67
+
68
+
69
+ # --------------------------------------------------------------------------
70
+ # SNOMED CT
71
+ # --------------------------------------------------------------------------
72
+
73
+ def verhoeff_ok(sctid: str) -> bool:
74
+ """True if the SCTID's trailing Verhoeff check digit is valid."""
75
+ c = 0
76
+ for i, ch in enumerate(reversed(sctid)):
77
+ c = _V_D[c][_V_P[i % 8][int(ch)]]
78
+ return c == 0
79
+
80
+
81
+ def sctid_partition(sctid: str) -> str | None:
82
+ """The 2-digit partition identifier (concept/description/relationship)."""
83
+ if len(sctid) < 3:
84
+ return None
85
+ return sctid[-3:-1]
86
+
87
+
88
+ def is_valid_sctid(code: str, concept_only: bool = True) -> bool:
89
+ """Shape + Verhoeff validation (does NOT confirm DB existence).
90
+
91
+ ``concept_only`` (default True): also require the partition to be a *concept*
92
+ (2nd partition digit == '0'), so description/relationship ids are rejected.
93
+ """
94
+ if not code:
95
+ return False
96
+ code = code.strip()
97
+ if not _SCTID_SHAPE.match(code) or not verhoeff_ok(code):
98
+ return False
99
+ if concept_only:
100
+ part = sctid_partition(code)
101
+ if part is None or part[1] != "0": # 2nd digit: 0=Concept,1=Desc,2=Rel
102
+ return False
103
+ return True
@@ -0,0 +1,149 @@
1
+ """Canonical intermediate representation (IR).
2
+
3
+ Every format adapter emits ``ClinicalStatement`` objects; terminology handlers
4
+ and the recovery layer consume them and produce ``NormalizedRecord`` objects.
5
+ The IR is deliberately format-agnostic: nothing here knows about XML or JSON.
6
+
7
+ See ARCHITECTURE.md §3 and LOINC_pattern_analysis.md §6/§7.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ from typing import Any, Optional
15
+
16
+
17
+ class System(str, Enum):
18
+ """Canonical code systems. Adapters map raw OIDs/URIs/names to these."""
19
+
20
+ LOINC = "LOINC"
21
+ SNOMEDCT = "SNOMED-CT"
22
+ RXNORM = "RxNorm"
23
+ ICD10CM = "ICD-10-CM"
24
+ ICD9CM = "ICD-9-CM"
25
+ ICD10PCS = "ICD-10-PCS"
26
+ CPT = "CPT"
27
+ HCPCS = "HCPCS"
28
+ NDC = "NDC"
29
+ MULTUM = "Multum" # Cerner Multum drug vocab → RxNorm
30
+ FDB = "FDB" # First DataBank / NDDF → RxNorm
31
+ MEDISPAN = "Medi-Span" # Medi-Span GPI / DDID → RxNorm
32
+ UNII = "UNII"
33
+ UCUM = "UCUM"
34
+ CVX = "CVX"
35
+ # HL7 internal vocabularies (not recovery targets, but seen on nodes)
36
+ HL7_ACTCODE = "HL7-ActCode"
37
+ HL7_OBS_INTERP = "HL7-ObservationInterpretation"
38
+ HL7_ADMIN_GENDER = "HL7-AdministrativeGender"
39
+ HL7_ACTCLASS = "HL7-ActClass" # structural: class of an act
40
+ HL7_ACTMOOD = "HL7-ActMood" # structural: mood (EVN/INT/…)
41
+ HL7_ROLECLASS = "HL7-RoleClass" # structural: role class
42
+ HL7_PARTICIPATIONTYPE = "HL7-ParticipationType" # structural: participation
43
+ NULLFLAVOR = "HL7-NullFlavor"
44
+ LOCAL = "LOCAL" # any local/source-specific system
45
+ UNKNOWN = "UNKNOWN" # system string present but unrecognized
46
+
47
+
48
+ # Value "type" mirrors the LOINC Scale axis. Adapters infer it from the
49
+ # document (CCDA xsi:type, FHIR value[x]); handlers cross-check it against
50
+ # the resolved code's Scale.
51
+ QN = "Qn" # quantitative (numeric + unit)
52
+ ORD = "Ord" # ordinal (Positive/Negative, +/++/+++)
53
+ NOM = "Nom" # nominal (a coded answer)
54
+ NAR = "Nar" # narrative free text
55
+ DOC = "Doc" # attached document
56
+
57
+
58
+ @dataclass
59
+ class CodingCandidate:
60
+ """One (system, code) pairing found on a node. A node may have several:
61
+ the root code plus CCDA ``<translation>`` children / FHIR ``coding[]``.
62
+ """
63
+
64
+ raw_system: Optional[str] = None # exactly what the document said
65
+ system: Optional[System] = None # canonicalized (None until resolved)
66
+ code: Optional[str] = None
67
+ display: Optional[str] = None
68
+ original_text: Optional[str] = None # resolved <originalText> for this code, if any
69
+ from_translation: bool = False # came from <translation>/secondary coding
70
+ user_selected: bool = False # FHIR coding.userSelected
71
+ check_digit_valid: Optional[bool] = None # set by validators where applicable
72
+
73
+ def is_system(self, system: System) -> bool:
74
+ return self.system == system
75
+
76
+
77
+ @dataclass
78
+ class ValueInfo:
79
+ type: Optional[str] = None # QN | ORD | NOM | NAR | DOC
80
+ raw: Optional[str] = None # raw textual value
81
+ num: Optional[float] = None # parsed numeric, if Qn
82
+ unit: Optional[str] = None
83
+ unit_system: Optional[str] = None # e.g. "UCUM"
84
+ coded_value: Optional[CodingCandidate] = None # the primary coded answer (root)
85
+ codings: list[CodingCandidate] = field(default_factory=list) # root + <translation>s
86
+ original_text: Optional[str] = None # the value's own resolved <originalText>
87
+
88
+
89
+ @dataclass
90
+ class ClinicalStatement:
91
+ """Format-agnostic unit of coded clinical data emitted by every adapter."""
92
+
93
+ source_format: str # ccda | fhir | custom
94
+ source_ref: str # xpath / fhir-path / json-pointer (provenance)
95
+ context: str # result | vital | problem | medication |
96
+ # survey | social_history | section | document_type
97
+ section_code: Optional[str] = None # the section's code (LOINC for CCDA, category for FHIR)
98
+ section_name: Optional[str] = None # the section's display name (e.g. "Results")
99
+ section_system: Optional[str] = None # canonical system of section_code (e.g. "LOINC")
100
+ role: str = "value" # value | question
101
+ target_system_hint: Optional[System] = None # adapter routing hint
102
+ raw_text: Optional[str] = None # best-effort concept label (display/originalText/…)
103
+ display_name: Optional[str] = None # the coded displayName (structured label)
104
+ original_text: Optional[str] = None # the clinician's free text (resolved <originalText>)
105
+ codings: list[CodingCandidate] = field(default_factory=list)
106
+ value: Optional[ValueInfo] = None
107
+ effective_time: Optional[str] = None
108
+ panel: Optional[CodingCandidate] = None # parent organizer / panel code
109
+ companion: dict[str, Any] = field(default_factory=dict) # interpretation, ref range, specimen…
110
+
111
+ # --- convenience for downstream layers -------------------------------
112
+ def coding_for(self, system: System) -> Optional[CodingCandidate]:
113
+ """Return the first validated coding for ``system`` (root preferred)."""
114
+ hits = [c for c in self.codings if c.system == system]
115
+ if not hits:
116
+ return None
117
+ roots = [c for c in hits if not c.from_translation]
118
+ return roots[0] if roots else hits[0]
119
+
120
+ def has_any_code(self) -> bool:
121
+ return any(c.code for c in self.codings)
122
+
123
+
124
+ @dataclass
125
+ class Gap:
126
+ """Why a code is missing/untrustworthy — the handoff to the recovery layer.
127
+
128
+ type is one of: NO_CODE_TEXT_PRESENT | LOCAL_CODE_ONLY | NULLFLAVOR |
129
+ NARRATIVE_ONLY | BAD_CODE | DEPRECATED | AXIS_MISMATCH | AMBIGUOUS
130
+ """
131
+
132
+ type: str
133
+ match_key: Optional[str] = None
134
+ candidates: list[Any] = field(default_factory=list) # DB candidate set for recovery
135
+ evidence: dict[str, Any] = field(default_factory=dict) # recovery evidence packet
136
+ note: Optional[str] = None
137
+
138
+
139
+ @dataclass
140
+ class NormalizedRecord:
141
+ """Final output: a resolved (or gap-flagged) statement."""
142
+
143
+ statement: ClinicalStatement
144
+ resolved_system: Optional[System] = None
145
+ resolved_code: Optional[str] = None
146
+ resolved_display: Optional[str] = None
147
+ source: Optional[str] = None # db_exact | db_translation | db_candidate+llm | llm_only
148
+ confidence: Optional[float] = None
149
+ gap: Optional[Gap] = None
@@ -0,0 +1,185 @@
1
+ """Single source of truth for code-system identity.
2
+
3
+ Maps every alias a document might use — HL7 V3 OID, FHIR URI, ``urn:oid:`` form,
4
+ or free-text name — to a canonical ``System`` enum. Adapters canonicalize here;
5
+ handlers match on the enum only.
6
+
7
+ Rule (ARCHITECTURE.md §7): identify by system, never by code shape or display
8
+ name alone. ``canonicalize`` returns ``System.UNKNOWN`` when a system string is
9
+ present but unrecognized, and ``None`` when no system string was given at all.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Optional
15
+
16
+ from .models import System
17
+
18
+ # OID (HL7 V3 / CCDA codeSystem) -> System
19
+ _OID = {
20
+ "2.16.840.1.113883.6.1": System.LOINC,
21
+ "2.16.840.1.113883.6.96": System.SNOMEDCT,
22
+ "2.16.840.1.113883.6.88": System.RXNORM,
23
+ "2.16.840.1.113883.6.90": System.ICD10CM,
24
+ "2.16.840.1.113883.6.103": System.ICD9CM, # ICD-9-CM diagnosis
25
+ "2.16.840.1.113883.6.104": System.ICD9CM, # ICD-9-CM procedure
26
+ "2.16.840.1.113883.6.4": System.ICD10PCS,
27
+ "2.16.840.1.113883.6.12": System.CPT,
28
+ "2.16.840.1.113883.6.285": System.HCPCS,
29
+ "2.16.840.1.113883.6.69": System.NDC,
30
+ # Cerner Multum drug vocabulary
31
+ "2.16.840.1.113883.6.314": System.MULTUM, # Multum drug id
32
+ "2.16.840.1.113883.6.312": System.MULTUM, # Multum drug synonym id
33
+ "2.16.840.1.113883.6.311": System.MULTUM, # Multum main drug code
34
+ "2.16.840.1.113883.6.27": System.MULTUM, # Multum
35
+ # First DataBank / NDDF
36
+ "2.16.840.1.113883.6.208": System.FDB, # NDDF
37
+ "2.16.840.1.113883.6.64": System.FDB, # First DataBank
38
+ "2.16.840.1.113883.4.65": System.FDB, # FDB HIC_SEQN
39
+ # Medi-Span
40
+ "2.16.840.1.113883.6.68": System.MEDISPAN, # Medi-Span GPI
41
+ "2.16.840.1.113883.6.253": System.MEDISPAN, # Medi-Span Drug Descriptor ID (MDDID)
42
+ "2.16.840.1.113883.6.162": System.MEDISPAN, # Med-File
43
+ "2.16.840.1.113883.6.65": System.MEDISPAN, # Medispan
44
+ "2.16.840.1.113883.4.9": System.UNII,
45
+ "2.16.840.1.113883.6.8": System.UCUM,
46
+ "2.16.840.1.113883.12.292": System.CVX,
47
+ "2.16.840.1.113883.5.4": System.HL7_ACTCODE,
48
+ "2.16.840.1.113883.5.6": System.HL7_ACTCLASS, # ActClass (structural)
49
+ "2.16.840.1.113883.5.1001": System.HL7_ACTMOOD, # ActMood (structural)
50
+ "2.16.840.1.113883.5.110": System.HL7_ROLECLASS, # RoleClass (structural)
51
+ "2.16.840.1.113883.5.90": System.HL7_PARTICIPATIONTYPE, # ParticipationType (structural)
52
+ "2.16.840.1.113883.5.83": System.HL7_OBS_INTERP,
53
+ "2.16.840.1.113883.5.1": System.HL7_ADMIN_GENDER,
54
+ "2.16.840.1.113883.5.1008": System.NULLFLAVOR,
55
+ }
56
+
57
+ # FHIR URI -> System (store canonical, lowercase, no trailing slash)
58
+ _URI = {
59
+ "http://loinc.org": System.LOINC,
60
+ "http://snomed.info/sct": System.SNOMEDCT,
61
+ "http://www.nlm.nih.gov/research/umls/rxnorm": System.RXNORM,
62
+ "http://hl7.org/fhir/sid/icd-10-cm": System.ICD10CM,
63
+ "http://hl7.org/fhir/sid/icd-10": System.ICD10CM,
64
+ "http://hl7.org/fhir/sid/icd-9-cm": System.ICD9CM,
65
+ "http://hl7.org/fhir/sid/icd-9-cm/diagnosis": System.ICD9CM,
66
+ "http://hl7.org/fhir/sid/icd-9-cm/procedure": System.ICD9CM,
67
+ "http://www.cms.gov/medicare/coding/icd10": System.ICD10PCS,
68
+ "http://www.icd10data.com/icd10pcs": System.ICD10PCS,
69
+ "http://www.ama-assn.org/go/cpt": System.CPT,
70
+ "urn:oid:2.16.840.1.113883.6.285": System.HCPCS,
71
+ "http://www.nlm.nih.gov/research/umls/hcpcs": System.HCPCS,
72
+ "http://www.cms.gov/medicare/coding/hcpcsreleasecodesets": System.HCPCS,
73
+ "https://terminology.hl7.org/codesystem/hcpcs-all-codes": System.HCPCS,
74
+ "http://hl7.org/fhir/sid/ndc": System.NDC,
75
+ "http://fdasis.nlm.nih.gov": System.UNII,
76
+ "http://unitsofmeasure.org": System.UCUM,
77
+ "http://hl7.org/fhir/sid/cvx": System.CVX,
78
+ # HL7 terminology.hl7.org forms (FHIR R4). The v3-* vocabularies are
79
+ # structural — mapping them here keeps them out of LOCAL_CODE_ONLY gaps.
80
+ "http://terminology.hl7.org/codesystem/v3-actcode": System.HL7_ACTCODE,
81
+ "http://terminology.hl7.org/codesystem/v3-actclass": System.HL7_ACTCLASS,
82
+ "http://terminology.hl7.org/codesystem/v3-actmood": System.HL7_ACTMOOD,
83
+ "http://terminology.hl7.org/codesystem/v3-roleclass": System.HL7_ROLECLASS,
84
+ "http://terminology.hl7.org/codesystem/v3-participationtype": System.HL7_PARTICIPATIONTYPE,
85
+ "http://terminology.hl7.org/codesystem/v3-observationinterpretation": System.HL7_OBS_INTERP,
86
+ "http://terminology.hl7.org/codesystem/v3-administrativegender": System.HL7_ADMIN_GENDER,
87
+ # FHIR's nullFlavor analog: dataAbsentReason codings
88
+ "http://terminology.hl7.org/codesystem/data-absent-reason": System.NULLFLAVOR,
89
+ }
90
+
91
+ # Free-text name (codeSystemName / custom "code_system") -> System.
92
+ # Matched case-insensitively after stripping punctuation/whitespace.
93
+ _NAME = {
94
+ "loinc": System.LOINC,
95
+ "snomed": System.SNOMEDCT,
96
+ "snomedct": System.SNOMEDCT,
97
+ "snomedctus": System.SNOMEDCT,
98
+ "rxnorm": System.RXNORM,
99
+ "icd10cm": System.ICD10CM,
100
+ "icd10": System.ICD10CM,
101
+ "icd9cm": System.ICD9CM,
102
+ "icd9": System.ICD9CM,
103
+ "icd10pcs": System.ICD10PCS,
104
+ "cpt": System.CPT,
105
+ "cpt4": System.CPT,
106
+ "hcpcs": System.HCPCS,
107
+ "ndc": System.NDC,
108
+ "multum": System.MULTUM,
109
+ "multumgenericdrug": System.MULTUM,
110
+ "multumgenericdrugidentifier": System.MULTUM,
111
+ "multumdrugid": System.MULTUM,
112
+ "nddf": System.FDB,
113
+ "firstdatabank": System.FDB,
114
+ "fdb": System.FDB,
115
+ "medispan": System.MEDISPAN,
116
+ "medispangpi": System.MEDISPAN,
117
+ "gpi": System.MEDISPAN,
118
+ "mddid": System.MEDISPAN,
119
+ "medfile": System.MEDISPAN,
120
+ "unii": System.UNII,
121
+ "ucum": System.UCUM,
122
+ "cvx": System.CVX,
123
+ }
124
+
125
+ _URN_OID_PREFIX = "urn:oid:"
126
+
127
+ # HL7 V3 structural vocabularies — present on nodes but never clinical recovery
128
+ # targets (the real concept lives elsewhere, usually in <value>). Handlers exclude
129
+ # these so a structural code (ASSERTION / ActClass / ActMood …) is not mistaken
130
+ # for a local concept gap. Single source of truth; handlers import this.
131
+ HL7_STRUCTURAL: frozenset = frozenset({
132
+ System.HL7_ACTCODE,
133
+ System.HL7_OBS_INTERP,
134
+ System.HL7_ADMIN_GENDER,
135
+ System.HL7_ACTCLASS,
136
+ System.HL7_ACTMOOD,
137
+ System.HL7_ROLECLASS,
138
+ System.HL7_PARTICIPATIONTYPE,
139
+ })
140
+
141
+
142
+ def _norm_name(s: str) -> str:
143
+ return "".join(ch for ch in s.lower() if ch.isalnum())
144
+
145
+
146
+ def canonicalize(raw_system: Optional[str]) -> Optional[System]:
147
+ """Resolve any raw system identifier to a canonical ``System``.
148
+
149
+ Returns ``None`` if ``raw_system`` is falsy (no system asserted),
150
+ ``System.UNKNOWN`` if a value is present but unrecognized.
151
+ """
152
+ if not raw_system:
153
+ return None
154
+ s = raw_system.strip()
155
+
156
+ # urn:oid: form (FHIR may carry an OID this way). Unrecognized deep OIDs
157
+ # (e.g. Epic 1.2.840.114350.…) are LOCAL, same as the bare-OID branch.
158
+ if s.lower().startswith(_URN_OID_PREFIX):
159
+ oid = s[len(_URN_OID_PREFIX):]
160
+ return _OID.get(oid, System.LOCAL if oid.count(".") > 4 else System.UNKNOWN)
161
+
162
+ # bare OID
163
+ if _looks_like_oid(s):
164
+ return _OID.get(s, System.LOCAL if s.count(".") > 4 else System.UNKNOWN)
165
+
166
+ # URI (normalize: lowercase scheme/host, strip trailing slash)
167
+ if "://" in s:
168
+ return _URI.get(s.rstrip("/").lower(), System.UNKNOWN)
169
+
170
+ # free-text name
171
+ return _NAME.get(_norm_name(s), System.UNKNOWN)
172
+
173
+
174
+ def _looks_like_oid(s: str) -> bool:
175
+ parts = s.split(".")
176
+ return len(parts) >= 3 and all(p.isdigit() for p in parts)
177
+
178
+
179
+ def system_aliases(system: System) -> dict[str, list[str]]:
180
+ """Reverse lookup — useful for tests/diagnostics."""
181
+ return {
182
+ "oids": [k for k, v in _OID.items() if v == system],
183
+ "uris": [k for k, v in _URI.items() if v == system],
184
+ "names": [k for k, v in _NAME.items() if v == system],
185
+ }
@@ -0,0 +1,3 @@
1
+ from .orchestrator import parse_document, detect_adapter
2
+
3
+ __all__ = ["parse_document", "detect_adapter"]
@@ -0,0 +1,33 @@
1
+ """Pipeline entry point (parsing phase).
2
+
3
+ Detects the input format and runs the matching adapter to produce the IR.
4
+ Routing to terminology handlers + recovery is stubbed until those layers land.
5
+ See ARCHITECTURE.md §2/§5.6.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ from ..adapters.base import FormatAdapter
13
+ from ..adapters.ccda import CcdaAdapter
14
+ from ..adapters.fhir import FhirAdapter
15
+ from ..core.models import ClinicalStatement
16
+
17
+ # Detection order matters: CCDA (XML) and FHIR (JSON) are mutually exclusive.
18
+ _ADAPTERS: list[FormatAdapter] = [CcdaAdapter(), FhirAdapter()]
19
+
20
+
21
+ def detect_adapter(document: str | bytes) -> Optional[FormatAdapter]:
22
+ for adapter in _ADAPTERS:
23
+ if adapter.detect(document):
24
+ return adapter
25
+ return None
26
+
27
+
28
+ def parse_document(document: str | bytes) -> list[ClinicalStatement]:
29
+ """Detect format and return the parsed IR statements."""
30
+ adapter = detect_adapter(document)
31
+ if adapter is None:
32
+ raise ValueError("Unrecognized document format (no adapter matched).")
33
+ return list(adapter.parse(document))
@@ -0,0 +1,3 @@
1
+ from .engine import RecoveryEngine
2
+
3
+ __all__ = ["RecoveryEngine"]