Commit Graph
1145 Commits
Author SHA1 Message Date
Marc-André Bombeck b0be36c55b feat(coach): carry discovered drivers in the no-tools snapshot
The FDR cross-metric driver pairs were reachable only through the
get_correlations tool, so a local/no-tools provider got the coincident
flag but zero discovered drivers and the cross-metric layer silently
degraded. The system prompt's fallback to a snapshot driver field
pointed at a field that did not exist on that path.

Attach a bounded top-3 slice of the same gated, ranked driver output the
tool serves to the no-tools snapshot floor, gated on the derived layer
and shed first under prompt-budget pressure. Local users now reach
parity on the flagship cross-metric capability.
2026-06-26 13:12:34 +02:00
Marc-André Bombeck 99a6163343 feat(insights): gate discovered correlations on effect size and confidence
Significance alone surfaced trivial drivers: a large-n pair with r=0.16
narrated as a confident "tends to go with" link while explaining ~2.5%
of variance. Add three gates before a pair is ranked as a driver:

- exclude same-metric-family lagged pairs (mood->mood, BP component
  self-lag) as serial-autocorrelation tautologies, widening the existing
  exact self-pair skip to the whole family.
- shrink each Pearson estimate toward null by n/(n+10) so a thin-data
  correlation cannot out-rank a deep one on an inflated point estimate.
- floor the shrunk effect at 0.2 (drop below) and bind 0.2-0.3 to a
  hedged "faint hint" phrasing tier, 0.3+ to confident phrasing scaled
  by sample depth. The reported r and p stay the honest raw statistics.

Rank by shrunk effect magnitude, q as tie-break.
2026-06-26 13:10:29 +02:00
Marc-André Bombeck b75cef9190 fix(insights): key coincident-deviation 'today' in the user's timezone
The coincident-deviation read grouped the latest day with a UTC date
slice while the sibling readiness read already used the tz-aware day key.
For a user east or west of UTC, a late-evening or early-morning reading
landed on the wrong calendar day, so a fired flag could compare a vital
from the wrong day against its band and narrate '2 or more vitals out of
band today' on the wrong day.

Thread the user's timezone into the latest-day read and mint the day key
with userDayKey, matching readiness. The Coach derived-snapshot and the
correlations reader pass the real account timezone through. Adds a
regression covering a UTC+2 user whose late-night reading rolls to the
next local day.
2026-06-26 13:01:39 +02:00
Marc-André Bombeck f2a9716671 fix(safety): unify clinical floors into one source of truth
The hypertensive-crisis floors were defined in three modules with a
divergent diastolic value: the notification engine and the Coach acute
clause used 180/120, but the dashboard hero used 180/110. A reading like
170/112 lit the red critical-BP banner on the hero yet never tripped the
notification alarm or matched the Coach's stated acute number — one
surface said crisis, the other two stayed calm on the same row.

