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,16 @@
|
|
|
1
|
+
"""LLM recovery engine — PLACEHOLDER.
|
|
2
|
+
|
|
3
|
+
Activated only on a Gap. Pattern: DB returns a candidate set from the match
|
|
4
|
+
key -> LLM selects/ranks within that set (constraints forbid out-of-set
|
|
5
|
+
output) -> emits resolved_* + confidence + source. Free generation is the last
|
|
6
|
+
resort. See ARCHITECTURE.md §5.5.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ..core.models import ClinicalStatement, Gap, NormalizedRecord
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RecoveryEngine:
|
|
15
|
+
def recover(self, stmt: ClinicalStatement, gap: Gap, db) -> NormalizedRecord:
|
|
16
|
+
raise NotImplementedError("LLM layer is a placeholder (parsing-first phase).")
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Serialize a gap (NormalizedRecord) into the target-tagged output structure.
|
|
2
|
+
|
|
3
|
+
One self-contained record per (observation, target). Four concerns are kept
|
|
4
|
+
separate: the facts (`observation`), the classification (`target`/`pattern`),
|
|
5
|
+
the plan (`recovery`), and the answer (`resolution`, filled later).
|
|
6
|
+
See docs/GAP_SCHEMA.md.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from .core.models import ClinicalStatement, NormalizedRecord, System
|
|
14
|
+
from .systems.loinc import axes as loinc_axes
|
|
15
|
+
from .systems.snomed import qualifiers as snomed_q
|
|
16
|
+
|
|
17
|
+
_LOCATION_TYPE = {"ccda": "xpath", "fhir": "fhirpath", "custom": "json-pointer"}
|
|
18
|
+
_CODED_TYPES = {"Ord", "Nom"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --------------------------------------------------------------------------
|
|
22
|
+
# observation (the facts) — system-agnostic
|
|
23
|
+
# --------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def _coding_dict(c) -> dict:
|
|
26
|
+
d = {
|
|
27
|
+
"code": c.code,
|
|
28
|
+
"system": (c.system.value if c.system else None),
|
|
29
|
+
"system_raw": c.raw_system,
|
|
30
|
+
"display": c.display,
|
|
31
|
+
}
|
|
32
|
+
if c.original_text:
|
|
33
|
+
d["original_text"] = c.original_text
|
|
34
|
+
if c.check_digit_valid is not None:
|
|
35
|
+
d["valid"] = c.check_digit_valid
|
|
36
|
+
return d
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _code_block(stmt: ClinicalStatement) -> dict:
|
|
40
|
+
absent = None
|
|
41
|
+
codings = []
|
|
42
|
+
for c in stmt.codings:
|
|
43
|
+
if c.system == System.NULLFLAVOR:
|
|
44
|
+
absent = c.raw_system
|
|
45
|
+
continue
|
|
46
|
+
codings.append(_coding_dict(c))
|
|
47
|
+
return {
|
|
48
|
+
"display_text": stmt.display_name,
|
|
49
|
+
"original_text": stmt.original_text,
|
|
50
|
+
"absent_reason": absent,
|
|
51
|
+
"codings": codings,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _value_block(stmt: ClinicalStatement) -> Optional[dict]:
|
|
56
|
+
v = stmt.value
|
|
57
|
+
if v is None:
|
|
58
|
+
return None
|
|
59
|
+
cv = v.coded_value
|
|
60
|
+
absent = cv.raw_system if (cv and cv.system == System.NULLFLAVOR) else None
|
|
61
|
+
codings = []
|
|
62
|
+
if cv and cv.code and cv.system != System.NULLFLAVOR:
|
|
63
|
+
codings.append(_coding_dict(cv))
|
|
64
|
+
|
|
65
|
+
if v.num is not None:
|
|
66
|
+
vtype = "quantity"
|
|
67
|
+
elif v.type in _CODED_TYPES:
|
|
68
|
+
vtype = "coded"
|
|
69
|
+
elif v.type == "Nar" or v.raw:
|
|
70
|
+
vtype = "text"
|
|
71
|
+
else:
|
|
72
|
+
vtype = "none"
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"type": vtype,
|
|
76
|
+
"number": v.num,
|
|
77
|
+
"unit": v.unit,
|
|
78
|
+
"display_text": (cv.display if cv else None),
|
|
79
|
+
"original_text": v.original_text,
|
|
80
|
+
"absent_reason": absent,
|
|
81
|
+
"codings": codings,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _qualifiers(stmt: ClinicalStatement) -> dict:
|
|
86
|
+
spec_hint, spec_src, spec_conf = loinc_axes.infer_specimen(stmt)
|
|
87
|
+
unit = stmt.value.unit if stmt.value else None
|
|
88
|
+
q = snomed_q.extract_qualifiers(stmt, stmt.raw_text)
|
|
89
|
+
out = {
|
|
90
|
+
"specimen": {"value": spec_hint, "source": spec_src, "confidence": spec_conf},
|
|
91
|
+
"property": loinc_axes.property_from_unit(unit),
|
|
92
|
+
"scale": loinc_axes.scale_from_value(stmt),
|
|
93
|
+
"laterality": q["laterality"],
|
|
94
|
+
"severity": q["severity"],
|
|
95
|
+
"course": q["course"],
|
|
96
|
+
# structural negationInd wins over text cues ("denies", "no known")
|
|
97
|
+
"negation": ("negated" if stmt.companion.get("negation") else q["negation_context"]),
|
|
98
|
+
"panel": (stmt.panel.code if stmt.panel else None),
|
|
99
|
+
"effective_time": stmt.effective_time,
|
|
100
|
+
}
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _payload_type(stmt: ClinicalStatement) -> str:
|
|
105
|
+
v = stmt.value
|
|
106
|
+
if v and (v.type in _CODED_TYPES or (v.coded_value is not None)):
|
|
107
|
+
return "qa"
|
|
108
|
+
return "codeable"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --------------------------------------------------------------------------
|
|
112
|
+
# recovery (the plan) — depends on the target slot
|
|
113
|
+
# --------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _dedupe(items) -> list:
|
|
116
|
+
seen, out = set(), []
|
|
117
|
+
for x in items:
|
|
118
|
+
if x and x not in seen:
|
|
119
|
+
seen.add(x)
|
|
120
|
+
out.append(x)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# SNOMED search-term enrichment: sections whose gaps also get the CODE-slot
|
|
125
|
+
# labels appended (Problems 11450-4, Allergies 48765-2, Social History 29762-2),
|
|
126
|
+
# matched by section LOINC or by the contexts those sections route to
|
|
127
|
+
# (FHIR resources often carry no meta.tag/section code).
|
|
128
|
+
_SNOMED_LABEL_SECTIONS = {"11450-4", "48765-2", "29762-2"}
|
|
129
|
+
_SNOMED_LABEL_CONTEXTS = {"problem", "allergy_substance", "allergy_reaction",
|
|
130
|
+
"social_history"}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _recovery(record: NormalizedRecord, slot: str, system: str) -> dict:
|
|
134
|
+
stmt, ev, g = record.statement, record.gap.evidence, record.gap
|
|
135
|
+
|
|
136
|
+
# search terms come from the targeted slot's labels
|
|
137
|
+
if slot == "value" and stmt.value:
|
|
138
|
+
terms = [stmt.value.coded_value.display if stmt.value.coded_value else None,
|
|
139
|
+
stmt.value.original_text]
|
|
140
|
+
else: # code slot
|
|
141
|
+
terms = [stmt.display_name, stmt.original_text]
|
|
142
|
+
# add any normalized term the handler produced
|
|
143
|
+
terms += [ev.get("analyte_normalized"), ev.get("term_normalized"), ev.get("ingredient")]
|
|
144
|
+
# SNOMED only, Problems/Allergies/Social History only: append the CODE
|
|
145
|
+
# tag's displayName/originalText (never the value's) as trailing context —
|
|
146
|
+
# e.g. the question label when the value label is a placeholder.
|
|
147
|
+
if system == System.SNOMEDCT.value and (
|
|
148
|
+
stmt.section_code in _SNOMED_LABEL_SECTIONS
|
|
149
|
+
or stmt.context in _SNOMED_LABEL_CONTEXTS):
|
|
150
|
+
terms += [stmt.display_name, stmt.original_text]
|
|
151
|
+
|
|
152
|
+
rec = {
|
|
153
|
+
"search_terms": _dedupe(terms),
|
|
154
|
+
"match_key": g.match_key,
|
|
155
|
+
"suggested_action": g.note or _ACTION.get(g.type, "resolve via DB + LLM"),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# filters / ecl (system-specific)
|
|
159
|
+
if ev.get("ecl"): # SNOMED
|
|
160
|
+
rec["ecl"] = ev["ecl"]
|
|
161
|
+
rec["filters"] = {"target_hierarchy": ev.get("target_hierarchy")}
|
|
162
|
+
elif ev.get("target_tty"): # RxNorm
|
|
163
|
+
rec["filters"] = {
|
|
164
|
+
"ingredient": ev.get("ingredient"),
|
|
165
|
+
"strength": ev.get("strength"),
|
|
166
|
+
"dose_form": ev.get("dose_form"),
|
|
167
|
+
"route": ev.get("route"),
|
|
168
|
+
"target_tty": ev.get("target_tty"),
|
|
169
|
+
}
|
|
170
|
+
elif any(ev.get(k) for k in ("laterality", "encounter", "complication")): # ICD-10-CM
|
|
171
|
+
rec["filters"] = {
|
|
172
|
+
"laterality": ev.get("laterality"),
|
|
173
|
+
"encounter": ev.get("encounter"),
|
|
174
|
+
"complication": ev.get("complication"),
|
|
175
|
+
}
|
|
176
|
+
elif any(ev.get(k) for k in ("specimen_hint", "property_hint", "scale_hint")): # LOINC
|
|
177
|
+
# Hard-filter only on axes we trust (confidence >= medium). Low-confidence
|
|
178
|
+
# specimen/property stay as soft hints (below) so a shaky guess never
|
|
179
|
+
# forces the wrong code — the failure mode behind the Bld/Ser-Plas and
|
|
180
|
+
# %/MFr bugs. Scale (from xsi:type) is reliable, so it always filters.
|
|
181
|
+
_FILTER_OK = {"high", "medium"}
|
|
182
|
+
rec["filters"] = {
|
|
183
|
+
"specimen": ev.get("specimen_hint") if ev.get("specimen_confidence") in _FILTER_OK else None,
|
|
184
|
+
"property": ev.get("property_hint") if ev.get("property_confidence") in _FILTER_OK else None,
|
|
185
|
+
"scale": ev.get("scale_hint"),
|
|
186
|
+
}
|
|
187
|
+
# soft hints: keep the low-confidence guesses visible for LLM ranking
|
|
188
|
+
rec["hints"] = {
|
|
189
|
+
"specimen": {"value": ev.get("specimen_hint"), "confidence": ev.get("specimen_confidence")},
|
|
190
|
+
"property": {"value": ev.get("property_hint"), "confidence": ev.get("property_confidence")},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# crosswalk hint (local / ICD / NDC code present on the targeted slot)
|
|
194
|
+
if ev.get("local_code"):
|
|
195
|
+
rec["crosswalk"] = {"from_system": ev.get("local_system"), "from_code": ev["local_code"]}
|
|
196
|
+
elif ev.get("icd_code"):
|
|
197
|
+
rec["crosswalk"] = {"from_system": ev.get("icd_system"), "from_code": ev["icd_code"]}
|
|
198
|
+
elif ev.get("ndc_code"):
|
|
199
|
+
rec["crosswalk"] = {"from_system": ev.get("ndc_system"), "from_code": ev["ndc_code"]}
|
|
200
|
+
elif ev.get("snomed_code"):
|
|
201
|
+
rec["crosswalk"] = {"from_system": ev.get("snomed_system"), "from_code": ev["snomed_code"]}
|
|
202
|
+
elif ev.get("rxnorm_code"):
|
|
203
|
+
rec["crosswalk"] = {"from_system": ev.get("rxnorm_system"), "from_code": ev["rxnorm_code"]}
|
|
204
|
+
if ev.get("checkdigit_correction"):
|
|
205
|
+
rec["checkdigit_correction"] = ev["checkdigit_correction"]
|
|
206
|
+
return rec
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_ACTION = {
|
|
210
|
+
"LOCAL_CODE_ONLY": "crosswalk local code → target; else text + filter search",
|
|
211
|
+
"NDC_ONLY": "use the NDC↔RxNorm crosswalk; LLM only if 1→many RxCUIs",
|
|
212
|
+
"MULTUM_ONLY": "crosswalk Multum → RxNorm via UMLS CUI; LLM only if ambiguous",
|
|
213
|
+
"FDB_ONLY": "crosswalk FDB/NDDF → RxNorm via UMLS CUI; LLM only if ambiguous",
|
|
214
|
+
"MEDISPAN_ONLY": "crosswalk Medi-Span (GPI/DDID) → RxNorm via UMLS CUI; LLM only if ambiguous",
|
|
215
|
+
"RXNORM_ONLY": "use the CDC/NLM CVX↔RxNorm map; LLM only if 1→many valences",
|
|
216
|
+
"ICD_ONLY": "use the official ICD↔SNOMED map; LLM only to choose among targets",
|
|
217
|
+
"NULLFLAVOR": "map recovered label → target via text + filter search",
|
|
218
|
+
"DISPLAY_ONLY": "map display → target via text + filter search",
|
|
219
|
+
"NARRATIVE_ONLY": "narrative text; resolve via text + filter search (low priority)",
|
|
220
|
+
"BAD_CODE": "apply check-digit correction / re-resolve via text + filter",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# --------------------------------------------------------------------------
|
|
225
|
+
# top-level record
|
|
226
|
+
# --------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def build_record(record: NormalizedRecord, idx: int,
|
|
229
|
+
target_system: str, target_slot: str) -> dict:
|
|
230
|
+
stmt = record.statement
|
|
231
|
+
return {
|
|
232
|
+
"id": idx,
|
|
233
|
+
"target": {"system": target_system, "slot": target_slot},
|
|
234
|
+
"pattern": record.gap.type,
|
|
235
|
+
"payload_type": _payload_type(stmt),
|
|
236
|
+
"section": {
|
|
237
|
+
"code": stmt.section_code,
|
|
238
|
+
"name": stmt.section_name,
|
|
239
|
+
"system": stmt.section_system,
|
|
240
|
+
},
|
|
241
|
+
"observation": {
|
|
242
|
+
"code": _code_block(stmt),
|
|
243
|
+
"value": _value_block(stmt),
|
|
244
|
+
"qualifiers": _qualifiers(stmt),
|
|
245
|
+
},
|
|
246
|
+
"recovery": _recovery(record, target_slot, target_system),
|
|
247
|
+
"provenance": {
|
|
248
|
+
"location": {
|
|
249
|
+
"type": _LOCATION_TYPE.get(stmt.source_format, "path"),
|
|
250
|
+
"path": stmt.source_ref,
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
"resolution": {
|
|
254
|
+
"status": "pending", "code": None, "display": None,
|
|
255
|
+
"system": None, "confidence": None, "source": None,
|
|
256
|
+
},
|
|
257
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""TerminologyHandler contract — PLACEHOLDER axis.
|
|
2
|
+
|
|
3
|
+
A handler knows ONE code system and operates only on the IR. Validation,
|
|
4
|
+
normalization, disambiguation, and gap detection live here. Implementations
|
|
5
|
+
(loinc/, snomed/, …) will be added after the parsing layer is solid.
|
|
6
|
+
See ARCHITECTURE.md §4/§5.3.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from ..core.models import (
|
|
15
|
+
ClinicalStatement,
|
|
16
|
+
CodingCandidate,
|
|
17
|
+
NormalizedRecord,
|
|
18
|
+
System,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TerminologyHandler(ABC):
|
|
23
|
+
system: System
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def applies_to(self, stmt: ClinicalStatement) -> bool:
|
|
27
|
+
"""True if this handler should resolve the statement (system + context)."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def validate(self, coding: CodingCandidate) -> bool:
|
|
31
|
+
"""Shape + check digit + DB existence."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def normalize(self, stmt: ClinicalStatement) -> Optional[CodingCandidate]:
|
|
35
|
+
"""Pick the canonical coding from stmt.codings (scan-and-identify)."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def build_match_key(self, stmt: ClinicalStatement) -> str:
|
|
39
|
+
"""Composite disambiguation key (component + specimen + property + …)."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def resolve(self, stmt: ClinicalStatement, db) -> NormalizedRecord:
|
|
43
|
+
"""Full pipeline incl. gap classification + recovery handoff."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""CVX gap extraction (immunizations).
|
|
2
|
+
|
|
3
|
+
The vaccine concept lives on the code slot (CCDA manufacturedMaterial/code under
|
|
4
|
+
an immunization act; FHIR Immunization.vaccineCode). CVX has NO check digit — it
|
|
5
|
+
is a short numeric code, so a present, numeric CVX is treated as resolved here.
|
|
6
|
+
The dominant recovery paths are the CDC CVX↔RxNorm and vaccine NDC↔CVX crosswalks
|
|
7
|
+
(both free/deterministic). DB lookup and LLM ranking are placeholders.
|
|
8
|
+
See CVX_pattern_analysis.md.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .handler import CvxGapExtractor, CvxGapPattern
|
|
12
|
+
from .patterns import classify, is_cvx_expected, is_valid_cvx
|
|
13
|
+
|
|
14
|
+
__all__ = ["CvxGapExtractor", "CvxGapPattern", "classify",
|
|
15
|
+
"is_cvx_expected", "is_valid_cvx"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""CvxGapExtractor — CVX-only, gaps-only entry point (immunizations)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Iterable, Optional
|
|
6
|
+
|
|
7
|
+
from ...core.models import ClinicalStatement, NormalizedRecord, System
|
|
8
|
+
from ...pipeline import parse_document
|
|
9
|
+
from . import patterns
|
|
10
|
+
from .patterns import CvxGapPattern
|
|
11
|
+
|
|
12
|
+
__all__ = ["CvxGapExtractor", "CvxGapPattern"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CvxGapExtractor:
|
|
16
|
+
def extract(
|
|
17
|
+
self,
|
|
18
|
+
statements: Iterable[ClinicalStatement],
|
|
19
|
+
contexts: Optional[set[str]] = None,
|
|
20
|
+
include_narrative: bool = False,
|
|
21
|
+
apply_dict: bool = False, # accepted for CLI parity; unused
|
|
22
|
+
) -> list[NormalizedRecord]:
|
|
23
|
+
records: list[NormalizedRecord] = []
|
|
24
|
+
for stmt in statements:
|
|
25
|
+
if contexts is not None and stmt.context not in contexts:
|
|
26
|
+
continue
|
|
27
|
+
pattern = patterns.classify(stmt)
|
|
28
|
+
if pattern is None:
|
|
29
|
+
continue
|
|
30
|
+
if pattern is CvxGapPattern.NARRATIVE_ONLY and not include_narrative:
|
|
31
|
+
continue
|
|
32
|
+
records.append(NormalizedRecord(
|
|
33
|
+
statement=stmt,
|
|
34
|
+
resolved_system=System.CVX,
|
|
35
|
+
resolved_code=None,
|
|
36
|
+
gap=patterns.parse_gap(stmt, pattern),
|
|
37
|
+
))
|
|
38
|
+
return records
|
|
39
|
+
|
|
40
|
+
def extract_from_document(
|
|
41
|
+
self,
|
|
42
|
+
document: str | bytes,
|
|
43
|
+
contexts: Optional[set[str]] = None,
|
|
44
|
+
include_narrative: bool = False,
|
|
45
|
+
apply_dict: bool = False,
|
|
46
|
+
) -> list[NormalizedRecord]:
|
|
47
|
+
return self.extract(parse_document(document), contexts=contexts,
|
|
48
|
+
include_narrative=include_narrative)
|
|
49
|
+
|
|
50
|
+
def summary(self, records: list[NormalizedRecord]) -> dict[str, int]:
|
|
51
|
+
out: dict[str, int] = {}
|
|
52
|
+
for r in records:
|
|
53
|
+
t = r.gap.type if r.gap else "RESOLVED"
|
|
54
|
+
out[t] = out.get(t, 0) + 1
|
|
55
|
+
return out
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Parse the disambiguating attributes from an immunization label.
|
|
2
|
+
|
|
3
|
+
CVX's analog of the RxNorm axes is much shallower: the vaccine *concept*
|
|
4
|
+
(antigen / target disease) plus, where present, valence/formulation hints
|
|
5
|
+
(e.g. "quadrivalent", "high-dose", "PCV13"). The dominant recovery key is the
|
|
6
|
+
normalized vaccine name; CDC publishes deterministic CVX↔RxNorm and CVX↔NDC
|
|
7
|
+
crosswalks, so a coded RxNorm/NDC almost always resolves a CVX without search.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
_WS = re.compile(r"\s+")
|
|
16
|
+
|
|
17
|
+
# keyword -> normalized vaccine concept (longest/most-specific first wins)
|
|
18
|
+
_VACCINE_KEYWORDS = [
|
|
19
|
+
("covid-19", "COVID-19"),
|
|
20
|
+
("covid", "COVID-19"),
|
|
21
|
+
("sars-cov-2", "COVID-19"),
|
|
22
|
+
("influenza", "influenza"),
|
|
23
|
+
("flu", "influenza"),
|
|
24
|
+
("hepatitis b", "hepatitis B"),
|
|
25
|
+
("hep b", "hepatitis B"),
|
|
26
|
+
("hepatitis a", "hepatitis A"),
|
|
27
|
+
("hep a", "hepatitis A"),
|
|
28
|
+
("tdap", "Tdap"),
|
|
29
|
+
("dtap", "DTaP"),
|
|
30
|
+
("td ", "Td"),
|
|
31
|
+
("mmr", "MMR"),
|
|
32
|
+
("measles", "MMR"),
|
|
33
|
+
("varicella", "varicella"),
|
|
34
|
+
("zoster", "zoster"),
|
|
35
|
+
("shingles", "zoster"),
|
|
36
|
+
("pneumococcal", "pneumococcal"),
|
|
37
|
+
("pcv", "pneumococcal"),
|
|
38
|
+
("ppsv", "pneumococcal"),
|
|
39
|
+
("hpv", "HPV"),
|
|
40
|
+
("human papillomavirus", "HPV"),
|
|
41
|
+
("meningococcal", "meningococcal"),
|
|
42
|
+
("menacwy", "meningococcal"),
|
|
43
|
+
("polio", "polio"),
|
|
44
|
+
("ipv", "polio"),
|
|
45
|
+
("rsv", "RSV"),
|
|
46
|
+
("rotavirus", "rotavirus"),
|
|
47
|
+
("hib", "Hib"),
|
|
48
|
+
("haemophilus", "Hib"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# valence / formulation modifiers worth carrying into the match key
|
|
52
|
+
_FORMULATION = [
|
|
53
|
+
"quadrivalent", "trivalent", "high-dose", "high dose", "adjuvanted",
|
|
54
|
+
"recombinant", "live", "inactivated", "preservative-free", "pcv13",
|
|
55
|
+
"pcv15", "pcv20", "ppsv23", "bivalent",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_vaccine(text: Optional[str]) -> Optional[str]:
|
|
60
|
+
"""Normalized vaccine concept (target disease), e.g. 'influenza'."""
|
|
61
|
+
if not text:
|
|
62
|
+
return None
|
|
63
|
+
t = text.lower()
|
|
64
|
+
for kw, concept in _VACCINE_KEYWORDS:
|
|
65
|
+
if kw in t:
|
|
66
|
+
return concept
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_formulation(text: Optional[str]) -> Optional[str]:
|
|
71
|
+
t = (text or "").lower()
|
|
72
|
+
for f in _FORMULATION:
|
|
73
|
+
if f in t:
|
|
74
|
+
return f.replace(" ", "-")
|
|
75
|
+
return None
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Per-pattern CVX gap classification + parsers (immunizations, code slot).
|
|
2
|
+
|
|
3
|
+
The vaccine concept lives on the code slot (CCDA manufacturedMaterial/code under
|
|
4
|
+
an immunization substanceAdministration; FHIR Immunization.vaccineCode). CVX has
|
|
5
|
+
NO check digit — it is a short numeric code (1-4 digits), so validity = numeric
|
|
6
|
+
format + DB existence. The dominant recovery paths are the CDC's deterministic
|
|
7
|
+
CVX↔RxNorm and vaccine NDC↔CVX crosswalks. See CVX_pattern_analysis.md.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from ...core.models import ClinicalStatement, CodingCandidate, Gap, System
|
|
16
|
+
from ...core.system_registry import HL7_STRUCTURAL
|
|
17
|
+
from . import normalize as N
|
|
18
|
+
|
|
19
|
+
CVX_CONTEXTS = {"immunization"}
|
|
20
|
+
STRUCTURAL = HL7_STRUCTURAL | {System.NULLFLAVOR}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CvxGapPattern(str, Enum):
|
|
24
|
+
RXNORM_ONLY = "RXNORM_ONLY" # RxNorm vaccine product, no CVX -> CVX↔RxNorm map
|
|
25
|
+
NDC_ONLY = "NDC_ONLY" # vaccine NDC, no CVX -> CDC vaccine NDC↔CVX map
|
|
26
|
+
LOCAL_CODE_ONLY = "LOCAL_CODE_ONLY" # local/in-house immunization code
|
|
27
|
+
NULLFLAVOR = "NULLFLAVOR"
|
|
28
|
+
DISPLAY_ONLY = "DISPLAY_ONLY"
|
|
29
|
+
NARRATIVE_ONLY = "NARRATIVE_ONLY"
|
|
30
|
+
BAD_CODE = "BAD_CODE" # claims CVX but not a valid numeric code
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_valid_cvx(code: Optional[str]) -> bool:
|
|
34
|
+
"""CVX codes are short numeric strings (no check digit). Format-validate;
|
|
35
|
+
DB existence is the recovery layer's job."""
|
|
36
|
+
if not code:
|
|
37
|
+
return False
|
|
38
|
+
c = code.strip()
|
|
39
|
+
return c.isdigit() and 1 <= len(c) <= 4
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_cvx_expected(stmt: ClinicalStatement) -> bool:
|
|
43
|
+
return (
|
|
44
|
+
stmt.context in CVX_CONTEXTS
|
|
45
|
+
and (stmt.raw_text is not None or stmt.codings)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def concept_coding(stmt: ClinicalStatement) -> Optional[CodingCandidate]:
|
|
50
|
+
"""Vaccines are code-slot: first non-structural coding with a code."""
|
|
51
|
+
return next((c for c in stmt.codings
|
|
52
|
+
if c.code and c.system not in STRUCTURAL), None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _has_nullflavor(stmt: ClinicalStatement) -> bool:
|
|
56
|
+
return any(c.system == System.NULLFLAVOR for c in stmt.codings)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def classify(stmt: ClinicalStatement) -> Optional[CvxGapPattern]:
|
|
60
|
+
if not is_cvx_expected(stmt):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
cc = concept_coding(stmt)
|
|
64
|
+
|
|
65
|
+
# CVX present -> resolved if it format-validates, else malformed.
|
|
66
|
+
if cc and cc.system == System.CVX and cc.code:
|
|
67
|
+
return None if is_valid_cvx(cc.code) else CvxGapPattern.BAD_CODE
|
|
68
|
+
|
|
69
|
+
if cc and cc.code and cc.system == System.RXNORM:
|
|
70
|
+
return CvxGapPattern.RXNORM_ONLY
|
|
71
|
+
if cc and cc.code and cc.system == System.NDC:
|
|
72
|
+
return CvxGapPattern.NDC_ONLY
|
|
73
|
+
if cc and cc.code and cc.system not in STRUCTURAL:
|
|
74
|
+
return CvxGapPattern.LOCAL_CODE_ONLY
|
|
75
|
+
|
|
76
|
+
if _has_nullflavor(stmt):
|
|
77
|
+
return CvxGapPattern.NULLFLAVOR
|
|
78
|
+
if stmt.raw_text:
|
|
79
|
+
if "/text" in (stmt.source_ref or ""):
|
|
80
|
+
return CvxGapPattern.NARRATIVE_ONLY
|
|
81
|
+
return CvxGapPattern.DISPLAY_ONLY
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _match_key(stmt: ClinicalStatement) -> str:
|
|
88
|
+
t = stmt.raw_text
|
|
89
|
+
return " | ".join([
|
|
90
|
+
f"vaccine:{N.parse_vaccine(t) or '?'}",
|
|
91
|
+
f"formulation:{N.parse_formulation(t) or '-'}",
|
|
92
|
+
"target:CVX",
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _evidence(stmt: ClinicalStatement) -> dict:
|
|
97
|
+
t = stmt.raw_text
|
|
98
|
+
return {
|
|
99
|
+
"term": t,
|
|
100
|
+
"vaccine": N.parse_vaccine(t),
|
|
101
|
+
"formulation": N.parse_formulation(t),
|
|
102
|
+
"section_context": stmt.context,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_gap(stmt: ClinicalStatement, pattern: CvxGapPattern) -> Gap:
|
|
107
|
+
gap = Gap(type=pattern.value, match_key=_match_key(stmt), evidence=_evidence(stmt))
|
|
108
|
+
cc = concept_coding(stmt)
|
|
109
|
+
|
|
110
|
+
if pattern == CvxGapPattern.RXNORM_ONLY and cc:
|
|
111
|
+
gap.evidence["rxnorm_code"] = cc.code
|
|
112
|
+
gap.evidence["rxnorm_system"] = cc.raw_system
|
|
113
|
+
gap.note = ("Use the CDC/NLM CVX↔RxNorm crosswalk — deterministic; "
|
|
114
|
+
"LLM only if a generic RxCUI maps to multiple CVX valences.")
|
|
115
|
+
elif pattern == CvxGapPattern.NDC_ONLY and cc:
|
|
116
|
+
gap.evidence["ndc_code"] = cc.code
|
|
117
|
+
gap.evidence["ndc_system"] = cc.raw_system
|
|
118
|
+
gap.note = ("Use the CDC vaccine NDC↔CVX table (published, free) — "
|
|
119
|
+
"deterministic mapping from the dispensed NDC to CVX.")
|
|
120
|
+
elif pattern == CvxGapPattern.LOCAL_CODE_ONLY and cc:
|
|
121
|
+
gap.evidence["local_code"] = cc.code
|
|
122
|
+
gap.evidence["local_system"] = cc.raw_system
|
|
123
|
+
gap.note = ("Crosswalk local immunization code → CVX first; else search "
|
|
124
|
+
"by normalized vaccine concept (+ valence) → CVX.")
|
|
125
|
+
elif pattern == CvxGapPattern.BAD_CODE and cc:
|
|
126
|
+
gap.evidence["malformed_code"] = cc.code
|
|
127
|
+
gap.note = "Claims CVX but not a valid numeric code; re-resolve via vaccine name → CVX."
|
|
128
|
+
elif pattern == CvxGapPattern.NULLFLAVOR:
|
|
129
|
+
nf = next((c for c in stmt.codings if c.system == System.NULLFLAVOR), None)
|
|
130
|
+
gap.evidence["null_flavor"] = nf.raw_system if nf else None
|
|
131
|
+
gap.note = "Vaccine recovered from narrative; search vaccine concept → CVX."
|
|
132
|
+
else:
|
|
133
|
+
gap.note = "Vaccine name only; normalize the concept and search → CVX."
|
|
134
|
+
return gap
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""ICD-10-CM gap extraction (billing diagnoses) — the mirror of SNOMED.
|
|
2
|
+
|
|
3
|
+
Reads the diagnosis value slot (problems, encounters). The dominant recovery
|
|
4
|
+
path is the official SNOMED CT → ICD-10-CM map (the inverse of SNOMED's ICD_ONLY).
|
|
5
|
+
ICD-10-CM has no check digit — validity is a format regex + DB existence.
|
|
6
|
+
See docs/patterns/ICD10CM_pattern_analysis.md.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .handler import Icd10cmGapExtractor, Icd10cmGapPattern
|
|
10
|
+
from .patterns import classify, is_icd_expected
|
|
11
|
+
from .icdformat import is_valid_icd10cm
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Icd10cmGapExtractor", "Icd10cmGapPattern",
|
|
15
|
+
"classify", "is_icd_expected", "is_valid_icd10cm",
|
|
16
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Icd10cmGapExtractor — ICD-10-CM-only, gaps-only entry point (diagnoses)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Iterable, Optional
|
|
6
|
+
|
|
7
|
+
from ...core.models import ClinicalStatement, NormalizedRecord, System
|
|
8
|
+
from ...pipeline import parse_document
|
|
9
|
+
from . import patterns
|
|
10
|
+
from .patterns import Icd10cmGapPattern
|
|
11
|
+
|
|
12
|
+
__all__ = ["Icd10cmGapExtractor", "Icd10cmGapPattern"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Icd10cmGapExtractor:
|
|
16
|
+
def extract(
|
|
17
|
+
self,
|
|
18
|
+
statements: Iterable[ClinicalStatement],
|
|
19
|
+
contexts: Optional[set[str]] = None,
|
|
20
|
+
include_narrative: bool = False,
|
|
21
|
+
apply_dict: bool = False, # accepted for CLI parity; unused
|
|
22
|
+
) -> list[NormalizedRecord]:
|
|
23
|
+
records: list[NormalizedRecord] = []
|
|
24
|
+
for stmt in statements:
|
|
25
|
+
if contexts is not None and stmt.context not in contexts:
|
|
26
|
+
continue
|
|
27
|
+
pattern = patterns.classify(stmt)
|
|
28
|
+
if pattern is None:
|
|
29
|
+
continue
|
|
30
|
+
if pattern is Icd10cmGapPattern.NARRATIVE_ONLY and not include_narrative:
|
|
31
|
+
continue
|
|
32
|
+
records.append(NormalizedRecord(
|
|
33
|
+
statement=stmt,
|
|
34
|
+
resolved_system=System.ICD10CM,
|
|
35
|
+
resolved_code=None,
|
|
36
|
+
gap=patterns.parse_gap(stmt, pattern),
|
|
37
|
+
))
|
|
38
|
+
return records
|
|
39
|
+
|
|
40
|
+
def extract_from_document(
|
|
41
|
+
self,
|
|
42
|
+
document: str | bytes,
|
|
43
|
+
contexts: Optional[set[str]] = None,
|
|
44
|
+
include_narrative: bool = False,
|
|
45
|
+
apply_dict: bool = False,
|
|
46
|
+
) -> list[NormalizedRecord]:
|
|
47
|
+
return self.extract(parse_document(document), contexts=contexts,
|
|
48
|
+
include_narrative=include_narrative)
|
|
49
|
+
|
|
50
|
+
def summary(self, records: list[NormalizedRecord]) -> dict[str, int]:
|
|
51
|
+
out: dict[str, int] = {}
|
|
52
|
+
for r in records:
|
|
53
|
+
t = r.gap.type if r.gap else "RESOLVED"
|
|
54
|
+
out[t] = out.get(t, 0) + 1
|
|
55
|
+
return out
|