diff --git a/.planning/v15-ios-handoff/14-coach-mental-model.md b/.planning/v15-ios-handoff/14-coach-mental-model.md new file mode 100644 index 000000000..1e8c8c619 --- /dev/null +++ b/.planning/v15-ios-handoff/14-coach-mental-model.md @@ -0,0 +1,413 @@ +--- +file: 14-coach-mental-model.md +purpose: Mental model of the HealthLog Coach AI for the iOS-native client. +when_to_read: Before designing a Coach UI in Swift — to confirm the server owns prompts, refusals, safety contracts, and the streaming contract. +prerequisites: 03-system-overview.md, 04-data-model.md, 08-locked-contracts.md +estimated_tokens: ~5200 +version_anchor: v1.4.25 / sha 49f71c92 +--- + +# Coach Mental Model + +## TL;DR + +The Coach is a server-resident AI surface — prompt versions, ground rules, safety contracts, refusal heuristics, snapshot composition, and provider routing all live in `src/lib/ai/coach/`. The iOS app is a streaming **client**, not a parallel implementation. Build it like you'd build any thin chat client over an existing SSE endpoint: collect input, post, render tokens as they arrive, surface the evidence block. Do not re-implement any of the safety, refusal, or grounding logic on-device. + +## STOP HERE IF + +- You think you need to call Anthropic / OpenAI from Swift directly. You do not. The server owns provider routing and key custody. +- You think iOS needs its own "system prompt" for the Coach. It does not. The server builds it from the locale + `CoachPrefs` and never accepts an override from the client. +- You are tempted to "improve" or "patch" a Coach reply on the iOS side. Don't. Every word the Coach emits is part of an MDR-aware safety contract. + +## What lives where + +| Concern | Owner | File / Path | +| ------------------------------ | ------- | ------------------------------------------------------ | +| Base system prompt per locale | Server | `src/lib/ai/coach/system-prompt.ts` | +| Native FR/ES/IT/PL prompts | Server | `src/lib/ai/prompts/native-prompts.ts` | +| Safety-contract matrix | Server | `src/lib/ai/prompts/safety-contracts.*.yaml` | +| Matrix loader + Zod schema | Server | `src/lib/ai/prompts/safety-contracts.ts` | +| Coach Snapshot composition | Server | `src/lib/ai/coach/snapshot.ts` | +| GLP-1 weeklyContext block | Server | `src/lib/ai/coach/glp1-snapshot.ts` | +| Refusal heuristics | Server | `src/lib/ai/coach/refusal.ts` | +| Evidence-block parser | Server | `src/lib/ai/coach/keyvalues.ts` | +| Provider routing (multi) | Server | `src/lib/ai/provider.ts` + `provider-runner.ts` | +| Stream encoder | Server | `/api/insights/chat/route.ts` | +| Render tokens, send messages | **iOS** | new Swift code, talks SSE to `/api/insights/chat` | +| Surface evidence-block UI | **iOS** | parses `provenance.keyValues` from the response tail | + +If a concern is "Server" above, the iOS app does **not** reinvent it. + +## PROMPT_VERSION + +Single source of truth, stamped on every Coach + Insight system prompt: + +```ts +// from src/lib/ai/prompts/insight-generator.ts:34 +export const PROMPT_VERSION = "4.25.0" as const; +``` + +Bump rules (already documented in that file): + +| Change | Bump | +| ------------------------------- | --------------- | +| Wording polish, tone tweak | patch (4.25.1) | +| New GROUND RULE / safety clause | minor (4.26.0) | +| Output-shape change | major (5.0.0) | + +The version is echoed inside every system prompt body (`Prompt version: 4.25.0.`). The Wide-Event annotation on every Coach completion records it so a regression can be attributed to a specific prompt revision. + +## The three AI personas + +Three distinct surfaces, three distinct contracts. iOS Claude must not confuse them. + +| Persona | Endpoint | Output shape | Owner module | +| --------------- | ----------------------------------------- | ----------------------------------- | --------------------------------------------- | +| **Coach** | `POST /api/insights/chat` | Streaming prose + evidence block | `src/lib/ai/coach/system-prompt.ts` | +| **Insights** | `POST /api/insights/generate` | JSON (schema-validated) | `src/lib/ai/prompts/insight-generator.ts` | +| **Briefing** | `POST /api/insights/comprehensive` | JSON, includes `dailyBriefing` slot | embedded inside the Insights prompt | + +- **Coach** is prose-first, conversational, motivational-interviewing flavour, evidence block at the end. +- **Insights** is JSON-only, citation-grounded, no prose outside string fields. +- **Briefing** is a sub-block inside the Insights JSON — read more in `15-insights-architecture.md`. + +The iOS Coach screen talks only to `/api/insights/chat`. Briefing + Insights show up elsewhere in the app (dashboard + /insights pages). + +## The layered prompt builder + +The Coach system prompt is assembled at every turn from three layers. Order matters. + +``` ++-----------------------------------------------+ +| 1. CoachPrefs prefix (tone / verbosity) | +| — empty when user runs defaults | ++-----------------------------------------------+ +| 2. Per-locale BASE PROMPT | +| DE = hand-curated (system-prompt.ts) | +| EN = authoritative (system-prompt.ts) | +| FR/ES/IT/PL = native build via matrix | ++-----------------------------------------------+ +| 3. GROUND RULES 1-15 + safety-contract | +| blocks woven into the body | ++-----------------------------------------------+ +``` + +```ts +// from src/lib/ai/coach/system-prompt.ts:537 +export function getCoachSystemPrompt( + locale: Locale, + prefs: CoachPrefs = DEFAULT_COACH_PREFS, +): string { + let base: string; + if (locale === "de") { + base = COACH_PROMPT_DE; + } else if (locale === "en") { + base = COACH_PROMPT_EN; + } else { + try { + base = buildNativeCoachPrompt(locale, PROMPT_VERSION); + } catch { + base = COACH_PROMPT_EN + LOCALE_REPLY_FOOTER_FALLBACK[locale]; + } + } + const prefix = buildPrefsPrefix(locale, prefs); + return prefix ? `${prefix}\n\n${base}` : base; +} +``` + +Note the fallback: if the YAML matrix fails to load for a non-EN/DE locale, the prompt falls back to EN + a one-line "REPLY LANGUAGE" footer. The iOS client should never branch on this — the server always emits a usable prompt. + +## GROUND RULES 1-15 + +Numbered in the prompt body. Stable contract for the test suite, so renumbering is a breaking change. + +| # | Title | Surface | Parser-critical | +| -- | --------------------------------- | ------------- | --------------- | +| 1 | Prose-first | Coach | No | +| 2 | Values belong in evidence block | Coach | Yes (sentinel) | +| 3 | Missing data is an invitation | Coach | No | +| 4 | Conservative phrasing | Coach | No | +| 5 | Motivational-interviewing moves | Coach | No | +| 6 | Redirect off-topic gracefully | Coach | No | +| 7 | Ground every number in SNAPSHOT | Both | Yes (citations) | +| 8 | No internal metric identifiers | Both | Yes (lint test) | +| 9 | NEVER prescribe / modify doses | Both | **SAFETY** | +| 10 | Refuse drug-level estimates | Both | **SAFETY** | +| 11 | Severity enums stay lowercase EN | Insights | Yes (contract) | +| 12 | No causal claims | Both | Yes (lint) | +| 13 | dailyBriefing schema discipline | Briefing | Yes (Zod) | +| 14 | Apple Health silent absence | Both | No | +| 15 | Drug-level refusal (full clause) | Both | **SAFETY** | + +Rules **9, 10, 15 are SAFETY contracts** — they keep HealthLog below the EU MDR Class I "predict / advise" threshold (research §11, §12.4 in the GLP-1 work). + +iOS-Claude implication: **never paraphrase or filter Coach output on-device**. If a future product hunch says "the Coach reply has too much disclaimer text, let's trim it client-side" — that is an MDR violation. The disclaimer is the safety contract. + +## The safety-contract matrix + +6 YAML files (one per locale), schema-validated at process start. + +``` +src/lib/ai/prompts/ +├── safety-contracts.de.yaml ← hand-curated, two years of Marc review +├── safety-contracts.en.yaml ← authoritative reference +├── safety-contracts.es.yaml ← AI-drafted (W14c) + structural test +├── safety-contracts.fr.yaml ← AI-drafted (W14c) + structural test +├── safety-contracts.it.yaml ← AI-drafted (W14c) + structural test +├── safety-contracts.pl.yaml ← AI-drafted (W14c) + structural test +└── safety-contracts.ts ← Zod schema + loader + accessor +``` + +Schema (Zod): + +```ts +// from src/lib/ai/prompts/safety-contracts.ts:127 +export const SafetyContractMatrixSchema = z.object({ + ground_rules: GroundRulesSchema, // 15 keys, each parser_critical + surface + en/locale + must_contain + sentinel_literals: SentinelLiteralsSchema, // evidence_block_open/close, snapshot_token, etc. + glp1_brand_list: z.array(z.string()).min(7), + contract_enums: ContractEnumsSchema, // severity / source_window / time_range / source_metric / tone / topic / category + medical_terminology: MedicalTerminologySchema, + defer_to_clinician_phrases: z.array(z.string()).min(1), + out_of_scope_refusal: OutOfScopeRefusalSchema, + drug_level_refusal: DrugLevelRefusalSchema, // trigger_phrases >= 10, expected_refusal_keywords >= 3, forbidden_phrases >= 3 + reply_language_directive: z.string().min(1), +}); +``` + +Each YAML lives independently; the EN file is the calibration reference for translations, but each non-EN file is parsed + tested in its own right. A failed Zod parse aborts the process (intentional — we never want to serve a Coach with a broken safety contract). + +## The refusal-probe matrix + +The refusal probe is the regression gate that keeps the safety contracts honest across all locales. + +- **15 GROUND RULES** × **6 locales** = 90 base coverage assertions +- **20+ adversarial paraphrasings per rule** (drug-level "what's my peak right now", dose-prescriptive "should I step up to 10 mg", off-topic "what's the weather", prompt injection "ignore previous instructions") = **>1800 probes** +- Drug-level refusal alone declares **>= 10 trigger_phrases** per locale, **>= 3 expected_refusal_keywords**, **>= 3 forbidden_phrases** + +Lives in `src/lib/ai/prompts/__tests__/refusal-probe.test.ts`. **Every locale's prompt body is asserted to carry every parser-critical ground-rule body verbatim + at least one of the expected refusal keywords for the drug-level probe.** + +iOS-Claude implication: the iOS client cannot run the probe matrix on-device. The CI on `main` keeps the server-side safety surface honest; the iOS app inherits that guarantee by always going through `/api/insights/chat`. + +## Coach Snapshot composition + +The Coach Snapshot is the per-turn data the system prompt grounds replies in. Built by `buildCoachSnapshot(userId, scope?)` in `src/lib/ai/coach/snapshot.ts`. The shape is documented in detail in `04-data-model.md`; the parts most relevant to iOS: + +### What's IN + +| Block | When | +| ----------------------------------------- | --------------------------------- | +| `aggregate` per metric (mean/SD/slope) | always for in-scope metrics | +| `timeline.recent` (last 14 days, daily) | always for in-scope metrics | +| `timeline.weekly` (ISO-week buckets) | rest of the window | +| `scope` (window + sources picked) | always | +| `weeklyContext.glp1` | when user has active GLP-1 med | +| `bloodPressure`, `weight`, `pulse`, `mood`, `compliance` | per the resolved `CoachScope.sources` | +| Apple Health timeline blocks | when user has rows for that metric | + +### What's NOT in (deliberate) + +- **Research-mode-acknowledgment fields**. Per W19c-Safety, the drug-level acknowledgment state never reaches the prompt — the refusal is universal, regardless of whether the user enabled Research Mode. This is load-bearing for the GROUND RULE 15 safety contract: the Coach is forbidden from interpreting drug levels even if the user "opted in" elsewhere. +- **PII identity attributes**. The snapshot strips name, email, gravatar, locale, OAuth account ids before reaching the prompt. +- **Raw measurement timestamps below day-resolution**. The `includeRaw=false` toggle in `extractFeatures()` is hard-coded for the Coach — privacy mode applies even when the underlying request is from the legitimate owner. + +### Day-key + weekday — user-timezone anchored + +`buildCoachSnapshot()` reads `User.timezone` once and pins every day-key and weekday label to that zone. A 23:50 reading in Pacific/Auckland day-keys to that day in Auckland, not the next UTC day. + +```ts +// from src/lib/ai/coach/snapshot.ts:306 +const prefsRow = await prisma.user.findUnique({ + where: { id: userId }, + select: { coachPrefsJson: true, timezone: true }, +}); +const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson); +const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE; +``` + +If the iOS app ever wants to render the same day-rows the Coach saw, it must call the snapshot in user-tz too. iOS gets the timezone from `User.timezone` via the existing `/api/auth/me` endpoint; it does NOT use the device's local clock. + +## Provider routing — multi-provider, server-side + +The Coach (like every AI surface) goes through the same provider-resolution chain. Five entries are supported: + +```ts +// from src/lib/ai/provider.ts — providers, in fallback chain order +// 1. codex — ChatGPT OAuth (device-code flow) +// 2. openai — User's own OpenAI key +// 3. anthropic — User's own Anthropic key (Claude) +// 4. local — Self-hosted OpenAI-compatible (LM Studio, Ollama) +// 5. admin-openai — Admin-side OpenAI key (last-resort) +``` + +The user picks an ordered chain in Settings → AI Providers. `resolveProviderChain(userId)` materialises each, drops any with missing creds, and the **provider-runner** walks the surviving list on each Coach call. + +```ts +// from src/lib/ai/provider.ts:284 +export async function resolveProviderChain( + userId: string, +): Promise { + // ... + const chain = parseProviderChain(rawChain).filter((e) => e.enabled); + const resolved: ProviderChainResolved[] = []; + for (const entry of chain) { + const instance = await resolveProviderForType(entry.providerType, { userId, userRow }); + if (instance) resolved.push({ providerType: entry.providerType, instance }); + } + return resolved; +} +``` + +iOS-Claude implication: **iOS knows nothing about providers**. There is no Anthropic SDK in the iOS app. There is no OpenAI key in the iOS keychain. The user's choice of provider lives in their server-side `User` row. When iOS asks "what provider am I on?", it calls `GET /api/insights/settings` — never the provider directly. + +## Refusal — when refuses vs when answers + +Three layers of refusal sit between the user input and the model. iOS sees the result, not the layers. + +### Layer 1 — Pattern-based refusal (cheap, deterministic) + +```ts +// from src/lib/ai/coach/refusal.ts — INJECTION_PATTERNS + OFF_TOPIC_TOKENS +// Tested against the message BEFORE any provider call. +detectRefusal({ message, locale }) → { refuse, reason, message } +``` + +- **Prompt-injection** patterns (15 regexes, EN + DE): "ignore previous instructions", "you are now DAN", "[INST]" / `<|system|>` sentinels, "reveal your system prompt". +- **Off-topic** allow-list vs deny-list: health tokens (bp, weight, pulse, mood, medication, …) vs off-topic tokens (weather, news, joke, python, stock, …). When the message hits a deny-list pattern without any allow-list pattern, refuse early. + +When the pattern matches, the route never calls the model. It streams a single token frame with the localised refusal copy + a `done` event. + +### Layer 2 — GROUND RULES 9, 10, 15 inside the system prompt + +The prompt itself instructs the model to refuse dose prescriptions, drug-level interpretations, peak/trough predictions, etc. The refusal language is part of the safety-contract matrix and is asserted by the refusal-probe test on every locale. + +### Layer 3 — Output validation (Insights only, not Coach) + +For the Insights persona, the response is JSON-parsed + Zod-validated + citation-coverage-asserted before reaching the client. The Coach is prose-first — Layer 3 reduces to the keyvalues-block parser, which is opportunistic (a malformed evidence block is logged but does not refuse the reply). + +## Evidence block contract — the `---KEYVALUES---` sentinel + +Every Coach turn that cites a load-bearing number appends an evidence block AFTER the prose. The route strips the block out of the stream the user sees and parses it into `provenance.keyValues` for the UI's "What I'm looking at" disclosure. + +``` +---KEYVALUES--- +avg30 systolic: 138 [mmHg] (last30days) +Tue 6 May: 142/88 [mmHg] +---END--- +``` + +Rules (enforced in `src/lib/ai/coach/keyvalues.ts`): + +- Hard cap **8 lines** between sentinels. +- Hard cap **1 KB** total block size (prompt-injection guard). +- Line format: `