mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
docs(planning): tick B2a complete, record phase B2a report
The Coach backend half of v1.4.20 wave B is complete; STATE.md splits B2 into B2a (backend, done) + B2b (drawer UI, deferred to a later dispatch) and records the test-count delta plus the migration file in the status block. phase-B2a-report.md captures the five- commit timeline, schema, helpers, route surface, token-budget rationale, provenance JSON shape, and flagged-uncertain items. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3168ba8e0a
commit
e7287d34e7
2 files changed
+197
-9
No files matched your search
+34
-9
@@ -101,15 +101,24 @@ ships behind a feature flag and unlocks independently.
|
||||
|
||||
### B2 — AI Coach panel + streaming chat + persistence (~5-7 days)
|
||||
|
||||
- [ ] Drawer overlay over `/insights`; full-page route deferred to v1.5
|
||||
- [ ] New `POST /api/insights/chat` SSE-streaming endpoint
|
||||
- [ ] New `src/components/insights/coach-panel/*`
|
||||
- [ ] New Prisma `CoachConversation` + `CoachMessage` (encrypted
|
||||
content, GDPR cascade)
|
||||
- [ ] Source-chip rows on every assistant message (provenance)
|
||||
- [ ] Token-budget cap + 20-turn conversation cap
|
||||
- [ ] Prompt-injection refusal pattern (extend v1.4.15 C1)
|
||||
- Detailed report: `.planning/phase-B2-report.md`
|
||||
Split into B2a (backend, complete) + B2b (drawer UI, deferred). The
|
||||
backend half landed under five atomic commits on `develop`; the
|
||||
frontend dispatches separately once the wire contract has settled.
|
||||
|
||||
- [x] New `POST /api/insights/chat` SSE-streaming endpoint
|
||||
- [x] New `GET /api/insights/chat` (list) + `GET/DELETE
|
||||
/api/insights/chat/[id]` (single + delete)
|
||||
- [x] New Prisma `CoachConversation` + `CoachMessage` + `CoachUsage`
|
||||
(encrypted content, GDPR cascade)
|
||||
- [x] Source-chip provenance attached to every assistant message
|
||||
- [x] Token-budget cap (25 000 tokens / user / UTC-day) + 20-turn
|
||||
conversation cap with summarised-history fold
|
||||
- [x] Prompt-injection + off-topic refusal pattern (EN+DE)
|
||||
- [ ] Drawer overlay over `/insights`; full-page route deferred to
|
||||
v1.5 — UI deferred to B2b dispatch
|
||||
- [ ] New `src/components/insights/coach-panel/*` — B2b
|
||||
- Detailed report: `.planning/phase-B2a-report.md` (backend);
|
||||
`.planning/phase-B2-report.md` reserved for B2b drawer UI
|
||||
|
||||
### B3 — Correlation discovery + Trends row with AI annotations (~3-5 days)
|
||||
|
||||
@@ -174,3 +183,19 @@ ships behind a feature flag and unlocks independently.
|
||||
B1–B5 Insights redesign + Wave D + Phase E), ROADMAP.md updated
|
||||
to track v1.4.20 with v1.4.19 archived. Branch model unchanged
|
||||
for this commit (still `main`); F1 will introduce `develop`.
|
||||
|
||||
## Status block — B2a (v1.4.20)
|
||||
|
||||
- 2026-05-10T16:15+02:00 — B2a complete. AI Coach backend landed in
|
||||
five atomic commits on `develop`: Prisma migration
|
||||
`0035_coach_conversations_v1420` adds `CoachConversation`,
|
||||
`CoachMessage`, and `CoachUsage` tables (cascade-on-user-delete);
|
||||
Coach helpers (types, persistence with AES-256-GCM message bodies,
|
||||
per-day 25 000-token budget ledger, EN+DE prompt-injection +
|
||||
off-topic refusal); SSE streaming endpoint at POST
|
||||
`/api/insights/chat` (idempotent on conversation creation only,
|
||||
20-turn history fold, refusal short-circuit, provenance frame, done
|
||||
frame); list / fetch-single / delete endpoints with 404-not-403
|
||||
ownership boundary. Tests: 1753 → 1781 unit (+28),
|
||||
67 → 78 integration (+11). UI drawer (B2b) deferred to a separate
|
||||
dispatch.
|
||||
@@ -0,0 +1,163 @@
|
||||
# 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.
|
||||
`title` is 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_content` is the AES-256-GCM payload
|
||||
written via `src/lib/crypto.ts` under the active `ENCRYPTION_KEYS`
|
||||
entry. Key rotation works the same way as for the existing
|
||||
encrypted columns. `metric_source_json` is 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-paginated
|
||||
`listConversations()`, `deleteConversation()` with ownership check.
|
||||
- `src/lib/ai/coach/budget.ts` — `MAX_TOKENS_PER_USER_PER_DAY = 25
|
||||
000`. UTC `dateKey` so the meter aligns with the LLM provider's
|
||||
billing boundary. `enforceBudget()` throws `HttpError(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` — reuses `extractFeatures()` 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, increments
|
||||
`CoachUsage`. Idempotency wrap applies only when no
|
||||
`conversationId` is supplied (= a brand-new conversation). The
|
||||
response is one `token` frame per word-sized chunk, then a
|
||||
`provenance` frame, then `done`. Provider-chain failure emits an
|
||||
`error` frame 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's `message` field 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}.json` under `insights.coach.*`.
|
||||
- Co-Author trailer present on every commit; pre-commit hooks ran
|
||||
green; no `--no-verify` or `--no-gpg-sign` used.
|
||||
|
||||
## 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
|
||||
```
|
||||
Reference in new issue
Block a user