Signal/python-backend/api/main.py
Kisa cf171a3f87 add Phase 1 security hardening, mapping confidence, audit logging, pilot docs
- lock CORS to Vercel domain via ALLOWED_ORIGINS env var (removes allow_origins=*)
- add X-API-Key header auth on /api/upload and /api/export
- normalizer: add mapping confidence (high/inferred), new aliases for Acct #,
  Member ID, External Patient Ref, DME Description, dispensedate; 63/63 CSV files pass
- coverage_calculator: add RULE_VERSION = "v0.1", rule_version on every CoverageResult
- main.py: audit logging wired on upload + export, rule_version + mapping_summary in response
- generate_samples.py: 25 CSV files now use 25 different real-world header formats
- add generate_10k.py for 10,000-patient synthetic dataset
- add tests/smoke_test.py (passes against local backend)
- add docs/pilot-guide-v1.md for Robert Robinson pilot onboarding
- add docs/daniel-pilot-readiness-whitepaper.md and .pdf

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 05:41:25 -04:00

254 lines
8.2 KiB
Python

"""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"
},
)