CoolFace
Apppublic

touchskyer/billfighter

sourceHugging Facemitupdated 5mo agoView on Hugging Face
0likes
app.py334 linesDownload Raw Back to root
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