# Project export: RadAssist

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: OpenAI Build Week
- Tagline: See more, miss less — AI-assisted radiology built on transparency
- Devpost: https://devpost.com/software/radassist
- GitHub: https://github.com/suryamanoj09/radiology-intervention
- Demo: https://radiology-intervention.razeplaygames.com/
- Video: https://www.youtube.com/embed/ffeMI8qCixA?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — Manoj Surya (13 commits), dependabot[bot] (9 commits)

## Devpost submission (written by the team)

### How we built it

# RadAssist — Explainable AI Radiology Assistant An AI that reads alongside radiologists — it drafts, highlights, and explains, but the clinician decides. And it's honest about exactly how good it is. 🔗 Try it out Use only public / de-identified images — it's a research demo, not for real patient data. 💡 About the project RadAssist is a full-stack, decision-support workspace for chest X-ray, CT, and MRI. Upload a scan and a deep-learning model flags possible findings and highlights where it looked; the radiologist confirms, edits, or dismisses each one; and the app drafts a complete report — clinical, a plain-English patient summary, and differentials — that a named clinician signs before it can be exported. Its guiding principle is unusual for a medical-AI demo: the model suggests, it never diagnoses — and it never claims more than it can measure. It provides the accuracy ** It shows a ranking score instead of a fake "probability", it **abstains on images it can't read instead of guessing, it never calls a scan "normal" on its own, and it publishes its real measured accuracy — including where it fails. 📖 The story It started with an uncomfortable observation: the world has a shortage of radiologists and a surplus of "AI that diagnoses" — tools that confidently label a scan and hope nobody reads the fine print. In medicine, a confident wrong answer is worse than no answer at all. RadAssist took the opposite bet — an AI that earns trust by being honest. It reads a scan with the clinician, shows its reasoning with Grad-CAM attention maps, admits its uncertainty, and refuses to overrule the human. When it isn't sure, it says so. When it sees an image it wasn't trained on, it abstains. And it puts its own scorecard on the wall — AUROC, calibration error, and the labels it's weak at. What began as a chest-X-ray demo grew into a real clinical product: a CT/MRI viewer with a full measurement suite and clearly-labelled research AI, structured reporting with mandatory human sign-off, and a production-grade backend — an optional database, two-factor authentication, session management, encrypted secrets, ~300 automated tests, a CI pipeline, and a one-command Docker deploy that's live on its own domain. RadAssist is a research/education prototype — not FDA-cleared, not a medical device. That honesty isn't a disclaimer bolted on at the end; it is the design. This cannot be used on real Patients 🧰 Tech stack Frontend [x] React 19 + Vite 8 (SPA, custom lightweight routing) [x] Plain CSS design-token system — light/dark, 4 accent themes, flash-free [x] three.js — interactive 3D "scan volume" hero [x] jsPDF — in-browser PDF report export (nothing stored server-side) [x] qrcode — 2FA enrollment QR [x] Web Speech API — voice dictation for findings Backend / API [x] Python 3.11 · FastAPI · Uvicorn (ASGI) · Pydantic [x] REST API (~30 endpoints) serving the SPA same-origin AI / ML [x] PyTorch + TorchXRayVision — DenseNet-121 chest-X-ray ensemble (18 pathologies) [x] pytorch-grad-cam — attention/explainability overlays [x] OpenCV, NumPy, SciPy, scikit-image, pandas — imaging, measurements, classical CT/MRI candidate detection [x] pydicom + Pillow — DICOM parsing, windowing, de-identification [x] Out-of-distribution abstain gate + isotonic calibration + an on-repo validation harness (AUROC / ECE / NPV) [x] Optional LLM report formatter — Gemini / Groq / Ollama, with a deterministic template fallback (the LLM only formats, never invents findings) Database / persistence [x] SQLModel (SQLAlchemy 2.0) — SQLite → PostgreSQL, versioned with Alembic [x] PHI-safe by design (no pixels or patient identifiers in the DB); off by default (zero-config demo) Security [x] Stateless HMAC signed-cookie sessions + DB-backed revocation [x] scrypt password hashing · TOTP two-factor auth · 2FA secrets encrypted at rest (Fernet / cryptography) [x] Double-submit CSRF, per-account lockout, rate limiting, CSP/HSTS headers, fail-closed secrets [x] In-memory DICOM de-identification + secondary-capture quarantine DevOps / quality [x] Docker (multi-stage: builds the SPA, bakes the model weights) [x] GitHub Actions CI (tests + build + dependency audits) · Dependabot [x] pytest — ~294 automated tests · pip-audit / npm audit (0 known vulnerabilities) [x] Hosted on Fly.io + Cloudflare (also deployable to Hugging Face Spaces) ⭐ What makes it different Honest by construction — ranking scores not fake probabilities; abstains rather than guesses; a "not a normal read" safeguard so a no-flag scan is never mistaken for a clean bill of health. Explainable — every finding carries a Grad-CAM attention map, a reliability label, and a plain-language explanation. Measured, not claimed — a live Evidence page shows the model's real performance and its weak spots. Human-in-the-loop, always — AI flags arrive unchecked; nothing is finalized until a clinician signs. Genuinely full-stack — model, API, database, auth/2FA, security, tests, CI, and a live deployment. ⚠️ Honest limitations Metrics are measured on a public in-distribution benchmark and are optimistic; the chest-X-ray model is reliable only as a high-sensitivity review prompt for a few findings, and CT/MRI AI is unvalidated research. RadAssist is not a diagnostic device and not FDA-cleared — it's a demonstration of how to build medical AI responsibly.

