Update src/billing.py
Browse files- src/billing.py +115 -85
src/billing.py
CHANGED
|
@@ -1,101 +1,131 @@
|
|
| 1 |
# src/billing.py
|
| 2 |
from __future__ import annotations
|
| 3 |
-
"""Billing helpers: CPT autosuggest and 837 claim builder (demo-only)."""
|
| 4 |
|
| 5 |
from dataclasses import dataclass
|
| 6 |
from typing import List, Dict, Any, Optional
|
| 7 |
-
|
| 8 |
-
import
|
| 9 |
-
from pathlib import Path
|
| 10 |
|
| 11 |
-
|
|
|
|
| 12 |
|
| 13 |
@dataclass
|
| 14 |
class CPTSuggestion:
|
| 15 |
code: str
|
| 16 |
-
rate: float
|
| 17 |
descriptor: str
|
|
|
|
| 18 |
eligible: bool
|
| 19 |
why: str
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
},
|
| 97 |
-
"attested": bool(attested),
|
| 98 |
-
"source_case_id": case.get("case_id"),
|
| 99 |
}
|
| 100 |
-
|
| 101 |
-
return out
|
|
|
|
| 1 |
# src/billing.py
|
| 2 |
from __future__ import annotations
|
|
|
|
| 3 |
|
| 4 |
from dataclasses import dataclass
|
| 5 |
from typing import List, Dict, Any, Optional
|
| 6 |
+
import re
|
| 7 |
+
import datetime as dt
|
|
|
|
| 8 |
|
| 9 |
+
|
| 10 |
+
# ---------------------- Data models ----------------------
|
| 11 |
|
| 12 |
@dataclass
|
| 13 |
class CPTSuggestion:
|
| 14 |
code: str
|
|
|
|
| 15 |
descriptor: str
|
| 16 |
+
rate: float
|
| 17 |
eligible: bool
|
| 18 |
why: str
|
| 19 |
|
| 20 |
+
@dataclass
|
| 21 |
+
class ICDSuggestion:
|
| 22 |
+
code: str
|
| 23 |
+
description: str
|
| 24 |
+
confidence: float
|
| 25 |
+
why: str
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ---------------------- CPT autosuggest -------------------
|
| 29 |
+
|
| 30 |
+
def autosuggest_cpt(*, minutes: int, spoke: bool) -> List[CPTSuggestion]:
|
| 31 |
+
"""
|
| 32 |
+
Very simple heuristic consistent with e-consult time thresholds.
|
| 33 |
+
Keep your original thresholds; these are illustrative defaults.
|
| 34 |
+
"""
|
| 35 |
+
table = [
|
| 36 |
+
("99451", "Interprofessional e-consultation—5+ min of consultative time", 45.00, minutes >= 5 and not spoke, "Time ≥5 min; no synchronous/phone"),
|
| 37 |
+
("99452", "Interprofessional referral service provided by the treating/requesting physician", 35.00, minutes >= 5 and spoke, "Time ≥5 min; spoke to referring clinician"),
|
| 38 |
+
]
|
| 39 |
+
out: List[CPTSuggestion] = []
|
| 40 |
+
for code, desc, rate, eligible, why in table:
|
| 41 |
+
out.append(CPTSuggestion(code=code, descriptor=desc, rate=rate, eligible=bool(eligible), why=why))
|
| 42 |
+
# If both are eligible, prefer 99452 when spoke=True
|
| 43 |
+
return out
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ---------------------- ICD autosuggest -------------------
|
| 47 |
+
|
| 48 |
+
_HF_PATTERNS = [
|
| 49 |
+
# (pattern, ICD code, label, reason)
|
| 50 |
+
(r"\b(hfref|reduced ejection|ef\s*(?:<|≤)?\s*40|ef\s*3\d)\b", "I50.2", "Systolic (HFrEF) heart failure", "Assessment indicates reduced EF / HFrEF"),
|
| 51 |
+
(r"\b(hfpef|preserved ejection|ef\s*(?:≥|>)\s*50|ef\s*5\d|ef\s*6\d)\b", "I50.3", "Diastolic (HFpEF) heart failure", "Assessment indicates preserved EF / HFpEF"),
|
| 52 |
+
(r"\bacute decompensated\b", "I50.23", "Acute on chronic systolic HF", "Language suggests acute on chronic HFrEF"),
|
| 53 |
+
(r"\bhypertension\b.*\bheart failure\b|\bhtn\b.*\bhf\b", "I11.0", "Hypertensive heart disease with HF", "HTN with HF mentioned"),
|
| 54 |
+
(r"\bcardiorenal\b|\bckd\b|\bcreatinine\b", "N18.9", "Chronic kidney disease, unspecified", "Cardiorenal considerations/CKD labs present"),
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
def autosuggest_icd(assessment_text: str, plan_text: str, *, max_codes: int = 3) -> List[ICDSuggestion]:
|
| 58 |
+
"""
|
| 59 |
+
Light rule-based ICD-10 suggestions from Assessment + Plan text.
|
| 60 |
+
Returns up to max_codes ordered by confidence.
|
| 61 |
+
"""
|
| 62 |
+
text = f"{assessment_text}\n{plan_text}".lower()
|
| 63 |
+
hits: Dict[str, ICDSuggestion] = {}
|
| 64 |
+
for pat, code, label, reason in _HF_PATTERNS:
|
| 65 |
+
if re.search(pat, text, flags=re.I):
|
| 66 |
+
# Simple confidence stacking: more matches → slightly higher confidence
|
| 67 |
+
conf = 0.7
|
| 68 |
+
if code in hits:
|
| 69 |
+
hits[code].confidence = min(0.95, hits[code].confidence + 0.1)
|
| 70 |
+
else:
|
| 71 |
+
hits[code] = ICDSuggestion(code=code, description=label, confidence=conf, why=reason)
|
| 72 |
+
# Always include non-specific HF if nothing matched but HF is suggested
|
| 73 |
+
if ("hf" in text or "heart failure" in text) and not hits:
|
| 74 |
+
hits["I50.9"] = ICDSuggestion(code="I50.9", description="Heart failure, unspecified", confidence=0.6, why="HF mentioned without phenotype")
|
| 75 |
+
# Return ordered by confidence
|
| 76 |
+
return sorted(hits.values(), key=lambda s: s.confidence, reverse=True)[:max_codes]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ---------------------- 837 JSON builder -------------------
|
| 80 |
+
|
| 81 |
+
def build_837_claim(
|
| 82 |
+
case: Dict[str, Any],
|
| 83 |
+
*,
|
| 84 |
+
code: str,
|
| 85 |
+
rate: float,
|
| 86 |
+
minutes: int,
|
| 87 |
+
spoke: bool,
|
| 88 |
+
attested: bool,
|
| 89 |
+
icd_codes: Optional[List[str]] = None,
|
| 90 |
+
) -> Dict[str, Any]:
|
| 91 |
+
"""
|
| 92 |
+
Build a compact 837-like JSON envelope for demo purposes.
|
| 93 |
+
Includes ICD diagnosis codes in the 2300-level 'diagnosis_codes' block.
|
| 94 |
+
"""
|
| 95 |
+
p = case.get("patient", {}) or {}
|
| 96 |
+
c = case.get("consult", {}) or {}
|
| 97 |
+
case_id = case.get("case_id", "UNKNOWN")
|
| 98 |
+
|
| 99 |
+
today = dt.datetime.utcnow().strftime("%Y-%m-%d")
|
| 100 |
+
icd_codes = icd_codes or []
|
| 101 |
+
|
| 102 |
+
envelope = {
|
| 103 |
+
"transaction": "837P",
|
| 104 |
+
"case_id": case_id,
|
| 105 |
+
"claim": {
|
| 106 |
+
"provider": {
|
| 107 |
+
"npi": "1234567890", # demo
|
| 108 |
+
"taxonomy_code": "207RC0000X", # Cardiology
|
| 109 |
+
"place_of_service": "02", # Telehealth (demo)
|
| 110 |
+
},
|
| 111 |
+
"patient": {
|
| 112 |
+
"name": p.get("name") or "Unknown",
|
| 113 |
+
"sex": p.get("sex") or "",
|
| 114 |
+
"age": p.get("age") or "",
|
| 115 |
+
},
|
| 116 |
+
"referral": {
|
| 117 |
+
"specialty": c.get("specialty") or "Cardiology",
|
| 118 |
+
"question": c.get("question") or "",
|
| 119 |
+
},
|
| 120 |
+
"service": {
|
| 121 |
+
"date": today,
|
| 122 |
+
"cpt_code": code,
|
| 123 |
+
"charge_amount": round(float(rate), 2),
|
| 124 |
+
"minutes": int(minutes),
|
| 125 |
+
"spoke_to_referrer": bool(spoke),
|
| 126 |
+
"attested": bool(attested),
|
| 127 |
+
},
|
| 128 |
+
"diagnosis_codes": icd_codes, # NEW: ICD-10 list
|
| 129 |
},
|
|
|
|
|
|
|
| 130 |
}
|
| 131 |
+
return envelope
|
|
|