# EKG Tutor — droplet deployment notes

Adapted from BLUEPRINT.md §5 + §11 Task 1.12 to fit the existing
adampowell.pro DigitalOcean droplet at `198.211.114.12` (Debian 10, nginx
1.14.2, native Postgres already running).

> **Read first:** `docs/WHEN CREATING A NEW APP.md` in the repo root.
> All conventions there apply.

---

## 0. One-time droplet prep

Run as `root` on the droplet.

### Node 22

The system has Node 18.20.8. EKG Tutor's TypeScript build targets ES2022 and
runs fine on 18, but bumping to 22 LTS aligns with the blueprint's `.nvmrc`
and matches the dev container.

```bash
# Install Node 22 LTS via NodeSource. Skip if already present.
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs

# Confirm.
node --version    # expect v22.x
```

### pnpm 10

```bash
corepack enable
corepack prepare pnpm@10.33.0 --activate
pnpm --version
```

### Python 3.12 + uv

Debian 10 ships Python 3.7. We need 3.12.

```bash
# Build deps for pyenv (one-time).
apt-get install -y make build-essential libssl-dev zlib1g-dev libbz2-dev \
    libreadline-dev libsqlite3-dev libncurses-dev libffi-dev liblzma-dev \
    libxml2-dev libxmlsec1-dev tk-dev wget curl llvm xz-utils

# Install uv (manages its own Python toolchains).
curl -LsSf https://astral.sh/uv/install.sh | sh
. "$HOME/.local/bin/env" 2>/dev/null || export PATH="$HOME/.local/bin:$PATH"

uv python install 3.12
uv --version
```

### Postgres database + role

The droplet already runs Postgres (used by `sharedtodo.service`). We add a
fresh database and a least-privilege role for ekg-tutor.

```bash
# Pick a strong password and store it in /root/secrets.env.
EKG_DB_PASS=$(openssl rand -hex 24)

sudo -u postgres psql <<SQL
CREATE ROLE ekg_tutor WITH LOGIN PASSWORD '$EKG_DB_PASS';
CREATE DATABASE ekg_tutor OWNER ekg_tutor;
\c ekg_tutor
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS citext;
-- pgvector is pulled in for Phase 2; harmless to install now.
-- Requires postgresql-NN-pgvector apt package. If not installed, skip.
CREATE EXTENSION IF NOT EXISTS vector;
GRANT ALL PRIVILEGES ON DATABASE ekg_tutor TO ekg_tutor;
SQL

# Append to /root/secrets.env.
cat >> /root/secrets.env <<EOF

# ----- EKG Tutor secrets -----
# Used by: apps/ekg-tutor/{api,tutor,interpret}
# Rotation: rotate ANTHROPIC_API_KEY in console.anthropic.com; rotate the
# DB password by ALTER ROLE ekg_tutor PASSWORD '...' and update this file.
EKG_TUTOR_DATABASE_URL=postgres://ekg_tutor:$EKG_DB_PASS@127.0.0.1:5432/ekg_tutor
EKG_TUTOR_UPLOAD_DIR=/var/lib/ekg-tutor/uploads
EKG_TUTOR_INTERPRET_URL=http://127.0.0.1:3112
EKG_TUTOR_TUTOR_URL=http://127.0.0.1:3111
EKG_TUTOR_AUTH_CHECK_URL=http://127.0.0.1:3003/api/auth/check
ANTHROPIC_API_KEY=sk-ant-CHANGEME
EOF

chmod 600 /root/secrets.env
```

If `pgvector` is missing, install with
`apt-get install postgresql-NN-pgvector` (match the Postgres major version)
or skip the `CREATE EXTENSION vector;` line and revisit in Phase 2.

### Image storage directory

```bash
mkdir -p /var/lib/ekg-tutor/uploads
chown root:root /var/lib/ekg-tutor/uploads
chmod 750 /var/lib/ekg-tutor/uploads
```

### Log directories

systemd writes to `/var/log/ekg-tutor-*.log`. Pre-create or rely on logrotate
defaults. Add a logrotate rule alongside the other apps if needed.

