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
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,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))
|