Commit Graph
12 Commits
Author SHA1 Message Date
Marc-André Bombeck 7f9aeba918 perf(insights): fold correlation-discovery channels into the status input gate
The slow-moving status input fingerprint covered the card's own metric
rows plus mood and height, but not the FDR discovery channels that also
feed the prompt via getRelevantCorrelationsForMetric. A correlation
newly surfacing off a behaviour channel (e.g. daylight -> next-day
weight) left the card's own rows untouched, so the input gate re-stamped
the stale assessment and the discovery never reached the prose.

computeStatusInputFingerprint gains includeCorrelationChannels: it widens
the same grouped probe by the discovery behaviour + outcome channels
(deduped, mood excluded as it is mood-entry backed), so a channel change
flips the hash. Wired into weight-status, the one caller that folds a
relations block.
2026-06-21 10:23:04 +02:00
Marc-André Bombeck 7a5f305a3a perf(insights): gate weight and BMI status on a cheap input fingerprint
Weight and BMI move on a weekly cadence, but the per-metric generators
rebuilt the full snapshot (bounded findMany + per-series rollup reads +
correlation math) every day and relied on the post-build content-hash gate
to skip only the model call. Add an input gate that runs first: a single
grouped probe over the salient inputs (per-type count + newest reading, plus
mood for weight and the profile height for BMI) fingerprints what the
snapshot would read. On a non-forced run whose fingerprint matches the cached
row, the cached assessment is re-stamped under today's key and the whole
build is skipped. A miss falls through to the normal build and the existing
post-build gate.

Consent is checked before any re-date so a revoked-consent user never has
stale text presented as current. Forced runs never gate but still record a
fresh fingerprint for the next day.
2026-06-21 00:56:07 +02:00
Marc-André Bombeck f9876f9492 feat(consent): mint AI consent receipt from the web client
The server-side gate requires an active ConsentReceipt before any health
snapshot egresses via the operator's server-managed key, but the web
client never minted one — so web users on a shared-key deployment hit the
fail-closed no-key fallback on every AI surface.

Add ensureWebAiConsentReceipt() and POST /api/consent/ai/web: an
idempotent ai_full grant mirroring the iOS master grant (a user with an
active receipt is a no-op). The AI-settings card calls it on mount — the
web equivalent of the iOS shell-mount heal — so existing accounts gain a
receipt without a re-consent step.

Surface an honest signal: resolveReadOnlyStatusMiss now returns a
consent-missing outcome when a provider is configured but the gate would
block egress, distinct from no-provider, and skips the wasted enqueue.

Document the receipt precondition + the consent surface in the OpenAPI
contract (it shipped without a bump): the /api/consent/ai family, a
Consent tag, and a 403 consent.ai.required response on the AI-egress
route.
2026-06-14 10:25:18 +02:00
Marc-André Bombeck 6bfba2f187 chore: drop the orphaned locale-prefix helper
The cross-locale status-cache eviction is gone, taking the prefix
builder's last caller with it.
2026-06-11 23:29:09 +02:00
Marc-André Bombeck f790e14edd fix(api,lib): consent before reuse, tighter inputs and caches that tell on themselves
Per-status texts check AI consent before the unchanged-data refresh can
re-stamp them, the comprehensive fingerprint folds in the comparison
baseline and generation language so toggling either regenerates once,
and profile, threshold and source-priority writes evict the analytics,
targets and derived caches they feed. CSV export neutralises spreadsheet
formula prefixes on free-text cells while numeric columns stay typed,
the derived batch validates its type token against the measurement enum
and carries a per-user rate limit, and a dose synced from the phone app
counts as the interactive write it is. Background cache refresh
failures log a structured event instead of vanishing, the warm queue
runs visit-triggered jobs alongside the nightly walk, the formatter
memo evicts one entry instead of flushing wholesale with its option
objects frozen, the targets cold build bounds its fan-out, and time in
bed unions every writer's envelope while stages stay with the richest
recorder.
2026-06-11 23:20:38 +02:00
Marc-André Bombeck 23f7c74c04 perf(insights): regenerate texts only when the underlying data changed
A sha256 fingerprint of the compacted feature snapshot is stored beside
every generated text; nightly, forced and ingest-triggered regeneration
all skip the model call when the fingerprint matches and refresh the
timestamp instead. Warms cover only the user's resolved locale and the
blanket per-status eviction after a comprehensive generation is gone —
a lazily-read second locale regenerates on its own miss path. The
ingest debounce widens from thirty minutes to six hours and no longer
deletes cache rows, the visit-triggered warm re-checks freshness at job
start, backs off for an hour after a failure and is capped at two
forced runs per user per day, and its dedup window now outlives the
client poll horizon. An active day drops from hundreds of model calls
to a handful tied to actual data change.
2026-06-11 21:17:05 +02:00
Marc-André Bombeck b13721cc97 fix(insights): stop a timed-out warm from evicting its own freshly-warmed caches
The on-demand force-warm bounds the comprehensive briefing with a 45 s
timeout and then warms the per-status + generic-metric caches itself. The
timeout could not cancel the underlying generation, so a comprehensive that
resolved after the timeout reached its own per-status eviction and deleted
the rows the warm passes had just written — leaving the user back on a cold
cache.

Thread an AbortSignal into the comprehensive generation and abort it when the
timeout fires; the generation now bails out before its write + evict once the
signal is aborted, so a late resolve can never undo the warm.

Also align the force-warm gating with the route: the route admits the job on
the per-user assessment surface, so warm the per-status + generic-metric
cards whenever that surface is enabled, even when the global briefing switch
is off. Only the comprehensive briefing stays gated on the briefing switch.

