|
1 | | -"""API Client for LumiClaim Backend.""" |
| 1 | +"""Serverless API Layer - Direct backend imports (no HTTP).""" |
2 | 2 |
|
3 | 3 | import os |
4 | | -import requests |
| 4 | +import sys |
| 5 | +import json |
5 | 6 | import streamlit as st |
6 | | -from typing import Any, Optional, Dict |
| 7 | +from typing import Any, Optional, Dict, List |
| 8 | +from pathlib import Path |
7 | 9 |
|
8 | | -# Default API Base URL (can be overridden by env var or UI) |
9 | | -DEFAULT_API_BASE = os.getenv("LUMICLAIM_API_BASE", "http://127.0.0.1:8080") |
| 10 | +# Ensure backend is importable |
| 11 | +_ROOT = Path(__file__).parent.parent.parent |
| 12 | +sys.path.insert(0, str(_ROOT)) |
10 | 13 |
|
11 | | -def get_api_base() -> str: |
12 | | - """Get the current API base URL from session state or default.""" |
13 | | - if "api_base" not in st.session_state: |
14 | | - st.session_state.api_base = DEFAULT_API_BASE |
15 | | - return st.session_state.api_base |
| 14 | +# --- Direct Backend Imports --- |
| 15 | +from backend import auth |
| 16 | +from backend.session import start_session, session_dir, load_profile, save_profile |
16 | 17 |
|
17 | 18 | def get_session_id() -> Optional[str]: |
18 | 19 | """Get the current session ID from session state.""" |
19 | 20 | return st.session_state.get("session_id") |
20 | 21 |
|
21 | 22 | def ensure_session(): |
22 | | - """Ensure a session ID exists, creating one if necessary.""" |
23 | | - # 1. Check State |
| 23 | + """Ensure a session ID exists.""" |
24 | 24 | if get_session_id(): |
25 | 25 | return |
26 | | - |
27 | | - # 2. Check Query Params (Persistence) |
28 | | - qp = st.query_params.get_all("session_id") if hasattr(st.query_params, "get_all") else [st.query_params.get("session_id")] |
29 | | - # Streamlit 1.30+ uses st.query_params as a dict-like object |
30 | | - # Older uses st.experimental_get_query_params |
31 | | - # We will assume modern Streamlit (st.query_params is a dict-like proxy) |
32 | 26 |
|
33 | | - # query_params.get returns None or value. |
| 27 | + # Check query params |
34 | 28 | qp_val = st.query_params.get("session_id") |
35 | | - |
36 | 29 | if qp_val: |
37 | 30 | st.session_state.session_id = qp_val |
38 | 31 | st.session_state.session_created = True |
39 | 32 | return |
40 | 33 |
|
41 | | - # 3. Create New (Anonymous) |
| 34 | +# --- Auth Functions (Direct Calls) --- |
| 35 | +def get_auth_status(username: str) -> Dict[str, Any]: |
| 36 | + """Check user status - replaces GET /auth/status/{username}""" |
| 37 | + status = auth.get_user_status(username) |
| 38 | + return {"status": status} |
| 39 | + |
| 40 | +def login(username: str, password: str) -> Dict[str, Any]: |
| 41 | + """Verify credentials - replaces POST /auth/login""" |
| 42 | + success = auth.verify_credentials(username, password) |
| 43 | + return {"success": success} |
| 44 | + |
| 45 | +def register(username: str, password: str) -> Dict[str, Any]: |
| 46 | + """Register user - replaces POST /auth/register""" |
| 47 | + success = auth.register_user(username, password) |
| 48 | + return {"success": success} |
| 49 | + |
| 50 | +def start_user_session(session_id: str) -> Dict[str, Any]: |
| 51 | + """Start session - replaces POST /session/start""" |
| 52 | + result = start_session(session_id) |
| 53 | + return {"session_id": result} |
| 54 | + |
| 55 | +# --- Document Functions --- |
| 56 | +def list_documents(sid: str) -> List[str]: |
| 57 | + """List documents in session.""" |
| 58 | + if not sid: |
| 59 | + return [] |
| 60 | + extracted_dir = session_dir(sid) / "extracted" |
| 61 | + if not extracted_dir.exists(): |
| 62 | + return [] |
| 63 | + return [f.stem for f in extracted_dir.glob("*.json")] |
| 64 | + |
| 65 | +def get_session_claims(sid: str) -> Dict[str, Any]: |
| 66 | + """Get all claims from session - replaces GET /session/claims""" |
| 67 | + docs = list_documents(sid) |
| 68 | + all_rows = [] |
| 69 | + doc_objs = [] |
| 70 | + |
| 71 | + for doc_id in docs: |
| 72 | + doc_path = session_dir(sid) / "extracted" / f"{doc_id}.json" |
| 73 | + if doc_path.exists(): |
| 74 | + doc = json.loads(doc_path.read_text()) |
| 75 | + doc_objs.append({"doc_id": doc_id}) |
| 76 | + claims = doc.get("claims", []) |
| 77 | + for claim in claims: |
| 78 | + claim["doc_id"] = doc_id |
| 79 | + all_rows.append(claim) |
| 80 | + |
| 81 | + return {"rows": all_rows, "docs": doc_objs} |
| 82 | + |
| 83 | +def upload_eob(file_bytes: bytes, filename: str, mimetype: str, sid: str) -> Dict[str, Any]: |
| 84 | + """Handle EOB upload - replaces POST /upload_eob""" |
| 85 | + from backend.upload_eob import handle_upload_file |
| 86 | + from io import BytesIO |
| 87 | + |
| 88 | + # Create file-like object |
| 89 | + file_obj = type('UploadFile', (), { |
| 90 | + 'filename': filename, |
| 91 | + 'content_type': mimetype, |
| 92 | + 'file': BytesIO(file_bytes), |
| 93 | + 'read': lambda self: file_bytes |
| 94 | + })() |
| 95 | + |
42 | 96 | try: |
43 | | - url = f"{get_api_base()}/session/start" |
44 | | - resp = requests.post(url, timeout=30) |
45 | | - if resp.ok: |
46 | | - data = resp.json() |
47 | | - sid = data.get("session_id") |
48 | | - st.session_state.session_id = sid |
49 | | - st.session_state.session_created = True |
50 | | - # Set param for future refreshes |
51 | | - st.query_params["session_id"] = sid |
52 | | - else: |
53 | | - st.error(f"Failed to start session: {resp.text}") |
| 97 | + result = handle_upload_file(file_obj, sid) |
| 98 | + return result |
54 | 99 | except Exception as e: |
55 | | - st.error(f"Could not connect to backend at {get_api_base()}: {e}") |
| 100 | + return {"error": str(e)} |
56 | 101 |
|
57 | | -def _attach_session(params: Dict[str, Any]) -> Dict[str, Any]: |
58 | | - """Attach session_id to request parameters (query or body).""" |
59 | | - sid = get_session_id() |
60 | | - if not sid: |
61 | | - return params |
62 | | - |
63 | | - # If param matches "params" (query args), inject session_id |
64 | | - if "params" in params: |
65 | | - params["params"]["session_id"] = sid |
66 | | - # If param matches "json" (body), inject session_id |
67 | | - elif "json" in params: |
68 | | - if isinstance(params["json"], dict): |
69 | | - params["json"]["session_id"] = sid |
70 | | - # If param matches "data" (form data), inject if dict |
71 | | - elif "data" in params: |
72 | | - if isinstance(params["data"], dict): |
73 | | - params["data"]["session_id"] = sid |
74 | | - |
75 | | - # Fallback: if we simply have a dict wrapper, try to inject |
76 | | - # This covers cases where the caller passes just the payload dict |
77 | | - # But usually requests.post(..., json=payload) is how it's called. |
78 | | - return params |
79 | | - |
80 | | -def fetch(method: str, endpoint: str, **kwargs) -> Optional[Any]: |
81 | | - """Make an API request with automatic session handling.""" |
82 | | - url = f"{get_api_base()}{endpoint}" |
83 | | - |
84 | | - # Auto-inject session ID for known parameterized keys if not present |
85 | | - # Case 1: GET request uses 'params' |
86 | | - if method.upper() == "GET": |
87 | | - kwargs.setdefault("params", {}) |
88 | | - if get_session_id(): |
89 | | - kwargs["params"].setdefault("session_id", get_session_id()) |
90 | | - |
91 | | - # Case 2: POST/PUT uses 'json' or 'data' |
92 | | - if method.upper() in ["POST", "PUT", "PATCH"]: |
93 | | - if "json" in kwargs and isinstance(kwargs["json"], dict) and get_session_id(): |
94 | | - kwargs["json"].setdefault("session_id", get_session_id()) |
| 102 | +def get_explanation(doc_id: str, sid: str, persona: str = "patient", level: str = "grade6") -> Dict[str, Any]: |
| 103 | + """Get document explanation - replaces GET /explain/{doc_id}""" |
| 104 | + from backend.explain import get_breakdown |
| 105 | + |
| 106 | + doc_path = session_dir(sid) / "extracted" / f"{doc_id}.json" |
| 107 | + if not doc_path.exists(): |
| 108 | + return {"error": "Document not found"} |
| 109 | + |
| 110 | + doc = json.loads(doc_path.read_text()) |
95 | 111 |
|
96 | 112 | try: |
97 | | - response = requests.request(method, url, timeout=kwargs.pop("timeout", 60), **kwargs) |
98 | | - if response.status_code == 204: |
99 | | - return None |
100 | | - response.raise_for_status() |
101 | | - return response.json() |
102 | | - except requests.exceptions.HTTPError as e: |
103 | | - # Try to return friendly error message from backend |
104 | | - try: |
105 | | - err_data = response.json() |
106 | | - st.error(f"API Error ({response.status_code}): {err_data.get('detail', str(e))}") |
107 | | - except: |
108 | | - st.error(f"API Error: {e}") |
| 113 | + result = get_breakdown(doc, persona, level) |
| 114 | + return result |
109 | 115 | except Exception as e: |
110 | | - st.error(f"Connection Error: {e}") |
| 116 | + # Fallback |
| 117 | + return { |
| 118 | + "doc_id": doc_id, |
| 119 | + "takeaway": doc.get("summary", "No summary available"), |
| 120 | + "claims": doc.get("claims", []), |
| 121 | + "verifiability_score": 1.0 |
| 122 | + } |
| 123 | + |
| 124 | +def get_evidence_graph(doc_id: str, sid: str) -> Dict[str, Any]: |
| 125 | + """Get evidence graph - replaces GET /egraph/{doc_id}""" |
| 126 | + # This is a visualization helper, return minimal structure |
| 127 | + return {"nodes": [], "edges": []} |
| 128 | + |
| 129 | +# --- Legacy API compatibility (for pages that still use api.get/api.post) --- |
| 130 | +def get(endpoint: str, **kwargs) -> Optional[Any]: |
| 131 | + """Compatibility layer - routes to direct functions.""" |
| 132 | + params = kwargs.get("params", {}) |
| 133 | + sid = params.get("session_id") or get_session_id() |
| 134 | + |
| 135 | + # Parse endpoint |
| 136 | + if endpoint.startswith("/auth/status/"): |
| 137 | + username = endpoint.split("/")[-1] |
| 138 | + return get_auth_status(username) |
| 139 | + elif "/session/claims" in endpoint: |
| 140 | + return get_session_claims(sid) |
| 141 | + elif endpoint.startswith("/explain/"): |
| 142 | + doc_id = endpoint.split("/")[-1] |
| 143 | + return get_explanation(doc_id, sid, params.get("persona", "patient")) |
| 144 | + elif endpoint.startswith("/egraph/"): |
| 145 | + doc_id = endpoint.split("/")[-1] |
| 146 | + return get_evidence_graph(doc_id, sid) |
| 147 | + elif "/profile" in endpoint: |
| 148 | + return {"profile": load_profile(sid) or {}} |
| 149 | + elif "/documents/" in endpoint: |
| 150 | + return list_documents(sid) |
| 151 | + elif "/reconcile/" in endpoint: |
| 152 | + return {"anomalies": []} # Placeholder |
| 153 | + else: |
| 154 | + # Unknown endpoint - try to return empty |
| 155 | + return {} |
| 156 | + |
| 157 | +def post(endpoint: str, **kwargs) -> Optional[Any]: |
| 158 | + """Compatibility layer - routes to direct functions.""" |
| 159 | + json_data = kwargs.get("json", {}) |
| 160 | + files = kwargs.get("files", {}) |
| 161 | + sid = json_data.get("session_id") or get_session_id() |
111 | 162 |
|
112 | | - return None |
| 163 | + if endpoint == "/auth/login": |
| 164 | + return login(json_data.get("username", ""), json_data.get("password", "")) |
| 165 | + elif endpoint == "/auth/register": |
| 166 | + return register(json_data.get("username", ""), json_data.get("password", "")) |
| 167 | + elif endpoint == "/session/start": |
| 168 | + return start_user_session(json_data.get("session_id", "")) |
| 169 | + elif endpoint == "/upload_eob": |
| 170 | + # Handle file upload |
| 171 | + if files and "file" in files: |
| 172 | + fname, fbytes, ftype = files["file"] |
| 173 | + return upload_eob(fbytes, fname, ftype, sid) |
| 174 | + return {"error": "No file provided"} |
| 175 | + elif endpoint == "/profile": |
| 176 | + return _set_profile(json_data) |
| 177 | + elif "/explain/ai" in endpoint: |
| 178 | + return _explain_ai(json_data) |
| 179 | + elif "/appeal/generate_ai" in endpoint: |
| 180 | + return _generate_appeal_ai(json_data) |
| 181 | + elif "/session/manual_entry" in endpoint: |
| 182 | + return _manual_entry(json_data) |
| 183 | + elif "/simulate" in endpoint: |
| 184 | + return _simulate(json_data) |
| 185 | + else: |
| 186 | + return {} |
113 | 187 |
|
114 | | -def get(endpoint: str, **kwargs): |
115 | | - return fetch("GET", endpoint, **kwargs) |
| 188 | +# --- Helper functions --- |
| 189 | +def _set_profile(data: Dict[str, Any]) -> Dict[str, Any]: |
| 190 | + session_id = data.pop("session_id", get_session_id()) |
| 191 | + save_profile(session_id, data) |
| 192 | + return {"success": True} |
116 | 193 |
|
117 | | -def post(endpoint: str, **kwargs): |
118 | | - return fetch("POST", endpoint, **kwargs) |
| 194 | +def _explain_ai(data: Dict[str, Any]) -> Dict[str, Any]: |
| 195 | + """AI explanation - replaces POST /explain/ai""" |
| 196 | + try: |
| 197 | + from backend.llm import summarize_bill |
| 198 | + except ImportError: |
| 199 | + return {"summary": "AI module not available"} |
| 200 | + |
| 201 | + sid = data.get("session_id", get_session_id()) |
| 202 | + doc_id = data.get("doc_id", "") |
| 203 | + persona = data.get("persona", "patient") |
| 204 | + grade = data.get("grade_level", "8th Grade") |
| 205 | + |
| 206 | + doc_path = session_dir(sid) / "extracted" / f"{doc_id}.json" |
| 207 | + if not doc_path.exists(): |
| 208 | + return {"summary": "Document not found."} |
| 209 | + |
| 210 | + doc = json.loads(doc_path.read_text()) |
| 211 | + breakdown = doc.get("breakdown_text", str(doc.get("claims", []))) |
| 212 | + |
| 213 | + try: |
| 214 | + summary = summarize_bill(breakdown, persona, grade) |
| 215 | + return {"summary": summary} |
| 216 | + except Exception as e: |
| 217 | + return {"summary": f"AI unavailable: {e}"} |
| 218 | + |
| 219 | +def _generate_appeal_ai(data: Dict[str, Any]) -> Dict[str, Any]: |
| 220 | + """AI appeal - replaces POST /appeal/generate_ai""" |
| 221 | + try: |
| 222 | + from backend.llm import generate_appeal_letter |
| 223 | + except ImportError: |
| 224 | + return {"letter": "AI module not available"} |
| 225 | + |
| 226 | + sid = data.get("session_id", get_session_id()) |
| 227 | + doc_id = data.get("doc_id", "") |
| 228 | + user_context = data.get("user_context", "") |
| 229 | + |
| 230 | + doc_path = session_dir(sid) / "extracted" / f"{doc_id}.json" |
| 231 | + if not doc_path.exists(): |
| 232 | + return {"letter": "Document not found."} |
| 233 | + |
| 234 | + doc = json.loads(doc_path.read_text()) |
| 235 | + profile = load_profile(sid) or {} |
| 236 | + |
| 237 | + try: |
| 238 | + letter = generate_appeal_letter(doc, profile, user_context) |
| 239 | + return {"letter": letter} |
| 240 | + except Exception as e: |
| 241 | + return {"letter": f"AI unavailable: {e}"} |
| 242 | + |
| 243 | +def _manual_entry(data: Dict[str, Any]) -> Dict[str, Any]: |
| 244 | + """Handle manual entry - replaces POST /session/manual_entry""" |
| 245 | + sid = data.get("session_id", get_session_id()) |
| 246 | + if not sid: |
| 247 | + return {"error": "No session"} |
| 248 | + |
| 249 | + # Create manual entry document |
| 250 | + manual_dir = session_dir(sid) / "extracted" |
| 251 | + manual_dir.mkdir(parents=True, exist_ok=True) |
| 252 | + |
| 253 | + # Generate ID |
| 254 | + existing = list(manual_dir.glob("MANUAL-*.json")) |
| 255 | + next_id = len(existing) + 1 |
| 256 | + doc_id = f"MANUAL-{next_id:03d}" |
| 257 | + |
| 258 | + doc = { |
| 259 | + "doc_id": doc_id, |
| 260 | + "claims": [{ |
| 261 | + "description": data.get("description", ""), |
| 262 | + "date": data.get("date", ""), |
| 263 | + "cpt": data.get("cpt", ""), |
| 264 | + "billed": data.get("billed", 0), |
| 265 | + "allowed": data.get("allowed", 0), |
| 266 | + "insurer_paid": data.get("insurer_paid", 0), |
| 267 | + "patient_resp": data.get("patient_resp", 0) |
| 268 | + }] |
| 269 | + } |
| 270 | + |
| 271 | + doc_path = manual_dir / f"{doc_id}.json" |
| 272 | + doc_path.write_text(json.dumps(doc, indent=2)) |
| 273 | + |
| 274 | + return {"success": True, "doc_id": doc_id} |
| 275 | + |
| 276 | +def _simulate(data: Dict[str, Any]) -> Dict[str, Any]: |
| 277 | + """Cost simulation - replaces POST /simulate""" |
| 278 | + # Simplified simulation |
| 279 | + sid = data.get("session_id", get_session_id()) |
| 280 | + doc_id = data.get("doc_id", "") |
| 281 | + |
| 282 | + profile = load_profile(sid) or {} |
| 283 | + deductible_rem = float(profile.get("deductible_remaining", 500)) |
| 284 | + coinsurance = float(profile.get("coinsurance", 0.2)) |
| 285 | + |
| 286 | + doc_path = session_dir(sid) / "extracted" / f"{doc_id}.json" |
| 287 | + if not doc_path.exists(): |
| 288 | + return {"error": "Document not found"} |
| 289 | + |
| 290 | + doc = json.loads(doc_path.read_text()) |
| 291 | + claims = doc.get("claims", []) |
| 292 | + |
| 293 | + total_allowed = sum(float(c.get("allowed", 0)) for c in claims) |
| 294 | + |
| 295 | + # Simple calculation |
| 296 | + ded_applied = min(deductible_rem, total_allowed) |
| 297 | + after_ded = total_allowed - ded_applied |
| 298 | + coins_amount = after_ded * coinsurance |
| 299 | + expected = ded_applied + coins_amount |
| 300 | + |
| 301 | + return { |
| 302 | + "doc_id": doc_id, |
| 303 | + "expected_patient_resp": round(expected, 2), |
| 304 | + "deductible_applied": round(ded_applied, 2), |
| 305 | + "coinsurance_amount": round(coins_amount, 2) |
| 306 | + } |
0 commit comments