From e3afd9038ccd6a99e8115d4c6f004f476be1258a Mon Sep 17 00:00:00 2001 From: Kisa Date: Mon, 18 May 2026 19:01:35 -0400 Subject: [PATCH] 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 --- Dockerfile | 15 +++ python-backend/__init__.py | 0 python-backend/api/__init__.py | 0 python-backend/api/main.py | 187 ++++++++++++++++++++++++++ python-backend/api/normalizer.py | 221 +++++++++++++++++++++++++++++++ python-backend/core/__init__.py | 0 railway.toml | 9 ++ requirements.txt | 4 + signal-ui/demo/index.html | 143 ++++++++++++++++---- vercel.json | 15 +++ 10 files changed, 571 insertions(+), 23 deletions(-) create mode 100644 Dockerfile create mode 100644 python-backend/__init__.py create mode 100644 python-backend/api/__init__.py create mode 100644 python-backend/api/main.py create mode 100644 python-backend/api/normalizer.py create mode 100644 python-backend/core/__init__.py create mode 100644 railway.toml create mode 100644 requirements.txt create mode 100644 vercel.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f8636a9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY python-backend/ ./python-backend/ + +ENV PORT=8000 + +EXPOSE 8000 + +# Run from within python-backend so 'api' and 'core' are importable as top-level packages +CMD ["sh", "-c", "cd /app/python-backend && uvicorn api.main:app --host 0.0.0.0 --port ${PORT}"] diff --git a/python-backend/__init__.py b/python-backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-backend/api/__init__.py b/python-backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-backend/api/main.py b/python-backend/api/main.py new file mode 100644 index 0000000..28c6df7 --- /dev/null +++ b/python-backend/api/main.py @@ -0,0 +1,187 @@ +"""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" + }, + ) diff --git a/python-backend/api/normalizer.py b/python-backend/api/normalizer.py new file mode 100644 index 0000000..0695788 --- /dev/null +++ b/python-backend/api/normalizer.py @@ -0,0 +1,221 @@ +""" +CSV header normalization for Signal. + +Maps messy supplier CSV exports to canonical ShipmentRecord fields. +Tolerates header drift, alternative column names, and common date formats. +""" + +import csv +import io +import re +from datetime import date, datetime +from typing import Optional + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) +from core.coverage_calculator import ShipmentRecord + +HEADER_MAP: dict[str, list[str]] = { + "patient_id": [ + "patient_id", "patientid", "patient id", "pt_id", "pt id", + "mrn", "account_number", "account number", "account_no", + "patient_account", "acct_no", "id", "patient", + ], + "device_type": [ + "device_type", "device type", "device", "devicetype", + "product_type", "product type", "product", "item", + "item_description", "item description", "hcpcs_description", + "description", "product_name", + ], + "shipment_date": [ + "shipment_date", "shipment date", "ship_date", "ship date", + "dispense_date", "dispense date", "service_date", "service date", + "order_date", "order date", "date_of_service", "dos", + "fill_date", "fill date", "last_ship_date", "last ship date", + ], + "quantity": [ + "quantity", "qty", "units", "count", "qty_dispensed", + "units_dispensed", "quantity_dispensed", "qty_shipped", + ], + "payer": [ + "payer", "insurance", "insurance_name", "insurance name", + "plan", "plan_name", "plan name", "payer_name", "payer name", + "primary_payer", "primary payer", "ins_name", "carrier", + ], + "component": [ + "component", "item_type", "component_type", "type", "supply_type", + ], +} + +DEVICE_MAP: dict[str, str] = { + "dexcom g7": "dexcom_g7", + "dexcom_g7": "dexcom_g7", + "dexcomg7": "dexcom_g7", + "g7": "dexcom_g7", + "dexcom g6": "dexcom_g6", + "dexcom_g6": "dexcom_g6", + "dexcomg6": "dexcom_g6", + "g6": "dexcom_g6", + "freestyle libre 2": "freestyle_libre_2", + "freestyle_libre_2": "freestyle_libre_2", + "freestylelibre2": "freestyle_libre_2", + "libre 2": "freestyle_libre_2", + "libre2": "freestyle_libre_2", + "fsl2": "freestyle_libre_2", + "fs libre 2": "freestyle_libre_2", + "freestyle libre 3": "freestyle_libre_3", + "freestyle_libre_3": "freestyle_libre_3", + "freestylelibre3": "freestyle_libre_3", + "libre 3": "freestyle_libre_3", + "libre3": "freestyle_libre_3", + "fsl3": "freestyle_libre_3", + "fs libre 3": "freestyle_libre_3", + "omnipod 5": "omnipod_5", + "omnipod_5": "omnipod_5", + "omnipod5": "omnipod_5", + "omnipod": "omnipod_5", + "op5": "omnipod_5", +} + +PAYER_MAP: dict[str, str] = { + "medicare part b": "medicare", + "medicare part a": "medicare", + "medicare advantage": "commercial", + "medicare": "medicare", + "cms": "medicare", + "medicaid": "medicaid", + "mcd": "medicaid", + "molina": "medicaid", + "centene": "medicaid", + "wellcare": "medicaid", + "bcbs": "commercial", + "blue cross": "commercial", + "blue shield": "commercial", + "aetna": "commercial", + "cigna": "commercial", + "unitedhealthcare": "commercial", + "united health": "commercial", + "uhc": "commercial", + "humana": "commercial", + "anthem": "commercial", + "united": "commercial", +} + +DATE_FORMATS = [ + "%Y-%m-%d", + "%m/%d/%Y", + "%m-%d-%Y", + "%d/%m/%Y", + "%m/%d/%y", + "%Y%m%d", + "%d-%b-%Y", + "%b %d, %Y", + "%B %d, %Y", + "%m/%d/%Y %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", +] + + +def _normalize_key(s: str) -> str: + return s.strip().lower().replace("-", " ").replace("_", " ") + + +def _map_header(raw: str) -> Optional[str]: + key = _normalize_key(raw) + for canonical, aliases in HEADER_MAP.items(): + if key in [_normalize_key(a) for a in aliases]: + return canonical + return None + + +def _parse_date(value: str) -> Optional[date]: + value = value.strip() + for fmt in DATE_FORMATS: + try: + return datetime.strptime(value, fmt).date() + except ValueError: + continue + return None + + +def _normalize_device(value: str) -> Optional[str]: + key = _normalize_key(value) + key_compact = re.sub(r"\s+", "", key) + for alias, canonical in DEVICE_MAP.items(): + alias_compact = re.sub(r"\s+", "", alias) + if key == alias or key_compact == alias_compact: + return canonical + return None + + +def _normalize_payer(value: str) -> str: + key = _normalize_key(value) + # Longest-match first (payer_map keys are already ordered longest first for medicare) + for alias, canonical in PAYER_MAP.items(): + if alias in key: + return canonical + return "commercial" + + +def normalize_csv(text: str) -> tuple[list[ShipmentRecord], list[str]]: + """ + Parse raw CSV text and return (records, skipped_reasons). + Tolerates header drift and normalizes device/payer/date values. + """ + reader = csv.DictReader(io.StringIO(text.strip())) + if not reader.fieldnames: + return [], ["No headers found in file"] + + column_map: dict[str, str] = {} + for raw_header in reader.fieldnames: + canonical = _map_header(raw_header) + if canonical: + column_map[raw_header] = canonical + + records: list[ShipmentRecord] = [] + skipped: list[str] = [] + + for i, row in enumerate(reader, start=2): + mapped: dict[str, str] = {} + for raw_h, canonical in column_map.items(): + mapped[canonical] = (row.get(raw_h) or "").strip() + + patient_id = mapped.get("patient_id", "").strip() + if not patient_id: + skipped.append(f"Row {i}: missing patient_id") + continue + + raw_device = mapped.get("device_type", "") + device_type = _normalize_device(raw_device) + if not device_type: + skipped.append(f"Row {i} ({patient_id}): unrecognized device '{raw_device}'") + continue + + raw_date = mapped.get("shipment_date", "") + shipment_date = _parse_date(raw_date) + if not shipment_date: + skipped.append(f"Row {i} ({patient_id}): unparseable date '{raw_date}'") + continue + + raw_qty = mapped.get("quantity", "1") + try: + quantity = max(1, int(float(raw_qty))) + except (ValueError, TypeError): + quantity = 1 + + payer = _normalize_payer(mapped.get("payer", "")) + component = (mapped.get("component", "sensor") or "sensor").lower().strip() + if component not in ("sensor", "transmitter", "pod"): + component = "sensor" + + records.append(ShipmentRecord( + patient_id=patient_id, + device_type=device_type, + shipment_date=shipment_date, + quantity=quantity, + payer=payer, + component=component, + )) + + return records, skipped diff --git a/python-backend/core/__init__.py b/python-backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..3a91e41 --- /dev/null +++ b/railway.toml @@ -0,0 +1,9 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/health" +healthcheckTimeout = 30 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 3 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e42679f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.29.0 +python-multipart>=0.0.9 +pydantic>=2.0.0 diff --git a/signal-ui/demo/index.html b/signal-ui/demo/index.html index 3f631ef..d79e68f 100644 --- a/signal-ui/demo/index.html +++ b/signal-ui/demo/index.html @@ -576,10 +576,10 @@ @@ -597,7 +597,7 @@ - + Request Access → @@ -759,6 +759,35 @@
diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..860231a --- /dev/null +++ b/vercel.json @@ -0,0 +1,15 @@ +{ + "version": 2, + "builds": [ + { + "src": "signal-ui/demo/index.html", + "use": "@vercel/static" + } + ], + "routes": [ + { + "src": "/(.*)", + "dest": "signal-ui/demo/$1" + } + ] +}