hccinfhir 0.0.1__py3-none-any.whl → 0.0.2__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.
- hccinfhir/__init__.py +12 -0
- hccinfhir/data/ra_eligible_cpt_hcpcs_2023.csv +6646 -0
- hccinfhir/data/sample_837_0.txt +175 -0
- hccinfhir/data/sample_837_1.txt +49 -0
- hccinfhir/data/sample_837_10.txt +42 -0
- hccinfhir/data/sample_837_11.txt +29 -0
- hccinfhir/data/sample_837_2.txt +41 -0
- hccinfhir/data/sample_837_3.txt +40 -0
- hccinfhir/data/sample_837_4.txt +38 -0
- hccinfhir/data/sample_837_5.txt +48 -0
- hccinfhir/data/sample_837_6.txt +52 -0
- hccinfhir/data/sample_837_7.txt +47 -0
- hccinfhir/data/sample_837_8.txt +45 -0
- hccinfhir/data/sample_837_9.txt +50 -0
- hccinfhir/data/sample_eob_200.ndjson +200 -0
- hccinfhir/extractor.py +45 -116
- hccinfhir/extractor_837.py +175 -0
- hccinfhir/extractor_fhir.py +193 -0
- hccinfhir/filter.py +43 -0
- hccinfhir/models.py +44 -0
- hccinfhir-0.0.2.dist-info/METADATA +179 -0
- hccinfhir-0.0.2.dist-info/RECORD +28 -0
- hccinfhir-0.0.1.dist-info/METADATA +0 -89
- hccinfhir-0.0.1.dist-info/RECORD +0 -10
- {hccinfhir-0.0.1.dist-info → hccinfhir-0.0.2.dist-info}/WHEEL +0 -0
- {hccinfhir-0.0.1.dist-info → hccinfhir-0.0.2.dist-info}/licenses/LICENSE +0 -0
hccinfhir/extractor.py
CHANGED
|
@@ -1,122 +1,51 @@
|
|
|
1
|
-
from
|
|
2
|
-
from
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
code: Optional[str] = None
|
|
17
|
-
display: Optional[str] = None
|
|
18
|
-
|
|
19
|
-
class CodeableConcept(BaseModel):
|
|
20
|
-
coding: Optional[List[Coding]] = []
|
|
21
|
-
|
|
22
|
-
class Period(BaseModel):
|
|
23
|
-
start: Optional[date] = None
|
|
24
|
-
end: Optional[date] = None
|
|
25
|
-
|
|
26
|
-
class Diagnosis(BaseModel):
|
|
27
|
-
sequence: Optional[int] = None
|
|
28
|
-
diagnosisCodeableConcept: Optional[CodeableConcept] = None
|
|
29
|
-
|
|
30
|
-
class CareTeamMember(BaseModel):
|
|
31
|
-
role: Optional[CodeableConcept] = None
|
|
32
|
-
qualification: Optional[CodeableConcept] = None
|
|
33
|
-
|
|
34
|
-
class EoBItem(BaseModel):
|
|
35
|
-
productOrService: Optional[CodeableConcept] = None
|
|
36
|
-
diagnosisSequence: Optional[List[int]] = None
|
|
37
|
-
servicedPeriod: Optional[Period] = None
|
|
38
|
-
|
|
39
|
-
class ExplanationOfBenefit(BaseModel):
|
|
40
|
-
model_config = ConfigDict(frozen=True)
|
|
41
|
-
resourceType: Literal["ExplanationOfBenefit"] = "ExplanationOfBenefit"
|
|
42
|
-
type: Optional[CodeableConcept] = None
|
|
43
|
-
diagnosis: Optional[List[Diagnosis]] = []
|
|
44
|
-
item: Optional[List[EoBItem]] = []
|
|
45
|
-
careTeam: Optional[List[CareTeamMember]] = []
|
|
46
|
-
billablePeriod: Optional[Period] = None
|
|
47
|
-
|
|
48
|
-
def extract_mde(eob_data: dict) -> List[dict]:
|
|
49
|
-
"""Extract medical data elements from FHIR ExplanationOfBenefit data."""
|
|
50
|
-
try:
|
|
51
|
-
eob = ExplanationOfBenefit.model_validate(eob_data)
|
|
52
|
-
|
|
53
|
-
# Get codes from CodeableConcept
|
|
54
|
-
get_code = lambda concept, system: next((
|
|
55
|
-
c.code for c in (concept.coding or [])
|
|
56
|
-
if c and c.system == system and c.code
|
|
57
|
-
), None)
|
|
58
|
-
|
|
59
|
-
# Get date from Period
|
|
60
|
-
get_date = lambda period: (
|
|
61
|
-
period.end.isoformat() if period and period.end
|
|
62
|
-
else period.start.isoformat() if period and period.start
|
|
63
|
-
else None
|
|
64
|
-
)
|
|
1
|
+
from typing import Union, List, Literal
|
|
2
|
+
from .models import ServiceLevelData
|
|
3
|
+
from .extractor_837 import extract_sld_837
|
|
4
|
+
from .extractor_fhir import extract_sld_fhir
|
|
5
|
+
|
|
6
|
+
def extract_sld(
|
|
7
|
+
data: Union[str, dict],
|
|
8
|
+
format: Literal["837", "fhir"] = "fhir"
|
|
9
|
+
) -> List[ServiceLevelData]:
|
|
10
|
+
"""
|
|
11
|
+
Unified entry point for SLD extraction with explicit format specification
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
data: Input data - string for 837, dict for FHIR
|
|
15
|
+
format: Data format - either "837" or "fhir"
|
|
65
16
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
d.sequence: get_code(d.diagnosisCodeableConcept, SYSTEMS['dx'])
|
|
69
|
-
for d in (eob.diagnosis or [])
|
|
70
|
-
if d.sequence is not None and d.diagnosisCodeableConcept
|
|
71
|
-
}
|
|
17
|
+
Returns:
|
|
18
|
+
List of ServiceLevelData
|
|
72
19
|
|
|
73
|
-
|
|
74
|
-
|
|
20
|
+
Raises:
|
|
21
|
+
ValueError: If format and data type don't match or format is invalid
|
|
22
|
+
TypeError: If data is None or wrong type
|
|
23
|
+
"""
|
|
24
|
+
if data is None:
|
|
25
|
+
raise TypeError("Input data cannot be None")
|
|
75
26
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
# Get associated data
|
|
94
|
-
dos = get_date(item.servicedPeriod) or get_date(eob.billablePeriod)
|
|
95
|
-
dx_codes = [dx_lookup[seq] for seq in (item.diagnosisSequence or [])
|
|
96
|
-
if seq in dx_lookup]
|
|
97
|
-
|
|
98
|
-
# Build result
|
|
99
|
-
result = {k: v for k, v in {
|
|
100
|
-
'procedure_code': pr_code,
|
|
101
|
-
'diagnosis_codes': dx_codes,
|
|
102
|
-
'claim_type': claim_type,
|
|
103
|
-
'provider_specialty': specialty,
|
|
104
|
-
'service_date': dos
|
|
105
|
-
}.items()}
|
|
106
|
-
|
|
107
|
-
results.append(result)
|
|
108
|
-
|
|
109
|
-
return results
|
|
110
|
-
|
|
111
|
-
except Exception as e:
|
|
112
|
-
raise ValueError(f"Error processing EOB: {str(e)}")
|
|
27
|
+
if format == "837":
|
|
28
|
+
if not isinstance(data, str) or data == "":
|
|
29
|
+
raise TypeError(f"837 format requires string input, got {type(data)}")
|
|
30
|
+
return extract_sld_837(data)
|
|
31
|
+
elif format == "fhir":
|
|
32
|
+
if not isinstance(data, dict) or data == {}:
|
|
33
|
+
raise TypeError(f"FHIR format requires dict input, got {type(data)}")
|
|
34
|
+
return extract_sld_fhir(data)
|
|
35
|
+
else:
|
|
36
|
+
raise ValueError(f'Format must be either "837" or "fhir", got {format}')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_sld_list(data: Union[List[str], List[dict]], format: Literal["837", "fhir"] = "fhir") -> List[ServiceLevelData]:
|
|
40
|
+
"""Extract SLDs from a list of FHIR EOBs"""
|
|
41
|
+
output = []
|
|
42
|
+
for item in data:
|
|
113
43
|
|
|
114
|
-
def extract_mde_list(eob_data_list: List[dict]) -> List[dict]:
|
|
115
|
-
"""Process a list of EOB data dictionaries."""
|
|
116
|
-
results = []
|
|
117
|
-
for eob_data in eob_data_list:
|
|
118
44
|
try:
|
|
119
|
-
|
|
45
|
+
output.extend(extract_sld(item, format))
|
|
46
|
+
except TypeError as e:
|
|
47
|
+
print(f"Warning: Skipping invalid types: {str(e)}")
|
|
120
48
|
except ValueError as e:
|
|
121
|
-
print(f"Warning: Skipping invalid
|
|
122
|
-
return
|
|
49
|
+
print(f"Warning: Skipping invalid values: {str(e)}")
|
|
50
|
+
return output
|
|
51
|
+
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from typing import List, Optional, Dict
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from datetime import date
|
|
4
|
+
from .models import ServiceLevelData
|
|
5
|
+
|
|
6
|
+
CLAIM_TYPES = {
|
|
7
|
+
"005010X222A1": "837P", # Professional
|
|
8
|
+
"005010X223A2": "837I" # Institutional
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class ClaimData(BaseModel):
|
|
12
|
+
"""Container for claim-level data"""
|
|
13
|
+
claim_id: Optional[str] = None
|
|
14
|
+
patient_id: Optional[str] = None
|
|
15
|
+
performing_provider_npi: Optional[str] = None
|
|
16
|
+
billing_provider_npi: Optional[str] = None
|
|
17
|
+
provider_specialty: Optional[str] = None
|
|
18
|
+
facility_type: Optional[str] = None
|
|
19
|
+
service_type: Optional[str] = None
|
|
20
|
+
claim_type: str
|
|
21
|
+
dx_lookup: Dict[str, str] = {}
|
|
22
|
+
|
|
23
|
+
def parse_date(date_str: str) -> Optional[str]:
|
|
24
|
+
"""Convert 8-digit date string to ISO format YYYY-MM-DD"""
|
|
25
|
+
if not isinstance(date_str, str) or len(date_str) != 8:
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
year, month, day = int(date_str[:4]), int(date_str[4:6]), int(date_str[6:8])
|
|
29
|
+
if not (1900 <= year <= 2100 and 1 <= month <= 12 and 1 <= day <= 31):
|
|
30
|
+
return None
|
|
31
|
+
return f"{year:04d}-{month:02d}-{day:02d}"
|
|
32
|
+
except ValueError:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
def parse_amount(amount_str: str) -> Optional[float]:
|
|
36
|
+
"""Convert string to float, return None if invalid"""
|
|
37
|
+
try:
|
|
38
|
+
return float(amount_str)
|
|
39
|
+
except (ValueError, TypeError):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def get_segment_value(segment: List[str], index: int) -> Optional[str]:
|
|
43
|
+
"""Safely get value from segment at given index"""
|
|
44
|
+
return segment[index] if len(segment) > index else None
|
|
45
|
+
|
|
46
|
+
def parse_diagnosis_codes(segment: List[str]) -> Dict[str, str]:
|
|
47
|
+
"""Extract diagnosis codes from HI segment"""
|
|
48
|
+
dx_lookup = {}
|
|
49
|
+
for pos, element in enumerate(segment[1:], 1):
|
|
50
|
+
if ':' not in element:
|
|
51
|
+
continue
|
|
52
|
+
qualifier, code = element.split(':')[:2]
|
|
53
|
+
if qualifier in ['ABK', 'ABF']: # ICD-10 qualifiers
|
|
54
|
+
dx_lookup[str(pos)] = code
|
|
55
|
+
return dx_lookup
|
|
56
|
+
|
|
57
|
+
def process_service_line(segments: List[List[str]], start_index: int) -> tuple[Optional[str], Optional[str]]:
|
|
58
|
+
"""Extract NDC and service date from service line segments"""
|
|
59
|
+
ndc = None
|
|
60
|
+
service_date = None
|
|
61
|
+
|
|
62
|
+
for seg in segments[start_index:]:
|
|
63
|
+
if seg[0] in ['LX', 'CLM', 'SE']:
|
|
64
|
+
break
|
|
65
|
+
if seg[0] == 'LIN' and len(seg) > 3 and seg[2] == 'N4':
|
|
66
|
+
ndc = seg[3]
|
|
67
|
+
elif seg[0] == 'DTP' and seg[1] == '472':
|
|
68
|
+
service_date = parse_date(seg[3])
|
|
69
|
+
if ndc and service_date:
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
return ndc, service_date
|
|
73
|
+
|
|
74
|
+
def extract_sld_837(content: str) -> List[ServiceLevelData]:
|
|
75
|
+
"""Extract service level data from 837 Professional or Institutional claims"""
|
|
76
|
+
if not content:
|
|
77
|
+
raise ValueError("Input X12 data cannot be empty")
|
|
78
|
+
|
|
79
|
+
# Split content into segments
|
|
80
|
+
segments = [seg.strip().split('*') for seg in content.split('~') if seg.strip()]
|
|
81
|
+
|
|
82
|
+
# Detect claim type from GS segment
|
|
83
|
+
claim_type = None
|
|
84
|
+
for segment in segments:
|
|
85
|
+
if segment[0] == 'GS' and len(segment) > 8:
|
|
86
|
+
claim_type = CLAIM_TYPES.get(segment[8])
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
if not claim_type:
|
|
90
|
+
raise ValueError("Invalid or unsupported 837 format")
|
|
91
|
+
|
|
92
|
+
encounters = []
|
|
93
|
+
current_data = ClaimData(claim_type=claim_type)
|
|
94
|
+
in_claim_loop = False
|
|
95
|
+
in_rendering_provider_loop = False
|
|
96
|
+
|
|
97
|
+
for i, segment in enumerate(segments):
|
|
98
|
+
if len(segment) < 2:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
seg_id = segment[0]
|
|
102
|
+
|
|
103
|
+
# Process NM1 segments (Provider and Patient info)
|
|
104
|
+
if seg_id == 'NM1':
|
|
105
|
+
if segment[1] == 'IL': # Subscriber/Patient
|
|
106
|
+
current_data.patient_id = get_segment_value(segment, 9)
|
|
107
|
+
in_claim_loop = False
|
|
108
|
+
in_rendering_provider_loop = False
|
|
109
|
+
elif segment[1] == '82' and len(segment) > 8 and segment[8] == 'XX': # Rendering Provider
|
|
110
|
+
current_data.performing_provider_npi = get_segment_value(segment, 9)
|
|
111
|
+
in_rendering_provider_loop = True
|
|
112
|
+
elif segment[1] == '85' and len(segment) > 8 and segment[8] == 'XX': # Billing Provider
|
|
113
|
+
current_data.billing_provider_npi = get_segment_value(segment, 9)
|
|
114
|
+
|
|
115
|
+
# Process Provider Specialty
|
|
116
|
+
elif seg_id == 'PRV' and segment[1] == 'PE' and in_rendering_provider_loop:
|
|
117
|
+
current_data.provider_specialty = get_segment_value(segment, 3)
|
|
118
|
+
|
|
119
|
+
# Process Claim Information
|
|
120
|
+
elif seg_id == 'CLM':
|
|
121
|
+
in_claim_loop = True
|
|
122
|
+
in_rendering_provider_loop = False
|
|
123
|
+
current_data.claim_id = segment[1] if len(segment) > 1 else None
|
|
124
|
+
|
|
125
|
+
# Parse facility and service type for institutional claims
|
|
126
|
+
if claim_type == "837I" and len(segment) > 5 and ':' in segment[5]:
|
|
127
|
+
current_data.facility_type = segment[5][0]
|
|
128
|
+
current_data.service_type = segment[5][1] if len(segment[5]) > 1 else None
|
|
129
|
+
|
|
130
|
+
# Process Diagnosis Codes
|
|
131
|
+
elif seg_id == 'HI' and in_claim_loop:
|
|
132
|
+
current_data.dx_lookup = parse_diagnosis_codes(segment)
|
|
133
|
+
|
|
134
|
+
# Process Service Lines
|
|
135
|
+
elif seg_id in ['SV1', 'SV2'] and in_claim_loop:
|
|
136
|
+
# Parse procedure info
|
|
137
|
+
proc_info = segment[1].split(':')
|
|
138
|
+
procedure_code = proc_info[1] if len(proc_info) > 1 else None
|
|
139
|
+
modifiers = proc_info[2:] if len(proc_info) > 2 else []
|
|
140
|
+
|
|
141
|
+
# Get diagnosis pointers and linked diagnoses
|
|
142
|
+
dx_pointer_pos = 7 if seg_id == 'SV1' else 11
|
|
143
|
+
dx_pointers = get_segment_value(segment, dx_pointer_pos)
|
|
144
|
+
linked_diagnoses = [
|
|
145
|
+
current_data.dx_lookup[pointer]
|
|
146
|
+
for pointer in (dx_pointers.split(',') if dx_pointers else [])
|
|
147
|
+
if pointer in current_data.dx_lookup
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
# Get service line details
|
|
151
|
+
ndc, service_date = process_service_line(segments, i)
|
|
152
|
+
|
|
153
|
+
# Create service level data
|
|
154
|
+
service_data = ServiceLevelData(
|
|
155
|
+
claim_id=current_data.claim_id,
|
|
156
|
+
procedure_code=procedure_code,
|
|
157
|
+
linked_diagnosis_codes=linked_diagnoses,
|
|
158
|
+
claim_diagnosis_codes=list(current_data.dx_lookup.values()),
|
|
159
|
+
claim_type=current_data.claim_type,
|
|
160
|
+
provider_specialty=current_data.provider_specialty,
|
|
161
|
+
performing_provider_npi=current_data.performing_provider_npi,
|
|
162
|
+
billing_provider_npi=current_data.billing_provider_npi,
|
|
163
|
+
patient_id=current_data.patient_id,
|
|
164
|
+
facility_type=current_data.facility_type,
|
|
165
|
+
service_type=current_data.service_type,
|
|
166
|
+
service_date=service_date,
|
|
167
|
+
place_of_service=get_segment_value(segment, 6) if seg_id == 'SV1' else None,
|
|
168
|
+
quantity=parse_amount(get_segment_value(segment, 4)),
|
|
169
|
+
modifiers=modifiers,
|
|
170
|
+
ndc=ndc,
|
|
171
|
+
allowed_amount=None
|
|
172
|
+
)
|
|
173
|
+
encounters.append(service_data)
|
|
174
|
+
|
|
175
|
+
return encounters
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from pydantic import BaseModel, ConfigDict, Field, AliasChoices
|
|
2
|
+
from typing import List, Optional, Literal, Dict
|
|
3
|
+
from datetime import date
|
|
4
|
+
from .models import ServiceLevelData
|
|
5
|
+
|
|
6
|
+
SYSTEMS = {
|
|
7
|
+
'diagnosis': {
|
|
8
|
+
'icd10cm': 'http://hl7.org/fhir/sid/icd-10-cm',
|
|
9
|
+
'icd10': 'http://hl7.org/fhir/sid/icd-10'
|
|
10
|
+
},
|
|
11
|
+
'procedures': {
|
|
12
|
+
'hcpcs': 'https://bluebutton.cms.gov/resources/codesystem/hcpcs'
|
|
13
|
+
},
|
|
14
|
+
'identifiers': {
|
|
15
|
+
'npi': 'http://hl7.org/fhir/sid/us-npi',
|
|
16
|
+
'ndc': 'http://hl7.org/fhir/sid/ndc'
|
|
17
|
+
},
|
|
18
|
+
'context': {
|
|
19
|
+
'specialty': 'https://bluebutton.cms.gov/resources/variables/prvdr_spclty',
|
|
20
|
+
'role': 'http://hl7.org/fhir/us/carin-bb/CodeSystem/C4BBClaimCareTeamRole',
|
|
21
|
+
'claim_type': 'https://bluebutton.cms.gov/resources/variables/nch_clm_type_cd',
|
|
22
|
+
'facility': 'https://bluebutton.cms.gov/resources/variables/clm_fac_type_cd',
|
|
23
|
+
'service': 'https://bluebutton.cms.gov/resources/variables/clm_srvc_clsfctn_type_cd',
|
|
24
|
+
'place': 'https://bluebutton.cms.gov/resources/variables/line_place_of_srvc_cd'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class Coding(BaseModel):
|
|
29
|
+
system: Optional[str] = None
|
|
30
|
+
code: Optional[str] = None
|
|
31
|
+
display: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
class Extension(BaseModel):
|
|
34
|
+
url: str
|
|
35
|
+
valueCoding: Optional[dict] = None
|
|
36
|
+
|
|
37
|
+
class ExtensionMixin(BaseModel):
|
|
38
|
+
extension: Optional[List[Extension]] = None
|
|
39
|
+
|
|
40
|
+
def get_extension_code(self, system_url: str) -> Optional[str]:
|
|
41
|
+
"""Extract code from extensions for a specific system URL"""
|
|
42
|
+
if not self.extension:
|
|
43
|
+
return None
|
|
44
|
+
return next((
|
|
45
|
+
ext.valueCoding.get('code')
|
|
46
|
+
for ext in self.extension
|
|
47
|
+
if ext.url == system_url and ext.valueCoding
|
|
48
|
+
), None)
|
|
49
|
+
|
|
50
|
+
class CodeableConcept(ExtensionMixin):
|
|
51
|
+
coding: Optional[List[Coding]] = None
|
|
52
|
+
|
|
53
|
+
def get_code(self, system: str) -> Optional[str]:
|
|
54
|
+
"""Extract code for a specific coding system"""
|
|
55
|
+
if not self.coding:
|
|
56
|
+
return None
|
|
57
|
+
return next((c.code for c in self.coding if c and c.system == system and c.code), None)
|
|
58
|
+
|
|
59
|
+
class Period(BaseModel):
|
|
60
|
+
start: Optional[date] = None
|
|
61
|
+
end: Optional[date] = None
|
|
62
|
+
|
|
63
|
+
def get_service_date(self) -> Optional[str]:
|
|
64
|
+
"""Return the most specific date available"""
|
|
65
|
+
return self.end.isoformat() if self.end else self.start.isoformat() if self.start else None
|
|
66
|
+
|
|
67
|
+
class Diagnosis(BaseModel):
|
|
68
|
+
sequence: int
|
|
69
|
+
diagnosisCodeableConcept: CodeableConcept
|
|
70
|
+
|
|
71
|
+
class CareTeamMember(BaseModel):
|
|
72
|
+
role: CodeableConcept
|
|
73
|
+
qualification: Optional[CodeableConcept] = None
|
|
74
|
+
provider: Optional[dict] = None
|
|
75
|
+
|
|
76
|
+
class EoBItem(BaseModel):
|
|
77
|
+
productOrService: Optional[CodeableConcept] = Field(None, validation_alias=AliasChoices('service', 'productOrService'))
|
|
78
|
+
quantity: Optional[dict] = None
|
|
79
|
+
diagnosisSequence: Optional[List[int]] = None
|
|
80
|
+
servicedPeriod: Optional[Period] = None
|
|
81
|
+
locationCodeableConcept: Optional[CodeableConcept] = None
|
|
82
|
+
modifier: Optional[List[CodeableConcept]] = None
|
|
83
|
+
adjudication: Optional[List[dict]] = None
|
|
84
|
+
|
|
85
|
+
class Facility(ExtensionMixin):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
class ExplanationOfBenefit(ExtensionMixin):
|
|
89
|
+
model_config = ConfigDict(frozen=True)
|
|
90
|
+
resourceType: Literal["ExplanationOfBenefit"] = "ExplanationOfBenefit"
|
|
91
|
+
id: Optional[str] = None
|
|
92
|
+
type: Optional[CodeableConcept] = None
|
|
93
|
+
diagnosis: Optional[List[Diagnosis]] = []
|
|
94
|
+
item: Optional[List[EoBItem]] = []
|
|
95
|
+
careTeam: Optional[List[CareTeamMember]] = []
|
|
96
|
+
billablePeriod: Optional[Period] = None
|
|
97
|
+
patient: Optional[dict] = None
|
|
98
|
+
facility: Optional[Facility] = None
|
|
99
|
+
contained: Optional[List[dict]] = None
|
|
100
|
+
|
|
101
|
+
def get_diagnosis_codes(self) -> Dict[int, str]:
|
|
102
|
+
"""Extract all diagnosis codes with their sequences"""
|
|
103
|
+
dx_codes = {}
|
|
104
|
+
for dx in self.diagnosis or []:
|
|
105
|
+
code = (dx.diagnosisCodeableConcept.get_code(SYSTEMS['diagnosis']['icd10cm']) or
|
|
106
|
+
dx.diagnosisCodeableConcept.get_code(SYSTEMS['diagnosis']['icd10']))
|
|
107
|
+
if code:
|
|
108
|
+
dx_codes[dx.sequence] = code
|
|
109
|
+
return dx_codes
|
|
110
|
+
|
|
111
|
+
def get_rendering_provider(self) -> Optional[CareTeamMember]:
|
|
112
|
+
"""Get the rendering provider from the care team"""
|
|
113
|
+
return next((
|
|
114
|
+
m for m in self.careTeam or []
|
|
115
|
+
if m.role.get_code(SYSTEMS['context']['role']) in {'performing', 'rendering'}
|
|
116
|
+
), None)
|
|
117
|
+
|
|
118
|
+
def get_billing_npi(self) -> Optional[str]:
|
|
119
|
+
"""Extract billing provider NPI from contained resources"""
|
|
120
|
+
return next((
|
|
121
|
+
i.get('value')
|
|
122
|
+
for c in (self.contained or [])
|
|
123
|
+
for i in c.get('identifier', [])
|
|
124
|
+
if i.get('system') == SYSTEMS['identifiers']['npi']
|
|
125
|
+
), None)
|
|
126
|
+
|
|
127
|
+
def extract_sld_fhir(eob_data: dict) -> List[ServiceLevelData]:
|
|
128
|
+
try:
|
|
129
|
+
eob = ExplanationOfBenefit.model_validate(eob_data)
|
|
130
|
+
dx_lookup = eob.get_diagnosis_codes()
|
|
131
|
+
rendering_provider = eob.get_rendering_provider()
|
|
132
|
+
|
|
133
|
+
common_data = {
|
|
134
|
+
'claim_id': eob.id,
|
|
135
|
+
'claim_type': eob.type.get_code(SYSTEMS['context']['claim_type']) if eob.type else None,
|
|
136
|
+
'provider_specialty': (rendering_provider.qualification.get_code(SYSTEMS['context']['specialty'])
|
|
137
|
+
if rendering_provider and rendering_provider.qualification else None),
|
|
138
|
+
'performing_provider_npi': (rendering_provider.provider.get('identifier', {}).get('value')
|
|
139
|
+
if rendering_provider else None),
|
|
140
|
+
'patient_id': eob.patient.get('reference', '').split('/')[-1] if eob.patient else None,
|
|
141
|
+
'facility_type': (eob.facility.get_extension_code(SYSTEMS['context']['facility'])
|
|
142
|
+
if eob.facility else None),
|
|
143
|
+
'service_type': (eob.type.get_extension_code(SYSTEMS['context']['service']) or
|
|
144
|
+
eob.type.get_code(SYSTEMS['context']['service']) if eob.type else None),
|
|
145
|
+
'billing_provider_npi': eob.get_billing_npi()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
results = []
|
|
149
|
+
for item in eob.item or []:
|
|
150
|
+
if not item.productOrService:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
service_data = {
|
|
154
|
+
**common_data,
|
|
155
|
+
'procedure_code': item.productOrService.get_code(SYSTEMS['procedures']['hcpcs']),
|
|
156
|
+
'ndc': (item.productOrService.get_code(SYSTEMS['identifiers']['ndc']) or
|
|
157
|
+
item.productOrService.get_extension_code(SYSTEMS['identifiers']['ndc'])),
|
|
158
|
+
'quantity': item.quantity.get('value') if item.quantity else None,
|
|
159
|
+
'linked_diagnosis_codes': [dx_lookup[seq] for seq in (item.diagnosisSequence or []) if seq in dx_lookup],
|
|
160
|
+
'claim_diagnosis_codes': list(dx_lookup.values()),
|
|
161
|
+
'service_date': (item.servicedPeriod.get_service_date() if item.servicedPeriod else
|
|
162
|
+
eob.billablePeriod.get_service_date() if eob.billablePeriod else None),
|
|
163
|
+
'place_of_service': item.locationCodeableConcept.get_code(SYSTEMS['context']['place'])
|
|
164
|
+
if item.locationCodeableConcept else None,
|
|
165
|
+
'modifiers': [m.get_code(SYSTEMS['procedures']['hcpcs'])
|
|
166
|
+
for m in (item.modifier or []) if m],
|
|
167
|
+
'allowed_amount': next((adj.get('amount', {}).get('value')
|
|
168
|
+
for adj in (item.adjudication or [])
|
|
169
|
+
if any(c.get('code') == 'eligible'
|
|
170
|
+
for c in adj.get('category', {}).get('coding', []))), None)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if service_data['procedure_code'] or service_data['ndc']:
|
|
174
|
+
results.append(service_data)
|
|
175
|
+
|
|
176
|
+
if not results:
|
|
177
|
+
results.append({
|
|
178
|
+
**common_data,
|
|
179
|
+
'linked_diagnosis_codes': [],
|
|
180
|
+
'claim_diagnosis_codes': list(dx_lookup.values()),
|
|
181
|
+
'service_date': eob.billablePeriod.get_service_date() if eob.billablePeriod else None,
|
|
182
|
+
'procedure_code': None,
|
|
183
|
+
'ndc': None,
|
|
184
|
+
'quantity': None,
|
|
185
|
+
'place_of_service': None,
|
|
186
|
+
'modifiers': [],
|
|
187
|
+
'allowed_amount': None
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
return [ServiceLevelData.model_validate(r) for r in results]
|
|
191
|
+
|
|
192
|
+
except ValueError as e:
|
|
193
|
+
raise ValueError(f"Error processing EOB: {str(e)}")
|
hccinfhir/filter.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import List, Set
|
|
2
|
+
from .models import ServiceLevelData
|
|
3
|
+
import importlib.resources
|
|
4
|
+
|
|
5
|
+
# use import importlib.resources to load the professional_cpt_fn file as a list of strings
|
|
6
|
+
professional_cpt_default_fn = 'ra_eligible_cpt_hcpcs_2023.csv'
|
|
7
|
+
professional_cpt_default = []
|
|
8
|
+
with importlib.resources.open_text('hccinfhir.data', professional_cpt_default_fn) as f:
|
|
9
|
+
professional_cpt_default = set(f.read().splitlines())
|
|
10
|
+
|
|
11
|
+
def apply_filter(
|
|
12
|
+
data: List[ServiceLevelData],
|
|
13
|
+
inpatient_tob: Set[str] = {'11X', '41X'},
|
|
14
|
+
outpatient_tob: Set[str] = {'12X', '13X', '43X', '71X', '73X', '76X', '77X', '85X'},
|
|
15
|
+
professional_cpt: Set[str] = professional_cpt_default
|
|
16
|
+
) -> List[ServiceLevelData]:
|
|
17
|
+
# tob (Type of Bill) Filter is based on:
|
|
18
|
+
# https://www.hhs.gov/guidance/sites/default/files/hhs-guidance-documents/2012181486-wq-092916_ra_webinar_slides_5cr_092816.pdf
|
|
19
|
+
# https://www.hhs.gov/guidance/sites/default/files/hhs-guidance-documents/final%20industry%20memo%20medicare%20filtering%20logic%2012%2022%2015_85.pdf
|
|
20
|
+
|
|
21
|
+
# Break down the inpatient ToB into facility and service types
|
|
22
|
+
inpatient_facility_types = {tob[0] for tob in inpatient_tob}
|
|
23
|
+
inpatient_service_types = {tob[1] for tob in inpatient_tob}
|
|
24
|
+
|
|
25
|
+
# Break down the outpatient ToB into facility and service types
|
|
26
|
+
outpatient_facility_types = {tob[0] for tob in outpatient_tob}
|
|
27
|
+
outpatient_service_types = {tob[1] for tob in outpatient_tob}
|
|
28
|
+
|
|
29
|
+
# If ServiceLevelData has a facility_type and service_type, then filter the data based on the facility_type and service_type
|
|
30
|
+
# If not, then filter the data based on the CPT code
|
|
31
|
+
filtered_data = []
|
|
32
|
+
for item in data:
|
|
33
|
+
if item.facility_type and item.service_type:
|
|
34
|
+
if item.facility_type in inpatient_facility_types and item.service_type in inpatient_service_types:
|
|
35
|
+
filtered_data.append(item)
|
|
36
|
+
elif (item.facility_type in outpatient_facility_types and
|
|
37
|
+
item.service_type in outpatient_service_types and
|
|
38
|
+
item.procedure_code in professional_cpt):
|
|
39
|
+
filtered_data.append(item)
|
|
40
|
+
else:
|
|
41
|
+
if item.procedure_code in professional_cpt:
|
|
42
|
+
filtered_data.append(item)
|
|
43
|
+
return filtered_data
|
hccinfhir/models.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
class ServiceLevelData(BaseModel):
|
|
5
|
+
"""
|
|
6
|
+
Represents standardized service-level data extracted from healthcare claims.
|
|
7
|
+
|
|
8
|
+
Attributes:
|
|
9
|
+
claim_id: Unique identifier for the claim
|
|
10
|
+
procedure_code: Healthcare Common Procedure Coding System (HCPCS) code
|
|
11
|
+
ndc: National Drug Code
|
|
12
|
+
linked_diagnosis_codes: ICD-10 diagnosis codes linked to this service
|
|
13
|
+
claim_diagnosis_codes: All diagnosis codes on the claim
|
|
14
|
+
claim_type: Type of claim (e.g., NCH Claim Type Code, or 837I, 837P)
|
|
15
|
+
provider_specialty: Provider taxonomy or specialty code
|
|
16
|
+
performing_provider_npi: National Provider Identifier for performing provider
|
|
17
|
+
billing_provider_npi: National Provider Identifier for billing provider
|
|
18
|
+
patient_id: Unique identifier for the patient
|
|
19
|
+
facility_type: Type of facility where service was rendered
|
|
20
|
+
service_type: Type of service provided (facility type + service type = Type of Bill)
|
|
21
|
+
service_date: Date service was performed (YYYY-MM-DD)
|
|
22
|
+
place_of_service: Place of service code
|
|
23
|
+
quantity: Number of units provided
|
|
24
|
+
quantity_unit: Unit of measure for quantity
|
|
25
|
+
modifiers: List of procedure code modifiers
|
|
26
|
+
allowed_amount: Allowed amount for the service
|
|
27
|
+
"""
|
|
28
|
+
claim_id: Optional[str] = None
|
|
29
|
+
procedure_code: Optional[str] = None
|
|
30
|
+
ndc: Optional[str] = None
|
|
31
|
+
linked_diagnosis_codes: List[str] = []
|
|
32
|
+
claim_diagnosis_codes: List[str] = []
|
|
33
|
+
claim_type: Optional[str] = None
|
|
34
|
+
provider_specialty: Optional[str] = None
|
|
35
|
+
performing_provider_npi: Optional[str] = None
|
|
36
|
+
billing_provider_npi: Optional[str] = None
|
|
37
|
+
patient_id: Optional[str] = None
|
|
38
|
+
facility_type: Optional[str] = None
|
|
39
|
+
service_type: Optional[str] = None
|
|
40
|
+
service_date: Optional[str] = None
|
|
41
|
+
place_of_service: Optional[str] = None
|
|
42
|
+
quantity: Optional[float] = None
|
|
43
|
+
modifiers: List[str] = []
|
|
44
|
+
allowed_amount: Optional[float] = None
|