Signal/python-backend/api/main.py
Kisa a424ac9d13 feat: add reason strings per patient, fix export headers, add signal-ui source
- Add _build_reason() to backend — per-patient reason strings with specific
  day counts (e.g. "Supply lapsed 70 days ago. Prescriber contact required.")
- Add reason field to RecordOut model and backend /api/export CSV
- Fix export column headers: Coverage End Date → Resupply End Date,
  Days Until Coverage End → Days Until Resupply End
- Pass reason through apiRecordToLocal in frontend api.js
- Display reason as muted sub-line under status badge in WorklistTable
- Add reason column to client-side CSVExport
- Add signal-ui React source to repo (was untracked)
- CLAUDE.md: add Billing and CMS integrations to Phase 2 deferred table
- research: restore Section 14 stat verification (May 23 recovery)

Deployed to Railway production — health check confirmed live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 09:45:02 -04:00

212 lines
6.6 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
reason: str
class UploadResponse(BaseModel):
records: list[RecordOut]
total: int
skipped: int
skipped_reasons: list[str]
stats: 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),
)
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 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()
return StreamingResponse(
io.BytesIO(output.getvalue().encode("utf-8")),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=signal-work-queue-{today}.csv"
},
)