Commit Graph
187 Commits
Author SHA1 Message Date
Marc-André Bombeck cdee3e3efc feat(environment): register env signals and wire weather into correlation discovery
Register the seven environmental-exposure signals through the signal registry
as a new "environment" kind (correlation-eligible; off Coach/MCP/FHIR in v1),
and fold their daily series into the correlation engine as lagged behaviour
channels. Weather/daylight now pair against mood, sleep, and vitals through
the existing FDR / effect-size / shrinkage gates, so a thin series degrades to
absent rather than a spurious link. All env channels share one ENVIRONMENT
family so two same-day fields never lag-correlate against each other.
2026-06-28 16:58:30 +02:00
Marc-André Bombeck c0a91dbbcd Merge branch 'fix/375-generated-narratives' into release/v1.25.0 2026-06-28 14:42:46 +02:00
Marc-André Bombeck 95ee088646 Merge branch 'feat/biomarker-detail-page' into release/v1.25.0 2026-06-28 14:42:04 +02:00
Marc-André Bombeck ccbf4e07ae Merge branch 'wave/v125-delight' into release/v1.25.0 2026-06-28 14:41:41 +02:00
Marc-André Bombeck 52e8dab3d7 Merge branch 'wave/v125-sec' into release/v1.25.0 2026-06-28 14:40:39 +02:00
Marc-André Bombeck f17131dbdf test(labs): cover the biomarker assessment, detail page, and marker delete/range
Add coverage for the new biomarker-assessment seam and the marker detail
surface:

- the generator serves today's cache, re-stamps unchanged readings without an
  LLM call, and regenerates only when the latest-reading fingerprint changes
- the read-only endpoint returns the envelope, 422s a missing id, and honours
  the auth + insightStatus gates
- the detail page paints the stat strip, the chart placeholder, and the
  assessment card
- the biomarker resource route drops the marker with its readings in one
  userId-narrowed transaction and rejects an inverted target range

Hold the input gate independent of the worker's forced run so an idle marker
re-stamps cached text instead of regenerating, and list the new route on the
non-Coach gate allowlist.
2026-06-28 13:58:43 +02:00
Marc-André Bombeck 0790a96ca4 feat(labs): add an AI assessment and stat strip to the biomarker detail page
Give each biomarker a detail page that matches the metric sub-pages: a
numbers-first Min/Max/Median/Mean strip over the numeric readings and a
data-grounded AI assessment card.

The assessment is served by a new read-only endpoint
GET /api/insights/biomarker-assessment?biomarkerId=&locale=, returning the
same envelope as /api/insights/metric-status so InsightStatusCard consumes
it unchanged. A new generator reads the marker's LabResult history and
reuses the shared provider runner, the stale-while-revalidate read-only
path, and the persist helper. Regeneration is gated on a fingerprint of the
latest reading, so the assessment is rebuilt only when a new reading lands;
an idle marker re-stamps the cached text without an LLM call. The
biomarker: scope rides the existing on-demand status queue.

The stat strip and the assessment card both self-gate on numeric data, so a
qualitative-only or brand-new marker paints neither.
2026-06-28 13:43:14 +02:00
Marc-André Bombeck 556f442724 fix(insights): honor the per-user AI timeout and keep the last good narrative
The daily briefing and the per-metric status assessments ignored the
per-user response-timeout setting: they hardcoded their per-surface
AI_BUDGETS default, so raising the timeout for a slow self-hosted or
local backend had no effect and the surfaces still aborted early. Only
the coach threaded the setting through.

Resolve the effective timeout from User.aiResponseTimeoutSeconds in one
shared helper and thread it onto every non-coach generation call:

- runStatusCompletion is the single chokepoint every status / reference
  surface funnels through (per-metric cards, the batched assessment, the
  derived assessments, the period narratives), so resolving it there
  applies the setting to all of them at once — on both the upstream
  call's own timeout and the outer wall-clock cap that previously clipped
  a raised value at 60 s.
- The comprehensive briefing generator and the user-initiated regenerate
  route thread it onto the main generation, the JSON retry, the grounding
  retry, and the paragraph re-roll.