---

## 1. Build on the droplet

```bash
cd /var/www/adampowell.pro
git fetch origin
git checkout claude/ekg-tutor-pwa-lBkZz   # or main after merge
git pull --ff-only

cd apps/ekg-tutor
pnpm install --frozen-lockfile
pnpm build

cd interpret
uv venv
uv pip install -e .
```

The webhook does `git pull` automatically on push, but Node/Python deps need
a manual `pnpm install` / `uv pip install` after dependency-changing commits.
Adding that to the webhook is a Phase 2 enhancement.

---

## 2. Install systemd units

```bash
cp /var/www/adampowell.pro/apps/ekg-tutor/infra/systemd/*.service \
   /etc/systemd/system/

systemctl daemon-reload
systemctl enable ekg-tutor-api.service \
                 ekg-tutor-tutor.service \
                 ekg-tutor-interpret.service
systemctl start  ekg-tutor-api.service \
                 ekg-tutor-tutor.service \
                 ekg-tutor-interpret.service

# Confirm.
ss -tlnp | grep -E ':311[012]'
```

Expected: three lines, ports 3110/3111/3112, all bound to 127.0.0.1.

---

## 3. Install nginx block

```bash
bash /var/www/adampowell.pro/apps/ekg-tutor/infra/install-nginx-block.sh
```

Script is idempotent. It backs up sites-enabled + sites-available before
injecting, runs `nginx -t`, and reloads. Reverts automatically on failure.

---

## 4. Add the symlink

Add the symlink line to `scripts/rebuild-symlinks.sh` so future deploys also
recreate it. Pattern matches the other PWA apps (per-file symlinks for the
build output + manifest):

```bash
# In scripts/rebuild-symlinks.sh:
mkdir -p "$HTML/ekg-tutor"
ekg_root="$(resolve ekg-tutor)"
if [ -n "$ekg_root" ] && [ -d "$ekg_root/web/dist" ]; then
    link "$HTML/ekg-tutor" "$ekg_root/web/dist"
fi
```

(The `web/dist` symlink target gives nginx the built Vite output. SPA
fallback for client-side routes lives in the nginx snippet as part of the
catch-all gated `location /` — `try_files $uri /ekg-tutor/index.html`-style
behavior is not strictly required for Phase 1 because there's only one
entry point, but consider adding it for the case-detail deep links.)

Then run once manually:

```bash
bash /var/www/adampowell.pro/scripts/rebuild-symlinks.sh
```

---

## 5. Add a `nav.json` entry

In `html/nav.json`:

```json
{
  "name": "EKG Tutor",
  "icon": "❤",
  "desc": "Learn 12-lead EKG interpretation. Educational only.",
  "href": "/ekg-tutor/"
}
```

Place under the Medical Tools category.

---

## 6. Smoke test

```bash
# Auth gate (unauthenticated).
curl -s -o /dev/null -w '%{http_code}\n' https://adampowell.pro/ekg-tutor/
# Expect: 302

# Direct hit on each backend (localhost, no nginx).
curl -s http://127.0.0.1:3110/health   # CORRECTED 2026-05-18: api uses /health, not /v1/health
curl -s http://127.0.0.1:3111/health
curl -s http://127.0.0.1:3112/health
# Expect: {"ok": true, "service": "..."} on each. The api response also has no `model_configured`
# field; only tutor + interpret report that (along with `model: "claude-sonnet-4-6"`). The
# interpret service additionally returns `verifier_available: false` until the ECG-FM
# loader + weights are wired (Phase 4 TODO in pipelines/ecg_fm_verify.py).

# Authenticated end-to-end via nginx.
COOKIE=$(curl -sD - --max-time 15 -H 'Content-Type: application/json' \
  -d '{"username":"<u>","password":"<p>"}' \
  https://adampowell.pro/auth/api/login | \
  awk '/^[Ss]et-[Cc]ookie:.*apsess|connect\.sid/ {sub(/^[Ss]et-[Cc]ookie: /, ""); sub(/;.*/, ""); print}' | head -1)
curl -s -H "Cookie: $COOKIE" -o /dev/null -w '%{http_code}\n' \
  https://adampowell.pro/ekg-tutor/
# Expect: 200
```