Promote the absolute floors into a dependency-free clinical-floors leaf
and have the hero, the safety-floor engine, the status registry's fever
band, and the illness escalation all import from it. The crisis diastolic
floor is the guideline-correct 120 (ACC/AHA hypertensive urgency); the
wider 110 hero net is dropped so the surfaces tell one story. A coverage
test pins that every consumer resolves the same constants.
2026-06-26 12:58:50 +02:00
Marc-André Bombeck 490c9d240a merge(FALLBACK): v1.21.0
# Conflicts:
#	src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts
#	src/lib/insights/narrative/period-narrative-generate.ts
2026-06-26 12:40:03 +02:00
Marc-André Bombeck 37dfce2104 merge(COACHUI): v1.21.0 2026-06-26 12:39:17 +02:00
Marc-André Bombeck f12f349a38 merge(COACHPROMPT): v1.21.0 2026-06-26 12:39:17 +02:00
Marc-André Bombeck 93433c3197 feat(coach): add "ask the Coach about this" to insight cards
Add a discreet, on-brand action to the high-value cards (briefing,
recommendation, correlation, status/assessment, health score, period
narrative) that opens the Coach pre-scoped to the card topic with a
seeded question. The action self-gates on the operator flag and the
per-user opt-out, never tints the card, and surfaces once per card.
2026-06-26 12:38:03 +02:00
Marc-André Bombeck 44796d1c0e feat(coach): carry metric scope into the Coach launch
Make the launch context's metric scope live instead of discarding it.
A conversation opened from a metric surface now narrows its snapshot to
that metric with a data-aware seed question, and the global FAB inherits
the page's ambient scope so drilling into a metric and tapping it no
longer opens a blank chat. The scope threads through the drawer into the
first turn of a fresh conversation.
2026-06-26 12:37:54 +02:00
Marc-André Bombeck ce07548bc4 feat(coach): fold the coincident-deviation flag into the snapshot
The multi-vital coincident-deviation flag (two or more vitals outside
their usual band on the same day, with the illness-explained reframe)
already reached the period narrative but never the Coach. Attach it,
fired-only, to the derived block so both Coach paths can narrate it: the
tool path via the recovery/correlations tools and the no-tools snapshot
floor. A quiet day adds no entry, keeping the snapshot noise-free.
2026-06-26 12:36:14 +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 6de1f34efb feat(coach): widen tool-mode data reach to every stored domain
The retrieval-tool DATA INVENTORY was built against the user's default
narration cluster (cardio/body/mood/medication), so sleep, glucose,
activity, body composition, vascular, SpO2, gait and environment series
reported absent even when the data existed — and the grounding rule then
told the model not to fetch them. Probe presence against the full source
set instead, and generate the metric-series inventory rows from the
complete source-to-section map so every series the user has rows for is
advertised. Per-tool reads still re-scope to their own domain, so the
wider probe never widens a figure read.

Add three retrieval tools whose snapshot blocks already existed but were
unreachable in tool mode:
  - get_workouts: recent sessions + per-sport rollup
  - get_cycle: phase / prediction / correlation (gated by cycle access)
  - get_correlations: the FDR-controlled lagged cross-metric drivers plus
    the coincident-deviation flag, surfaced descriptively

Also honour the caller's window in get_labs and get_illness_recovery so a
cross-metric answer no longer silently mixes horizons.
2026-06-26 12:35:57 +02:00
Marc-André Bombeck d22372e800 fix(insights): warm the period retrospective to the shared house voice
The retrospective narrative was the one model surface that omitted the
shared tone contract, so it read colder than the daily briefing beside it.
Compose the tone fragment into both prompts and warm the hand-written tone
line to name a genuine win when the period earns it, while keeping the
descriptive-never-causal and no-alarm guards intact. The cross-surface
coverage test now asserts the contract reaches this surface too.
2026-06-26 12:29:43 +02:00
Marc-André Bombeck 5b406d58cd fix(insights): close the deterministic score assessment with one grounded step
The per-score deterministic text named the score, its standing, and the
weakest contributor, then stopped at the diagnosis. When the band is not
green and the weakest driver is behaviourally addressable (sleep, mood,
consistency, timing), append a single grounded pointer drawn from that same
contributor. Physiology-only drivers add nothing, so the text affirms and
watches rather than manufacturing a step.
2026-06-26 12:29:35 +02:00
Marc-André Bombeck b470a14187 fix(insights): ground deterministic status fallbacks in the user's own numbers
The no-key and timeout fallback text carried zero reference to the user's
own readings and, on a provider timeout, rendered as hasProvider:true —
indistinguishable from a fresh assessment. Compose each fallback from the
per-metric signal the card already builds: name the current value, place it
against the user's own baseline, and close with one plain-language pointer,
in the same warm voice as the model surfaces. Degrade to the prior generic
tip only when a metric has no usable history. The timeout envelope now
reports hasProvider:false so the UI surfaces it as the computed summary it
is rather than mislabelling it as provider prose.
2026-06-26 12:29:29 +02:00
Marc-André Bombeck 9279b062d3 feat(coach): connect signals, confidence-ruler close, three-beat shape
Sharpen the Coach's behaviour-change reflexes from the coaching literature,
prompt-only:

