Cardiosense-AG commited on
Commit
c937b8c
·
verified ·
1 Parent(s): 7c80e07

Update src/billing.py

Browse files
Files changed (1) hide show
  1. 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
- from datetime import datetime, timezone
8
- import json
9
- from pathlib import Path
10
 
11
- from .config import CPT, PROVIDER
 
12
 
13
  @dataclass
14
  class CPTSuggestion:
15
  code: str
16
- rate: float
17
  descriptor: str
 
18
  eligible: bool
19
  why: str
20
 
21
- def _why_for_minutes(code: str, minutes: int, spoke: bool) -> str:
22
- if code == "99451":
23
- if not spoke:
24
- return "Eligible when no live interprofessional call; written report only; requires ≥5 minutes."
25
- return "Ineligible: 99451 is for written report without live discussion; 'spoke' was checked."
26
- ranges = {
27
- "99446": (5, 10),
28
- "99447": (11, 20),
29
- "99448": (21, 30),
30
- "99449": (31, 10**9),
31
- }
32
- lo, hi = ranges[code]
33
- base = f"Eligible minutes window {lo}–{hi if hi<10**9 else '∞'}; requires live discussion with referring clinician."
34
- if not spoke:
35
- return f"Ineligible: requires live discussion (spoke=False). Suggested 99451 if ≥5 minutes."
36
- if minutes < lo:
37
- return f"Ineligible for {code}: minutes below required threshold ({minutes}<{lo})."
38
- return base
39
-
40
- def autosuggest_cpt(minutes: int, spoke: bool) -> List[CPTSuggestion]:
41
- """Suggest CPT codes based on minutes and whether a live discussion occurred."""
42
- suggestions: List[CPTSuggestion] = []
43
- if spoke:
44
- for code in ["99446", "99447", "99448", "99449"]:
45
- lo_hi = {
46
- "99446": (5, 10),
47
- "99447": (11, 20),
48
- "99448": (21, 30),
49
- "99449": (31, 10**9),
50
- }[code]
51
- eligible = (minutes >= lo_hi[0]) and (minutes <= lo_hi[1])
52
- suggestions.append(CPTSuggestion(
53
- code=code,
54
- rate=float(CPT[code]["rate"]),
55
- descriptor=str(CPT[code]["descriptor"]),
56
- eligible=eligible,
57
- why=_why_for_minutes(code, minutes, spoke),
58
- ))
59
- else:
60
- eligible = minutes >= 5
61
- suggestions.append(CPTSuggestion(
62
- code="99451",
63
- rate=float(CPT["99451"]["rate"]),
64
- descriptor=str(CPT["99451"]["descriptor"]),
65
- eligible=eligible,
66
- why=_why_for_minutes("99451", minutes, spoke),
67
- ))
68
- return suggestions
69
-
70
- def build_837_claim(case: Dict[str, Any], code: str, rate: float, minutes: int,
71
- spoke: bool, attested: bool, *, template_path: Optional[Path] = None) -> Dict[str, Any]:
72
- """Build a demo 837 claim JSON from a case and billing selection."""
73
- now = datetime.now(timezone.utc).isoformat()
74
- # Load optional template
75
- template: Dict[str, Any] = {}
76
- try:
77
- if template_path and Path(template_path).exists():
78
- template = json.loads(Path(template_path).read_text(encoding="utf-8"))
79
- except Exception:
80
- template = {}
81
-
82
- claim_id = f"EC-{case.get('case_id','UNKNOWN')}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}"
83
- out: Dict[str, Any] = {
84
- "schema_version": 2,
85
- "demo_only": True,
86
- "claim_id": claim_id,
87
- "created_at": now,
88
- "provider": PROVIDER,
89
- "patient": case.get("patient", {}),
90
- "referring": {"name": (case.get("referrer") or {}).get("name", "Referring Clinician")},
91
- "service": {
92
- "cpt_code": code,
93
- "minutes": minutes,
94
- "spoke_to_referrer": bool(spoke),
95
- "amount": float(rate),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  },
97
- "attested": bool(attested),
98
- "source_case_id": case.get("case_id"),
99
  }
100
- out = {**template, **out}
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