"""Signal API — FastAPI backend for CSV ingestion, scoring, and export.""" import csv import io import os import sys from datetime import date from pathlib import Path from typing import Optional from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel # Ensure python-backend root is on path (works both locally and in Docker) _backend_root = Path(__file__).parent.parent if str(_backend_root) not in sys.path: sys.path.insert(0, str(_backend_root)) from core.coverage_calculator import ShipmentRecord, calculate_batch from core.audit_logger import AuditAction, log_event from api.normalizer import normalize_csv app = FastAPI(title="Signal API", version="1.0.0", docs_url="/docs") # CORS — locked to Vercel frontend and localhost for dev. # Set ALLOWED_ORIGINS in Railway as a comma-separated list for production. _origins_env = os.getenv("ALLOWED_ORIGINS", "") _allowed_origins: list[str] = ( [o.strip() for o in _origins_env.split(",") if o.strip()] if _origins_env else [ "http://localhost:5173", "http://localhost:5174", "http://127.0.0.1:5173", ] ) app.add_middleware( CORSMiddleware, allow_origins=_allowed_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # API key auth — enforced when SIGNAL_API_KEY env var is set. # In dev (no env var), all requests pass. In production, X-API-Key header is required. _api_key = os.getenv("SIGNAL_API_KEY", "") def _require_api_key(x_api_key: str = Header(default="")) -> None: if _api_key and x_api_key != _api_key: raise HTTPException(status_code=401, detail="Invalid or missing API key") DEVICE_DISPLAY = { "dexcom_g7": "Dexcom G7", "dexcom_g6": "Dexcom G6", "freestyle_libre_2": "FreeStyle Libre 2", "freestyle_libre_3": "FreeStyle Libre 3", "omnipod_5": "Omnipod 5", } FLAG_LABELS = { "OUT_OF_COVERAGE": "Supply Lapsed", "VISIT_DUE": "Renewal Due", "REFILL_WINDOW": "Resupply Ready", "OK": "Active", } FLAG_ACTIONS = { "OUT_OF_COVERAGE": "Contact Prescriber", "VISIT_DUE": "Request Renewal", "REFILL_WINDOW": "Initiate Resupply", "OK": "No action needed", } class RecordOut(BaseModel): patient_id: str device_type: str device_display: str payer: str component: str days_until_coverage_end: int days_until_visit_due: Optional[int] = None flag: str priority_score: int coverage_end_date: str next_visit_due_date: Optional[str] = None action: str status_label: str reason: str rule_version: str class UploadResponse(BaseModel): records: list[RecordOut] total: int skipped: int skipped_reasons: list[str] stats: dict mapping_summary: dict def _build_reason(flag_val: str, days_until_end: int, days_until_visit: Optional[int]) -> str: if flag_val == "OUT_OF_COVERAGE": ago = abs(days_until_end) unit = "day" if ago == 1 else "days" return f"Supply lapsed {ago} {unit} ago. Prescriber contact required before next shipment." if flag_val == "VISIT_DUE": if days_until_visit is not None and days_until_visit <= 0: overdue = abs(days_until_visit) unit = "day" if overdue == 1 else "days" return f"Qualifying visit overdue by {overdue} {unit}. Confirm documentation immediately." if days_until_visit is not None: unit = "day" if days_until_visit == 1 else "days" return f"Qualifying visit due in {days_until_visit} {unit}. Confirm visit documentation before resupply." return "Qualifying visit renewal required. Confirm documentation before resupply." if flag_val == "REFILL_WINDOW": unit = "day" if days_until_end == 1 else "days" return f"Coverage ends in {days_until_end} {unit}. Patient is within resupply window — initiate shipment now." unit = "day" if days_until_end == 1 else "days" return f"Coverage on track. Resupply window opens in approximately {days_until_end} {unit}." def _to_record_out(r) -> RecordOut: flag_val = r.flag.value if hasattr(r.flag, "value") else str(r.flag) return RecordOut( patient_id=r.patient_id, device_type=r.device_type, device_display=DEVICE_DISPLAY.get(r.device_type, r.device_type), payer=r.payer, component=r.component, days_until_coverage_end=r.days_until_coverage_end, days_until_visit_due=r.days_until_visit_due, flag=flag_val, priority_score=r.priority_score, coverage_end_date=r.coverage_end_date.isoformat(), next_visit_due_date=r.next_visit_due_date.isoformat() if r.next_visit_due_date else None, action=FLAG_ACTIONS.get(flag_val, "Review"), status_label=FLAG_LABELS.get(flag_val, flag_val), reason=_build_reason(flag_val, r.days_until_coverage_end, r.days_until_visit_due), rule_version=r.rule_version, ) def _compute_stats(records: list[RecordOut]) -> dict: flags = [r.flag for r in records] return { "total": len(records), "supply_lapsed": flags.count("OUT_OF_COVERAGE"), "renewal_due": flags.count("VISIT_DUE"), "resupply_ready": flags.count("REFILL_WINDOW"), "active": flags.count("OK"), "prescriber_action": flags.count("OUT_OF_COVERAGE") + flags.count("VISIT_DUE"), } @app.get("/health") def health(): return {"status": "ok", "service": "signal-api", "version": "1.0.0"} @app.post("/api/upload", response_model=UploadResponse) async def upload_csv( file: UploadFile = File(...), _auth: None = Depends(_require_api_key), ): if not (file.filename or "").endswith(".csv"): raise HTTPException(status_code=400, detail="File must be a .csv") content = await file.read() try: text = content.decode("utf-8") except UnicodeDecodeError: text = content.decode("latin-1") records, skipped_reasons, mapping_summary = normalize_csv(text) if not records: log_event(AuditAction.CSV_INGEST, file.filename or "unknown", "demo_user", "failure", "0.0.0.0", detail="No processable rows") raise HTTPException( status_code=422, detail={ "message": "No processable rows found in the uploaded file.", "skipped": skipped_reasons[:10], "mapping_summary": mapping_summary, }, ) results = calculate_batch(records, as_of=date.today()) out = [_to_record_out(r) for r in results] log_event(AuditAction.CSV_INGEST, file.filename or "unknown", "demo_user", "success", "0.0.0.0", detail=f"{len(out)} records scored") return UploadResponse( records=out, total=len(out), skipped=len(skipped_reasons), skipped_reasons=skipped_reasons[:20], stats=_compute_stats(out), mapping_summary=mapping_summary, ) @app.post("/api/export") async def export_work_queue( records: list[RecordOut], _auth: None = Depends(_require_api_key), ): """Generate a downloadable work-queue CSV from a list of scored records.""" output = io.StringIO() writer = csv.writer(output) writer.writerow([ "Patient ID", "Device", "Payer", "Status", "Priority Score", "Days Until Resupply End", "Next Visit Due", "Recommended Action", "Resupply End Date", "Reason", ]) for r in records: writer.writerow([ r.patient_id, r.device_display, r.payer, r.status_label, r.priority_score, r.days_until_coverage_end, r.next_visit_due_date or "", r.action, r.coverage_end_date, r.reason, ]) output.seek(0) today = date.today().isoformat() log_event(AuditAction.WORKLIST_EXPORT, f"work-queue-{today}", "demo_user", "success", "0.0.0.0", detail=f"{len(records)} records exported") return StreamingResponse( io.BytesIO(output.getvalue().encode("utf-8")), media_type="text/csv", headers={ "Content-Disposition": f"attachment; filename=signal-work-queue-{today}.csv" }, )