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