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,166 @@
|
|
|
1
|
+
"""Per-pattern LOINC gap classification + dedicated parsers.
|
|
2
|
+
|
|
3
|
+
Each LOINC gap pattern has its own parser that builds a tailored ``Gap`` (with
|
|
4
|
+
the shared evidence packet plus pattern-specific recovery hints). Valid,
|
|
5
|
+
already-coded LOINCs return None — they need no correction/filling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from ...core.checkdigits import correct_loinc_check_digit
|
|
14
|
+
from ...core.models import ClinicalStatement, Gap, System
|
|
15
|
+
from ...core.system_registry import HL7_STRUCTURAL
|
|
16
|
+
from . import axes
|
|
17
|
+
|
|
18
|
+
# Entry-level contexts where a LOINC code is expected on the observation.
|
|
19
|
+
LOINC_ENTRY_CONTEXTS = {
|
|
20
|
+
"result", "vital", "social_history", "functional_status", "survey", "assessment",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# HL7 structural vocabularies that are NOT clinical codes. When one of these is
|
|
24
|
+
# the only code on an observation (e.g. code="ASSERTION"), the real concept
|
|
25
|
+
# lives in <value> (often SNOMED), so it is NOT a LOINC gap.
|
|
26
|
+
STRUCTURAL_SYSTEMS = HL7_STRUCTURAL
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GapPattern(str, Enum):
|
|
30
|
+
LOCAL_CODE_ONLY = "LOCAL_CODE_ONLY" # a local code, but no LOINC
|
|
31
|
+
NULLFLAVOR = "NULLFLAVOR" # nullFlavor code, label via originalText
|
|
32
|
+
DISPLAY_ONLY = "DISPLAY_ONLY" # displayName present, no code attr
|
|
33
|
+
NARRATIVE_ONLY = "NARRATIVE_ONLY" # value/label only in narrative table
|
|
34
|
+
BAD_CODE = "BAD_CODE" # LOINC-shaped but check digit fails
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_loinc_expected(stmt: ClinicalStatement) -> bool:
|
|
38
|
+
"""True if a LOINC code belongs on this observation (so a missing/bad one
|
|
39
|
+
is a real correction target)."""
|
|
40
|
+
return (
|
|
41
|
+
stmt.target_system_hint == System.LOINC
|
|
42
|
+
and stmt.context in LOINC_ENTRY_CONTEXTS
|
|
43
|
+
and (stmt.raw_text is not None or stmt.value is not None)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def classify(stmt: ClinicalStatement) -> Optional[GapPattern]:
|
|
48
|
+
"""Return the gap pattern, or None if no correction/filling is needed."""
|
|
49
|
+
if not is_loinc_expected(stmt):
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
loinc = stmt.coding_for(System.LOINC)
|
|
53
|
+
|
|
54
|
+
# A valid LOINC is present (root OR translation) -> nothing to do.
|
|
55
|
+
# (Deprecation re-mapping is deferred to the DB layer.)
|
|
56
|
+
if loinc and loinc.check_digit_valid:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
# LOINC-shaped but check digit fails -> correctable.
|
|
60
|
+
if loinc and loinc.check_digit_valid is False:
|
|
61
|
+
return GapPattern.BAD_CODE
|
|
62
|
+
|
|
63
|
+
# No usable LOINC. Distinguish real clinical codes from HL7 structural codes.
|
|
64
|
+
clinical_codes = [
|
|
65
|
+
c for c in stmt.codings
|
|
66
|
+
if c.code and c.system not in STRUCTURAL_SYSTEMS and c.system != System.NULLFLAVOR
|
|
67
|
+
]
|
|
68
|
+
structural_only = (
|
|
69
|
+
not clinical_codes
|
|
70
|
+
and any(c.code and c.system in STRUCTURAL_SYSTEMS for c in stmt.codings)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# code="ASSERTION" etc. -> the concept is in <value> (e.g. SNOMED), not LOINC.
|
|
74
|
+
if structural_only:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
if clinical_codes:
|
|
78
|
+
return GapPattern.LOCAL_CODE_ONLY
|
|
79
|
+
|
|
80
|
+
has_nullflavor = any(c.system == System.NULLFLAVOR for c in stmt.codings)
|
|
81
|
+
if has_nullflavor:
|
|
82
|
+
return GapPattern.NULLFLAVOR
|
|
83
|
+
# no codings at all -> narrative vs structured display-only
|
|
84
|
+
if "/text" in (stmt.source_ref or ""):
|
|
85
|
+
return GapPattern.NARRATIVE_ONLY
|
|
86
|
+
return GapPattern.DISPLAY_ONLY
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --------------------------------------------------------------------------
|
|
90
|
+
# Per-pattern parsers: each returns a Gap with the evidence packet + hints.
|
|
91
|
+
# --------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def parse_local_code_only(stmt: ClinicalStatement, expand_abbrev: bool = False) -> Gap:
|
|
94
|
+
local = next((c for c in stmt.codings if c.code and c.system != System.LOINC), None)
|
|
95
|
+
ev = axes.evidence_packet(stmt, expand_abbrev)
|
|
96
|
+
ev["local_code"] = local.code if local else None
|
|
97
|
+
ev["local_system"] = (local.raw_system if local else None)
|
|
98
|
+
return Gap(
|
|
99
|
+
type=GapPattern.LOCAL_CODE_ONLY.value,
|
|
100
|
+
match_key=axes.build_match_key(stmt, expand_abbrev),
|
|
101
|
+
evidence=ev,
|
|
102
|
+
note="Try (source_system, local_code) crosswalk FIRST; fall back to "
|
|
103
|
+
"text+axis search, then LLM. Cache the resolved mapping.",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_nullflavor(stmt: ClinicalStatement, expand_abbrev: bool = False) -> Gap:
|
|
108
|
+
nf = next((c for c in stmt.codings if c.system == System.NULLFLAVOR), None)
|
|
109
|
+
ev = axes.evidence_packet(stmt, expand_abbrev)
|
|
110
|
+
ev["null_flavor"] = nf.raw_system if nf else None
|
|
111
|
+
return Gap(
|
|
112
|
+
type=GapPattern.NULLFLAVOR.value,
|
|
113
|
+
match_key=axes.build_match_key(stmt, expand_abbrev),
|
|
114
|
+
evidence=ev,
|
|
115
|
+
note="Label recovered from originalText/narrative; resolve via "
|
|
116
|
+
"text+axis search then LLM.",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_display_only(stmt: ClinicalStatement, expand_abbrev: bool = False) -> Gap:
|
|
121
|
+
return Gap(
|
|
122
|
+
type=GapPattern.DISPLAY_ONLY.value,
|
|
123
|
+
match_key=axes.build_match_key(stmt, expand_abbrev),
|
|
124
|
+
evidence=axes.evidence_packet(stmt, expand_abbrev),
|
|
125
|
+
note="displayName present, no code attribute; resolve via text+axis search.",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_narrative_only(stmt: ClinicalStatement, expand_abbrev: bool = False) -> Gap:
|
|
130
|
+
return Gap(
|
|
131
|
+
type=GapPattern.NARRATIVE_ONLY.value,
|
|
132
|
+
match_key=axes.build_match_key(stmt, expand_abbrev),
|
|
133
|
+
evidence=axes.evidence_packet(stmt, expand_abbrev),
|
|
134
|
+
note="No structured entry; row parsed from narrative table. Unit + "
|
|
135
|
+
"reference range present to constrain candidates.",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def parse_bad_code(stmt: ClinicalStatement, expand_abbrev: bool = False) -> Gap:
|
|
140
|
+
loinc = stmt.coding_for(System.LOINC)
|
|
141
|
+
bad = loinc.code if loinc else None
|
|
142
|
+
ev = axes.evidence_packet(stmt, expand_abbrev)
|
|
143
|
+
ev["malformed_code"] = bad
|
|
144
|
+
ev["checkdigit_correction"] = correct_loinc_check_digit(bad) if bad else None
|
|
145
|
+
return Gap(
|
|
146
|
+
type=GapPattern.BAD_CODE.value,
|
|
147
|
+
match_key=axes.build_match_key(stmt, expand_abbrev),
|
|
148
|
+
evidence=ev,
|
|
149
|
+
note="LOINC shape with failed check digit. If only the check digit is "
|
|
150
|
+
"wrong, 'checkdigit_correction' is the likely fix; still confirm "
|
|
151
|
+
"against text+axis before applying.",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_PARSERS = {
|
|
156
|
+
GapPattern.LOCAL_CODE_ONLY: parse_local_code_only,
|
|
157
|
+
GapPattern.NULLFLAVOR: parse_nullflavor,
|
|
158
|
+
GapPattern.DISPLAY_ONLY: parse_display_only,
|
|
159
|
+
GapPattern.NARRATIVE_ONLY: parse_narrative_only,
|
|
160
|
+
GapPattern.BAD_CODE: parse_bad_code,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_gap(stmt: ClinicalStatement, pattern: GapPattern,
|
|
165
|
+
expand_abbrev: bool = False) -> Gap:
|
|
166
|
+
return _PARSERS[pattern](stmt, expand_abbrev)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""RxNorm gap extraction (medications).
|
|
2
|
+
|
|
3
|
+
The drug concept lives on the code slot (CCDA manufacturedMaterial/code, FHIR
|
|
4
|
+
medicationCodeableConcept). RxCUI has NO check digit — validity is DB existence +
|
|
5
|
+
TTY, so a present RxNorm code is treated as resolved here. The dominant recovery
|
|
6
|
+
path is the NDC↔RxNorm crosswalk. DB lookup and LLM ranking are placeholders.
|
|
7
|
+
See RxNorm_pattern_analysis.md.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .handler import RxnormGapExtractor, RxnormGapPattern
|
|
11
|
+
from .patterns import classify, is_rxnorm_expected
|
|
12
|
+
|
|
13
|
+
__all__ = ["RxnormGapExtractor", "RxnormGapPattern", "classify", "is_rxnorm_expected"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""RxnormGapExtractor — RxNorm-only, gaps-only entry point (medications)."""
|
|
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 RxnormGapPattern
|
|
11
|
+
|
|
12
|
+
__all__ = ["RxnormGapExtractor", "RxnormGapPattern"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RxnormGapExtractor:
|
|
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 RxnormGapPattern.NARRATIVE_ONLY and not include_narrative:
|
|
31
|
+
continue
|
|
32
|
+
records.append(NormalizedRecord(
|
|
33
|
+
statement=stmt,
|
|
34
|
+
resolved_system=System.RXNORM,
|
|
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,88 @@
|
|
|
1
|
+
"""Parse the disambiguating drug attributes from a medication label.
|
|
2
|
+
|
|
3
|
+
RxNorm's analog of LOINC axes / SNOMED hierarchy is: ingredient + strength +
|
|
4
|
+
dose form + route (+ brand) → the TTY level (we target SCD/SBD).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
_STRENGTH = re.compile(
|
|
13
|
+
r"(\d+(?:\.\d+)?)\s*(mg|mcg|g|ml|%|unit|units|iu|meq|mmol)\b", re.I)
|
|
14
|
+
|
|
15
|
+
# longest-first so "extended release oral tablet" wins over "oral tablet"
|
|
16
|
+
_DOSE_FORMS = [
|
|
17
|
+
"extended release oral tablet", "extended release oral capsule",
|
|
18
|
+
"oral tablet", "oral capsule", "oral solution", "oral suspension",
|
|
19
|
+
"delayed release tablet", "chewable tablet",
|
|
20
|
+
"injection", "injectable solution", "ophthalmic solution",
|
|
21
|
+
"tablet", "capsule", "solution", "suspension", "cream", "ointment",
|
|
22
|
+
"patch", "inhaler", "suppository", "lotion", "gel", "spray", "syrup",
|
|
23
|
+
]
|
|
24
|
+
_ROUTES = ["oral", "intravenous", "iv", "topical", "subcutaneous",
|
|
25
|
+
"intramuscular", "inhalation", "rectal", "nasal", "ophthalmic"]
|
|
26
|
+
|
|
27
|
+
_WS = re.compile(r"\s+")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_strength(text: Optional[str]) -> Optional[str]:
|
|
31
|
+
if not text:
|
|
32
|
+
return None
|
|
33
|
+
m = _STRENGTH.search(text)
|
|
34
|
+
return f"{m.group(1)} {m.group(2).upper()}" if m else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_strengths(text: Optional[str]) -> list[str]:
|
|
38
|
+
"""ALL strength tokens (combination products carry more than one)."""
|
|
39
|
+
if not text:
|
|
40
|
+
return []
|
|
41
|
+
return [f"{n} {u.upper()}" for n, u in _STRENGTH.findall(text)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_combination(text: Optional[str]) -> bool:
|
|
45
|
+
"""A multi-ingredient product ("amlodipine 5 MG / benazepril 10 MG").
|
|
46
|
+
|
|
47
|
+
Detected by more than one distinct strength, or an ingredient-joining slash.
|
|
48
|
+
NOTE: '-' is NOT used — it appears inside salt names (e.g. metoprolol
|
|
49
|
+
tartrate). Combos map to a *combination* SCD, so a single-ingredient key
|
|
50
|
+
would resolve the wrong drug.
|
|
51
|
+
"""
|
|
52
|
+
if not text:
|
|
53
|
+
return False
|
|
54
|
+
if len(set(parse_strengths(text))) > 1:
|
|
55
|
+
return True
|
|
56
|
+
# a slash between two alphabetic ingredient tokens (not a unit like mg/ml)
|
|
57
|
+
return bool(re.search(r"[A-Za-z]{3,}\s*/\s*[A-Za-z]{3,}", text))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_dose_form(text: Optional[str]) -> Optional[str]:
|
|
61
|
+
t = (text or "").lower()
|
|
62
|
+
for f in _DOSE_FORMS:
|
|
63
|
+
if f in t:
|
|
64
|
+
return f
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_route(text: Optional[str]) -> Optional[str]:
|
|
69
|
+
t = (text or "").lower()
|
|
70
|
+
for r in _ROUTES:
|
|
71
|
+
if re.search(rf"\b{re.escape(r)}\b", t):
|
|
72
|
+
return r
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def normalize_ingredient(text: Optional[str]) -> Optional[str]:
|
|
77
|
+
"""Rough ingredient: the label minus strength and dose-form words."""
|
|
78
|
+
if not text:
|
|
79
|
+
return None
|
|
80
|
+
t = text.lower()
|
|
81
|
+
t = _STRENGTH.sub(" ", t)
|
|
82
|
+
for f in _DOSE_FORMS:
|
|
83
|
+
t = t.replace(f, " ")
|
|
84
|
+
for r in _ROUTES:
|
|
85
|
+
t = re.sub(rf"\b{re.escape(r)}\b", " ", t)
|
|
86
|
+
t = re.sub(r"[^\w\s/+-]", " ", t)
|
|
87
|
+
t = _WS.sub(" ", t).strip(" -/")
|
|
88
|
+
return t or None
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Per-pattern RxNorm gap classification + parsers (medications, code slot)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ...core.models import ClinicalStatement, CodingCandidate, Gap, System
|
|
9
|
+
from ...core.system_registry import HL7_STRUCTURAL
|
|
10
|
+
from . import normalize as N
|
|
11
|
+
|
|
12
|
+
RXNORM_CONTEXTS = {"medication"}
|
|
13
|
+
STRUCTURAL = HL7_STRUCTURAL | {System.NULLFLAVOR}
|
|
14
|
+
TARGET_TTY = "SCD|SBD" # the prescribable level we aim to resolve to
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RxnormGapPattern(str, Enum):
|
|
18
|
+
NDC_ONLY = "NDC_ONLY" # NDC present, no RxNorm -> NDC↔RxNorm crosswalk
|
|
19
|
+
MULTUM_ONLY = "MULTUM_ONLY" # Cerner Multum drug code -> Multum↔RxNorm (UMLS)
|
|
20
|
+
FDB_ONLY = "FDB_ONLY" # First DataBank / NDDF code -> FDB↔RxNorm (UMLS)
|
|
21
|
+
MEDISPAN_ONLY = "MEDISPAN_ONLY" # Medi-Span GPI / DDID -> Medi-Span↔RxNorm (UMLS)
|
|
22
|
+
LOCAL_CODE_ONLY = "LOCAL_CODE_ONLY" # local/formulary code, no RxNorm
|
|
23
|
+
NULLFLAVOR = "NULLFLAVOR"
|
|
24
|
+
DISPLAY_ONLY = "DISPLAY_ONLY"
|
|
25
|
+
NARRATIVE_ONLY = "NARRATIVE_ONLY"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Proprietary drug source vocabularies that crosswalk to RxNorm via UMLS.
|
|
29
|
+
# Each maps to its own gap pattern so the recovery layer runs the right crosswalk.
|
|
30
|
+
_SOURCE_VOCAB_PATTERN = {
|
|
31
|
+
System.MULTUM: RxnormGapPattern.MULTUM_ONLY,
|
|
32
|
+
System.FDB: RxnormGapPattern.FDB_ONLY,
|
|
33
|
+
System.MEDISPAN: RxnormGapPattern.MEDISPAN_ONLY,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_rxnorm_expected(stmt: ClinicalStatement) -> bool:
|
|
38
|
+
return (
|
|
39
|
+
stmt.context in RXNORM_CONTEXTS
|
|
40
|
+
and (stmt.raw_text is not None or stmt.codings)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def concept_coding(stmt: ClinicalStatement) -> Optional[CodingCandidate]:
|
|
45
|
+
"""Meds are code-slot: first non-structural coding with a code."""
|
|
46
|
+
return next((c for c in stmt.codings
|
|
47
|
+
if c.code and c.system not in STRUCTURAL), None)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _has_nullflavor(stmt: ClinicalStatement) -> bool:
|
|
51
|
+
return any(c.system == System.NULLFLAVOR for c in stmt.codings)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def classify(stmt: ClinicalStatement) -> Optional[RxnormGapPattern]:
|
|
55
|
+
if not is_rxnorm_expected(stmt):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
cc = concept_coding(stmt)
|
|
59
|
+
|
|
60
|
+
# RxCUI present -> resolved (no checksum; existence/TTY checks need the DB).
|
|
61
|
+
if cc and cc.system == System.RXNORM and cc.code:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
if cc and cc.code and cc.system == System.NDC:
|
|
65
|
+
return RxnormGapPattern.NDC_ONLY
|
|
66
|
+
if cc and cc.code and cc.system in _SOURCE_VOCAB_PATTERN:
|
|
67
|
+
return _SOURCE_VOCAB_PATTERN[cc.system] # Multum / FDB / Medi-Span
|
|
68
|
+
if cc and cc.code and cc.system not in STRUCTURAL:
|
|
69
|
+
return RxnormGapPattern.LOCAL_CODE_ONLY # local/formulary/other
|
|
70
|
+
|
|
71
|
+
if _has_nullflavor(stmt):
|
|
72
|
+
return RxnormGapPattern.NULLFLAVOR
|
|
73
|
+
if stmt.raw_text:
|
|
74
|
+
if "/text" in (stmt.source_ref or ""):
|
|
75
|
+
return RxnormGapPattern.NARRATIVE_ONLY
|
|
76
|
+
return RxnormGapPattern.DISPLAY_ONLY
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _match_key(stmt: ClinicalStatement) -> str:
|
|
83
|
+
t = stmt.raw_text
|
|
84
|
+
combo = N.is_combination(t)
|
|
85
|
+
strength = " + ".join(N.parse_strengths(t)) if combo else (N.parse_strength(t) or "-")
|
|
86
|
+
return " | ".join([
|
|
87
|
+
f"ingredient:{N.normalize_ingredient(t) or '?'}",
|
|
88
|
+
f"strength:{strength}",
|
|
89
|
+
f"dose_form:{N.parse_dose_form(t) or '-'}",
|
|
90
|
+
f"route:{N.parse_route(t) or '-'}",
|
|
91
|
+
f"combination:{'yes' if combo else 'no'}",
|
|
92
|
+
f"target_tty:{TARGET_TTY}",
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _evidence(stmt: ClinicalStatement) -> dict:
|
|
97
|
+
t = stmt.raw_text
|
|
98
|
+
combo = N.is_combination(t)
|
|
99
|
+
return {
|
|
100
|
+
"term": t,
|
|
101
|
+
"ingredient": N.normalize_ingredient(t),
|
|
102
|
+
"strength": N.parse_strength(t),
|
|
103
|
+
"strengths": N.parse_strengths(t), # all strengths (combos carry >1)
|
|
104
|
+
"is_combination": combo, # multi-ingredient -> combination SCD
|
|
105
|
+
"dose_form": N.parse_dose_form(t),
|
|
106
|
+
"route": N.parse_route(t),
|
|
107
|
+
"target_tty": TARGET_TTY,
|
|
108
|
+
"section_context": stmt.context,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_gap(stmt: ClinicalStatement, pattern: RxnormGapPattern) -> Gap:
|
|
113
|
+
gap = Gap(type=pattern.value, match_key=_match_key(stmt), evidence=_evidence(stmt))
|
|
114
|
+
cc = concept_coding(stmt)
|
|
115
|
+
|
|
116
|
+
if pattern == RxnormGapPattern.NDC_ONLY and cc:
|
|
117
|
+
gap.evidence["ndc_code"] = cc.code
|
|
118
|
+
gap.evidence["ndc_system"] = cc.raw_system
|
|
119
|
+
gap.note = ("Use the NDC↔RxNorm crosswalk (RxNorm release / REST API) — "
|
|
120
|
+
"deterministic; LLM only if multiple RxCUIs map.")
|
|
121
|
+
elif pattern in _SOURCE_VOCAB_PATTERN.values() and cc:
|
|
122
|
+
vocab = cc.system.value if cc.system else "source vocabulary"
|
|
123
|
+
gap.evidence["source_code"] = cc.code
|
|
124
|
+
gap.evidence["source_vocab"] = vocab
|
|
125
|
+
gap.evidence["source_system"] = cc.raw_system
|
|
126
|
+
# serialize.py builds a crosswalk hint from local_code/local_system
|
|
127
|
+
gap.evidence["local_code"] = cc.code
|
|
128
|
+
gap.evidence["local_system"] = vocab
|
|
129
|
+
gap.note = (f"Crosswalk {vocab} → RxNorm via the UMLS Metathesaurus "
|
|
130
|
+
f"(shared CUI) or the vendor's RxNorm map — deterministic; "
|
|
131
|
+
f"LLM only if the mapping is ambiguous or 1→many.")
|
|
132
|
+
elif pattern == RxnormGapPattern.LOCAL_CODE_ONLY and cc:
|
|
133
|
+
gap.evidence["local_code"] = cc.code
|
|
134
|
+
gap.evidence["local_system"] = cc.raw_system
|
|
135
|
+
gap.note = ("Crosswalk local/formulary code → RxNorm first; else search by "
|
|
136
|
+
"ingredient + strength + dose form → SCD/SBD.")
|
|
137
|
+
elif pattern == RxnormGapPattern.NULLFLAVOR:
|
|
138
|
+
nf = next((c for c in stmt.codings if c.system == System.NULLFLAVOR), None)
|
|
139
|
+
gap.evidence["null_flavor"] = nf.raw_system if nf else None
|
|
140
|
+
gap.note = "Drug name recovered from narrative; search ingredient+strength+form → SCD/SBD."
|
|
141
|
+
else:
|
|
142
|
+
gap.note = "Drug name only; parse strength/form and search → SCD/SBD."
|
|
143
|
+
return gap
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""SNOMED CT gap extraction.
|
|
2
|
+
|
|
3
|
+
Mirrors systems/loinc but for SNOMED CT: SNOMED is the *answer/value* (diagnosis,
|
|
4
|
+
allergen, procedure, organism…), not the question. "Expected" is decided by the
|
|
5
|
+
section/slot → target hierarchy (see config/snomed_section_bindings.yaml and
|
|
6
|
+
SNOMED_pattern_analysis.md). Emits ONLY codes needing correction/filling.
|
|
7
|
+
|
|
8
|
+
DB lookup (active/inactive, hierarchy, ECL/value-set candidate search) and LLM
|
|
9
|
+
ranking remain placeholders; this layer prepares everything they need.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .handler import SnomedGapExtractor, SnomedGapPattern
|
|
13
|
+
from .patterns import classify, is_snomed_expected
|
|
14
|
+
|
|
15
|
+
__all__ = ["SnomedGapExtractor", "SnomedGapPattern", "classify", "is_snomed_expected"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""SnomedGapExtractor — SNOMED-only, gaps-only entry point.
|
|
2
|
+
|
|
3
|
+
Same shape as LoincGapExtractor: document/IR in, gap NormalizedRecords out
|
|
4
|
+
(only concepts needing correction/filling). resolved_* left for the DB/LLM step.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Iterable, Optional
|
|
10
|
+
|
|
11
|
+
from ...core.models import ClinicalStatement, NormalizedRecord, System
|
|
12
|
+
from ...pipeline import parse_document
|
|
13
|
+
from . import patterns
|
|
14
|
+
from .patterns import SnomedGapPattern
|
|
15
|
+
|
|
16
|
+
__all__ = ["SnomedGapExtractor", "SnomedGapPattern"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SnomedGapExtractor:
|
|
20
|
+
def extract(
|
|
21
|
+
self,
|
|
22
|
+
statements: Iterable[ClinicalStatement],
|
|
23
|
+
contexts: Optional[set[str]] = None,
|
|
24
|
+
include_narrative: bool = False,
|
|
25
|
+
apply_dict: bool = False, # accepted for CLI parity; unused
|
|
26
|
+
) -> list[NormalizedRecord]:
|
|
27
|
+
records: list[NormalizedRecord] = []
|
|
28
|
+
for stmt in statements:
|
|
29
|
+
if contexts is not None and stmt.context not in contexts:
|
|
30
|
+
continue
|
|
31
|
+
pattern = patterns.classify(stmt)
|
|
32
|
+
if pattern is None:
|
|
33
|
+
continue
|
|
34
|
+
if pattern is SnomedGapPattern.NARRATIVE_ONLY and not include_narrative:
|
|
35
|
+
continue
|
|
36
|
+
gap = patterns.parse_gap(stmt, pattern)
|
|
37
|
+
records.append(NormalizedRecord(
|
|
38
|
+
statement=stmt,
|
|
39
|
+
resolved_system=System.SNOMEDCT,
|
|
40
|
+
resolved_code=None,
|
|
41
|
+
gap=gap,
|
|
42
|
+
))
|
|
43
|
+
return records
|
|
44
|
+
|
|
45
|
+
def extract_from_document(
|
|
46
|
+
self,
|
|
47
|
+
document: str | bytes,
|
|
48
|
+
contexts: Optional[set[str]] = None,
|
|
49
|
+
include_narrative: bool = False,
|
|
50
|
+
apply_dict: bool = False, # accepted for CLI parity; unused
|
|
51
|
+
) -> list[NormalizedRecord]:
|
|
52
|
+
return self.extract(parse_document(document), contexts=contexts,
|
|
53
|
+
include_narrative=include_narrative)
|
|
54
|
+
|
|
55
|
+
def summary(self, records: list[NormalizedRecord]) -> dict[str, int]:
|
|
56
|
+
out: dict[str, int] = {}
|
|
57
|
+
for r in records:
|
|
58
|
+
t = r.gap.type if r.gap else "RESOLVED"
|
|
59
|
+
out[t] = out.get(t, 0) + 1
|
|
60
|
+
return out
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Section/slot → target SNOMED hierarchy + ECL (the candidate constraint).
|
|
2
|
+
|
|
3
|
+
Hard-codes the bindings from config/snomed_section_bindings.yaml (kept in code so
|
|
4
|
+
the extractor has no YAML dependency at runtime; the YAML is the human/DB-layer
|
|
5
|
+
reference). All root SCTIDs are Verhoeff-verified.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
# top-level hierarchy roots (Verhoeff-verified)
|
|
14
|
+
CLINICAL_FINDING = "404684003"
|
|
15
|
+
PROCEDURE = "71388002"
|
|
16
|
+
SITUATION = "243796009"
|
|
17
|
+
OBSERVABLE = "363787002"
|
|
18
|
+
BODY_STRUCTURE = "123037004"
|
|
19
|
+
ORGANISM = "410607006"
|
|
20
|
+
SUBSTANCE = "105590001"
|
|
21
|
+
PRODUCT = "373873005"
|
|
22
|
+
SPECIMEN = "123038009"
|
|
23
|
+
QUALIFIER = "362981000"
|
|
24
|
+
|
|
25
|
+
# context (as tagged by the adapter) -> (human label, ECL filter)
|
|
26
|
+
CONTEXT_HIERARCHY: dict[str, tuple[str, str]] = {
|
|
27
|
+
"problem": ("Clinical finding", f"< {CLINICAL_FINDING}"),
|
|
28
|
+
"family_history": ("Finding/Situation", f"< {CLINICAL_FINDING} OR < {SITUATION}"),
|
|
29
|
+
"allergy_substance": ("Substance/Product", f"< {SUBSTANCE} OR < {PRODUCT}"),
|
|
30
|
+
"allergy_reaction": ("Clinical finding", f"< {CLINICAL_FINDING}"),
|
|
31
|
+
"procedure": ("Procedure", f"< {PROCEDURE}"),
|
|
32
|
+
"body_site": ("Body structure", f"< {BODY_STRUCTURE}"),
|
|
33
|
+
"result_organism": ("Organism", f"< {ORGANISM}"),
|
|
34
|
+
"social_history": ("Clinical finding", f"< {CLINICAL_FINDING}"),
|
|
35
|
+
"functional_status": ("Clinical finding", f"< {CLINICAL_FINDING}"),
|
|
36
|
+
"specimen": ("Specimen", f"< {SPECIMEN}"),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# contexts where a SNOMED concept is expected in the value/answer slot
|
|
40
|
+
SNOMED_CONTEXTS = set(CONTEXT_HIERARCHY)
|
|
41
|
+
|
|
42
|
+
# SNOMED FSN semantic tag (the trailing "(...)") -> top-level hierarchy root.
|
|
43
|
+
# The document often carries the FSN, e.g. "Anticoagulant therapy (procedure)",
|
|
44
|
+
# which states the hierarchy directly — far more reliable than the section
|
|
45
|
+
# default (a Problem List legitimately holds findings, procedures AND situations).
|
|
46
|
+
_SEMANTIC_TAG_ROOT: dict[str, str] = {
|
|
47
|
+
"finding": CLINICAL_FINDING,
|
|
48
|
+
"disorder": CLINICAL_FINDING,
|
|
49
|
+
"procedure": PROCEDURE,
|
|
50
|
+
"regime/therapy": PROCEDURE,
|
|
51
|
+
"situation": SITUATION,
|
|
52
|
+
"observable entity": OBSERVABLE,
|
|
53
|
+
"body structure": BODY_STRUCTURE,
|
|
54
|
+
"morphologic abnormality": BODY_STRUCTURE,
|
|
55
|
+
"organism": ORGANISM,
|
|
56
|
+
"substance": SUBSTANCE,
|
|
57
|
+
"product": PRODUCT,
|
|
58
|
+
"medicinal product": PRODUCT,
|
|
59
|
+
"clinical drug": PRODUCT,
|
|
60
|
+
"specimen": SPECIMEN,
|
|
61
|
+
"qualifier value": QUALIFIER,
|
|
62
|
+
}
|
|
63
|
+
_ROOT_LABEL: dict[str, str] = {
|
|
64
|
+
CLINICAL_FINDING: "Clinical finding", PROCEDURE: "Procedure",
|
|
65
|
+
SITUATION: "Situation", OBSERVABLE: "Observable entity",
|
|
66
|
+
BODY_STRUCTURE: "Body structure", ORGANISM: "Organism",
|
|
67
|
+
SUBSTANCE: "Substance", PRODUCT: "Product", SPECIMEN: "Specimen",
|
|
68
|
+
QUALIFIER: "Qualifier value",
|
|
69
|
+
}
|
|
70
|
+
_TAG_RE = re.compile(r"\(([^()]+)\)\s*$")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def hierarchy_for(context: str) -> Optional[tuple[str, str]]:
|
|
74
|
+
"""Return (label, ecl) for a context, or None if SNOMED isn't expected."""
|
|
75
|
+
return CONTEXT_HIERARCHY.get(context)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def hierarchy_from_semantic_tag(term: Optional[str]) -> Optional[tuple[str, str]]:
|
|
79
|
+
"""If a term ends in a SNOMED FSN semantic tag, return that hierarchy.
|
|
80
|
+
'Depression screening (procedure)' -> ('Procedure', '< 71388002')."""
|
|
81
|
+
if not term:
|
|
82
|
+
return None
|
|
83
|
+
m = _TAG_RE.search(term.strip())
|
|
84
|
+
if not m:
|
|
85
|
+
return None
|
|
86
|
+
root = _SEMANTIC_TAG_ROOT.get(m.group(1).strip().lower())
|
|
87
|
+
if not root:
|
|
88
|
+
return None
|
|
89
|
+
return (_ROOT_LABEL[root], f"< {root}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve_hierarchy(context: str, term: Optional[str] = None) -> Optional[tuple[str, str]]:
|
|
93
|
+
"""Hierarchy for a gap: the FSN semantic tag in the term wins (the document
|
|
94
|
+
states it), else the section default. This stops a Problem List procedure or
|
|
95
|
+
situation being force-fit to 'Clinical finding'."""
|
|
96
|
+
return hierarchy_from_semantic_tag(term) or hierarchy_for(context)
|