---

## 7. Phase 1 ingest of the seed case library

After the api/tutor/interpret services are running and the database is
migrated, run the ingest script to populate the 20 PTB-XL cases. See
`docs/CASE_LIBRARY.md` (added in Task 1.7) for the per-case workflow.

```bash
cd /var/www/adampowell.pro/apps/ekg-tutor/api
pnpm tsx scripts/ingest-ptbxl.ts --library phase1
```

The script reads `apps/ekg-tutor/api/scripts/phase1-cases.json` (the
curated list from Blueprint Appendix B) and:

1. Renders each PTB-XL record to PNG via the interpret service helper.
2. Uploads the rendered image to `/var/lib/ekg-tutor/uploads/`.
3. Calls the interpret service to produce a canonical interpretation.
4. Validates it against the Zod schema.
5. Inserts the case + concept tags into the database.

All cases land with `clinician_reviewed = false`. They must be marked
reviewed (in the curator UI, Phase 2; or via direct SQL in Phase 1) before
they are visible to learners.

---

## Phase 4 — ECG-FM verifier (optional, one-time setup)

The verifier scaffolding ships in Phase 4 but the model loader is a TODO
inside `interpret/src/pipelines/ecg_fm_verify.py`. Until that's wired and
the weights are present, the interpret service runs LLM-only and the
verifier reports `available: false`. To enable verification:

```bash
cd /var/www/adampowell.pro/apps/ekg-tutor/interpret
.venv/bin/pip install torch wfdb
.venv/bin/python -c "from huggingface_hub import snapshot_download; \
    snapshot_download('bowang-lab/ECG-FM', local_dir='./models/ecg-fm')"
```

Then add to `/root/secrets.env`:

```
EKG_TUTOR_ECG_FM_PATH=/var/www/adampowell.pro/apps/ekg-tutor/interpret/models/ecg-fm
```

Restart `ekg-tutor-interpret.service`. Wire the loader + forward-pass per
the comments inside `pipelines/ecg_fm_verify.py` — that's the focused review
change that flips verification on.

When verification is live and the ingest CLI is run with `--signals`, each
case's measurements are corrected above their per-field threshold (default:
±10 bpm rate, ±20 ms PR/QRS, ±30 ms QTc, ±20° axis). The corrections are
persisted on `cases.measurements_verified` so the PWA can flag them.

---

## Nightly eval harness

Catches prompt regressions before they ship. Runs the live interpret service
against a fixed 6-case eval set with hand-validated canonical summaries and
checks per-metric floor pass rates.

```bash
cd /var/www/adampowell.pro/apps/ekg-tutor/interpret
.venv/bin/python scripts/eval.py \
  --eval-set scripts/eval-set.json \
  --interpret-url http://127.0.0.1:3112 \
  --images-dir /var/lib/ekg-tutor/seed-images \
  --report /var/log/ekg-tutor-eval-$(date +%Y%m%d).json
```

Exits non-zero if any metric (schema_valid, primary_correct, rate_within_10,
axis_category_match, bbb_match, av_block_match, st_elevation_match) drops
below its floor. Wire into cron or systemd timers; alert on non-zero exit.

The eval set is hand-validated by a clinician (same reviewer who signs off
on canonical interpretations). Growing it requires the same review
discipline because every entry calibrates every prompt change.

---

## Operational notes

- Logs: `journalctl -u ekg-tutor-api -f` (and -tutor / -interpret).
- Cost monitoring: each cached case interpretation is paid for once at
  ingest. Per-attempt tutor responses are the recurring spend (~$0.03–$0.08).
- The audit log table fills steadily; plan to archive rows older than
  365 days during the first quarter of operation.
- Backups: the existing droplet-wide backup strategy covers Postgres. The
  `/var/lib/ekg-tutor/uploads/` directory contains the only non-DB state;
  add it to whatever backup rotation the droplet already runs.
