Commit Graph
54 Commits
Author SHA1 Message Date
Marc-André Bombeck b217535b62 chore(v1.22): clear the QA punch-list across Coach, insights, settings
Code-review and design follow-ups before the v1.22 cut. No behaviour change
beyond the items below.

Coach / insights:
- Coach SSE route: a non-provider error raised inside the open producer (e.g. a
  DB failure persisting the reply) now records on the wide event and forwards to
  GlitchTip instead of masquerading as a provider outage; it still surfaces an
  error frame since the stream is open.
- Lab ↔ outcome correlations: hedge faint-tier pairs ("a faint hint, if
  anything … too small to lean on") so the narrated confidence never outruns the
  effect, matching the daily engine.
- Reminder sweep: mint the plan-review reminder and clear the plan's reviewDate
  in one transaction so a half-applied write can't duplicate the reminder.
- Fold the two raw provider-fallback runners onto one shared chain walker.

Design / accessibility:
- Lift sub-44px tap targets to the 44px mobile floor (suggested-action card,
  Coach bubble affordances), brighten the resting timestamp clock for non-text
  contrast, and route the lab chart height through the shared constant.
- Replace ad-hoc arbitrary type sizes with the scale token and static size-11
  icon buttons with the named icon-lg variant.
- Settings: add the missing About card-header descriptions, normalise the
  low-stock numeric widths, and relax the notification-status rows from nested
  cards to a divided list.

Housekeeping:
- Drop three unused exported types (and the now-orphaned source const), re-stamp
  v1.21.5 comments to v1.22, and register the live-judge eval harness as a knip
  entry so the script-only module resolves.
2026-06-27 16:29:58 +02:00
Marc-André Bombeck b91649d6de feat(ai): UI-configurable response timeout, local streaming, dedicated OCR provider
Add three per-user AI settings, all configured in the app (no new env vars),
behind one migration (0198):

- Response timeout (seconds). A per-user upstream timeout threaded onto the
  provider call. A local/self-hosted backend can take more than the legacy
  60 s default to load a model on the first request, which timed the Coach
  out before the first token. Defaults to a generous ~180 s when unset;
  validated to 10–600 s.

- True token streaming for the local OpenAI-compatible client. The client now
  POSTs with stream:true and parses the SSE delta chunks, emitting tokens as
  they arrive, with a per-idle-gap timeout instead of a whole-call ceiling and
  a graceful fallback to the buffered path when a server cannot stream. The
  Coach route runs the provider call inside the response stream behind a
  keepalive heartbeat so a reverse proxy no longer idle-drops the connection
  during model load or generation. Grounding, refusal, and sentinel-stripping
  still run on the complete reply before any token reaches the client.

- Dedicated document-scan (Lab-OCR) provider. Off by default — the OCR path
  uses the main provider chain. When enabled, lab-report processing resolves a
  separate provider/model/key, with a hint when the chosen model is not
  vision-capable. The new key column is AES-256-GCM at rest and registered in
  the encrypted-columns registry and the rotation script.

i18n: EN + DE translations for the new Settings → AI strings (other locales
carry interim copies to keep locale parity green).
2026-06-27 15:21:40 +02:00
Marc-André Bombeck 5674f10b2f feat(coach): quote the real BP range, address by name, render action cards
Feed the user's own usual blood-pressure range — the median plus or minus
MAD band, computed from the readings already in the snapshot — into the Coach
context with a clause to quote those bounds verbatim, so a band is never
invented from window means. Thread the stored first name into the prompt and
surface it sparingly on roughly one turn in three, never as a rote greeting.
Add the context-layer rule that names the relevant context before a
suggestion, a single-chart-token allowance that works on every provider, and
a per-turn opener hint.

Wire the already-shipped suggested-action SSE frame to render: the streaming
hook collects it and the message thread shows the action card, mirroring the
reminder-suggestion path. Extend the eval golden set with band-verbatim,
chart-token, and no-overclaim cases, and pin a red-team case proving the
chart-token syntax cannot smuggle a dose past the outbound screen.
2026-06-27 14:40:07 +02:00
Marc-André Bombeck 27a8a59d67 feat(coach): capture and resurface "remind me" reminders
Capture the casual "remember this / remind me about X" utterance the Coach
used to drop. The model emits a provider-neutral `---REMEMBER---` sentinel
that the chat route strips inline on EVERY turn — so a short conversation,
where the request usually happens, no longer waits for the long-chat memory
worker. The note is the user's own explicit request, so it is stored
silently as active; a validated closed grammar (ISO date, relative +Nd/+Nw,
or a context cue) sets the trigger, and a malformed block is dropped.

Surface the stored reminders two ways. A snapshot recall sub-block injects
near, overdue, and already-surfaced reminders so the Coach references them
unprompted next turn. A daily sweep flips overdue reminders to due and
mints a one-off review reminder from a plan whose review date has passed,
finally activating the plan review-date column. In-app surfacing only here;
push delivery follows later.

Also generalise the propose-then-confirm action card: a closed
`---SUGGEST-ACTION---` allowlist (a preventive-care item or a reminder note)
rides an additive SSE frame. Nothing is created until the user confirms.
2026-06-27 13:38:04 +02:00
Marc-André Bombeck 5142582d3d chore(release): v1.21.2.1
Hotfix: the Coach and daily briefing failed for ChatGPT-provider accounts —
a provider tool-call rejection surfaced as a 500. Provider failures now
degrade gracefully, the Codex tool-call wire format is corrected, and the
provider error body is captured for diagnosis. Also: the thinking bubble
shows only the animated dots.
2026-06-27 10:33:11 +02:00
Marc-André Bombeck 8b7648bdd0 fix(coach): never 500 on a provider failure; fix Codex tool-call wire
A Codex backend 400 on the richer Coach tool-call request (and the daily
briefing) propagated out of the provider chain runner unwrapped: a 4xx other
than 401/403/429 was not classified as a hard provider failure, so it bypassed
AllProvidersFailedError and rethrew from the chat route as an HTTP 500. Plain
status-card completions, which never send a tools array, were unaffected.

Harden the failure path so a provider error can never surface as a 500:
- Treat any 4xx a provider wire returns as a hard failure so it cascades and is
  wrapped in AllProvidersFailedError. InsightSchemaError (the strict-JSON 422
  surface) stays the only non-hard class, so that diagnostic is preserved.
- The chat route now also classifies a tagged provider error that reaches its
  catch unwrapped and emits the graceful coach.provider.* SSE frame instead of
  rethrowing. Only an untagged error keeps the 500 + error-reporter path.

Make the Codex 400 diagnosable: fold the redacted upstream response body into
the thrown error message and the chain wide-event, so the field the backend
rejects is visible in logs (it was previously dropped).

Fix the Codex request shape behind the tool-call 400:
- Send the required strict flag on each Responses-API function tool.
- Replay prior assistant turns as output_text, not input_text, per the
  Responses API multi-turn contract — the violation only surfaced on round two
  of the tool loop, which is the path that broke.

Codex-only; the OpenAI, Anthropic, local and mock providers are untouched.
2026-06-27 10:26:10 +02:00
Marc-André Bombeck 92243997f6 fix(coach): run the prose number-verifier on the no-tools path
The post-hoc numeric verifier only graded the tool path, so local /
no-tools providers got no grounding net. Grade the no-tools prose
against the snapshot the model was shown — its structured sections,
which already carry the correlations-snapshot block — and flag any
cited number that traces to no snapshot leaf. Non-blocking: the count
is annotated under coach.prose.number_unverified and the unverified
token soft-stripped, never a stream-killer. Only runs when the full
figures were delivered this turn; a cheap follow-up has no fresh set
to grade and no-ops. Tool-path behaviour and tolerance unchanged.
2026-06-26 23:52:18 +02:00
Marc-André Bombeck d5cb1e4ef8 Stop the Coach inventing a /learn link
The Coach may point at a published /learn guide, but only a slug in the
catalog. That was a prompt instruction with no enforcement — a model could
hallucinate /learn/lower-your-cortisol and ship a dead link. Add a deterministic
post-filter on the assembled reply that keeps a published slug verbatim and
strips any reference whose slug is not in the catalog, tidying the prose around
the removed link. Makes the catalog's impossible-by-construction claim an
enforced guarantee.
2026-06-26 13:38:31 +02:00
Marc-André Bombeck abceff04c7 Build the Coach snapshot once per turn and widen the round budget to three
Each retrieval tool re-scoped the snapshot to a single source, so its cache key
never matched the inventory's full-source build — a four-tool turn paid four
extra full snapshot builds on top of the inventory's. Thread the inventory's
probe scope to every tool so a per-tool read lands the one cache entry the
inventory already primed, collapsing the turn to a single build (a window
override still gets its own correct build). Raise the loop's round cap from two
to three so a sequential cross-metric why-chain isn't starved; the per-round
token budget is unchanged and the absolute ceiling tracks one above so the
final round still forces prose.
2026-06-26 13:32:42 +02:00
Marc-André Bombeck d45d60627f Narrow the tool-mode Coach to the metric it was launched from
The tool-mode path dropped the launch scope's sources: the inventory probes the
full source set, so a Coach opened from a metric page saw the whole inventory
and could roam, while only the no-tools path honoured the narrowing. Inject a
one-line FOCUS hint naming the launched domain(s) into the tool-mode user turn
so the model prioritises that metric and fetches its figures first, with every
other domain still reachable if the question leads there. Empty on a generic
open, so the prompt prefix is unchanged on that path.
2026-06-26 13:25:35 +02:00
Marc-André Bombeck 3783348506 fix(coach): make the daily token budget provider-aware
The daily AI-token gate was a flat 25,000/day for every provider. That cap
exists to bound the OPERATOR's API bill, but a ChatGPT-OAuth (Codex) or BYOK
chain egresses on the USER's own plan/key and costs the operator nothing, so
gating it on the operator-cost ceiling is wrong. A single gpt-5.x reasoning
turn legitimately reports 20k-40k gross total_tokens (re-sent system prompt +
inventory + tool defs per round + hidden reasoning, summed across rounds), so
one or two turns exhausted the flat cap and locked the user out of a plan they
pay for.

Classify the chain's cost owner by its primary provider via resolveDailyCap:
an admin-openai primary (operator pays) keeps the operator-cost cap; every
user-egress primary (codex/openai/anthropic/local) gets a generous
abuse-only ceiling. Threaded into the Coach chat gate and both OCR-extract
modes.

Raise the operator-cost cap 25,000 -> 200,000 so the operator-key path also
survives a normal day of reasoning turns, keeping gross-token accounting
rather than reworking the reconcile math.

Subtract cached input tokens at reconcile: the Responses-API gross total still
includes prompt-cached input the user did not re-pay for. The codex client
already parsed cached_tokens; sum it through the tool loop and bill
total_tokens minus cached.
2026-06-26 12:53:04 +02:00
Marc-André Bombeck 58376a0705 feat(coach): verify Coach prose numbers against fetched figures
The Daily Briefing strips any number absent from its server-computed
figures; the Coach's tool path had no equivalent, so a transcription or
paraphrase drift (a tool returns systolic 128, the reply says ~138) could
ship. Collect the numeric leaves from every present tool result this turn,
extract the numbers the reply asserts, and soft-correct any that match no
fetched figure to a neutral placeholder — annotated, non-blocking, and a
no-op on a qualitative turn or the no-tools path where there is nothing to
grade against. The bounded retrieval loop now returns the present results'
payloads so the check has an authoritative figure set.
2026-06-26 12:36:07 +02:00
Marc-André Bombeck bf29ab1e0e fix(insights): cancel the Coach tool-loop provider calls on disconnect
Thread the request abort signal through runCoachToolLoop into each
per-round completion so a mid-generation client disconnect tears down
the upstream call instead of paying the full token cost. Completes the
no-tools-path wiring to cover the tool-retrieval path as well.

Regenerate the OpenAPI spec for the trimmed OCR-capability reason enum.
2026-06-26 09:30:08 +02:00
Marc-André Bombeck 4c571b0e04 fix(insights): cancel the Coach upstream provider call on client disconnect
The Coach SSE turn ran the provider call to completion before the stream
existed and never wired it to the request's abort signal, so a mid-generation
client disconnect still paid the full upstream token cost. Thread an optional
`signal` through CompletionParams and singleUserTurn into each client's
safeFetch (composed with the per-call timeout via AbortSignal.any), and pass
`request.signal` from the chat route's no-tools path. An early disconnect now
tears the upstream request down instead of generating into a closed
connection. Omitting the signal leaves behaviour unchanged (timeout-only).
2026-06-26 09:27:29 +02:00
Marc-André Bombeck 7a9e326e85 feat(coach): retrieve health data on demand via tools
Replace snapshot-stuffing in the Coach turn with a small base context plus
on-demand retrieval. The model receives the full system prompt, a tool-mode
grounding addendum, and a tiny DATA INVENTORY manifest listing which domains the
user has logged and which tool fetches each; the figures themselves are pulled
only when a question needs them.

Six read-only tools wrap the existing server-authoritative snapshot builder:
get_metric_series (blood pressure / weight / pulse + the additive series),
get_glucose_panel, get_sleep, get_medication_compliance, get_labs, and
get_illness_recovery. Each returns a structured { present: false } for an absent
or opted-out domain rather than throwing or returning an ambiguous empty list,
so the model can always tell missing data from real data. Module and cycle gates
are enforced at the builder, so opted-out domains are unfetchable.

A bounded loop runs the call -> tool-result -> answer cycle: at most two rounds
with a hard cap of three that forces a final answer, parallel tool execution,
and only the final round streamed. The day's budget reserves the per-call
ceiling times the round count and reconciles the summed tokens; the atomic
reserve and reconcile primitives are unchanged. All inbound gates (rate limit,
refusal, replay scan, consent, budget) stay before the loop.

The legacy snapshot path stays alive as the no-tools fallback: tool mode is on
only when every provider in the chain supports tool-calling, so a chain that
includes a local model falls back to the prior behaviour verbatim. The tool
trace is persisted onto the assistant message provenance.
2026-06-22 07:37:29 +02:00
Marc-André Bombeck 4e777a7e6a refactor(ai): take a structured message array instead of a string pair
Replace the CompletionParams { systemPrompt, userPrompt } string pair with a
structured { system, messages[] } request across all five AI clients. One
refactor unlocks three things: provider prompt-caching (Anthropic cache_control
on the stable system prefix, OpenAI/Codex automatic prefix cache, with
cachedInputTokens surfaced for observability), the tool-calling wire surface,
and clean multi-turn.

Each client maps the new shape to its own wire and degrades unsupported
features silently: the local OpenAI-compatible server never receives a tools
field (most reject unknown keys), and prompt-caching is best-effort everywhere.
CompletionResult gains toolCalls, finishReason and cachedInputTokens; the tool
types and per-provider mapping ship now, the tool-use loop does not.

Vision inputs fold into the user turn's content parts via singleUserTurn, with
each provider's pre-existing block ordering preserved. The corrective-retry
paths append to the last user message rather than a flat string. The Coach
keeps assembling one user turn so its snapshot-once and grounding behaviour is
unchanged. No wire schema visible to API consumers changed.

Behaviour is unchanged across every surface; the full suite stays green.
2026-06-22 07:17:10 +02:00
Marc-André Bombeck bc1f30fec4 perf(coach): send the health snapshot once per conversation
The full SNAPSHOT block (every cluster plus labs, illness, cycle and the
reference-grounding lines) was interpolated into the prompt on every turn,
so a one-word follow-up re-paid the entire grounding prefix. Ship it only on
the first turn, or once the history window starts eliding turns; on a cheap
follow-up send a short pointer back to the snapshot the model already received
earlier in the same conversation. The prior turns ride the transcript, so
grounding and cross-metric correlation are preserved while a follow-up turn
sheds about 6,000 tokens. Record snapshotSent and promptChars on the reply
annotation so the cut is observable.
2026-06-21 13:56:49 +02:00
Marc-André Bombeck 869f1d9c11 fix(coach): wire the outbound screen and an SSE cancel handler
Apply the outbound safety screen to the assembled Coach reply before
persistence and streaming, with a per-turn audit row on a block.

createSseStream gains a cancel handler + a shared AbortSignal: a client
disconnect now flips the signal so the token-emit loop stops enqueuing
into a closed connection and any provider fetch wired to the signal is
torn down. The chat emit loop honours it between frames.
2026-06-20 16:58:23 +02:00
Marc-André Bombeck f46e9d3bb8 feat(coach): surface per-turn token usage on the chat stream
Carry an additive usage envelope on the SSE done frame
({ totalTokens, model }) and persist tokensUsed + model onto
coach_messages (migration 0182, nullable, no backfill) so the
per-message token footer survives a conversation reload. Add an
optional reasoning frame the client can render inside the thinking
disclosure when a reasoning-capable provider emits a summary; providers
without reasoning emit none. Extend CoachMessageDTO and regenerate the
OpenAPI contract. Server-authoritative: clients display the count, never
recompute it.
2026-06-20 11:58:58 +02:00
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00
Marc-André Bombeck 5f2a76ddbc fix(coach): close the budget-gate TOCTOU and count burned tokens
The daily token cap was read before the provider call and recorded only
after a successful reply, so concurrent requests could all read
`spent < cap` and all hit the provider, and an empty/sentinel reply
recorded zero spend despite burning upstream tokens.

Reserve the budget atomically before the call: a single upsert-increment
returns the new total, so concurrent requests serialise on the row and
cannot both pass the cap; over-cap refunds the reservation and surfaces
`coach.budget.exceeded` as an SSE error frame. Reconcile the reservation
to the actual token count immediately after the provider returns —
before any empty/sentinel short-circuit — so burned tokens are always
recorded. An integration test proves two concurrent requests cannot both
exceed the cap and that an empty reply still records its cost.
2026-06-19 21:16:43 +02:00
Marc-André Bombeck 7530324fd1 refactor(ai): centralise per-surface generation budgets
Replace the scattered `?? 0.3` / `?? 1000` temperature and maxTokens
magic numbers across the status provider, Coach route, comprehensive
generator and period-narrative generator with a single typed
`ai-budgets.ts` map. Each entry documents the output contract that
fixes its token ceiling.

Right-size the per-metric status output from 1000 to 250 tokens: the
card renders one `{ "summary": "..." }` of 2-4 sentences (30-60 words),
so 1000 was an order of magnitude over the real ceiling.
2026-06-19 20:57:37 +02:00
Marc-André Bombeck 22ffb19fa5 feat(coach): ground replies in cited reference ranges
Surface the population reference bands behind the metrics a Coach turn
carries as a compact, citation-aware grounding block appended after the
SNAPSHOT. For each present metric the block states the headline band, its
source (blood pressure on the ESH 2023 line, US/ESC framings as context
only), and where the user's value sits via the shared four-state
placement contract.

Framed strictly as general guidance: the block opens and closes with a
not-a-diagnosis caveat, never overrides the existing refusal/dose/drug-
level safety discipline, and carries no commercial brand name. Glucose
respects the diabetes opt-in — the tighter ADA management goal only when
the user has declared it, otherwise the general non-diabetic band.

The block is deterministic and inspectable: built purely from the
reference-range backbone over the same scalars the snapshot already
surfaces, so it can never cite a value or band the snapshot does not
carry.
2026-06-18 20:32:45 +02:00
Marc-André Bombeck 12f4bc24e8 feat(coach): evidence-based cadence suggestions with one-tap in-chat reminder
The Coach can append a ---SUGGEST-REMINDER--- sentinel to propose a
measurement cadence (weight daily, the ESH/AHA 7-2-2 home-BP protocol,
structured glucose). The server resolves the cadence from a closed catalog
and emits an additive 'suggestion' SSE frame the chat renders as a one-tap
action card; accepting it creates a MeasurementReminder with origin COACH
through the same engine the Vorsorge surface uses (server-authoritative
schedule, finite course window self-expires via endsOn).

Non-naggy by construction: a frequency-cap cooldown, dismissal memory, a
master opt-out under Coach settings, and an explicit 'I measure enough'
stop path, all in the coach-prefs blob. Triple dedup at suggest, render,
and create so a cadence is never double-suggested or double-created, and
suggestions for a disabled module never surface. RHR/HRV stay passive --
the Coach surfaces the baseline, it never nudges to 'go measure' them.

The reminder DTO now echoes origin and endsOn for the iOS client.
2026-06-16 21:17:51 +02:00
Marc-André Bombeck 85b0b635f2 test(coach): cover the module gate on the chat and about-me routes
Per-route tests assert the two-layer Coach module gate: a disabled
module (operator availability OR per-user disableCoach) yields 403
module.disabled before any Coach work runs, and an enabled module falls
through to the normal path. Covers the chat POST + list GET, about-me
GET/PUT, the questions GET/DELETE, and the adopt POST short-circuit
ahead of the locked read-modify-write.
2026-06-16 01:28:28 +02:00
Marc-André Bombeck 2b5974cf4a fix(coach): enforce the module gate on coach chat and list routes
The streaming POST and the conversation-list GET enforced only the
legacy operator assistant flag (requireAssistantSurface("coach")),
missing the per-user disableCoach opt-out and the operator
module-availability layer. Both handlers now run the two-layer
requireModuleEnabled(userId, "coach") gate right after auth, returning
403 module.disabled when the account has Coach turned off — even with a
valid Bearer token. The legacy assistant-flag check stays in place.
2026-06-16 01:24:20 +02:00
Marc-André Bombeck 40f7196c20 feat(medications,coach,admin,perf): era corrections, a truthful weekly status and one card language everywhere
The dashboard snapshot fires the moment login succeeds instead of
waiting for the page chunk (start ~670 to ~200 ms throttled). Guided
follow-ups are personalised from the user's snapshot and an answer gets
the coach's streamed reaction before the next question. Archived
schedule eras can be corrected through a superseding revision that
keeps the original as an audit trail, era writes serialise per
medication, and duplicate dose times collapse in validation. A rolling
medication whose next dose is tomorrow can no longer read as heavily
overdue — the card status derives strictly from the served next-due —
and the adherence bars keep a fixed geometry in every state. The
server-wide assistant key gains its admin card, the one duplicated
coach preference collapses onto a single owner, twenty-seven admin and
settings card headers move onto the shared pattern with sixty
containers normalised, and the remaining hydration warnings are gone.
2026-06-11 13:57:15 +02:00
Marc-André Bombeck 9f2c403f84 feat(coach): a chat that behaves like one
The composer starts as a single line with send beside it and grows only
when the text does; the shift-enter hint and the inline disclaimer are
gone. A typing indicator bubbles before the first token. The context
rail hides by default behind a toggle and stops scrolling sideways,
conversations open in the full view instead of a broken side drawer,
and the coach preferences move into Settings where the rest of the
configuration lives — context toggles and the exclusion list now derive
from one source so they can no longer contradict each other. A floating
bubble appears only while a coach-initiated nudge is unread. Clearly
stated allergies and conditions are remembered immediately through a
deterministic extractor instead of waiting for the twenty-turn
summary pass.
2026-06-10 21:19:05 +02:00
Marc-André Bombeck 5b75184649 feat(coach,admin): finish the self-context and invite surfaces end to end
Invites move into their own admin section with a real ledger: status
badges, expiry, uses, a redemption history of who joined when (new
redemption table, revocation is now soft so history survives), and an
honest one-time link/QR moment. The assistant self-context grows
structured fields for conditions, allergies and focus — encrypted like
the free text — merges age and gender from the profile into one fenced
prompt block, and asks up to three targeted follow-up questions that
surface as tappable chips above the coach composer. A fourth nudge
trigger gently suggests a check-up when the self-context is stale and
the coach is actually in use.
2026-06-10 16:18:48 +02:00
Marc-André Bombeck 51dd614199 feat(coach,auth): self-context for the assistant, invite links, proactive nudges
Users can now tell the assistant about themselves in an encrypted
free-text profile that feeds the coach and briefing prompts as clearly
fenced context, with the coach allowed one targeted follow-up question
when the picture is incomplete. Operators on closed registrations can
mint expiring invite links with a QR code from the admin area; a valid
invite opens registration exactly as many times as it allows and records
who redeemed it. A nightly pass watches three deterministic signals —
low adherence, blood pressure above its target band, a falling recovery
trend — and sends at most one coach nudge per week through the existing
notification channels, gated behind the same opt-outs as every other
assistant surface.
2026-06-10 15:40:48 +02:00
Marc-André Bombeck 65677c2112 fix(api): bound every JSON body read
Raising the middleware body cap to 512 MB for archive imports removed
the implicit ceiling that protected JSON routes, so any bulk endpoint
would buffer an arbitrarily large body before validation ran. Every
JSON body read under /api now carries an explicit byte cap sized to its
legitimate payload (64 KB for single-record routes up to 16 MB for the
measurement import), webhooks cap before signature work, and oversized
bodies return 413.
2026-06-10 11:36:51 +02:00
Marc-André Bombeck 37e9f32f56 fix(ai): require active consent before external-provider egress; rate-limit and harden Fitbit sync
Close the launch-blocking gap where the ConsentReceipt infrastructure was
built but never enforced as a precondition: a direct API caller with a valid
token could forward a health snapshot to a server-managed external LLM with no
receipt on file. Consent was enforced client-side only.

Add a consent guard (src/lib/ai/consent-guard.ts) and call it before the first
external-provider call on the server-managed path. The gate fires only when the
resolved chain could egress via the operator's global key (admin-openai); a
user's own BYOK key (openai/anthropic), their own ChatGPT OAuth account, and
the self-hosted local provider stay ungated — that egress is the user's own act
and the settings flow is its consent. The check fails closed: a BYOK-primary
chain with an admin-openai fallback still requires a receipt, since the runner
may cascade to the operator key. The surface maps to the consent kind (coach ->
ai_coach, insights -> ai_insights_only; master ai_full satisfies either).

- Interactive routes (insights/chat, insights/generate POST) throw
  ConsentRequiredError; api-handler renders a 403 with
  meta.errorCode = "consent.ai.required", mirroring assistant.disabled.* so
  the client can render an inline grant-consent notice.
- Off-request pipelines fail closed quietly: comprehensive generation returns a
  typed skipped:no-consent outcome, and the per-metric status family
  (runStatusCompletion, gating every status card + the period-narrative warm +
  the off-budget Coach memory workers) surfaces the no-key fallback instead of
  egressing.

Also rate-limit POST /api/fitbit/sync (the one Fitbit route without a limiter):
a 5/60s baseline plus a tighter 1/hour bucket on the expensive fullSync walk,
keyed by user id, matching the sibling Fitbit routes.

Harden the Fitbit OAuth redirect_uri: assert it is an absolute https origin (or
http on localhost), lands on /api/fitbit/callback, and stays same-origin with
NEXT_PUBLIC_APP_URL — defence-in-depth behind Google's registered-URI check so
a Host-coerced or misconfigured app URL cannot redirect the authorization code
off-origin.

(cherry picked from commit 46b5fc2cea1b6e80bee0445bfdacf5790442c67d)
2026-06-05 03:01:36 +02:00
Marc-André Bombeck 9e6865841d feat(coach): stream replies incrementally, collapsible history and provenance, single disclaimer
Token-by-token streaming was buffering on the server. The chat route
enqueued every tokenised SSE frame synchronously inside the stream's
start() callback, so the runtime coalesced them into a single network
read and the client painted the full answer at once despite the
per-token render path already wired in use-coach. Make the emit callback
async and yield to the event loop between token frames so each one
flushes as its own chunk; the visible cadence now reads ChatGPT/Claude-
style. Refusal and error paths stay single-frame.

The conversation history was a permanent left column on lg+, eating chat
width on laptops and reading as a takeover behind the narrowed drawer
cap. It now lives behind an always-visible "Conversations" toggle on
every viewport and opens as the left-edge tray on demand; the thread
keeps the full width by default and never loses it on mobile.

The provenance block (source chips plus raw key-values) rendered fully
expanded under every answer and was often taller than the reply. Fold
the chips into the existing collapsed disclosure alongside the key-
values so the whole grounding is one click-to-expand surface, collapsed
by default, with the disclosure's aria-expanded/aria-controls intact.

The clinical-decisions disclaimer rendered both at the bottom of the
thread and in the sources-rail footer. Consolidate to a single line
directly above the composer, always visible on every viewport.

The auto-growing composer (single line growing to a capped multi-line
with internal scroll, Enter-to-send, Shift+Enter-newline) and the
aria-live streaming region are preserved. Plain-text React children
throughout; no new dependencies.
2026-06-04 22:17:55 +02:00
Marc-André BombeckandClaude Opus 4.8 9a0ea1be56 feat(coach): inject the rolling summary + enqueue memory refresh
fetchConversationWithMessages now returns the decrypted rolling summary
(fail-closed → null), and buildHistoryWindow substitutes it for the dead
placeholder so a long conversation keeps memory of its elided turns.
Once a conversation grows past the turn cap the chat turn fires a
fire-and-forget coach-memory-refresh so the next long turn is fresh,
without adding a provider round-trip to the interactive reply.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:21:19 +02:00
Marc-André Bombeck fbd45f3044 feat(ai): durable provider-health ledger and credential-expired surfacing
Promote the volatile per-worker last-working cache to a Postgres
provider-health ledger so the fallback chain shares one health signal
across every instance, modelled on the atomic-upsert rate limiter.

The ledger holds a durable negative cache: an auth-class failure
(401/403) benches that provider for a cooldown instead of re-burning the
dead round-trip on every generation; a sustained hard failure backs off
briefly. The local model is never benched — it stays the guaranteed
floor of the chain. Every ledger interaction fails open, so a DB error
falls back to today's chain walk exactly.

When the user's primary provider fails auth-class, the Coach emits a
distinct credential_expired frame that deep-links to reconnect rather
than a generic "try again later".

Adds the provider_health table (migration 0112) and the
errorCredentialExpired copy across all locales.
2026-06-04 00:50:13 +02:00
Marc-André BombeckandClaude Opus 4.8 50e0a65228 chore(release): v1.7.0
v1.7.0 — health-record export (PDF + FHIR), flexible medication schedules, first-paint dashboard snapshot, full HealthKit coverage, offline sync delta feed, Coach data clustering. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:36:17 +02:00
Marc-André BombeckandClaude Opus 4.7 53e7389854 fix(ai): apply a per-user request-rate ceiling to the Coach chat endpoint
`enforceBudget()` catches the cost dimension — once a user has burned
the day's token allowance the route returns 429 with
`coach.budget.exceeded`. It does not catch the request-rate
dimension: a tight loop or a stolen session can issue 60 requests in
a second, all accepted up to the moment the budget arithmetic
finally rejects, while pinning Prisma connections and provider-side
SSE slots in the meantime.

A `checkRateLimit("coach-chat:${userId}", 20, 60_000)` call lands in
front of the budget gate. 20 requests per minute is well outside any
realistic interactive pace — a human doesn't type that fast and the
iOS client paces from user gestures — while keeping the budget logic
as the cost ceiling it was designed to be.

The 429 returns through `apiError()` for envelope parity with the
other rate-limited routes; an `insights.coach.rate-limited`
wide-event lands so operators can see when the limit fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:12:42 +02:00
Marc-André Bombeck 6c7fc7dc84 chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged
Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
2026-05-21 23:13:21 +02:00
Marc-André Bombeck a550031a84 chore(release): v1.4.38
Robustness sweep, perf hotspots, full localization, coach gate
cascade closure.

- /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket
  sparkline + 60 s response cache.
- Coach API gate inventory test surfaced and closed two orphan
  routes (chat[id] GET+DELETE, chat-message feedback POST).
- Cross-tz fast-path runtime guard falls back to live for users
  more than ±3 h from UTC; proper bucket minting stays v1.5.
- es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus
  three medication-cluster placeholder restorations.
- IANA timezone validation closes the User.timezone self-DoS
  regression the new dashboard SQL would have surfaced.
- Unit suite 4520 → 4524.

See CHANGELOG.md for the full set.
2026-05-17 19:07:03 +02:00
Marc-André Bombeck 5ea813e20c chore(release): v1.4.31
Operator-side assistant feature-flag matrix, /insights tab-strip blocking fix on mobile, and the GHCR-propagation-race Coolify auto-deploy fix. Full notes in CHANGELOG.
2026-05-16 14:19:57 +02:00
Marc-André Bombeck 17b8d8d2c2 chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
2026-05-14 23:49:32 +02:00
Marc-André BombeckandClaude Opus 4.7 650f150353 fix(coach): feedback references coach_messages, drops plaintext column
Reviewer note: W5 H7 wrote Coach assistant prose plaintext into
recommendation_feedback.recommendation_text. Same prose lives
encrypted in coach_messages — backups captured the leak.

Migration 0042_coach_feedback_message_ref adds nullable coachMessageId
(FK + cascade) and drops recommendation_text. Aggregator joins +
decrypts on read; admin view reads through the same path.

Fixes: Sr-H3 (Wave-6 v1.4.23 senior-dev review)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:38:17 +02:00
Marc-André BombeckandClaude Opus 4.7 05c7f1474d feat(coach): per-message thumbs feedback + admin aggregate view
The v1.4.22 prose rewrite shipped without a way for users to signal
whether the new tone is landing. v1.4.23 H7 closes that loop with a
light-touch thumbs row + an admin first-week dashboard.

Schema: extend `RecommendationFeedback` with a polymorphic
`target_type` column (default "recommendation" so legacy rows stay
representative) plus a nullable `reason` field. New index on
(target_type, created_at) so the aggregator can scan per-surface
buckets without a full sweep. Migration `0040_recommendation_feedback_target_type`.

Route: POST /api/insights/chat/messages/:id/feedback. Body
{ rating: "helpful" | "unhelpful", reason?: string }. Verifies
ownership (404 on cross-account messages — no existence leak),
decrypts the message body for the dedup key, and writes the row with
target_type="coach" + a metric-source string encoding the user's
active prefs (`coach:tone=warm:verbosity=default`). Per-user 60/h rate
limit. 409 on a P2002 (already_rated) so a double-click is a no-op.

Aggregator: new `buildCoachFeedbackBuckets()` slices Coach rows by
(promptVersion, tone, verbosity). The summary blob the worker writes
to AppSettings.adminAiInsightsFeedbackSummary now carries both
`buckets` (pre-existing) and `coachBuckets` (H7 addition). Targets
the same singleton row so no second worker handler is needed.

Admin view: `/admin/coach-feedback` (new section slug) renders the
Coach buckets as a small DataTable (prompt version × tone × verbosity
× helpful × not-helpful × n × helpful-rate). Helpful-rate column
tinted via Dracula tokens (green ≥80%, yellow ≥50%, orange below).
First useful question this answers: "Is the v1.4.22 prose rewrite
landing well, or did the warm tone overshoot?" If the helpful-rate
for PROMPT_VERSION 4.23.x drops below 50% within the first 100
ratings, v1.4.24 walks the persona back.

UI: `<ThumbsUp>` / `<ThumbsDown>` buttons (lucide icons, no emoji)
under each persisted assistant bubble. Streaming bubbles + refusals
+ error bubbles skip the row so users can't rate before the message
lands on disk. Localised "Thanks for the signal." after submit.

OpenAPI: spec carries the new POST route + the request schema.
Coverage now includes the H4 + H7 routes — three additions over
v1.4.22 baseline.

Tests +9 unit (aggregator Coach-bucket slicing, admin section
render-paths) plus an integration round-trip for the route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:51:27 +02:00
Marc-André BombeckandClaude Opus 4.7 3f60c81b9e feat(coach): per-user prompt-tuning surface (settings cog returns)
The v1.4.22 B5 audit removed the placeholder cog from the Coach
drawer header because it had no real wiring. v1.4.23 H4 returns
the cog with an actual settings surface backed by a per-user
preferences row.

Schema: nullable JSONB column `users.coach_prefs_json` (migration
0038_coach_prefs). Null = legacy v1.4.22 defaults; non-null carries
`{ tone, verbosity, excludeMetrics, showEvidenceByDefault }` validated
by the Zod schema in `src/lib/validations/coach-prefs.ts`.

Routes: GET + PUT `/api/auth/me/coach-prefs`. PUT validates the body,
persists the canonical defaulted form, and emits an
`auth.me.coach-prefs.put` annotation with the four-knob meta. OpenAPI
registry covers both verbs; the spec regenerates without drift.

Coach prompt builder: `getCoachSystemPrompt(locale, prefs)` prepends a
per-user OVERRIDE paragraph for non-default tone (neutral / concise)
and verbosity (brief / detailed). Concise tone forces brief verbosity
regardless of the explicit selection — concise == short. Default
prefs leave the prompt unchanged so the v1.4.22 train stays in place.

Snapshot builder: reads the prefs row BEFORE any measurement query
and intersects `excludeMetrics` with the resolved scope. The model
never sees data the user opted out of — that's the load-bearing
order: filter first, query second.

UI: settings cog in the drawer header opens a right-edge `<Sheet>`
with three Select pickers (tone, verbosity) and one Switch column
(per-metric exclusions + showEvidenceByDefault). Save calls the PUT
route and invalidates the `["coach-prefs"]` query so the next reply
honours the new defaults without a reload. EN + DE i18n keys for
every label and hint.

Message-thread reads the same `["coach-prefs"]` cache and toggles the
`open` attribute on the evidence `<details>` element when
`showEvidenceByDefault` is true.

Tests +24 unit (coach-prefs schema, system-prompt prefix, snapshot
mock for new prefs row). Integration round-trip seeds a session,
PUTs a non-default prefs shape, asserts the GET reflects the saved
values + the DB row carries the canonical defaulted form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:35:45 +02:00
Marc-André BombeckandClaude Opus 4.7 58ae9bca42 feat(coach): partial-malformed observability on sentinel parser
The W3-of-v1.4.22 sentinel parser silently dropped malformed rows and
fired `coach.keyvalues.parse_failed` only when the entire block was
unusable. Mixed-format drift (one row breaks, the other six parse)
landed without any signal. Surface it.

- `SentinelParseResult.malformedEntries[]` records each skipped row
  with a typed reason (`missing_colon` / `value_overflow` /
  `label_overflow` / `no_END_marker` / `byte_overflow` /
  `schema_invalid`). Block-level failures (truncation, missing close
  marker) land here as synthetic entries so the wide-event always
  carries a typed cause.
- Per-row label and value caps moved out of the Zod schema into the
  parser so the row-level reason can be `label_overflow` /
  `value_overflow` rather than the catch-all `schema_invalid`.
- Chat route splits the annotation: `coach.keyvalues.parse_partial`
  when at least one row parsed AND at least one row failed, the
  existing `coach.keyvalues.parse_failed` stays for full-block
  failures. Both annotations carry the per-line reason array.
- Backwards-compatible `parseKeyValueLine` wrapper preserved.

Tests +5: label_overflow, value_overflow, byte_overflow,
mixed-malformed entry assertions, and a tryParseKeyValueLine
direct-API surface check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:20:01 +02:00
Marc-André BombeckandClaude Opus 4.7 bbdc3fbd00 style: prettier on W5 reconcile touchpoints
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:30:53 +02:00
Marc-André BombeckandClaude Opus 4.7 2075d392b1 refactor(api): extract createSseStream helper from chat route
The Coach chat route had three identical `new ReadableStream({
start(controller) { try { ... } finally { controller.close(); } } })`
blocks (refusal / provider-error / happy path), differing only in
which frames they enqueued. Extract the boilerplate into
`src/lib/sse/create-stream.ts` so the v1.5 iOS streaming endpoints
inherit the same try/finally close semantics without copy-paste.

The helper accepts sync or async emit callbacks; the controller is
always closed in `finally` so a thrown emit never hangs the response.

Three unit tests pin the sync, async, and throw paths.

Closes Sr-H2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:12:22 +02:00
Marc-André BombeckandClaude Opus 4.7 75ccc01984 fix(coach): graceful fallback when sentinel-only output strips empty
When the model emits only a `---KEYVALUES---` block with no leading
prose (or a malformed block missing the closing marker), the
post-strip prose is empty. The previous fallback chained back to
`rawReply`, surfacing raw sentinel markers to the user. Replace with
a structured `coach.provider.empty` error frame so the UI renders
the localised provider-error copy instead of leaking the markers.

Add a regression integration test pinning that the streamed body
never contains `---KEYVALUES---` for a sentinel-only reply, and that
no assistant message is persisted in this branch.

Closes Code-H1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:05:14 +02:00
Marc-André BombeckandClaude Opus 4.7 7b8efaae14 feat(coach): parse evidence-block sentinel out of streamed prose
Add a text-marker sentinel parser (`---KEYVALUES---` … `---END---`)
that strips the Coach's evidence block out of the prose before it
streams to the client, validates each line against a Zod schema, and
attaches the parsed entries to `provenance.keyValues`. The persisted
envelope round-trips so the disclosure renders on conversation
reload. Hard-capped at 8 lines and 1 KB to guard against
prompt-injection padding; malformed blocks log a wide-event and pass
the prose through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:07:32 +02:00
Marc-André BombeckandClaude Opus 4.7 52dd9ff9c3 docs(planning): record v1.4.20.1 hotfix wave
New `.planning/phase-v1421-hotfix-report.md` summarises the six
fixes (Daily Briefing regenerate · Coach day-level snapshot · Coach
prompt update · streaming-bubble dedup · drawer header · sources rail
scope picker), the test-count delta (2026 → 2036 unit), the
token-budget impact of the day-level snapshot (~190 → ~3000 tokens
per full-scope turn), and a recommendation to ship as a v1.4.20.1
hotfix.

STATE.md grows a Phase F section + status block tracking the six
commits on develop. Includes prettier polish across the touched
coach surfaces (sources-rail, drawer, message-thread, snapshot,
chat route).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:31:20 +02:00