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