- 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>
212 lines
6.6 KiB
Python
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"
|
|
},
|
|
)
|