On a failed generation the briefing keeps its last good text — no cache
row is written, so the previous payload stays intact — and records a
dated, append-only failure marker (no migration; reuses the audit-log
JSON pattern). The read path compares the marker against the last
successful generation so it can tell a held briefing from a current one.
2026-06-28 12:42:35 +02:00
Marc-André Bombeck a827fe2ca7 fix(insights): correct medication compliance and per-user insight bucketing
A cluster of related correctness gaps in the compliance denominator and the
insight surfaces that read it:

- Paused medications. A doctor-ordered pause flipped `active` off and stamped
  `pausedAt`, but resume nulled `pausedAt`, so the interval was irrecoverable
  and every paused day became a missed dose after resume — collapsing
  adherence across the Health Score, the BP gate, the Coach snapshot and the
  report. Persist pause intervals as durable eras (new `MedicationPauseEra`,
  migration 0211), close the open era on resume, thread the eras through the
  compliance context and drop slots inside a paused interval from the
  denominator. Every compliance call site now fetches and passes the eras.

- Health Score eras. The Health-Score fast path was the only compliance
  caller that skipped the shared schedule select and schedule revisions, so
  after a schedule edit it scored past days against the current schedule
  while every other surface used the historical era. Switch it to the shared
  select and thread revisions (and pause eras) like the canonical path.

- Insight bucketing timezone. The per-metric status series bucketed every
  user in Europe/Berlin, so a near-midnight reading landed in the wrong day
  and the relative-day phrasing was off for non-Berlin self-hosters. Thread
  the resolved user timezone and key off `userDayKey`.

- Correlation significance. User-facing correlations surfaced from five
  paired points with strength read straight off the coefficient. Route them
  through the rigorous engine (at least twenty pairs, Student-t p below 0.05)
  so small-N coincidences no longer render as confident findings.

- Resting-pulse input. The mood-versus-"resting pulse" correlation fed the
  raw PULSE day-mean, which folds in workout HR, under a "Resting pulse"
  label. Feed `resolveRestingPulseSeries` (clean RESTING_HEART_RATE, else a
  daily low-percentile proxy) so the label matches the input.
2026-06-28 03:28:37 +02:00
Marc-André Bombeck ec3fcea84f feat: baseline-drift, breathing-screening and lab-change insight cards
Add three read-only awareness cards to the Insights overview, each drawn
off an existing engine and each honest about empty state — the section
un-mounts rather than surfacing a husk.

- Health status: surfaces what is drifting from the user's own normal by
  folding the personal-band deviations (coincident-deviation engine) with
  the dated, sustained level shifts (changepoint detector) at
  GET /api/insights/health-status. Awareness only, never a diagnosis.
- Breathing screening: summarises the last 30 nights of the per-night
  sleep-breathing-disturbance index plus device-flagged events at
  GET /api/insights/breathing-screening. Mirrors the device's own
  classification and states plainly that it is a screening signal, not a
  diagnosis — discuss with a clinician.
- Lab changes: pairs the two most-recent numeric lab panels and reports
  each shared analyte's signed delta and reference-band standing at
  GET /api/insights/labs-changes. Neutral framing only.

Each reshaping helper is pure and unit-tested for its present/absent
states. Register the three sections in the insights-layout universe so
they merge in as default-visible, wire them into the overview and the
edit-mode title map, add the query-factory keys, document the routes in
the OpenAPI contract, and translate every new string across all six
locales.
2026-06-28 03:20:49 +02:00
Marc-André Bombeck 5ea17398bc feat(security): encrypt medication free-text notes at rest + password-change MFA step-up
Encrypt MedicationSideEffect.notes, MedicationDoseChange.note and
MedicationInventoryItem.notes with AES-256-GCM, closing the last plaintext
PHI columns left after the v1.23 note-encryption rollout. Add the three
*Encrypted Bytes columns (migration 0208), register them in the encrypted-
column rotation set, route every read/write through the note cipher, and add
a transactional fail-closed boot-time backfill for legacy rows. Plaintext
columns drop in a later release once the backfill reports zero remaining rows.

