Mostly table column normalisation under docs/audit/ + planning, object- literal expansion in src/lib/ai/coach/snapshot.ts after the W4 F6 Apple Health metric block landed, and small wrap/unwrap line adjustments in the device + APNs routes / tests touched across W3/W4/W5 sessions. No behavioural change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.5 KiB
Phase B2a — AI Coach backend
Date: 2026-05-10
Status: complete
Branch: develop
The B2a half of v1.4.20 wave B ships the AI Coach backend: Prisma schema, encrypted persistence, SSE streaming endpoint, token-budget ledger, prompt-injection + off-topic refusal, and conversation-rail endpoints. The drawer UI (B2b) dispatches separately.
Commit timeline (five atomic commits on develop)
| SHA | Subject |
|---|---|
5527001 |
feat(coach): add CoachConversation, CoachMessage, CoachUsage models |
a68557d |
feat(coach): types, persistence, budget, and refusal helpers |
16f3ece |
feat(coach): add POST /api/insights/chat SSE streaming endpoint |
3168ba8 |
feat(coach): GET list, GET single, DELETE conversation endpoints |
| (this) | docs(planning): tick B2a complete, record phase-B2a report |
Schema + migration
prisma/migrations/0035_coach_conversations_v1420/migration.sql adds
three tables:
coach_conversations— one row per chat thread, owned by a user.titleis a plain-text snippet (≤80 chars) of the first user message so the rail can render without paying the per-message decrypt cost.coach_messages—encrypted_contentis the AES-256-GCM payload written viasrc/lib/crypto.tsunder the activeENCRYPTION_KEYSentry. Key rotation works the same way as for the existing encrypted columns.metric_source_jsonis plain text on disk and carries label-only provenance (window names, metric tags, sample counts) — never raw values.coach_usage— per-(user, UTC-day) token ledger. Unique index on(user_id, date_key)lets the dispatcher upsert the day's row without read-modify-write contention.
GDPR cascade is wired through the FK chain so user deletion drops every Coach row.
Helpers
src/lib/ai/coach/types.ts— chat-request zod schema, SSE event shapes (token,provenance,done,error), DTOs.src/lib/ai/coach/persistence.ts— encrypt-on-write / decrypt-on- read helpers,summariseTitle()for the rail, cursor-paginatedlistConversations(),deleteConversation()with ownership check.src/lib/ai/coach/budget.ts—MAX_TOKENS_PER_USER_PER_DAY = 25 000. UTCdateKeyso the meter aligns with the LLM provider's billing boundary.enforceBudget()throwsHttpError(429, "coach.budget.exceeded").recordSpend()clamps non-finite token figures to 0 so a misbehaving provider can't poison the ledger.src/lib/ai/coach/refusal.ts— pattern-based detector for prompt injection (EN + DE wording, jailbreak variants, "reveal your system prompt",system:impersonation) and obvious off-topic asks (weather, code, recipes). A health-allow list short-circuits the deny list when the message mixes a health term with an off-topic word.src/lib/ai/coach/system-prompt.ts— Coach-specific prompt that reuses the v1.4.20 PROMPT_VERSION and ground-rule rules but in conversational tone (no JSON, ~80–220 words, "consult your doctor" footer on actionable replies).src/lib/ai/coach/snapshot.ts— reusesextractFeatures()so the Coach narrates the same numbers the dashboard tiles render. Returns the JSON snapshot and the provenance envelope built from the windows + metrics actually present.
Routes
POST /api/insights/chat— SSE streaming. Cookie or bearer auth. Validates body, enforces budget, runs refusal scanner, walks provider chain, persists user + assistant turns, incrementsCoachUsage. Idempotency wrap applies only when noconversationIdis supplied (= a brand-new conversation). The response is onetokenframe per word-sized chunk, then aprovenanceframe, thendone. Provider-chain failure emits anerrorframe and does NOT persist an assistant message.GET /api/insights/chat— cursor-paginated list (default limit 20, hard cap 50). Returns{ conversations, nextCursor }.GET /api/insights/chat/[id]— fetch one conversation with every message decrypted on the fly. 404 (not 403) on cross-user lookup.DELETE /api/insights/chat/[id]— hard-delete; FK chain cascades the messages. 404 on cross-user.
Test coverage
| Suite | Before | After | Δ |
|---|---|---|---|
| Unit (vitest) | 1753 | 1781 | +28 |
| Integration | 67 | 78 | +11 |
Unit tests cover refusal patterns (13 cases), title summarisation (6), and budget guard semantics (9). Integration tests pin five chat-route scenarios (refusal, round-trip with encrypted persistence, budget 429, foreign-conversation 404, validation 422) and six conversation- endpoint scenarios (list ordering, owner-only filter, decryption round-trip, 404 boundary on GET single, delete with cascade, 404 boundary on DELETE).
Token-budget default — rationale
MAX_TOKENS_PER_USER_PER_DAY = 25 000 chosen as a conservative cap:
- One assistant turn at ~600 tokens output + ~1 200 tokens input (system prompt + snapshot + 19 turns of history) = ~1 800 tokens.
- 25 000 tokens / 1 800 ≈ 13 turns/day for a heavy user.
- A typical Insight-generation reply runs ~1 500 tokens; 25 000 is still cheap on the operator's budget at scale.
UTC-aligned to the LLM provider billing boundary so reasoning about overnight rollover stays trivial. Operators can dial the cap via a future env override (out of scope for B2a; tracked in v1.4.21 backlog).
Provenance JSON schema (one paragraph)
metricSourceJson stores the assistant's source-chip envelope as a
small JSON object: windows is an array of the analytic windows the
snapshot block covered (subset of last7days, last30days,
last90days, allTime); metrics is an array of stable contract
keys (bp, weight, pulse, mood, compliance, or the catch-
all general when no metric data was present); counts is an
optional partial map from metric key to sample count. Labels only —
never raw values, never timestamps — so the column stays queryable
without the encryption key for future analytics.
Flagged-uncertain items (handed off, not blocking)
- The "summarise older half" pass is currently a placeholder (the
history-window builder injects a synthetic
[summary placeholder — N earlier turns elided]line rather than calling a provider to produce a real summary). Cheap and deterministic; an opt-in real- summarisation pass can land alongside B2b once the drawer UI shows whether users actually push past 20 turns. - The Coach inherits the existing rate-limit-free posture for authenticated routes. Burst protection rides on the per-day token ledger only. If the drawer UI exposes a "regenerate" button we may want a per-minute floor on top.
coach.error*i18n keys are wired but the route currently surfaces the JSON code rather than the localised string in the error envelope; B2b can swap to localised copy in the SSE error frame'smessagefield once the UI wants to render it without a separate translator round-trip.
Voice / hygiene
- No maintainer-name leaks in committed source.
- All UI-rendered strings (refusal copy, error labels) live in
messages/{en,de}.jsonunderinsights.coach.*. - Co-Author trailer present on every commit; pre-commit hooks ran
green; no
--no-verifyor--no-gpg-signused.
Verification gate snapshot
pnpm typecheck 0 errors
pnpm lint 0 errors / 12 baseline warnings (unchanged)
pnpm test --run 217 files, 1781 passed
pnpm test:integration 20 files, 78 passed