Signal/python-backend/api/main.py
Kisa e3afd9038c feat: FastAPI backend + full deployment stack (Railway + Vercel)
- FastAPI backend: /health, /api/upload (CSV parse + score), /api/export (work queue CSV)
- CSV normalizer: tolerates 10+ header aliases per field, 8 date formats, all 5 devices, all major payers
- Python coverage_calculator wired as the authoritative scoring engine
- Frontend: backend-first upload with local fallback, export CSV wired, J. Sullivan placeholder removed
- Dockerfile + railway.toml for Railway deploy
- vercel.json for static frontend deploy
- Railway MCP installed for future sessions

Backend live: https://signal-api-production-91c2.up.railway.app
Frontend live: https://signal-ui-xi.vercel.app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:01:35 -04:00

187 lines
5.2 KiB
Python

"""Signal API — FastAPI backend for CSV ingestion, scoring, and export."""
import csv
import io
import sys
from datetime import date
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, File, 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 api.normalizer import normalize_csv
app = FastAPI(title="Signal API", version="1.0.0", docs_url="/docs")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
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
class UploadResponse(BaseModel):
records: list[RecordOut]
total: int
skipped: int
skipped_reasons: list[str]
stats: dict
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),
)
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(...)):
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 = normalize_csv(text)
if not records:
raise HTTPException(
status_code=422,
detail={
"message": "No processable rows found in the uploaded file.",
"skipped": skipped_reasons[:10],
},
)
results = calculate_batch(records, as_of=date.today())
out = [_to_record_out(r) for r in results]
return UploadResponse(
records=out,
total=len(out),
skipped=len(skipped_reasons),
skipped_reasons=skipped_reasons[:20],
stats=_compute_stats(out),
)
@app.post("/api/export")
async def export_work_queue(records: list[RecordOut]):
"""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 Coverage End",
"Next Visit Due",
"Recommended Action",
"Coverage End Date",
])
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,
])
output.seek(0)
today = date.today().isoformat()
return StreamingResponse(
io.BytesIO(output.getvalue().encode("utf-8")),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=signal-work-queue-{today}.csv"
},
)