Require a fresh second factor on password change for MFA-enrolled accounts,
matching the existing step-up on MFA-disable and recovery-code regen. Redact
free-text note values from the Zod validation excerpt that can reach a wide
event.
2026-06-28 03:05:10 +02:00
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 b393527cac feat(insights): verdict-first briefing, richer score cards, grounded opener rotation
Replace the fixed three-beat assessment skeleton with a menu of moves the
model orders freely, and reframe the daily briefing from per-signal
enumeration to a verdict-first read that weaves the few salient signals into
one associative story. Score cards now name both the helped and the hurt
contributors, translate the band into a forward read for the day, and close
on one grounded nudge; the deterministic fallback gains the same band-meaning
sentence so provider-less and demo accounts read as well.

Add a hash-seeded opener-archetype rotation (deterministic, testable) so
consecutive cards and days no longer open the same way, a day-rotated sampling
seed for the period narrative in place of the fixed reference seed, and a
shared paragraph formatting contract. Add a score-number grounding gate and
extend the briefing grounding allow-set to the glucose, labs, preventive-care
and workout aggregate blocks so real figures can be cited without opening the
gate. Gate the daily score warm on an input fingerprint so a quiet day skips
the model call.
2026-06-27 14:39:54 +02:00
Marc-André Bombeck e2c6e46f2a merge: W7 coach memory, reminders, action cards into v1.22 2026-06-27 13:39:45 +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 fa82109ca7 merge: W8 cross-signal integration + early detection into v1.22 2026-06-27 13:35:55 +02:00
Marc-André Bombeck 0aaaaac081 feat(insights): surface early-detection and lab correlations to the correlations route and coach
Thread the rolling early-detection pass and the labs -> outcome pass through the
correlations route and the coach correlations reader, both of which already
build the same daily series. The route response gains an optional `emerging`
block (recent-window pairs not yet established, flagged provisional) and a
`labCorrelations` block; the coach reader gains matching `emerging` and
`labDrivers` fields. Regenerate the OpenAPI contract for the new response shape.
2026-06-27 13:34:43 +02:00
Marc-André Bombeck 9ef79b2357 fix(coach): deep-link the proactive nudge to its conversation
The bottom-right launcher opened a blank new chat on an unread proactive
message instead of the thread the Coach wrote. Return the owning
conversation id from the nudge-status endpoint, have the launcher open
/coach?c=<id> on the unread path, and stamp the same deep link on the push
metadata so the notification lands in the exact conversation rather than
relying on a most-recent heuristic.
2026-06-27 13:31:17 +02:00
Marc-André Bombeck 7e48e2c26d fix(insights): stop the daily briefing going blank on slow generations
The comprehensive insight that feeds both the daily briefing and the
insights trend narrative is a single reasoning-heavy generation over the
full feature snapshot. On a large account it runs past the provider
client's fixed 60 s timeout and is aborted mid-stream, so the chain
reports an all-failed outcome, no cached block is written, and the
surface falls back to its static placeholder on every visit. The warm
paths bounded the same generation tighter still (45 s) and counted the
failure with no observable signal, so the blank state was permanent and
silent.