Factor the per-status cache-action shape into a single helper so the key is
built in one place rather than hand-assembled across the generators, the
eviction sweep, and the ingest debounce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 75e0b5a9129893915f7a213ec96253df19341d15)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck 3694efe6e8 fix(insights): keep the assessment card polling while a stale text revalidates
The stale-while-revalidate path serves the last-good assessment on a cache
miss and returns a terminal payload, so the status hook stopped polling. The
worker warmed a fresh assessment out of band seconds later, but the open card
never picked it up — the stale text stuck until the page remounted.

Add a `revalidating` flag to the read-only miss outcome, set only when
last-good text is served AND a refresh was enqueued (false on the
suppressed-stub branch and when no prior text exists). Thread it through the
generic metric-status generator and the seven specialised generators onto the
wire, type it on the client, and make `nextStatusPollInterval` keep polling on
`preparing || revalidating`. The existing 12-attempt ceiling bounds both
signals, so a persistently failing generation still cannot poll forever.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c9217534fbdb304c4df1b6eb55fb19e4360b8a10)
2026-06-02 16:45:14 +02:00
Marc-André Bombeck e33911e2d4 feat(insights): generic per-metric assessment coverage for HealthKit metrics
Extend the assessment-card system beyond the seven specialised scopes to
the ~30 HealthKit metric pages, without hand-writing a prompt per metric.

- Add a metric-metadata registry keyed by HealthKit id (display name,
  unit, direction, optional normal range, archetype, scope), covering the
  data-bearing HealthKit metrics. The seven specialised metrics keep
  their own scopes and are excluded from the generic set; an unregistered
  id gets no card.
- Add a generic data-driven generator that reuses the existing graded
  snapshot, provider runner, stale-while-revalidate cache, and persist
  path. One prompt template per archetype (physiological-vital,
  activity-fitness, body-composition, mobility-gait,
  environmental-exposure) plus a dedicated sleep template, parameterised
  by metric metadata. Empty-data metrics return an insufficient marker
  with no provider call. Normal ranges use age + sex where the profile
  stores them; no ancestry/region ranges.
- Add GET /api/insights/metric-status?metric=<id>: apiHandler-wrapped,
  Zod-validated against the closed registry id set (422 on unknown),
  read-only with stale-while-revalidate. First insights route with
  OpenAPI coverage; regenerate docs/api/openapi.yaml.
- Route the generic metric:<id> scope through the on-demand status queue
  worker dispatch.
- Extend the nightly warm pass to the generic metric caches for a user's
  data-bearing metrics only (one distinct read gates the set), keeping
  de+en warm and never calling a provider for an empty metric.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 41453fbdfee60793c16269b9a9e5f04b6c3ea450)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck 60f347bf8d feat(insights): serve assessments stale-while-revalidate on a cache miss
A category open whose today-cache had been evicted dropped to the
preparing skeleton even when a perfectly good assessment from an earlier
day was still on record. Read the most recent non-stub assessment for the
scope and serve it instantly while the worker re-warms the cache; only
fall back to the preparing state when no assessment was ever produced.
The user no longer waits on an open if an assessment ever existed.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck 35800becbb fix(insights): make per-category status cards read-only so navigation never blocks on the LLM
Opening an Insights category page froze the whole UI: the per-metric
status card fired a cold, uncapped LLM fetch on mount with no client
abort, so the request hung up to the server's 60s ceiling and the
pending/resolve re-renders thrashed the tab strip and ate touch events.
Timeouts were never negatively cached, so every visit re-blocked.

Fix the class holistically:

- Bound every status GET on the client. The shared use-insight-status
  hook and the inline medication-compliance query now wrap fetch in an
  8s AbortController and stop retrying inline; navigation can no longer
  await an uncapped round-trip.

- Make the status routes read-only. On a cache miss the generator
  enqueues an out-of-band pg-boss job (insight-status-generate) and
  returns a preparing envelope; the client polls a bounded interval
  until the worker warms the cache. Models the dashboard snapshot's
  ready/preparing pattern. The queue is registered in the worker
  allQueues loop + a boss.work handler, with a guard test.

- Fix the nightly warm-pass regression: warm every supported locale
  (the client reads against the active UI locale, not the persisted
  User.locale) and warm on a cached comprehensive outcome too (its
  write still evicts the per-status caches). Add a short-TTL negative
  cache on the timeout path so a transient provider stall no longer
  makes every subsequent visit re-block or re-enqueue.

- Add loading.tsx for the insights segment + the root so any future
  RSC await streams a skeleton instead of blocking the transition.

- Stabilise the advisor regenerate callback so a status-query flip
  can't re-render the memoised tab strip mid-gesture.

New i18n key insights.assessmentPreparing across all six locales.
2026-06-01 08:59:59 +02:00
Marc-André Bombeck 5432063f5c fix(insights): stop caching the timeout fallback and align the status timeout with the coach
The per-card status path capped provider generation at 20 s and, on
timeout, persisted the generic no-key fallback as a day-keyed cache row.
The cache-read only checked the day key and text, so that stub looked
like a real assessment and stuck until midnight — hiding the real,
data-driven text for the rest of the day on any account that hit a
single transient stall.

Align the budget with the provider clients' 60 s ceiling, route the
status path through resolveProviderChain + runRawCompletionWithFallback
so a degraded provider cascades like the coach, and stop persisting the
fallback: a timeout or error is a transient miss that serves the
fallback for the current render and re-attempts on the next mount. A
shared cache-read rejects any lingering timeout-stub rows by their
markers, and withTimeout now annotates real errors instead of folding
them silently into the timeout path.
2026-05-31 21:51:18 +02:00