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>
This commit is contained in:
Kisa 2026-05-18 19:01:35 -04:00
parent d6eef34509
commit e3afd9038c
10 changed files with 571 additions and 23 deletions

15
Dockerfile Normal file
View file

@ -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}"]

View file

View file

187
python-backend/api/main.py Normal file
View file

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

View file

@ -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

View file

9
railway.toml Normal file
View file

@ -0,0 +1,9 @@
[build]
builder = "DOCKERFILE"
dockerfilePath = "Dockerfile"
[deploy]
healthcheckPath = "/health"
healthcheckTimeout = 30
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 3

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
python-multipart>=0.0.9
pydantic>=2.0.0

View file

@ -576,10 +576,10 @@
<div class="sidebar-footer">
<div class="user-row">
<div class="user-avatar">JS</div>
<div class="user-avatar">DS</div>
<div>
<div class="user-name">J. Sullivan</div>
<div class="user-role">Billing Manager</div>
<div class="user-name">Demo Supplier</div>
<div class="user-role">Billing Staff</div>
</div>
</div>
</div>
@ -597,7 +597,7 @@
<button class="mode-toggle" onclick="document.body.classList.toggle('light')">
◐ Toggle Light/Dark
</button>
<button class="export-btn">↓ Export CSV</button>
<button class="export-btn" onclick="exportWorkQueue()">↓ Export CSV</button>
<a href="mailto:kisa@sttilsolutions.com" class="export-btn" style="text-decoration:none; background:#D97B35;">Request Access →</a>
</div>
</header>
@ -759,6 +759,35 @@
<div class="import-toast" id="import-toast"></div>
<script>
// ── Global state ──
let currentResults = [];
const BACKEND_URL = 'https://signal-api-production-91c2.up.railway.app';
// ── Backend upload (with local fallback) ──
async function uploadToBackend(file) {
const formData = new FormData();
formData.append('file', file);
const resp = await fetch(`${BACKEND_URL}/api/upload`, { method: 'POST', body: formData });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail?.message || `API error ${resp.status}`);
}
return resp.json();
}
function apiRecordToLocal(r) {
return {
patient_id: r.patient_id,
device_type: r.device_type,
payer: r.payer,
component: r.component,
flag: r.flag,
daysUntilEnd: r.days_until_coverage_end,
daysUntilVisit: r.days_until_visit_due,
priority: r.priority_score,
};
}
// ── Coverage calculation rules (mirrored from payer_rules.json) ──
const DEVICE_RULES = {
dexcom_g7: { sensor: 10 },
@ -964,41 +993,109 @@ function showToast(msg) {
setTimeout(() => t.classList.remove('show'), 3500);
}
// ── Main import handler ──
function handleCSVImport(event) {
const file = event.target.files[0];
if (!file) return;
// ── Local CSV processing (fallback) ──
function processLocally(file) {
const reader = new FileReader();
reader.onload = function(e) {
const rows = parseCSV(e.target.result);
const results = [];
const skipped = [];
const results = [], skipped = [];
for (const row of rows) {
const result = calculateCoverage(row);
if (result) {
results.push(result);
} else {
skipped.push(row.patient_id || '?');
if (result) { results.push(result); } else { skipped.push(row.patient_id || '?'); }
}
}
results.sort((a, b) => b.priority - a.priority);
currentResults = results;
renderRows(results);
updateStats(results);
const label = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
document.getElementById('import-label').textContent = `Import: ${file.name} · ${label}`;
let msg = `Loaded ${results.length} patient${results.length !== 1 ? 's' : ''} from ${file.name}`;
if (skipped.length) msg += ` · ${skipped.length} skipped (unknown device)`;
if (skipped.length) msg += ` · ${skipped.length} skipped`;
showToast(msg);
};
reader.readAsText(file);
}
// ── Main import handler — tries backend, falls back to local ──
function handleCSVImport(event) {
const file = event.target.files[0];
if (!file) return;
event.target.value = '';
uploadToBackend(file)
.then(data => {
const results = data.records.map(apiRecordToLocal);
currentResults = results;
renderRows(results);
updateStats(results);
const label = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
document.getElementById('import-label').textContent = `${file.name} · ${label} · via Signal API`;
let msg = `Loaded ${data.total} patient${data.total !== 1 ? 's' : ''} from ${file.name}`;
if (data.skipped) msg += ` · ${data.skipped} skipped`;
showToast(msg);
})
.catch(() => {
// Backend unreachable — process in browser
processLocally(file);
});
}
// ── Export work queue to CSV ──
function flagLabel(flag) {
const labels = {
OUT_OF_COVERAGE: 'Supply Lapsed',
VISIT_DUE: 'Renewal Due',
REFILL_WINDOW: 'Resupply Ready',
OK: 'Active'
};
return labels[flag] || flag;
}
function actionText(flag) {
const actions = {
OUT_OF_COVERAGE: 'Contact Prescriber',
VISIT_DUE: 'Request Renewal',
REFILL_WINDOW: 'Initiate Resupply',
OK: 'No action needed'
};
return actions[flag] || 'Review';
}
function exportWorkQueue() {
if (currentResults.length === 0) {
showToast('No data to export — import a CSV first.');
return;
}
const headers = [
'Patient ID', 'Device', 'Payer', 'Status',
'Priority Score', 'Days Until Coverage End', 'Recommended Action'
];
const rows = currentResults.map(r => [
r.patient_id,
DEVICE_DISPLAY[r.device_type] || r.device_type,
r.payer,
flagLabel(r.flag),
r.priority,
r.daysUntilEnd,
actionText(r.flag)
]);
const csvContent = [headers, ...rows]
.map(row => row.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))
.join('\r\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `signal-work-queue-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`Exported ${currentResults.length} patients to CSV`);
}
</script>
</body>

15
vercel.json Normal file
View file

@ -0,0 +1,15 @@
{
"version": 2,
"builds": [
{
"src": "signal-ui/demo/index.html",
"use": "@vercel/static"
}
],
"routes": [
{
"src": "/(.*)",
"dest": "signal-ui/demo/$1"
}
]
}