Make the per-request upstream timeout configurable
(CompletionParams.timeoutMs, default 60 s for every existing caller) and
raise it to 120 s for the comprehensive surface only, across all three
generation paths: the on-demand regenerate, the on-demand warm, and the
nightly pre-generation. Raise the warm-pass budget to 130 s so the
provider's own timeout governs a genuine completion rather than clipping
it early. Annotate the comprehensive failure in both the generator and
the nightly loop so a generation that keeps failing is queryable instead
of leaving the surface empty without a trace.
2026-06-27 12:37:25 +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 2c33faa648 chore(insights): align the ambient-Coach file comments to the shipped version
The A1–A3 source files carried v1.22.0 docstrings from before the work was
re-cut as the v1.21.2 point release. Comment-only sweep so blame and the
in-file version tags read consistently with the shipped version.
2026-06-27 00:48:40 +02:00
Marc-André Bombeck cbc428c89b fix(insights): gate the ambient Coach routes on the Coach assistant matrix
The metric-page "Coach read" strip and the unscoped-hero seeded opener are
Coach surfaces, so both routes now call requireAssistantSurface("coach")
alongside the insights module gate. With the Coach turned off they return
the same refusal every other Coach route does, instead of lingering as a
"Coach read" line while the assistant is disabled. Restores the coach
route-gate inventory invariant.
2026-06-27 00:33:07 +02:00
Marc-André Bombeck f5195cfcda merge: wave/v1220-coachui into release/v1.21.2 2026-06-26 23:58:51 +02:00
Marc-André Bombeck 089a5254aa merge: wave/v1220-strip into release/v1.21.2 2026-06-26 23:58:51 +02:00
Marc-André Bombeck 9136a9bdba feat(coach): make launch scope visible and seed the blank state
Replace the hidden composer prefill with a visible scope affordance: when
the Coach is launched narrowed to a metric, the hero shows a scope pill
naming that metric plus a tappable seed question, and the drawer sources
rail surfaces the same scope line. When the Coach opens unscoped, resolve
today's most notable derived signal server-side and offer it as a tappable
opener; fall back to the neutral greeting when no signal crosses the gate,
so the blank box is never cold and never fabricated.

