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,166 @@
1
+ """Per-pattern SNOMED gap classification + parsers.
2
+
3
+ Key difference from LOINC: SNOMED is the *concept* (answer). Depending on the
4
+ slot it lives either in the observation **value** (problem/reaction/social/…) or
5
+ in the node's **code** (procedure/allergen substance). We read the right slot per
6
+ context, then classify.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from enum import Enum
12
+ from typing import Optional
13
+
14
+ from ...core.checkdigits import is_valid_sctid
15
+ from ...core.models import ClinicalStatement, CodingCandidate, Gap, System
16
+ from ...core.system_registry import HL7_STRUCTURAL
17
+ from . import qualifiers as Q
18
+ from .hierarchy import SNOMED_CONTEXTS, hierarchy_for, resolve_hierarchy
19
+
20
+ # contexts where the SNOMED concept is the observation VALUE (answer slot)
21
+ VALUE_SLOT_CONTEXTS = {
22
+ "problem", "family_history", "allergy_reaction",
23
+ "social_history", "result_organism", "functional_status",
24
+ }
25
+ # the rest (procedure, allergy_substance, body_site, specimen) carry it on the CODE
26
+
27
+ STRUCTURAL = HL7_STRUCTURAL | {System.NULLFLAVOR}
28
+ ICD_SYSTEMS = {System.ICD10CM, System.ICD9CM, System.ICD10PCS}
29
+
30
+
31
+ class SnomedGapPattern(str, Enum):
32
+ LOCAL_CODE_ONLY = "LOCAL_CODE_ONLY" # a local/other clinical code (incl. CPT), no SNOMED
33
+ ICD_ONLY = "ICD_ONLY" # ICD-9/10 only -> ICD↔SNOMED crosswalk
34
+ NULLFLAVOR = "NULLFLAVOR"
35
+ DISPLAY_ONLY = "DISPLAY_ONLY"
36
+ NARRATIVE_ONLY = "NARRATIVE_ONLY"
37
+ BAD_CODE = "BAD_CODE" # SCTID-shaped but Verhoeff fails
38
+
39
+
40
+ def is_snomed_expected(stmt: ClinicalStatement) -> bool:
41
+ return (
42
+ stmt.context in SNOMED_CONTEXTS
43
+ and (stmt.raw_text is not None or stmt.value is not None or stmt.codings)
44
+ )
45
+
46
+
47
+ def concept_coding(stmt: ClinicalStatement) -> Optional[CodingCandidate]:
48
+ """The coding that represents the clinical concept for this slot.
49
+
50
+ For value-slot contexts, scan all value codings (root + translations) and
51
+ prefer a SNOMED coding (so a SNOMED translation is found even when another
52
+ system is primary); else fall back to the first real coding present.
53
+ """
54
+ if stmt.context in VALUE_SLOT_CONTEXTS:
55
+ codings = stmt.value.codings if stmt.value else []
56
+ snomed = next((c for c in codings
57
+ if c.system == System.SNOMEDCT and c.code), None)
58
+ if snomed:
59
+ return snomed
60
+ other = next((c for c in codings
61
+ if c.code and c.system not in STRUCTURAL), None)
62
+ return other or (stmt.value.coded_value if stmt.value else None)
63
+ # code-slot: first non-structural coding with a code
64
+ return next((c for c in stmt.codings
65
+ if c.code and c.system not in STRUCTURAL), None)
66
+
67
+
68
+ def concept_term(stmt: ClinicalStatement) -> Optional[str]:
69
+ """The human label of the concept (value display for value-slots, code
70
+ display for code-slots), falling back to recovered value originalText."""
71
+ cc = concept_coding(stmt)
72
+ if cc and cc.display:
73
+ return cc.display
74
+ if stmt.context in VALUE_SLOT_CONTEXTS and stmt.value:
75
+ return stmt.value.original_text or stmt.value.raw
76
+ return stmt.raw_text
77
+
78
+
79
+ def concept_labels(stmt: ClinicalStatement) -> tuple[Optional[str], Optional[str]]:
80
+ """(display_name, original_text) for the concept's own slot."""
81
+ if stmt.context in VALUE_SLOT_CONTEXTS:
82
+ cc = stmt.value.coded_value if stmt.value else None
83
+ display = cc.display if (cc and cc.code) else None
84
+ ot = stmt.value.original_text if stmt.value else None
85
+ return display, ot
86
+ # code-slot (procedure / allergen substance): the node's own code labels
87
+ cc = concept_coding(stmt)
88
+ return (cc.display if cc else stmt.display_name), stmt.original_text
89
+
90
+
91
+ def _has_nullflavor(stmt: ClinicalStatement) -> bool:
92
+ if any(c.system == System.NULLFLAVOR for c in stmt.codings):
93
+ return True
94
+ cv = stmt.value.coded_value if stmt.value else None
95
+ return bool(cv and cv.system == System.NULLFLAVOR)
96
+
97
+
98
+ def classify(stmt: ClinicalStatement) -> Optional[SnomedGapPattern]:
99
+ if not is_snomed_expected(stmt):
100
+ return None
101
+
102
+ cc = concept_coding(stmt)
103
+
104
+ if cc and cc.system == System.SNOMEDCT and cc.code:
105
+ # valid SNOMED present -> no gap (active/hierarchy checks need the DB)
106
+ return None if is_valid_sctid(cc.code) else SnomedGapPattern.BAD_CODE
107
+
108
+ if cc and cc.code and cc.system in ICD_SYSTEMS:
109
+ return SnomedGapPattern.ICD_ONLY
110
+ if cc and cc.code and cc.system not in STRUCTURAL:
111
+ return SnomedGapPattern.LOCAL_CODE_ONLY # CPT / local / other
112
+
113
+ # no usable concept code
114
+ if _has_nullflavor(stmt):
115
+ return SnomedGapPattern.NULLFLAVOR
116
+ if stmt.raw_text:
117
+ if "/text" in (stmt.source_ref or ""):
118
+ return SnomedGapPattern.NARRATIVE_ONLY
119
+ return SnomedGapPattern.DISPLAY_ONLY
120
+ return None
121
+
122
+
123
+ # --------------------------------------------------------------------------
124
+
125
+ def _base(stmt, pattern, label, ecl, term) -> Gap:
126
+ return Gap(
127
+ type=pattern.value,
128
+ match_key=Q.build_match_key(stmt, label, term),
129
+ evidence=Q.evidence_packet(stmt, label, ecl, term),
130
+ )
131
+
132
+
133
+ def parse_gap(stmt: ClinicalStatement, pattern: SnomedGapPattern) -> Gap:
134
+ term = concept_term(stmt)
135
+ # semantic tag in the term (e.g. "... (procedure)") overrides the section
136
+ # default so a Problem-List procedure/situation isn't forced to Clinical finding.
137
+ label, ecl = resolve_hierarchy(stmt.context, term) or ("?", "?")
138
+ gap = _base(stmt, pattern, label, ecl, term)
139
+ # concept labels come from the value slot for value-slot contexts
140
+ dn, ot = concept_labels(stmt)
141
+ gap.evidence["display_name"] = dn
142
+ gap.evidence["original_text"] = ot
143
+ cc = concept_coding(stmt)
144
+
145
+ if pattern == SnomedGapPattern.LOCAL_CODE_ONLY and cc:
146
+ gap.evidence["local_code"] = cc.code
147
+ gap.evidence["local_system"] = cc.raw_system
148
+ gap.note = ("Crosswalk (system, code) → SNOMED first (e.g. CPT/local map); "
149
+ "else hierarchy-constrained text search, then LLM.")
150
+ elif pattern == SnomedGapPattern.ICD_ONLY and cc:
151
+ gap.evidence["icd_code"] = cc.code
152
+ gap.evidence["icd_system"] = (cc.system.value if cc.system else None)
153
+ gap.note = ("Use the official ICD↔SNOMED map (NLM/UMLS) — deterministic; "
154
+ "LLM only to choose among multiple map targets.")
155
+ elif pattern == SnomedGapPattern.BAD_CODE and cc:
156
+ gap.evidence["malformed_code"] = cc.code
157
+ gap.note = "SCTID-shaped but Verhoeff check failed; resolve via text + hierarchy."
158
+ elif pattern == SnomedGapPattern.NULLFLAVOR:
159
+ nf = next((c for c in stmt.codings if c.system == System.NULLFLAVOR), None)
160
+ gap.evidence["null_flavor"] = nf.raw_system if nf else None
161
+ gap.note = "Label recovered from originalText; hierarchy-constrained text search."
162
+ elif pattern == SnomedGapPattern.DISPLAY_ONLY:
163
+ gap.note = "Display text only; hierarchy-constrained text search."
164
+ elif pattern == SnomedGapPattern.NARRATIVE_ONLY:
165
+ gap.note = "Narrative-table text; resolve via hierarchy-constrained search (low priority)."
166
+ return gap
@@ -0,0 +1,90 @@
1
+ """SNOMED disambiguation: term normalization, qualifier extraction, match key.
2
+
3
+ SNOMED's analog of LOINC's axes is: hierarchy (from section) + qualifiers
4
+ (laterality, severity, clinical course, context/negation) parsed from the text.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Optional
11
+
12
+ from ...core.models import ClinicalStatement
13
+
14
+ _PUNCT = re.compile(r"[^\w\s]")
15
+ _WS = re.compile(r"\s+")
16
+
17
+ _LATERALITY = ["bilateral", "left", "right"]
18
+ _SEVERITY = ["mild", "moderate", "severe"]
19
+ _COURSE = ["acute", "chronic", "acute on chronic"]
20
+ _CONTEXT = {
21
+ "family history": "family-history",
22
+ "history of": "history-of",
23
+ "no known": "absent",
24
+ "denies": "absent",
25
+ "negative for": "absent",
26
+ "suspected": "suspected",
27
+ }
28
+
29
+
30
+ def normalize_term(text: Optional[str]) -> Optional[str]:
31
+ if not text:
32
+ return None
33
+ t = _PUNCT.sub(" ", text.lower())
34
+ t = _WS.sub(" ", t).strip()
35
+ return t or None
36
+
37
+
38
+ def _find(text: str, options: list[str]) -> Optional[str]:
39
+ for o in options:
40
+ if re.search(rf"\b{re.escape(o)}\b", text):
41
+ return o
42
+ return None
43
+
44
+
45
+ def extract_qualifiers(stmt: ClinicalStatement, term: Optional[str] = None) -> dict:
46
+ text = ((term or stmt.raw_text or "")).lower()
47
+ ctx = None
48
+ for kw, val in _CONTEXT.items():
49
+ if kw in text:
50
+ ctx = val
51
+ break
52
+ return {
53
+ "laterality": _find(text, _LATERALITY),
54
+ "severity": _find(text, _SEVERITY),
55
+ "course": _find(text, _COURSE),
56
+ "negation_context": ctx,
57
+ }
58
+
59
+
60
+ def build_match_key(stmt: ClinicalStatement, hierarchy_label: str,
61
+ term: Optional[str] = None) -> str:
62
+ """component | hierarchy | laterality | severity | course | context."""
63
+ term = term or stmt.raw_text
64
+ q = extract_qualifiers(stmt, term)
65
+ parts = [
66
+ f"component:{normalize_term(term) or '?'}",
67
+ f"hierarchy:{hierarchy_label or '?'}",
68
+ f"laterality:{q['laterality'] or '-'}",
69
+ f"severity:{q['severity'] or '-'}",
70
+ f"course:{q['course'] or '-'}",
71
+ f"context:{q['negation_context'] or 'current'}",
72
+ ]
73
+ return " | ".join(parts)
74
+
75
+
76
+ def evidence_packet(stmt: ClinicalStatement, hierarchy_label: str, ecl: str,
77
+ term: Optional[str] = None) -> dict:
78
+ term = term or stmt.raw_text
79
+ q = extract_qualifiers(stmt, term)
80
+ return {
81
+ "term": term,
82
+ "term_normalized": normalize_term(term),
83
+ "target_hierarchy": hierarchy_label,
84
+ "ecl": ecl,
85
+ "laterality": q["laterality"],
86
+ "severity": q["severity"],
87
+ "course": q["course"],
88
+ "context": q["negation_context"] or "current",
89
+ "section_context": stmt.context,
90
+ }
@@ -0,0 +1,3 @@
1
+ from .service import TerminologyService
2
+
3
+ __all__ = ["TerminologyService"]
@@ -0,0 +1,31 @@
1
+ """Terminology DB service — PLACEHOLDER.
2
+
3
+ Will wrap the local terminology database: exact lookup, existence/deprecation,
4
+ free-text candidate search (returning a constrained set), axis/attribute
5
+ retrieval, crosswalks. Loaders for Loinc.csv / SNOMED RF2 / RxNorm RRF / ICD
6
+ files will live under terminology/loaders/.
7
+ See ARCHITECTURE.md §5.4.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Optional
13
+
14
+ from ..core.models import System
15
+
16
+
17
+ class TerminologyService:
18
+ def lookup(self, system: System, code: str) -> Optional[dict[str, Any]]:
19
+ raise NotImplementedError("DB layer is a placeholder (parsing-first phase).")
20
+
21
+ def is_deprecated(self, system: System, code: str) -> bool:
22
+ raise NotImplementedError("DB layer is a placeholder (parsing-first phase).")
23
+
24
+ def search_candidates(
25
+ self, system: System, match_key: str, limit: int = 10
26
+ ) -> list[dict[str, Any]]:
27
+ """Free-text + axis-filtered candidate search for the recovery layer."""
28
+ raise NotImplementedError("DB layer is a placeholder (parsing-first phase).")
29
+
30
+ def crosswalk(self, from_system: System, code: str, to_system: System) -> list[str]:
31
+ raise NotImplementedError("DB layer is a placeholder (parsing-first phase).")
@@ -0,0 +1,276 @@
1
+ Metadata-Version: 2.4
2
+ Name: aimedicalcoding
3
+ Version: 0.3.1
4
+ Summary: Detect missing/broken clinical codes (LOINC / SNOMED CT / RxNorm / ICD-10-CM / CVX) in CCDA and FHIR documents, with recovery evidence
5
+ Author: Aakash Kag, Vinay Shankar Miryala, Siva Karthik, Mohammed Rayaan
6
+ License: MIT
7
+ Keywords: loinc snomed rxnorm icd-10-cm cvx ccda fhir clinical-terminology medical-coding interoperability
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Healthcare Industry
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: lxml>=4.9
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7; extra == "dev"
24
+ Requires-Dist: build; extra == "dev"
25
+ Requires-Dist: twine; extra == "dev"
26
+ Dynamic: author
27
+ Dynamic: classifier
28
+ Dynamic: description
29
+ Dynamic: description-content-type
30
+ Dynamic: keywords
31
+ Dynamic: license
32
+ Dynamic: license-file
33
+ Dynamic: provides-extra
34
+ Dynamic: requires-dist
35
+ Dynamic: requires-python
36
+ Dynamic: summary
37
+
38
+ # aimedicalcoding
39
+
40
+ **Find every missing or broken standard clinical code in a CCDA or FHIR document — with the evidence needed to fix it.**
41
+
42
+ `aimedicalcoding` parses a clinical document (CCDA XML or FHIR R4 Bundle JSON),
43
+ inspects every coded clinical item — labs, vitals, problems, medications,
44
+ immunizations, allergies, procedures, social history — and reports the ones
45
+ whose standard terminology code (**LOINC · SNOMED CT · RxNorm · ICD-10-CM ·
46
+ CVX**) is missing, local-only, crosswalkable, or malformed. Each finding (a
47
+ *gap*) is emitted as a self-contained JSON record carrying the classification,
48
+ the document facts, and a ready-made recovery plan (search terms, axis filters,
49
+ crosswalk candidates, check-digit corrections).
50
+
51
+ Items that are already correctly coded produce no output — the result is a
52
+ work list, not an inventory.
53
+
54
+ ---
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install aimedicalcoding
60
+ ```
61
+
62
+ Requires Python 3.10+. The only runtime dependency is `lxml`.
63
+
64
+ ---
65
+
66
+ ## Quick start (CLI)
67
+
68
+ The input format is **auto-detected** — the same command works for CCDA XML
69
+ and FHIR JSON, and the output schema is identical for both.
70
+
71
+ ```bash
72
+ # human-readable gap report (card view)
73
+ aimedicalcoding patient.xml
74
+
75
+ # FHIR R4 Bundle — same flags, same output
76
+ aimedicalcoding bundle.json --table
77
+
78
+ # counts only
79
+ aimedicalcoding patient.xml --summary --table
80
+
81
+ # machine-readable JSON, written to a file as UTF-8
82
+ aimedicalcoding patient.xml --out gaps.json
83
+
84
+ # restrict to one terminology and/or certain contexts
85
+ aimedicalcoding patient.xml --system loinc --contexts result,vital
86
+ ```
87
+
88
+ If the entry point is not on your PATH, `python -m aimedicalcoding <file> [flags]`
89
+ is equivalent.
90
+
91
+ ### CLI flags
92
+
93
+ | Flag | Meaning |
94
+ |---|---|
95
+ | `--system` | `loinc` \| `snomed` \| `rxnorm` \| `icd10cm` \| `cvx` \| `all` (default `all`) |
96
+ | `--contexts` | comma-separated contexts to include, e.g. `result,vital,problem` |
97
+ | `--table` | compact one-line-per-gap table |
98
+ | `--json` | print the JSON report to stdout |
99
+ | `-o, --out FILE` | write the JSON report to FILE as UTF-8 (implies `--json`) |
100
+ | `--summary` | counts only |
101
+ | `--include-narrative` | also report narrative-table-only items (CCDA; off by default) |
102
+ | `--apply-dict` | expand lab abbreviations (BUN → urea nitrogen) in search terms |
103
+ | `--json-full` | dump the complete internal representation (debugging) |
104
+
105
+ ---
106
+
107
+ ## Quick start (Python)
108
+
109
+ ```python
110
+ from aimedicalcoding import extract_gaps
111
+
112
+ result = extract_gaps("patient.xml") # CCDA — auto-detected
113
+ result = extract_gaps("bundle.json") # FHIR R4 Bundle — auto-detected
114
+ result = extract_gaps(raw_bytes, # bytes / str / path / file-like
115
+ systems=("loinc", "snomed", "rxnorm", "icd10cm", "cvx"))
116
+
117
+ len(result) # number of gaps
118
+ result.by_target # {"LOINC": 17, "SNOMED-CT": 30, "RxNorm": 4, ...}
119
+ result.by_pattern # {"LOCAL_CODE_ONLY": 15, "NULLFLAVOR": 32, ...}
120
+ result.gaps # list[dict] — one record per (item, missing terminology)
121
+ result.to_json() # the same JSON the CLI emits
122
+ ```
123
+
124
+ Lower-level access — parse to the intermediate representation without gap
125
+ analysis:
126
+
127
+ ```python
128
+ from aimedicalcoding.pipeline import parse_document
129
+
130
+ statements = parse_document(open("bundle.json", "rb").read())
131
+ for s in statements:
132
+ print(s.context, s.raw_text, [(c.system, c.code) for c in s.codings])
133
+ ```
134
+
135
+ ---
136
+
137
+ ## How it works
138
+
139
+ The library is built on two independent axes that meet at a format-agnostic
140
+ intermediate representation (IR):
141
+
142
+ ```
143
+ FORMAT axis (adapters) TERMINOLOGY axis (extractors)
144
+ CCDA XML ──┐ LOINC SNOMED CT
145
+ FHIR R4 ───┤──► ClinicalStatement ──► RxNorm ICD-10-CM CVX
146
+ │ (the IR)
147
+ (auto-detected) (identical for every input format)
148
+ ```
149
+
150
+ 1. **Detect** — XML with a `<ClinicalDocument>` root → CCDA adapter; JSON with
151
+ `"resourceType": "Bundle"` → FHIR adapter.
152
+ 2. **Parse to IR** — the adapter walks the document and emits one
153
+ `ClinicalStatement` per clinical item: every coding found on the item (root
154
+ code *plus* all translations / `coding[]` entries, each canonicalized by
155
+ system), the human labels, the value (quantity or coded answer), specimen,
156
+ panel membership, timestamps.
157
+ 3. **Extract gaps** — each terminology extractor examines the statements
158
+ relevant to it (labs/vitals for LOINC, problems/procedures/reactions for
159
+ SNOMED, medications for RxNorm, diagnoses for ICD-10-CM, immunizations for
160
+ CVX), validates any code present (check digits, format), and classifies
161
+ everything else into a gap pattern.
162
+ 4. **Serialize** — one JSON record per (item × missing terminology), identical
163
+ schema for CCDA and FHIR input.
164
+
165
+ ### What each adapter understands
166
+
167
+ **CCDA**: document/section/entry structure, `<code>` + `<translation>`
168
+ scan-and-identify, `nullFlavor`, `<originalText>` reference resolution into the
169
+ narrative, organizer → panel linkage, specimen, interpretation and reference
170
+ ranges, narrative-table fallback for sections without structured entries.
171
+
172
+ **FHIR R4 Bundle**: routing by resource type + `Observation.category` (with
173
+ section-tag fallback), `CodeableConcept.coding[]` as primary + translations,
174
+ `valueQuantity` / `valueCodeableConcept` / `valueString` / `dataAbsentReason`,
175
+ `hasMember` / `DiagnosticReport.result` panel linkage (children inherit context
176
+ and specimen), `medicationReference → Medication.code` and
177
+ `specimen → Specimen.type` resolution across the Bundle, per-reaction allergy
178
+ statements, and normalization of `"unknown"` null-marker strings.
179
+
180
+ ---
181
+
182
+ ## Gap patterns
183
+
184
+ Each record's `pattern` says *why* the code is missing — which determines how
185
+ to fix it:
186
+
187
+ | Pattern | What's wrong | Recovery route |
188
+ |---|---|---|
189
+ | `LOCAL_CODE_ONLY` | a local/in-house/CPT code, no standard code | crosswalk local → target, else text search |
190
+ | `NULLFLAVOR` | code explicitly declared unknown (`nullFlavor`, `"unknown"`, `dataAbsentReason`) | resolve the recovered label |
191
+ | `DISPLAY_ONLY` | only a display name, no code and no absence marker | resolve the label |
192
+ | `NARRATIVE_ONLY` | exists only as narrative table text (CCDA, opt-in) | text search, low priority |
193
+ | `BAD_CODE` | right shape, fails validation (LOINC Luhn · SNOMED Verhoeff · ICD format) | check-digit / format correction (suggested fix included) |
194
+ | `ICD_ONLY` | ICD present, SNOMED missing | official ICD ↔ SNOMED map |
195
+ | `SNOMED_ONLY` | SNOMED present, ICD-10-CM missing | official SNOMED → ICD-10-CM map |
196
+ | `NDC_ONLY` | NDC present, RxNorm/CVX missing | NDC ↔ RxNorm / CDC NDC ↔ CVX table |
197
+ | `RXNORM_ONLY` | RxNorm vaccine product, CVX missing | CVX ↔ RxNorm map |
198
+ | `MULTUM_ONLY` / `FDB_ONLY` / `MEDISPAN_ONLY` | proprietary drug vocabulary, RxNorm missing | vendor ↔ RxNorm via UMLS |
199
+
200
+ ---
201
+
202
+ ## Output
203
+
204
+ The JSON envelope:
205
+
206
+ ```jsonc
207
+ {
208
+ "source": "patient.xml",
209
+ "source_format": "ccda", // or "fhir" — auto-detected
210
+ "targets_checked": ["LOINC", "SNOMED-CT", "RxNorm", "ICD-10-CM", "CVX"],
211
+ "gap_count": 4,
212
+ "by_pattern": { "LOCAL_CODE_ONLY": 1, "NULLFLAVOR": 3 },
213
+ "by_target": { "LOINC": 1, "SNOMED-CT": 3 },
214
+ "gaps": [ ... ]
215
+ }
216
+ ```
217
+
218
+ Each gap record separates four concerns — the classification, the facts, the
219
+ plan, and the (pending) answer:
220
+
221
+ ```jsonc
222
+ {
223
+ "id": 1,
224
+ "target": { "system": "LOINC", "slot": "code" }, // what's missing, and in which slot
225
+ "pattern": "LOCAL_CODE_ONLY", // why
226
+ "section": { "code": "30954-2", "name": "Results", "system": "LOINC" },
227
+
228
+ "observation": { // the facts, verbatim
229
+ "code": {
230
+ "display_text": "LIPID PANEL",
231
+ "original_text": "LIPID PANEL",
232
+ "codings": [
233
+ { "code": null, "system": "LOINC", "system_raw": "http://loinc.org" },
234
+ { "code": "80061", "system": "CPT", "system_raw": "http://www.ama-assn.org/go/cpt" }
235
+ ]
236
+ },
237
+ "value": null,
238
+ "qualifiers": { "specimen": { "value": "Bld", "confidence": "low" }, "...": "..." }
239
+ },
240
+
241
+ "recovery": { // the plan
242
+ "search_terms": ["LIPID PANEL", "lipid panel"],
243
+ "match_key": "component:LIPID PANEL | specimen:Bld | property:? | scale:? | method:-",
244
+ "crosswalk": { "from_system": "http://www.ama-assn.org/go/cpt", "from_code": "80061" },
245
+ "suggested_action": "Try (source_system, local_code) crosswalk FIRST; ..."
246
+ },
247
+
248
+ "provenance": { "location": { "type": "fhirpath", "path": "..." } },
249
+ "resolution": { "status": "pending", "code": null, "...": "..." } // filled by your recovery step
250
+ }
251
+ ```
252
+
253
+ Reading it: this lab was coded with CPT `80061` but its LOINC slot is empty
254
+ (`code: null` — the source wrote a null marker). The record hands you the CPT
255
+ crosswalk candidate, the text to search with, and the LOINC axis hints
256
+ (specimen/property/scale) to constrain candidates — everything a
257
+ crosswalk-database or LLM step needs to fill `resolution`.
258
+
259
+ ---
260
+
261
+ ## Scope and status
262
+
263
+ - Emits **gap detections + recovery evidence**. It does not itself query
264
+ terminology databases or LLMs — `resolution` is intentionally left `pending`
265
+ for your downstream step.
266
+ - Terminologies checked today: LOINC, SNOMED CT, RxNorm, ICD-10-CM, CVX.
267
+ CPT / HCPCS / ICD-10-PCS are planned.
268
+ - Validation included: LOINC Luhn check digits, SNOMED Verhoeff check digits
269
+ (incl. long-format extension SCTIDs), ICD-10-CM format rules, CVX shape.
270
+ - Code-system identification covers HL7 OIDs, FHIR URIs, `urn:oid:` forms and
271
+ free-text names for 20+ vocabularies (Epic/local OIDs are recognized as
272
+ local codes, HL7 structural vocabularies are excluded from gap detection).
273
+
274
+ ## License
275
+
276
+ MIT — © 2026 Trove Health.
@@ -0,0 +1,57 @@
1
+ aimedicalcoding/__init__.py,sha256=viWad5P5w8ZOXDVhZZ2kXMpTTkUt-f4A28IZqDfHJm8,1182
2
+ aimedicalcoding/__main__.py,sha256=swkEmrnXebaTGwsIKonb6XfrAi_Movq0uKbU91wb8oE,12586
3
+ aimedicalcoding/api.py,sha256=bADfs8Nm0bARYDxDcLb2ML3Mn2yNkgehhCKzznhwEpA,6128
4
+ aimedicalcoding/serialize.py,sha256=3BaJIJj32tYGhcciSwBnwq4lPcs6nBNeofvw84TD4To,10644
5
+ aimedicalcoding/adapters/__init__.py,sha256=XKnZJyHCIkjLTKHKUfalztZt87KYRNRyOY4zGRxDmgc,64
6
+ aimedicalcoding/adapters/base.py,sha256=gMfYxK3nho2LjL7h1Y14HrTZ0KqRDAGeQicBEBKo5ug,790
7
+ aimedicalcoding/adapters/ccda/__init__.py,sha256=PbdC_mmRW5BrxKsWS4JlE9H5psPAvhRprmZfOQ3Gzo8,63
8
+ aimedicalcoding/adapters/ccda/adapter.py,sha256=J7xcjZF4KVqxAVoZAuWGhflHxJ7qkS6wicGLx9BWH2A,12687
9
+ aimedicalcoding/adapters/ccda/codes.py,sha256=gYDZP5jVWR7gUrUzP-jB9j62U_9JDdqWiC-k3FH3Kfo,7125
10
+ aimedicalcoding/adapters/ccda/narrative.py,sha256=7tC5TCtj3WbmeWXmbXPMLRby0foE9Vn5xBlYEuXsAoI,4162
11
+ aimedicalcoding/adapters/ccda/sections.py,sha256=TnKXVUtS6XsxhZZYEMAwv3YciXsmIcUIJeGJoFwkZ3M,2145
12
+ aimedicalcoding/adapters/custom_json/__init__.py,sha256=QPsWZfo1hSTCOp64UiAbmfjtIULKDyjrlSUDVH9YYVc,75
13
+ aimedicalcoding/adapters/custom_json/adapter.py,sha256=8cVT8dFeB1YlKJUmDCGr28I-xmo3LGfF5_kPfJLATWg,1017
14
+ aimedicalcoding/adapters/fhir/__init__.py,sha256=RDoieLHyrnWqf6dqs0UVwQ-JY8ukm5Ds9kxrGsXsJBY,63
15
+ aimedicalcoding/adapters/fhir/adapter.py,sha256=8b68P2hNQEKrmyHmE2_EHo7M5gQSYnBFir3yvHuEbIY,14955
16
+ aimedicalcoding/adapters/fhir/codes.py,sha256=w-eDA5OMJS2duBIHwI01phwZBhjPDmFrMOBXnYK_g28,8368
17
+ aimedicalcoding/adapters/fhir/mapping.py,sha256=dtESD28QZ3q1ze_E77yYkTWxEOg-WMC1TfRHr-hy0u4,3056
18
+ aimedicalcoding/core/__init__.py,sha256=cMtmrbXfZir5v-9_6bzf94wdmyKVFReJvZ4QKGcPAgw,694
19
+ aimedicalcoding/core/checkdigits.py,sha256=54vcbScOxG21ngGK7tsaS7d0ZjD-LppUuXvkxBJjO4s,3615
20
+ aimedicalcoding/core/models.py,sha256=VlRYtkNJQeF_vdc9TVT9BP4OU3JaKbNxwVfASdp1Pdw,6654
21
+ aimedicalcoding/core/system_registry.py,sha256=vgtUzsW8006ohxYR5bRh04C4InPzxGDrDQ6QXDOYmNs,8024
22
+ aimedicalcoding/pipeline/__init__.py,sha256=KGE1IpfLT62r9Iv1MGEhrutYRO6_q4lKv36vOnIjS3I,108
23
+ aimedicalcoding/pipeline/orchestrator.py,sha256=p-PbGhM5KXWW9f-Ocy8Ds-FMglV--9MirSB7QsOFriQ,1136
24
+ aimedicalcoding/recovery/__init__.py,sha256=kDE5TkBDET4ZyQ4aNsDYCnXUGhZ7PuNnG6gfkmgyt6I,68
25
+ aimedicalcoding/recovery/engine.py,sha256=-Ec8H6n2RN2ZfzgKkao091E5QBujN3nXiFXH_a4W8u4,620
26
+ aimedicalcoding/systems/__init__.py,sha256=clo-oaRc880EXmUrAtdGQ29Fam88r7Ad3joi4MyjLgI,74
27
+ aimedicalcoding/systems/base.py,sha256=-ZugvfiyOLU7NXogAB1bujoaY_s6JbH5EbX3cRXjg18,1419
28
+ aimedicalcoding/systems/cvx/__init__.py,sha256=7y8Tf7hUR1E478oD7blqrGo-h82A89foK0V6eKcgKak,710
29
+ aimedicalcoding/systems/cvx/handler.py,sha256=JOobyneuFF3vJXxK-aF6WEpxE7pEHOhq8zwfmSdfEQM,1969
30
+ aimedicalcoding/systems/cvx/normalize.py,sha256=FrHn8s3Sajdo0DX0DGJzbC6IyxQWOSyuDomLBdNOpQk,2343
31
+ aimedicalcoding/systems/cvx/patterns.py,sha256=y5n8_noaWxetMRugH_AHTeZi76tCda4oYVPW2Y9DQ-8,5391
32
+ aimedicalcoding/systems/icd10cm/__init__.py,sha256=bAlE4-1qctHJKiut_uQsEkOu0MLxfGTAEL4nimyHV8s,647
33
+ aimedicalcoding/systems/icd10cm/handler.py,sha256=S6vs634eGEiaJguKFQldQRAXZ56QEai-iGTiwivpwZI,1999
34
+ aimedicalcoding/systems/icd10cm/icdformat.py,sha256=t81aq7YiaKzfuYtjSk8GQFcd3XXAc5790M-A-FKEyGQ,1422
35
+ aimedicalcoding/systems/icd10cm/patterns.py,sha256=JgjmpT5AGJoyQF6VFueIUolqJzLzLgEtS3mwKDX1s80,5446
36
+ aimedicalcoding/systems/loinc/__init__.py,sha256=OimAW2Mz1u6P0v1-SRN_rYYpZoltJprjkxg3mR4VTFE,573
37
+ aimedicalcoding/systems/loinc/axes.py,sha256=YD8m2tvlHp0xQDuWDgDU1OjhpkXhpnPPX8f9YYYDV5c,8715
38
+ aimedicalcoding/systems/loinc/handler.py,sha256=2x1VI1YqxIg0pa4vapSWC533pJ8AK8lyBvFI6mXnhEo,3207
39
+ aimedicalcoding/systems/loinc/normalize.py,sha256=M6KGYuvSgIipMgsP4I7LW49aR7Owpw_kOS5Z6UEdmWE,2545
40
+ aimedicalcoding/systems/loinc/patterns.py,sha256=dG8_Cf9hq2xdQFYLfhWvF28V1Oyo9bVSqFodw9pZeoU,6714
41
+ aimedicalcoding/systems/rxnorm/__init__.py,sha256=JOz1CgTTgFRDAW82HYzWYdcc1vRNPW-DXlF52613Al4,611
42
+ aimedicalcoding/systems/rxnorm/handler.py,sha256=FpBYBhovnHfTyXMMiXFHKgJFb_OvHLaMGOpWW7SkZaM,1991
43
+ aimedicalcoding/systems/rxnorm/normalize.py,sha256=UJDu50yMKbLpmasI8oTlpH7GEQOXBZEGzDg5qjytK9c,3034
44
+ aimedicalcoding/systems/rxnorm/patterns.py,sha256=cCJ-27q3_1ITaGHxwrFXDMNFK8uO7GSYR8KjrfUdAkI,6064
45
+ aimedicalcoding/systems/snomed/__init__.py,sha256=I0X2Oc5-jZ4mHKdvSN4xlxucwYwHUmDirvp7X5TeMxI,715
46
+ aimedicalcoding/systems/snomed/handler.py,sha256=NI4zpIBvnysd_hKPjVNyyMWulZwhuBw9yZa8HfZn9ks,2208
47
+ aimedicalcoding/systems/snomed/hierarchy.py,sha256=vNFMCXmOlvWt0WZqeaXLZNf9uV1e8OPQDSQzxY1Ohss,3954
48
+ aimedicalcoding/systems/snomed/patterns.py,sha256=YU6ZpHo-Kw7BFsG5QZJ2AW2nGIYH7qxehyZiMxvsjMw,7341
49
+ aimedicalcoding/systems/snomed/qualifiers.py,sha256=zz_CCx7vW7cAz_dc7aWPtibtiMC7-oLQPtE7sUdXwOw,2815
50
+ aimedicalcoding/terminology/__init__.py,sha256=KVoIA1CCQn_v-1elbVEqDhy1j3oTPuxoQk9uRKTUv1k,77
51
+ aimedicalcoding/terminology/service.py,sha256=9EllY7NhWKFAgu6akhLEG4oMmqjCqKfrAe93KCT8I0Q,1289
52
+ aimedicalcoding-0.3.1.dist-info/licenses/LICENSE,sha256=K6yywBLgHo248RJhXjwoW1n9bDoYgyItdlBixb6iwGY,1069
53
+ aimedicalcoding-0.3.1.dist-info/METADATA,sha256=1DEr1j7M3HO9-U96MGdDQrPM47lKmw2_Ze7G43VUjg4,11418
54
+ aimedicalcoding-0.3.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
55
+ aimedicalcoding-0.3.1.dist-info/entry_points.txt,sha256=qYa-Olxsrca0nMczzXgmvRh1KKgI2xBBLbsM-IGst1g,66
56
+ aimedicalcoding-0.3.1.dist-info/top_level.txt,sha256=Oyr7k6Ihz10OEQ5Cw_uOCPFbOE14KCcMeTtjbX4MHi0,16
57
+ aimedicalcoding-0.3.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aimedicalcoding = aimedicalcoding.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Trove Health
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ aimedicalcoding