- Connect the signals on a why/pattern question — consult correlations and
  link them descriptively ("after short-sleep nights your next-morning HRV
  tends to read lower") instead of reading metrics in isolation, always an
  association worth a small experiment, never a cause.
- Confidence ruler on action turns — after naming one small step, ask how
  doable it feels 0-10 and shrink it when that lands low, keeping the choice
  the user's.
- Three-beat shape on data-review turns — finding, likely driver, one small
  step; name the dominant contributor when citing a derived band.
- Extend the MI micro-moves with developing discrepancy and rolling with
  resistance, and add an anti-persuasion check: any suggested change must
  serve the user's own stated goal.

One bilingual tone-calibration example added per locale.
2026-06-26 12:24:43 +02:00
Marc-André Bombeck 34ed1a0c93 feat(coach): add acute red-flag escalation to the shared safety contract
The shared contracts cover chronic deferral (dose, diagnosis, drug-level
to a clinician) but had no acute branch. Add a closed-list crisis clause —
chest pain, syncope, sudden severe symptoms, hypertensive-crisis readings,
suicidal ideation — that points to prompt or emergency care without
diagnosing, and keep every other turn calm and non-alarmist. It rides the
single shared-contract source so it lands on the Coach, the per-metric
status cards, the comprehensive briefing, and the period narrative; the
coverage test asserts it on all four surfaces.
2026-06-26 12:22:32 +02:00
Marc-André Bombeck 86a9ca2e1e feat(profile): persist and expose the date-format preference
Accept dateFormat on the profile PATCH field-by-field, echo it from
the profile + /me reads, mirror it into the client auth user, and add
the field to the OpenAPI request/response schemas.
2026-06-26 12:15:09 +02:00
Marc-André Bombeck 37e539d0bf feat(format): resolve and apply the date-format preference
Add a date-order resolver + formatter mirroring the hour-cycle path:
AUTO defers to the active locale, DMY/MDY/YMD pin the field order
through a canonical Intl locale. Thread it through makeFormatters and
expose useDateFormatPreference from the i18n context, backed by the
same localStorage mirror as the time-format preference.
2026-06-26 12:14:56 +02:00
Marc-André Bombeck 5e701cebc0 test(insights): bracket the snapshot window floor with the call's own clock
The floor reads Date.now() inside buildComparisonSnapshotForUser, so it
lands in [before - 400d, after - 400d] relative to the test's brackets.
The prior upper bound used before - 400d, which fails when a millisecond
ticks between the bracket read and the snapshot's clock read.
2026-06-26 09:44:35 +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 ca1cf53d2c merge(FIX-E): v1.20.2 audit fixes 2026-06-26 09:28:43 +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 9bffa80950 merge(FIX-C): v1.20.2 audit fixes 2026-06-26 09:28:43 +02:00
Marc-André Bombeck a65dde00ce merge(FIX-B): v1.20.2 audit fixes 2026-06-26 09:28:43 +02:00
Marc-André Bombeck c304268a9e fix(ai): tag empty-reply and stream-failed provider throws with a sentinel status
The four AI clients threw bare Errors on an empty reply (and, for Codex, on a
stream-level failure) with no httpStatus, so the provider-chain classifier saw
the same null-status shape as a genuine transport failure (ECONNRESET, DNS,
timeout) and could not tell them apart in observability. Attach a sentinel
`httpStatus: 0` plus a `kind` discriminator (`empty_response` /
`stream_failed`) on those throws. The cascade is unchanged:
`isHardProviderFailure` already treats a status <= 0 as a hard failure and
walks to the next provider — this only sharpens the logs.
2026-06-26 09:27:42 +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 e023571a08 chore(labs): tidy OCR capability enum, Fitbit key delimiter, Coach docblock
Drop the unreachable text-only-model reason from the OCR capability
enum and its DTO type. Replace the literal NUL delimiter in the Fitbit
dedup map keys with a named constant so text tooling stops treating the
file as binary (behaviour unchanged). Correct the Coach tool-executor
docblock, which overstated cross-tool snapshot-cache sharing.
2026-06-26 09:27:05 +02:00
Marc-André Bombeck fb54b46034 fix(labs): reserve a proportionate budget for text-mode OCR
Text-mode OCR structures browser-extracted text rather than running a
vision call, so it reserved and reconciled the full vision budget even
though the spend is far lower. Add an ocrExtractText ceiling and reserve
that in text mode, and refund the reservation in full on a clean extract
failure instead of charging the reserved estimate.
2026-06-26 09:26:47 +02:00
Marc-André Bombeck e1e1b0e985 fix(notifications): treat channel config decrypt/parse failure as a hard reject
A channel whose stored config cannot be decrypted or JSON-parsed is a
structurally-permanent failure, not a transient blip: the ciphertext/key
pairing and the malformed body decode the same way on every retry. Routing
it through the transient path burned the 5-failure backoff budget and ended
on the misleading `give_up_after_5_failures` reason — during a key-rotation
gap every channel hit this and auto-disabled for the wrong reason.

Classify decrypt and parse failures as hard rejects so the channel
auto-disables on first occurrence with the precise reason
(`<type>_config_decrypt_failed` / `<type>_config_parse_failed`). Genuine
transient failures (network, timeout, 5xx) keep the existing backoff path.
2026-06-26 09:26:31 +02:00
Marc-André Bombeck 672e3f0e07 fix(rollups): stabilise composed regression via centered identities
Reformulate composeRegression from the determinant form (n*Sxx - Sx^2)
to the mean-centered corrected-sum identities (Sxx = Sxx - Sx^2/n, ...).
Algebraically identical to the prior form and to Postgres REGR_* /
STDDEV_POP, but it subtracts the x mean at matched scale and avoids the
catastrophic cancellation the determinant form suffers on the ~1e10
epoch-day x-axis. Also read the exact stored sum_value accumulator for
the Sy term instead of reconstructing it as mean*count, dropping the
AVG-to-multiply float residual; fall back to mean*count when the column
is null.

Composition-only: no migration, no schema change. The WEIGHT live-parity
case stays bit-identical at the 1e-9 relative bound. The near-flat DST
parity seed improves by ~30x but still exceeds 1e-9, so it remains
quarantined with an updated note pointing at a window-local x-rescale as
the real fix.
2026-06-26 09:25:12 +02:00
Marc-André Bombeck 3e4cc53ede fix(ai): exclude text-only o-series variants from vision capability
The o1/o3/o4 prefix match also caught o1-mini, o1-preview and o3-mini,
which have no image input. A user pinned to one was told OCR works, then
the provider rejected the image block. Treat an o1/o3/o4 slug as
vision-capable only when it is not a -mini or -preview variant, applied
to both the OpenAI and Codex gates.
2026-06-26 09:21:54 +02:00
Marc-André Bombeck e4c0af0970 fix(illness): gate fever red-flag runs on calendar-adjacent days
Symptom and vital points are present-days-only, so a sparse febrile
series (e.g. days 10/15/20/25) was counted as one long run and falsely
fired the sustained_fever escalation. Continue a run only when the next
matching point is the calendar day immediately after the previous one,
using the existing dayDiff helper; otherwise start a fresh run of one.
2026-06-26 09:21:49 +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 2c112c3323 fix(dashboard): stop the chart-row render loop on tab resume
A user reported the dashboard crashing on returning to a backgrounded
tab. The on-focus snapshot refetch re-rendered the page, and each chart
received a fresh inline `() => markChartReady(id)` handler every render;
the chart's data-ready notify effect, keyed on that ever-changing prop,
re-fired on every commit. That per-commit passive effect kept the chart
row's anchored popovers re-committing until the browser tripped its
render-depth guard and the page fell back to its error card.

Latch the notify behind a ref so it fires once, when the chart's initial
query settles, and depend on the load flag alone. The handler is read
through a ref so a late-arriving callback still fires without re-arming
the effect. Extract the pure fire-once decision into a small helper with
direct unit coverage (no DOM runner, per project convention).
2026-06-23 21:45:42 +02:00
Marc-André Bombeck b36671d8c6 refactor(integrations): drop the unused RotatedTokens type
persistRotatedToken takes the fresh access token directly; the interface
was never wired in. Removing it clears the dead-code gate.
2026-06-22 08:43:14 +02:00
Marc-André Bombeck d3b341f8a7 Merge AI client + coach cache fixes into release/v1.20.0
Send the OpenAI JSON response format only when JSON is requested and no
tools are in play, so the Coach prose path (and the tool loop's final
round) is no longer corrupted into a JSON blob. Resolve the Anthropic
finish reason to tool_calls whenever tool_use blocks are present. Memoize
the per-request feature extraction and prefs read and project the feature
query, so the coach tool fan-out shares one read instead of six.
2026-06-22 08:19:12 +02:00
Marc-André Bombeck 600498da12 perf(coach): share the heavy per-user reads across the tool-snapshot fan-out
The F1 coach tools each rebuild a snapshot with a distinct single-source
scope, so the 60s snapshot LRU (keyed on the source list) never shares their
reads. On a multi-metric turn the model can fan out up to six tools that each
fire `extractFeatures` (a user lookup plus a windowed measurement read plus the
all-time extremes) and a prefs `findUnique` concurrently — up to twelve heavy
reads against the shared Prisma pool at once.

Memoise both reads on the request-scoped event cache, keyed on userId (and
windowDays for the features read), so the fan-out collapses to one of each
within a request while staying correct across requests. Project the feature
extractor's measurement read to the six columns its consumers actually use
(type, value, measuredAt, sleepStage, source, deviceType) so the 200k-row-class
PULSE / glucose windows stop pulling every column. Outputs are unchanged.
2026-06-22 08:18:13 +02:00
Marc-André Bombeck c3c90c5117 Merge integration tz + oauth-refresh fixes into release/v1.20.0
Parse Fitbit's offset-less classic sleep/activity wall-clock strings
against the user's timezone (DST-safe) so a non-UTC user's segments land
on the right wake-day. Add a shared rotated-refresh-token persist that
compare-and-swaps on the stored ciphertext and reuses a peer's rotated
token on a lost race, so concurrent syncs across Fitbit, WHOOP and Oura
no longer park a connection at a spurious reauth.
2026-06-22 08:16:55 +02:00
Marc-André Bombeck 0f5e563403 fix(integrations): make rotating-refresh persistence race-safe
Fitbit, WHOOP, and Oura all issue one-time-use refresh tokens. The token
persistence did an unguarded read → refresh → write, so two overlapping
syncs for one user both read the same stored refresh token and both POST
it. The provider honours only the first; the loser's refresh comes back 401,
gets classified as reauth, and the connection is parked at `error_reauth`
spuriously even though the peer rotated the token successfully.

Add a shared `persistRotatedToken` helper that writes the rotated pair with
a compare-and-swap on the stored ciphertext (matched verbatim, since AES-GCM
re-encryption of the same plaintext differs byte-for-byte). When the
conditional update touches zero rows a peer already won the race; re-read the
row and reuse the peer's freshly rotated access token instead of erroring.

Wire it into all three providers — Fitbit and WHOOP `getValidToken`
(proactive expiry refresh, keyed by connection id) and Oura `storeOuraTokens`
(reactive 401 refresh, keyed by user id). Both racing callers now end with a
usable token and neither drops the connection.
2026-06-22 08:16:04 +02:00
Marc-André Bombeck b6f0ad61cb fix(ai): treat Anthropic tool_use blocks as authoritative for finishReason
The Anthropic client derived `finishReason` solely from `stop_reason`. The
Coach tool loop continues only when `finishReason === "tool_calls"`, so if
Anthropic ever returned tool_use blocks under a different (or absent)
stop_reason, the reason resolved to undefined, the loop dropped the tool
request, and the empty tool_use-only reply surfaced as the final answer —
the requested data was never fetched.

Set `finishReason` to "tool_calls" whenever tool_use blocks are present,
clamped ahead of the stop_reason map, so the presence of a tool request is
authoritative regardless of the reported stop reason.
2026-06-22 08:12:11 +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 b5f0857a14 Merge illness correlation fixes into release/v1.20.0
Sort the fever red-flag points chronologically before the consecutive-run
scan so a dual-source episode (thermometer plus journaled fever) can no
longer falsely escalate or hide a sustained-fever flag. Treat a trailing
out-of-band tail shorter than the stability window as transient noise so
a settled vital keeps its recovery day; only a sustained re-deviation
clears it.
2026-06-22 08:11:05 +02:00
Marc-André Bombeck b31def38c0 fix(illness): tolerate a transient trailing reading in the recovery return
The last-sustained-return walk nulled the recovery day whenever the final reading
sat out of band, even after a vital had settled for several days. A lone noisy
sample on the last logged day — common in resting-HR and HRV curves — then erased
a real recovery gap that had previously been reported.

Treat a trailing out-of-band tail shorter than the stability window as transient
noise and keep the settle day; only a sustained re-deviation (a tail at least as
long as the stability window, a genuine relapse with no re-settle) clears the
return. Apply the same noise tolerance to the symptom-burden track for parity.
Cover the trailing-outlier, genuine-relapse-then-re-settle, and
sustained-no-re-settle cases.
2026-06-22 08:10:10 +02:00
Marc-André Bombeck 68c67e4700 fix(fitbit): anchor offset-less sleep timestamps to the user timezone
The classic 1.2 sleep log and the activities list emit local wall-clock
ISO strings without an offset. `parseLocalInstant` parsed them with a bare
`new Date(iso)`, which reads an offset-less string in the process zone (UTC
in production). A non-UTC user's sleep-segment `measuredAt` (the segment
end) was therefore shifted by their UTC offset, and a near-midnight segment
could flip onto the wrong wake-day in the night reconstruction.