Add an internal GET /api/insights/coach/seeded-question that reuses the
briefing's derived-signal detector to select the opener server-side; the
client renders the resolved DTO.
2026-06-26 23:56:56 +02:00
Marc-André Bombeck cbf903841c feat(insights): add Coach-read strip above metric charts
Render a compact two-line "Coach read" above each metric sub-page chart:
an own-baseline line ("Your usual range is X–Y; today's Z sits within /
above / below") and one top lagged-association line from discovered
correlations, framed descriptively. It makes a metric read seen-in-context
rather than isolated.

The strip is server-authoritative: a new GET /api/insights/coach-read
resolves the band via the median ± k·MAD baseline engine (with its 7-day
history floor — below it the strip says "still learning your range" rather
than inventing a range) and the strongest on-metric driver from the
existing FDR-controlled, effect-size-floored correlation discovery (no
lowered floor; the line is omitted when nothing clears it). The client
component only renders the resolved DTO, so web and iOS read identical
numbers. DTO shapes and the pure selection logic live in a client-safe
sibling shared by the component and the unit test.

Wired through the HealthKit metric scaffold (every generic metric page) and
the bespoke weight, blood-pressure, and pulse pages.
2026-06-26 23:56:28 +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 57682f8df1 fix(coach): build compliance + symptom channels in get_correlations
The correlation discovery matrix gained two non-MeasurementType channels
(medication compliance, symptom severity), but the Coach get_correlations
reader still filtered its measurement query by hand and never built the new
channels. The channel keys leaked into the Postgres enum cast and threw for any
user with medication or illness data.

Extract the route's two channel-series fetchers into a shared module
(correlation-channel-series.ts) so both consumers build the channels identically,
then wire the reader to derive its type filter from discoveryMeasurementTypes and
fold the fetched compliance + symptom series into the discovery input. The reader
now humanises the channel keys to "medication adherence" / "symptom severity".
2026-06-26 13:50:17 +02:00
Marc-André Bombeck 1fa02ce4cf merge(FDREXTEND): compliance + symptom as FDR correlation channels 2026-06-26 13:42:51 +02:00
Marc-André Bombeck aad72ea0e1 feat(insights): add compliance and symptom severity to the correlation matrix
Render daily medication-compliance rate and illness symptom-severity as
first-class series in the FDR discovery engine, so the adherence-dip →
symptom-flare link (and compliance → vital drift) can finally surface.

Compliance pools every active medication's dose-history ledger into a per-day
taken/(taken+missed) rate, re-keyed to the user's display timezone. Symptom
severity reads the illness day-log functional impact, zero-filling healthy days
only across real episode spans so a user with no episodes yields an empty
series rather than a constant. Both flow through the existing n >= 20, BH-FDR,
effect-size, and shrinkage gates unchanged; a sparse series degrades to absent.

Each channel forms its own metric family (no self-lag tautology) and keeps the
association-not-causation framing. Add discoveryMeasurementTypes() so every
caller drops the non-measurement channels before a Prisma type filter.
2026-06-26 13:42:06 +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 9c3b119695 merge(FIX-D): v1.20.2 audit fixes 2026-06-26 09:28:43 +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 23cc48475d perf(insights): bound the comparison snapshot to a 400-day window
buildComparisonSnapshotForUser read a user's entire measurement history
per type with no date floor, then fed each series to summarize(). The
snapshot only consumes avg30 / avg30LastMonth / avg30LastYear, and the
widest of those reaches into the [365, 395)-day window (ageMs < 395 days
in meanOfWindow), so nothing older than 395 days can change a result.

Add a 400-day measuredAt.gte lower bound to every per-type read in both
copies of the builder — the page-blocking POST /api/insights/generate
handler and the shared comprehensive-generate path the nightly
pregenerate cron drives. The fan-out runs all seven snapshot types via
Promise.all, so on multi-year accounts the unbounded scan was tens of
thousands of rows per type. 400 days keeps a 5-day floor over the
strict-less-than 395-day reach.
2026-06-26 09:21:29 +02:00
Marc-André Bombeck 7b0dcbf560 fix(ai): gate OpenAI JSON mode on responseFormat instead of forcing it
The OpenAI client pinned `response_format: { type: "json_object" }` on every
call. The Coach tool loop's forced-final round runs with `tool_choice: "none"`
and expects plain prose, but JSON mode coerced the reply into a JSON object —
the user saw a JSON blob or an empty reply instead of the coach's answer.

Send `response_format` only when the caller opts in with `responseFormat: "json"`
and no tools are present, mirroring the Anthropic client's prefill gate. The
JSON-consuming callers that relied on the implicit mode (insight generate +
briefing retry, medication extraction, the connection-test probe) now pass the
flag explicitly; the prose Coach path and every tool round stay out of JSON
mode. Codex already omitted the flag and is unaffected.
2026-06-22 08:11:16 +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 d7c71f3b32 perf(insights): compute-once feature snapshot per warm scope
The comprehensive briefing's bounded feature read (extractFeatures) was
paid more than once where it should not be: the on-demand route and the
shared generator each run a downgrade ladder that re-reads with identical
arguments, and a nightly warm has no shared object across consumers in a
single tick.

Add a request/run-scoped feature cache: withFeatureCacheScope opens an
AsyncLocalStorage store, getCachedFeatures memoises the first compute per
(userId, day, includeRaw, sinceDays), gated on a cheap salient-input
fingerprint so a mid-scope ingest recomputes and a probe failure fails
safe. Outside a scope it is a pure passthrough. Wired into the
comprehensive generator, the on-demand POST handler, and the nightly
per-candidate loop + forceWarmUser. Coach keeps its own snapshot.
2026-06-21 10:23:14 +02:00
Marc-André Bombeck 85828371a6 test(insights): stub briefing illness/cycle context in generate route test
The v1.18.11 briefing now folds illness-episode and cycle state into the
prompt via buildBriefingIllnessCycleContext, which resolves module gates
through memoizePerRequest and queries cycleProfile. The generate route test
mocks neither getEvent nor the cycle/illness models, so every case threw once
the new call path ran. Stub the context module to no output and add the
getEvent export to the logging-context mock so these cases stay scoped to the
provider and cache behaviour they assert.
2026-06-21 01:02:44 +02:00
Marc-André Bombeck 8479ea001b perf(insights): bound briefing feature window and add illness/cycle context
Bound the comprehensive briefing's feature read to a 400-day window instead of
walking the full measurement history on every generation. All-time min/max/avg
are sourced from one grouped aggregation over the whole history, so the
allTime* fields stay honest while the unbounded row materialisation is gone;
the oversize downgrade ladder remains as the safety net.

Fold illness-episode and cycle state into the briefing prompt, reusing the
server-authoritative builders the Coach already assembles. Both are
module-gated and cost nothing for users without them, so a mid-illness or
mid-cycle user finally gets context-aware prose.
2026-06-20 19:04:08 +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