aimedicalcoding 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. aimedicalcoding/__init__.py +29 -0
  2. aimedicalcoding/__main__.py +313 -0
  3. aimedicalcoding/adapters/__init__.py +3 -0
  4. aimedicalcoding/adapters/base.py +25 -0
  5. aimedicalcoding/adapters/ccda/__init__.py +3 -0
  6. aimedicalcoding/adapters/ccda/adapter.py +294 -0
  7. aimedicalcoding/adapters/ccda/codes.py +198 -0
  8. aimedicalcoding/adapters/ccda/narrative.py +121 -0
  9. aimedicalcoding/adapters/ccda/sections.py +44 -0
  10. aimedicalcoding/adapters/custom_json/__init__.py +3 -0
  11. aimedicalcoding/adapters/custom_json/adapter.py +30 -0
  12. aimedicalcoding/adapters/fhir/__init__.py +3 -0
  13. aimedicalcoding/adapters/fhir/adapter.py +326 -0
  14. aimedicalcoding/adapters/fhir/codes.py +211 -0
  15. aimedicalcoding/adapters/fhir/mapping.py +68 -0
  16. aimedicalcoding/api.py +163 -0
  17. aimedicalcoding/core/__init__.py +34 -0
  18. aimedicalcoding/core/checkdigits.py +103 -0
  19. aimedicalcoding/core/models.py +149 -0
  20. aimedicalcoding/core/system_registry.py +185 -0
  21. aimedicalcoding/pipeline/__init__.py +3 -0
  22. aimedicalcoding/pipeline/orchestrator.py +33 -0
  23. aimedicalcoding/recovery/__init__.py +3 -0
  24. aimedicalcoding/recovery/engine.py +16 -0
  25. aimedicalcoding/serialize.py +257 -0
  26. aimedicalcoding/systems/__init__.py +3 -0
  27. aimedicalcoding/systems/base.py +43 -0
  28. aimedicalcoding/systems/cvx/__init__.py +15 -0
  29. aimedicalcoding/systems/cvx/handler.py +55 -0
  30. aimedicalcoding/systems/cvx/normalize.py +75 -0
  31. aimedicalcoding/systems/cvx/patterns.py +134 -0
  32. aimedicalcoding/systems/icd10cm/__init__.py +16 -0
  33. aimedicalcoding/systems/icd10cm/handler.py +55 -0
  34. aimedicalcoding/systems/icd10cm/icdformat.py +43 -0
  35. aimedicalcoding/systems/icd10cm/patterns.py +133 -0
  36. aimedicalcoding/systems/loinc/__init__.py +14 -0
  37. aimedicalcoding/systems/loinc/axes.py +199 -0
  38. aimedicalcoding/systems/loinc/handler.py +80 -0
  39. aimedicalcoding/systems/loinc/normalize.py +81 -0
  40. aimedicalcoding/systems/loinc/patterns.py +166 -0
  41. aimedicalcoding/systems/rxnorm/__init__.py +13 -0
  42. aimedicalcoding/systems/rxnorm/handler.py +55 -0
  43. aimedicalcoding/systems/rxnorm/normalize.py +88 -0
  44. aimedicalcoding/systems/rxnorm/patterns.py +143 -0
  45. aimedicalcoding/systems/snomed/__init__.py +15 -0
  46. aimedicalcoding/systems/snomed/handler.py +60 -0
  47. aimedicalcoding/systems/snomed/hierarchy.py +96 -0
  48. aimedicalcoding/systems/snomed/patterns.py +166 -0
  49. aimedicalcoding/systems/snomed/qualifiers.py +90 -0
  50. aimedicalcoding/terminology/__init__.py +3 -0
  51. aimedicalcoding/terminology/service.py +31 -0
  52. aimedicalcoding-0.3.1.dist-info/METADATA +276 -0
  53. aimedicalcoding-0.3.1.dist-info/RECORD +57 -0
  54. aimedicalcoding-0.3.1.dist-info/WHEEL +5 -0
  55. aimedicalcoding-0.3.1.dist-info/entry_points.txt +2 -0
  56. aimedicalcoding-0.3.1.dist-info/licenses/LICENSE +21 -0
  57. aimedicalcoding-0.3.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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,3 @@
1
+ from .base import TerminologyHandler
2
+
3
+ __all__ = ["TerminologyHandler"]
@@ -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