touchskyer/billfighter
0
1import os2import io3import json4from collections import defaultdict5from pathlib import Path6from datetime import datetime7from time import time8 9from dotenv import load_dotenv10from fastapi import FastAPI, Request, UploadFile, File, Form, HTTPException11from fastapi.staticfiles import StaticFiles12from fastapi.responses import FileResponse, JSONResponse13import anthropic14 15load_dotenv()16 17app = FastAPI(title="BillFighter", version="0.1.0")18 19SAMPLES_DIR = Path(__file__).parent / "samples"20STATIC_DIR = Path(__file__).parent / "static"21TEMPLATES_DIR = Path(__file__).parent / "templates"22 23MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB24MAX_TEXT_LENGTH = 50_00025 26RATE_LIMIT = int(os.getenv("RATE_LIMIT_PER_MIN", "10"))27_rate_store: dict[str, list[float]] = defaultdict(list)28 29def check_rate_limit(ip: str) -> None:30 now = time()31 window = [t for t in _rate_store[ip] if now - t < 60]32 _rate_store[ip] = window33 if len(window) >= RATE_LIMIT:34 raise HTTPException(status_code=429, detail="Rate limit exceeded. Try again in a minute.")35 _rate_store[ip].append(now)36 37# ---------------------------------------------------------------------------38# System prompts39# ---------------------------------------------------------------------------40 41ANALYZE_PROMPT = """You are BillFighter, an AI medical bill advocate for US consumers. You analyze medical bills and EOBs to find actionable issues — overcharges, billing errors, surprise billing violations, and appeal opportunities.42 43Your output MUST be valid JSON matching this schema:44 45{46 "summary": "2-3 sentence summary focused on WHAT'S WRONG and HOW MUCH the patient could save",47 "confidence": "high | medium | low",48 "patientOwes": 0.00,49 "potentialSavings": 0.00,50 "issues": [51 {52 "id": "issue_1",53 "type": "duplicate_charge | unbundled_codes | surprise_billing | upcoding | unlisted_procedure | missing_modifier | pricing_excessive | balance_billing | math_error | other",54 "severity": "critical | major | minor",55 "title": "Short title",56 "description": "Plain English explanation",57 "evidence": "What in the bill triggered this",58 "estimatedSavings": 0.00,59 "fightStrategy": "appeal | negotiate | dispute | complain_cms | complain_state",60 "successLikelihood": "high | medium | low",61 "deadline": "Relevant deadline or null"62 }63 ],64 "provider": "Provider name or Unknown",65 "insuranceCompany": "Insurance company or Unknown",66 "serviceDate": "Date or Unknown",67 "noSurprisesActApplies": false,68 "lineItems": [69 {70 "cptCode": "Code or null",71 "description": "Original description",72 "plainEnglish": "What this means",73 "billedAmount": 0.00,74 "allowedAmount": 0.00,75 "insurancePaid": 0.00,76 "youOwe": 0.00,77 "flagged": false,78 "issueRef": "issue_1 or null"79 }80 ]81}82 83## Issue Detection Rules:84 851. **Duplicate charges**: Same CPT code, same date, same amount → appeal862. **Unbundled codes**: Multiple codes that should be a single bundle (e.g. 29874+29877+29881) → appeal873. **Surprise billing**: Out-of-network provider at in-network facility → NSA complaint884. **Upcoding**: Visit level (99214/99215) seems high for described services → negotiate895. **Unlisted procedures** (codes ending in 99): Red flag, bypasses fee schedules → request documentation906. **Missing modifiers**: No -59 or -XE/XS/XP/XU on similar procedures → may indicate bundling issue917. **Excessive pricing**: Individual charges >3x Medicare rates → negotiate928. **Balance billing**: Provider billing beyond allowed amount → illegal in many states, dispute939. **Math errors**: Line items don't sum to total → dispute94 95## Strategy Assignment:96- "appeal": File formal insurance appeal (highest success rate for clear errors)97- "negotiate": Call provider billing dept to negotiate lower rate98- "dispute": Formal dispute with provider (for math errors, balance billing)99- "complain_cms": File complaint with CMS (for NSA violations)100- "complain_state": File with state insurance commissioner101 102IMPORTANT: Output ONLY the JSON object. No markdown, no explanation."""103 104APPEAL_LETTER_PROMPT = """You are a medical billing advocate writing a formal insurance appeal letter. Generate a professional, assertive appeal letter based on the bill analysis provided.105 106The letter should:1071. Be addressed to the insurance company's appeals department1082. Reference the specific claim number, service date, and provider1093. Clearly state the issue found (with specific CPT codes and dollar amounts)1104. Cite relevant regulations (No Surprises Act, state laws, CMS guidelines)1115. Request specific remedial action (reprocess claim, adjust payment, etc.)1126. Set a 30-day response deadline1137. Mention escalation path (state insurance commissioner, CMS complaint, legal action)114 115Tone: Professional but firm. Not aggressive, but makes clear the patient knows their rights.116 117Output the letter as plain text, ready to print and mail. Include placeholders like [YOUR NAME], [YOUR ADDRESS], [CLAIM NUMBER] where the patient needs to fill in personal info."""118 119CALL_SCRIPT_PROMPT = """You are a medical billing advocate preparing a phone call script for a patient to call their insurance company or provider's billing department.120 121Generate a conversational script that:1221. Opens with identifying information (claim number, member ID, service date)1232. States the specific issue clearly and concisely1243. Cites the patient's rights and relevant regulations1254. Has branching responses for common pushbacks:126 - "That charge is correct" → how to escalate127 - "You need to file an appeal" → confirm appeal process details128 - "Let me transfer you" → what to ask the next person129 - "We'll review and call you back" → what timeline to demand1305. Closes with a clear ask and follow-up plan131 132Format as a readable script with PATIENT: and IF/THEN branches.133 134Output as plain text."""135 136CMS_COMPLAINT_PROMPT = """You are helping a patient file a complaint with CMS (Centers for Medicare & Medicaid Services) about a No Surprises Act violation.137 138Based on the bill analysis, generate:139 1401. A summary of the NSA violation1412. The specific section of the NSA that was violated1423. A draft complaint narrative for the CMS No Surprises Help Desk1434. Key information the patient needs to gather before filing1445. Step-by-step filing instructions (online at cms.gov/nosurprises, phone 1-800-985-3059)1456. Timeline expectations146 147Output as structured plain text with clear sections."""148 149 150_client: anthropic.AsyncAnthropic | None = None151 152def get_client() -> anthropic.AsyncAnthropic:153 global _client154 if _client is not None:155 return _client156 api_key = os.getenv("ANTHROPIC_API_KEY")157 if not api_key or api_key == "your-key-here":158 raise HTTPException(159 status_code=500,160 detail="ANTHROPIC_API_KEY not configured. Copy .env.example to .env and add your key.",161 )162 _client = anthropic.AsyncAnthropic(api_key=api_key)163 return _client164 165 166async def call_claude(system: str, user_message: str, max_tokens: int = 8192) -> str:167 client = get_client()168 message = await client.messages.create(169 model="claude-sonnet-4-20250514",170 max_tokens=max_tokens,171 system=system,172 messages=[{"role": "user", "content": user_message}],173 )174 return message.content[0].text175 176 177def extract_text(file: UploadFile | None, contents: bytes, text: str | None) -> str:178 """Extract text from upload or direct input."""179 if text and text.strip():180 t = text.strip()181 if len(t) > MAX_TEXT_LENGTH:182 raise HTTPException(status_code=400, detail=f"Text too long ({len(t)} chars). Max {MAX_TEXT_LENGTH}.")183 return t184 185 if not contents:186 raise HTTPException(status_code=400, detail="No input provided.")187 if len(contents) > MAX_FILE_SIZE:188 raise HTTPException(status_code=400, detail=f"File too large. Max {MAX_FILE_SIZE // (1024*1024)}MB.")189 190 ct = (file.content_type or "").lower() if file else ""191 fname = (file.filename or "upload").lower() if file else "upload"192 193 # Try as plain text first194 try:195 return contents.decode("utf-8").strip()196 except UnicodeDecodeError:197 pass198 199 # PDF200 if ct == "application/pdf" or fname.endswith(".pdf"):201 try:202 from PyPDF2 import PdfReader203 reader = PdfReader(io.BytesIO(contents))204 pages = [p.extract_text() for p in reader.pages if p.extract_text()]205 if pages:206 return "\n\n".join(pages)207 except Exception:208 pass209 210 # Image → OCR211 if ct.startswith("image/") or any(fname.endswith(e) for e in (".png", ".jpg", ".jpeg", ".tiff", ".bmp")):212 try:213 import pytesseract214 from PIL import Image215 img = Image.open(io.BytesIO(contents))216 text_out = pytesseract.image_to_string(img)217 if text_out.strip():218 return text_out219 except Exception:220 pass221 222 raise HTTPException(status_code=400, detail="Could not extract text. Try pasting the bill text directly.")223 224 225# ---------------------------------------------------------------------------226# Routes227# ---------------------------------------------------------------------------228 229@app.post("/api/analyze")230async def analyze_bill(231 request: Request,232 file: UploadFile | None = File(None),233 text: str | None = Form(None),234):235 """Analyze a medical bill/EOB and find actionable issues."""236 check_rate_limit(request.client.host if request.client else "unknown")237 contents = b""238 if file:239 contents = await file.read()240 241 bill_text = extract_text(file, contents, text)242 raw = await call_claude(ANALYZE_PROMPT, f"Analyze this medical bill/EOB:\n\n{bill_text}")243 244 try:245 result = json.loads(raw, strict=False)246 except json.JSONDecodeError:247 import re248 match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL)249 if match:250 try:251 result = json.loads(match.group(1), strict=False)252 except json.JSONDecodeError:253 raise HTTPException(status_code=500, detail="Failed to parse analysis. Please try again.")254 else:255 raise HTTPException(status_code=500, detail="Failed to parse analysis. Please try again.")256 257 return JSONResponse(content=result)258 259 260@app.post("/api/fight")261async def generate_fight_tools(262 request: Request,263 analysis: str = Form(...),264 issue_id: str = Form(...),265 tool_type: str = Form(...), # "appeal_letter" | "call_script" | "cms_complaint"266):267 """Generate a fight tool (appeal letter, call script, or CMS complaint) for a specific issue."""268 check_rate_limit(request.client.host if request.client else "unknown")269 try:270 analysis_data = json.loads(analysis)271 except json.JSONDecodeError:272 raise HTTPException(status_code=400, detail="Invalid analysis JSON.")273 274 # Find the specific issue275 issue = None276 for i in analysis_data.get("issues", []):277 if i.get("id") == issue_id:278 issue = i279 break280 if not issue:281 raise HTTPException(status_code=400, detail=f"Issue '{issue_id}' not found in analysis.")282 283 context = json.dumps({284 "provider": analysis_data.get("provider"),285 "insuranceCompany": analysis_data.get("insuranceCompany"),286 "serviceDate": analysis_data.get("serviceDate"),287 "patientOwes": analysis_data.get("patientOwes"),288 "issue": issue,289 "lineItems": [li for li in analysis_data.get("lineItems", []) if li.get("issueRef") == issue_id],290 }, indent=2)291 292 prompts = {293 "appeal_letter": APPEAL_LETTER_PROMPT,294 "call_script": CALL_SCRIPT_PROMPT,295 "cms_complaint": CMS_COMPLAINT_PROMPT,296 }297 298 if tool_type not in prompts:299 raise HTTPException(status_code=400, detail=f"Invalid tool_type. Use: {list(prompts.keys())}")300 301 result = await call_claude(302 prompts[tool_type],303 f"Generate based on this bill analysis:\n\n{context}",304 max_tokens=4096,305 )306 307 return JSONResponse(content={"toolType": tool_type, "issueId": issue_id, "content": result})308 309 310@app.get("/api/samples")311async def list_samples():312 samples = []313 for f in sorted(SAMPLES_DIR.glob("*.txt")):314 samples.append({"name": f.stem, "filename": f.name})315 return samples316 317 318@app.get("/api/samples/{filename}")319async def get_sample(filename: str):320 filepath = (SAMPLES_DIR / filename).resolve()321 if not filepath.parent == SAMPLES_DIR.resolve():322 raise HTTPException(status_code=400, detail="Invalid filename.")323 if not filepath.exists() or not filepath.is_file():324 raise HTTPException(status_code=404, detail="Sample not found.")325 return {"filename": filename, "text": filepath.read_text()}326 327 328@app.get("/")329async def serve_index():330 return FileResponse(STATIC_DIR / "index.html")331 332 333app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")334 