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,43 @@
1
+ """ICD-10-CM format validation + billing-specificity qualifiers.
2
+
3
+ ICD-10-CM has no check digit; a code is *well-formed* if it matches the shape
4
+ (letter, 2 digits, optional dotted alphanumeric subclassification, up to 7 chars).
5
+ Whether it is *billable* (vs a non-billable category) needs the DB.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Optional
12
+
13
+ # First char is any letter A-Z. U was historically reserved, but ICD-10-CM now
14
+ # uses it: U07.1 (COVID-19), U09.9 (post-COVID condition), U07.0 (vaping).
15
+ _ICD10CM = re.compile(r"^[A-Z][0-9][0-9A-Z](\.[0-9A-Z]{1,4})?$")
16
+
17
+ _LATERALITY = ["bilateral", "left", "right"]
18
+ _ENCOUNTER = ["initial", "subsequent", "sequela"]
19
+ _COMPLICATIONS = [
20
+ "without complication", "with hyperglycemia", "with hypoglycemia",
21
+ "with neuropathy", "with nephropathy", "with retinopathy",
22
+ "with ketoacidosis", "with complication",
23
+ ]
24
+
25
+
26
+ def is_valid_icd10cm(code: Optional[str]) -> bool:
27
+ return bool(code and _ICD10CM.match(code.strip().upper()))
28
+
29
+
30
+ def _find(text: str, options) -> Optional[str]:
31
+ for o in options:
32
+ if o in text:
33
+ return o
34
+ return None
35
+
36
+
37
+ def extract_qualifiers(text: Optional[str]) -> dict:
38
+ t = (text or "").lower()
39
+ return {
40
+ "laterality": _find(t, _LATERALITY),
41
+ "encounter": _find(t, _ENCOUNTER),
42
+ "complication": _find(t, _COMPLICATIONS),
43
+ }
@@ -0,0 +1,133 @@
1
+ """Per-pattern ICD-10-CM gap classification + parsers (diagnosis value 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 icdformat as F
11
+
12
+ # diagnosis sections where an ICD-10-CM code is expected (value slot)
13
+ ICD_CONTEXTS = {"problem", "encounters"}
14
+ STRUCTURAL = HL7_STRUCTURAL | {System.NULLFLAVOR}
15
+
16
+
17
+ class Icd10cmGapPattern(str, Enum):
18
+ SNOMED_ONLY = "SNOMED_ONLY" # SNOMED in the value, no ICD -> SNOMED→ICD map
19
+ LOCAL_CODE_ONLY = "LOCAL_CODE_ONLY" # local/ICD-9/other code, no ICD-10-CM
20
+ NULLFLAVOR = "NULLFLAVOR"
21
+ DISPLAY_ONLY = "DISPLAY_ONLY"
22
+ NARRATIVE_ONLY = "NARRATIVE_ONLY"
23
+ BAD_CODE = "BAD_CODE" # ICD-shaped but malformed
24
+
25
+
26
+ def is_icd_expected(stmt: ClinicalStatement) -> bool:
27
+ return stmt.context in ICD_CONTEXTS and stmt.value is not None
28
+
29
+
30
+ def _value_codings(stmt: ClinicalStatement):
31
+ return stmt.value.codings if (stmt.value and stmt.value.codings) else (
32
+ [stmt.value.coded_value] if (stmt.value and stmt.value.coded_value) else [])
33
+
34
+
35
+ def icd_coding(stmt: ClinicalStatement) -> Optional[CodingCandidate]:
36
+ """The ICD-10-CM coding on the value, if present (root or translation)."""
37
+ return next((c for c in _value_codings(stmt)
38
+ if c.system == System.ICD10CM and c.code), None)
39
+
40
+
41
+ def concept_coding(stmt: ClinicalStatement) -> Optional[CodingCandidate]:
42
+ """The coding to recover from: prefer ICD-10-CM, else the best alternative
43
+ present (SNOMED / ICD-9 / local)."""
44
+ icd = icd_coding(stmt)
45
+ if icd:
46
+ return icd
47
+ return next((c for c in _value_codings(stmt)
48
+ if c.code and c.system not in STRUCTURAL), None)
49
+
50
+
51
+ def _has_nullflavor(stmt: ClinicalStatement) -> bool:
52
+ return any(c.system == System.NULLFLAVOR for c in _value_codings(stmt))
53
+
54
+
55
+ def classify(stmt: ClinicalStatement) -> Optional[Icd10cmGapPattern]:
56
+ if not is_icd_expected(stmt):
57
+ return None
58
+
59
+ icd = icd_coding(stmt)
60
+ if icd:
61
+ return None if F.is_valid_icd10cm(icd.code) else Icd10cmGapPattern.BAD_CODE
62
+
63
+ other = next((c for c in _value_codings(stmt)
64
+ if c.code and c.system not in STRUCTURAL), None)
65
+ if other and other.system == System.SNOMEDCT:
66
+ return Icd10cmGapPattern.SNOMED_ONLY
67
+ if other: # ICD-9 / local / anything else
68
+ return Icd10cmGapPattern.LOCAL_CODE_ONLY
69
+
70
+ if _has_nullflavor(stmt):
71
+ return Icd10cmGapPattern.NULLFLAVOR
72
+ if stmt.raw_text or (stmt.value and (stmt.value.original_text or stmt.value.raw)):
73
+ if "/text" in (stmt.source_ref or ""):
74
+ return Icd10cmGapPattern.NARRATIVE_ONLY
75
+ return Icd10cmGapPattern.DISPLAY_ONLY
76
+ return None
77
+
78
+
79
+ # --------------------------------------------------------------------------
80
+
81
+ def _term(stmt: ClinicalStatement) -> Optional[str]:
82
+ cc = concept_coding(stmt)
83
+ if cc and cc.display:
84
+ return cc.display
85
+ if stmt.value and (stmt.value.original_text or stmt.value.raw):
86
+ return stmt.value.original_text or stmt.value.raw
87
+ return stmt.raw_text
88
+
89
+
90
+ def _match_key(stmt: ClinicalStatement, term: Optional[str]) -> str:
91
+ q = F.extract_qualifiers(term)
92
+ return " | ".join([
93
+ f"condition:{(term or '?').lower()}",
94
+ f"laterality:{q['laterality'] or '-'}",
95
+ f"encounter:{q['encounter'] or '-'}",
96
+ f"complication:{q['complication'] or '-'}",
97
+ ])
98
+
99
+
100
+ def parse_gap(stmt: ClinicalStatement, pattern: Icd10cmGapPattern) -> Gap:
101
+ term = _term(stmt)
102
+ q = F.extract_qualifiers(term)
103
+ ev = {
104
+ "term": term,
105
+ "laterality": q["laterality"],
106
+ "encounter": q["encounter"],
107
+ "complication": q["complication"],
108
+ "section_context": stmt.context,
109
+ }
110
+ gap = Gap(type=pattern.value, match_key=_match_key(stmt, term), evidence=ev)
111
+ cc = concept_coding(stmt)
112
+
113
+ if pattern == Icd10cmGapPattern.SNOMED_ONLY and cc:
114
+ gap.evidence["snomed_code"] = cc.code
115
+ gap.evidence["snomed_system"] = (cc.system.value if cc.system else None)
116
+ gap.note = ("Use the official SNOMED CT → ICD-10-CM map (NLM) — mostly "
117
+ "deterministic; LLM to pick among targets / add specificity.")
118
+ elif pattern == Icd10cmGapPattern.LOCAL_CODE_ONLY and cc:
119
+ gap.evidence["local_code"] = cc.code
120
+ gap.evidence["local_system"] = (cc.system.value if cc.system else cc.raw_system)
121
+ gap.note = ("Crosswalk the source code → ICD-10-CM (ICD-9 → use CMS GEMs); "
122
+ "else text + qualifier search.")
123
+ elif pattern == Icd10cmGapPattern.BAD_CODE and cc:
124
+ gap.evidence["malformed_code"] = cc.code
125
+ gap.note = "ICD-shaped but malformed; re-resolve via text + qualifiers."
126
+ elif pattern == Icd10cmGapPattern.NULLFLAVOR:
127
+ nf = next((c for c in _value_codings(stmt)
128
+ if c.system == System.NULLFLAVOR), None)
129
+ gap.evidence["null_flavor"] = nf.raw_system if nf else None
130
+ gap.note = "Diagnosis recovered from narrative; text + qualifier search."
131
+ else:
132
+ gap.note = "Diagnosis text only; search ICD-10-CM by term + qualifiers."
133
+ return gap
@@ -0,0 +1,14 @@
1
+ """LOINC-specific gap extraction.
2
+
3
+ Consumes the format-agnostic IR and emits ONLY the statements that need a LOINC
4
+ code corrected or filled — classified per pattern, each with a recovery evidence
5
+ packet + composite match key. Valid, already-coded LOINCs are skipped.
6
+
7
+ DB lookup and LLM ranking remain placeholders; this layer prepares everything
8
+ the recovery step needs.
9
+ """
10
+
11
+ from .handler import LoincGapExtractor, GapPattern
12
+ from .patterns import classify, is_loinc_expected
13
+
14
+ __all__ = ["LoincGapExtractor", "GapPattern", "classify", "is_loinc_expected"]
@@ -0,0 +1,199 @@
1
+ """Derive LOINC disambiguating axes from a ClinicalStatement.
2
+
3
+ Turns the raw IR (unit, value shape, panel, text) into the axis hints that
4
+ constrain a LOINC candidate set (LOINC_pattern_analysis.md §2.5):
5
+ unit -> Property value -> Scale panel/text/section -> System(specimen)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ from ...core.models import ClinicalStatement, QN, ORD, NOM, NAR
13
+ from .normalize import normalize_analyte
14
+
15
+ # UCUM unit -> LOINC Property hint
16
+ _UNIT_PROPERTY = {
17
+ "mg/dl": "MCnc", "g/dl": "MCnc", "ug/dl": "MCnc", "ng/ml": "MCnc",
18
+ "mmol/l": "SCnc", "meq/l": "SCnc", "umol/l": "SCnc", "u/l": "CCnc",
19
+ "%": "MFr", "fl": "EntVol", "pg": "EntMass",
20
+ "mm[hg]": "Pres", "/min": "NRat", "cel": "Temp", "kg": "Mass",
21
+ "cm": "Len", "kg/m2": "Ratio", "[degf]": "Temp", "/min)": "NRat",
22
+ }
23
+
24
+ _SPECIMEN_HINTS = {
25
+ "serum": "Ser", "plasma": "Plas", "ser/plas": "Ser/Plas",
26
+ "blood": "Bld", "whole blood": "Bld", "urine": "Urine",
27
+ "csf": "CSF", "cerebrospinal": "CSF", "stool": "Stool",
28
+ "saliva": "Saliva", "sputum": "Sputum",
29
+ }
30
+
31
+ # SNOMED specimen codes -> LOINC System-axis hint (explicit <specimen> element)
32
+ _SPECIMEN_CODE = {
33
+ "119364003": "Ser", "119361006": "Plas", "119297000": "Bld",
34
+ "122555007": "Bld", "122575003": "Urine", "258574006": "Bld",
35
+ "258450006": "CSF", "119339001": "Stool", "119342007": "Saliva",
36
+ "119334006": "Sputum",
37
+ }
38
+
39
+ # A *generic* "blood" specimen is the COLLECTED sample, not a reliable LOINC
40
+ # System axis. Routine chemistry drawn from blood is reported in Serum/Plasma,
41
+ # so a generic blood specimen must NOT be trusted as "Bld" — LOINC reserves
42
+ # "Bld" for true whole-blood analytes (HbA1c, blood gases, lead, …). We demote
43
+ # it and let panel/analyte context decide.
44
+ _GENERIC_BLOOD = "Bld"
45
+
46
+ # panels (LOINC and CPT) whose membership implies a serum/plasma specimen
47
+ _SERUM_PANELS = {
48
+ "24323-8", "24321-2", "24320-4", # LOINC: CMP, BMP, electrolytes
49
+ "24325-3", "24331-1", # LOINC: hepatic function, lipid
50
+ "80053", "80048", "80051", # CPT: CMP, BMP, electrolytes
51
+ "80076", "80061", # CPT: hepatic function, lipid
52
+ }
53
+
54
+
55
+ def _specimen_to_hint(display: Optional[str], code: Optional[str]) -> Optional[str]:
56
+ if code and code in _SPECIMEN_CODE:
57
+ return _SPECIMEN_CODE[code]
58
+ d = (display or "").lower()
59
+ for word, hint in _SPECIMEN_HINTS.items():
60
+ if word in d:
61
+ return hint
62
+ return display or None
63
+
64
+
65
+ # Units whose Property is ambiguous — the same unit maps to different LOINC
66
+ # Properties depending on the analyte, so it must NOT be a hard filter:
67
+ # "%" -> HbA1c is MFr, but CBC differentials are NFr, SpO2 is a sat fraction.
68
+ _AMBIGUOUS_UNITS = {"%"}
69
+
70
+
71
+ def property_from_unit(unit: Optional[str]) -> Optional[str]:
72
+ if not unit:
73
+ return None
74
+ return _UNIT_PROPERTY.get(unit.strip().lower())
75
+
76
+
77
+ def property_from_unit_conf(unit: Optional[str]) -> tuple[Optional[str], str]:
78
+ """Property hint + confidence. Ambiguous units (e.g. '%') are LOW confidence
79
+ so downstream treats them as a soft hint, not a hard filter (mirrors the
80
+ specimen-axis gating that fixed the Bld/Ser-Plas bug)."""
81
+ hint = property_from_unit(unit)
82
+ if not hint:
83
+ return (None, "none")
84
+ conf = "low" if (unit or "").strip().lower() in _AMBIGUOUS_UNITS else "high"
85
+ return (hint, conf)
86
+
87
+
88
+ def scale_from_value(stmt: ClinicalStatement) -> Optional[str]:
89
+ if stmt.value and stmt.value.type:
90
+ return stmt.value.type # QN / ORD / NOM / NAR already inferred by adapter
91
+ return None
92
+
93
+
94
+ def infer_specimen(stmt: ClinicalStatement) -> tuple[Optional[str], str, str]:
95
+ """Resolve the LOINC System axis with a priority that reflects trust. Returns
96
+ (hint, source, confidence):
97
+ 1. explicit SPECIFIC specimen (Ser/Plas/Urine/CSF/…) -> "explicit", high
98
+ 1b. explicit GENERIC "blood": -> ambiguous —
99
+ - inside a serum/plasma panel -> "Ser/Plas", "panel_over_blood", medium
100
+ - otherwise -> "Bld", "explicit_generic_blood", low
101
+ 2. analyte text ("urine", "csf", …) -> "text", medium
102
+ 3. parent panel convention (CMP/BMP/…) -> "panel", low
103
+ else -> (None, "none", "none")
104
+
105
+ Why 1b exists: a CCDA commonly carries SNOMED 119297000 "Blood specimen" (the
106
+ *collected* draw) on a chemistry panel whose analytes are actually reported in
107
+ Serum/Plasma. Trusting that generic "blood" as LOINC "Bld" picked the wrong
108
+ code (e.g. Glucose 2339-0 Bld instead of 2345-7 Ser/Plas). The panel — and the
109
+ coded siblings — are the reliable signal, so generic blood no longer overrides
110
+ a serum/plasma panel.
111
+ """
112
+ panel_is_serum = bool(stmt.panel and stmt.panel.code in _SERUM_PANELS)
113
+
114
+ # (1) explicit specimen captured from the document
115
+ explicit = None
116
+ if stmt.companion.get("specimen") or stmt.companion.get("specimen_code"):
117
+ explicit = _specimen_to_hint(stmt.companion.get("specimen"),
118
+ stmt.companion.get("specimen_code"))
119
+
120
+ # (1) explicit + SPECIFIC -> trust it fully
121
+ if explicit and explicit != _GENERIC_BLOOD:
122
+ return (explicit, "explicit", "high")
123
+
124
+ # (1b) explicit but GENERIC blood -> ambiguous; serum/plasma panel overrides
125
+ if explicit == _GENERIC_BLOOD:
126
+ if panel_is_serum:
127
+ return ("Ser/Plas", "panel_over_blood", "medium")
128
+ return (_GENERIC_BLOOD, "explicit_generic_blood", "low")
129
+
130
+ # (2) analyte text
131
+ text = (stmt.raw_text or "").lower()
132
+ for word, hint in _SPECIMEN_HINTS.items():
133
+ if word in text:
134
+ if hint == _GENERIC_BLOOD and panel_is_serum:
135
+ return ("Ser/Plas", "panel_over_blood", "medium")
136
+ return (hint, "text", "medium")
137
+
138
+ # (3) panel convention (fallback, low confidence)
139
+ if panel_is_serum:
140
+ return ("Ser/Plas", "panel", "low")
141
+
142
+ return (None, "none", "none")
143
+
144
+
145
+ def method_from_text(stmt: ClinicalStatement) -> Optional[str]:
146
+ text = (stmt.raw_text or "").lower()
147
+ for kw in ("fasting", "post", "challenge", "poc", "point of care", "random"):
148
+ if kw in text:
149
+ return kw
150
+ return None
151
+
152
+
153
+ def build_match_key(stmt: ClinicalStatement, expand_abbrev: bool = False) -> str:
154
+ """Composite disambiguation key (component + specimen + property + scale + method).
155
+
156
+ The component shows the original text; when ``expand_abbrev`` is on and an
157
+ abbreviation was expanded, it also shows the search term after an arrow
158
+ (e.g. ``BUN→urea nitrogen``) so the file's own wording stays visible.
159
+ """
160
+ raw = (stmt.raw_text or "").strip()
161
+ norm = normalize_analyte(stmt.raw_text, expand=expand_abbrev) or "?"
162
+ if not raw:
163
+ component = norm
164
+ elif norm == raw.lower():
165
+ component = raw
166
+ else:
167
+ component = f"{raw}→{norm}"
168
+ specimen_hint, _, _ = infer_specimen(stmt)
169
+ parts = [
170
+ f"component:{component}",
171
+ f"specimen:{specimen_hint or '?'}",
172
+ f"property:{property_from_unit(stmt.value.unit if stmt.value else None) or '?'}",
173
+ f"scale:{scale_from_value(stmt) or '?'}",
174
+ f"method:{method_from_text(stmt) or '-'}",
175
+ ]
176
+ return " | ".join(parts)
177
+
178
+
179
+ def evidence_packet(stmt: ClinicalStatement, expand_abbrev: bool = False) -> dict:
180
+ """Everything the recovery layer (DB search / LLM) needs to resolve a LOINC."""
181
+ unit = stmt.value.unit if stmt.value else None
182
+ specimen_hint, specimen_source, specimen_conf = infer_specimen(stmt)
183
+ property_hint, property_conf = property_from_unit_conf(unit)
184
+ return {
185
+ "analyte_raw": stmt.raw_text,
186
+ "analyte_normalized": normalize_analyte(stmt.raw_text, expand=expand_abbrev),
187
+ "unit": unit,
188
+ "property_hint": property_hint,
189
+ "property_confidence": property_conf, # high | low | none
190
+ "scale_hint": scale_from_value(stmt),
191
+ "specimen_hint": specimen_hint,
192
+ "specimen_source": specimen_source, # explicit | text | panel | none
193
+ "specimen_confidence": specimen_conf, # high | medium | low | none
194
+ "method_hint": method_from_text(stmt),
195
+ "value": stmt.value.raw if stmt.value else None,
196
+ "reference_range": stmt.companion.get("reference_range"),
197
+ "panel": (stmt.panel.code if stmt.panel else None),
198
+ "context": stmt.context,
199
+ }
@@ -0,0 +1,80 @@
1
+ """LoincGapExtractor — the LOINC-only, gaps-only entry point.
2
+
3
+ Takes either a raw document or already-parsed IR statements and returns
4
+ ``NormalizedRecord``s ONLY for observations whose LOINC needs correcting or
5
+ filling. Each record carries the gap pattern, match key, and evidence packet;
6
+ ``resolved_*`` is left for the (placeholder) DB/LLM recovery step.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Iterable, Optional
12
+
13
+ from ...core.models import ClinicalStatement, NormalizedRecord, System
14
+ from ...pipeline import parse_document
15
+ from . import patterns
16
+ from .patterns import GapPattern
17
+
18
+ __all__ = ["LoincGapExtractor", "GapPattern"]
19
+
20
+
21
+ class LoincGapExtractor:
22
+ def extract(
23
+ self,
24
+ statements: Iterable[ClinicalStatement],
25
+ contexts: Optional[set[str]] = None,
26
+ include_narrative: bool = False,
27
+ apply_dict: bool = False,
28
+ ) -> list[NormalizedRecord]:
29
+ """Return gap records ONLY (valid/coded LOINCs are skipped).
30
+
31
+ ``contexts`` optionally restricts which statement contexts are inspected
32
+ (e.g. {"result", "vital"} for a lab-focused run).
33
+
34
+ ``include_narrative`` (default False): NARRATIVE_ONLY gaps come from
35
+ human-readable narrative tables (often questionnaire text) and are
36
+ skipped by default.
37
+
38
+ ``apply_dict`` (default False): expand lab abbreviations (BUN -> urea
39
+ nitrogen) in the match key / evidence to boost LOINC text-search recall.
40
+ Off by default so output reflects the document's own wording.
41
+ """
42
+ records: list[NormalizedRecord] = []
43
+ for stmt in statements:
44
+ if contexts is not None and stmt.context not in contexts:
45
+ continue
46
+ pattern = patterns.classify(stmt)
47
+ if pattern is None:
48
+ continue # valid LOINC, or not a LOINC-expected node
49
+ if pattern is GapPattern.NARRATIVE_ONLY and not include_narrative:
50
+ continue # human-readable narrative; not a code-recovery target
51
+ gap = patterns.parse_gap(stmt, pattern, expand_abbrev=apply_dict)
52
+ records.append(NormalizedRecord(
53
+ statement=stmt,
54
+ resolved_system=System.LOINC, # the target system
55
+ resolved_code=None, # filled by recovery layer later
56
+ gap=gap,
57
+ ))
58
+ return records
59
+
60
+ def extract_from_document(
61
+ self,
62
+ document: str | bytes,
63
+ contexts: Optional[set[str]] = None,
64
+ include_narrative: bool = False,
65
+ apply_dict: bool = False,
66
+ ) -> list[NormalizedRecord]:
67
+ """Parse a CCDA/FHIR document and return LOINC gap records."""
68
+ return self.extract(
69
+ parse_document(document),
70
+ contexts=contexts,
71
+ include_narrative=include_narrative,
72
+ apply_dict=apply_dict,
73
+ )
74
+
75
+ def summary(self, records: list[NormalizedRecord]) -> dict[str, int]:
76
+ out: dict[str, int] = {}
77
+ for r in records:
78
+ t = r.gap.type if r.gap else "RESOLVED"
79
+ out[t] = out.get(t, 0) + 1
80
+ return out
@@ -0,0 +1,81 @@
1
+ """Analyte text normalization for LOINC matching.
2
+
3
+ Local codes and narrative labels are terse and abbreviated; normalize before
4
+ any text search. Whole-token abbreviation expansion only (so "Na" -> sodium but
5
+ "Nasal" is untouched).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ # common lab abbreviation -> canonical component word(s)
13
+ ABBREV = {
14
+ "na": "sodium",
15
+ "k": "potassium",
16
+ "cl": "chloride",
17
+ "co2": "carbon dioxide",
18
+ "bun": "urea nitrogen",
19
+ "cr": "creatinine",
20
+ "creat": "creatinine",
21
+ "glu": "glucose",
22
+ "ca": "calcium",
23
+ "mg": "magnesium",
24
+ "phos": "phosphate",
25
+ "hgb": "hemoglobin",
26
+ "hb": "hemoglobin",
27
+ "hct": "hematocrit",
28
+ "wbc": "leukocytes",
29
+ "rbc": "erythrocytes",
30
+ "plt": "platelets",
31
+ "a1c": "hemoglobin a1c",
32
+ "hba1c": "hemoglobin a1c",
33
+ "alt": "alanine aminotransferase",
34
+ "ast": "aspartate aminotransferase",
35
+ "alk phos": "alkaline phosphatase",
36
+ "tsh": "thyrotropin",
37
+ "ldl": "ldl cholesterol",
38
+ "hdl": "hdl cholesterol",
39
+ "trig": "triglyceride",
40
+ "inr": "inr",
41
+ "sbp": "systolic blood pressure",
42
+ "dbp": "diastolic blood pressure",
43
+ "hr": "heart rate",
44
+ "rr": "respiratory rate",
45
+ "temp": "body temperature",
46
+ "spo2": "oxygen saturation",
47
+ "bmi": "body mass index",
48
+ }
49
+
50
+ _PUNCT = re.compile(r"[^\w\s%/.\-]")
51
+ _WS = re.compile(r"\s+")
52
+
53
+
54
+ def normalize_analyte(text: str | None, expand: bool = False) -> str | None:
55
+ """Lowercase and strip noise punctuation.
56
+
57
+ ``expand`` (default False): when True, also expand known lab abbreviations
58
+ via the ABBREV dictionary (e.g. BUN -> urea nitrogen). Off by default so the
59
+ output reflects the document's own wording; enable to boost LOINC text-search
60
+ recall. Case/punctuation normalization always happens; only the dictionary
61
+ is gated.
62
+ """
63
+ if not text:
64
+ return None
65
+ t = _PUNCT.sub(" ", text.lower())
66
+ t = _WS.sub(" ", t).strip()
67
+ if not t:
68
+ return None
69
+ if not expand:
70
+ return t
71
+ # whole-string abbreviation (e.g. "alk phos", "hba1c")
72
+ if t in ABBREV:
73
+ return ABBREV[t]
74
+ # token-wise expansion, then collapse adjacent duplicate words
75
+ # (e.g. "hemoglobin a1c" -> tokens expand to "hemoglobin hemoglobin a1c")
76
+ words: list[str] = []
77
+ for tok in t.split():
78
+ for w in ABBREV.get(tok, tok).split():
79
+ if not words or words[-1] != w:
80
+ words.append(w)
81
+ return " ".join(words)