Resolve offset-less timestamps against the user's stored timezone via a new
`zonedWallClockToUtc` helper (the inverse of `wallClockInTz`, DST-safe via
the same two-pass converge as `startOfLocalDayInTz`). Thread the resolved
zone through the sleep and workout sync paths. Strings that already carry an
offset or `Z` stay absolute and parse as-is; an absent zone keeps the prior
host-local fallback.
2026-06-22 08:09:57 +02:00
Marc-André Bombeck 21f6dcfe13 observability(rollups): surface the pending-backfill regression miss
The windowed slope / r2 / sd readers compose from the per-bucket
regression accumulators (migration 0190). Between a deploy and the
boot-backfill completing, a tenant's pre-0190 DAY buckets still carry
NULL accumulators, so composeWindowedRegression returns a full miss and
the slim / comprehensive readers emit a null slope. The null is the
honest answer until the backfill refills the accumulators, but the miss
was passing as a plain rollup read with no signal, against the no-silent-
cap rule.

Add hasPendingAccumulatorBackfill to the read tier and key a new
regression_source annotation off it (unavailable_pending_backfill vs
rollup) on both warm paths. No live-SQL fallback is added; the design
intentionally avoids the heavy scan.
2026-06-22 08:07:58 +02:00
Marc-André Bombeck c8ebdba43e fix(illness): sort the fever red-flag points before the run scan
The fever escalation unions two individually-sorted sources — the passive
BODY_TEMPERATURE series and the day-log feverC — into a Map, then feeds the
entries to the consecutive-run scanner. Map iteration is insertion order, not
chronology, so an episode with both a thermometer feed and journaled fever read
the run out of day order: a broken fever could read as a sustained run (a false
seek-care escalation) or a genuine run could be hidden (a missed one).

Sort the unioned points by day before the scan. Cover both directions with
interleaved-source fixtures.
2026-06-22 08:06:26 +02:00
Marc-André Bombeck 5c2d3f12cb Merge Coach tool-based retrieval into release/v1.20.0
Replace the Coach snapshot-stuffing with a small base context plus a data
inventory and six on-demand retrieval tools (metric series, glucose,
sleep, medication compliance, labs, illness recovery) the model calls as
needed, then answers. Cuts first-turn input ~35-65% while preserving
grounding and every gate; tools return a structured present:false rather
than throwing, so the model cites only what it fetched. Falls back to the
snapshot path when a provider lacks tool support.
2026-06-22 07:38:18 +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 ec77862286 feat(coach): expose structured snapshot sections + tool-trace provenance
The Coach snapshot builder returns the structured, post-degrade record it
serialises to JSON alongside the JSON string, so a single domain block can be
read out without re-parsing. Add an optional tool-call trace to the provenance
envelope (which retrieval tools ran + whether each found data) and mirror it in
the OpenAPI provenance schema.

Groundwork for on-demand retrieval: the retrieval tools slice one section out
of this record, reusing the builder's gates and I/O verbatim.
2026-06-22 07:37:15 +02:00