## README (from the GitHub repository)

---
title: RadAssist
emoji: "\U0001FA7B"
colorFrom: blue
colorTo: gray
sdk: docker
app_port: 7860
pinned: false
license: apache-2.0
---

# RadAssist — AI Radiology Assistant & Report Generator

A **non-clinical, research/decision-support prototype** for radiology: **the AI drafts and
highlights; a licensed clinician reviews, corrects, and signs.** The model suggests — the
clinician decides.

> ⚠️ **For decision support only — not a diagnosis.** Every output (findings, highlighted
> regions, differentials, CT/MRI candidates) is AI-generated and may be wrong. It must be
> reviewed, corrected, and approved by a licensed radiologist before any clinical use.
> RadAssist is **not FDA-cleared and is not a medical device.** Use public / de-identified
> images only. Read [INTENDED-USE.md](INTENDED-USE.md) and
> [KNOWN-LIMITATIONS.md](KNOWN-LIMITATIONS.md) first.

---

## Contents

- [What it is / is not](#what-it-is--is-not)
- [Features](#features)
- [The app, screen by screen](#the-app-screen-by-screen)
- [Quick start](#quick-start)
- [How to use it](#how-to-use-it)
- [Demo login (test credentials)](#demo-login-test-credentials)
- [Checking the results (tests, build, accuracy, security)](#checking-the-results)
- [Configuration reference](#configuration-reference)
- [Optional: database persistence](#optional-database-persistence)
- [Optional: LLM report formatter](#optional-llm-report-formatter)
- [Architecture](#architecture)
- [Security](#security)
- [Project structure](#project-structure)
- [Documentation index](#documentation-index)
- [Data sources](#data-sources)
- [Accuracy & limitations (honest)](#accuracy--limitations-honest)

---

## What it is / is not

- **Is:** a research/education decision-support workspace with *measured, transparent* behaviour
  — every number is either measured on a public benchmark or clearly labelled "not calibrated /
  unvalidated". It abstains on out-of-distribution input instead of guessing.
- **Is not:** a diagnostic device. Not FDA-cleared or CE-marked. The chest X-ray model is a
  high-sensitivity/low-precision *review prompt*, reliable for only a few findings; CT/MRI AI is
  explicitly **unvalidated research**. "No flag" is **never** "normal".

---

## Features

### Chest X-ray AI
- **18-pathology screening** — a pretrained **TorchXRayVision DenseNet-121 ensemble** scores
  nodule, mass, effusion, pneumothorax, consolidation, pneumonia, cardiomegaly, atelectasis,
  edema, and more. Shown as a **ranking score** (a calibrated P≈ only where one is measured),
  never a diagnosis.
- **Grad-CAM region highlighting** — an attention heatmap/contour for the top finding, labelled
  "region of model attention — not a lesion boundary".
- **Abstain / OOD gate** — refuses non-chest, synthetic, or off-distribution images rather than
  emit a confident but meaningless flag; a **competence banner** downgrades low-quality films.
- **"NOT a normal read" safeguard** — a zero-flag film is never presented as normal; the app
  shows the measured negative-predictive-value of the no-flag state (≈0.82, in-distribution).
- **Per-label reliability gating** — findings with too few positives or at/below-chance AUROC
  (e.g. Pneumonia) are marked *"cannot exclude / not reliably measured"* and don't drive triage.
- **Two-tier triage** — high-confidence critical findings (calibrated-P gated) raise a
  "needs priority review" banner.

### CT / MRI (viewer + opt-in AI)
- **Full DICOM viewer** — windowing/presets, slice navigation + mouse-wheel, **cine**, **2-up
  compare**, MRI **series rail** (T1/T2/FLAIR/DWI/ADC auto-labelled from coded tags — *verify*),
  raw 16-bit window/level canvas, burned-in-annotation warning.
- **Measurement suite** — length, angle, and **HU / a.u. ROI** statistics computed on the true
  16-bit intensity, with undo/redo, a measurements list, and jump-to-slice.
- **Opt-in AI channels (default OFF, server-flag gated):** an **anatomy overlay** that labels
  organs/tissue (never disease) and an **unvalidated research candidate detector** (classical/
  deterministic; every candidate `validated=False`, "research use only — not a diagnosis").
- **CT/MRI research report** — a structured summary of confirmed candidates + measurements,
  server-guarded against any diagnostic/probability language, with sign-off + PDF export.

### Reporting & review
- **Findings form** — AI flags arrive **unchecked by default**; the clinician confirms, edits, or
  dismisses each. Free-text supports **voice dictation**.
- **Three-part report** — clinical report (Technique / History / Comparison / Findings /
  Impression / Recommendations), an 8th-grade **patient summary**, and reference **differentials**.
  Fully editable; AI-vs-edited provenance tracked; a completeness check runs before sign-off.
- **Mandatory sign-off** — nothing is finalized or exported until a named reviewer attests.
- **Local PDF export** — generated in-browser (jsPDF); downloads to your device (named with the
  patient), **never stored on the server**.
- **Prior-study comparison** — per-finding stable / new / worsened / improved / resolved changes.
- **Reviewer feedback loop** — confirm/dismiss feedback feeds a transparent operating-point
  refit (no black-box retraining).

### Platform
- **Full app shell** — a marketing site (Home w/ animated 3D hero, About, Help, Evidence) and a
  console (Dashboard w/ KPIs + charts + worklist, Upload, Workspace, Profile, Settings).
- **Theming** — light / dark / system, four **accent** colours, comfortable/compact density
  (flash-free, synced across the app).
- **Accounts & sessions** — optional login with **TOTP 2FA enrollment**, **active-session list +
  revoke** ("sign out of all other devices"), and a demo-login mode (below).
- **Optional database** — durable users/2FA, feedback, and audit (SQLite → Postgres); off by
  default (zero-config).
- **Client diagnostics log** — every API call/timing/error captured locally (viewable in
  Settings), never logging response bodies (no PHI).
- **Evidence page** — the model's real measured behaviour (AUROC, ECE, per-label sensitivity,
  no-flag NPV), served live from the validation harness — *measured, not claimed*.

---

## The app, screen by screen

| Area | Screens |
|---|---|
| **Marketing** | Home (hero + capabilities + modalities + evidence + FAQ), About, Help, Evidence, Privacy |
| **Console** | Dashboard (KPIs, charts, session worklist), Upload, **Workspace** (X-ray analyzer + CT/MRI viewer), Profile (security/2FA/sessions), Settings (theme/accent/prefs/diagnostics) |
| **Auth** | Login (password + optional 2FA; SSO shown as roadmap), demo-login mode |

---

## Quick start

**Prereqs:** Python 3.11+ and Node 18+.

```powershell
# 1) Backend (first run downloads pretrained weights, ~30 MB)
cd backend
python -m venv .venv
.\.venv\Scripts\pip install -r requirements.txt
.\.venv\Scripts\python -m uvicorn app.main:app --port 8000

# 2) Frontend (second terminal)
cd frontend
npm install
npm run dev
```

Open **http://localhost:5173** — the Vite dev server proxies `/api` and `/static` to the backend
on `:8000`. Drop a chest X-ray (e.g. from `samples/`) onto the upload panel.

Helper scripts from the repo root: `.\start-backend.ps1` and `.\start-frontend.ps1`.
Production build of the UI: `cd frontend && npm run build` (a `Dockerfile` builds the SPA and
serves it from FastAPI on port 7860 for the Hugging Face Space deploy).

---

## How to use it

**Chest X-ray:**
1. **Upload** a chest radiograph (DICOM `.dcm`, PNG, or JPG) on the Upload screen or the Workspace.
2. The model **analyses** it (or **abstains** with a reason if it isn't a readable chest film).
3. **Review** the AI findings in the right rail — each is a suggestion with a score, a Grad-CAM
   region, and a plain-language explanation. **Confirm / edit / dismiss** each; nothing is a
   finding until you say so. Watch for the **"not a normal read"** and reliability chips.
4. Optionally add **history**, take **caliper** measurements, and **compa

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 210 recognized source files, 1715 KB.
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Docker (technology) — claimed on Devpost, not found in the code
- PostgreSQL (technology) — claimed on Devpost, not found in the code
- PyTorch (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (120 of 243)

```
.dockerignore
.github/dependabot.yml
.github/workflows/ci.yml
.gitignore
backend/.env.demo
backend/.env.example
backend/alembic.ini
backend/alembic/env.py
backend/alembic/README
backend/alembic/script.py.mako
backend/alembic/versions/70ac64f3c673_baseline_users_feedback_events_audit_.py
backend/app/__init__.py
backend/app/auth.py
backend/app/config.py
backend/app/db.py
backend/app/main.py
backend/app/models/__init__.py
backend/app/models/ct_report.py
backend/app/models/db_models.py
backend/app/models/detect.py
backend/app/models/schemas.py
backend/app/models/segment.py
backend/app/routers/__init__.py
backend/app/routers/analyze.py
backend/app/routers/compare.py
backend/app/routers/ct_report.py
backend/app/routers/detect.py
backend/app/routers/feedback.py
backend/app/routers/report.py
backend/app/routers/segment.py
backend/app/routers/study.py
backend/app/routers/viewer.py
backend/app/security.py
backend/app/services/__init__.py
backend/app/services/anatomy.py
backend/app/services/audit.py
backend/app/services/calibration.py
backend/app/services/compare.py
backend/app/services/completeness.py
backend/app/services/ct_cade.py
backend/app/services/ct_report.py
backend/app/services/decode_limit.py
backend/app/services/dicom_utils.py
backend/app/services/feedback_stats.py
backend/app/services/fusion.py
backend/app/services/label_map.py
backend/app/services/llm.py
backend/app/services/localizer.py
backend/app/services/mr_cade.py
backend/app/services/mr_classical_seg.py
backend/app/services/provenance.py
backend/app/services/readability.py
backend/app/services/reliability.py
backend/app/services/seg_store.py
backend/app/services/segmentation.py
backend/app/services/self_audit.py
backend/app/services/storage.py
backend/app/services/store.py
backend/app/services/templates.py
backend/app/services/tissue_seg.py
backend/app/services/triage.py
backend/app/services/upload_guard.py
backend/app/services/vision_xray.py
backend/behavior_card.json
backend/calibration_map.json
backend/calibration.json
backend/conftest.py
backend/perturbation_stats.json
backend/predictions.json
backend/pytest.ini
backend/requirements-dev.txt
backend/requirements-segment.txt
backend/requirements.txt
backend/smoke_test.py
backend/storage/audit/audit.jsonl
backend/storage/feedback/feedback.jsonl
backend/tests/__init__.py
backend/tests/_dicom_factory.py
backend/tests/test_account_sessions.py
backend/tests/test_analyze.py
backend/tests/test_audit_store.py
backend/tests/test_auth_hardening.py
backend/tests/test_auth_persistence.py
backend/tests/test_auth.py
backend/tests/test_calibration_fusion.py
backend/tests/test_completeness.py
backend/tests/test_ct_detect.py
backend/tests/test_ct_report.py
backend/tests/test_ct_segment_hu_determinism.py
backend/tests/test_decode_limits.py
backend/tests/test_dicom_roi.py
backend/tests/test_feedback_db.py
backend/tests/test_feedback_events.py
backend/tests/test_feedback_refit.py
backend/tests/test_heatmap_honesty.py
backend/tests/test_label_contract.py
backend/tests/test_label_fidelity.py
backend/tests/test_load_saved.py
backend/tests/test_measurements.py
backend/tests/test_model_whitelist.py
backend/tests/test_phi_response_models.py
backend/tests/test_phi_security.py
backend/tests/test_report_modality.py
backend/tests/test_report_safety.py
backend/tests/test_report.py
backend/tests/test_safety_fixes.py
backend/tests/test_secrets_posture.py
backend/tests/test_seg_store.py
backend/tests/test_segment_alignment.py
backend/tests/test_segment_endpoint_model_free.py
backend/tests/test_segment_feature_flag.py
backend/tests/test_segment_modality_guard.py
backend/tests/test_segment_phi_quarantine.py
backend/tests/test_segment_schema_taboo.py
backend/tests/test_self_audit.py
backend/tests/test_session_revocation.py
backend/tests/test_static_privacy.py
backend/tests/test_study.py
backend/tests/test_triage.py
backend/tests/test_twofa_encryption.py
[123 more files omitted for size]
```

### Dependencies

- backend/requirements.txt: alembic@==1.18.5, cryptography@==49.0.0, fastapi@==0.139.2, google-generativeai@==0.8.6, grad-cam@==1.5.5, groq@==1.5.0, numpy@==2.5.1, opencv-python-headless@==5.0.0.93, pandas@==3.0.3, pillow@==12.3.0, pydicom@==3.0.2, python-dotenv@==1.2.2, python-multipart@==0.0.32, scikit-image@==0.26.0, scipy@==1.18.0, SQLAlchemy@==2.0.51, sqlmodel@==0.0.39, torchxrayvision@==1.5.2, uvicorn[standard]@==0.51.0
- frontend/package.json: @vitejs/plugin-react@^6.0.3, jspdf@^4.2.1, qrcode@^1.5.4, react@^19.2.7, react-dom@^18.3.1, three@^0.185.1, vite@^6.0.7

### Recent commits (newest first)

- chore: configure Fly deployment
- Merge pull request #1 from suryamanoj09/dependabot/github_actions/actions/upload-artifact-7
- Merge pull request #3 from suryamanoj09/dependabot/github_actions/actions/setup-python-7
- Merge pull request #2 from suryamanoj09/dependabot/github_actions/actions/setup-node-7
- Merge pull request #4 from suryamanoj09/dependabot/github_actions/actions/checkout-7
- Merge pull request #5 from suryamanoj09/dependabot/npm_and_yarn/frontend/react-dom-19.2.7
- Merge branch 'main' into dependabot/npm_and_yarn/frontend/react-dom-19.2.7
- Merge pull request #6 from suryamanoj09/dependabot/npm_and_yarn/frontend/vite-8.1.5
- Merge branch 'main' into dependabot/npm_and_yarn/frontend/vite-8.1.5
- Merge pull request #7 from suryamanoj09/dependabot/npm_and_yarn/frontend/react-19.2.7
- Merge pull request #8 from suryamanoj09/dependabot/npm_and_yarn/frontend/vitejs/plugin-react-6.0.3
- Merge pull request #9 from suryamanoj09/dependabot/pip/backend/backend-minor-patch-d70bb6e767
- deps(backend): bump the backend-minor-patch group
- deps(frontend): bump @vitejs/plugin-react in /frontend
- deps(frontend): bump react from 18.3.1 to 19.2.7 in /frontend
- deps(frontend): bump vite from 6.4.3 to 8.1.5 in /frontend
- deps(frontend): bump react-dom from 18.3.1 to 19.2.7 in /frontend
- deps(ci): bump actions/checkout from 4 to 7
- deps(ci): bump actions/setup-python from 5 to 7
- deps(ci): bump actions/setup-node from 4 to 7

## Key source files (fetched from GitHub, selected and truncated for size)

### INTENDED-USE.md

```markdown
# Intended use

**RadAssist is a non-clinical demonstration and portfolio prototype of an AI radiology
decision-support workflow.** It analyzes de-identified or public chest X-ray images with a
pretrained model to surface candidate findings, a region of model attention, a rule-based
priority-review flag, and a draft three-part report (clinical, plain-language patient summary,
and reference differentials) that a licensed radiologist would review, correct, and approve
before it has any meaning.

It is intended for developers and technical reviewers evaluating the prototype. It is **NOT**
intended for the diagnosis, treatment, triage, or clinical management of any patient, **NOT**
for use with real or identifiable patient data, and has **not** been evaluated or cleared by
any regulatory authority.

All model outputs are draft signals only — "model confidence" and "region of model attention",
never a diagnosis. No output is a finalized or signed medical record. The in-app review
attestation ("I have reviewed and adopt these findings") is a **workflow acknowledgement to
gate the draft**, not a legal electronic signature — the tool has no identity, audit, or
records function.

## Why this framing matters

Device classification flows from intended use. On the basis of this non-clinical,
public/de-identified, no-real-patient intended use, RadAssist is **not a regulated medical
device today**. That safe harbor rests entirely on the intended use above:

- If the stated intended use were clinical, the same functions would be regulated — in the US
  as FDA Class II radiological CADe/CADx (the vision path) and CADt (the triage flag); in the
  EU under MDR 2017/745 Rule 11 as Class IIa or higher.
- The **differential** list is a fixed, human-curated set of textbook associations, never
  patient-specific model reasoning, precisely so the tool does not perform a diagnostic
  (CADx) function.
- The sanctioned future CT-brain intracranial-hemorrhage feature falls in one of the most
  heavily regulated AI-radiology categories. Its intended-use guardrails and its "experimental
  screening flag, not detection" framing must be locked before that build.

For any move toward real clinical use, this document, the disclaimers, and the whole
development process would need to be revisited under IEC 62304 (software lifecycle) and
ISO 14971 (risk management). This prototype deliberately stays on the demonstration side of
that line.

```

### DEMO-SCRIPT.md

```markdown
# Demo script (~5 minutes)

Order matters: lead with the patient summary (the novel part), show the reliable core, then
the vision assist, then be honest about limits — that reads as maturity.

## Setup (before the demo)

1. `.\start-backend.ps1` and `.\start-frontend.ps1`, open http://localhost:5173.
2. Have 2–3 sample images ready (`samples/` folder, or Open-i/NIH images).
3. Optional: put a free Gemini/Groq key in `backend/.env` for fluent reports; the template
   engine works without it.

## Script

**1. The problem (30s).** "Radiologists spend a huge share of their time writing reports,
and patients almost never get their imaging explained. RadAssist drafts the paperwork so
the radiologist just validates — and it produces a plain-English summary for the patient."

**2. Findings → report, the reliable core (90s).**
- Without uploading anything… actually upload a film first (the form needs an analysis), or
  tick findings manually: nodule 12 mm RUL + pleural effusion, history "62M, smoker".
- Click **Generate report** → walk through the three tabs:
  - *Clinical report* — standard Technique/History/Comparison/Findings/Impression skeleton.
  - **Patient summary — the star.** Read a line aloud: 8th-grade English, no jargon,
    ends by directing the patient to their doctor.
  - *Differentials* — prefixed "for physician review only".
- Edit a line, then **Export PDF** — the draft disclaimer prints on the PDF.

**3. AI assist on the image (90s).**
- Upload a chest X-ray. Point out:
  - per-pathology **model confidence** bars (say "confidence", never "diagnosis"),
  - the **heatmap toggle** — "region of model attention, not a lesion boundary",
  - the pre-filled findings form — "the AI drafts, I validate; I can untick anything",
  - the **caliper** for a real measurement,
  - dictate a sentence with the **🎙 Dictate** button.
- If a high-confidence critical finding appears, show the **priority-review banner**.

**4. Prior comparison (30s).** Upload a second film as "Prior study" → the interval-change
table (stable / new / worsened / improved / resolved) and how it flows into the report's
Comparison section.

**5. Honesty as a feature (30s).** Open the CT/MRI viewer — point out the AI channels are
opt-in and off by default, with the candidate detector marked "unvalidated research" — and open
KNOWN-LIMITATIONS.md: "We know exactly what this tool can't do — that's why every output
routes through a clinician and every screen carries the disclaimer."

## One-liners to keep handy

- "The LLM never invents findings — it only formats what the clinician confirmed."
- "The heatmap shows where the model looked, not where the disease is."
- "If everything else failed, the findings-to-report core still ships value."

```

### Dockerfile

```
# syntax=docker/dockerfile:1

# ---------- Stage 1: build the React SPA ----------
FROM node:20-slim AS frontend
WORKDIR /app
# Install deps first so this layer caches unless the lockfile changes.
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# Build the static bundle -> /app/dist
COPY frontend/ ./
RUN npm run build

# ---------- Stage 2: FastAPI + baked model + built SPA ----------
FROM python:3.11-slim AS runtime

# libglib2.0-0 is the one native lib opencv-python-headless still needs at runtime.
RUN apt-get update \
    && apt-get install -y --no-install-recommends libglib2.0-0 \
    && rm -rf /var/lib/apt/lists/*

# Non-root uid-1000 user (Hugging Face Spaces run the container as uid 1000).
RUN useradd -m -u 1000 user

# HOME-scoped caches so baked weights + any runtime cache live under the
# user-owned home; PORT default 7860; storage under the app dir (user-owned).
ENV HOME=/home/user \
    TORCH_HOME=/home/user/.cache/torch \
    XDG_CACHE_HOME=/home/user/.cache \
    PORT=7860 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    STORAGE_DIR=/home/user/app/storage

WORKDIR /home/user/app

# 1) CPU-only torch/torchvision FIRST, from the PyTorch CPU index, so pip never
#    resolves the multi-GB CUDA wheels. Both pins have cp311 linux CPU wheels.
RUN pip install --no-cache-dir \
      --index-url https://download.pytorch.org/whl/cpu \
      torch==2.2.2 torchvision==0.17.2

# 2) THEN the rest of the backend deps from plain PyPI. requirements.txt no
#    longer lists torch/torchvision, so the CPU wheels installed above are kept.
COPY backend/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt

# 3) Backend source.
COPY backend/ ./

# Make the app tree + caches + storage owned by the runtime user.
RUN mkdir -p /home/user/.cache /home/user/app/storage \
    && chown -R user:user /home/user

USER user

# 4) BAKE the pretrained weights into the image at build time so a cold Space
#    does zero downloading on first request. Runs as `user` so the download
#    lands in the user-owned HOME cache that is committed into the image.
#    If the ensemble later adds more weight sets, add one bake line per set.
RUN python -c "import torchxrayvision as xrv; xrv.models.DenseNet(weights='densenet121-res224-all'); print('weights baked')"

# 5) Built SPA from stage 1 -> ./frontend_dist. main.py mounts this at '/' as
#    the LAST route (config.BASE_DIR/frontend_dist == /home/user/app/frontend_dist).
COPY --chown=user:user --from=frontend /app/dist ./frontend_dist

EXPOSE 7860

# Shell form so ${PORT} (default 7860) is read from the environment at runtime.
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]

```

### frontend/package.json

```
{
  "name": "radassist-frontend",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "jspdf": "^4.2.1",
    "qrcode": "^1.5.4",
    "react": "^19.2.7",
    "react-dom": "^18.3.1",
    "three": "^0.185.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^6.0.3",
    "vite": "^6.0.7"
  }
}

```

### backend/requirements.txt

```
# Pinned for reproducible, auditable builds. Pillow/pydicom/opencv parse
# untrusted uploaded bytes, so an unpinned build could silently pull a
# vulnerable release — run `pip-audit` on this file in CI and bump deliberately.
fastapi==0.139.2
uvicorn[standard]==0.51.0
python-multipart==0.0.32
pydicom==3.0.2
pillow==12.3.0
numpy==2.5.1
pandas==3.0.3
scikit-image==0.26.0
# scipy: transitively present via scikit-image, but pinned explicitly because the
# classical anatomy-segmentation baseline (services/tissue_seg.py) imports
# scipy.ndimage directly. NOTE: the heavy segmentation seam (TotalSegmentator/
# SynthSeg) is NOT here — it lives in the optional requirements-segment.txt.
scipy==1.18.0
# torch and torchvision are installed as CPU-only wheels in the Dockerfile
# (from the PyTorch CPU index, BEFORE this file) so the multi-GB CUDA wheels are
# never pulled. Do NOT re-add bare torch/torchvision lines here.
# For a NON-Docker local install, install them manually, e.g.:
#   pip install torch==2.2.2 torchvision==0.17.2 --index-url https://download.pytorch.org/whl/cpu
torchxrayvision==1.5.2
grad-cam==1.5.5
opencv-python-headless==5.0.0.93
google-generativeai==0.8.6
groq==1.5.0
python-dotenv==1.2.2
# Encryption at rest for the users.twofa_secret column (Fernet/HKDF via
# app/services/store.py). Only exercised when DATABASE_URL is set AND a 2FA secret is
# stored; the zero-config demo never reaches it. If this dependency is missing the
# store logs a one-time warning and stores plaintext (documented gap, no silent
# false claim). Also a transitive dep of several libs already; pinned for auditability.
cryptography==49.0.0
# Opt-in persistence layer (app/db.py, app/models/db_models.py, app/services/store.py).
# DORMANT unless DATABASE_URL is set — the zero-config demo never touches it. Default
# engine is stdlib SQLite (a file, no external service); Postgres works by changing only
# DATABASE_URL (add a driver such as psycopg[binary] then). SQLAlchemy is pulled in by
# SQLModel; pinned for reproducible, auditable builds.
sqlmodel==0.0.39
SQLAlchemy==2.0.51
# Alembic: versioned schema migrations for the Postgres path (backend/alembic/).
# Not needed for the SQLite demo (app/db.init_db uses create_all), but pinned so a
# real deploy can run `alembic upgrade head` reproducibly.
alembic==1.18.5
# NOTE: reportlab removed — PDF export is client-side (jsPDF), so the backend
# never needed it (smaller image, smaller attack surface).

```

### frontend/src/main.jsx

```javascript
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import ErrorBoundary from './components/ErrorBoundary.jsx'
import { initTheme } from './components/ThemeToggle.jsx'
import { initLogging } from './logger.js'
import './styles.css'

initTheme()     // re-sync data-theme; the flash-free stamp runs earlier via the
                // render-blocking inline script in index.html <head>.
initLogging()   // capture uncaught errors, rejections, and every API call into the
                // in-app log (viewable in Settings).

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <ErrorBoundary>
      <App />
    </ErrorBoundary>
  </React.StrictMode>,
)

```

### backend/app/main.py

```python
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles

from . import auth, config
from .routers import (analyze, compare, ct_report, detect, feedback, report,
                      segment, study, viewer)
from .security import (AccessCodeMiddleware, RateLimitMiddleware,
                       SecurityHeadersMiddleware, SegmentLaunchLimitMiddleware)
from .services import self_audit, storage, vision_xray

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Warm the model at startup so the first upload isn't a multi-second cold stall.
    try:
        vision_xray.warm_up()
        self_audit.warm_up()
        from .services import anatomy, localizer, seg_store
        anatomy.warm_up()
        localizer.warm_up()  # no-op unless LOCALIZER_WEIGHTS is set
        seg_store.warm_up()  # no-op unless segmentation is enabled
        logger.info("Vision + self-audit + anatomy models warmed up.")
    except Exception:
        logger.exception("Model warm-up failed; will load lazily on first request.")
    try:
        storage.start_sweeper()
    except Exception:
        logger.exception("Storage sweeper failed to start.")
    yield


app = FastAPI(
    title="RadAssist API",
    description="AI radiology decision-support backend. AI drafts; a licensed clinician reviews and approves.",
    version="0.2.0",
    lifespan=lifespan,
    # Public API docs enumerate every endpoint/schema — off unless explicitly enabled.
    docs_url="/docs" if config.ENABLE_DOCS else None,
    redoc_url="/redoc" if config.ENABLE_DOCS else None,
    openapi_url="/openapi.json" if config.ENABLE_DOCS else None,
)

# Starlette wraps middleware in reverse registration order, so the middleware
# added LAST is outermost. Register the security middlewares first and CORS
# last, so CORS stays outermost and can attach its headers to every response —
# including a 429 emitted by the rate limiter — when the frontend is on a
# separate origin.
app.add_middleware(RateLimitMiddleware)
app.add_middleware(SegmentLaunchLimitMiddleware)  # stricter per-IP budget for seg launches
app.add_middleware(AccessCodeMiddleware)
app.add_middleware(auth.AuthMiddleware)  # gates PHI-adjacent paths when AUTH_ENABLED
app.add_middleware(SecurityHeadersMiddleware)

app.add_middleware(
    CORSMiddleware,
    allow_origins=config.ALLOWED_ORIGINS,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(auth.router)
app.include_router(analyze.router)
app.include_router(report.router)
app.include_router(compare.router)
app.include_router(study.router)
app.include_router(feedback.router)
app.include_router(viewer.router)  # CT/MRI model-free viewer (no AI)
app.include_router(segment.router)  # opt-in, non-diagnostic anatomy-overlay (default off)
app.include_router(detect.router)   # opt-in RESEARCH CADe: disease candidates (default off)
app.include_router(ct_report.router)  # CT/MRI research report (confirmed candidates + measurements)

# Only images/heatmaps/segment-masks are public. Analysis JSON lives in
# config.ANALYSIS_DIR, which is deliberately NOT mounted here.
app.mount("/static/uploads", StaticFiles(directory=config.UPLOADS_DIR), name="uploads")
app.mount("/static/heatmaps", StaticFiles(directory=config.HEATMAPS_DIR), name="heatmaps")
app.mount("/static/segments", StaticFiles(directory=config.SEGMENTS_DIR), name="segments")


@app.get("/api/health")
def health():
    return {
        "status": "ok",
        "llm_provider": config.LLM_PROVIDER,
        "llm_key_present": bool(
            (config.LLM_PROVIDER == "gemini" and config.GEMINI_API_KEY)
            or (config.LLM_PROVIDER == "groq" and config.GROQ_API_KEY)
            or config.LLM_PROVIDER == "ollama"
        ),
        "report_default": "template" if config.LLM_PROVIDER == "none"
        or not (config.GEMINI_API_KEY or config.GROQ_API_KEY or config.LLM_PROVIDER == "ollama")
        else config.LLM_PROVIDER,
        "disclaimer": config.DISCLAIMER,
    }


# Serve the built React SPA from the SAME origin as the API. This mount MUST be
# registered LAST: FastAPI matches routes/mounts in registration order, so every
# /api router and the /static mounts above still win, and this only catches
# everything else. html=True serves index.html for unknown paths (client-side
# routing). Guarded so local dev (no build present) runs API-only without crashing.
_SPA_DIR = config.BASE_DIR / "frontend_dist"
if _SPA_DIR.is_dir():
    app.mount("/", StaticFiles(directory=_SPA_DIR, html=True), name="spa")
    logger.info("Serving SPA from %s", _SPA_DIR)
else:
    logger.warning("SPA dir %s absent; API-only mode (local dev).", _SPA_DIR)

```

### frontend/src/App.jsx

```javascript
import { useEffect, useState } from 'react'
import { emptyPatient } from './components/PatientIntake.jsx'
import WorkspaceLayout from './components/workspace/WorkspaceLayout.jsx'
import InfoPage from './components/InfoPage.jsx'
import KnownLimitations from './components/KnownLimitations.jsx'
import FailureGallery from './components/FailureGallery.jsx'
import ThemeToggle from './components/ThemeToggle.jsx'
import FeedbackAdmin from './components/FeedbackAdmin.jsx'
import HomePage from './components/HomePage.jsx'
import AboutPage from './components/AboutPage.jsx'
import EvidencePage from './components/EvidencePage.jsx'
import HelpPage from './components/HelpPage.jsx'
import UploadScreen from './components/UploadScreen.jsx'
import Dashboard from './components/dashboard/Dashboard.jsx'
import PrivacyPolicy from './components/PrivacyPolicy.jsx'
import SettingsPage from './components/SettingsPage.jsx'
import ProfilePage from './components/ProfilePage.jsx'
import MarketingHeader from './components/shell/MarketingHeader.jsx'
import MarketingFooter from './components/shell/MarketingFooter.jsx'
import AppShell from './components/shell/AppShell.jsx'
import Login from './components/Login.jsx'
import { shellFor, titleFor } from './routes/pageRegistry.js'
import { analyzeImage, compareStudies, getBehaviorCard, me } from './api.js'
import { aiSuggestions, emptyStructured } from './labelMap.js'
import { log } from './logger.js'

export default function App() {
  const [page, setPage] = useState('home')   // see routes/pageRegistry.js for the full set
  const [tab, setTab] = useState('xray')
  const [infoOpen, setInfoOpen] = useState(false)
  const [limitsOpen, setLimitsOpen] = useState(false)
  const [failuresOpen, setFailuresOpen] = useState(false)
  const [adminOpen, setAdminOpen] = useState(false)
  const [railsCollapsed, setRailsCollapsed] = useState(false)
  const [analysis, setAnalysis] = useState(null)
  const [prior, setPrior] = useState(null)
  const [comparison, setComparison] = useState(null)
  const [comparisonError, setComparisonError] = useState(null)
  // structured = CLINICIAN-CONFIRMED findings only; starts empty every study.
  const [structured, setStructured] = useState(emptyStructured())
  const [history, setHistory] = useState('')
  const [busySlot, setBusySlot] = useState(null) // 'current' | 'prior' | null
  const [error, setError] = useState(null)
  const [behaviorCard, setBehaviorCard] = useState(null)
  // BUG2: session search text from the TopBar; filters the Dashboard worklist/activity.
  const [studyQuery, setStudyQuery] = useState('')
  // BUG5: was a patient name explicitly typed at the Upload intake for THIS study?
  // Lives for one analysis so a typed name survives into the workspace/PDF without
  // a stale name bleeding across a later study.
  const [intakeEntered, setIntakeEntered] = useState(false)
  // Current user, fetched once for the console TopBar/Sidebar. Tolerates auth
  // being disabled: me() resolves to { auth_enabled:false, authenticated:false,
  // user:null } on any failure, so we degrade to a guest.
  const [meInfo, setMeInfo] = useState(null)
  // Grounded hover: label of the finding whose region should be highlighted in the Viewer.
  const [focusedFinding, setFocusedFinding] = useState(null)
  // Patient identifiers: OPTIONAL, client-side ONLY. Kept in React state + sessionStorage
  // (ephemeral — gone when the tab closes). NEVER sent to any /api endpoint, never
  // persisted server-side. Rendered only into the locally-exported PDF header.
  const [patient, setPatient] = useState(() => {
    try { return JSON.parse(sessionStorage.getItem('radassist_patient') || 'null') || emptyPatient() }
    catch { return emptyPatient() }
  })
  useEffect(() => {
    try { sessionStorage.setItem('radassist_patient', JSON.stringify(patient)) } catch { /* ignore */ }
  }, [patient])

  // On page navigation: scroll to top (a single-page app has no browser scroll reset)
  // and dismiss any analyzer utility modal so it can't stack over another page.
  useEffect(() => {
    window.scrollTo({ top: 0, behavior: 'smooth' })
    setInfoOpen(false); setLimitsOpen(false); setFailuresOpen(false); setAdminOpen(false)
  }, [page])

  useEffect(() => {
    getBehaviorCard().then(setBehaviorCard).catch(() => setBehaviorCard({ available: false }))
  }, [])

  // Fetch the current user once for the console chrome. me() never rejects.
  useEffect(() => {
    me().then(setMeInfo).catch(() => setMeInfo({ auth_enabled: false, authenticated: false, user: null }))
  }, [])

  // Called by <Login> after a full sign-in (2FA satisfied when enrolled). Flip the
  // session locally so the gate lifts; the intended console route re-renders in place.
  // If the user reached Login via the explicit /login route, send them to the console.
  function handleAuthed(user) {
    setMeInfo((prev) => ({ ...(prev || {}), auth_enabled: true, authenticated: true, user }))
    if (page === 'login') setPage('dashboard')
  }

  // Autosave/draft recovery: radiologists get interrupted constantly, and a
  // half-written report + a tab crash = lost work. We persist the current analysis
  // + the report draft (history + confirmed findings) to localStorage and restore
  // it on load. Patient identifiers are NOT included (they stay in sessionStorage).
  const [draftRestored, setDraftRestored] = useState(false)
  useEffect(() => {
    try {
      const raw = localStorage.getItem('radassist_report_session')
      if (raw) {
        const d = JSON.parse(raw)
        if (d && d.analysis) {
          setAnalysis(d.analysis)
          setStructured(d.structured || emptyStructured())
          setHistory(d.history || '')
          setDraftRestored(true)
        }
      }
    } catch { /* ignore */ }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])
  useEffect(() => {
    try {
      if (analysis) {
        localStorage.setItem('radassist_report_session',
          JSON.stringify({ analysis, history,
[truncated — 11018 more characters]
```

### frontend/vite.config.js

```javascript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      },
      '/static': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      },
    },
  },
})

```

### frontend/index.html

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>RadAssist — AI Radiology Assistant</title>
    <!-- Flash-free theme: stamp data-theme from the stored choice BEFORE the CSS
         paints, so a manual Light/Dark differing from the system default never
         flashes the wrong background. Render-blocking on purpose (must run before
         first paint); the module bundle's initTheme() only re-syncs. Keep the
         storage key in sync with ThemeToggle.jsx (radassist_theme). -->
    <script>
      (function () {
        try {
          var m = localStorage.getItem('radassist_theme');
          if (m === 'light' || m === 'dark') document.documentElement.dataset.theme = m;
        } catch (e) { /* system default */ }
      })();
    </script>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

```

[194 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]