# Sehat AI Health Coach

A patient- and doctor-facing conversational AI agent with EMR grounding,
proactive cron-driven health nudges, and push delivery across web +
Android + iOS. Mounted as a floating draggable avatar in all three
portals (patient web, doctor web, Flutter mobile).

This doc is the **operator + new-engineer handoff** for the Coach. Skim
the table of contents, jump to the section you need.

## Contents

1. [What the Coach does](#what-the-coach-does)
2. [Architecture map](#architecture-map)
3. [Configuration](#configuration)
4. [Guardrail policy](#guardrail-policy)
5. [Prompt engineering](#prompt-engineering)
6. [Scheduled jobs](#scheduled-jobs)
7. [Notification delivery](#notification-delivery)
8. [Operator runbook](#operator-runbook)
9. [How to add a new seasonal alert](#how-to-add-a-new-seasonal-alert)
10. [Tests](#tests)
11. [Known caveats + future work](#known-caveats--future-work)

---

## What the Coach does

**Patient-facing** (patient portal + Flutter):
- Chat about the user's OWN EMR (allergies, conditions, medications,
  upcoming appointments). Grounded — won't invent data.
- Daily 7am medication reminders for users with active prescriptions.
- Daily 8am seasonal disease advisories tuned for Pakistan (dengue,
  smog, heatstroke, flu, monsoon gastro, pollen).
- Weekly Sunday routine-checkup nudges for users managing a chronic
  condition who haven't seen a doctor in 6+ months.
- Notification permission prompt on first open; once granted, alerts
  fire as OS-level pushes via VAPID web-push (browser) or FCM v1
  (mobile + Chrome tab-closed).

**Doctor-facing** (doctor portal):
- Chat about the doctor's OWN allocated appointments (gated by
  appointment ownership). Surfaces patient summaries, helps draft
  SOAP-note skeletons, suggests follow-up timing. Output is always a
  DRAFT; the doctor decides.

**Hard limits across both**:
- Never gives medical advice (dose / diagnosis / prescription change)
- Never discusses another user's data (cross-user is structurally
  impossible — every query starts from `req.user.id`)
- Only answers questions inside the Sehat Sahoolat health-journey scope
- Two-layer guardrail rejects offending requests before they reach the
  main LLM

---

## Architecture map

```
┌──────────────────────────────────────────────────────────────────┐
│  Browser / Flutter app                                          │
│  ┌────────────────┐   ┌─────────────────────┐                   │
│  │ FloatingCoach  │──▶│  CoachPanel sheet   │                   │
│  │ (Rive avatar)  │   │  chat + settings     │                   │
│  └────────────────┘   └─────────────────────┘                   │
│         │                       │                                │
└─────────┼───────────────────────┼────────────────────────────────┘
          │                       │
          │ POST /health-coach/conversations/:id/messages
          ▼                       ▼
┌──────────────────────────────────────────────────────────────────┐
│  Backend (NestJS)                                                │
│                                                                  │
│  ┌─────────────────────┐    ┌──────────────────────┐             │
│  │ HealthCoachController│──▶ │ HealthCoachService  │             │
│  │ (patient role)       │    │                      │             │
│  └─────────────────────┘    │  1. Persist user turn │             │
│  ┌─────────────────────┐    │  2. Guardrail check ──┼──▶ Refusal │
│  │ DoctorCoachController│   │  3. Load EMR (self    │             │
│  │ (doctor role)        │   │     scoped by userId) │             │
│  └─────────────────────┘    │  4. RAG retrieval     │             │
│         │                    │  5. LLM call          │             │
│         ▼                    │  6. Persist reply     │             │
│  ┌─────────────────────┐    │  7. Audit log         │             │
│  │ DoctorCoachService  │    └──────────────────────┘             │
│  └─────────────────────┘             │                            │
│                                       │                            │
│  ┌────────────────────────────────────┴───────────────────────┐  │
│  │  Reused infrastructure                                     │  │
│  │  ─ AiProviderService (Anthropic + OpenAI chat completions) │  │
│  │  ─ EmbeddingService (OpenAI text-embedding-3-small)        │  │
│  │  ─ RetrievalService (pgvector top-K against approved KB)   │  │
│  │  ─ NotificationsService (in-app + email + SMS + push)      │  │
│  │  ─ AuditService                                            │  │
│  └────────────────────────────────────────────────────────────┘  │
│                                                                  │
│  ┌─────────────────────┐    ┌─────────────────────────────────┐  │
│  │ HealthCoachJobs     │──▶ │  WebPushDeliveryService (VAPID) │  │
│  │  @Cron daily/weekly │──▶ │  NotificationProvidersService   │  │
│  │  meds, seasonal,    │    │    .sendPush (FCM v1)           │  │
│  │  checkup nudges     │    └─────────────────────────────────┘  │
│  └─────────────────────┘                                          │
└──────────────────────────────────────────────────────────────────┘
```

### Module layout

**Backend** — `backend/src/modules/`:

| Path | Role |
|---|---|
| `health-coach/health-coach.module.ts` | Wires patient + jobs + web-push |
| `health-coach/health-coach.service.ts` | Patient chat — self-scoped EMR + RAG + guardrail |
| `health-coach/health-coach.controller.ts` | `@Roles(PATIENT)` REST surface |
| `health-coach/health-coach-prompts.ts` | Patient system prompt + JSON parser |
| `health-coach/health-coach-guardrail.ts` | Two-layer reject (rules + LLM classifier) |
| `health-coach/health-coach-jobs.service.ts` | `@Cron` reminders + seasonal + checkup |
| `health-coach/health-coach-jobs.controller.ts` | `@Roles(ADMIN)` "run now" triggers |
| `health-coach/seasonal-alerts.ts` | PK-tuned alert calendar |
| `health-coach/web-push-delivery.service.ts` | VAPID send + auto-clear dead subs |
| `health-coach/entities/health-coach-settings.entity.ts` | Per-user opt-ins + position |
| `doctor-coach/doctor-coach.*` | Mirror for doctor side (different prompt + gate) |

**Patient portal** — `web/apps/patient-portal/components/sehat-coach/`:

| Path | Role |
|---|---|
| `sehat-avatar.tsx` | Animated SVG (Rive upgrade path documented inside) |
| `floating-coach.tsx` | Drag, snap, persist position to localStorage + server |
| `coach-panel.tsx` | Chat + settings tabs, push permission prompt |

**Doctor portal** — `web/apps/doctor-portal/components/sehat-coach/` — same shape, no settings tab (doctors don't opt into reminders), `/doctor-coach/*` endpoints.

**Flutter** — `mobile/lib/features/sehat_coach/` — Riverpod-based mirror with the same widget vocabulary.

### Data model

- `ai_conversations` (existing table) with `kind` extended:
  - `health_coach` (patient chat)
  - `doctor_coach` (doctor chat)
- `ai_conversation_messages` (existing) — every turn persisted, refusals included
- `health_coach_settings` (new) — one row per user: opt-ins, push subscription, avatar position, locale
- `notifications` (existing) — coach pushes tagged with `metadata.source = 'sehat_coach'` + `metadata.coachKind = 'medication' | 'seasonal' | 'checkup'`
- `device_tokens` (existing) — FCM tokens registered by mobile + browser clients

---

## Configuration

### Backend `.env`

```env
# === Encryption (32-byte hex) ===
# All third-party keys (Anthropic, OpenAI, FCM legacy, SMTP) are AES-256-GCM
# encrypted at rest under this key. DEFAULT IS PUBLICLY KNOWN — set a real
# one in production: openssl rand -hex 32
ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

# === Web push (VAPID) ===
# Generated via: npx web-push generate-vapid-keys --json
# Public key is also exposed to the patient portal via
# NEXT_PUBLIC_VAPID_PUBLIC_KEY in web/apps/patient-portal/.env.local
VAPID_PUBLIC_KEY=...
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:ops@your-domain.com

# === FCM HTTP v1 ===
# Path (relative to backend cwd or absolute) to the Firebase service-
# account JSON. Download from Firebase console > Project Settings >
# Service Accounts > Generate new private key.
FIREBASE_CREDENTIALS_PATH=secrets/firebase-admin.json
```

### LLM provider (`ai_config` table — managed through admin UI)

| Field | What it does |
|---|---|
| `provider` | `anthropic` or `openai` |
| `model_id` | e.g. `claude-sonnet-4.6` or `gpt-4o-mini` (cheapest sensible) |
| `api_key_encrypted` / `_iv` | Encrypted Anthropic/OpenAI key |
| `embedding_provider` / `embedding_model_id` | RAG embeddings (OpenAI `text-embedding-3-small`) |
| `embedding_api_key_encrypted` / `_iv` | Embedding API key |
| `is_active` | Master switch — false disables all AI features |

Set via admin portal → AI Settings, or directly via `POST /ai/config`.

### Patient portal `.env.local`

```env
NEXT_PUBLIC_VAPID_PUBLIC_KEY=...   # must match backend VAPID_PUBLIC_KEY
```

### Flutter app

`mobile/lib/firebase_options.dart` is generated by FlutterFire:

```bash
cd mobile
dart pub global activate flutterfire_cli
flutterfire configure --project=<your-firebase-project-id> \
  --platforms=ios,android --out=lib/firebase_options.dart
```

Then `Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform)`
in `main.dart` before `runApp`. For iOS push, also upload an APNS auth
key to Firebase console → Project Settings → Cloud Messaging.

---

## Guardrail policy

Defence in depth, three layers:

### Layer 1 — Deterministic rules (free, instant)

`runDeterministicRules` in `health-coach-guardrail.ts`. Regex matchers
for known-bad patterns, ordered specific → general. Catches ~95% of
abuse before any LLM cost:

| Pattern | Reason |
|---|---|
| "my friend / neighbour / cousin / patient X" | `cross_user` |
| "list/dump all patients/users" | `cross_user` |
| "what dose of X should I take" | `medical_advice` |
| "can you prescribe me X" | `medical_advice` |
| "should I stop taking my insulin" | `medical_advice` |
| "diagnose me / am I having a stroke" | `medical_advice` |
| "weather / cricket / stock / joke / homework" | `out_of_scope` |
| "ignore previous instructions" | `unsafe` |
| "jailbreak / developer mode / act as X" | `unsafe` |

Tests at `health-coach-guardrail.spec.ts`.

### Layer 2 — LLM classifier (only if rules pass)

Single Claude/OpenAI call with `max_tokens: 80, temperature: 0`. Strict
JSON output `{"v":"A|M|X|O|U","r":"reason"}`. Letters map to
`ALLOW / REJECT_MEDICAL_ADVICE / REJECT_CROSS_USER / REJECT_OUT_OF_SCOPE / REJECT_UNSAFE`.

- Unknown letter → fail-closed REJECT unsafe
- Classifier throws (provider down) → fail-OPEN with a warn log; main
  system-prompt guardrails still apply

### Layer 3 — In-prompt instructions

The system prompt (`health-coach-prompts.ts`) tells the model:
- Discuss only the signed-in user
- Never diagnose / prescribe / change doses
- Output `selfRefused: true` if the user's question slips past layers 1+2

Three layers means a jailbreak has to fool *all three*; the patient sees
a polite, on-brand refusal whichever layer catches.

### Audit trail

Every rejected message is `notifications.dispatch`'d (no — it's
**audit-logged**) via `AuditService` with the full rationale + which
layer caught it. Compliance reviewers can query the audit log by
`action LIKE 'health_coach.message.rejected'`.

---

## Prompt engineering

Two prompt families, both producing strict JSON.

### Patient — `health-coach-prompts.ts`

`buildCoachSystemPrompt(ctx, kbBlock, locale)` returns one big system
prompt block:

```
{base persona + rules}

## Language
{Urdu vs English instruction}

## Patient (the signed-in user — the ONLY person you may discuss)
Profile: Aisha, female, 42y, blood group O+
Allergies:
  - Penicillin (severe) — anaphylaxis
Current medications:
  - Metformin 500 mg Twice daily (oral)
  - Losartan 50 mg Once daily (oral)
...

## Approved knowledge-base excerpts
[#1] (Diabetes basics — medical_education)
{chunk content from pgvector top-K}
...

## Output format
{strict JSON schema with selfRefused, escalate, proposedActions}
```

### Doctor — `doctor-coach-prompts.ts`

Same shape, different persona ("clinical assistant for Sehat Sahoolat
doctors in Pakistan"), different patient context (loaded by
appointment ownership), different action vocabulary (`draft_soap`,
`view_patient_emr`, `schedule_followup`).

### Output parsing

Both prompts use a permissive parser that:
- Strips markdown fences
- Falls back to wrapping prose as `message` if JSON parsing fails
- Coerces unknown action kinds to null + filters them out
- Caps `suggestedReplies` at 3, `proposedActions` at 2

---

## Scheduled jobs

`HealthCoachJobsService` with three `@Cron`-decorated methods.
`ScheduleModule.forRoot()` is wired in `HealthCoachModule`.

### Schedule

| Job | Cron (UTC) | Pakistan local | Cooldown |
|---|---|---|---|
| `runMedicationReminders` | `0 2 * * *` | 07:00 daily | 22h per user |
| `runSeasonalAlerts` | `0 3 * * *` | 08:00 daily | 7d per (user, alertKey) |
| `runCheckupNudges` | `0 5 * * 0` | 10:00 Sundays | 30d per user |

### Eligibility gates

| Job | Sent when |
|---|---|
| Medication | `health_coach_settings.remindersOptIn=true` AND at least one active medication (no end_date or end_date in future) |
| Seasonal | `seasonalAlertsOptIn=true` AND today falls in an active alert window |
| Checkup | `enabled=true` AND has a chronic/active condition AND no appointment in last 90 days |

### Cooldown dedup

Each job queries the `notifications` table for the user's most recent
sehat-coach push of the same `coachKind` (+ `alertKey` for seasonal)
within the cooldown window. If a row exists, the user is skipped. This
makes the jobs **idempotent** — running them twice in the same window
sends 0 notifications.

### Admin "run now" triggers

`@Roles(ADMIN, SUPER_ADMIN)` — useful for verification and operator
intervention:

```
POST /api/v2/health-coach/jobs/medication-reminders/run
POST /api/v2/health-coach/jobs/seasonal-alerts/run
POST /api/v2/health-coach/jobs/checkup-nudges/run
```

Returns `{ job, eligible, sent, skipped: {...}, meta?: {...} }`.

---

## Notification delivery

Every coach notification goes through `NotificationsService.dispatch()`
which writes the durable in-app row + fan-outs to channels:

| Channel | Surface | Delivery mechanism |
|---|---|---|
| `IN_APP` | Patient portal toaster (live) + /messages inbox | RealtimeGateway WebSocket + DB row |
| `PUSH` | Browser (tab closed) | VAPID web-push via `WebPushDeliveryService` |
| `PUSH` | Android / iOS | FCM HTTP v1 via `NotificationProvidersService.sendPush` |
| `EMAIL` | Inbox | Configurable HTTP gateway (Resend / Postmark / SendGrid) |
| `SMS` | Phone | Twilio |

Coach jobs request `[IN_APP, PUSH]` only — meds reminders shouldn't
spam email or SMS by default.

### Tagging

Every coach notification carries:
- `metadata.source = 'sehat_coach'`
- `metadata.coachKind = 'medication' | 'seasonal' | 'checkup'`
- For seasonal: `metadata.alertKey = 'dengue' | 'smog' | ...`

The patient portal toaster + `/messages` inbox branch on
`metadata.source` to render with the coach avatar + coral accent.

### Dead-token cleanup

- **Web push**: 410 Gone → `WebPushDeliveryService.clearSubscription` nulls the row
- **FCM v1**: `messaging/registration-token-not-registered` /
  `invalid-registration-token` / `invalid-argument` → returned in
  `SendPushResult.invalidTokens`, `NotificationsService.fanOutAsync`
  deletes them from `device_tokens`

---

## Operator runbook

### "Why isn't the coach replying?"

1. Check `ai_config.is_active` — must be `true`
2. Check `ai_config.api_key_encrypted` — must be populated
3. Check `ai_config.embedding_api_key_encrypted` — required for RAG;
   absence is non-fatal (Coach answers without KB context, see
   `RetrievalService.retrieve()` fail-open path)
4. Tail backend logs for `[HealthCoachGuardrailService]` warnings
5. Audit log: `SELECT * FROM audit_logs WHERE action LIKE 'health_coach.message.%' ORDER BY created_at DESC LIMIT 20`

### "Why isn't the user getting reminders?"

1. `SELECT * FROM health_coach_settings WHERE user_id = '...'` — verify `enabled=true` AND `remindersOptIn=true`
2. `SELECT * FROM emr_medications JOIN emr_records ... WHERE patient_id = ...` — must have active meds (no `end_date` or `end_date > NOW()`)
3. `SELECT * FROM notifications WHERE user_id = '...' AND metadata->>'source' = 'sehat_coach' ORDER BY created_at DESC LIMIT 5` — was the cooldown row created?
4. Trigger manually: `POST /api/v2/health-coach/jobs/medication-reminders/run` (admin token)
5. Check FCM/VAPID config matches the platform the user is on

### "Why is push not landing on the phone?"

1. Has the device registered? `SELECT * FROM device_tokens WHERE user_id = '...'`
2. Is the FCM project initialised? Tail backend logs for `FCM (HTTP v1) configured for project ...`
3. Is `FIREBASE_CREDENTIALS_PATH` reachable from the backend cwd?
4. iOS specifically: APNS auth key uploaded to Firebase console?

### Rotating the LLM key

1. Admin portal → AI Settings → paste new key → Save
2. Existing in-flight conversations continue with the previous key for
   their current turn; subsequent turns use the new key (key is read
   per-call from `ai_config`).

### Rotating VAPID keys

Web push subscriptions are pinned to the public key used at
subscription time. Rotating VAPID:
1. Generate new pair
2. Update both `backend/.env` and `web/apps/patient-portal/.env.local`
3. Bump a `vapid-version` in localStorage so the client invalidates and
   re-subscribes (small `coach-panel.tsx` change)
4. Existing subscriptions will start failing 401/403 → service auto-
   clears them on the next dispatch

### Rotating Firebase credentials

1. Firebase console → Service Accounts → delete old key → generate new
2. Replace `backend/secrets/firebase-admin.json` (file mode 0600)
3. `nest --watch` auto-reloads on the file change; verify
   `FCM (HTTP v1) configured for project ...` in the log

---

## How to add a new seasonal alert

Edit `backend/src/modules/health-coach/seasonal-alerts.ts`:

```ts
const ALERTS: SeasonalAlert[] = [
  // ... existing entries
  {
    key: 'cholera_emergency',   // <-- stable key; used in cooldown dedup
    priority: 110,              // <-- higher than dengue if overlap
    startMonth: 7, startDay: 15,
    endMonth: 9, endDay: 30,
    title: 'Cholera advisory — extra care with water',
    body: 'Recent cholera reports in {region}. Drink only boiled or '
        + 'sealed water. Get medical attention immediately for severe '
        + 'watery diarrhoea or sudden dehydration.',
  },
];
```

That's it — no migration, no controller wiring. The next cron tick (or
the next `/jobs/seasonal-alerts/run` admin call) picks it up. Add a
spec case in `seasonal-alerts.spec.ts` to cover the new window.

Year-wrap windows (e.g. Oct 15 → Feb 28) work — the `isWithin` helper
handles them.

---

## Tests

```bash
cd backend
npx jest --testPathPatterns=health-coach    # 49 tests, all should pass
```

- `seasonal-alerts.spec.ts` — 14 tests covering the 6 alerts, year-wrap,
  priority resolution, boundary dates
- `health-coach-guardrail.spec.ts` — 22 tests covering each refusal
  reason, classifier mapping (A/M/X/O/U), fail-open, bare-letter
  fallback, fail-closed on unknown
- `health-coach-jobs.service.spec.ts` — 13 tests for all three jobs
  (eligible/skipped/cooldown branches)

Frontend type-check:
```bash
cd web/apps/patient-portal && npx tsc --noEmit
cd web/apps/doctor-portal  && npx tsc --noEmit
cd mobile && flutter analyze lib/features/sehat_coach
```

---

## Known caveats + future work

### Production-blocker before launch

1. **Rotate every key that touched this transcript** — the Firebase
   service-account private key and OpenAI key were both pasted into
   chat during development.
2. **Set a real `ENCRYPTION_KEY`** — the default is publicly visible
   in `backend/src/config/app.config.ts`. All encrypted secrets are
   currently unwrappable by anyone with the source. Generate fresh
   with `openssl rand -hex 32`, set in production `.env`, re-PATCH
   each encrypted key via the admin endpoints so it re-encrypts under
   the new key.

### Soft caveats

- **Seasonal alert calendar covers every real date** — there's no day
  in the year where `findActiveSeasonalAlert` returns null. The
  `outOfSeason` skip branch in `runSeasonalAlerts` is therefore dead
  code with the current catalog. Either accept that, or remove the
  pollen/flu shoulder months so genuine quiet days exist.
- **Single push subscription per user** — `health_coach_settings.push_subscription`
  holds one browser subscription. Users on Chrome desktop + Chrome
  mobile of the same login overwrite each other. FCM device_tokens
  table supports many-per-user; web-push should follow if needed.
- **No `.riv` asset shipped** — both web and Flutter fall back to the
  custom-painted brand-coral face. Drop a Rive file with a state
  machine named `State` and a numeric input named `state`
  (idle=0, listening=1, thinking=2, speaking=3) at:
  - Web: `web/apps/patient-portal/public/brand/sehat-coach.riv` (note: web side currently uses SVG-only; the Rive swap-in is documented in `sehat-avatar.tsx` header)
  - Flutter: `mobile/assets/rive/sehat_coach.riv` (declare under `flutter.assets:` in `pubspec.yaml`)
- **Voice replies toggle wired in settings but not yet implemented** —
  TTS hook is the next small win.

### Roadmap candidates

- **First-run coach tour** — pulse the floating button + tooltip on
  the patient's first authenticated session
- **Coach inside EMR onboarding** — proactive "need help filling
  allergies?" prompts on specific EMR sections
- **SMS fallback for low-bandwidth users** — Twilio is already wired,
  could add a daily SMS digest channel for the rural-PK case
- **Per-condition seasonal alerts** — current alerts fire for everyone
  opted in; could gate dengue alerts to users in Karachi/Lahore, smog
  to Punjab, etc., using `emr_demographics.city`
- **Doctor-coach scheduled jobs** — same `@Cron` pattern, but
  "tomorrow's panel summary" sent the evening before
