Merge pull request #358 from MBombeck/release/v1.20.0

v1.20.0 — Coach on-demand retrieval, deeper recovery insight, Fitbit, leaner data tier
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-22 11:05:17 +02:00
commit 20a15dade2
122 files changed
+9757 -3641

No files matched your search

+13
View File
@@ -50,6 +50,19 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# WHOOP_WEBHOOK_SECRET="<openssl rand -hex 32>"
# WHOOP_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Fitbit -- optional integration (classic Web API, per-user BYO-keys)
# -----------------------------------------------------------------------------
# Each user registers their own app at https://dev.fitbit.com (self-serve, no
# brand verification) and pastes the OAuth Client ID + Client Secret into
# Settings (stored encrypted in the DB). There is therefore no
# FITBIT_CLIENT_ID/SECRET here. The OAuth flow uses Authorization Code + PKCE.
#
# FITBIT_REDIRECT_URI overrides the derived
# `${NEXT_PUBLIC_APP_URL}/api/fitbit/callback` when the public URL differs; it
# must exactly match the Callback URL registered in the dev.fitbit.com app.
# FITBIT_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Polar AccessLink -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
+2 -2
View File
@@ -12,9 +12,9 @@ jobs:
if: github.actor == 'dependabot[bot]'
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+2 -2
View File
@@ -44,9 +44,9 @@ jobs:
NODE_ENV: production
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
+2 -2
View File
@@ -25,10 +25,10 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+2 -2
View File
@@ -14,9 +14,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
+2 -2
View File
@@ -16,9 +16,9 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Scan src/ for TODO/FIXME/XXX/HACK markers
run: |
+6 -6
View File
@@ -15,9 +15,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@@ -66,9 +66,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@@ -91,7 +91,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
@@ -106,7 +106,7 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Build Docker image
run: docker build -t healthlog:scan .
+33
View File
@@ -2,6 +2,39 @@
## [Unreleased]
## [1.20.0] — 2026-06-22 — Coach on-demand retrieval, deeper recovery insight, Fitbit, and a leaner data tier
A feature release. The Coach fetches what it needs on demand instead of carrying a full snapshot; the recovery-gap learns from the symptom journal and sleep; Fitbit connects over its own Web API; the trend tier serves slope and variability without a live scan; and several built surfaces become discoverable. Two additive migrations (`0190`, `0191`); no breaking changes.
### Added
- The Coach answers from on-demand retrieval: a small base context plus a data inventory, with tools it calls to pull the exact series, lab, sleep, medication or recovery figures a question needs. It cites only what it fetched and says so plainly when there is no data. First-turn cost drops substantially while the grounding and cross-metric reasoning are unchanged.
- The recovery-gap reads the illness journal's own symptom-severity curve and adds a sleep-context line, and it is now relapse-aware — it reports the final sustained return, not the first.
- Fitbit connects experimentally over the Fitbit Web API with PKCE, so a self-hoster can link it with an instant developer app.
- The sleep, steps and glucose dashboard tiles and the preventive-care row are surfaced by default (each self-hides without data), so built features are easier to find.
### Changed
- The AI client layer moves to a structured message format with per-provider prompt-caching, which lowers cost on repeated context and lays the groundwork for retrieval.
- Trend slope, r² and standard deviation are served from the rollup tier via stored regression accumulators instead of a live scan on every request; the result matches the live computation.
### Fixed
- The fever red-flag now reads days in chronological order, so an episode logged from both a thermometer and the journal can no longer raise or hide a sustained-fever flag incorrectly.
- A vital that settles and then shows a single stray out-of-range reading keeps its recovery day instead of reporting none.
- Fitbit sleep segments are placed on the correct day for users outside UTC.
- Concurrent syncs for Withings/WHOOP/Oura/Fitbit no longer park a connection at a spurious reconnect when a one-time-use refresh token is rotated.
- The Coach reply is no longer corrupted into raw JSON on some providers, and a tool request is no longer dropped on an unexpected stop reason.
- The trend tier records when slope is briefly unavailable pending backfill rather than reporting it silently.
### Refactor
- The Coach's per-request reads are shared across the retrieval fan-out instead of repeated per tool.
### Security
- Dependency bumps: next, pg-boss, radix-ui, pg, @testcontainers/postgresql, and the CI actions.
## [1.19.2] — 2026-06-21 — Recovery-gap symptom signal, resilience tile, long-range charts, richer device + Telegram data
A follow-up that folds the illness journal's own symptom curve into the recovery-gap, surfaces Oura resilience, fixes multi-year chart ranges, and widens the heart-rate and Telegram data paths. Two additive migrations (`0188`, `0189`); no breaking changes.
+1 -1
View File
@@ -4,7 +4,7 @@ Working notes for anyone (human or otherwise) editing this repository. Skim befo
## What HealthLog is
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.18.x.
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.20.x.
## Voice and privacy
+1 -1
View File
@@ -33,7 +33,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
## Highlights
- **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set.
- **Your devices, one server.** Withings and WHOOP sync over OAuth2, Google Health/Fitbit connects experimentally, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Fitbit connects experimentally over the Fitbit Web API, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements, with proactive check-ins. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network.
- **Clinician-ready output.** A doctor-report PDF generated client-side, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
+46 -2
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.19.2
version: 1.20.0
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -16272,6 +16272,21 @@ components:
- label
- value
additionalProperties: false
toolCalls:
description: "v1.20.0 — the retrieval-tool trace for this turn: which tools the Coach called and whether each found
data. Metadata only (no values). Absent on the legacy snapshot path and on turns that called no tools."
type: array
items:
type: object
properties:
name:
type: string
present:
type: boolean
required:
- name
- present
additionalProperties: false
required:
- windows
- metrics
@@ -20464,6 +20479,10 @@ components:
type: array
items:
$ref: "#/components/schemas/IllnessRedFlag"
sleepContext:
anyOf:
- $ref: "#/components/schemas/IllnessSleepContext"
- type: "null"
required:
- episodeId
- preOnset
@@ -20474,10 +20493,12 @@ components:
- feltBetterDay
- gapDriverType
- redFlags
- sleepContext
additionalProperties: false
description: "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital
physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline
recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), and any red flags."
recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), any red flags, and an optional
sleep-as-context observation (`sleepContext`, never in the gap)."
IllnessVitalDeviation:
type: object
properties:
@@ -20558,6 +20579,29 @@ components:
additionalProperties: false
description: A retrospective red-flag escalation (sustained low SpO2 or sustained fever) against absolute clinical
floors. Copy must escalate ('seek care if this recurs'), never reassure.
IllnessSleepContext:
type: object
properties:
baselineMeanMinutes:
type: number
episodeMeanMinutes:
type: number
deltaMinutes:
type: number
nightsCounted:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- baselineMeanMinutes
- episodeMeanMinutes
- deltaMinutes
- nightsCounted
additionalProperties: false
description: "A neutral sleep-as-context observation: the user's median asleep minutes over the well baseline vs the
mean over the episode window, and their signed delta (positive = slept more than usual). A pure OBSERVATION
surfaced alongside the gap — NEVER a recovery return and NEVER part of the headline recovery-gap. Withheld (the
field is null) on thin sleep data or a sub-floor delta."
IllnessInsightsEnvelope:
type: object
properties:
+16 -14
View File
@@ -4454,9 +4454,9 @@
"token": "Der Token-Austausch mit WHOOP ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
"fitbit": "Google Health",
"fitbit": "Fitbit",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Herzfrequenz und mehr.",
"fitbitDescription": "Verbinde Fitbit, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Herzfrequenz und mehr.",
"fitbitExperimentalBadge": "Experimentell",
"fitbitCredentials": "API-Zugangsdaten",
"fitbitClientId": "Client-ID",
@@ -4468,26 +4468,26 @@
"fitbitSync": "Jetzt synchronisieren",
"fitbitFullSync": "Alle Daten synchronisieren",
"fitbitFullSyncTitle": "Vollständige Synchronisierung?",
"fitbitFullSyncDescription": "Der gesamte verfügbare Google-Health-Verlauf wird vollständig synchronisiert. Das kann je nach Umfang deines Verlaufs einige Zeit dauern.",
"fitbitFullSyncDescription": "Der gesamte verfügbare Fitbit-Verlauf wird vollständig synchronisiert. Das kann je nach Umfang deines Verlaufs einige Zeit dauern.",
"fitbitSyncResult": "{count} Messungen synchronisiert",
"fitbitSyncFailed": "Synchronisierung fehlgeschlagen",
"fitbitSynchronize": "Synchronisieren",
"fitbitDisconnect": "Trennen",
"fitbitDisconnectTitle": "Google Health trennen?",
"fitbitDisconnectDescription": "Die Verbindung zu Google Health wird getrennt. Zuvor synchronisierte Daten bleiben erhalten.",
"fitbitConnect": "Mit Google Health verbinden",
"fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.",
"fitbitBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…",
"fitbitOauthConnected": "Google Health verbunden — die erste Synchronisierung startet im Hintergrund.",
"fitbitOauthFailed": "Google-Health-Verbindung fehlgeschlagen",
"fitbitDisconnectTitle": "Fitbit trennen?",
"fitbitDisconnectDescription": "Die Verbindung zu Fitbit wird getrennt. Zuvor synchronisierte Daten bleiben erhalten.",
"fitbitConnect": "Mit Fitbit verbinden",
"fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Fitbit zu verbinden.",
"fitbitBackfillInProgress": "Dein Fitbit-Verlauf wird im Hintergrund importiert…",
"fitbitOauthConnected": "Fitbit verbunden — die erste Synchronisierung startet im Hintergrund.",
"fitbitOauthFailed": "Fitbit-Verbindung fehlgeschlagen",
"fitbitOauthError": {
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
"state": "Die Antwort von Google passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
"state": "Die Antwort von Fitbit passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
"expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. Bitte starte die Verbindung erneut.",
"cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.",
"nocode": "Google hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Google-Dialog.",
"nocreds": "Google Health ist nicht konfiguriert. Hinterlege oben deine Google-OAuth-Zugangsdaten und versuche es erneut.",
"token": "Der Token-Austausch mit Google ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"nocode": "Fitbit hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Fitbit-Dialog.",
"nocreds": "Fitbit ist nicht konfiguriert. Hinterlege oben deine Fitbit-OAuth-Zugangsdaten und versuche es erneut.",
"token": "Der Token-Austausch mit Fitbit ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
"integrations": {
@@ -6891,6 +6891,8 @@
"recoveryGapLagSymptom": "Dir ging es {days} Tage besser, bevor deine erfassten Symptome abklangen.",
"recoveryGapLeadSymptom": "Deine erfassten Symptome klangen {days} Tage ab, bevor du Genesung vermerktest.",
"recoveryGapSame": "Deine Werte normalisierten sich am selben Tag, an dem es dir besser ging.",
"sleepContextMore": "Während dieser Episode hast du deutlich mehr geschlafen als sonst (etwa {hours} Std./Nacht).",
"sleepContextLess": "Während dieser Episode hast du deutlich weniger geschlafen als sonst (etwa {hours} Std./Nacht).",
"redFlagTitle": "Genauer ansehen",
"redFlagSpo2": "Deine Sauerstoffsättigung blieb mehrere Tage niedrig. Wenn das erneut auftritt, suche ärztliche Hilfe.",
"redFlagFever": "Du hattest anhaltend Fieber. Wenn das erneut auftritt, suche ärztliche Hilfe.",
+16 -14
View File
@@ -4454,9 +4454,9 @@
"token": "The token exchange with WHOOP failed. Please start the connection again.",
"generic": "Unknown error while connecting. Please start the connection again."
},
"fitbit": "Google Health",
"fitbit": "Fitbit",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch vitals — weight, heart rate, and more.",
"fitbitDescription": "Connect Fitbit to sync your Fitbit and Pixel Watch vitals — weight, heart rate, and more.",
"fitbitExperimentalBadge": "Experimental",
"fitbitCredentials": "API Credentials",
"fitbitClientId": "Client ID",
@@ -4468,26 +4468,26 @@
"fitbitSync": "Sync now",
"fitbitFullSync": "Sync all data",
"fitbitFullSyncTitle": "Full synchronization?",
"fitbitFullSyncDescription": "All available Google Health history will be fully synchronized. This may take some time depending on your history.",
"fitbitFullSyncDescription": "All available Fitbit history will be fully synchronized. This may take some time depending on your history.",
"fitbitSyncResult": "{count} measurements synchronized",
"fitbitSyncFailed": "Sync failed",
"fitbitSynchronize": "Synchronize",
"fitbitDisconnect": "Disconnect",
"fitbitDisconnectTitle": "Disconnect Google Health?",
"fitbitDisconnectDescription": "The connection to Google Health will be disconnected. Previously synced data will be preserved.",
"fitbitConnect": "Connect with Google Health",
"fitbitNoCredentials": "Please enter your API credentials above to connect Google Health.",
"fitbitBackfillInProgress": "Importing your Google Health history in the background…",
"fitbitOauthConnected": "Google Health connected — the first sync starts in the background.",
"fitbitOauthFailed": "Google Health connection failed",
"fitbitDisconnectTitle": "Disconnect Fitbit?",
"fitbitDisconnectDescription": "The connection to Fitbit will be disconnected. Previously synced data will be preserved.",
"fitbitConnect": "Connect with Fitbit",
"fitbitNoCredentials": "Please enter your API credentials above to connect Fitbit.",
"fitbitBackfillInProgress": "Importing your Fitbit history in the background…",
"fitbitOauthConnected": "Fitbit connected — the first sync starts in the background.",
"fitbitOauthFailed": "Fitbit connection failed",
"fitbitOauthError": {
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
"state": "The response from Google did not match any open connection request. Please start the connection again.",
"state": "The response from Fitbit did not match any open connection request. Please start the connection again.",
"expired": "The connection request expired before it completed. Please start the connection again.",
"cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
"nocode": "Google did not return an authorization code. Try again and allow access in the Google dialog.",
"nocreds": "Google Health is not configured. Add your Google OAuth credentials above and try again.",
"token": "The token exchange with Google failed. Please start the connection again.",
"nocode": "Fitbit did not return an authorization code. Try again and allow access in the Fitbit dialog.",
"nocreds": "Fitbit is not configured. Add your Fitbit OAuth credentials above and try again.",
"token": "The token exchange with Fitbit failed. Please start the connection again.",
"generic": "Unknown error while connecting. Please start the connection again."
},
"integrations": {
@@ -6891,6 +6891,8 @@
"recoveryGapLagSymptom": "You felt better {days} days before your logged symptoms eased.",
"recoveryGapLeadSymptom": "Your logged symptoms eased {days} days before you marked recovery.",
"recoveryGapSame": "Your numbers settled the same day you felt better.",
"sleepContextMore": "During this episode you slept noticeably more than usual (about {hours}h/night).",
"sleepContextLess": "During this episode you slept noticeably less than usual (about {hours}h/night).",
"redFlagTitle": "Worth a closer look",
"redFlagSpo2": "Your oxygen saturation stayed low for several days. If this recurs, seek medical care.",
"redFlagFever": "You ran a sustained fever. If this recurs, seek medical care.",
+16 -14
View File
@@ -4454,9 +4454,9 @@
"token": "El intercambio de token con WHOOP falló. Inicia la conexión de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
"fitbit": "Google Health",
"fitbit": "Fitbit",
"fitbitTag": "Fitbit y Pixel",
"fitbitDescription": "Conecta Google Health para sincronizar tus datos de Fitbit y Pixel Watch — peso, frecuencia cardíaca y más.",
"fitbitDescription": "Conecta Fitbit para sincronizar tus datos de Fitbit y Pixel Watch — peso, frecuencia cardíaca y más.",
"fitbitExperimentalBadge": "Experimental",
"fitbitCredentials": "Credenciales de API",
"fitbitClientId": "Client ID",
@@ -4468,26 +4468,26 @@
"fitbitSync": "Sincronizar ahora",
"fitbitFullSync": "Sincronizar todos los datos",
"fitbitFullSyncTitle": "¿Sincronización completa?",
"fitbitFullSyncDescription": "Se sincronizará por completo todo el historial disponible de Google Health. Esto puede tardar un tiempo según tu historial.",
"fitbitFullSyncDescription": "Se sincronizará por completo todo el historial disponible de Fitbit. Esto puede tardar un tiempo según tu historial.",
"fitbitSyncResult": "{count} mediciones sincronizadas",
"fitbitSyncFailed": "Error de sincronización",
"fitbitSynchronize": "Sincronizar",
"fitbitDisconnect": "Desconectar",
"fitbitDisconnectTitle": "¿Desconectar Google Health?",
"fitbitDisconnectDescription": "Se desconectará la conexión con Google Health. Los datos sincronizados previamente se conservarán.",
"fitbitConnect": "Conectar con Google Health",
"fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.",
"fitbitBackfillInProgress": "Importando tu historial de Google Health en segundo plano…",
"fitbitOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.",
"fitbitOauthFailed": "Error al conectar con Google Health",
"fitbitDisconnectTitle": "¿Desconectar Fitbit?",
"fitbitDisconnectDescription": "Se desconectará la conexión con Fitbit. Los datos sincronizados previamente se conservarán.",
"fitbitConnect": "Conectar con Fitbit",
"fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Fitbit.",
"fitbitBackfillInProgress": "Importando tu historial de Fitbit en segundo plano…",
"fitbitOauthConnected": "Fitbit conectado: la primera sincronización empieza en segundo plano.",
"fitbitOauthFailed": "Error al conectar con Fitbit",
"fitbitOauthError": {
"csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
"state": "La respuesta de Google no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
"state": "La respuesta de Fitbit no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
"expired": "La solicitud de conexión caducó antes de completarse. Inicia la conexión de nuevo.",
"cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
"nocode": "Google no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Google.",
"nocreds": "Google Health no está configurado. Añade tus credenciales OAuth de Google arriba e inténtalo de nuevo.",
"token": "El intercambio de token con Google falló. Inicia la conexión de nuevo.",
"nocode": "Fitbit no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Fitbit.",
"nocreds": "Fitbit no está configurado. Añade tus credenciales OAuth de Fitbit arriba e inténtalo de nuevo.",
"token": "El intercambio de token con Fitbit falló. Inicia la conexión de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
"integrations": {
@@ -6891,6 +6891,8 @@
"recoveryGapLagSymptom": "Te sentiste mejor {days} días antes de que cedieran tus síntomas registrados.",
"recoveryGapLeadSymptom": "Tus síntomas registrados cedieron {days} días antes de marcar la recuperación.",
"recoveryGapSame": "Tus valores se estabilizaron el mismo día en que te sentiste mejor.",
"sleepContextMore": "Durante este episodio dormiste notablemente más de lo habitual (unas {hours} h/noche).",
"sleepContextLess": "Durante este episodio dormiste notablemente menos de lo habitual (unas {hours} h/noche).",
"redFlagTitle": "Conviene revisarlo",
"redFlagSpo2": "Tu saturación de oxígeno se mantuvo baja varios días. Si se repite, busca atención médica.",
"redFlagFever": "Tuviste fiebre sostenida. Si se repite, busca atención médica.",
+16 -14
View File
@@ -4454,9 +4454,9 @@
"token": "L'échange de jeton avec WHOOP a échoué. Veuillez relancer la connexion.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
},
"fitbit": "Google Health",
"fitbit": "Fitbit",
"fitbitTag": "Fitbit et Pixel",
"fitbitDescription": "Connecte Google Health pour synchroniser tes données Fitbit et Pixel Watch — poids, fréquence cardiaque et plus.",
"fitbitDescription": "Connecte Fitbit pour synchroniser tes données Fitbit et Pixel Watch — poids, fréquence cardiaque et plus.",
"fitbitExperimentalBadge": "Expérimental",
"fitbitCredentials": "Identifiants d'API",
"fitbitClientId": "Client ID",
@@ -4468,26 +4468,26 @@
"fitbitSync": "Synchroniser maintenant",
"fitbitFullSync": "Tout synchroniser",
"fitbitFullSyncTitle": "Synchronisation complète ?",
"fitbitFullSyncDescription": "Tout l'historique Google Health disponible sera entièrement synchronisé. Cela peut prendre un certain temps selon votre historique.",
"fitbitFullSyncDescription": "Tout l'historique Fitbit disponible sera entièrement synchronisé. Cela peut prendre un certain temps selon votre historique.",
"fitbitSyncResult": "{count} mesures synchronisées",
"fitbitSyncFailed": "Échec de la synchronisation",
"fitbitSynchronize": "Synchroniser",
"fitbitDisconnect": "Déconnecter",
"fitbitDisconnectTitle": "Déconnecter Google Health ?",
"fitbitDisconnectDescription": "La connexion à Google Health sera déconnectée. Les données déjà synchronisées seront conservées.",
"fitbitConnect": "Se connecter à Google Health",
"fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Google Health.",
"fitbitBackfillInProgress": "Importation de votre historique Google Health en arrière-plan…",
"fitbitOauthConnected": "Google Health connecté — la première synchronisation démarre en arrière-plan.",
"fitbitOauthFailed": "Échec de la connexion à Google Health",
"fitbitDisconnectTitle": "Déconnecter Fitbit ?",
"fitbitDisconnectDescription": "La connexion à Fitbit sera déconnectée. Les données déjà synchronisées seront conservées.",
"fitbitConnect": "Se connecter à Fitbit",
"fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Fitbit.",
"fitbitBackfillInProgress": "Importation de votre historique Fitbit en arrière-plan…",
"fitbitOauthConnected": "Fitbit connecté — la première synchronisation démarre en arrière-plan.",
"fitbitOauthFailed": "Échec de la connexion à Fitbit",
"fitbitOauthError": {
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
"state": "La réponse de Google ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
"state": "La réponse de Fitbit ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
"expired": "La demande de connexion a expiré avant d'aboutir. Veuillez relancer la connexion.",
"cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.",
"nocode": "Google n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Google.",
"nocreds": "Google Health n'est pas configuré. Ajoutez vos identifiants OAuth Google ci-dessus et réessayez.",
"token": "L'échange de jeton avec Google a échoué. Veuillez relancer la connexion.",
"nocode": "Fitbit n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Fitbit.",
"nocreds": "Fitbit n'est pas configuré. Ajoutez vos identifiants OAuth Fitbit ci-dessus et réessayez.",
"token": "L'échange de jeton avec Fitbit a échoué. Veuillez relancer la connexion.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
},
"integrations": {
@@ -6891,6 +6891,8 @@
"recoveryGapLagSymptom": "Tu t'es senti mieux {days} jours avant que tes symptômes consignés s'apaisent.",
"recoveryGapLeadSymptom": "Tes symptômes consignés se sont apaisés {days} jours avant que tu notes la guérison.",
"recoveryGapSame": "Tes valeurs se sont stabilisées le jour même où tu t'es senti mieux.",
"sleepContextMore": "Pendant cet épisode, tu as dormi nettement plus que d'habitude (environ {hours} h/nuit).",
"sleepContextLess": "Pendant cet épisode, tu as dormi nettement moins que d'habitude (environ {hours} h/nuit).",
"redFlagTitle": "À examiner de plus près",
"redFlagSpo2": "Ta saturation en oxygène est restée basse plusieurs jours. Si cela se reproduit, consulte un médecin.",
"redFlagFever": "Tu as eu une fièvre persistante. Si cela se reproduit, consulte un médecin.",
+16 -14
View File
@@ -4454,9 +4454,9 @@
"token": "Lo scambio del token con WHOOP è fallito. Riavvia la connessione.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
"fitbit": "Google Health",
"fitbit": "Fitbit",
"fitbitTag": "Fitbit e Pixel",
"fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch — peso, frequenza cardiaca e altro.",
"fitbitDescription": "Collega Fitbit per sincronizzare i dati di Fitbit e Pixel Watch — peso, frequenza cardiaca e altro.",
"fitbitExperimentalBadge": "Sperimentale",
"fitbitCredentials": "Credenziali API",
"fitbitClientId": "Client ID",
@@ -4468,26 +4468,26 @@
"fitbitSync": "Sincronizza ora",
"fitbitFullSync": "Sincronizza tutti i dati",
"fitbitFullSyncTitle": "Sincronizzazione completa?",
"fitbitFullSyncDescription": "Tutto lo storico disponibile di Google Health verrà sincronizzato completamente. L'operazione può richiedere del tempo a seconda del tuo storico.",
"fitbitFullSyncDescription": "Tutto lo storico disponibile di Fitbit verrà sincronizzato completamente. L'operazione può richiedere del tempo a seconda del tuo storico.",
"fitbitSyncResult": "{count} misurazioni sincronizzate",
"fitbitSyncFailed": "Sincronizzazione non riuscita",
"fitbitSynchronize": "Sincronizza",
"fitbitDisconnect": "Disconnetti",
"fitbitDisconnectTitle": "Disconnettere Google Health?",
"fitbitDisconnectDescription": "La connessione a Google Health verrà disconnessa. I dati già sincronizzati saranno conservati.",
"fitbitConnect": "Connetti con Google Health",
"fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.",
"fitbitBackfillInProgress": "Importazione dello storico Google Health in background…",
"fitbitOauthConnected": "Google Health collegato — la prima sincronizzazione parte in background.",
"fitbitOauthFailed": "Connessione a Google Health non riuscita",
"fitbitDisconnectTitle": "Disconnettere Fitbit?",
"fitbitDisconnectDescription": "La connessione a Fitbit verrà disconnessa. I dati già sincronizzati saranno conservati.",
"fitbitConnect": "Connetti con Fitbit",
"fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Fitbit.",
"fitbitBackfillInProgress": "Importazione dello storico Fitbit in background…",
"fitbitOauthConnected": "Fitbit collegato — la prima sincronizzazione parte in background.",
"fitbitOauthFailed": "Connessione a Fitbit non riuscita",
"fitbitOauthError": {
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
"state": "La risposta di Google non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
"state": "La risposta di Fitbit non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
"expired": "La richiesta di connessione è scaduta prima di completarsi. Riavvia la connessione.",
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
"nocode": "Google non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Google.",
"nocreds": "Google Health non è configurato. Aggiungi le tue credenziali OAuth di Google qui sopra e riprova.",
"token": "Lo scambio del token con Google è fallito. Riavvia la connessione.",
"nocode": "Fitbit non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Fitbit.",
"nocreds": "Fitbit non è configurato. Aggiungi le tue credenziali OAuth di Fitbit qui sopra e riprova.",
"token": "Lo scambio del token con Fitbit è fallito. Riavvia la connessione.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
"integrations": {
@@ -6891,6 +6891,8 @@
"recoveryGapLagSymptom": "Ti sei sentito meglio {days} giorni prima che i sintomi registrati si attenuassero.",
"recoveryGapLeadSymptom": "I tuoi sintomi registrati si sono attenuati {days} giorni prima di segnare la guarigione.",
"recoveryGapSame": "I tuoi valori si sono stabilizzati lo stesso giorno in cui ti sei sentito meglio.",
"sleepContextMore": "Durante questo episodio hai dormito molto più del solito (circa {hours} h/notte).",
"sleepContextLess": "Durante questo episodio hai dormito molto meno del solito (circa {hours} h/notte).",
"redFlagTitle": "Da guardare più da vicino",
"redFlagSpo2": "La tua saturazione di ossigeno è rimasta bassa per diversi giorni. Se si ripete, rivolgiti a un medico.",
"redFlagFever": "Hai avuto febbre persistente. Se si ripete, rivolgiti a un medico.",
+16 -14
View File
@@ -4454,9 +4454,9 @@
"token": "Wymiana tokena z WHOOP nie powiodła się. Rozpocznij połączenie ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
"fitbit": "Google Health",
"fitbit": "Fitbit",
"fitbitTag": "Fitbit i Pixel",
"fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbit i Pixel Watch — wagę, tętno i więcej.",
"fitbitDescription": "Połącz Fitbit, aby synchronizować dane z Fitbit i Pixel Watch — wagę, tętno i więcej.",
"fitbitExperimentalBadge": "Eksperymentalne",
"fitbitCredentials": "Dane uwierzytelniające API",
"fitbitClientId": "Client ID",
@@ -4468,26 +4468,26 @@
"fitbitSync": "Synchronizuj teraz",
"fitbitFullSync": "Synchronizuj wszystkie dane",
"fitbitFullSyncTitle": "Pełna synchronizacja?",
"fitbitFullSyncDescription": "Cała dostępna historia Google Health zostanie w pełni zsynchronizowana. W zależności od historii może to chwilę potrwać.",
"fitbitFullSyncDescription": "Cała dostępna historia Fitbit zostanie w pełni zsynchronizowana. W zależności od historii może to chwilę potrwać.",
"fitbitSyncResult": "Zsynchronizowano {count} pomiarów",
"fitbitSyncFailed": "Synchronizacja nie powiodła się",
"fitbitSynchronize": "Synchronizuj",
"fitbitDisconnect": "Rozłącz",
"fitbitDisconnectTitle": "Rozłączyć Google Health?",
"fitbitDisconnectDescription": "Połączenie z Google Health zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.",
"fitbitConnect": "Połącz z Google Health",
"fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Google Health.",
"fitbitBackfillInProgress": "Importowanie historii Google Health w tle…",
"fitbitOauthConnected": "Google Health połączony — pierwsza synchronizacja startuje w tle.",
"fitbitOauthFailed": "Połączenie z Google Health nie powiodło się",
"fitbitDisconnectTitle": "Rozłączyć Fitbit?",
"fitbitDisconnectDescription": "Połączenie z Fitbit zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.",
"fitbitConnect": "Połącz z Fitbit",
"fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Fitbit.",
"fitbitBackfillInProgress": "Importowanie historii Fitbit w tle…",
"fitbitOauthConnected": "Fitbit połączony — pierwsza synchronizacja startuje w tle.",
"fitbitOauthFailed": "Połączenie z Fitbit nie powiodło się",
"fitbitOauthError": {
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
"state": "Odpowiedź z Google nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
"state": "Odpowiedź z Fitbit nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
"expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.",
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
"nocode": "Google nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Google.",
"nocreds": "Google Health nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Google i spróbuj ponownie.",
"token": "Wymiana tokena z Google nie powiodła się. Rozpocznij połączenie ponownie.",
"nocode": "Fitbit nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Fitbit.",
"nocreds": "Fitbit nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Fitbit i spróbuj ponownie.",
"token": "Wymiana tokena z Fitbit nie powiodła się. Rozpocznij połączenie ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
"integrations": {
@@ -6891,6 +6891,8 @@
"recoveryGapLagSymptom": "Poczułeś się lepiej {days} dni przed ustąpieniem zapisanych objawów.",
"recoveryGapLeadSymptom": "Twoje zapisane objawy ustąpiły {days} dni przed oznaczeniem wyzdrowienia.",
"recoveryGapSame": "Twoje wartości ustabilizowały się tego samego dnia, gdy poczułeś się lepiej.",
"sleepContextMore": "Podczas tego epizodu spałeś zauważalnie więcej niż zwykle (około {hours} godz./noc).",
"sleepContextLess": "Podczas tego epizodu spałeś zauważalnie mniej niż zwykle (około {hours} godz./noc).",
"redFlagTitle": "Warto przyjrzeć się bliżej",
"redFlagSpo2": "Twoje wysycenie tlenem pozostawało niskie przez kilka dni. Jeśli się powtórzy, zasięgnij pomocy medycznej.",
"redFlagFever": "Miałeś utrzymującą się gorączkę. Jeśli się powtórzy, zasięgnij pomocy medycznej.",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.19.2",
"version": "1.20.0",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -71,14 +71,14 @@
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.18.0",
"mmdb-lib": "^3.0.2",
"next": "16.2.6",
"next": "16.2.9",
"next-themes": "^0.4.6",
"nodemailer": "^9.0.1",
"p-limit": "^7.3.0",
"pg-boss": "^12.18.2",
"pg-boss": "^12.20.0",
"prisma": "^7.8.0",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"radix-ui": "^1.6.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-hook-form": "^7.78.0",
@@ -100,7 +100,7 @@
"@next/bundle-analyzer": "^16.2.6",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^12.0.1",
"@testcontainers/postgresql": "^12.0.3",
"@types/node": "^25.9.2",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.20.0",
@@ -115,7 +115,7 @@
"knip": "^6.16.1",
"lefthook": "^2.1.9",
"pdf-parse": "^2.4.5",
"pg": "^8.21.0",
"pg": "^8.22.0",
"prettier": "^3.8.4",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn": "^4.9.0",
+910 -726
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,66 @@
-- v1.20.0 F6 — rollup-tier completion: per-bucket regression accumulators.
--
-- Up to v1.19.x the windowed slope / r² / sd (slope7/30/90, r²_7/30/90,
-- the 90-day population stddev) still ran as a live `$queryRaw` over the
-- raw `measurements` partition on EVERY warm-rollup request, because the
-- per-bucket `mean` cannot reconstruct an OLS regression over the raw
-- points in a window. The per-bucket `sd / slope / r2` columns are a
-- within-bucket fold and are NOT linearly composable across DAY buckets.
--
-- This adds the four regression-accumulator sums Postgres' REGR_* /
-- STDDEV_POP folds expose, computed per (user, type, granularity,
-- bucketStart, source) over the same canonical-source rows the live
-- query reads. With `n = count` and `Σy = mean·count` already stored,
-- these four close the set: all six terms are ADDITIVE across buckets,
-- so a 7/30/90-day slope / r² / sd composes from the summed accumulators
-- with a bit-identical result to the live REGR_SLOPE / REGR_R2 /
-- STDDEV_POP over the raw rows (Postgres uses the same closed form):
--
-- slope = (n·Σxy − Σx·Σy) / (n·Σxx − Σx²)
-- r² = (n·Σxy − Σx·Σy)² / ((n·Σxx − Σx²)(n·Σyy − Σy²))
-- sd_pop = sqrt(Σyy/n − (Σy/n)²)
--
-- The x-axis is EPOCH DAYS (`EXTRACT(EPOCH FROM measured_at) / 86400.0`),
-- matching the live REGR_* calls in `measurement-rollups.ts`,
-- `summaries-slice.ts`, and `comprehensive-aggregator.ts`. The raw
-- (un-rebased) epoch-day sums are stored so the cross-bucket sum
-- reconstructs the same regression the live query runs.
--
-- Four additive, nullable `DOUBLE PRECISION` columns on
-- `measurement_rollups`:
--
-- * `sum_x` — Σ epoch_days
-- * `sum_xy` — Σ (epoch_days · value)
-- * `sum_xx` — Σ epoch_days²
-- * `sum_yy` — Σ value²
--
-- Nullable keeps the migration additive: existing rows read NULL until
-- the boot-time `rollup-full-backfill` re-fold refills them (mirrors the
-- v1.4.39 `sum_value` rollout). New writes self-populate because the
-- accumulators ride the same aggregate that already recomputes on every
-- write hook. The legacy per-bucket `sd / slope / r2` columns are left
-- in place (harmless) to keep this migration purely additive.
--
-- Additive; no existing row touched. Idempotent guards
-- (`ADD COLUMN IF NOT EXISTS`) so a rerun is safe on prod.
--
-- Reversibility (down):
-- ALTER TABLE "measurement_rollups" DROP COLUMN IF EXISTS "sum_x";
-- ALTER TABLE "measurement_rollups" DROP COLUMN IF EXISTS "sum_xy";
-- ALTER TABLE "measurement_rollups" DROP COLUMN IF EXISTS "sum_xx";
-- ALTER TABLE "measurement_rollups" DROP COLUMN IF EXISTS "sum_yy";
-- Dropping them reverts the readers to the live REGR_* path on a
-- coverage miss; the read-swap falls back automatically when the
-- accumulators are NULL.
ALTER TABLE "measurement_rollups"
ADD COLUMN IF NOT EXISTS "sum_x" DOUBLE PRECISION;
ALTER TABLE "measurement_rollups"
ADD COLUMN IF NOT EXISTS "sum_xy" DOUBLE PRECISION;
ALTER TABLE "measurement_rollups"
ADD COLUMN IF NOT EXISTS "sum_xx" DOUBLE PRECISION;
ALTER TABLE "measurement_rollups"
ADD COLUMN IF NOT EXISTS "sum_yy" DOUBLE PRECISION;
@@ -0,0 +1,8 @@
-- v1.20.0 — classic Fitbit Web API (api.fitbit.com) authorises with PKCE
-- (Authorization Code + S256). Carry the per-handshake `code_verifier` from the
-- connect route to the callback on the short-lived OAuth-state ledger row.
--
-- Additive + nullable: a legacy in-flight row (or any non-PKCE path) simply
-- carries no verifier, so this is reversible and rerun-safe with no data loss.
-- `IF NOT EXISTS` guards a prod rerun.
ALTER TABLE "fitbit_oauth_states" ADD COLUMN IF NOT EXISTS "code_verifier" TEXT;
+27 -4
View File
@@ -1292,6 +1292,20 @@ model MeasurementRollup {
/// to keep the migration additive — the boot backfill fills NULLs
/// for existing rows; new rows always carry the sum.
sumValue Float? @map("sum_value")
/// v1.20.0 F6 — per-bucket OLS regression accumulators over the same
/// canonical-source raw rows the live REGR_* query reads. With
/// `n = count` and `Σy = mean·count` these close the additive set so a
/// windowed slope / r² / sd composes from summed accumulators across DAY
/// buckets with a bit-identical result to live REGR_SLOPE / REGR_R2 /
/// STDDEV_POP. The x-axis is epoch days (`EXTRACT(EPOCH FROM
/// measured_at) / 86400.0`), stored un-rebased so the cross-bucket sum
/// reconstructs the live regression. Nullable to keep migration 0190
/// additive — the boot backfill fills NULLs for pre-migration rows; new
/// rows always carry them (they ride the same aggregate as mean / sum).
sumX Float? @map("sum_x")
sumXy Float? @map("sum_xy")
sumXx Float? @map("sum_xx")
sumYy Float? @map("sum_yy")
computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -2644,11 +2658,20 @@ model FitbitConnection {
/// v1.12.0 — short-lived `(nonce → userId)` ledger backing the Fitbit OAuth
/// state check, identical in shape + lifecycle to `WhoopOAuthState`.
///
/// v1.20.0 — the classic Fitbit Web API (`api.fitbit.com`) authorises with PKCE
/// (Authorization Code + S256), so the connect route mints a `code_verifier`
/// alongside the nonce and stashes it here; the callback reads it back to present
/// on the token exchange. Nullable + additive: a legacy row (or a non-PKCE path)
/// simply carries no verifier.
model FitbitOAuthState {
nonce String @id
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
nonce String @id
userId String @map("user_id")
/// PKCE code_verifier carried from connect to callback (v1.20.0). Null when no
/// PKCE handshake is in flight (legacy rows).
codeVerifier String? @map("code_verifier")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.19.2";
/* @sw-version-fallback */ "v1.20.0";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+30
View File
@@ -48,6 +48,10 @@ import { Buffer } from "node:buffer";
import pg from "pg";
import { encryptToBytes } from "../src/lib/ai/coach/bytes-codec";
import { encrypt } from "../src/lib/crypto";
import {
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
} from "../src/lib/dashboard-layout";
import { VALUE_RANGES } from "../src/lib/validations/measurement";
const DATABASE_URL = process.env.DATABASE_URL;
@@ -253,6 +257,32 @@ async function seed() {
],
);
// ── Dashboard showcase layout ─────────────
// The demo seeds rich sleep / steps / glucose / VO2max / vorsorge data,
// so persist an explicit dashboard layout that surfaces those tiles plus
// the daily-verdict hero. The v1.20.0 defaults already turn the data
// tiles on, but writing the layout verbatim keeps the public demo's home
// page stable regardless of future default tweaks. Derived from
// DEFAULT_DASHBOARD_LAYOUT with the seeded-rich strip tiles forced on.
const SHOWCASE_TILE_IDS = new Set([
"sleep",
"steps",
"glucose",
"vo2Max",
"recentWorkouts",
]);
const demoDashboardLayout = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
heroVisible: true,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) =>
SHOWCASE_TILE_IDS.has(w.id) ? { ...w, tileVisible: true } : w,
),
});
await client.query(
`UPDATE users SET dashboard_widgets_json = $1 WHERE id = $2`,
[JSON.stringify(demoDashboardLayout), userId],
);
// ── Measurements (90 days) ────────────────
// The loop runs i = 0..days (inclusive) so the final iteration lands a
// point for TODAY (daysAgo(0)) — every headline metric has a current
@@ -105,10 +105,11 @@ describe("resolveDashboardFirstPaintGate", () => {
describe("resolveConfiguredTileCount (v1.16.8)", () => {
it("counts only widgets that paint a strip tile, with bp counting double", () => {
// Default layout: weight, bp, pulse, bodyFat, mood, bpInTarget, vo2Max
// are tile-visible AND tile-capable; medications + recentWorkouts are
// tile-visible in the stored layout but paint NO strip tile, so
// they must not inflate the silhouette. bp paints sys + dia = 2.
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(8);
// plus the v1.20.0 sleep / steps / glucose flips are tile-visible AND
// tile-capable; medications + recentWorkouts are tile-visible in the
// stored layout but paint NO strip tile, so they must not inflate the
// silhouette. bp paints sys + dia = 2.
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(11);
});
it("ignores chart-only and iOS-pin-only widget ids", () => {
@@ -139,9 +140,10 @@ describe("resolveConfiguredTileCount (v1.16.8)", () => {
describe("resolveChartRowPlaceholderCount (v1.16.8)", () => {
it("reserves one cell per chart-visible chart-capable widget", () => {
// Default layout chart row: weight, bp, pulse, bodyFat, mood,
// medications are `visible`; bpInTarget / achievements /
// recentWorkouts carry no chart-shaped footprint.
expect(resolveChartRowPlaceholderCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(6);
// medications are `visible`, plus the v1.20.0 vorsorge flip
// (chart-row only); bpInTarget / achievements / recentWorkouts carry
// no chart-shaped footprint.
expect(resolveChartRowPlaceholderCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(7);
});
it("adds the BMI cell riding the weight gate when a height is known", () => {
@@ -149,7 +151,7 @@ describe("resolveChartRowPlaceholderCount (v1.16.8)", () => {
resolveChartRowPlaceholderCount(DEFAULT_DASHBOARD_LAYOUT, {
hasHeightCm: true,
}),
).toBe(7);
).toBe(8);
});
it("respects a trimmed layout", () => {
+12 -6
View File
@@ -6,6 +6,7 @@ import {
AITestConfigError,
type AITestOverride,
} from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
@@ -65,12 +66,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
try {
const result = await provider.generateCompletion({
systemPrompt: "You are a connection-test responder.",
userPrompt: 'Reply with the JSON object {"ok": true} and nothing else.',
temperature: 0,
maxTokens: 32,
});
const result = await provider.generateCompletion(
singleUserTurn({
system: "You are a connection-test responder.",
user: 'Reply with the JSON object {"ok": true} and nothing else.',
temperature: 0,
maxTokens: 32,
// The probe asks for a JSON object — keep the OpenAI / Codex strict
// JSON mode it relied on before the response_format gate landed.
responseFormat: "json",
}),
);
return apiSuccess({
ok: true,
+27 -13
View File
@@ -19,9 +19,9 @@ import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
/**
* OAuth callback from Google for Fitbit / Google Health (v1.12.0). Mirrors the
* WHOOP callback: the `state` param is a random base64url nonce keyed against
* the `FitbitOAuthState` ledger. The in-flight user is resolved via the row's
* OAuth callback from Fitbit (classic Web API, v1.20.0). Mirrors the WHOOP
* callback: the `state` param is a random base64url nonce keyed against the
* `FitbitOAuthState` ledger. The in-flight user is resolved via the row's
* `userId` (never parsed from the cookie value) and cross-checked against the
* session. The row is consumed (deleted) atomically on every exit branch so a
* replay of the same nonce fails the second time.
@@ -31,9 +31,12 @@ import { timingSafeEqual } from "node:crypto";
* nonce already consumed), `expired` (valid row, TTL elapsed), and `cross_user`
* (valid row, session userId mismatch).
*
* On success: exchange the code, fetch the Google Health profile for the
* external `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any
* prior reauth state, and enqueue the self-converging history backfill.
* v1.20.0 — the deleted ledger row carries the PKCE `code_verifier` minted at
* connect; it is presented on the token exchange.
*
* On success: exchange the code, fetch the Fitbit profile for the external
* `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any prior
* reauth state, and enqueue the self-converging history backfill.
*/
const ERR = (reason: string) =>
NextResponse.redirect(
@@ -70,7 +73,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
// payload. P2025 means the nonce was already consumed (replay) or never
// existed. Atomic at the Postgres row level: two concurrent callbacks with the
// same nonce can't both pass before either deletes.
let stateRow: { userId: string; expiresAt: Date } | null = null;
let stateRow: {
userId: string;
expiresAt: Date;
codeVerifier: string | null;
} | null = null;
try {
stateRow = await prisma.fitbitOAuthState.delete({
where: { nonce: state },
@@ -102,19 +109,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
return ERR("nocode");
}
// The PKCE verifier minted at connect rides on the ledger row. Its absence
// means a malformed / legacy handshake — refuse rather than send a code
// exchange Fitbit will reject for a missing verifier.
if (!stateRow.codeVerifier) {
annotate({ meta: { reason: "no_verifier" } });
return ERR("no_verifier");
}
try {
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return ERR("nocreds");
}
const tokens = await exchangeCode(code, creds);
const tokens = await exchangeCode(code, stateRow.codeVerifier, creds);
// The initial code exchange MUST return a refresh token (the connect route
// forces `prompt=consent` + `access_type=offline`). If Google ever omits it,
// refuse to persist — a stored empty refresh token silently bricks every
// future token refresh (the refresh POST then 400s and the connection dies
// the moment the access token expires). Error out cleanly so the user
// The initial code exchange MUST return a refresh token. If Fitbit ever
// omits it, refuse to persist — a stored empty refresh token silently bricks
// every future token refresh (the refresh POST then 400s and the connection
// dies the moment the access token expires). Error out cleanly so the user
// re-consents rather than landing a dead connection.
if (!tokens.refresh_token) {
getEvent()?.addWarning(
+13 -5
View File
@@ -3,7 +3,7 @@ import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/fitbit/client";
import { getAuthorizationUrl, generatePkcePair } from "@/lib/fitbit/client";
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
import {
FITBIT_OAUTH_STATE_COOKIE,
@@ -14,8 +14,8 @@ import { NextRequest, NextResponse } from "next/server";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
* Redirect the user to the Google OAuth consent page for Fitbit / Google Health
* (v1.12.0).
* Redirect the user to the Fitbit OAuth consent page (classic
* `www.fitbit.com/oauth2/authorize`, v1.20.0).
*
* Mirrors the WHOOP connect route: a fully-random base64url state nonce backed
* by a 10-minute `FitbitOAuthState` ledger row carries the `(nonce → userId)`
@@ -23,6 +23,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
* land in request logs / network captures). The httpOnly + Secure cookie
* carries JUST the nonce; the callback resolves the user via the row's `userId`.
*
* v1.20.0 — the classic Fitbit Web API uses PKCE (S256). The route mints a
* `code_verifier`/`code_challenge` pair, stashes the verifier on the ledger row
* (server-side, never exposed to the browser), and sends the challenge to the
* authorize endpoint. The callback reads the verifier back to present on the
* token exchange.
*
* Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam
* ledger rows for the full 10-min TTL window. Both the rate-limit and
* create-failure paths redirect (not JSON) because the entry point is a browser
@@ -53,17 +59,19 @@ export const GET = apiHandler(async (req: NextRequest) => {
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return apiError(
"Please configure your Google OAuth Client ID and Client Secret in Settings first.",
"Please configure your Fitbit OAuth Client ID and Client Secret in Settings first.",
400,
);
}
const nonce = mintFitbitOAuthStateNonce();
const pkce = generatePkcePair();
try {
await prisma.fitbitOAuthState.create({
data: {
nonce,
userId: user.id,
codeVerifier: pkce.verifier,
expiresAt: new Date(Date.now() + FITBIT_OAUTH_STATE_TTL_MS),
},
});
@@ -75,7 +83,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
}
const url = getAuthorizationUrl(nonce, creds);
const url = getAuthorizationUrl(nonce, creds, pkce.challenge);
const response = NextResponse.redirect(url);
response.cookies.set(FITBIT_OAUTH_STATE_COOKIE, nonce, {
+7 -7
View File
@@ -6,17 +6,17 @@ import { syncUserFitbit } from "@/lib/fitbit/sync";
import { NextRequest } from "next/server";
/**
* Manually trigger a Fitbit / Google Health sync for the current user (v1.12.0).
* Manually trigger a Fitbit sync for the current user.
*
* Mirrors the WHOOP manual-sync route: incremental by default, full history when
* `{ fullSync: true }` is posted.
*
* v1.12.1 — rate-limited (M-1). Every sibling Fitbit route (connect / test /
* resume) is limited; this one was the outlier, while `{ fullSync: true }`
* drives four paginated Google walkers (each capped at 1000 pages). A tight
* loop or a stolen native token could pin Prisma + the Google Health quota.
* A baseline 5/60s bucket (matching the test route) gates the route, and the
* expensive `fullSync` path carries a tighter 1/hour bucket of its own.
* Rate-limited: every sibling Fitbit route (connect / test / resume) is limited;
* this one was the outlier, while `{ fullSync: true }` drives four resource syncs
* over the classic Web API's tight 150 req/h-per-user budget. A tight loop or a
* stolen native token could exhaust that budget and pin Prisma. A baseline 5/60s
* bucket (matching the test route) gates the route, and the expensive `fullSync`
* path carries a tighter 1/hour bucket of its own.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -66,6 +66,17 @@ vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
vi.mock("@/lib/ai/coach/types", () => ({
coachChatRequestSchema: { safeParse: () => ({ success: false }) },
}));
// v1.20.0 (F1) — the route statically imports the tool barrel; this suite
// never reaches tool code (it asserts the 403 module-gate short-circuit), so a
// thin stub keeps the import graph satisfied without pulling the real schemas.
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [],
MAX_ROUNDS: 2,
buildCoachDataInventory: vi.fn(),
renderDataInventory: vi.fn(),
buildToolModeAddendum: vi.fn(),
runCoachToolLoop: vi.fn(),
}));
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(),
createConversation: vi.fn(),
@@ -58,8 +58,11 @@ vi.mock("@/lib/ai/provider-runner", () => ({
runRawCompletionWithFallback,
}));
vi.mock("@/lib/ai/provider", () => ({
// v1.20.0 (F1) — pin a no-tools provider so this suite exercises the legacy
// snapshot-stuffing fallback path (the behaviour it was written to pin). The
// tool-retrieval path is covered by route-tool-mode.test.ts.
resolveProviderChain: vi.fn(async () => [
{ providerType: "admin-openai", instance: {} },
{ providerType: "admin-openai", instance: { supportsTools: false } },
]),
resolveProvider: vi.fn(),
}));
@@ -164,10 +167,11 @@ function chatReq(body: Record<string, unknown>): Request {
function lastUserPrompt(): string {
const calls = runRawCompletionWithFallback.mock.calls as unknown as Array<
[{ params: { userPrompt: string } }]
[{ params: { messages: Array<{ role: string; content: string }> } }]
>;
const last = calls[calls.length - 1];
return last[0].params.userPrompt;
const userTurn = last[0].params.messages.find((m) => m.role === "user");
return typeof userTurn?.content === "string" ? userTurn.content : "";
}
describe("coach chat — snapshot sent once per conversation (C4)", () => {
@@ -0,0 +1,311 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* v1.20.0 (F1) — Coach tool-retrieval routing contract.
*
* - When every provider in the chain supports tools, the route runs the tool
* loop with a base context (DATA INVENTORY, NOT the snapshot figures), and
* persists the tool trace onto provenance.
* - When any provider lacks tool support, the route falls back to the legacy
* snapshot-stuffing single completion verbatim.
* - The inbound gates (rate limit, refusal, consent, budget reservation) all
* fire BEFORE the loop in both modes.
*/
const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) },
coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) },
},
}));
const { checkRateLimit } = vi.hoisted(() => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
const { runRawCompletionWithFallback } = vi.hoisted(() => ({
runRawCompletionWithFallback: vi.fn(),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runRawCompletionWithFallback,
}));
const { resolveProviderChain } = vi.hoisted(() => ({
resolveProviderChain: vi.fn(),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain,
resolveProvider: vi.fn(),
}));
const { assertConsentForChain } = vi.hoisted(() => ({
assertConsentForChain: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain }));
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } },
}));
vi.mock("@/lib/ai/coach/types", async () => {
const actual = await vi.importActual<typeof import("@/lib/ai/coach/types")>(
"@/lib/ai/coach/types",
);
return actual;
});
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(async () => ({ id: "m1" })),
createConversation: vi.fn(async () => ({ id: "c1" })),
fetchConversationWithMessages: vi.fn(),
listConversations: vi.fn(),
}));
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
enqueueCoachMemoryRefresh: vi.fn(),
}));
vi.mock("@/lib/ai/coach/facts", () => ({
storeDeterministicFacts: vi.fn(async () => undefined),
}));
const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
reserveBudget: vi.fn<
() => Promise<{
allowed: boolean;
reserved: number;
totalAfter?: number;
}>
>(async () => ({ allowed: true, reserved: 3000 })),
reconcileSpend: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-06-21"),
reserveBudget,
reconcileSpend,
}));
const { detectRefusal } = vi.hoisted(() => ({
detectRefusal: vi.fn(() => ({ refuse: false })),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal }));
vi.mock("@/lib/ai/coach/outbound-guard", () => ({
screenCoachReply: vi.fn(() => ({ block: false })),
coachOutboundFallback: vi.fn(() => "fallback"),
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/coach/snapshot", () => ({
buildCoachSnapshot: vi.fn(async () => ({
snapshotJson: SNAPSHOT_JSON,
sections: { bloodPressure: { aggregate: { mean: 128 } } },
provenance: { windows: ["last30days"], metrics: ["bp"] },
referenceGrounding: "REFERENCE RANGES",
})),
}));
// Tool-module surface — stub the inventory + loop so we assert routing only.
const { buildCoachDataInventory, renderDataInventory, runCoachToolLoop } =
vi.hoisted(() => ({
buildCoachDataInventory: vi.fn(async () => ({
entries: [],
restMode: false,
cycleEnabled: false,
window: "last30days",
})),
renderDataInventory: vi.fn(
() => "DATA INVENTORY\n- blood pressure: present",
),
runCoachToolLoop: vi.fn(async () => ({
result: { content: "Your BP is steady.", tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 2,
toolTrace: [{ name: "get_metric_series", present: true }],
})),
}));
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
MAX_ROUNDS: 2,
buildCoachDataInventory,
renderDataInventory,
buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"),
runCoachToolLoop,
}));
vi.mock("@/lib/ai/coach/keyvalues", () => ({
parseKeyValuesSentinel: vi.fn(() => ({
prose: "Your BP is steady.",
keyValues: [],
malformed: false,
malformedEntries: [],
})),
}));
vi.mock("@/lib/ai/coach/suggest-reminder", () => ({
parseSuggestReminder: vi.fn(() => ({ prose: "Your BP is steady." })),
}));
vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() }));
vi.mock("@/lib/validations/coach-prefs", () => ({
parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })),
DEFAULT_REMINDER_SUGGESTION_PREFS: {},
}));
const { appendMessage } = await import("@/lib/ai/coach/persistence");
vi.mock("@/lib/sse/create-stream", () => ({
createSseStream: (
producer: (c: {
signal: { aborted: boolean };
enqueue: () => void;
}) => void | Promise<void>,
) => {
void Promise.resolve(
producer({ signal: { aborted: true }, enqueue: () => {} }),
);
return new ReadableStream();
},
}));
import { POST } from "../route";
const post = POST as unknown as (req: Request) => Promise<Response>;
function chatReq(body: Record<string, unknown>): Request {
return new Request("http://localhost/api/insights/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("coach chat — tool-mode routing (F1)", () => {
beforeEach(() => {
vi.clearAllMocks();
reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 });
detectRefusal.mockReturnValue({ refuse: false });
checkRateLimit.mockResolvedValue({ allowed: true });
assertConsentForChain.mockResolvedValue(undefined);
});
it("runs the tool loop (not the snapshot path) when the chain supports tools", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} }, // no supportsTools=false ⇒ tools on
]);
await post(chatReq({ message: "How is my BP?" }));
expect(runCoachToolLoop).toHaveBeenCalledTimes(1);
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
// The base context carries the inventory, NOT the snapshot figures.
const loopArgs = (runCoachToolLoop.mock.calls[0] as unknown[])[0] as {
messages: Array<{ content: string }>;
};
const userContent = loopArgs.messages[0].content;
expect(userContent).toContain("DATA INVENTORY");
expect(userContent).not.toContain(SNAPSHOT_JSON);
});
it("reserves the multi-round budget in tool mode", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
await post(chatReq({ message: "How is my BP?" }));
// maxTokens (1500) × MAX_ROUNDS (2).
expect(reserveBudget).toHaveBeenCalledWith("u1", 3000, "2026-06-21");
});
it("persists the tool trace onto provenance", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
await post(chatReq({ message: "How is my BP?" }));
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
const assistantCall = calls.find(
(c) => (c[0] as { role: string }).role === "assistant",
);
expect(
(assistantCall?.[0] as { metricSource: { toolCalls?: unknown } })
.metricSource.toolCalls,
).toEqual([{ name: "get_metric_series", present: true }]);
});
it("falls back to the snapshot-stuffing path when a provider lacks tools", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
{ providerType: "local", instance: { supportsTools: false } },
]);
runRawCompletionWithFallback.mockResolvedValue({
result: { content: "Your BP is steady.", tokensUsed: 42, model: "m" },
workingProvider: { providerType: "anthropic" },
});
await post(chatReq({ message: "How is my BP?" }));
expect(runCoachToolLoop).not.toHaveBeenCalled();
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1);
// Single-round budget reservation in the fallback path.
expect(reserveBudget).toHaveBeenCalledWith("u1", 1500, "2026-06-21");
// The legacy path ships the snapshot figures in the user turn.
const params = (
(runRawCompletionWithFallback.mock.calls[0] as unknown[])[0] as {
params: { messages: Array<{ content: string }> };
}
).params;
expect(params.messages[0].content).toContain(SNAPSHOT_JSON);
});
it("fires the inbound gates BEFORE the loop (consent throws ⇒ no loop)", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
assertConsentForChain.mockRejectedValue(new Error("consent.ai.required"));
await expect(post(chatReq({ message: "How is my BP?" }))).rejects.toThrow(
"consent.ai.required",
);
expect(runCoachToolLoop).not.toHaveBeenCalled();
expect(reserveBudget).not.toHaveBeenCalled();
});
it("refuses over-budget before entering the loop", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
reserveBudget.mockResolvedValue({
allowed: false,
reserved: 3000,
totalAfter: 99999,
});
await post(chatReq({ message: "How is my BP?" }));
expect(runCoachToolLoop).not.toHaveBeenCalled();
});
});
+132 -39
View File
@@ -44,7 +44,7 @@ import {
} from "@/lib/ai/provider-runner";
import { resolveProviderChain, resolveProvider } from "@/lib/ai/provider";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import type { CompletionResult } from "@/lib/ai/types";
import { singleUserTurn, type CompletionResult } from "@/lib/ai/types";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
@@ -73,6 +73,16 @@ import {
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import {
COACH_TOOL_DEFS,
buildCoachDataInventory,
renderDataInventory,
buildToolModeAddendum,
runCoachToolLoop,
MAX_ROUNDS,
type CoachToolTrace,
} from "@/lib/ai/coach/tools";
import type { AiMessage } from "@/lib/ai/types";
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder";
import { gateSuggestion } from "@/lib/ai/coach/suggest-gate";
@@ -419,6 +429,36 @@ The user's message answers this clarifying question from their self-context ques
React briefly and personally to the answer; do not repeat the question and do not ask it again.
`
: "";
// ── Provider chain ──────────────────────────────────────────
// v1.20.0 (F1) — resolved BEFORE the prompt is built so we know whether to
// run the tool-based retrieval path or the legacy snapshot-stuffing path.
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
annotate({
action: { name: "insights.coach.noProvider" },
});
return streamProviderError({ code: "coach.provider.none" });
}
chain.push({ providerType: "admin-openai", instance: legacy });
}
// v1.12.1 — consent gate before any server-managed external egress. When
// the chain could egress via the operator's global key, require an active
// `ai_coach` (or master `ai_full`) receipt. BYOK / local / ChatGPT-OAuth
// chains are the user's own egress and stay ungated. Throws
// ConsentRequiredError → apiHandler returns 403 + `consent.ai.required`.
await assertConsentForChain({ userId, chain, surface: "coach" });
// v1.20.0 (F1) — tool mode is on only when EVERY provider in the chain
// supports tool-calling, so whichever hop the fallback runner lands on can
// still serve tools. A chain that includes a no-tools provider (local /
// Ollama) falls back to the legacy snapshot-stuffing path verbatim, exactly
// as before — the snapshot builder stays alive as the no-tools floor.
const toolMode = chain.every((c) => c.instance.supportsTools !== false);
// v1.18.6 (W7) — citation-aware reference-range grounding for the metrics
// present in this snapshot. Deterministic + brand-free; framed as general
// guidance, never a diagnosis. Appended after the SNAPSHOT so it is fully
@@ -447,26 +487,6 @@ ${transcript}
Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// ── Provider chain ──────────────────────────────────────────
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
annotate({
action: { name: "insights.coach.noProvider" },
});
return streamProviderError({ code: "coach.provider.none" });
}
chain.push({ providerType: "admin-openai", instance: legacy });
}
// v1.12.1 — consent gate before any server-managed external egress. When
// the chain could egress via the operator's global key, require an active
// `ai_coach` (or master `ai_full`) receipt. BYOK / local / ChatGPT-OAuth
// chains are the user's own egress and stay ungated. Throws
// ConsentRequiredError → apiHandler returns 403 + `consent.ai.required`.
await assertConsentForChain({ userId, chain, surface: "coach" });
// v1.18.7 (SENIOR-DEV HIGH) — atomically RESERVE the day's budget before
// the provider call. The reservation increments the day's total by the
// per-call ceiling (`maxTokens`) in one upsert and returns the new total;
@@ -474,10 +494,17 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// Over-cap → 429 refusal frame, reservation already refunded. The actual
// token count is reconciled against this reservation after the call,
// including on empty / sentinel replies (their tokens were still burned).
//
// v1.20.0 (F1) — the tool loop makes up to MAX_ROUNDS provider round-trips,
// so reserve the per-call ceiling × the round count up front and reconcile
// the SUMMED actual tokens afterwards. The atomic reserve/reconcile
// primitives are unchanged; only the reserved amount scales.
const reqDateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.coach.maxTokens,
toolMode
? AI_BUDGETS.coach.maxTokens * MAX_ROUNDS
: AI_BUDGETS.coach.maxTokens,
reqDateKey,
);
if (!reservation.allowed) {
@@ -490,19 +517,62 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
let result: CompletionResult;
let workingProviderType: string;
let toolTrace: CoachToolTrace[] = [];
let totalTokensSpent: number;
try {
const fallback = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt,
if (toolMode) {
// v1.20.0 (F1) — base context: the full system prompt + a tool-mode
// grounding addendum, with the tiny DATA INVENTORY manifest and the
// transcript on the user turn. The figures are NOT in the prompt — the
// model pulls only what it needs via the retrieval tools. The inventory
// build reuses the snapshot we already computed (60s LRU), so the tools
// that fire this turn share its reads.
const inventory = await buildCoachDataInventory(userId, effectiveScope);
const toolSystem = `${systemPrompt}\n\n${buildToolModeAddendum(locale)}`;
const messages: AiMessage[] = [
{
role: "user",
content: `${renderDataInventory(inventory)}${guidedBlock}
CONVERSATION
${transcript}
Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch any figures you cite with the tools first.`,
},
];
const loop = await runCoachToolLoop({
userId,
providers: chain,
system: toolSystem,
messages,
tools: COACH_TOOL_DEFS,
temperature: AI_BUDGETS.coach.temperature,
maxTokens: AI_BUDGETS.coach.maxTokens,
},
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
fallbackWindow: effectiveScope?.window,
});
result = loop.result;
workingProviderType = loop.workingProviderType;
toolTrace = loop.toolTrace;
totalTokensSpent = loop.totalTokens;
} else {
const fallback = await runRawCompletionWithFallback({
userId,
providers: chain,
// v1.20.0 — the no-tools path still builds one assembled user turn (the
// transcript-flattening preserves the once-per-conversation snapshot
// trick + grounding exactly), so it ships as a single user message. The
// stable persona/grounding rides `system` and is now cache-eligible.
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
temperature: AI_BUDGETS.coach.temperature,
maxTokens: AI_BUDGETS.coach.maxTokens,
}),
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
totalTokensSpent = result.tokensUsed ?? 0;
}
} catch (err) {
// The provider chain failed outright — no tokens were billed, so refund
// the full reservation before surfacing the error frame.
@@ -551,10 +621,12 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// against the actual count NOW, before any empty/sentinel short-circuit, so
// an empty or sentinel-only reply still records its burned cost (the old
// post-hoc `recordSpend` ran only on the happy path, undercounting these).
// v1.20.0 (F1) — reconcile against the SUMMED tokens across every tool round
// (the loop accumulates them); the no-tools path sums to the single call.
await reconcileSpend(
userId,
reservation.reserved,
result.tokensUsed ?? 0,
totalTokensSpent,
reqDateKey,
).catch(() => {
// Ledger reconcile is best-effort; a failure leaves the conservative
@@ -677,6 +749,17 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
? { keyValues: sentinel.keyValues }
: {}),
...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}),
// v1.20.0 (F1) — persist the retrieval-tool trace (which tools ran +
// whether each found data) so a reload can show "what I looked at" and the
// audit can replay grounding. Metadata only.
...(toolTrace.length > 0
? {
toolCalls: toolTrace.map((t) => ({
name: t.name,
present: t.present,
})),
}
: {}),
};
if (sentinel.malformed) {
// Graceful degrade: log so ops can spot a provider whose
@@ -716,7 +799,9 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// v1.18.9 — persist the per-turn token count + model so the quiet
// token footer survives a conversation reload. The live turn paints
// from the `done.usage` SSE frame below; reloads read these columns.
tokensUsed: result.tokensUsed ?? null,
// v1.20.0 (F1) — the summed cost across every tool round, so the footer
// reflects the true turn cost on the tool path too.
tokensUsed: totalTokensSpent || null,
model: result.model ?? null,
});
@@ -729,14 +814,21 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
action: { name: "insights.coach.replied" },
meta: {
provider: workingProviderType,
tokens: result.tokensUsed ?? null,
// v1.20.0 (F1) — summed tokens across every tool round (the loop) or the
// single call (no-tools path), so the dashboards see the true turn cost.
tokens: totalTokensSpent,
promptVersion: PROMPT_VERSION,
conversationId: workingConversationId,
historyTurns: window.length,
// v1.20.0 (F1) — whether this turn ran the tool-retrieval path, and how
// many tools it fetched, so the dashboards can correlate the token delta
// with the new path vs the legacy snapshot path.
toolMode,
toolsCalled: toolTrace.length,
// v1.19.1 (C4) — whether the full SNAPSHOT block rode this turn (the
// expensive prefix) vs the cheap pointer. Lets the dashboards confirm
// the once-per-conversation grounding cut the tokens-per-message.
snapshotSent: includeFullSnapshot,
// expensive prefix) vs the cheap pointer. On the tool path the snapshot
// never rides the prompt, so this is the legacy-path signal only.
snapshotSent: !toolMode && includeFullSnapshot,
promptChars: userPrompt.length,
// v1.7.0 — count of provenance metrics the snapshot surfaced
// this turn (a proxy for cluster breadth) so the dashboards can
@@ -789,7 +881,8 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
conversationId: workingConversationId,
messageId: assistantMessage.id,
usage: {
totalTokens: result.tokensUsed ?? null,
// v1.20.0 (F1) — summed across every tool round.
totalTokens: totalTokensSpent || null,
model: result.model ?? null,
},
}),
+18 -15
View File
@@ -52,7 +52,11 @@ import {
type ComparisonBaseline,
} from "@/lib/dashboard-layout";
import type { MeasurementType } from "@/generated/prisma/client";
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
import {
insightResultSchema,
singleUserTurn,
type InsightResult,
} from "@/lib/ai/types";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import {
AllProvidersFailedError,
@@ -662,7 +666,7 @@ export const POST = apiHandler((request: NextRequest) =>
const fallback = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
params: singleUserTurn({
// The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8
// — emit a top-level `dailyBriefing` block when the snapshot has
// analysable signal — plus the trendAnnotations and
@@ -674,14 +678,14 @@ export const POST = apiHandler((request: NextRequest) =>
// tolerates the strict shape via `insightResultSchema.safeParse`'s
// soft fallback to `parsed` and `passthrough()` on the strict
// schema, so switching the prompt does not break legacy callers.
systemPrompt: buildSystemPromptWithReferences(
locale,
referenceMetrics,
),
userPrompt,
system: buildSystemPromptWithReferences(locale, referenceMetrics),
user: userPrompt,
temperature: 0.3,
maxTokens: 1500,
},
// The reply is parsed with `JSON.parse` below, so opt the OpenAI /
// Codex chains into their strict JSON mode (gated on this flag).
responseFormat: "json",
}),
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
@@ -809,15 +813,14 @@ export const POST = apiHandler((request: NextRequest) =>
const retry = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt: buildSystemPromptWithReferences(
locale,
referenceMetrics,
),
userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
params: singleUserTurn({
system: buildSystemPromptWithReferences(locale, referenceMetrics),
user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
temperature: 0.3,
maxTokens: 1500,
},
// Parsed with `JSON.parse` below — opt OpenAI / Codex into JSON mode.
responseFormat: "json",
}),
});
const parsedRetry = JSON.parse(retry.result.content);
const validatedRetry = insightResultSchema.safeParse(parsedRetry);
@@ -206,7 +206,7 @@ describe("POST /api/integrations/fitbit/test", () => {
new Response(
JSON.stringify({
error: "Authorization: Bearer sk-secret echoed",
url: "https://health.googleapis.com/v4/users/me/profile",
url: "https://api.fitbit.com/1/user/-/profile.json",
}),
{ status: 401 },
),
@@ -215,6 +215,5 @@ describe("POST /api/integrations/fitbit/test", () => {
const text = await response.text();
expect(response.status).toBe(502);
expect(text).not.toMatch(/sk-/);
expect(text).not.toMatch(/health\.googleapis\.com/);
});
});
@@ -13,7 +13,7 @@ export const dynamic = "force-dynamic";
// Cheap authenticated probe — the single (non-paginated) profile endpoint the
// Fitbit client already calls via `fetchProfile`.
// Base from `src/lib/fitbit/client.ts` (`FITBIT_API_BASE`).
const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/users/me/profile`;
const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/1/user/-/profile.json`;
const TIMEOUT_MS = 5_000;
type CategorisedError = { code: string; message: string };
@@ -22,24 +22,24 @@ function categoriseHttpStatus(status: number): CategorisedError {
if (status === 401 || status === 403) {
return {
code: "credentials_rejected",
message: "Google Health rejected the credentials",
message: "Fitbit rejected the credentials",
};
}
if (status === 429) {
return {
code: "rate_limited",
message: "Google Health rate-limited the request",
message: "Fitbit rate-limited the request",
};
}
if (status >= 500) {
return {
code: "upstream_error",
message: "Google Health returned a server error",
message: "Fitbit returned a server error",
};
}
return {
code: "connection_failed",
message: "Google Health connection failed",
message: "Fitbit connection failed",
};
}
@@ -57,7 +57,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const tokenInfo = await getValidToken(user.id);
if (!tokenInfo) {
return apiError("Google Health not connected", 422, {
return apiError("Fitbit not connected", 422, {
errorCode: "not_configured",
});
}
@@ -115,9 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
return apiError(
isAbort
? "Google Health request timed out"
: "Google Health connection failed",
isAbort ? "Fitbit request timed out" : "Fitbit connection failed",
502,
{ errorCode: code },
);
+8 -4
View File
@@ -57,6 +57,7 @@ import {
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import {
@@ -185,16 +186,19 @@ async function handleExtract(request: NextRequest): Promise<Response> {
completion = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt,
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
// Low temperature because we want a deterministic field
// extractor, not a creative rephrase.
temperature: 0.1,
// The reply body is a single small JSON object; 600 is well
// over the empirical max we have seen on a 2k-char input.
maxTokens: 600,
},
// Parsed with `parseModelJson` (JSON.parse) below — opt the OpenAI /
// Codex chains into their strict JSON-object mode (gated on this flag).
responseFormat: "json",
}),
});
} catch (err) {
if (err instanceof AllProvidersFailedError) {
@@ -41,6 +41,11 @@ function DeviationRow({ d }: { d: IllnessVitalDeviation }) {
);
}
/** Round asleep-minutes to a friendly hours figure (one decimal). */
function hoursFromMinutes(minutes: number): string {
return (Math.round((minutes / 60) * 10) / 10).toString();
}
function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
const { t } = useTranslations();
const gap = value.recoveryGapDays;
@@ -48,6 +53,9 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
// the symptoms ("…before your symptoms eased"); otherwise the existing
// vital-driven phrasing. Clinically calm — observation, never prediction.
const symptomDriven = value.gapDriverType === "FUNCTIONAL_IMPACT";
// Sleep-as-context: a neutral observation subordinate to the gap, withheld by
// the engine on thin data — never a recovery claim, never a second gap.
const sleep = value.sleepContext;
return (
<div className="space-y-5">
@@ -90,6 +98,16 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
)
: t("illness.correlation.recoveryGapSame")}
</p>
{sleep ? (
<p className="text-muted-foreground mt-1.5 text-xs">
{t(
sleep.deltaMinutes > 0
? "illness.correlation.sleepContextMore"
: "illness.correlation.sleepContextLess",
{ hours: hoursFromMinutes(Math.abs(sleep.deltaMinutes)) },
)}
</p>
) : null}
</div>
) : null}
+18
View File
@@ -111,6 +111,19 @@ export interface IllnessRedFlag {
days: number;
}
/**
* A neutral sleep-as-context observation for the episode — slept more / less
* than usual — surfaced ALONGSIDE the gap, never part of the recovery-gap
* number. Null when the sleep data is too thin or the delta sub-floor.
*/
export interface SleepContext {
baselineMeanMinutes: number;
episodeMeanMinutes: number;
/** `episode − baseline` asleep minutes (positive = slept more than usual). */
deltaMinutes: number;
nightsCounted: number;
}
export interface IllnessCorrelationValue {
episodeId: string;
preOnset: IllnessVitalDeviation[];
@@ -126,6 +139,11 @@ export interface IllnessCorrelationValue {
*/
gapDriverType: string | null;
redFlags: IllnessRedFlag[];
/**
* Sleep-as-context observation, or null when withheld (thin data / sub-floor
* delta). Additive — never part of the headline recovery-gap.
*/
sleepContext: SleepContext | null;
}
/** The flat `Derived<T>` wire shape the correlation route returns. */
+18 -5
View File
@@ -469,16 +469,29 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(36);
});
it("registers the Vorsorge widget default-off on both surfaces", () => {
// v1.18.2 — Vorsorge becomes a first-class dashboard widget like
// medications: chart-row only, default-invisible so existing
// dashboards are unchanged until the user opts in.
it("ships the sleep / steps / glucose strip tiles default-on (v1.20.0)", () => {
// These modules are on by default and their tiles self-gate on having
// data in page.tsx, so accounts that sync sleep / step / glucose data
// discover the tiles on / without hunting through Settings. The chart
// rows stay off because those charts live on the /insights sub-pages.
for (const id of ["sleep", "steps", "glucose"]) {
const widget = DEFAULT_DASHBOARD_LAYOUT.widgets.find((w) => w.id === id);
expect(widget).toBeDefined();
expect(widget?.tileVisible).toBe(true);
expect(widget?.visible).toBe(false);
}
});
it("registers the Vorsorge widget chart-row default-on (v1.20.0)", () => {
// v1.18.2 — Vorsorge is a first-class chart-row widget (no strip tile).
// v1.20.0 flipped it default-visible so preventive-care reminders
// surface on /; it self-gates and renders nothing without due reminders.
expect(DASHBOARD_WIDGET_IDS).toContain("vorsorge");
const entry = DEFAULT_DASHBOARD_LAYOUT.widgets.find(
(w) => w.id === "vorsorge",
);
expect(entry).toBeDefined();
expect(entry?.visible).toBe(false);
expect(entry?.visible).toBe(true);
expect(entry?.tileVisible).toBe(false);
});
+155 -32
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AnthropicClient } from "../anthropic-client";
import { singleUserTurn } from "../types";
describe("AnthropicClient", () => {
beforeEach(() => {
@@ -22,12 +23,14 @@ describe("AnthropicClient", () => {
model: "claude-3-5-sonnet-latest",
});
const result = await client.generateCompletion({
systemPrompt: "You are a doctor.",
userPrompt: "Analyze this data.",
temperature: 0.4,
maxTokens: 800,
});
const result = await client.generateCompletion(
singleUserTurn({
system: "You are a doctor.",
user: "Analyze this data.",
temperature: 0.4,
maxTokens: 800,
}),
);
expect(result.content).toBe('{"summary":"ok"}');
expect(result.tokensUsed).toBe(42);
@@ -45,10 +48,16 @@ describe("AnthropicClient", () => {
expect(body.model).toBe("claude-3-5-sonnet-latest");
expect(body.max_tokens).toBe(800);
expect(body.temperature).toBe(0.4);
// System prompt is a separate top-level field, NOT a message.
expect(body.system).toBe("You are a doctor.");
// v1.20.0 — system is a top-level cache-marked text block (NOT a message).
expect(body.system).toEqual([
{
type: "text",
text: "You are a doctor.",
cache_control: { type: "ephemeral" },
},
]);
expect(body.messages).toEqual([expect.objectContaining({ role: "user" })]);
// User message should be wrapped to coerce JSON output.
// User message carries the original text plus the JSON-coercion instruction.
expect(body.messages[0].content).toContain("Analyze this data.");
expect(body.messages[0].content.toLowerCase()).toContain("json");
});
@@ -70,11 +79,9 @@ describe("AnthropicClient", () => {
model: "claude-3-5-sonnet-latest",
});
const result = await client.generateCompletion({
systemPrompt: "s",
userPrompt: "u",
responseFormat: "json",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "s", user: "u", responseFormat: "json" }),
);
// The returned content is the complete object (the `{` re-prepended).
expect(result.content).toBe('{"summary":"ok"}');
@@ -101,10 +108,9 @@ describe("AnthropicClient", () => {
model: "claude-3-5-sonnet-latest",
});
const result = await client.generateCompletion({
systemPrompt: "s",
userPrompt: "u",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "s", user: "u" }),
);
expect(result.content).toBe("Just some prose.");
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
@@ -129,7 +135,7 @@ describe("AnthropicClient", () => {
baseUrl: "https://api.anthropic.example/v1/",
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
expect(mockFetch.mock.calls[0][0]).toBe(
"https://api.anthropic.example/v1/messages",
@@ -156,10 +162,9 @@ describe("AnthropicClient", () => {
let caught: unknown;
try {
await client.generateCompletion({
systemPrompt: "s",
userPrompt: "u",
});
await client.generateCompletion(
singleUserTurn({ system: "s", user: "u" }),
);
} catch (e) {
caught = e;
}
@@ -195,13 +200,15 @@ describe("AnthropicClient", () => {
model: "claude-sonnet-4-6",
});
await client.generateCompletion({
systemPrompt: "Transcribe this report.",
userPrompt: "Extract the readings.",
responseFormat: "json",
images: [{ mediaType: "image/jpeg", dataBase64: "aW1n" }],
documents: [{ mediaType: "application/pdf", dataBase64: "cGRm" }],
});
await client.generateCompletion(
singleUserTurn({
system: "Transcribe this report.",
user: "Extract the readings.",
responseFormat: "json",
images: [{ mediaType: "image/jpeg", dataBase64: "aW1n" }],
documents: [{ mediaType: "application/pdf", dataBase64: "cGRm" }],
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
// First message is the user turn; its content is now a typed array.
@@ -240,7 +247,7 @@ describe("AnthropicClient", () => {
model: "claude-sonnet-4-6",
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(typeof body.messages[0].content).toBe("string");
});
@@ -260,7 +267,123 @@ describe("AnthropicClient", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
client.generateCompletion(singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow("Anthropic returned empty content");
});
it("maps tools, drops the JSON prefill, parses tool_use + cache reads", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
content: [
{
type: "tool_use",
id: "toolu_1",
name: "fetch_glucose",
input: { window: "last30days" },
},
],
stop_reason: "tool_use",
usage: {
input_tokens: 50,
output_tokens: 8,
cache_read_input_tokens: 40,
},
}),
});
vi.stubGlobal("fetch", mockFetch);
const client = new AnthropicClient({
apiKey: "sk-ant-x",
model: "claude-sonnet-4-6",
});
const result = await client.generateCompletion(
singleUserTurn({
system: "s",
user: "u",
// Even with responseFormat json, tools must win and drop the prefill.
responseFormat: "json",
tools: [
{
name: "fetch_glucose",
description: "Fetch glucose readings",
parameters: { type: "object", properties: {} },
},
],
toolChoice: "auto",
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.tools).toEqual([
{
name: "fetch_glucose",
description: "Fetch glucose readings",
input_schema: { type: "object", properties: {} },
},
]);
expect(body.tool_choice).toEqual({ type: "auto" });
// No `{`-prefill assistant turn when tools are present.
expect(body.messages).toHaveLength(1);
expect(result.toolCalls).toEqual([
{
id: "toolu_1",
name: "fetch_glucose",
arguments: JSON.stringify({ window: "last30days" }),
},
]);
expect(result.finishReason).toBe("tool_calls");
expect(result.cachedInputTokens).toBe(40);
});
it("derives finishReason from tool_use blocks even on an unexpected stop_reason (M-2)", async () => {
// Anthropic normally pairs tool_use blocks with stop_reason "tool_use".
// If it ever returns the blocks under a different (or absent) stop_reason,
// the loop must still see finishReason "tool_calls" so it executes the tool
// round instead of surfacing the empty tool_use-only reply as the answer.
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
content: [
{
type: "tool_use",
id: "toolu_9",
name: "fetch_glucose",
input: { window: "last30days" },
},
],
// Deliberately NOT "tool_use" — the pre-fix code derived finishReason
// from this field alone and would have returned undefined here.
stop_reason: "end_turn",
usage: { input_tokens: 10, output_tokens: 4 },
}),
});
vi.stubGlobal("fetch", mockFetch);
const client = new AnthropicClient({
apiKey: "sk-ant-x",
model: "claude-sonnet-4-6",
});
const result = await client.generateCompletion(
singleUserTurn({
system: "s",
user: "u",
tools: [
{
name: "fetch_glucose",
description: "Fetch glucose readings",
parameters: { type: "object", properties: {} },
},
],
toolChoice: "auto",
}),
);
expect(result.toolCalls).toHaveLength(1);
expect(result.finishReason).toBe("tool_calls");
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
import { singleUserTurn } from "../types";
import {
detectsNormativeClaim,
computeCitationCoverage,
@@ -276,10 +277,10 @@ describe("generateInsight() — citation-coverage annotation", () => {
warnings: [],
}),
});
await generateInsight(provider, {
systemPrompt: "system",
userPrompt: "user",
});
await generateInsight(
provider,
singleUserTurn({ system: "system", user: "user" }),
);
const meta = findCoverageCall(annotateMock.mock.calls);
expect(meta).toMatchObject({
ai_total_recommendations: 1,
@@ -306,10 +307,10 @@ describe("generateInsight() — citation-coverage annotation", () => {
warnings: [],
}),
});
await generateInsight(provider, {
systemPrompt: "system",
userPrompt: "user",
});
await generateInsight(
provider,
singleUserTurn({ system: "system", user: "user" }),
);
const meta = findCoverageCall(annotateMock.mock.calls);
expect(meta).toMatchObject({
ai_normative_recommendations: 1,
@@ -324,7 +325,7 @@ describe("generateInsight() — citation-coverage annotation", () => {
responses: ["not json", "still not json"],
});
await expect(
generateInsight(provider, { systemPrompt: "s", userPrompt: "u" }),
generateInsight(provider, singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow();
expect(annotateMock).not.toHaveBeenCalled();
});
@@ -8,6 +8,7 @@ import {
} from "../schema";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
import { singleUserTurn } from "../types";
/**
* Phase C1 — citation-from-data hard requirement.
@@ -247,10 +248,10 @@ describe("citation-from-data — wrapper end-to-end", () => {
const provider = new MockAIProvider({
responses: [JSON.stringify(orphan), JSON.stringify(baseValid)],
});
const outcome = await generateInsight(provider, {
systemPrompt: "system",
userPrompt: "user",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "system", user: "user" }),
);
expect(outcome.retried).toBe(true);
expect(outcome.parsed.recommendations[0].id).toBe("rec-1");
});
@@ -284,10 +285,10 @@ describe("citation-from-data — wrapper end-to-end", () => {
const provider = new MockAIProvider({
responses: [JSON.stringify(orphan), JSON.stringify(droppedRecs)],
});
const outcome = await generateInsight(provider, {
systemPrompt: "system",
userPrompt: "user",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "system", user: "user" }),
);
expect(outcome.retried).toBe(true);
expect(outcome.parsed.recommendations).toHaveLength(0);
});
+97 -23
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { CodexClient } from "../codex-client";
import { singleUserTurn } from "../types";
/**
* Build a `Response` whose `.body` streams a list of SSE event strings.
@@ -57,10 +58,9 @@ describe("CodexClient", () => {
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
const result = await client.generateCompletion({
systemPrompt: "You are a doctor.",
userPrompt: "Analyze this.",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "You are a doctor.", user: "Analyze this." }),
);
expect(result.content).toBe('{"summary":"ok"}');
expect(result.providerType).toBe("codex");
@@ -103,10 +103,9 @@ describe("CodexClient", () => {
.fn()
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
const result = await client.generateCompletion({
systemPrompt: "test",
userPrompt: "test",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "test", user: "test" }),
);
expect(result.content).toBe("Hello world");
});
@@ -135,10 +134,9 @@ describe("CodexClient", () => {
},
});
const result = await client.generateCompletion({
systemPrompt: "test",
userPrompt: "test",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "test", user: "test" }),
);
expect(onRefresh).toHaveBeenCalledOnce();
expect(result.content).toBe('{"test":true}');
@@ -167,7 +165,9 @@ describe("CodexClient", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "test", userPrompt: "test" }),
client.generateCompletion(
singleUserTurn({ system: "test", user: "test" }),
),
).rejects.toThrow("Codex request failed after token refresh (401)");
});
@@ -192,7 +192,9 @@ describe("CodexClient", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "test", userPrompt: "test" }),
client.generateCompletion(
singleUserTurn({ system: "test", user: "test" }),
),
).rejects.toThrow("Codex request failed (500)");
expect(onRefresh).not.toHaveBeenCalled();
});
@@ -213,11 +215,13 @@ describe("CodexClient", () => {
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
await client.generateCompletion({
systemPrompt: "Transcribe.",
userPrompt: "Read this report.",
images: [{ mediaType: "image/png", dataBase64: "QUJD" }],
});
await client.generateCompletion(
singleUserTurn({
system: "Transcribe.",
user: "Read this report.",
images: [{ mediaType: "image/png", dataBase64: "QUJD" }],
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
const content = body.input[0].content;
@@ -248,14 +252,84 @@ describe("CodexClient", () => {
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
await client.generateCompletion({
systemPrompt: "Answer.",
userPrompt: "Plain text only.",
});
await client.generateCompletion(
singleUserTurn({ system: "Answer.", user: "Plain text only." }),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.input[0].content).toEqual([
{ type: "input_text", text: "Plain text only." },
]);
});
it("maps tool defs and parses function_call items + cached tokens", async () => {
const functionCallEvent = `event: response.output_item.done\ndata: ${JSON.stringify(
{
type: "response.output_item.done",
item: {
type: "function_call",
call_id: "fc_1",
name: "fetch_glucose",
arguments: '{"window":"last30days"}',
},
},
)}\n\n`;
const completedWithCache = `event: response.completed\ndata: ${JSON.stringify(
{
type: "response.completed",
response: {
id: "resp1",
usage: {
total_tokens: 30,
input_tokens_details: { cached_tokens: 20 },
},
},
},
)}\n\n`;
const mockFetch = vi
.fn()
.mockResolvedValue(sseResponse([functionCallEvent, completedWithCache]));
vi.stubGlobal("fetch", mockFetch);
const client = new CodexClient({
accessToken: "test-token",
accountId: "acct-test",
onTokenRefresh: vi
.fn()
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
const result = await client.generateCompletion(
singleUserTurn({
system: "s",
user: "u",
tools: [
{
name: "fetch_glucose",
description: "Fetch glucose readings",
parameters: { type: "object", properties: {} },
},
],
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.tools).toEqual([
{
type: "function",
name: "fetch_glucose",
description: "Fetch glucose readings",
parameters: { type: "object", properties: {} },
},
]);
expect(result.toolCalls).toEqual([
{
id: "fc_1",
name: "fetch_glucose",
arguments: '{"window":"last30days"}',
},
]);
expect(result.finishReason).toBe("tool_calls");
expect(result.cachedInputTokens).toBe(20);
});
});
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CodexClient, __test } from "../codex-client";
import { singleUserTurn } from "../types";
import {
clearCodexSlugCache,
getCachedCodexSlug,
@@ -94,7 +95,7 @@ describe("Codex slug fallback chain", () => {
onTokenRefresh,
slugChain: ["gpt-5.3-codex", "gpt-5-codex", "gpt-4o"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
expect(mockFetch).toHaveBeenCalledTimes(1);
const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body);
@@ -121,7 +122,7 @@ describe("Codex slug fallback chain", () => {
onTokenRefresh,
slugChain: ["gpt-5", "gpt-5.3-codex", "gpt-4o"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(JSON.parse(mockFetch.mock.calls[0][1].body).model).toBe("gpt-5");
@@ -155,7 +156,7 @@ describe("Codex slug fallback chain", () => {
onTokenRefresh,
slugChain: ["nonexistent-slug", "gpt-5.3-codex"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(client.getLastDiagnostics()?.workingSlug).toBe("gpt-5.3-codex");
@@ -174,7 +175,7 @@ describe("Codex slug fallback chain", () => {
onTokenRefresh,
slugChain: ["dead-slug", "gpt-5.3-codex"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
expect(mockFetch).toHaveBeenCalledTimes(2);
});
@@ -193,7 +194,7 @@ describe("Codex slug fallback chain", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
client.generateCompletion(singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow("Codex request failed (500)");
expect(mockFetch).toHaveBeenCalledTimes(1); // no walk
@@ -213,7 +214,7 @@ describe("Codex slug fallback chain", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
client.generateCompletion(singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow("Codex request failed (429)");
expect(mockFetch).toHaveBeenCalledTimes(1);
@@ -235,7 +236,7 @@ describe("Codex slug fallback chain", () => {
slugChain: ["gpt-5.3-codex", "gpt-4o"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
// Both calls hit the SAME slug — auth retry didn't walk to gpt-4o.
expect(JSON.parse(mockFetch.mock.calls[0][1].body).model).toBe(
@@ -263,7 +264,9 @@ describe("Codex slug fallback chain", () => {
let caught: unknown;
try {
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(
singleUserTurn({ system: "s", user: "u" }),
);
} catch (e) {
caught = e;
}
@@ -291,7 +294,7 @@ describe("Codex slug fallback chain", () => {
onTokenRefresh,
slugChain: ["gpt-5.3-codex", "gpt-5-codex", "gpt-4o"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(JSON.parse(mockFetch.mock.calls[0][1].body).model).toBe("gpt-4o");
@@ -314,7 +317,7 @@ describe("Codex slug fallback chain", () => {
onTokenRefresh,
slugChain: ["gpt-5.3-codex", "gpt-5-codex"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
// After walking to "gpt-5.3-codex" (the next entry after the
// cached one), the cache is updated to the new working slug.
+25 -24
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
import { singleUserTurn } from "../types";
/**
* v1.4.16 phase B5d — wrapper post-validation override.
@@ -55,10 +56,10 @@ describe("generateInsight() — confidence override", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(validResponseWithModelConfidence),
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "user",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "user" }),
);
const rec = outcome.parsed.recommendations[0];
// Model claimed 99; wrapper must have replaced it.
// n=12, recencyDays=0 (default), ratio=null (default) →
@@ -83,10 +84,10 @@ describe("generateInsight() — confidence override", () => {
// strip the undefined to emulate the model not emitting the key
const stringified = JSON.stringify(noConfidence);
const provider = new MockAIProvider({ responses: stringified });
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "u",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "u" }),
);
expect(outcome.parsed.recommendations[0].confidence).toBe(66);
});
@@ -111,10 +112,10 @@ describe("generateInsight() — confidence override", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(fourteenN),
});
const outcome = await generateInsight(provider, {
systemPrompt: "s",
userPrompt: "u",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "s", user: "u" }),
);
// n=14, recencyDays=0, ratio=null → 10 + 10*log10(14) + 30 + 15 ≈ 66.46
expect(outcome.parsed.recommendations[0].confidence).toBe(66);
});
@@ -133,10 +134,10 @@ describe("generateInsight() — confidence override", () => {
],
};
const provider = new MockAIProvider({ responses: JSON.stringify(tinyN) });
const outcome = await generateInsight(provider, {
systemPrompt: "s",
userPrompt: "u",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "s", user: "u" }),
);
expect(outcome.parsed.recommendations[0].confidence).toBeLessThanOrEqual(
15,
);
@@ -158,10 +159,10 @@ describe("generateInsight() — confidence override", () => {
],
};
const provider = new MockAIProvider({ responses: JSON.stringify(noN) });
const outcome = await generateInsight(provider, {
systemPrompt: "s",
userPrompt: "u",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "s", user: "u" }),
);
// n=0 → hard-cap floor 10
expect(outcome.parsed.recommendations[0].confidence).toBe(10);
});
@@ -189,10 +190,10 @@ describe("generateInsight() — confidence override", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(twoRecs),
});
const outcome = await generateInsight(provider, {
systemPrompt: "s",
userPrompt: "u",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "s", user: "u" }),
);
// Model claimed (99, 1); both must be discarded and replaced with
// the deterministic value (n=12 → 66, n=5 → 62 from the saturating
// formula 10 + 10*log10(5) + 30 + 15 ≈ 61.99).
+35 -19
View File
@@ -6,6 +6,22 @@ import {
} from "../schema";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
import { singleUserTurn, type CompletionParams } from "../types";
/** Read the last user message's text out of a recorded CompletionParams. */
function lastUserText(params: CompletionParams): string {
for (let i = params.messages.length - 1; i >= 0; i -= 1) {
const m = params.messages[i];
if (m.role !== "user") continue;
return typeof m.content === "string"
? m.content
: m.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("");
}
return "";
}
const validResponse = {
summary: "Your blood pressure is trending high.",
@@ -143,10 +159,10 @@ describe("generateInsight wrapper", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(validResponse),
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "user",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "user" }),
);
expect(outcome.attempts).toBe(1);
expect(outcome.retried).toBe(false);
expect(outcome.parsed.summary).toMatch(/blood pressure/);
@@ -157,16 +173,16 @@ describe("generateInsight wrapper", () => {
const provider = new MockAIProvider({
responses: ["definitely not json", JSON.stringify(validResponse)],
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "Original user prompt.",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "Original user prompt." }),
);
expect(outcome.attempts).toBe(2);
expect(outcome.retried).toBe(true);
expect(provider.callCount).toBe(2);
// The retry call must include the original user prompt PLUS the
// corrective suffix referencing the schema.
const retryUserPrompt = provider.calls[1].userPrompt;
const retryUserPrompt = lastUserText(provider.calls[1]);
expect(retryUserPrompt).toContain("Original user prompt.");
expect(retryUserPrompt).toContain("did not satisfy");
expect(retryUserPrompt).toContain("metricSource");
@@ -178,10 +194,10 @@ describe("generateInsight wrapper", () => {
});
let caught: unknown;
try {
await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "user",
});
await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "user" }),
);
} catch (e) {
caught = e;
}
@@ -220,13 +236,13 @@ describe("generateInsight wrapper", () => {
JSON.stringify(validResponse),
],
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "u",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "u" }),
);
expect(outcome.attempts).toBe(2);
expect(outcome.retried).toBe(true);
expect(provider.calls[1].userPrompt).toContain(
expect(lastUserText(provider.calls[1])).toContain(
"metricSources not in citations",
);
});
@@ -238,7 +254,7 @@ describe("generateInsight wrapper", () => {
}),
});
await expect(
generateInsight(provider, { systemPrompt: "s", userPrompt: "u" }),
generateInsight(provider, singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow("OpenAI request failed (500)");
// Provider only called once — wrapper does not retry on provider errors.
expect(provider.callCount).toBe(1);
@@ -8,6 +8,7 @@ import {
import { aiInsightResponseSchema, findUncitedRecommendations } from "../schema";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
import { singleUserTurn } from "../types";
/**
* Phase C1 — scope-hardened system prompt assertions.
@@ -130,13 +131,15 @@ describe("out-of-scope refusal payloads", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(OUT_OF_SCOPE_REFUSAL_EN),
});
const outcome = await generateInsight(provider, {
systemPrompt: getStrictInsightsSystemPrompt("en"),
// Deliberately out-of-scope user prompt — model is instructed to
// return the refusal rather than fabricate health metrics.
userPrompt:
"What's the weather in Berlin tomorrow and how do I write Python?",
});
const outcome = await generateInsight(
provider,
singleUserTurn({
system: getStrictInsightsSystemPrompt("en"),
// Deliberately out-of-scope user prompt — model is instructed to
// return the refusal rather than fabricate health metrics.
user: "What's the weather in Berlin tomorrow and how do I write Python?",
}),
);
expect(outcome.attempts).toBe(1);
expect(outcome.parsed.recommendations).toEqual([]);
expect(outcome.parsed.summary).toContain("only summarise");
+50 -14
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { LocalOpenAICompatibleClient } from "../local-client";
import { singleUserTurn } from "../types";
describe("LocalOpenAICompatibleClient", () => {
beforeEach(() => {
@@ -31,10 +32,9 @@ describe("LocalOpenAICompatibleClient", () => {
baseUrl: "http://localhost:11434/v1",
});
const result = await client.generateCompletion({
systemPrompt: "system",
userPrompt: "user-input",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "system", user: "user-input" }),
);
expect(result.content).toBe('{"summary":"local"}');
expect(result.tokensUsed).toBe(17);
@@ -76,12 +76,14 @@ describe("LocalOpenAICompatibleClient", () => {
baseUrl: "http://localhost:11434/v1",
});
await client.generateCompletion({
systemPrompt: "s",
userPrompt: "u",
responseFormat: "json",
seed: 99,
});
await client.generateCompletion(
singleUserTurn({
system: "s",
user: "u",
responseFormat: "json",
seed: 99,
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.format).toBe("json");
@@ -105,7 +107,7 @@ describe("LocalOpenAICompatibleClient", () => {
baseUrl: "http://localhost:11434/v1",
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
const init = mockFetch.mock.calls[0][1];
expect(init.headers.Authorization).toBeUndefined();
@@ -128,7 +130,7 @@ describe("LocalOpenAICompatibleClient", () => {
baseUrl: "http://192.168.1.20:1234/v1",
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
const init = mockFetch.mock.calls[0][1];
expect(init.headers.Authorization).toBe("Bearer lm-studio-secret");
@@ -155,7 +157,9 @@ describe("LocalOpenAICompatibleClient", () => {
let caught: unknown;
try {
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(
singleUserTurn({ system: "s", user: "u" }),
);
} catch (e) {
caught = e;
}
@@ -192,7 +196,39 @@ describe("LocalOpenAICompatibleClient", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
client.generateCompletion(singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow("Local AI returned empty content");
});
it("degrades tools silently — never forwards a `tools` field", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "{}" } }],
usage: { total_tokens: 1 },
}),
});
vi.stubGlobal("fetch", mockFetch);
const client = new LocalOpenAICompatibleClient({
apiKey: null,
model: "llama3",
baseUrl: "http://localhost:11434/v1",
});
expect(client.supportsTools).toBe(false);
await client.generateCompletion(
singleUserTurn({
system: "s",
user: "u",
tools: [{ name: "t", description: "d", parameters: {} }],
toolChoice: "auto",
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body).not.toHaveProperty("tools");
expect(body).not.toHaveProperty("tool_choice");
});
});
+187 -14
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { OpenAIClient } from "../openai-client";
import { singleUserTurn } from "../types";
describe("OpenAIClient", () => {
beforeEach(() => {
@@ -23,10 +24,13 @@ describe("OpenAIClient", () => {
baseUrl: "https://api.openai.com/v1",
});
const result = await client.generateCompletion({
systemPrompt: "You are a doctor.",
userPrompt: "Analyze this data.",
});
const result = await client.generateCompletion(
singleUserTurn({
system: "You are a doctor.",
user: "Analyze this data.",
responseFormat: "json",
}),
);
expect(result.content).toBe('{"summary":"test"}');
expect(result.tokensUsed).toBe(42);
@@ -43,6 +47,66 @@ describe("OpenAIClient", () => {
expect(body).not.toHaveProperty("seed");
});
it("forces JSON mode only when responseFormat is json AND no tools (M-1)", async () => {
const replyContent = (content: string) =>
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content } }],
usage: { total_tokens: 1 },
}),
});
const client = new OpenAIClient({
apiKey: "sk-test",
model: "gpt-4o-mini",
baseUrl: "https://api.openai.com/v1",
});
// 1. Prose Coach path (no responseFormat, no tools) → NO json_object. This
// is the F1 forced-final round contract: JSON mode would corrupt prose.
const proseFetch = replyContent("Here is a plain-prose reply.");
vi.stubGlobal("fetch", proseFetch);
await client.generateCompletion(
singleUserTurn({ system: "coach", user: "how am I doing?" }),
);
expect(JSON.parse(proseFetch.mock.calls[0][1].body)).not.toHaveProperty(
"response_format",
);
// 2. responseFormat:"json" but tools present → still NO json_object (a tool
// round must not be coerced into a JSON object).
const toolFetch = replyContent("{}");
vi.stubGlobal("fetch", toolFetch);
await client.generateCompletion({
system: "s",
messages: [{ role: "user", content: "u" }],
responseFormat: "json",
tools: [
{
name: "t",
description: "d",
parameters: { type: "object", properties: {} },
},
],
});
expect(JSON.parse(toolFetch.mock.calls[0][1].body)).not.toHaveProperty(
"response_format",
);
// 3. responseFormat:"json" with no tools → json_object IS sent (insight /
// extraction callers keep strict JSON).
const jsonFetch = replyContent('{"summary":"x"}');
vi.stubGlobal("fetch", jsonFetch);
await client.generateCompletion(
singleUserTurn({ system: "s", user: "u", responseFormat: "json" }),
);
expect(JSON.parse(jsonFetch.mock.calls[0][1].body).response_format).toEqual(
{ type: "json_object" },
);
});
it("threads a deterministic seed onto the request body when supplied", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
@@ -60,11 +124,9 @@ describe("OpenAIClient", () => {
baseUrl: "https://api.openai.com/v1",
});
await client.generateCompletion({
systemPrompt: "test",
userPrompt: "test",
seed: 1234,
});
await client.generateCompletion(
singleUserTurn({ system: "test", user: "test", seed: 1234 }),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.seed).toBe(1234);
@@ -87,7 +149,9 @@ describe("OpenAIClient", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "test", userPrompt: "test" }),
client.generateCompletion(
singleUserTurn({ system: "test", user: "test" }),
),
).rejects.toThrow("OpenAI request failed (429)");
});
@@ -108,13 +172,122 @@ describe("OpenAIClient", () => {
baseUrl: "https://openrouter.ai/api/v1",
});
await client.generateCompletion({
systemPrompt: "test",
userPrompt: "test",
});
await client.generateCompletion(
singleUserTurn({ system: "test", user: "test" }),
);
expect(mockFetch.mock.calls[0][0]).toBe(
"https://openrouter.ai/api/v1/chat/completions",
);
});
it("maps tool defs onto the function wire and parses tool_calls", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: null,
tool_calls: [
{
id: "call_1",
function: {
name: "fetch_glucose",
arguments: '{"window":"last30days"}',
},
},
],
},
finish_reason: "tool_calls",
},
],
usage: {
total_tokens: 12,
prompt_tokens_details: { cached_tokens: 8 },
},
}),
});
vi.stubGlobal("fetch", mockFetch);
const client = new OpenAIClient({
apiKey: "sk-test",
model: "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
const result = await client.generateCompletion(
singleUserTurn({
system: "s",
user: "u",
tools: [
{
name: "fetch_glucose",
description: "Fetch glucose readings",
parameters: { type: "object", properties: {} },
},
],
toolChoice: "auto",
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.tools).toEqual([
{
type: "function",
function: {
name: "fetch_glucose",
description: "Fetch glucose readings",
parameters: { type: "object", properties: {} },
},
},
]);
expect(body.tool_choice).toBe("auto");
expect(result.toolCalls).toEqual([
{
id: "call_1",
name: "fetch_glucose",
arguments: '{"window":"last30days"}',
},
]);
expect(result.finishReason).toBe("tool_calls");
expect(result.cachedInputTokens).toBe(8);
});
it("folds image parts into the multimodal content array", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '{"rows":[]}' } }],
usage: { total_tokens: 3 },
}),
});
vi.stubGlobal("fetch", mockFetch);
const client = new OpenAIClient({
apiKey: "sk-test",
model: "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
await client.generateCompletion(
singleUserTurn({
system: "s",
user: "transcribe",
images: [{ mediaType: "image/png", dataBase64: "AAAA" }],
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
const userTurn = body.messages[1];
expect(userTurn.role).toBe("user");
// Text-first, then image (matches the pre-refactor OpenAI vision wire).
expect(userTurn.content[0]).toEqual({ type: "text", text: "transcribe" });
expect(userTurn.content[1]).toEqual({
type: "image_url",
image_url: { url: "data:image/png;base64,AAAA" },
});
});
});
+35 -35
View File
@@ -4,7 +4,11 @@ import { OpenAIClient } from "../openai-client";
import { AnthropicClient } from "../anthropic-client";
import { LocalOpenAICompatibleClient } from "../local-client";
import { CodexClient } from "../codex-client";
import type { AIProvider, CompletionResult } from "../types";
import {
singleUserTurn,
type AIProvider,
type CompletionResult,
} from "../types";
/**
* Provider-contract tests: every concrete AIProvider implementation must
@@ -44,10 +48,9 @@ describe("AIProvider contract", () => {
it("MockAIProvider — default response satisfies CompletionResult shape", async () => {
const provider: AIProvider = new MockAIProvider();
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
const result = await provider.generateCompletion(
singleUserTurn({ system: "sys", user: "user" }),
);
assertCompletionResult(result);
expect(result.providerType).toBe("local");
expect(result.model).toBe("mock-model");
@@ -59,18 +62,15 @@ describe("AIProvider contract", () => {
tokensUsed: [10, 20],
providerType: "codex",
});
const r1 = await mock.generateCompletion({
systemPrompt: "s",
userPrompt: "u1",
});
const r2 = await mock.generateCompletion({
systemPrompt: "s",
userPrompt: "u2",
});
const r3 = await mock.generateCompletion({
systemPrompt: "s",
userPrompt: "u3",
});
const r1 = await mock.generateCompletion(
singleUserTurn({ system: "s", user: "u1" }),
);
const r2 = await mock.generateCompletion(
singleUserTurn({ system: "s", user: "u2" }),
);
const r3 = await mock.generateCompletion(
singleUserTurn({ system: "s", user: "u3" }),
);
expect(r1.content).toBe('{"a":1}');
expect(r1.tokensUsed).toBe(10);
expect(r2.content).toBe('{"a":2}');
@@ -78,13 +78,17 @@ describe("AIProvider contract", () => {
// Last entry repeats once the queue is exhausted.
expect(r3.content).toBe('{"a":2}');
expect(mock.callCount).toBe(3);
expect(mock.calls.map((c) => c.userPrompt)).toEqual(["u1", "u2", "u3"]);
expect(mock.calls.map((c) => c.messages[0].content)).toEqual([
"u1",
"u2",
"u3",
]);
});
it("MockAIProvider — rejectWith causes generateCompletion to throw", async () => {
const mock = new MockAIProvider({ rejectWith: new Error("boom") });
await expect(
mock.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
mock.generateCompletion(singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow("boom");
});
@@ -106,10 +110,9 @@ describe("AIProvider contract", () => {
model: "gpt-4o-mini",
baseUrl: "https://api.openai.com/v1",
});
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
const result = await provider.generateCompletion(
singleUserTurn({ system: "sys", user: "user" }),
);
assertCompletionResult(result);
expect(result.providerType).toBe("admin-key");
expect(result.model).toBe("gpt-4o-mini");
@@ -132,10 +135,9 @@ describe("AIProvider contract", () => {
apiKey: "sk-ant-test",
model: "claude-3-5-sonnet-latest",
});
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
const result = await provider.generateCompletion(
singleUserTurn({ system: "sys", user: "user" }),
);
assertCompletionResult(result);
expect(result.providerType).toBe("anthropic");
expect(result.tokensUsed).toBe(14);
@@ -158,10 +160,9 @@ describe("AIProvider contract", () => {
model: "llama3:8b",
baseUrl: "http://localhost:11434/v1",
});
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
const result = await provider.generateCompletion(
singleUserTurn({ system: "sys", user: "user" }),
);
assertCompletionResult(result);
expect(result.providerType).toBe("local");
});
@@ -205,10 +206,9 @@ describe("AIProvider contract", () => {
.fn()
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
const result = await provider.generateCompletion(
singleUserTurn({ system: "sys", user: "user" }),
);
assertCompletionResult(result);
expect(result.providerType).toBe("codex");
expect(result.tokensUsed).toBe(23);
+28 -23
View File
@@ -1,5 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AIProvider, CompletionParams, CompletionResult } from "../types";
import {
singleUserTurn,
type AIProvider,
type CompletionParams,
type CompletionResult,
} from "../types";
// v1.11.0 W1 — the runner now defaults to the Postgres-backed
// provider-health ledger. These pure chain tests do not stand up a DB,
@@ -139,7 +144,7 @@ describe("runWithFallback — happy path", () => {
const result = await runWithFallback({
userId: "u1",
providers: [{ providerType: "codex", instance: codex }],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(result.parsed.summary).toBe("ok");
expect(result.raw.providerType).toBe("codex");
@@ -155,7 +160,7 @@ describe("runWithFallback — happy path", () => {
const result = await runWithFallback({
userId: "u1",
providers: [{ providerType: "openai", instance: openai }],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(result.workingProvider.providerType).toBe("openai");
expect(result.raw.providerType).toBe("admin-key");
@@ -178,7 +183,7 @@ describe("runWithFallback — primary fails 401, secondary succeeds", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(result.parsed.summary).toBe("ok");
expect(result.workingProvider.providerType).toBe("openai");
@@ -209,7 +214,7 @@ describe("runWithFallback — all-fail cascade", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
} catch (e) {
caught = e;
@@ -250,7 +255,7 @@ describe("runWithFallback — non-hard errors bubble unchanged", () => {
{ providerType: "codex", instance: breaking },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
} catch (e) {
caught = e;
@@ -281,7 +286,7 @@ describe("runWithFallback — last-working cache", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(getLastWorkingProvider("u-cache")).toBe("openai");
@@ -294,7 +299,7 @@ describe("runWithFallback — last-working cache", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
// codex was called exactly once (first call's failure); the cache
@@ -324,7 +329,7 @@ describe("runWithFallback — last-working cache", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(getLastWorkingProvider("u-ttl")).toBe("openai");
@@ -340,7 +345,7 @@ describe("runWithFallback — last-working cache", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(getLastWorkingProvider("u-ttl")).toBe("codex");
});
@@ -353,7 +358,7 @@ describe("runWithFallback — last-working cache", () => {
await runWithFallback({
userId: "u-clear",
providers: [{ providerType: "openai", instance: ok }],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(getLastWorkingProvider("u-clear")).toBe("openai");
clearLastWorkingProviderCache();
@@ -377,7 +382,7 @@ describe("runRawCompletionWithFallback — legacy route shim", () => {
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(result.result.content).toBe('{ "legacy": true }');
expect(result.workingProvider.providerType).toBe("admin-openai");
@@ -404,7 +409,7 @@ describe("runRawCompletionWithFallback — legacy route shim", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(result.result.content).toBe('{ "ok": true }');
expect(result.workingProvider.providerType).toBe("openai");
@@ -432,7 +437,7 @@ describe("runRawCompletionWithFallback — legacy route shim", () => {
{ providerType: "codex", instance: a },
{ providerType: "admin-openai", instance: b },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
} catch (e) {
caught = e;
@@ -449,7 +454,7 @@ describe("runWithFallback — empty input", () => {
await runWithFallback({
userId: "u-empty",
providers: [],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
} catch (e) {
caught = e;
@@ -483,7 +488,7 @@ describe("provider-health ledger — auth-failure negative cache", () => {
await runRawCompletionWithFallback({
userId: "u-neg",
providers,
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
expect((await ledger.getSkipHints("u-neg")).get("codex")?.reason).toBe(
@@ -500,7 +505,7 @@ describe("provider-health ledger — auth-failure negative cache", () => {
await runRawCompletionWithFallback({
userId: "u-neg",
providers,
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
expect(codex.callCount).toBe(codexCallsBefore); // not re-burned
@@ -519,7 +524,7 @@ describe("provider-health ledger — auth-failure negative cache", () => {
await runRawCompletionWithFallback({
userId: "u-clear-neg",
providers: [{ providerType: "codex", instance: codex }],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
expect((await ledger.getSkipHints("u-clear-neg")).size).toBe(0);
@@ -559,7 +564,7 @@ describe("provider-health ledger — local model as guaranteed floor", () => {
{ providerType: "anthropic", instance: dead("anthropic") },
{ providerType: "local", instance: local },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
@@ -583,7 +588,7 @@ describe("provider-health ledger — local model as guaranteed floor", () => {
await runRawCompletionWithFallback({
userId: "u-local-hard",
providers: [{ providerType: "local", instance: local }],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
} catch (e) {
@@ -597,7 +602,7 @@ describe("provider-health ledger — local model as guaranteed floor", () => {
const result = await runRawCompletionWithFallback({
userId: "u-local-hard",
providers: [{ providerType: "local", instance: local }],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
expect(result.workingProvider.providerType).toBe("local");
@@ -623,7 +628,7 @@ describe("AllProvidersFailedError — primaryCredentialExpired", () => {
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
} catch (e) {
@@ -651,7 +656,7 @@ describe("AllProvidersFailedError — primaryCredentialExpired", () => {
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
} catch (e) {
+18 -17
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
import { singleUserTurn } from "../types";
const annotateMock = vi.fn();
@@ -75,13 +76,13 @@ describe("generateInsight() — corrective retry mentions rationale", () => {
const provider = new MockAIProvider({
responses: [JSON.stringify(broken), JSON.stringify(validResponse)],
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "Original user prompt.",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "Original user prompt." }),
);
expect(outcome.attempts).toBe(2);
expect(outcome.retried).toBe(true);
const retryUserPrompt = provider.calls[1].userPrompt;
const retryUserPrompt = provider.calls[1].messages[0].content as string;
expect(retryUserPrompt).toContain("Original user prompt.");
expect(retryUserPrompt).toContain("rationale");
expect(retryUserPrompt).toContain("dataWindow");
@@ -108,10 +109,10 @@ describe("generateInsight() — corrective retry mentions rationale", () => {
const provider = new MockAIProvider({
responses: [JSON.stringify(broken), JSON.stringify(validResponse)],
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "user",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "user" }),
);
expect(outcome.attempts).toBe(2);
expect(outcome.retried).toBe(true);
});
@@ -126,10 +127,10 @@ describe("generateInsight() — rationale-coverage annotation", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(validResponse),
});
await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "user",
});
await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "user" }),
);
expect(annotateMock).toHaveBeenCalled();
const meta = annotateMock.mock.calls[0][0]?.meta as Record<string, unknown>;
expect(meta).toMatchObject({
@@ -157,10 +158,10 @@ describe("generateInsight() — rationale-coverage annotation", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(twoRecs),
});
await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "user",
});
await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "user" }),
);
const meta = annotateMock.mock.calls[0][0]?.meta as Record<string, unknown>;
expect(meta).toMatchObject({
ai_rationale_total_recommendations: 2,
+239 -72
View File
@@ -1,5 +1,13 @@
import { safeFetch } from "@/lib/safe-fetch";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import type {
AIProvider,
AiContentPart,
AiMessage,
AiToolCall,
AiToolDef,
CompletionParams,
CompletionResult,
} from "./types";
/**
* v1.18.9 — Anthropic Messages API content blocks. The text-only path sends a
@@ -7,6 +15,10 @@ import type { AIProvider, CompletionParams, CompletionResult } from "./types";
* uploaded image / PDF is framed strictly as DATA to transcribe by the system
* prompt — never as instructions (prompt-injection backstop is the human
* review screen downstream).
*
* v1.20.0 — adds the `tool_use` (assistant requests a tool) and `tool_result`
* (a `role:"user"` turn answering one) blocks so a multi-round tool loop (F1)
* round-trips natively.
*/
type AnthropicContentBlock =
| { type: "text"; text: string }
@@ -17,45 +29,22 @@ type AnthropicContentBlock =
| {
type: "document";
source: { type: "base64"; media_type: "application/pdf"; data: string };
}
| {
type: "tool_use";
id: string;
name: string;
input: Record<string, unknown>;
}
| {
type: "tool_result";
tool_use_id: string;
content: string;
};
/**
* Build the user-turn content for a vision request: the image / document
* blocks first (so the model reads the report before the instruction), then
* the JSON-wrapped instruction text. Returns null when there is nothing to
* attach, so the caller keeps the bare-string text path unchanged.
*/
function buildVisionContent(
params: CompletionParams,
text: string,
): AnthropicContentBlock[] | null {
const images = params.images ?? [];
const documents = params.documents ?? [];
if (images.length === 0 && documents.length === 0) return null;
const blocks: AnthropicContentBlock[] = [];
for (const img of images) {
blocks.push({
type: "image",
source: {
type: "base64",
media_type: img.mediaType,
data: img.dataBase64,
},
});
}
for (const doc of documents) {
blocks.push({
type: "document",
source: {
type: "base64",
media_type: "application/pdf",
data: doc.dataBase64,
},
});
}
blocks.push({ type: "text", text });
return blocks;
interface AnthropicWireMessage {
role: "user" | "assistant";
content: string | AnthropicContentBlock[];
}
interface AnthropicClientConfig {
@@ -67,13 +56,99 @@ interface AnthropicClientConfig {
const DEFAULT_BASE_URL = "https://api.anthropic.com/v1";
const ANTHROPIC_VERSION = "2023-06-01";
const JSON_INSTRUCTION =
"\n\nRespond only with valid JSON matching the requested schema. Do not include any prose, markdown fences, or explanation outside the JSON object.";
/**
* Wrap the user prompt so Claude reliably emits valid JSON.
* Anthropic's Messages API has no `response_format: json_object` knob; the
* documented best-practice is an explicit instruction in the user message.
* Map an `AiContentPart[]` body into Anthropic content blocks. The media blocks
* (image / document) are emitted FIRST and the text blocks LAST so the model
* reads the report before the instruction — matching the pre-refactor
* `buildVisionContent` ordering exactly.
*/
function wrapForJson(userPrompt: string): string {
return `${userPrompt}\n\nRespond only with valid JSON matching the requested schema. Do not include any prose, markdown fences, or explanation outside the JSON object.`;
function mapParts(parts: AiContentPart[]): AnthropicContentBlock[] {
const media: AnthropicContentBlock[] = [];
const text: AnthropicContentBlock[] = [];
for (const part of parts) {
if (part.type === "text") {
text.push({ type: "text", text: part.text });
} else if (part.type === "image") {
media.push({
type: "image",
source: {
type: "base64",
media_type: part.mediaType,
data: part.dataBase64,
},
});
} else if (part.type === "document") {
media.push({
type: "document",
source: {
type: "base64",
media_type: "application/pdf",
data: part.dataBase64,
},
});
}
}
return [...media, ...text];
}
/**
* Map one `AiMessage` to its Anthropic wire turn. A `role:"tool"` turn becomes
* a `role:"user"` turn carrying a `tool_result` block (Anthropic's convention).
* An assistant turn with `toolCalls` emits `tool_use` blocks alongside any text.
*/
function mapMessage(m: AiMessage): AnthropicWireMessage {
if (m.role === "tool") {
return {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: m.toolCallId ?? "",
content: typeof m.content === "string" ? m.content : "",
},
],
};
}
const hasToolCalls =
m.role === "assistant" && !!m.toolCalls && m.toolCalls.length > 0;
// Plain-string content with no tool calls stays a bare string on the wire —
// byte-identical to the pre-refactor text-only path. Only build a block array
// when there are content parts or tool_use blocks to carry.
if (typeof m.content === "string" && !hasToolCalls) {
return { role: m.role, content: m.content };
}
const blocks: AnthropicContentBlock[] =
typeof m.content === "string"
? m.content.length > 0
? [{ type: "text", text: m.content }]
: []
: mapParts(m.content);
if (hasToolCalls) {
for (const tc of m.toolCalls!) {
let input: Record<string, unknown> = {};
try {
input = JSON.parse(tc.arguments) as Record<string, unknown>;
} catch {
input = {};
}
blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input });
}
}
return { role: m.role, content: blocks };
}
/** Map tool defs into the Anthropic `tools` array. */
function buildAnthropicTools(tools: AiToolDef[]) {
return tools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.parameters,
}));
}
export class AnthropicClient implements AIProvider {
@@ -93,14 +168,71 @@ export class AnthropicClient implements AIProvider {
);
const url = `${baseUrl}/messages`;
// v1.18.9 — fold vision inputs (Lab-OCR) into the user turn when present.
// The text-only path keeps a bare-string content; the vision path sends a
// typed content array (image / document blocks + the instruction text).
// The `{`-prefill JSON trick works after either shape.
const wrappedText = wrapForJson(params.userPrompt);
const visionContent = buildVisionContent(params, wrappedText);
const userContent: AnthropicContentBlock[] | string =
visionContent ?? wrappedText;
const hasTools = !!params.tools && params.tools.length > 0;
// v1.20.0 — the `{`-prefill JSON trick injects an assistant turn that is
// incompatible with a `tool_use` response in the same call, so tools and
// the prefill are mutually exclusive: when tools are present we drop the
// prefill (the tool flow is non-JSON-prefill).
const usePrefill = params.responseFormat === "json" && !hasTools;
const wireMessages: AnthropicWireMessage[] =
params.messages.map(mapMessage);
// JSON-reliability instruction. The pre-refactor client appended this to
// the user prompt UNCONDITIONALLY (not gated on `responseFormat`), so every
// Anthropic caller — including the Coach prose path — saw it. Preserve that
// exactly by appending to the LAST user turn's text. Tools imply the
// non-JSON tool flow, so skip the instruction when tools are present.
if (!hasTools) {
for (let i = wireMessages.length - 1; i >= 0; i -= 1) {
if (wireMessages[i].role !== "user") continue;
const turn = wireMessages[i];
if (typeof turn.content === "string") {
turn.content = `${turn.content}${JSON_INSTRUCTION}`;
} else {
// Merge into the trailing text block when there is one (vision turns
// end with the instruction text) so the wire stays a single text
// block — byte-identical to the pre-refactor `wrapForJson` output.
const last = turn.content[turn.content.length - 1];
if (last && last.type === "text") {
last.text = `${last.text}${JSON_INSTRUCTION}`;
} else {
turn.content = [
...turn.content,
{ type: "text", text: JSON_INSTRUCTION },
];
}
}
break;
}
}
if (usePrefill) {
wireMessages.push({ role: "assistant", content: "{" });
}
// v1.20.0 — prompt-cache the stable system prefix. Anthropic needs an
// explicit `cache_control` marker; the system block carries the large
// brand-free reference grounding + persona, so marking it lets repeated
// calls (status batch, briefing) read it from cache. Surface
// `cache_read_input_tokens` for observability.
const system = [
{
type: "text" as const,
text: params.system,
cache_control: { type: "ephemeral" as const },
},
];
const tools = hasTools
? buildAnthropicTools(params.tools as AiToolDef[])
: undefined;
const toolChoice =
hasTools && params.toolChoice
? params.toolChoice === "none"
? { type: "none" as const }
: { type: "auto" as const }
: undefined;
const res = await safeFetch(
url,
@@ -114,26 +246,14 @@ export class AnthropicClient implements AIProvider {
// NOTE: Anthropic's Messages API has no `seed` parameter, so
// `params.seed` is intentionally not forwarded here — output on this
// provider is non-deterministic regardless of the pinned seed.
//
// v1.18.7 — JSON-reliability prefill. For the structured surfaces
// (`responseFormat: "json"`) we seed the assistant turn with a bare
// `{`. Anthropic continues from that token, so the first emitted
// character is already inside a JSON object — this drops the
// first-pass "prose preamble before the JSON" failure the
// fence-stripping net otherwise has to catch. We re-prepend the `{`
// to the returned text below so the caller sees a complete object.
body: JSON.stringify({
model: this.config.model,
max_tokens: params.maxTokens ?? 1000,
temperature: params.temperature ?? 0.3,
system: params.systemPrompt,
messages:
params.responseFormat === "json"
? [
{ role: "user", content: userContent },
{ role: "assistant", content: "{" },
]
: [{ role: "user", content: userContent }],
system,
messages: wireMessages,
...(tools ? { tools } : {}),
...(toolChoice ? { tool_choice: toolChoice } : {}),
}),
},
// 60 s ceiling — see openai-client.ts for the rationale.
@@ -164,14 +284,40 @@ export class AnthropicClient implements AIProvider {
}
const json = (await res.json()) as {
content?: Array<{ type: string; text?: string }>;
usage?: { input_tokens?: number; output_tokens?: number };
content?: Array<{
type: string;
text?: string;
id?: string;
name?: string;
input?: Record<string, unknown>;
}>;
stop_reason?: string;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
};
};
const textBlock = json.content?.find((c) => c.type === "text");
const rawText = textBlock?.text;
if (!rawText) {
// v1.20.0 — surface tool_use blocks for F1's loop.
const toolUseBlocks = (json.content ?? []).filter(
(c) => c.type === "tool_use",
);
const toolCalls: AiToolCall[] | undefined =
toolUseBlocks.length > 0
? toolUseBlocks.map((b) => ({
id: b.id ?? "",
name: b.name ?? "",
arguments: JSON.stringify(b.input ?? {}),
}))
: undefined;
// A tool_use-only reply carries no text — that is valid (F1). Only an empty
// reply with neither text NOR a tool call is an error.
if (!rawText && !toolCalls) {
throw new Error("Anthropic returned empty content");
}
@@ -180,19 +326,40 @@ export class AnthropicClient implements AIProvider {
// it so the caller parses a complete object. Guard against a model that
// (rarely) echoes the prefill itself.
const content =
params.responseFormat === "json" && !rawText.trimStart().startsWith("{")
usePrefill && rawText && !rawText.trimStart().startsWith("{")
? `{${rawText}`
: rawText;
: (rawText ?? "");
const inputTokens = json.usage?.input_tokens ?? 0;
const outputTokens = json.usage?.output_tokens ?? 0;
const tokensUsed = inputTokens + outputTokens || null;
const cachedInputTokens = json.usage?.cache_read_input_tokens ?? null;
// The presence of tool_use blocks is authoritative for the tool-loop gate:
// F1's loop continues only when `finishReason === "tool_calls"`. Anthropic
// normally pairs tool_use blocks with `stop_reason: "tool_use"`, but if it
// ever returns the blocks under a different (or absent) stop_reason, deriving
// the reason from `stop_reason` alone would drop the tool request and the
// loop would surface the empty tool_use-only reply as the final answer.
const finishReason: CompletionResult["finishReason"] =
toolCalls && toolCalls.length > 0
? "tool_calls"
: json.stop_reason === "tool_use"
? "tool_calls"
: json.stop_reason === "max_tokens"
? "length"
: json.stop_reason === "end_turn"
? "stop"
: undefined;
return {
content,
tokensUsed,
cachedInputTokens,
model: this.config.model,
providerType: "anthropic",
...(toolCalls ? { toolCalls } : {}),
finishReason,
};
}
}
@@ -63,6 +63,10 @@ vi.mock("@/lib/logging/context", () => ({
}) => {
annotateCalls.push({ name: fields.action?.name, meta: fields.meta });
},
// The snapshot's request-scoped read memoisation (`memoizePerRequest`) reads
// the active event here; no request context in this unit test → null, so the
// memoiser falls through to the factory and behaviour is unchanged.
getEvent: () => null,
}));
vi.mock("./glp1-snapshot", () => ({
@@ -7,6 +7,8 @@ import {
__resetCoachSnapshotCacheForTests,
buildCoachSnapshot,
} from "../snapshot";
import { eventStorage } from "@/lib/logging/context";
import { WideEventBuilder } from "@/lib/logging/event-builder";
vi.mock("@/lib/db", () => ({
prisma: {
@@ -994,3 +996,67 @@ describe("buildCoachSnapshot — reference grounding (W7)", () => {
}
});
});
describe("buildCoachSnapshot request-scoped read sharing (H-1)", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetCoachSnapshotCacheForTests();
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null });
resolveModuleMapMock.mockResolvedValue(allModulesEnabled());
featuresMock.mockResolvedValue({
bloodPressure: undefined,
weight: undefined,
pulse: undefined,
mood: undefined,
});
});
it("runs extractFeatures + the prefs read once across distinct-scope tool builds in one request", async () => {
// The F1 coach tools each call buildCoachSnapshot with a DIFFERENT
// single-source scope, so the 60s snapshot LRU (keyed on the source list)
// does NOT share their reads. Memoising the two heavy per-user reads on the
// request-scoped WideEventBuilder cache collapses the fan-out to one of each.
await eventStorage.run(new WideEventBuilder(), async () => {
// Same window (→ same windowDays key), distinct sources (→ distinct LRU
// keys ⇒ two separate snapshot builds, mirroring the real tool fan-out).
await Promise.all([
buildCoachSnapshot("user-h1", {
sources: ["bp"],
window: "last30days",
}),
buildCoachSnapshot("user-h1", {
sources: ["pulse"],
window: "last30days",
}),
]);
});
expect(featuresMock).toHaveBeenCalledTimes(1);
expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1);
});
it("keeps separate caches per request (no cross-request leakage)", async () => {
// Distinct users so the in-process 60s snapshot LRU (keyed on userId) cannot
// short-circuit the second request — the only thing under test here is that
// the request-scoped feature/prefs cache does NOT survive across requests.
await eventStorage.run(new WideEventBuilder(), async () => {
await buildCoachSnapshot("user-h1a", {
sources: ["bp"],
window: "last30days",
});
});
await eventStorage.run(new WideEventBuilder(), async () => {
await buildCoachSnapshot("user-h1b", {
sources: ["bp"],
window: "last30days",
});
});
expect(featuresMock).toHaveBeenCalledTimes(2);
expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,197 @@
/**
* v1.20.0 (F1) — Coach retrieval tool executor: dispatch, grounding, and the
* gate-preservation contract. The executor must NEVER throw and must return a
* structured `{ present: false }` for an absent domain — the hallucination
* audit asserts the model can always tell "no data" from "data".
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
const buildCoachSnapshot =
vi.fn<(userId: string, scope?: unknown) => Promise<CoachSnapshotResult>>();
vi.mock("@/lib/ai/coach/snapshot", () => ({
buildCoachSnapshot: (userId: string, scope?: unknown) =>
buildCoachSnapshot(userId, scope),
}));
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
function snapshot(
sections: Record<string, unknown>,
referenceGrounding: string | null = null,
): CoachSnapshotResult {
return {
snapshotJson: JSON.stringify(sections),
sections,
provenance: { windows: [], metrics: [] },
referenceGrounding,
};
}
describe("executeCoachTool", () => {
beforeEach(() => {
buildCoachSnapshot.mockReset();
});
it("returns the matching section for get_metric_series when present", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot(
{ bloodPressure: { aggregate: { avgSys30: 128 } } },
"REFERENCE GROUNDING\nBP systolic 128 …",
),
);
const result = await executeCoachTool({
userId: "u1",
name: "get_metric_series",
rawArguments: JSON.stringify({ metric: "bp" }),
});
expect(result.present).toBe(true);
expect(result.data).toMatchObject({
metric: "bp",
section: { aggregate: { avgSys30: 128 } },
});
expect(result.grounding).toContain("REFERENCE GROUNDING");
});
it("returns { present: false } (never throws) when the section is absent", async () => {
buildCoachSnapshot.mockResolvedValue(snapshot({}));
const result = await executeCoachTool({
userId: "u1",
name: "get_metric_series",
rawArguments: JSON.stringify({ metric: "hrv" }),
});
expect(result.present).toBe(false);
expect(result.reason).toBe("no_data");
expect(result.data).toBeUndefined();
});
it("points get_metric_series(glucose) at the dedicated tool", async () => {
const result = await executeCoachTool({
userId: "u1",
name: "get_metric_series",
rawArguments: JSON.stringify({ metric: "glucose" }),
});
expect(result.present).toBe(false);
expect(result.reason).toBe("use_get_glucose_panel");
// Must not even read the snapshot for a wrong-tool call.
expect(buildCoachSnapshot).not.toHaveBeenCalled();
});
it("rejects invalid arguments with a grounded miss, not a throw", async () => {
const result = await executeCoachTool({
userId: "u1",
name: "get_metric_series",
rawArguments: JSON.stringify({ metric: "not_a_metric" }),
});
expect(result.present).toBe(false);
expect(result.reason).toBe("invalid_arguments");
});
it("rejects malformed JSON arguments with a grounded miss", async () => {
const result = await executeCoachTool({
userId: "u1",
name: "get_metric_series",
rawArguments: "{not json",
});
expect(result.present).toBe(false);
expect(result.reason).toBe("invalid_arguments");
});
it("rejects an unknown tool name without throwing", async () => {
const result = await executeCoachTool({
userId: "u1",
name: "drop_table_users",
rawArguments: "{}",
});
expect(result.present).toBe(false);
expect(result.reason).toBe("unknown_tool");
expect(buildCoachSnapshot).not.toHaveBeenCalled();
});
it("degrades a snapshot-builder throw to { present: false }", async () => {
buildCoachSnapshot.mockRejectedValue(new Error("db down"));
const result = await executeCoachTool({
userId: "u1",
name: "get_glucose_panel",
rawArguments: "{}",
});
expect(result.present).toBe(false);
expect(result.reason).toBe("retrieval_failed");
});
it("combines sleep nights + rhythm sections", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({
sleep: { timeline: { recent: [] } },
sleepRhythm: { sleepDebt: 120, chronotype: "intermediate" },
}),
);
const result = await executeCoachTool({
userId: "u1",
name: "get_sleep",
rawArguments: "{}",
});
expect(result.present).toBe(true);
expect(result.data).toMatchObject({
nights: { timeline: { recent: [] } },
rhythm: { sleepDebt: 120 },
});
});
it("surfaces glp1 from weeklyContext on get_medication_compliance", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({
compliance: { rate: 0.92 },
weeklyContext: { glp1: { drug: "—", dose: "—" } },
}),
);
const result = await executeCoachTool({
userId: "u1",
name: "get_medication_compliance",
rawArguments: "{}",
});
expect(result.present).toBe(true);
expect(result.data).toMatchObject({
compliance: { rate: 0.92 },
glp1: { drug: "—" },
});
});
it("filters labs by analyte and reports analyte_not_found", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({
labs: { recent: [{ name: "LDL", value: "120" }] },
}),
);
const hit = await executeCoachTool({
userId: "u1",
name: "get_labs",
rawArguments: JSON.stringify({ analyte: "ldl" }),
});
expect(hit.present).toBe(true);
expect(hit.data).toMatchObject({ recent: [{ name: "LDL" }] });
const miss = await executeCoachTool({
userId: "u1",
name: "get_labs",
rawArguments: JSON.stringify({ analyte: "glucose" }),
});
expect(miss.present).toBe(false);
expect(miss.reason).toBe("analyte_not_found");
});
it("never passes userId as a tool argument (read from session only)", async () => {
buildCoachSnapshot.mockResolvedValue(snapshot({}));
// A crafted argument trying to smuggle a userId must be rejected by the
// strict schema, never forwarded to the read.
const result = await executeCoachTool({
userId: "u1",
name: "get_metric_series",
rawArguments: JSON.stringify({ metric: "bp", userId: "victim" }),
});
expect(result.present).toBe(false);
expect(result.reason).toBe("invalid_arguments");
});
});
@@ -0,0 +1,100 @@
/**
* v1.20.0 (F1) — the DATA INVENTORY manifest: it must mark present/absent per
* domain from the snapshot's sections, carry restMode + cycleEnabled, and
* render a compact, brand-free, grounding-honest block. The model reads this to
* know what is fetchable so it never invents a metric.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
const buildCoachSnapshot = vi.fn<() => Promise<CoachSnapshotResult>>();
const isCycleAvailableForUser = vi.fn<() => Promise<boolean>>();
vi.mock("@/lib/ai/coach/snapshot", () => ({
buildCoachSnapshot: () => buildCoachSnapshot(),
}));
vi.mock("@/lib/cycle/gate", () => ({
isCycleAvailableForUser: () => isCycleAvailableForUser(),
}));
import {
buildCoachDataInventory,
renderDataInventory,
} from "@/lib/ai/coach/tools/inventory";
function snapshot(
sections: Record<string, unknown>,
counts?: Record<string, number>,
): CoachSnapshotResult {
return {
snapshotJson: JSON.stringify(sections),
sections,
provenance: {
windows: [],
metrics: [],
...(counts ? { counts: counts as never } : {}),
},
referenceGrounding: null,
};
}
describe("buildCoachDataInventory", () => {
beforeEach(() => {
buildCoachSnapshot.mockReset();
isCycleAvailableForUser.mockReset();
isCycleAvailableForUser.mockResolvedValue(false);
});
it("marks present domains and carries counts + window", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot(
{
bloodPressure: { aggregate: {} },
glucose: { byContext: {} },
scope: { window: "last7days" },
},
{ bp: 42, glucose: 100 },
),
);
const inv = await buildCoachDataInventory("u1", { window: "last7days" });
expect(inv.window).toBe("last7days");
const bp = inv.entries.find((e) => e.metric === "bp");
expect(bp).toMatchObject({ present: true, count: 42 });
const glucose = inv.entries.find((e) => e.tool === "get_glucose_panel");
expect(glucose).toMatchObject({ present: true, count: 100 });
const sleep = inv.entries.find((e) => e.tool === "get_sleep");
expect(sleep?.present).toBe(false);
});
it("reports restMode + cycleEnabled", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({ illness: { restMode: true } }),
);
isCycleAvailableForUser.mockResolvedValue(true);
const inv = await buildCoachDataInventory("u1", undefined);
expect(inv.restMode).toBe(true);
expect(inv.cycleEnabled).toBe(true);
});
});
describe("renderDataInventory", () => {
it("renders a grounding-honest, brand-free block", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot(
{ bloodPressure: {}, scope: { window: "last30days" } },
{ bp: 5 },
),
);
isCycleAvailableForUser.mockResolvedValue(false);
const inv = await buildCoachDataInventory("u1", undefined);
const text = renderDataInventory(inv);
expect(text).toContain("DATA INVENTORY");
expect(text).toContain("Never cite a figure you did not fetch");
expect(text).toContain("blood pressure: present → get_metric_series");
// brand-free: no vendor names.
expect(text.toLowerCase()).not.toContain("withings");
expect(text.toLowerCase()).not.toContain("oura");
expect(text.toLowerCase()).not.toContain("whoop");
});
});
@@ -0,0 +1,198 @@
/**
* v1.20.0 (F1) — bounded Coach retrieval loop: tool dispatch round-trip, the
* max-rounds cap with a forced-final answer, parallel tool execution, and the
* summed-token accounting the budget reconcile depends on.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
const executeCoachTool = vi.fn<() => Promise<CoachToolResult>>();
vi.mock("@/lib/ai/coach/tools/executor", () => ({
executeCoachTool: () => executeCoachTool(),
}));
// The loop calls the real fallback runner; stub it to a deterministic
// per-round script so we exercise the loop control flow, not the chain.
const runRawCompletionWithFallback = vi.fn();
vi.mock("@/lib/ai/provider-runner", () => ({
runRawCompletionWithFallback: (args: unknown) =>
runRawCompletionWithFallback(args),
}));
import { runCoachToolLoop, HARD_CAP } from "@/lib/ai/coach/tools/loop";
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
function completion(opts: {
content: string;
tokensUsed?: number;
toolCalls?: Array<{ id: string; name: string; arguments: string }>;
finishReason?: "stop" | "tool_calls" | "length";
}) {
return {
result: {
content: opts.content,
tokensUsed: opts.tokensUsed ?? 10,
model: "mock",
providerType: "anthropic" as const,
...(opts.toolCalls ? { toolCalls: opts.toolCalls } : {}),
finishReason: opts.finishReason,
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
};
}
const baseArgs = {
userId: "u1",
providers: [],
system: "sys",
messages: [{ role: "user" as const, content: "how is my bp?" }],
tools: COACH_TOOL_DEFS,
};
describe("runCoachToolLoop", () => {
beforeEach(() => {
executeCoachTool.mockReset();
runRawCompletionWithFallback.mockReset();
});
it("runs one tool round then streams the final prose (happy path)", async () => {
executeCoachTool.mockResolvedValue({ present: true, data: { x: 1 } });
runRawCompletionWithFallback
.mockResolvedValueOnce(
completion({
content: "",
tokensUsed: 30,
finishReason: "tool_calls",
toolCalls: [
{
id: "c1",
name: "get_metric_series",
arguments: '{"metric":"bp"}',
},
],
}),
)
.mockResolvedValueOnce(
completion({ content: "Your BP looks steady.", tokensUsed: 20 }),
);
const out = await runCoachToolLoop(baseArgs);
expect(out.rounds).toBe(2);
expect(out.result.content).toBe("Your BP looks steady.");
expect(out.totalTokens).toBe(50); // summed across both rounds
expect(out.toolTrace).toEqual([
{ name: "get_metric_series", present: true },
]);
// The second call must forbid tools? No — round 2 still offers them
// (round <= MAX_ROUNDS). It simply chose to answer.
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(2);
});
it("answers immediately when the model emits no tool calls", async () => {
runRawCompletionWithFallback.mockResolvedValueOnce(
completion({ content: "Here's what I can help with.", tokensUsed: 12 }),
);
const out = await runCoachToolLoop(baseArgs);
expect(out.rounds).toBe(1);
expect(out.toolTrace).toHaveLength(0);
expect(executeCoachTool).not.toHaveBeenCalled();
});
it("executes parallel tool calls in one round", async () => {
executeCoachTool.mockResolvedValue({ present: true });
runRawCompletionWithFallback
.mockResolvedValueOnce(
completion({
content: "",
finishReason: "tool_calls",
toolCalls: [
{
id: "a",
name: "get_metric_series",
arguments: '{"metric":"bp"}',
},
{ id: "b", name: "get_sleep", arguments: "{}" },
],
}),
)
.mockResolvedValueOnce(completion({ content: "Combined view." }));
const out = await runCoachToolLoop(baseArgs);
expect(executeCoachTool).toHaveBeenCalledTimes(2);
expect(out.toolTrace).toHaveLength(2);
expect(out.result.content).toBe("Combined view.");
});
it("forces a final answer at the hard cap (no infinite loop)", async () => {
executeCoachTool.mockResolvedValue({ present: false });
// The model keeps asking for tools every round. The loop must still
// terminate: the last allowed round offers no tools (toolChoice none) so
// the model is forced to produce prose.
runRawCompletionWithFallback.mockImplementation(
(args: { params: { toolChoice?: string } }) => {
if (args.params.toolChoice === "none") {
return Promise.resolve(
completion({ content: "Final forced answer." }),
);
}
return Promise.resolve(
completion({
content: "",
finishReason: "tool_calls",
toolCalls: [
{
id: "x",
name: "get_metric_series",
arguments: '{"metric":"bp"}',
},
],
}),
);
},
);
const out = await runCoachToolLoop(baseArgs);
expect(out.rounds).toBe(HARD_CAP);
expect(out.result.content).toBe("Final forced answer.");
// The final round must have been called with toolChoice "none".
const lastCall =
runRawCompletionWithFallback.mock.calls[
runRawCompletionWithFallback.mock.calls.length - 1
][0];
expect(lastCall.params.toolChoice).toBe("none");
});
it("appends assistant(toolCalls) + tool turns to the message array", async () => {
executeCoachTool.mockResolvedValue({ present: true, data: { v: 1 } });
let secondCallMessages: unknown;
runRawCompletionWithFallback
.mockResolvedValueOnce(
completion({
content: "",
finishReason: "tool_calls",
toolCalls: [{ id: "c1", name: "get_sleep", arguments: "{}" }],
}),
)
.mockImplementationOnce((args: { params: { messages: unknown } }) => {
secondCallMessages = args.params.messages;
return Promise.resolve(completion({ content: "done" }));
});
await runCoachToolLoop(baseArgs);
const msgs = secondCallMessages as Array<{
role: string;
toolCallId?: string;
toolCalls?: unknown[];
}>;
// user, assistant(toolCalls), tool
expect(msgs).toHaveLength(3);
expect(msgs[1].role).toBe("assistant");
expect(msgs[1].toolCalls).toHaveLength(1);
expect(msgs[2].role).toBe("tool");
expect(msgs[2].toolCallId).toBe("c1");
});
});
@@ -0,0 +1,31 @@
/**
* v1.20.0 (F1) — every CoachScopeSource must either resolve to a snapshot
* section key (so get_metric_series can fetch it) or be one of the
* dedicated-tool sources. A new source added to the scope enum without a key
* here would otherwise fall through to a fabricated "no data" answer for a
* metric the user actually has — this guard fails the build first.
*/
import { describe, expect, it } from "vitest";
import { coachScopeSourceSchema } from "@/lib/ai/coach/types";
import {
COACH_SOURCE_SNAPSHOT_KEY,
METRIC_SERIES_EXCLUDED_SOURCES,
} from "@/lib/ai/coach/tools/source-keys";
describe("COACH_SOURCE_SNAPSHOT_KEY", () => {
it("covers every CoachScopeSource (key map or dedicated tool)", () => {
const uncovered = coachScopeSourceSchema.options.filter(
(source) =>
COACH_SOURCE_SNAPSHOT_KEY[source] === undefined &&
!METRIC_SERIES_EXCLUDED_SOURCES.has(source),
);
expect(uncovered).toEqual([]);
});
it("never maps an excluded (dedicated-tool) source to a series key", () => {
for (const source of METRIC_SERIES_EXCLUDED_SOURCES) {
expect(COACH_SOURCE_SNAPSHOT_KEY[source]).toBeUndefined();
}
});
});
+9 -6
View File
@@ -22,6 +22,7 @@
* Server-only — reads `@/lib/db` through the provider resolver.
*/
import { hasAnyConfiguredProvider, resolveProvider } from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import {
buildDateKey,
getDailyTokenSpend,
@@ -151,12 +152,14 @@ export async function deriveClarifyingQuestions(
resolveLocale(locale),
snapshotJson,
);
const result = await provider.generateCompletion({
systemPrompt,
userPrompt,
temperature: 0.4,
maxTokens: QUESTIONS_MAX_TOKENS,
});
const result = await provider.generateCompletion(
singleUserTurn({
system: systemPrompt,
user: userPrompt,
temperature: 0.4,
maxTokens: QUESTIONS_MAX_TOKENS,
}),
);
if (typeof result.tokensUsed === "number" && result.tokensUsed > 0) {
await recordSpend({ userId, tokens: result.tokensUsed, dateKey });
}
+60 -28
View File
@@ -43,6 +43,7 @@ import {
} from "@/lib/insights/derived/sleep-rhythm";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
import { annotate } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
import { buildDerivedSnapshotBlock } from "./derived-snapshot";
import { buildCoachMemoryBlock } from "./memory-snapshot";
@@ -83,6 +84,16 @@ import type {
export interface CoachSnapshotResult {
snapshotJson: string;
/**
* v1.20.0 (F1) — the structured, post-degrade snapshot record `snapshotJson`
* is serialised from. Keyed by domain block (`bloodPressure`, `weight`,
* `pulse`, `mood`, `compliance`, `glucose`, `sleep`, `sleepRhythm`, `labs`,
* `illness`, `derived`, `dayStrain`, `trajectory`, `weeklyContext`, …) plus a
* `scope` block. The coach tool executor reads a single domain block out of
* this so an on-demand retrieval tool returns exactly the numbers the legacy
* snapshot-stuffing path would have shown — identical builder, gates, and I/O.
*/
sections: Record<string, unknown>;
/**
* Provenance built from snapshot keys actually present. Stays in
* sync with the SNAPSHOT block so the source-chip row mirrors what
@@ -660,31 +671,38 @@ async function buildCoachSnapshotImpl(
// single extra round-trip at most. Disabled modules fold into the same
// SYSTEM-side exclusion the user's `excludeMetrics` flow already drives.
const moduleMapPromise = resolveModuleMap(userId);
const prefsRow = await prisma.user.findUnique({
where: { id: userId },
select: {
coachPrefsJson: true,
timezone: true,
locale: true,
// v1.11.5 — needed to collapse a dual-source sleep night to one
// canonical source before reconstructing per-night asleep totals.
sourcePriorityJson: true,
// v1.15 — the cycle snapshot block is gated on the resolved cycle
// toggle (an explicit opt-in/out overrides the gender default). Read
// both columns here so a non-cycle account pays no extra round-trip.
gender: true,
cycleProfile: { select: { cycleTrackingEnabled: true } },
// v1.16.16 — the glucose block converts canonical mg/dL to the user's
// display unit so the Coach reads the same number every other surface
// shows. Read it on this existing prefs hop (no extra round-trip).
glucoseUnit: true,
// v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects
// the tighter ADA glycemic GOAL band for the glucose reference-grounding
// line only; never inferred from a reading, never a diagnosis. Read on
// this existing prefs hop (no extra round-trip).
hasDiabetes: true,
},
});
// v1.20.0 (H-1) — the F1 coach tools each rebuild a single-source snapshot
// with a distinct LRU key, so the 60s snapshot cache does not share this read
// across the fan-out. Memoise it per-request (the select shape is constant, so
// userId is the only key) so up to 6 concurrent tool builds collapse to one
// prefs round-trip instead of starving the shared Prisma pool.
const prefsRow = await memoizePerRequest(`coach-prefs:${userId}`, () =>
prisma.user.findUnique({
where: { id: userId },
select: {
coachPrefsJson: true,
timezone: true,
locale: true,
// v1.11.5 — needed to collapse a dual-source sleep night to one
// canonical source before reconstructing per-night asleep totals.
sourcePriorityJson: true,
// v1.15 — the cycle snapshot block is gated on the resolved cycle
// toggle (an explicit opt-in/out overrides the gender default). Read
// both columns here so a non-cycle account pays no extra round-trip.
gender: true,
cycleProfile: { select: { cycleTrackingEnabled: true } },
// v1.16.16 — the glucose block converts canonical mg/dL to the user's
// display unit so the Coach reads the same number every other surface
// shows. Read it on this existing prefs hop (no extra round-trip).
glucoseUnit: true,
// v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects
// the tighter ADA glycemic GOAL band for the glucose reference-grounding
// line only; never inferred from a reading, never a diagnosis. Read on
// this existing prefs hop (no extra round-trip).
hasDiabetes: true,
},
}),
);
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
// Resolve the UI locale for the rolling-profile narrative recall. The
// narrative rows are keyed by ("de" | "en"); default to "de" (the app
@@ -749,9 +767,16 @@ async function buildCoachSnapshotImpl(
// `userId` + the resolved window/sources), so running them
// concurrently shaves a round-trip off the cold path. No block reads
// `features` before the shared await, so the deferral is safe.
const featuresPromise = extractFeatures(userId, false, {
sinceDays: windowDays,
});
// v1.20.0 (H-1) — `extractFeatures` is the heaviest read on the cold path
// (user findUnique + a windowed measurement findMany + the all-time extremes).
// The F1 tools rebuild distinct-scope snapshots whose LRU keys differ, so they
// do not share this read; memoise it per-request keyed on the only varying
// input (windowDays) so the fan-out runs it once instead of up to 6× against
// the shared pool.
const featuresPromise = memoizePerRequest(
`coach-features:${userId}:${windowDays}`,
() => extractFeatures(userId, false, { sinceDays: windowDays }),
);
// Trim down to the metrics the Coach narrates. extractFeatures
// returns more (sleep, steps, etc.) — the Coach surface keeps the
@@ -2352,6 +2377,13 @@ async function buildCoachSnapshotImpl(
return {
snapshotJson: JSON.stringify(compactSnapshot, null, 2),
// v1.20.0 (F1) — the structured, post-degrade snapshot record keyed by
// domain block (`bloodPressure`, `glucose`, `labs`, …). The coach tool
// executor slices a single domain block out of this so a retrieval tool
// returns exactly the numbers the legacy snapshot path would have shown —
// same builder, same gates, same I/O. `snapshotJson` is this record
// serialised; exposing the record avoids re-parsing it.
sections: compactSnapshot,
provenance: {
windows: Array.from(windows),
metrics: Array.from(metrics),
+208
View File
@@ -0,0 +1,208 @@
/**
* v1.20.0 (F1) — Coach retrieval tool catalogue.
*
* Replaces snapshot-stuffing with on-demand retrieval: the model is handed a
* tiny DATA INVENTORY in the base context and pulls only the domains it needs
* via these tools. Each tool is a THIN, READ-ONLY wrapper over the existing
* server-authoritative snapshot builder (`buildCoachSnapshot`) — same numbers,
* same module/cycle gates, same I/O. No tool ever accepts `userId` (it is
* narrowed from the session in the executor), and every tool is read-only, so
* there is no new mutation or egress surface.
*
* Six tools ship in v1.20.0 (the research's recommended slice):
* 1. get_metric_series — BP / weight / pulse + the ~38 additive series
* 2. get_glucose_panel — per-context daily means + the 30-day clinical panel
* 3. get_sleep — per-night sleep + sleep-rhythm (debt + chronotype)
* 4. get_medication_compliance — dose-weighted compliance + GLP-1
* 5. get_labs — latest reading per biomarker (12 months)
* 6. get_illness_recovery — restMode + active/resolved illnesses + recovery composites
*
* cycle / correlation / workouts are deferred to a later release.
*/
import { z } from "zod/v4";
import type { AiToolDef } from "@/lib/ai/types";
import {
coachScopeSourceSchema,
coachScopeWindowSchema,
} from "@/lib/ai/coach/types";
/** The closed set of tool names F1 ships. */
export const COACH_TOOL_NAMES = [
"get_metric_series",
"get_glucose_panel",
"get_sleep",
"get_medication_compliance",
"get_labs",
"get_illness_recovery",
] as const;
export type CoachToolName = (typeof COACH_TOOL_NAMES)[number];
export function isCoachToolName(name: string): name is CoachToolName {
return (COACH_TOOL_NAMES as readonly string[]).includes(name);
}
// ── Per-tool argument schemas ────────────────────────────────────────
// Closed enums + optional windows only — no free-text, no host, no id. The
// executor `safeParse`s the model's raw JSON arguments against these before it
// touches the snapshot builder, so a malformed / adversarial argument blob can
// never widen scope or reach an un-validated read.
export const getMetricSeriesArgsSchema = z
.object({
metric: coachScopeSourceSchema,
window: coachScopeWindowSchema.optional(),
})
.strict();
export const getGlucosePanelArgsSchema = z
.object({
window: coachScopeWindowSchema.optional(),
})
.strict();
export const getSleepArgsSchema = z
.object({
window: coachScopeWindowSchema.optional(),
})
.strict();
export const getMedicationComplianceArgsSchema = z
.object({
window: coachScopeWindowSchema.optional(),
})
.strict();
export const getLabsArgsSchema = z
.object({
/** Optional single-analyte filter (free-text biomarker name). */
analyte: z.string().min(1).max(80).optional(),
})
.strict();
export const getIllnessRecoveryArgsSchema = z.object({}).strict();
/**
* JSON-Schema parameter shapes handed to the provider. Kept hand-written
* (rather than generated from Zod) so the wire description the model reads is
* compact and stable — the byte-stable tool block keeps the cached prefix
* intact across turns.
*/
const WINDOW_ENUM = [
"last7days",
"last30days",
"last90days",
"lastYear",
"allTime",
];
/**
* The tool definitions offered to the model. Descriptions are deliberately
* terse and brand-free; they tell the model WHEN to reach for each tool and
* that an absent domain returns `{ present: false }` rather than an error.
*/
export const COACH_TOOL_DEFS: AiToolDef[] = [
{
name: "get_metric_series",
description:
"Fetch the user's own time series for ONE metric: blood pressure (bp), weight, pulse, or any synced series (hrv, resting_hr, steps, sleep duration, body composition, gait, audio exposure, vo2_max, …). Returns an aggregate plus a recent-daily and weekly timeline. Call once per metric you need; call several in parallel for a multi-metric question. Returns { present: false } when the user has no data for that metric.",
parameters: {
type: "object",
additionalProperties: false,
required: ["metric"],
properties: {
metric: {
type: "string",
enum: coachScopeSourceSchema.options,
description: "The metric to fetch.",
},
window: {
type: "string",
enum: WINDOW_ENUM,
description: "Analysis window. Defaults to the user's scope window.",
},
},
},
},
{
name: "get_glucose_panel",
description:
"Fetch the user's glucose data: per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). Returns { present: false } when the user logs no glucose.",
parameters: {
type: "object",
additionalProperties: false,
required: [],
properties: {
window: {
type: "string",
enum: WINDOW_ENUM,
description:
"Window for the per-context means. The clinical panel is always the fixed trailing 30 days.",
},
},
},
},
{
name: "get_sleep",
description:
"Fetch the user's sleep: per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). Returns { present: false } when no sleep is tracked.",
parameters: {
type: "object",
additionalProperties: false,
required: [],
properties: {
window: {
type: "string",
enum: WINDOW_ENUM,
description: "Analysis window. Defaults to the user's scope window.",
},
},
},
},
{
name: "get_medication_compliance",
description:
"Fetch the user's medication compliance: the dose-weighted adherence rate plus a recent timeline, and any GLP-1 titration context. Returns { present: false } when no medications are tracked.",
parameters: {
type: "object",
additionalProperties: false,
required: [],
properties: {
window: {
type: "string",
enum: WINDOW_ENUM,
description: "Analysis window. Defaults to the user's scope window.",
},
},
},
},
{
name: "get_labs",
description:
"Fetch the user's most recent lab results — the latest reading per biomarker over the last 12 months, or one named analyte. Returns { present: false } when no labs are on file.",
parameters: {
type: "object",
additionalProperties: false,
required: [],
properties: {
analyte: {
type: "string",
description:
"Optional single biomarker name to filter to (e.g. 'LDL').",
},
},
},
},
{
name: "get_illness_recovery",
description:
"Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses, and the recovery / strain composites. Returns { present: false } when there is nothing to report.",
parameters: {
type: "object",
additionalProperties: false,
required: [],
properties: {},
},
},
];
+359
View File
@@ -0,0 +1,359 @@
/**
* v1.20.0 (F1) — Coach retrieval tool executor.
*
* Dispatches a single tool call from the model: validates the raw JSON
* arguments against the tool's Zod schema, runs the existing
* server-authoritative snapshot builder scoped to just the requested domain,
* and slices the matching section(s) out of the structured result.
*
* GROUNDING CONTRACTS (the hallucination audit hammers these):
* - Every tool returns a structured `{ present: boolean }`. When the domain
* carries no data — or the user opted out of the owning module — the result
* is `{ present: false, reason }`. It NEVER throws and NEVER returns an
* ambiguous `[]`, so the model can always tell "no data" from "data".
* - Module / cycle gates are enforced AT THE BUILDER (`buildCoachSnapshot`
* applies `MODULE_EXCLUDED_SOURCES` + the cycle gate before any row is
* read), so an opted-out domain is structurally unfetchable here — the
* section simply never appears and the executor reports `present: false`.
* - `userId` is the session-narrowed id passed by the route, never a tool
* argument. Tools are read-only (snapshot reads only), so there is no
* mutation or egress surface.
*
* The builder result is memoised by the 60s snapshot LRU keyed on
* `(userId, window, sources)`, so several tool calls in one turn (and the
* base-context inventory build) share the underlying reads.
*/
import { z } from "zod/v4";
import { annotate } from "@/lib/logging/context";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
import {
getMetricSeriesArgsSchema,
getGlucosePanelArgsSchema,
getSleepArgsSchema,
getMedicationComplianceArgsSchema,
getLabsArgsSchema,
getIllnessRecoveryArgsSchema,
isCoachToolName,
type CoachToolName,
} from "./definitions";
import {
COACH_SOURCE_SNAPSHOT_KEY,
METRIC_SERIES_EXCLUDED_SOURCES,
} from "./source-keys";
/** A read-only structured tool result. Serialised to a `role:"tool"` turn. */
export interface CoachToolResult {
present: boolean;
/** Short machine reason on a `present: false` result (no PII). */
reason?: string;
/** The domain payload on a `present: true` result (the snapshot section). */
data?: unknown;
/**
* Optional citation-coupled reference grounding for the fetched metric —
* published population bands + the user's placement, general guidance only.
*/
grounding?: string;
}
/** What the route persists onto provenance: which tools ran, did data exist. */
export interface CoachToolTrace {
name: string;
present: boolean;
}
function pickSection(
sections: Record<string, unknown>,
key: string,
): unknown | undefined {
const value = sections[key];
if (value === undefined || value === null) return undefined;
return value;
}
/**
* Resolve `args.window` into a scope. Falls back to the conversation's
* effective window so a tool call that omits `window` matches the user's
* chosen analysis window rather than the builder default.
*/
function scopeFor(
sources: CoachScope["sources"],
window: CoachScopeWindow | undefined,
fallbackWindow: CoachScopeWindow | undefined,
): CoachScope {
return {
sources,
window: window ?? fallbackWindow,
};
}
/**
* Execute one tool call. `rawArguments` is the model's raw JSON-string
* arguments (parsed + validated here, never trusted blindly). Returns a
* grounded `CoachToolResult` — a validation failure or unknown tool resolves
* to `{ present: false }`, never a throw, so a single bad call can't break the
* turn.
*/
export async function executeCoachTool(args: {
userId: string;
name: string;
rawArguments: string;
/** The conversation's effective window, used when a call omits `window`. */
fallbackWindow?: CoachScopeWindow;
}): Promise<CoachToolResult> {
const { userId, name, rawArguments, fallbackWindow } = args;
if (!isCoachToolName(name)) {
annotate({
action: { name: "coach.tool.unknown" },
meta: { tool: name.slice(0, 48) },
});
return { present: false, reason: "unknown_tool" };
}
let parsedArgs: unknown;
try {
parsedArgs = rawArguments.trim() === "" ? {} : JSON.parse(rawArguments);
} catch {
annotate({
action: { name: "coach.tool.bad_arguments" },
meta: { tool: name, reason: "invalid_json" },
});
return { present: false, reason: "invalid_arguments" };
}
try {
const result = await dispatch(name, userId, parsedArgs, fallbackWindow);
annotate({
action: { name: "coach.tool.executed" },
meta: { tool: name, present: result.present },
});
return result;
} catch (err) {
// A read failure must degrade to a grounded "no data" rather than break
// the loop — the model then says it could not retrieve the metric.
annotate({
action: { name: "coach.tool.error" },
meta: {
tool: name,
reason: err instanceof Error ? err.name : "unknown",
},
});
return { present: false, reason: "retrieval_failed" };
}
}
async function dispatch(
name: CoachToolName,
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
): Promise<CoachToolResult> {
switch (name) {
case "get_metric_series":
return getMetricSeries(userId, rawArgs, fallbackWindow);
case "get_glucose_panel":
return getGlucosePanel(userId, rawArgs, fallbackWindow);
case "get_sleep":
return getSleep(userId, rawArgs, fallbackWindow);
case "get_medication_compliance":
return getMedicationCompliance(userId, rawArgs, fallbackWindow);
case "get_labs":
return getLabs(userId, rawArgs);
case "get_illness_recovery":
return getIllnessRecovery(userId);
}
}
function badArgs(name: string, error: z.ZodError): CoachToolResult {
annotate({
action: { name: "coach.tool.bad_arguments" },
meta: { tool: name, reason: "schema", issues: error.issues.length },
});
return { present: false, reason: "invalid_arguments" };
}
async function getMetricSeries(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
): Promise<CoachToolResult> {
const parsed = getMetricSeriesArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_metric_series", parsed.error);
const { metric, window } = parsed.data;
// Glucose / workouts / compliance have dedicated tools (or are deferred);
// refuse to answer them here so the model is pointed at the right tool
// rather than getting a fabricated-shape miss.
if (METRIC_SERIES_EXCLUDED_SOURCES.has(metric)) {
return {
present: false,
reason:
metric === "glucose"
? "use_get_glucose_panel"
: metric === "compliance"
? "use_get_medication_compliance"
: "unsupported_metric",
};
}
const sectionKey = COACH_SOURCE_SNAPSHOT_KEY[metric];
if (!sectionKey) {
return { present: false, reason: "unsupported_metric" };
}
const snapshot = await buildCoachSnapshot(
userId,
scopeFor([metric], window, fallbackWindow),
);
const section = pickSection(snapshot.sections, sectionKey);
if (section === undefined) {
return { present: false, reason: "no_data" };
}
return {
present: true,
data: { metric, section },
grounding: snapshot.referenceGrounding ?? undefined,
};
}
async function getGlucosePanel(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
): Promise<CoachToolResult> {
const parsed = getGlucosePanelArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_glucose_panel", parsed.error);
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["glucose"], parsed.data.window, fallbackWindow),
);
const section = pickSection(snapshot.sections, "glucose");
if (section === undefined) return { present: false, reason: "no_data" };
return {
present: true,
data: section,
grounding: snapshot.referenceGrounding ?? undefined,
};
}
async function getSleep(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
): Promise<CoachToolResult> {
const parsed = getSleepArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_sleep", parsed.error);
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["sleep"], parsed.data.window, fallbackWindow),
);
const nights = pickSection(snapshot.sections, "sleep");
const rhythm = pickSection(snapshot.sections, "sleepRhythm");
if (nights === undefined && rhythm === undefined) {
return { present: false, reason: "no_data" };
}
return {
present: true,
data: {
...(nights !== undefined ? { nights } : {}),
...(rhythm !== undefined ? { rhythm } : {}),
},
grounding: snapshot.referenceGrounding ?? undefined,
};
}
async function getMedicationCompliance(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
): Promise<CoachToolResult> {
const parsed = getMedicationComplianceArgsSchema.safeParse(rawArgs);
if (!parsed.success) {
return badArgs("get_medication_compliance", parsed.error);
}
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["compliance"], parsed.data.window, fallbackWindow),
);
const compliance = pickSection(snapshot.sections, "compliance");
// GLP-1 context rides the `weeklyContext` block.
const weeklyContext = pickSection(snapshot.sections, "weeklyContext") as
| { glp1?: unknown }
| undefined;
const glp1 = weeklyContext?.glp1;
if (compliance === undefined && glp1 === undefined) {
return { present: false, reason: "no_data" };
}
return {
present: true,
data: {
...(compliance !== undefined ? { compliance } : {}),
...(glp1 !== undefined ? { glp1 } : {}),
},
};
}
async function getLabs(
userId: string,
rawArgs: unknown,
): Promise<CoachToolResult> {
const parsed = getLabsArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_labs", parsed.error);
// Labs ride the snapshot regardless of `sources` (attached unconditionally),
// so a minimal scope still surfaces them. Pass an empty source set to keep
// the read tight.
const snapshot = await buildCoachSnapshot(userId, { sources: [] });
const labs = pickSection(snapshot.sections, "labs") as
| { recent?: Array<{ name?: string; analyte?: string }> }
| undefined;
if (labs === undefined) return { present: false, reason: "no_data" };
const analyte = parsed.data.analyte?.trim().toLowerCase();
if (analyte && Array.isArray(labs.recent)) {
const filtered = labs.recent.filter((entry) => {
const haystack =
`${entry.name ?? ""} ${entry.analyte ?? ""}`.toLowerCase();
return haystack.includes(analyte);
});
if (filtered.length === 0) {
return { present: false, reason: "analyte_not_found" };
}
return { present: true, data: { recent: filtered } };
}
return { present: true, data: labs };
}
async function getIllnessRecovery(userId: string): Promise<CoachToolResult> {
// Validate the (empty) args shape for consistency; an empty object always
// passes.
getIllnessRecoveryArgsSchema.parse({});
// Recovery composites gate on the `recovery` module + the HRV/RHR/VO2max
// sources; request them so the derived / dayStrain / trajectory blocks can
// build when the module is on. Illness rides the snapshot unconditionally.
const snapshot = await buildCoachSnapshot(userId, {
sources: ["hrv", "resting_hr", "vo2_max"],
});
const illness = pickSection(snapshot.sections, "illness");
const derived = pickSection(snapshot.sections, "derived");
const dayStrain = pickSection(snapshot.sections, "dayStrain");
const trajectory = pickSection(snapshot.sections, "trajectory");
if (
illness === undefined &&
derived === undefined &&
dayStrain === undefined &&
trajectory === undefined
) {
return { present: false, reason: "no_data" };
}
return {
present: true,
data: {
...(illness !== undefined ? { illness } : {}),
...(derived !== undefined ? { derived } : {}),
...(dayStrain !== undefined ? { dayStrain } : {}),
...(trajectory !== undefined ? { trajectory } : {}),
},
};
}
+32
View File
@@ -0,0 +1,32 @@
/**
* v1.20.0 (F1) — Coach retrieval-tool barrel.
*
* The on-demand retrieval slice that replaces snapshot-stuffing: a tiny DATA
* INVENTORY in the base context, six read-only tools the model calls to fetch
* figures, and a bounded loop that executes them. The legacy snapshot path
* stays alive as the no-tools fallback (per-provider `supportsTools`).
*/
export {
COACH_TOOL_DEFS,
COACH_TOOL_NAMES,
isCoachToolName,
type CoachToolName,
} from "./definitions";
export {
executeCoachTool,
type CoachToolResult,
type CoachToolTrace,
} from "./executor";
export {
buildCoachDataInventory,
renderDataInventory,
type CoachDataInventory,
type InventoryEntry,
} from "./inventory";
export { buildToolModeAddendum } from "./system-addendum";
export {
runCoachToolLoop,
MAX_ROUNDS,
HARD_CAP,
type CoachToolLoopResult,
} from "./loop";
+203
View File
@@ -0,0 +1,203 @@
/**
* v1.20.0 (F1) — Coach DATA INVENTORY manifest.
*
* The tiny, always-sent manifest that replaces snapshot-stuffing in the base
* context. It tells the model WHAT data exists (so it never invents a metric)
* and WHICH tool to call to fetch it — without shipping the figures themselves.
*
* Built deterministically from a single `buildCoachSnapshot` over the user's
* effective scope: the snapshot already resolves the module / cycle gates and
* computes presence + counts, so a domain the user opted out of is structurally
* absent from the manifest (it will report `present: false`). The build is
* memoised by the 60s snapshot LRU, so the tools that fire on the same turn
* re-use these reads rather than paying for them twice.
*
* Cost: the manifest is ~0.3–0.5k tokens vs the ~6–15k-token snapshot JSON it
* replaces — the figures move out of the prompt and into on-demand tool results.
*/
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import type { CoachProvenanceMetric, CoachScope } from "@/lib/ai/coach/types";
/** One row of the inventory: a domain + whether the user has data for it. */
export interface InventoryEntry {
/** The tool that fetches this domain. */
tool: string;
/** Stable domain label the model reads (e.g. "blood pressure", "glucose"). */
domain: string;
present: boolean;
/** Sample count, when the snapshot reported one. */
count?: number;
/** For get_metric_series rows: the `metric` argument to pass. */
metric?: string;
}
export interface CoachDataInventory {
entries: InventoryEntry[];
/** Illness rest-mode flag, so the safety framing is right before any call. */
restMode: boolean;
/** Whether cycle tracking is available (the get_cycle tool is deferred). */
cycleEnabled: boolean;
/** The window the inventory was built against. */
window: string;
}
/**
* Map a snapshot section key → an inventory row. The per-metric series tool
* carries the `metric` argument; the dedicated-tool domains name their tool.
*/
const METRIC_SERIES_DOMAINS: Array<{
sectionKey: string;
metric: string;
domain: string;
provenance: CoachProvenanceMetric;
}> = [
{
sectionKey: "bloodPressure",
metric: "bp",
domain: "blood pressure",
provenance: "bp",
},
{
sectionKey: "weight",
metric: "weight",
domain: "weight",
provenance: "weight",
},
{
sectionKey: "pulse",
metric: "pulse",
domain: "pulse",
provenance: "pulse",
},
{ sectionKey: "mood", metric: "mood", domain: "mood", provenance: "mood" },
{
sectionKey: "heartRateVariability",
metric: "hrv",
domain: "heart-rate variability",
provenance: "hrv",
},
{
sectionKey: "restingHeartRate",
metric: "resting_hr",
domain: "resting heart rate",
provenance: "resting_hr",
},
{
sectionKey: "steps",
metric: "steps",
domain: "steps",
provenance: "steps",
},
{
sectionKey: "vo2Max",
metric: "vo2_max",
domain: "VO2 max",
provenance: "vo2_max",
},
];
export async function buildCoachDataInventory(
userId: string,
scope: CoachScope | undefined,
): Promise<CoachDataInventory> {
const [snapshot, cycleEnabled] = await Promise.all([
buildCoachSnapshot(userId, scope),
isCycleAvailableForUser(userId),
]);
const sections = snapshot.sections;
const counts = snapshot.provenance.counts ?? {};
const has = (key: string): boolean =>
sections[key] !== undefined && sections[key] !== null;
const entries: InventoryEntry[] = [];
// get_metric_series domains — one row per metric the snapshot surfaced.
for (const m of METRIC_SERIES_DOMAINS) {
const present = has(m.sectionKey);
entries.push({
tool: "get_metric_series",
metric: m.metric,
domain: m.domain,
present,
...(present && typeof counts[m.provenance] === "number"
? { count: counts[m.provenance] }
: {}),
});
}
// Dedicated-tool domains.
entries.push({
tool: "get_glucose_panel",
domain: "glucose",
present: has("glucose"),
...(typeof counts.glucose === "number" ? { count: counts.glucose } : {}),
});
entries.push({
tool: "get_sleep",
domain: "sleep",
present: has("sleep") || has("sleepRhythm"),
...(typeof counts.sleep === "number" ? { count: counts.sleep } : {}),
});
entries.push({
tool: "get_medication_compliance",
domain: "medication compliance",
present: has("compliance") || has("weeklyContext"),
...(typeof counts.compliance === "number"
? { count: counts.compliance }
: {}),
});
entries.push({
tool: "get_labs",
domain: "lab results",
present: has("labs"),
});
entries.push({
tool: "get_illness_recovery",
domain: "illness & recovery",
present:
has("illness") || has("derived") || has("dayStrain") || has("trajectory"),
});
// restMode rides the illness section.
const illness = sections.illness as { restMode?: boolean } | undefined;
const restMode = illness?.restMode === true;
const scopeBlock = sections.scope as { window?: string } | undefined;
return {
entries,
restMode,
cycleEnabled,
window: scopeBlock?.window ?? scope?.window ?? "last30days",
};
}
/**
* Render the inventory as the compact text block that rides the base context.
* Brand-free, deterministic, and stable across turns so the prompt-cache prefix
* stays intact. Each line tells the model the domain, whether data exists, and
* the tool + argument to call.
*/
export function renderDataInventory(inventory: CoachDataInventory): string {
const lines: string[] = [];
lines.push("DATA INVENTORY");
lines.push(
"This lists which of the user's data exists and how to fetch it. Call the named tool ONLY for a domain marked present. Never cite a figure you did not fetch with a tool this turn; when a tool returns present:false, say plainly you have no data for it and pivot.",
);
lines.push(`Window: ${inventory.window}.`);
if (inventory.restMode) {
lines.push(
"The user is currently in REST MODE (recovering from illness) — frame any activity guidance gently.",
);
}
for (const e of inventory.entries) {
const arg = e.metric ? ` (metric:"${e.metric}")` : "";
lines.push(
`- ${e.domain}: ${e.present ? "present" : "absent"} → ${e.tool}${arg}${
e.present && typeof e.count === "number" ? ` [~${e.count} samples]` : ""
}`,
);
}
return lines.join("\n");
}
+156
View File
@@ -0,0 +1,156 @@
/**
* v1.20.0 (F1) — bounded Coach retrieval loop.
*
* Runs the agentic call → tool-result → answer cycle on top of the existing
* `runRawCompletionWithFallback` chain. Each round is a NON-streaming
* completion (the chain returns a whole body); the route fake-tokenises the
* FINAL prose for the visible stream exactly as before.
*
* Bounds (the loop can never run away):
* - MAX_ROUNDS = 2: one tool-fetch round + the answer round.
* - HARD_CAP = 3: an absolute ceiling; on the last allowed round the loop
* re-calls with `toolChoice:"none"` so the model is FORCED to produce prose
* even if it would otherwise keep asking for tools.
* - Tool calls within a round run in parallel (`Promise.all`).
*
* Token accounting: the caller reserves `maxTokens * MAX_ROUNDS` up front and
* reconciles the SUMMED `tokensUsed` this function returns. The atomic
* reserve/reconcile primitives are unchanged.
*
* Provider fallback: when the working provider does not support tools
* (`supportsTools === false`) or returns no tool calls, the loop degrades to a
* single completion — the caller falls back to the snapshot path BEFORE
* entering this loop, so this is the safety floor, not the primary route.
*/
import { annotate } from "@/lib/logging/context";
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import type { AiMessage, AiToolDef, CompletionResult } from "@/lib/ai/types";
import type { ProviderHealthLedger } from "@/lib/ai/provider-health-ledger";
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
import { executeCoachTool, type CoachToolTrace } from "./executor";
export const MAX_ROUNDS = 2;
/** Absolute ceiling; the final allowed round forces prose (toolChoice none). */
export const HARD_CAP = 3;
export interface CoachToolLoopResult {
/** The final completion (prose). */
result: CompletionResult;
/** The working provider's type tag. */
workingProviderType: string;
/** Summed tokens across every round (for budget reconcile). */
totalTokens: number;
/** Number of model round-trips made. */
rounds: number;
/** Which tools ran + whether each found data (persisted onto provenance). */
toolTrace: CoachToolTrace[];
}
export async function runCoachToolLoop(args: {
userId: string;
providers: ProviderChainResolved[];
system: string;
/** The conversation messages (history + the new user turn). */
messages: AiMessage[];
tools: AiToolDef[];
temperature?: number;
maxTokens?: number;
fallbackWindow?: CoachScopeWindow;
ledger?: ProviderHealthLedger;
}): Promise<CoachToolLoopResult> {
const {
userId,
providers,
system,
tools,
temperature,
maxTokens,
fallbackWindow,
ledger,
} = args;
const messages: AiMessage[] = [...args.messages];
let totalTokens = 0;
let rounds = 0;
let workingProviderType = "";
const toolTrace: CoachToolTrace[] = [];
// Round budget: rounds 1..HARD_CAP. On the last allowed round we forbid tool
// calls so the model must answer.
for (let round = 1; round <= HARD_CAP; round += 1) {
rounds = round;
const isForcedFinal = round >= HARD_CAP;
const offerTools = round <= MAX_ROUNDS && !isForcedFinal;
const fallback = await runRawCompletionWithFallback({
userId,
providers,
ledger,
params: {
system,
messages,
temperature,
maxTokens,
...(offerTools
? { tools, toolChoice: "auto" as const }
: { toolChoice: "none" as const }),
},
});
const result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
totalTokens += result.tokensUsed ?? 0;
const calls = result.toolCalls ?? [];
const wantsTools =
offerTools && result.finishReason === "tool_calls" && calls.length > 0;
if (!wantsTools) {
// The model produced prose (or a no-tools provider returned empty
// toolCalls) — we are done.
annotate({
action: { name: "coach.tool.rounds" },
meta: {
rounds,
tools: toolTrace.length,
forcedFinal: isForcedFinal,
},
});
return { result, workingProviderType, totalTokens, rounds, toolTrace };
}
// Append the assistant turn that requested the tools, then execute them in
// parallel and append one tool-result turn per call.
messages.push({
role: "assistant",
content: result.content ?? "",
toolCalls: calls,
});
const results = await Promise.all(
calls.map(async (call) => {
const toolResult = await executeCoachTool({
userId,
name: call.name,
rawArguments: call.arguments,
fallbackWindow,
});
toolTrace.push({ name: call.name, present: toolResult.present });
return { call, toolResult };
}),
);
for (const { call, toolResult } of results) {
messages.push({
role: "tool",
toolCallId: call.id,
content: JSON.stringify(toolResult),
});
}
}
// Unreachable: the forced-final round (round === HARD_CAP) always returns
// above because `offerTools` is false, so `wantsTools` is false. Kept as a
// defensive guard.
throw new Error("coach tool loop exceeded hard cap without a final answer");
}
+75
View File
@@ -0,0 +1,75 @@
/**
* v1.20.0 (F1) — CoachScopeSource → snapshot section key map.
*
* `get_metric_series` fetches a single metric by re-running the snapshot
* builder scoped to that one source, then reads the matching section out of the
* structured `CoachSnapshotResult.sections`. The builder names each section by
* a camelCase key (e.g. `heartRateVariability`); this map mirrors the
* `valueBlocks` table in `snapshot.ts` plus the three core clinical blocks
* (bloodPressure / weight / pulse) and the mood block.
*
* `glucose` and `workouts` are intentionally absent: they have dedicated tools
* / branches (glucose carries the clinical panel; workouts is deferred). A
* `get_metric_series` call for one of them resolves to `null` here and the
* executor returns `{ present: false }` with a pointer to the right tool.
*
* The `source-keys.test.ts` guard asserts every `coachScopeSourceSchema` value
* either resolves here or is explicitly one of the dedicated-tool sources, so a
* future source addition cannot silently fall through to a fabricated answer.
*/
import type { CoachScopeSource } from "@/lib/ai/coach/types";
/** Sources that `get_metric_series` does NOT serve (they have own tools). */
export const METRIC_SERIES_EXCLUDED_SOURCES: ReadonlySet<CoachScopeSource> =
new Set<CoachScopeSource>(["glucose", "workouts", "compliance"]);
/** source → the snapshot section key its single-metric block lands under. */
export const COACH_SOURCE_SNAPSHOT_KEY: Readonly<
Partial<Record<CoachScopeSource, string>>
> = {
// ── core clinical ──
bp: "bloodPressure",
weight: "weight",
pulse: "pulse",
mood: "mood",
// ── cardio ──
hrv: "heartRateVariability",
resting_hr: "restingHeartRate",
walking_hr: "walkingHeartRateAverage",
respiratory_rate: "respiratoryRate",
spo2: "oxygenSaturation",
pulse_wave_velocity: "pulseWaveVelocity",
vascular_age: "vascularAge",
// ── body composition ──
body_fat: "bodyFat",
fat_mass: "fatMass",
fat_free_mass: "fatFreeMass",
muscle_mass: "muscleMass",
lean_body_mass: "leanBodyMass",
bone_mass: "boneMass",
total_body_water: "totalBodyWater",
bmi: "bodyMassIndex",
visceral_fat: "visceralFat",
// ── activity ──
steps: "steps",
active_energy: "activeEnergy",
flights: "flightsClimbed",
distance: "walkingRunningDistance",
vo2_max: "vo2Max",
// ── mobility & gait ──
walking_steadiness: "walkingSteadiness",
walking_asymmetry: "walkingAsymmetry",
walking_double_support: "walkingDoubleSupport",
walking_step_length: "walkingStepLength",
walking_speed: "walkingSpeed",
// ── environment / exposure ──
audio_env: "audioExposureEnvironment",
audio_headphone: "audioExposureHeadphone",
audio_event: "audioExposureEvent",
daylight: "timeInDaylight",
skin_temp: "skinTemperature",
// ── sleep (also has the dedicated get_sleep tool, but the raw duration
// series is reachable here too) ──
sleep: "sleep",
body_temp: "bodyTemperature",
};
+41
View File
@@ -0,0 +1,41 @@
/**
* v1.20.0 (F1) — tool-mode system-prompt addendum.
*
* Appended to the stable Coach system prompt ONLY when retrieval tools are
* offered this turn (a tool-capable provider). It reframes the grounding
* contract from "cite the SNAPSHOT" to "cite ONLY this turn's tool results",
* and pins the empty-result rule the hallucination audit checks.
*
* Kept as a separate appended block (rather than editing the giant bilingual
* base prompt) so the base prompt stays byte-identical — preserving the
* provider prompt-cache prefix and the system-prompt contract-parity tests —
* while the tool grounding rules ride a small, stable suffix.
*
* When tools are NOT offered (local / no-tools provider) this block is omitted
* and the legacy snapshot-stuffing path runs with the unchanged prompt.
*/
import type { Locale } from "@/lib/i18n/config";
const EN = `TOOL-BASED RETRIEVAL (this conversation)
You have read-only retrieval tools and a DATA INVENTORY listing what the user has logged. The figures are NOT in this prompt — fetch them.
1. GROUNDING (overrides ground rule 7 for this turn): ground EVERY number you cite in a TOOL RESULT you received THIS turn. Never cite a figure you did not just fetch, never recall a number from earlier turns as if it were fresh, and never invent one.
2. Call a tool ONLY for a domain the DATA INVENTORY marks "present". Call several tools in one step when a question spans metrics — they run in parallel.
3. When a tool returns { present: false } or an empty result, say plainly that you have no data for that metric and pivot — do NOT infer, estimate, or fabricate a value.
4. If you can answer without data (a definition, a "what can you help with?"), just answer — do not call a tool.
5. Keep using the EVIDENCE (---KEYVALUES---) block exactly as before, citing only numbers you fetched this turn.`;
const DE = `TOOL-BASIERTE ABFRAGE (diese Unterhaltung)
Du hast schreibgeschützte Abfrage-Tools und ein DATA INVENTORY, das auflistet, was der Nutzer erfasst hat. Die Zahlen stehen NICHT in diesem Prompt — rufe sie ab.
1. VERANKERUNG (ersetzt Grundregel 7 für diesen Zug): verankere JEDE genannte Zahl in einem TOOL-ERGEBNIS, das du in DIESEM Zug erhalten hast. Nenne keine Zahl, die du nicht gerade abgerufen hast, gib keine Zahl aus früheren Zügen als frisch aus und erfinde keine.
2. Rufe ein Tool NUR für eine Domäne auf, die das DATA INVENTORY als „present" markiert. Rufe mehrere Tools in einem Schritt auf, wenn eine Frage mehrere Metriken betrifft — sie laufen parallel.
3. Wenn ein Tool { present: false } oder ein leeres Ergebnis liefert, sage klar, dass dazu keine Daten vorliegen, und lenke um — leite nichts ab, schätze nichts und erfinde keinen Wert.
4. Wenn du ohne Daten antworten kannst (eine Definition, „Wobei kannst du helfen?"), antworte einfach — rufe kein Tool auf.
5. Nutze den EVIDENZ-Block (---KEYVALUES---) genau wie zuvor und zitiere nur Zahlen, die du in diesem Zug abgerufen hast.`;
export function buildToolModeAddendum(locale: Locale): string {
return locale === "de" ? DE : EN;
}
+9
View File
@@ -338,6 +338,15 @@ export interface CoachProvenance {
* on a conversation reload. Absent on turns that carry no suggestion.
*/
suggestion?: CoachSuggestion;
/**
* v1.20.0 (F1) — the retrieval-tool trace for this turn: which tools the
* Coach called and whether each found data (`present`). Metadata only — no
* raw values beyond what `keyValues` already persists. Lets a conversation
* reload show "what I looked at" and lets the hallucination audit replay
* grounding. Absent on the legacy snapshot path + on turns that called no
* tools.
*/
toolCalls?: ReadonlyArray<{ name: string; present: boolean }>;
}
/**
+145 -25
View File
@@ -1,6 +1,14 @@
import { randomUUID } from "node:crypto";
import { safeFetch } from "@/lib/safe-fetch";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import type {
AIProvider,
AiContentPart,
AiMessage,
AiToolCall,
AiToolDef,
CompletionParams,
CompletionResult,
} from "./types";
import {
getCachedCodexSlug,
invalidateCachedCodexSlug,
@@ -108,6 +116,100 @@ function isSlugRejection(status: number, bodyExcerpt: string): boolean {
const ORIGINATOR = "healthlog";
const USER_AGENT = "HealthLog/1.0 (+https://github.com/MBombeck/HealthLog)";
/** A Responses-API input item. */
type CodexInputItem =
| {
type: "message";
role: "user" | "assistant";
content: Array<
| { type: "input_text"; text: string }
| { type: "input_image"; image_url: string; detail: "high" }
>;
}
| {
type: "function_call";
call_id: string;
name: string;
arguments: string;
}
| {
type: "function_call_output";
call_id: string;
output: string;
};
/** Map an `AiContentPart[]` body into Responses input content blocks. */
function mapCodexParts(
parts: AiContentPart[],
): Array<
| { type: "input_text"; text: string }
| { type: "input_image"; image_url: string; detail: "high" }
> {
const out: Array<
| { type: "input_text"; text: string }
| { type: "input_image"; image_url: string; detail: "high" }
> = [];
for (const part of parts) {
if (part.type === "text") {
out.push({ type: "input_text", text: part.text });
} else if (part.type === "image") {
out.push({
type: "input_image",
image_url: `data:${part.mediaType};base64,${part.dataBase64}`,
detail: "high",
});
}
// `document` (PDF) parts are not representable here (image-only wire).
}
return out;
}
/**
* Build the Responses-API `input` array from the message turns. Tool-call /
* tool-result turns map to the `function_call` / `function_call_output`
* top-level items the Responses API expects; everything else is a `message`.
* A text-only single user turn is byte-identical to the pre-refactor wire.
*/
function buildCodexInput(messages: AiMessage[]): CodexInputItem[] {
const items: CodexInputItem[] = [];
for (const m of messages) {
if (m.role === "tool") {
items.push({
type: "function_call_output",
call_id: m.toolCallId ?? "",
output: typeof m.content === "string" ? m.content : "",
});
continue;
}
const content =
typeof m.content === "string"
? [{ type: "input_text" as const, text: m.content }]
: mapCodexParts(m.content);
items.push({ type: "message", role: m.role, content });
if (m.role === "assistant" && m.toolCalls) {
for (const tc of m.toolCalls) {
items.push({
type: "function_call",
call_id: tc.id,
name: tc.name,
arguments: tc.arguments,
});
}
}
}
return items;
}
/** Map tool defs into the Responses `function` tool shape. */
function buildCodexTools(tools: AiToolDef[]) {
return tools.map((t) => ({
type: "function" as const,
name: t.name,
description: t.description,
parameters: t.parameters,
}));
}
interface CodexClientConfig {
accessToken: string;
accountId: string;
@@ -317,18 +419,16 @@ export class CodexClient implements AIProvider {
// untouched. The image is UNTRUSTED data to transcribe, framed as such by
// the system prompt. Documents (PDF) are NOT folded in — `input_image` is
// image-only; the OCR route gates PDFs to Anthropic.
const images = params.images ?? [];
const content: Array<
| { type: "input_text"; text: string }
| { type: "input_image"; image_url: string; detail: "high" }
> = [{ type: "input_text", text: params.userPrompt }];
for (const img of images) {
content.push({
type: "input_image",
image_url: `data:${img.mediaType};base64,${img.dataBase64}`,
detail: "high",
});
}
//
// v1.20.0 — `input` is now built from the full message array (multi-turn +
// tool-result turns), with `tools` mapped to the Responses `function` shape
// (the wire already declared `tools`/`tool_choice` — now they carry defs).
const input = buildCodexInput(params.messages);
const tools =
params.tools && params.tools.length > 0
? buildCodexTools(params.tools)
: [];
const toolChoice = params.toolChoice ?? "auto";
return safeFetch(
CODEX_ENDPOINT,
@@ -348,16 +448,10 @@ export class CodexClient implements AIProvider {
},
body: JSON.stringify({
model: slug,
instructions: params.systemPrompt,
input: [
{
type: "message",
role: "user",
content,
},
],
tools: [],
tool_choice: "auto",
instructions: params.system,
input,
tools,
tool_choice: toolChoice,
parallel_tool_calls: false,
// Reasoning is required-but-nullable on the wire. We don't ask
// for reasoning summaries — emit `null` so the JSON has the
@@ -407,7 +501,9 @@ export class CodexClient implements AIProvider {
let assembledText = "";
let deltaText = "";
let tokensUsed: number | null = null;
let cachedInputTokens: number | null = null;
let serverModel: string | null = null;
const toolCalls: AiToolCall[] = [];
// The server reports the actual routed model in the OpenAI-Model
// response header — useful when safety-routing kicks in.
@@ -439,9 +535,16 @@ export class CodexClient implements AIProvider {
type?: string;
role?: string;
content?: Array<{ type?: string; text?: string }>;
id?: string;
call_id?: string;
name?: string;
arguments?: string;
};
response?: {
usage?: { total_tokens?: number };
usage?: {
total_tokens?: number;
input_tokens_details?: { cached_tokens?: number };
};
error?: { code?: string; message?: string };
incomplete_details?: { reason?: string };
};
@@ -466,11 +569,23 @@ export class CodexClient implements AIProvider {
.map((c) => c.text!)
.join("");
if (text) assembledText += text;
} else if (item?.type === "function_call") {
// v1.20.0 — the model asked to call a tool. Surface it for
// F1's loop; the id is the call_id the function_call_output
// turn must echo back.
toolCalls.push({
id: item.call_id ?? item.id ?? "",
name: item.name ?? "",
arguments: item.arguments ?? "",
});
}
break;
}
case "response.completed": {
tokensUsed = parsed.response?.usage?.total_tokens ?? null;
cachedInputTokens =
parsed.response?.usage?.input_tokens_details?.cached_tokens ??
null;
break;
}
case "response.failed": {
@@ -509,15 +624,20 @@ export class CodexClient implements AIProvider {
}
const content = assembledText || deltaText;
if (!content) {
// A function-call-only reply carries no text — valid (F1). Only an empty
// reply with neither text NOR a tool call is an error.
if (!content && toolCalls.length === 0) {
throw new Error("Codex returned empty content");
}
return {
content,
tokensUsed,
cachedInputTokens,
model: serverModel ?? requestedSlug,
providerType: "codex",
...(toolCalls.length > 0 ? { toolCalls } : {}),
finishReason: toolCalls.length > 0 ? "tool_calls" : "stop",
};
}
}
+10 -5
View File
@@ -1,5 +1,10 @@
import type { z } from "zod/v4";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import {
appendToLastUserMessage,
type AIProvider,
type CompletionParams,
type CompletionResult,
} from "./types";
import {
aiInsightResponseSchema,
findRecommendationsMissingRationale,
@@ -248,10 +253,10 @@ export async function generateInsight(
first.reason ?? "schema mismatch",
first.details ?? "",
);
const retryParams: CompletionParams = {
...params,
userPrompt: `${params.userPrompt}\n\n${correction}`,
};
const retryParams: CompletionParams = appendToLastUserMessage(
params,
`\n\n${correction}`,
);
const second = await tryOnce(provider, retryParams);
if (second.ok && second.parsed) {
annotateCitationCoverage(second.parsed);
+52 -25
View File
@@ -1,6 +1,12 @@
import { safeFetch } from "@/lib/safe-fetch";
import { isLocalAiHostAllowed } from "./local-host-allowlist";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import type {
AIProvider,
AiMessage,
CompletionParams,
CompletionResult,
} from "./types";
import { buildOpenAIMessages } from "./openai-wire";
interface LocalClientConfig {
apiKey?: string | null;
@@ -8,15 +14,50 @@ interface LocalClientConfig {
baseUrl: string;
}
const STRICT_JSON_PREFIX =
"Return strict JSON only, no markdown, no commentary.\n\n";
/**
* Prepend the strict-JSON instruction to the FIRST user turn's text. Local
* model templates respect an in-message instruction better than the
* `response_format` flag (which many reject). Mirrors the pre-refactor
* behaviour, which prepended to the single user prompt.
*/
function withStrictJsonPrefix(messages: AiMessage[]): AiMessage[] {
const idx = messages.findIndex((m) => m.role === "user");
if (idx === -1) return messages;
const out = [...messages];
const target = out[idx];
if (typeof target.content === "string") {
out[idx] = { ...target, content: `${STRICT_JSON_PREFIX}${target.content}` };
} else {
// Vision parts: prepend a leading text part with the instruction.
out[idx] = {
...target,
content: [{ type: "text", text: STRICT_JSON_PREFIX }, ...target.content],
};
}
return out;
}
/**
* Talks to OpenAI-compatible local servers (Ollama, LocalAI, LM Studio,
* vLLM, …). Same wire format as OpenAI but without
* `response_format: { type: "json_object" }` — many local models reject the
* field outright. Instead we prepend a strict-JSON instruction to the user
* message, which is what most local model templates respect.
*
* v1.20.0 — tool-calling DEGRADES silently here: most local servers reject an
* unknown `tools` field, so the client never forwards it (capability flag
* below). F1's tool loop must therefore tolerate a provider that never returns
* `toolCalls` and answer from base context. Prompt-caching, where the local
* server supports it (vLLM / Ollama prefix cache), is transparent — no flag,
* no harm.
*/
export class LocalOpenAICompatibleClient implements AIProvider {
readonly type = "local" as const;
/** Local servers commonly reject an unknown `tools` field — never send it. */
readonly supportsTools = false;
private config: LocalClientConfig;
constructor(config: LocalClientConfig) {
@@ -38,26 +79,15 @@ export class LocalOpenAICompatibleClient implements AIProvider {
headers.Authorization = `Bearer ${trimmedKey}`;
}
const userPrompt = `Return strict JSON only, no markdown, no commentary.\n\n${params.userPrompt}`;
// v1.18.9 — fold vision inputs (Lab-OCR) into the user turn when present,
// using the OpenAI-compatible `image_url` content array that llava /
// llama-vision models accept over the same endpoint. PDFs are not handled
// here (the OCR route gates PDF to Anthropic); only `params.images` is
// consumed. The image is framed as untrusted DATA by the system prompt.
const images = params.images ?? [];
const userContent =
images.length > 0
? [
{ type: "text" as const, text: userPrompt },
...images.map((img) => ({
type: "image_url" as const,
image_url: {
url: `data:${img.mediaType};base64,${img.dataBase64}`,
},
})),
]
: userPrompt;
// System turn first, then the conversation turns (with the strict-JSON
// instruction folded into the first user turn). Vision parts (Lab-OCR)
// become the OpenAI-compatible `image_url` content array that llava /
// llama-vision models accept over the same endpoint. The image is framed
// as untrusted DATA by the system prompt.
const messages = buildOpenAIMessages(
params.system,
withStrictJsonPrefix(params.messages),
);
// safeFetch defaults: no redirect-follow (the local endpoint is the
// most exploitable on this surface — a user-controlled baseUrl that
@@ -81,10 +111,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
headers,
body: JSON.stringify({
model: this.config.model,
messages: [
{ role: "system", content: params.systemPrompt },
{ role: "user", content: userContent },
],
messages,
temperature: params.temperature ?? 0.3,
max_tokens: params.maxTokens ?? 1000,
// Deterministic seed for reproducible reference output. Ollama +
+43 -1
View File
@@ -1,4 +1,9 @@
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import type {
AIProvider,
AiToolCall,
CompletionParams,
CompletionResult,
} from "./types";
/**
* Deterministic mock AI provider for tests.
@@ -37,6 +42,23 @@ export interface MockAIProviderOptions {
* or per-call array, same semantics as `responses`).
*/
tokensUsed?: number | number[] | null;
/**
* v1.20.0 — tool calls echoed on the result (single set replayed every
* call, or an array consumed per-call). Lets F1 exercise its tool loop
* against the mock without a live provider.
*/
toolCalls?: AiToolCall[] | AiToolCall[][];
/** v1.20.0 — finishReason echoed on the result. Defaults to undefined. */
finishReason?: CompletionResult["finishReason"];
/** v1.20.0 — cachedInputTokens echoed on the result. Defaults to null. */
cachedInputTokens?: number | null;
}
/** Type guard: a per-call array of tool-call sets. */
function isToolCallMatrix(
v: AiToolCall[] | AiToolCall[][] | undefined,
): v is AiToolCall[][] {
return Array.isArray(v) && v.length > 0 && Array.isArray(v[0]);
}
// Conforms to v1.4.15 strict `aiInsightResponseSchema`. Tests that need
@@ -50,9 +72,14 @@ const DEFAULT_RESPONSE = JSON.stringify({
export class MockAIProvider implements AIProvider {
readonly type: "codex" | "admin-key" | "anthropic" | "local";
/** The mock can echo tool calls, so it advertises tool support. */
readonly supportsTools = true;
readonly calls: CompletionParams[] = [];
private readonly responses: string[];
private readonly tokens: Array<number | null>;
private readonly toolCalls: AiToolCall[][] | undefined;
private readonly finishReason: CompletionResult["finishReason"];
private readonly cachedInputTokens: number | null;
private readonly model: string;
private readonly rejectWith: Error | undefined;
private callIdx = 0;
@@ -61,6 +88,15 @@ export class MockAIProvider implements AIProvider {
this.type = opts.providerType ?? "local";
this.model = opts.model ?? "mock-model";
this.rejectWith = opts.rejectWith;
this.finishReason = opts.finishReason;
this.cachedInputTokens = opts.cachedInputTokens ?? null;
if (isToolCallMatrix(opts.toolCalls)) {
this.toolCalls = opts.toolCalls;
} else if (Array.isArray(opts.toolCalls)) {
this.toolCalls = [opts.toolCalls];
} else {
this.toolCalls = undefined;
}
if (Array.isArray(opts.responses)) {
this.responses =
opts.responses.length > 0 ? opts.responses : [DEFAULT_RESPONSE];
@@ -87,12 +123,18 @@ export class MockAIProvider implements AIProvider {
}
const idx = Math.min(this.callIdx, this.responses.length - 1);
const tokIdx = Math.min(this.callIdx, this.tokens.length - 1);
const toolCalls = this.toolCalls
? this.toolCalls[Math.min(this.callIdx, this.toolCalls.length - 1)]
: undefined;
this.callIdx++;
return {
content: this.responses[idx],
tokensUsed: this.tokens[tokIdx] ?? null,
cachedInputTokens: this.cachedInputTokens,
model: this.model,
providerType: this.type,
...(toolCalls ? { toolCalls } : {}),
finishReason: this.finishReason,
};
}
+46 -28
View File
@@ -1,5 +1,13 @@
import { safeFetch } from "@/lib/safe-fetch";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import {
buildOpenAIMessages,
buildOpenAITools,
mapFinishReason,
parseCachedTokens,
parseOpenAIToolCalls,
type OpenAIResponseJson,
} from "./openai-wire";
interface OpenAIClientConfig {
apiKey: string;
@@ -31,25 +39,27 @@ export class OpenAIClient implements AIProvider {
): Promise<CompletionResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
// v1.18.9 — fold vision inputs (Lab-OCR) into the user turn when present.
// The text-only path keeps the bare-string content; the vision path sends
// the multimodal `image_url` content array `gpt-4o`-class models accept
// (a data-URL per image). PDFs are NOT handled here (the OCR route gates
// PDF uploads to Anthropic); only `params.images` is consumed. The image
// is framed as untrusted DATA by the system prompt.
const images = params.images ?? [];
const userContent =
images.length > 0
? [
{ type: "text" as const, text: params.userPrompt },
...images.map((img) => ({
type: "image_url" as const,
image_url: {
url: `data:${img.mediaType};base64,${img.dataBase64}`,
},
})),
]
: params.userPrompt;
// System turn first (the stable cache prefix — OpenAI prefix-caches a
// byte-identical ≥1024-token prefix automatically, no API flag), then the
// conversation turns mapped 1:1. Vision parts (Lab-OCR) become the
// multimodal `image_url` content array `gpt-4o`-class models accept. The
// image is framed as untrusted DATA by the system prompt.
const messages = buildOpenAIMessages(params.system, params.messages);
// v1.20.0 — tool plumbing. The defs map onto the OpenAI `function` tool
// wire; F1 supplies real defs and consumes the parsed `toolCalls`. No F4
// call site sets `tools`, so the text-only body is unchanged.
const hasTools = !!params.tools && params.tools.length > 0;
const tools = hasTools ? buildOpenAITools(params.tools!) : undefined;
// Only force OpenAI's strict JSON mode when the caller actually consumes a
// JSON object AND no tools are in play — mirrors the Anthropic client's
// `usePrefill` gate. JSON mode coerces `message.content` into a valid JSON
// object, which contradicts the Coach prose contract; the F1 tool loop's
// forced-final round (toolChoice:"none", no tools, no responseFormat) must
// therefore stay out of JSON mode, and every tool round is non-JSON by
// construction. Insight/extraction callers opt in with `responseFormat:"json"`.
const useJsonFormat = params.responseFormat === "json" && !hasTools;
const res = await safeFetch(
url,
@@ -61,17 +71,18 @@ export class OpenAIClient implements AIProvider {
},
body: JSON.stringify({
model: this.config.model,
messages: [
{ role: "system", content: params.systemPrompt },
{ role: "user", content: userContent },
],
messages,
temperature: params.temperature ?? 0.3,
max_tokens: params.maxTokens ?? 1000,
response_format: { type: "json_object" },
...(useJsonFormat
? { response_format: { type: "json_object" } }
: {}),
// Deterministic seed for reproducible reference output; omitted
// (undefined → dropped by JSON.stringify) when the caller does
// not pin one (e.g. the seedless daily-briefing re-roll).
...(params.seed !== undefined ? { seed: params.seed } : {}),
...(tools ? { tools } : {}),
...(params.toolChoice ? { tool_choice: params.toolChoice } : {}),
}),
},
// 60 s ceiling so a tar-pit upstream cannot pin a worker
@@ -105,18 +116,25 @@ export class OpenAIClient implements AIProvider {
throw err;
}
const json = await res.json();
const content = json.choices?.[0]?.message?.content;
const json = (await res.json()) as OpenAIResponseJson;
const choice = json.choices?.[0];
const content = choice?.message?.content;
const toolCalls = parseOpenAIToolCalls(choice);
if (!content) {
// A reply with tool calls and no prose is valid (F1 tool loop); only an
// empty reply with neither content NOR tool calls is an error.
if (!content && !toolCalls) {
throw new Error("OpenAI returned empty content");
}
return {
content,
content: content ?? "",
tokensUsed: json.usage?.total_tokens ?? null,
cachedInputTokens: parseCachedTokens(json),
model: this.config.model,
providerType: this.type,
...(toolCalls ? { toolCalls } : {}),
finishReason: mapFinishReason(choice?.finish_reason),
};
}
}
+157
View File
@@ -0,0 +1,157 @@
import type {
AiContentPart,
AiMessage,
AiToolCall,
AiToolDef,
CompletionResult,
} from "./types";
/**
* Shared OpenAI Chat-Completions wire mapping. Both the hosted OpenAI client
* and the local OpenAI-compatible client (Ollama / LM Studio / vLLM) speak this
* shape, so the message → wire translation and the response parsing live here
* once. The two clients differ only in how they treat `responseFormat` and the
* endpoint/auth, not in the message structure.
*/
type OpenAIChatContent =
| string
| Array<
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } }
>;
interface OpenAIChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: OpenAIChatContent | null;
tool_calls?: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
tool_call_id?: string;
}
/**
* Map a single `AiMessage` content into the Chat-Completions content shape.
* A plain string stays a string (byte-identical to the text-only wire we shipped
* before the refactor); parts become the multimodal array. PDF document parts
* are dropped — the Chat-Completions wire has no document block (the OCR route
* gates PDFs to Anthropic).
*/
function mapContent(content: string | AiContentPart[]): OpenAIChatContent {
if (typeof content === "string") return content;
const out: Array<
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } }
> = [];
for (const part of content) {
if (part.type === "text") {
out.push({ type: "text", text: part.text });
} else if (part.type === "image") {
out.push({
type: "image_url",
image_url: { url: `data:${part.mediaType};base64,${part.dataBase64}` },
});
}
// `document` parts are not representable on this wire; drop them.
}
return out;
}
/**
* Build the full Chat-Completions `messages[]` array: the system turn first,
* then every conversation turn mapped 1:1. Assistant `toolCalls` and the
* `role:"tool"` result turns map to the OpenAI tool wire so a multi-round
* tool loop (F1) round-trips.
*/
export function buildOpenAIMessages(
system: string,
messages: AiMessage[],
): OpenAIChatMessage[] {
const out: OpenAIChatMessage[] = [{ role: "system", content: system }];
for (const m of messages) {
if (m.role === "tool") {
out.push({
role: "tool",
tool_call_id: m.toolCallId,
content:
typeof m.content === "string" ? m.content : mapContent(m.content),
});
continue;
}
const mapped: OpenAIChatMessage = {
role: m.role,
content: mapContent(m.content),
};
if (m.role === "assistant" && m.toolCalls && m.toolCalls.length > 0) {
mapped.tool_calls = m.toolCalls.map((tc) => ({
id: tc.id,
type: "function",
function: { name: tc.name, arguments: tc.arguments },
}));
}
out.push(mapped);
}
return out;
}
/** Map tool defs into the OpenAI `tools` array. */
export function buildOpenAITools(tools: AiToolDef[]) {
return tools.map((t) => ({
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}));
}
interface OpenAIChoice {
message?: {
content?: string | null;
tool_calls?: Array<{
id?: string;
function?: { name?: string; arguments?: string };
}>;
};
finish_reason?: string;
}
interface OpenAIResponseJson {
choices?: OpenAIChoice[];
usage?: {
total_tokens?: number;
prompt_tokens_details?: { cached_tokens?: number };
};
}
/** Map the OpenAI `finish_reason` onto the provider-neutral finishReason. */
export function mapFinishReason(
reason: string | undefined,
): CompletionResult["finishReason"] {
if (reason === "tool_calls") return "tool_calls";
if (reason === "length") return "length";
if (reason === "stop") return "stop";
return undefined;
}
/** Pull tool calls off an OpenAI choice into the provider-neutral shape. */
export function parseOpenAIToolCalls(
choice: OpenAIChoice | undefined,
): AiToolCall[] | undefined {
const calls = choice?.message?.tool_calls;
if (!calls || calls.length === 0) return undefined;
return calls.map((c) => ({
id: c.id ?? "",
name: c.function?.name ?? "",
arguments: c.function?.arguments ?? "",
}));
}
export function parseCachedTokens(json: OpenAIResponseJson): number | null {
return json.usage?.prompt_tokens_details?.cached_tokens ?? null;
}
export type { OpenAIChoice, OpenAIResponseJson };
+230 -25
View File
@@ -111,12 +111,21 @@ export type ProviderType =
export interface AIProvider {
type: ProviderType;
/**
* v1.20.0 — per-provider tool-calling capability flag. Defaults to true
* (Anthropic / OpenAI / Codex / mock support tools natively). The local
* OpenAI-compatible client sets it `false` because most local servers reject
* an unknown `tools` field; F1's tool loop must treat a provider with
* `supportsTools === false` (or an empty `toolCalls` result) as "no tools
* available" and answer from base context.
*/
supportsTools?: boolean;
generateCompletion(params: CompletionParams): Promise<CompletionResult>;
}
/**
* v1.18.9 — a single vision input folded into the provider request. Used by
* the Lab-OCR extraction path: a photo of a paper report, or a PDF page.
* v1.18.9 — a single vision input folded into a message's content parts. Used
* by the Lab-OCR extraction path: a photo of a paper report, or a PDF page.
* `dataBase64` is the raw file bytes, base64-encoded (no `data:` prefix —
* each client wraps it in the wire shape it needs). The blob is NEVER threaded
* into an `annotate()` meta or any log field (it is health data).
@@ -129,9 +138,10 @@ export interface CompletionImage {
}
/**
* v1.18.9 — a PDF document folded into the provider request. Only Anthropic
* accepts a native `document` block over the Messages API we use; the other
* clients ignore it (the OCR route gates PDFs to Anthropic providers).
* v1.18.9 — a PDF document folded into a message's content parts. Only
* Anthropic accepts a native `document` block over the Messages API we use;
* the other clients ignore it (the OCR route gates PDFs to Anthropic
* providers).
*/
export interface CompletionDocument {
mediaType: "application/pdf";
@@ -139,25 +149,94 @@ export interface CompletionDocument {
dataBase64: string;
}
// ─── Message-array request shape (v1.20.0) ─────────────────
//
// The provider contract takes a structured `{ system, messages[] }` request
// instead of a single `{ systemPrompt, userPrompt }` string pair. This unlocks
// three things in one refactor: provider prompt-caching (Anthropic
// `cache_control` on the stable system prefix, OpenAI/local automatic prefix
// cache), tool-calling (the typed surface F1 builds on), and clean multi-turn.
//
// The provider contract is still request → complete-string today: the Coach
// fake-tokenises a fully-assembled reply downstream and Codex buffers its SSE
// internally, so there is no real token-streaming dependency here.
/** A plain-text content part. */
export interface AiTextPart {
type: "text";
text: string;
}
/** An image content part (Lab-OCR vision). */
export interface AiImagePart {
type: "image";
mediaType: CompletionImage["mediaType"];
/** Raw image bytes, base64-encoded (no data-URL prefix). */
dataBase64: string;
}
/** A PDF document content part (Anthropic-only; other clients drop it). */
export interface AiDocPart {
type: "document";
mediaType: "application/pdf";
/** Raw PDF bytes, base64-encoded (no data-URL prefix). */
dataBase64: string;
}
export type AiContentPart = AiTextPart | AiImagePart | AiDocPart;
/**
* v1.20.0 — a tool definition handed to the model. `parameters` is a JSON
* Schema object; each client maps it into the provider's tool shape. F4 plumbs
* the param + per-provider wire mapping; F1 supplies real definitions and the
* call→result→answer loop.
*/
export interface AiToolDef {
name: string;
description: string;
/** JSON-schema object describing the tool's arguments. */
parameters: Record<string, unknown>;
}
/**
* v1.20.0 — a tool the model asked to call. `arguments` is the raw JSON string
* the model produced (parsed by F1's loop, never trusted blindly).
*/
export interface AiToolCall {
id: string;
name: string;
arguments: string;
}
/**
* v1.20.0 — one conversation turn. The common case is a plain-string
* `content`; vision turns and tool-result turns use `parts[]`. `toolCalls` are
* set on assistant turns that requested tools (F1); `toolCallId` ties a
* `role:"tool"` turn back to the call it answers (F1).
*/
export interface AiMessage {
role: "user" | "assistant" | "tool";
content: string | AiContentPart[];
/** Set on assistant turns that called tools (F1 fills these). */
toolCalls?: AiToolCall[];
/** Set on `role:"tool"` turns — the id of the call this answers. */
toolCallId?: string;
/**
* Anthropic prompt-cache hint — marks a stable message-prefix boundary.
* A no-op on every other provider (their prefix caching is automatic).
*/
cacheBreakpoint?: boolean;
}
export interface CompletionParams {
systemPrompt: string;
userPrompt: string;
/**
* Stable system prompt. Carries the brand-free reference grounding, persona
* and instructions. Eligible for the provider cache prefix on every client;
* keep per-request volatile data (timestamps, interpolated dates) OUT of it
* so the cached prefix stays byte-identical across calls.
*/
system: string;
/** The conversation turns. The 80% case is one user turn (see {@link singleUserTurn}). */
messages: AiMessage[];
temperature?: number;
maxTokens?: number;
/**
* v1.18.9 — optional vision inputs (Lab-OCR). Additive: every text-only
* caller (Coach, status cards, insights, narratives) leaves these unset, so
* the existing wire shape and streaming path are untouched. A client that
* cannot read images/documents ignores the field. Treat the contents as
* UNTRUSTED data to transcribe — never as instructions.
*/
images?: CompletionImage[];
/**
* v1.18.9 — optional PDF document input (Lab-OCR). Anthropic-only; other
* clients ignore it. Mutually informative with `images` — the OCR route
* sends one or the other depending on the upload type and provider.
*/
documents?: CompletionDocument[];
/**
* Optional deterministic seed. Threaded onto the OpenAI and local
* (Ollama / OpenAI-compatible) request bodies for reproducible output on
@@ -167,20 +246,146 @@ export interface CompletionParams {
seed?: number;
/**
* v1.18.7 — opt the structured (JSON) surfaces into the provider's
* strongest JSON-reliability mode. When `"json"`:
* strongest JSON-reliability mode. When `"json"` (and no `tools`):
* - local (Ollama / OpenAI-compatible): adds `format: "json"`.
* - Anthropic: prefills the assistant turn with `{` so the first token
* is forced into a JSON object.
* The OpenAI client already pins `response_format: json_object`
* unconditionally and is unaffected. The Coach (prose, NOT JSON) leaves
* this unset, so its streaming path is untouched.
* - OpenAI / Codex: sends `response_format: { type: "json_object" }`.
* The Coach (prose, NOT JSON) leaves this unset, so no provider coerces
* its reply into a JSON object — including the OpenAI client, which now
* gates `response_format` on this flag rather than pinning it always.
*
* Mutually exclusive with `tools` on Anthropic: the `{`-prefill injects an
* assistant turn that collides with a `tool_use` response, so the Anthropic
* client drops the prefill whenever `tools` are present.
*/
responseFormat?: "json";
/**
* v1.20.0 — tool definitions offered to the model. F4 plumbs the param +
* per-provider wire mapping; no F4 call site sets it. Local providers may
* ignore tools entirely — F1's loop must tolerate an empty `toolCalls`.
*/
tools?: AiToolDef[];
/**
* v1.20.0 — `"auto"` lets the model decide; `"none"` forbids tool calls.
* Mapped per provider; omitted from the wire when unset.
*/
toolChoice?: "auto" | "none";
}
export interface CompletionResult {
content: string;
tokensUsed: number | null;
/**
* v1.20.0 — prompt-cache observability. The count of input tokens served
* from the provider's cache, where the provider reports it (Anthropic
* `cache_read_input_tokens`, OpenAI/Codex `cached_tokens`). Null / absent
* when the provider does not report it or the prefix did not hit.
*/
cachedInputTokens?: number | null;
model: string;
providerType: ProviderType;
/**
* v1.20.0 — populated when the model asked to call tools (F1 consumes).
* Absent on a plain completion.
*/
toolCalls?: AiToolCall[];
/**
* v1.20.0 — why the model stopped. Lets F1's loop branch only on
* `"tool_calls"`. Absent when the provider does not surface it.
*/
finishReason?: "stop" | "tool_calls" | "length";
}
/**
* Ergonomic builder for the common single-user-turn completion. Folds an
* optional vision payload (`images` / `documents`) into the user message's
* content parts so the Lab-OCR path keeps a one-call shape.
*/
export function singleUserTurn(p: {
system: string;
user: string;
temperature?: number;
maxTokens?: number;
seed?: number;
responseFormat?: "json";
images?: CompletionImage[];
documents?: CompletionDocument[];
tools?: AiToolDef[];
toolChoice?: "auto" | "none";
}): CompletionParams {
const images = p.images ?? [];
const documents = p.documents ?? [];
// Parts order is text-first, then media — the OpenAI / local / Codex wires
// emit it in this order. The Anthropic client re-orders to media-first when
// it maps the parts (it reads the report before the instruction), preserving
// each provider's pre-refactor vision wire byte-for-byte.
const content: string | AiContentPart[] =
images.length === 0 && documents.length === 0
? p.user
: [
{ type: "text", text: p.user },
...images.map(
(img): AiImagePart => ({
type: "image",
mediaType: img.mediaType,
dataBase64: img.dataBase64,
}),
),
...documents.map(
(doc): AiDocPart => ({
type: "document",
mediaType: doc.mediaType,
dataBase64: doc.dataBase64,
}),
),
];
return {
system: p.system,
messages: [{ role: "user", content }],
temperature: p.temperature,
maxTokens: p.maxTokens,
seed: p.seed,
responseFormat: p.responseFormat,
tools: p.tools,
toolChoice: p.toolChoice,
};
}
/**
* Return a copy of `params` whose LAST user message has `suffix` appended to
* its text. Preserves the wire shape of the corrective-retry paths (insight
* schema retry, OCR retry, briefing grounding correction): they used to append
* to the single `userPrompt` string, and this keeps the model seeing one user
* turn with the appended text rather than an extra back-to-back user turn.
*
* Throws if there is no user message to append to — every retry path always
* has one, so a throw is a programming error, not a runtime case.
*/
export function appendToLastUserMessage(
params: CompletionParams,
suffix: string,
): CompletionParams {
const messages = [...params.messages];
let idx = -1;
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i].role === "user") {
idx = i;
break;
}
}
if (idx === -1) {
throw new Error("appendToLastUserMessage: no user message to append to");
}
const target = messages[idx];
if (typeof target.content === "string") {
messages[idx] = { ...target, content: `${target.content}${suffix}` };
} else {
// Vision / parts content — append the suffix as a trailing text part.
messages[idx] = {
...target,
content: [...target.content, { type: "text", text: suffix }],
};
}
return { ...params, messages };
}
@@ -65,6 +65,7 @@ vi.mock("@/lib/analytics/source-rank-sql", () => ({
import { prisma } from "@/lib/db";
import { canonicalMeasurementsFrom } from "@/lib/analytics/source-rank-sql";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
import { computeSummariesSlice } from "../summaries-slice";
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
@@ -329,12 +330,8 @@ describe("computeSummariesSlice", () => {
avg7: 82,
avg30: 82.5,
median: 82.1,
slope7: 0.02,
r2_7: 0.5,
slope30: 0.01,
r2_30: 0.4,
slope90: 0.005,
r2_90: 0.2,
// v1.20.0 F6 — the narrow query no longer carries slope/r²; those
// compose from the accumulator buckets mocked below.
},
])
.mockResolvedValueOnce([
@@ -356,6 +353,40 @@ describe("computeSummariesSlice", () => {
},
]);
// v1.20.0 F6 — the 90-day accumulator findMany (first measurementRollup
// .findMany in the Promise.all). Two single-reading DAY buckets one day
// apart inside the 7-day window: x = epoch-days, y = 82.00 then 82.02.
// composeWindowedRegression over them yields slope = 0.02/day with a
// perfect fit (r² = 1) — a deterministic, hand-checkable regression.
const dayB = startOfUtcDay(new Date());
const dayA = startOfUtcDay(new Date(Date.now() - 24 * 60 * 60 * 1000));
const xA = dayA.getTime() / 86_400_000;
const xB = dayB.getTime() / 86_400_000;
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
type: "WEIGHT",
source: "MANUAL",
bucketStart: dayA,
count: 1,
mean: 82.0,
sumX: xA,
sumXy: xA * 82.0,
sumXx: xA * xA,
sumYy: 82.0 * 82.0,
},
{
type: "WEIGHT",
source: "MANUAL",
bucketStart: dayB,
count: 1,
mean: 82.02,
sumX: xB,
sumXy: xB * 82.02,
sumXx: xB * xB,
sumYy: 82.02 * 82.02,
},
]);
const result = await computeSummariesSlice("user-rollup");
const weight = result.summaries.WEIGHT;
@@ -368,10 +399,12 @@ describe("computeSummariesSlice", () => {
expect(weight.median).toBe(82.1);
expect(weight.latest).toBe(82.7);
expect(weight.avg7).toBe(82);
// v1.20.0 F6 — slope composed from the accumulator buckets: a clean
// +0.02/day with a perfect two-point fit.
expect(weight.slope7).toEqual({
slope: 0.02,
direction: "up",
confidence: 0.5,
confidence: 1,
});
// 1 RAW coverage probe + 3 UNSAFE data queries (narrow aggregate
@@ -385,7 +418,10 @@ describe("computeSummariesSlice", () => {
// 395-day window skips the YEAR floor (731 d) and walks MONTH
// → WEEK → DAY on full coverage miss; the default mock returns
// `[]` so all three reachable tiers probe.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
//
// v1.20.0 F6 — plus the 90-day accumulator findMany the warm path
// now runs to compose slope / r² / sd: 1 accumulator + 3 WMY = 4.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(4);
});
});
@@ -420,6 +456,10 @@ describe("computeSummariesSlice", () => {
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// v1.20.0 F6 — the warm path runs the 90-day accumulator findMany
// first; return empty (slope composes to null, irrelevant to this
// year-over-year assertion) so the year-ago MONTH probe is call #2.
ROLLUP_FIND_MANY.mockResolvedValueOnce([]);
// Year-ago slice MONTH bucket — `bucketStart` placed 380 days
// ago so it falls inside `[now-395d, now-365d)`. Single bucket
// (count=10, mean=85) → weighted mean = 85.
@@ -443,8 +483,9 @@ describe("computeSummariesSlice", () => {
const result = await computeSummariesSlice("user-yoy");
expect(result.summaries.WEIGHT.avg30LastYear).toBe(85);
// Router asked MONTH first (395 > 181, 395 < 731 → skip YEAR).
expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH");
// Router asked MONTH first (395 > 181, 395 < 731 → skip YEAR). Call #0
// is the v1.20.0 F6 accumulator findMany (DAY); the WMY probe is #1.
expect(ROLLUP_FIND_MANY.mock.calls[1][0].where.granularity).toBe("MONTH");
});
it("leaves avg30LastYear null when no bucket overlaps the year-ago slice", async () => {
@@ -469,6 +510,9 @@ describe("computeSummariesSlice", () => {
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// v1.20.0 F6 — accumulator findMany runs first; empty so the WMY
// probe is call #2.
ROLLUP_FIND_MANY.mockResolvedValueOnce([]);
// MONTH bucket placed 30 days ago — inside the YEAR / MONTH /
// WEEK 395-day window the router asks for, but outside the
// `[now-395d, now-365d)` slice. The helper returns null.
@@ -522,8 +566,9 @@ describe("computeSummariesSlice", () => {
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
// 395d window skips YEAR (floor 731 d) → MONTH → WEEK → DAY,
// three reachable tiers all probed on full coverage miss.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
// three reachable tiers all probed on full coverage miss; plus the
// v1.20.0 F6 accumulator findMany = 4.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(4);
});
it("only probes types that actually have data in the current window", async () => {
+129 -44
View File
@@ -62,7 +62,14 @@ import type { DataSummary } from "@/lib/analytics/trends";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { annotate } from "@/lib/logging/context";
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
collapseRollupRowsBySource,
composeWindowedRegression,
hasPendingAccumulatorBackfill,
loadUserSourcePriority,
type AccumulatorBucketRow,
} from "@/lib/rollups/measurement-read";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
import {
buildSourceRankCase,
canonicalMeasurementsFrom,
@@ -148,6 +155,18 @@ interface NarrowAggregateRow {
*/
median: number | null;
avg30_last_month: number | null;
// v1.20.0 F6 — slope7/30/90 + r2_7/30/90 have moved off this live query
// onto the rollup accumulators (composeWindowedRegression); they no longer
// scan the raw measurements partition on the warm path. `median` stays
// live (PERCENTILE_CONT is non-composable across buckets).
}
/**
* v1.20.0 F6 — cold-fallback windowed row. The coverage-miss path keeps the
* live REGR_* regression (no rollup accumulators to compose from yet), so it
* carries the slope / r² columns the warm `NarrowAggregateRow` dropped.
*/
interface WindowedAggregateRow extends NarrowAggregateRow {
slope7: number | null;
r2_7: number | null;
slope30: number | null;
@@ -380,7 +399,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
);
const rankM = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"');
const [narrows, latests, dayBuckets] = await Promise.all([
const [narrows, latests, dayBuckets, accumulatorRows] = await Promise.all([
// The FROM is restricted to the canonical-source rows per (type, day): the
// inner DISTINCT ON picks the ladder-winning source for each day, and the
// join keeps only that source's readings so the 90-day AVG / median / slope
@@ -403,43 +422,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
ORDER BY m."value"
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
)::double precision AS median,
REGR_SLOPE(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
)::double precision AS slope7,
REGR_R2(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
)::double precision AS r2_7,
REGR_SLOPE(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
)::double precision AS slope30,
REGR_R2(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
)::double precision AS r2_30,
REGR_SLOPE(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
)::double precision AS slope90,
REGR_R2(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
)::double precision AS r2_90
)::double precision AS median
FROM ${canonicalMeasurementsFrom(rankUnqualified, "90 days")}
GROUP BY m."type"
`,
@@ -503,8 +486,75 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
`,
userId,
),
// v1.20.0 F6 — trailing 90-day DAY rollup rows carrying the per-bucket
// regression accumulators. The windowed slope / r² / sd compose from
// these (composeWindowedRegression) instead of the live REGR_* scan the
// narrow query used to run. Rows are per source; collapsed per type
// below so a dual-source vital contributes only its canonical source's
// accumulators. Bounded to 90 days (≤ ~90 rows/type), the same window
// the dropped slope columns covered.
prisma.measurementRollup.findMany({
where: {
userId,
granularity: "DAY",
bucketStart: {
gte: startOfUtcDay(new Date(Date.now() - 90 * DAY_MS)),
},
},
orderBy: [{ type: "asc" }, { bucketStart: "asc" }],
select: {
type: true,
source: true,
bucketStart: true,
count: true,
mean: true,
sumX: true,
sumXy: true,
sumXx: true,
sumYy: true,
},
}),
]);
// v1.20.0 F6 — collapse the 90-day accumulator rows to the canonical
// source per (type, day), then build a per-type accumulator-bucket list so
// the windowed slope / r² / sd compose from the canonical source only.
const accBucketsByType = new Map<string, AccumulatorBucketRow[]>();
{
const byType = new Map<string, (typeof accumulatorRows)[number][]>();
for (const r of accumulatorRows) {
const list = byType.get(r.type) ?? [];
list.push(r);
byType.set(r.type, list);
}
for (const [type, rows] of byType) {
const collapsed = collapseRollupRowsBySource(
rows,
type as MeasurementType,
priorityJson,
);
accBucketsByType.set(
type,
collapsed.map((b) => ({
bucketStart: b.bucketStart,
count: b.count,
mean: b.mean,
sumX: b.sumX,
sumXy: b.sumXy,
sumXx: b.sumXx,
sumYy: b.sumYy,
})),
);
}
}
// Day-aligned 7/30/90-day anchors — the rollup-grain equivalent of the
// live `measured_at >= NOW() - INTERVAL 'N days'` filter.
const nowMs = Date.now();
const since7 = startOfUtcDay(new Date(nowMs - 7 * DAY_MS));
const since30 = startOfUtcDay(new Date(nowMs - 30 * DAY_MS));
const since90 = startOfUtcDay(new Date(nowMs - 90 * DAY_MS));
const latestByType = new Map<string, number>();
// v1.4.34 IW-B — capture the per-type `measured_at` alongside the
// value so we can surface the freshness map on the slim slice
@@ -554,6 +604,12 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
let totalRows = 0;
let typeCount = 0;
const typesWithData: string[] = [];
// v1.20.0 P3 M-1 — set once any in-window bucket carries a NULL
// accumulator (pre-0190 row, or boot re-fold pending). The slope stays
// null then — honest, it converges after backfill — but the miss must be
// observable per the "no silent cap" rule, so it surfaces in the
// annotation below rather than passing silently as a plain rollup read.
let regressionPendingBackfill = false;
for (const row of dayBuckets) {
if (!row.count || row.count <= 0) continue;
typeCount += 1;
@@ -561,6 +617,21 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
typesWithData.push(row.type);
const narrow = narrowByType.get(row.type);
const latest = latestByType.get(row.type) ?? null;
// v1.20.0 F6 — windowed slope / r² compose from the per-bucket
// accumulators (canonical-source-collapsed above) rather than the live
// REGR_* scan. A full miss (NULL accumulators pre-backfill, or < 2
// readings) yields a null slope, the same shape the empty REGR_* FILTER
// returned.
const accBuckets = accBucketsByType.get(row.type) ?? [];
if (
!regressionPendingBackfill &&
hasPendingAccumulatorBackfill(accBuckets, since90)
) {
regressionPendingBackfill = true;
}
const reg7 = composeWindowedRegression(accBuckets, since7);
const reg30 = composeWindowedRegression(accBuckets, since30);
const reg90 = composeWindowedRegression(accBuckets, since90);
summaries[row.type] = {
count: row.count,
latest,
@@ -570,9 +641,9 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
median: round2(narrow?.median ?? null),
avg7: round2(narrow?.avg7 ?? null),
avg30: round2(narrow?.avg30 ?? null),
slope7: buildSlope(narrow?.slope7 ?? null, narrow?.r2_7 ?? null),
slope30: buildSlope(narrow?.slope30 ?? null, narrow?.r2_30 ?? null),
slope90: buildSlope(narrow?.slope90 ?? null, narrow?.r2_90 ?? null),
slope7: buildSlope(reg7.slope, reg7.r2),
slope30: buildSlope(reg30.slope, reg30.r2),
slope90: buildSlope(reg90.slope, reg90.r2),
anomalyCount: 0,
avg30LastMonth: round2(narrow?.avg30_last_month ?? null),
avg30LastYear: null,
@@ -603,6 +674,20 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
type_count: typeCount,
path: "rollup",
year_over_year_types: yearOverYearTypeCount,
// v1.20.0 F6 — slope / r² now compose from the rollup
// accumulators (no live REGR_* scan). The median stays live
// (PERCENTILE_CONT is non-composable); both sources are logged
// per the "no silent cap" rule so a regression is observable.
slope_source: "rollup",
median_source: "live",
// v1.20.0 P3 M-1 — when a NULL accumulator (pre-0190 row, or boot
// re-fold pending) forced a slope miss, the read intentionally
// returns null without a live-SQL fallback (the design avoids the
// heavy scan). Surface the gap so the silent null is observable;
// it clears once the boot backfill refills the accumulators.
regression_source: regressionPendingBackfill
? "unavailable_pending_backfill"
: "rollup",
},
},
},
@@ -668,7 +753,7 @@ async function computeFromLiveAggregate(
`,
userId,
),
prisma.$queryRawUnsafe<NarrowAggregateRow[]>(
prisma.$queryRawUnsafe<WindowedAggregateRow[]>(
`
SELECT
m."type"::text AS type,
@@ -778,7 +863,7 @@ async function computeFromLiveAggregate(
// measurements in the 90-day window leave their windowed columns at
// `null` (the same shape the pre-split query produced via FILTER
// returning NULL on an empty set).
const windowedByType = new Map<string, NarrowAggregateRow>();
const windowedByType = new Map<string, WindowedAggregateRow>();
for (const row of windowed) {
windowedByType.set(row.type, row);
}
+19 -5
View File
@@ -367,9 +367,21 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
{ id: "mood", visible: true, tileVisible: true, order: 4 },
{ id: "bpInTarget", visible: true, tileVisible: true, order: 5 },
{ id: "medications", visible: true, tileVisible: true, order: 6 },
{ id: "sleep", visible: false, tileVisible: false, order: 7 },
{ id: "steps", visible: false, tileVisible: false, order: 8 },
{ id: "glucose", visible: false, tileVisible: false, order: 9 },
// v1.20.0 — sleep / steps / glucose strip tiles default-on so accounts
// that sync these signals discover them on / without hunting through
// Settings → Dashboard. Each follows the VO2max precedent: the strip
// tile is on (`tileVisible: true`) and self-gates on having data
// (`hasSleep` / `hasSteps` / `showGlucoseCards` in page.tsx), so an
// empty account still gets a clean dashboard. The chart row stays off
// (`visible: false`) — those charts live on the /insights sub-pages.
{ id: "sleep", visible: false, tileVisible: true, order: 7 },
{ id: "steps", visible: false, tileVisible: true, order: 8 },
{ id: "glucose", visible: false, tileVisible: true, order: 9 },
// totalBodyWater / boneMass / oxygenSaturation carry as catalogue ids
// for the iOS layout round-trip only — they have no web render block or
// data-floor flag in page.tsx, so flipping their defaults would surface
// nothing on web. Their data IS discoverable via the /insights sub-pages.
// Kept false until a web render path lands (see .planning/v1200-backlog.md).
{ id: "totalBodyWater", visible: false, tileVisible: false, order: 10 },
{ id: "boneMass", visible: false, tileVisible: false, order: 11 },
{ id: "oxygenSaturation", visible: false, tileVisible: false, order: 12 },
@@ -411,8 +423,10 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
{ id: "falls", visible: false, tileVisible: false, order: 22 },
{ id: "walkingSteadiness", visible: false, tileVisible: false, order: 23 },
// v1.18.2 — Vorsorge summary card. Chart-row only (no strip tile), so
// `tileVisible` is forced false; default-invisible until opt-in.
{ id: "vorsorge", visible: false, tileVisible: false, order: 24 },
// `tileVisible` is forced false. v1.20.0 — flipped `visible: true` so
// preventive-care reminders surface on / by default; the card self-gates
// (see page.tsx) and shows nothing for accounts without due reminders.
{ id: "vorsorge", visible: true, tileVisible: false, order: 24 },
],
};
File diff suppressed because it is too large. Load diff
@@ -241,19 +241,26 @@ describe("syncUserFitbit — single cycle-wide watermark", () => {
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
it("passes fullSync + deferRollup through with an undefined start on a backfill cycle", async () => {
it("passes fullSync + deferRollup through with a bounded backfill start", async () => {
syncUserMetrics.mockResolvedValue(2);
await syncUserFitbit("user1", { fullSync: true });
const opts = syncUserMetrics.mock.calls[0]![1] as {
start?: Date;
end?: Date;
fullSync?: boolean;
deferRollup?: boolean;
};
expect(opts.fullSync).toBe(true);
// Full sync walks deep history with no lower bound.
expect(opts.start).toBeUndefined();
// The classic Web API range caps mean the backfill walks a bounded horizon
// (~1 year back), not an unbounded deep history.
expect(opts.start).toBeInstanceOf(Date);
expect(opts.end).toBeInstanceOf(Date);
const spanDays =
(opts.end!.getTime() - opts.start!.getTime()) / (24 * 60 * 60 * 1000);
expect(spanDays).toBeGreaterThan(360);
expect(spanDays).toBeLessThan(370);
// The backfill defers the per-write rollup hook to one end-of-cycle pass.
expect(opts.deferRollup).toBe(true);
});
+42 -12
View File
@@ -1,7 +1,8 @@
/**
* v1.12.0 — Fitbit token-management tests (mocked). Covers `getValidToken`:
* - the NO-ROTATION refresh branch (refresh token preserved when the response
* omits it; overwritten only when a fresh one is returned);
* Fitbit token-management tests (mocked). Covers `getValidToken`:
* - the ROTATING refresh branch (classic Fitbit returns a fresh refresh token
* on every refresh; persist it, replacing the stored one);
* - the defensive keep-existing guard when a malformed response omits it;
* - the stored-token fast path when not near expiry;
* - a reauth failure recorded when credentials are missing on refresh.
*/
@@ -13,6 +14,7 @@ const { prismaMock, refreshAccessTokenMock, recordSyncFailure } = vi.hoisted(
fitbitConnection: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
measurement: {
findMany: vi.fn<(arg: Record<string, unknown>) => Promise<unknown[]>>(
@@ -86,8 +88,8 @@ afterEach(() => {
vi.clearAllMocks();
});
describe("getValidToken — non-rotating refresh", () => {
it("persists the new access token and KEEPS the stored refresh token when the response omits one", async () => {
describe("getValidToken — rotating refresh", () => {
it("defensively KEEPS the stored refresh token when a malformed response omits one", async () => {
prismaMock.fitbitConnection.findUnique.mockResolvedValue({
id: "conn1",
fitbitUserId: "abc123",
@@ -96,12 +98,13 @@ describe("getValidToken — non-rotating refresh", () => {
// Expired so the refresh path fires.
tokenExpiresAt: new Date(Date.now() - 1000),
});
// Google does NOT return a refresh_token on a routine refresh.
// Classic Fitbit always rotates; a missing refresh_token is a malformed
// reply. Guard it by keeping the existing token rather than writing a blank.
refreshAccessTokenMock.mockResolvedValue({
access_token: "new-access",
expires_in: 3600,
});
prismaMock.fitbitConnection.update.mockResolvedValue({});
prismaMock.fitbitConnection.updateMany.mockResolvedValue({ count: 1 });
const result = await getValidToken("user1");
@@ -110,13 +113,13 @@ describe("getValidToken — non-rotating refresh", () => {
clientId: "cid",
clientSecret: "csecret",
});
const updateArg = prismaMock.fitbitConnection.update.mock.calls[0]![0];
const updateArg = prismaMock.fitbitConnection.updateMany.mock.calls[0]![0];
expect(updateArg.data.accessToken).toBe("enc(new-access)");
// The stored refresh token must NOT be touched when the response omits one.
// The stored refresh token must NOT be wiped when the response omits one.
expect(updateArg.data).not.toHaveProperty("refreshToken");
});
it("overwrites the stored refresh token only when the response carries a fresh one", async () => {
it("persists the ROTATED refresh token when the response carries a fresh one", async () => {
prismaMock.fitbitConnection.findUnique.mockResolvedValue({
id: "conn1",
fitbitUserId: "abc123",
@@ -129,15 +132,42 @@ describe("getValidToken — non-rotating refresh", () => {
refresh_token: "rotated-refresh",
expires_in: 3600,
});
prismaMock.fitbitConnection.update.mockResolvedValue({});
prismaMock.fitbitConnection.updateMany.mockResolvedValue({ count: 1 });
await getValidToken("user1");
const updateArg = prismaMock.fitbitConnection.update.mock.calls[0]![0];
const updateArg = prismaMock.fitbitConnection.updateMany.mock.calls[0]![0];
// CAS guard scopes the write to the connection AND the spent ciphertext.
expect(updateArg.where.id).toBe("conn1");
expect(updateArg.where.refreshToken).toBe("enc(old-refresh)");
expect(updateArg.data.accessToken).toBe("enc(new-access)");
expect(updateArg.data.refreshToken).toBe("enc(rotated-refresh)");
});
it("reuses the peer's rotated token on a lost CAS race (no spurious reauth)", async () => {
prismaMock.fitbitConnection.findUnique
.mockResolvedValueOnce({
id: "conn1",
fitbitUserId: "abc123",
accessToken: "enc(old-access)",
refreshToken: "enc(old-refresh)",
tokenExpiresAt: new Date(Date.now() - 1000),
})
.mockResolvedValueOnce({ accessToken: "enc(peer-access)" });
refreshAccessTokenMock.mockResolvedValue({
access_token: "new-access",
refresh_token: "rotated-refresh",
expires_in: 3600,
});
// A concurrent sync rotated first → zero rows match the CAS guard.
prismaMock.fitbitConnection.updateMany.mockResolvedValue({ count: 0 });
const result = await getValidToken("user1");
expect(result?.accessToken).toBe("peer-access");
expect(recordSyncFailure).not.toHaveBeenCalled();
});
it("returns the stored token without refresh when not near expiry", async () => {
prismaMock.fitbitConnection.findUnique.mockResolvedValue({
id: "conn1",
+825 -1015
View File
File diff suppressed because it is too large. Load diff
+93 -87
View File
@@ -1,111 +1,117 @@
# Fitbit / Google Health field → Measurement mapping
# Fitbit Web API field → Measurement mapping
Single source of truth for the Fitbit (Google Health API v4) data-type →
Single source of truth for the classic Fitbit Web API (`api.fitbit.com`) →
HealthLog `Measurement` mapping. Keep this in sync with `FITBIT_FIELD_MAP` and
the per-type mappers in `client.ts`. Every Fitbit row ingests server-side with
`source = FITBIT` and `externalId = <anchor>:<fieldTag>` (the anchor is the data
point's sample time for spot readings, its civil date for daily summaries, or its
`interval.start_time` for INTERVAL types — steps/distance/calories/floors, sleep,
exercise; the field-tag disambiguates the metric).
`source = FITBIT`; the `externalId` is a stable per-reading key so a re-fetch of
the same window overwrites in place rather than minting a duplicate.
## Time anchor by grain (design §A.1)
Transport: v1.20.0 retargeted Fitbit from the Google Health API (Restricted
scopes behind brand-verification + CASA — a self-hoster adoption wall) onto the
classic Web API. A self-hoster registers an app at **dev.fitbit.com** in minutes
(no CASA). Auth: Authorization Code + **PKCE (S256)**;
`www.fitbit.com/oauth2/authorize` + `api.fitbit.com/oauth2/token`. Refresh tokens
**rotate** (one-time use) — the new token must be persisted every refresh.
Google Health exposes three time-anchor shapes. The incremental `filter` and the
read-time `measuredAt`/externalId resolution must target the right one or the
incremental sync stalls (a `sample_time` filter 400s/empties for an INTERVAL type):
Deprecation note: the classic Web API is announced for deprecation in September
2026, with migration directed back at the Google Health API. Until a self-serve
Google path exists, the classic API is the only viable transport for
self-hosters; the settings card keeps its experimental badge.
- **sample** — spot reading; anchor `{filter}.sample_time.physical_time`.
- **date** — daily-summary metric (SpO2, HRV, RHR, respiratory rate, wrist temp,
VO2 max); anchor `{filter}.date` (a civil date / `{year,month,day}`).
- **interval** — INTERVAL type (steps/distance/calories/floors daily totals, plus
sleep + exercise sessions); anchor `{filter}.interval.start_time` (a physical
instant) with `{filter}.interval.civil_start_time` as the civil fallback.
All data reads send `Accept-Language: en_GB` so weight + distance arrive in
metric (kg / km); the US default is imperial.
Transport docs: `developers.google.com/health` (v4, post-Fitbit-Web-API). The
per-type value-field JSON is **NOT fully published** — the mappers read a small
set of candidate value paths defensively (`firstNumber` walks each shape and
takes the first finite-positive hit). **Re-verify against a live test account at
build** and tighten the extractors. Design: `.planning/v1.12.0-google-health-design.md`.
## Health-metrics endpoints
## Casing gotcha (design §A.1)
Scope: `weight`, `oxygen_saturation`, `heartrate`, `respiratory_rate`. Verified
against the dev.fitbit.com reference (response shapes quoted there).
The data-type id is **kebab-case in the request path** (`body-fat`) and
**snake_case in the `filter` predicate** (`body_fat`). `FITBIT_DATA_TYPES` pins
both forms per type so a fetcher can never encode the wrong one.
| Endpoint | MeasurementType | Unit | Source field | Grain | Note |
| ---------------------------------------------- | ------------------------ | ----------- | ------------------------------------------- | ----- | ----------------------------------------------------------- |
| `/1/user/-/body/log/weight/date/{s}/{e}.json` | `WEIGHT` | kg | `weight[].weight` | log | externalId anchors on `logId`. Picker ranks Withings above. |
| `/1/user/-/body/log/fat/date/{s}/{e}.json` | `BODY_FAT` | % | `fat[].fat` | log | externalId anchors on `logId`. |
| `/1/user/-/spo2/date/{s}/{e}.json` | `OXYGEN_SATURATION` | % | `[].value.avg` (bare array) | daily | day-keyed externalId. |
| `/1/user/-/hrv/date/{s}/{e}.json` | `HEART_RATE_VARIABILITY` | ms | `hrv[].value.dailyRmssd` | daily | RMSSD estimator → canonical HRV slot (not `HRV_RMSSD`). |
| `/1/user/-/activities/heart/date/{s}/{e}.json` | `RESTING_HEART_RATE` | bpm | `activities-heart[].value.restingHeartRate` | daily | rows with no resting HR for the day are skipped. |
| `/1/user/-/br/date/{s}/{e}.json` | `RESPIRATORY_RATE` | breaths/min | `br[].value.breathingRate` | daily | |
## Health-metrics bundle (W3)
Each daily-summary value passes a finite + strictly-positive guard (a 0/NaN is a
garbage/empty reading and is dropped). Each endpoint caps its own date range
(30–31 days for these), so the sync chunks `[start, end]` into ≤30-day windows.
Scope: `googlehealth.health_metrics_and_measurements.readonly`.
## Activity + VO2 max endpoints
| Data type (path / filter) | MeasurementType | Unit | fieldTag | Grain | Note |
| ----------------------------------------------------------------------------- | ------------------------ | ----------- | ------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `weight` / `weight` | `WEIGHT` | kg | `weight` | sample | Picker ranks a real Withings scale above Fitbit. |
| `body-fat` / `body_fat` | `BODY_FAT` | % | `body_fat` | sample | |
| `daily-oxygen-saturation` / `daily_oxygen_saturation` | `OXYGEN_SATURATION` | % | `spo2` | daily | Already 0–100. |
| `daily-heart-rate-variability` / `daily_heart_rate_variability` | `HEART_RATE_VARIABILITY` | ms | `hrv` | daily | **Decision:** SDNN slot (Apple-comparable), NOT `HRV_RMSSD`. Fitbit reports an RMSSD-style nightly HRV — confirm the estimator at build and reconsider `HRV_RMSSD` if warranted. |
| `daily-resting-heart-rate` / `daily_resting_heart_rate` | `RESTING_HEART_RATE` | bpm | `rhr` | daily | |
| `daily-respiratory-rate` / `daily_respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | |
| `heart-rate` / `heart_rate` | `PULSE` | bpm | `hr` | sample | Intraday spot HR. |
| `daily-sleep-temperature-derivations` / `daily_sleep_temperature_derivations` | `WRIST_TEMPERATURE` | celsius | `wrist_temp` | daily | Closest semantic slot to Apple sleeping-wrist-temp. Confirm absolute-vs-baseline at build; the positive guard rejects a baseline-delta reading. |
| `height` / `height` | `User.heightCm` | cm | — | sample | Profile seed — written to `User.heightCm` ONLY when null; never minted as a Measurement (WHOOP `mapBody` pattern). m→cm when reported in metres. |
Scope: `activity`, `cardio_fitness`. Per-day summaries; the externalId carries
the `stats:` daily-total prefix — `stats:<fieldTag>:<YYYY-MM-DD>` — so a
re-fetched day **overwrites** in place (Apple-Health `stats:<HK>:<YYYY-MM-DD>`
contract). The running totals **preserve a 0** (a rest day is real data); VO2 max
stays strictly positive.
Every value passes a finite + strictly-positive guard (the launch metrics are
all positive — a zero/NaN is a garbage/empty reading and is dropped).
| Endpoint | MeasurementType | Unit | Source field | Note |
| --------------------------------------------------------- | -------------------------- | ----------- | ------------------------------------- | ------------------------------------------------------------ |
| `/1/user/-/activities/steps/date/{s}/{e}.json` | `ACTIVITY_STEPS` | steps | `activities-steps[].value` (string) | daily total; 0 valid |
| `/1/user/-/activities/distance/date/{s}/{e}.json` | `WALKING_RUNNING_DISTANCE` | m | `activities-distance[].value` | km → m (×1000) under the metric locale |
| `/1/user/-/activities/activityCalories/date/{s}/{e}.json` | `ACTIVE_ENERGY_BURNED` | kcal | `activities-activityCalories[].value` | ACTIVE portion only (excludes BMR) |
| `/1/user/-/activities/floors/date/{s}/{e}.json` | `FLIGHTS_CLIMBED` | flights | `activities-floors[].value` | daily total; 0 valid |
| `/1/user/-/cardioscore/date/{s}/{e}.json` | `VO2_MAX` | mL/(kg·min) | `cardioScore[].value.vo2Max` | **string** — a single number or a `"lo-hi"` range → midpoint |
## Deferred (Google Health Q3-2026 roadmap — slots exist, NOT in launch set)
Activity time-series values arrive as numeric **strings** (`"2504"`). VO2 max
arrives as a string that may be a single value (`"45"`) or a range (`"44-48"`,
when no GPS run data exists) — the range resolves to its midpoint.
`blood-glucose` → `BLOOD_GLUCOSE`, `blood-pressure` → `BLOOD_PRESSURE_SYS`+`_DIA`,
`basal-body-temperature` → `BODY_TEMPERATURE`. These light up for free when
Google ships the data types.
## Sleep endpoint
## Activity bundle (W5) — daily cumulative
Scope: `sleep`. `/1.2/user/-/sleep/date/{s}/{e}.json` →
`sleep[].levels.data[]` (`{ dateTime, level, seconds }`). One `SLEEP_DURATION`
row **per segment**, `measuredAt = segment START + seconds = segment END`, value =
seconds → minutes. The 1.2 log carries a real per-segment series, so the rows lay
each block at its true clock time (a MEASURED timeline — rows are NOT flagged
reconstructed). externalId = `<logId>:sleep_<stage>:<i>` (indexed so several
segments of one stage stay distinct), so a re-scored night overwrites in place.
The segment `dateTime` is local wall-clock ISO without an offset (the night
belongs to the user's local clock) — parsed as local time.
Scope: `googlehealth.activity_and_fitness.readonly`. Each is a per-day summary
(one value per calendar day). The externalId carries the `stats:` daily-total
prefix — `stats:<fieldTag>:<YYYY-MM-DD>` — so a re-fetched day **overwrites** in
place rather than minting a duplicate, matching the Apple-Health
`stats:<HK>:<YYYY-MM-DD>` daily-total overwrite contract. The running totals
(steps/distance/floors/active-energy) **preserve a 0** (a rest day is real data,
not a gap); VO2 max stays strictly positive (daily latest-wins).
Stage map (stages logs + classic logs): `light → CORE` (Fitbit "light" ↔ Apple
"core" shallow-NREM band), `deep → DEEP`, `rem → REM`, `wake`/`awake`/`restless →
AWAKE`, classic `asleep → ASLEEP`, `in_bed → IN_BED`. Unknown labels are skipped,
not mis-bucketed.
| Data type (path / filter) | MeasurementType | Unit | fieldTag | Note |
| ------------------------------------- | -------------------------- | ----------- | ----------------- | -------------------------------------------------------------------- |
| `steps` / `steps` | `ACTIVITY_STEPS` | steps | `steps` | daily total; 0 valid |
| `distance` / `distance` | `WALKING_RUNNING_DISTANCE` | m | `distance` | metres (km → m when reported in km) |
| `active-calories` / `active_calories` | `ACTIVE_ENERGY_BURNED` | kcal | `active_calories` | **ACTIVE portion only** — NOT total caloriesOut (which folds in BMR) |
| `floors` / `floors` | `FLIGHTS_CLIMBED` | flights | `floors` | daily total; 0 valid |
| `vo2-max` / `vo2_max` | `VO2_MAX` | mL/(kg·min) | `vo2_max` | daily latest-wins; strictly positive |
## Workouts (activity log list)
## Sleep bundle (W5)
Scope: `activity`. `/1/user/-/activities/list.json?afterDate=...&sort=asc&offset&limit`
→ `activities[]`. Each → one `Workout` row (NOT a Measurement), keyed
`(userId, source: "FITBIT", externalId=logId)`. Fields: `sportType`
(`activityName` → canonical `WorkoutSportType`, `other` fallback),
`startedAt`/`endedAt` (`startTime` + `duration` ms), `durationSec`,
`totalEnergyKcal` (`calories`), `totalDistanceM` (`distance` km → m),
`avgHeartRate` (`averageHeartRate`). The classic list endpoint does NOT surface
min/max HR, so those stay null. A Fitbit run and the same run via Apple Health /
WHOOP stay distinct rows; the read-time `pickCanonicalWorkoutRows` picker
collapses the cross-source twin (FITBIT ranks just below WHOOP in the default
ladder).
Scope: `googlehealth.sleep.readonly`. A sleep session carries per-stage segments
(stage label + start + end). HealthLog stores one `SLEEP_DURATION` row per stage
(summed minutes), `measuredAt = the stage's latest END instant`, harmonised onto
the shared `SleepStage` enum the night-total + hypnogram readers consume.
externalId = `<session-anchor>:sleep_<stage>` (session end ISO instant), so a
re-scored night overwrites in place. Stage map: `light → CORE` (Fitbit "light" ↔
Apple "core" shallow-NREM band), `deep → DEEP`, `rem → REM`, `awake`/`wake`/
`restless → AWAKE`, `in_bed → IN_BED`, classic `asleep → ASLEEP`. Unknown stage
labels are skipped, not mis-bucketed.
## Deliberately skipped
## Exercise bundle (W5) — Workouts
Scope: `googlehealth.activity_and_fitness.readonly`. Each exercise session → one
`Workout` row (NOT a Measurement), keyed `(userId, source: "FITBIT",
externalId)` where externalId is the session id (or `exercise:<startISO>` when
absent). Fields: sportType (Google activity type → canonical `WorkoutSportType`,
`other` fallback), startedAt/endedAt, durationSec, totalEnergyKcal (active
session energy), totalDistanceM, avg/max/min HR (optional). A Fitbit run and the
same run via Apple Health / WHOOP stay distinct rows; the read-time
`pickCanonicalWorkoutRows` picker collapses the cross-source twin (FITBIT ranks
just below WHOOP in the default ladder). `active-zone-minutes` is **skipped** at
launch (no slot; not blocking).
- **Intraday heart rate → `PULSE`.** The classic API gates intraday HR + step
series behind a Personal-app type or a per-app intraday access request at
dev.fitbit.com that most self-hosters do not have. Daily resting HR is always
available and is synced; intraday is left out so a missing grant never parks
the connection. (A per-endpoint 403 also soft-skips, as a safety net.)
- **Skin temperature.** `/1/user/-/temp/skin/...` returns
`value.nightlyRelative` — a **delta from the user's personal baseline**, not an
absolute temperature. Mapping a baseline delta into the absolute
`WRIST_TEMPERATURE`/`BODY_TEMPERATURE` slot would write misleading values, so
the metric is **not mapped** (and `temperature` is not requested). Revisit only
if a relative-temperature slot is added.
- **Blood glucose / blood pressure.** No classic self-serve endpoint maps cleanly
to the HealthLog slots at launch.
## Idempotency
`(userId, type, source: "FITBIT", externalId)` unique. Spot/daily metric rows:
`externalId = <anchor>:<fieldTag>`. Daily cumulative activity rows:
`externalId = stats:<fieldTag>:<YYYY-MM-DD>` (Apple-Health overwrite shape).
Workouts: `(userId, source: "FITBIT", externalId)` on the `Workout` table. A
re-fetch of the same window (daily summaries re-roll after the fact, so the
incremental overlap is 24 h) overwrites in place.
`(userId, type, source: "FITBIT", externalId)` unique. Log-anchored metric rows:
`externalId = <logId>:<fieldTag>`. Daily-summary rows: `externalId =
<YYYY-MM-DD>:<fieldTag>`. Daily cumulative activity rows: `externalId =
stats:<fieldTag>:<YYYY-MM-DD>` (Apple-Health overwrite shape). Sleep:
`<logId>:sleep_<stage>:<i>`. Workouts: `(userId, source: "FITBIT", externalId)`
on the `Workout` table. A re-fetch of the same window (daily summaries settle
after the fact, so the incremental overlap is 24 h) overwrites in place.
+5 -3
View File
@@ -1,6 +1,8 @@
/**
* v1.12.0 — shared constants + mint helper for the Fitbit / Google Health OAuth
* state nonce ledger. Mirrors `src/lib/whoop/oauth-state.ts`.
* Shared constants + mint helper for the Fitbit OAuth state nonce ledger.
* Mirrors `src/lib/whoop/oauth-state.ts`. (v1.20.0 — the PKCE `code_verifier`
* lives on the `FitbitOAuthState` row; the connect route mints it via the
* client's `generatePkcePair`.)
*
* The nonce is random + opaque; a short-lived `FitbitOAuthState` row carries
* the `(nonce → userId)` mapping so the user id never leaks into request logs
@@ -19,7 +21,7 @@ export const FITBIT_OAUTH_STATE_COOKIE = "fitbit_state" as const;
/**
* 10-minute TTL on the state row. Matches the cookie `maxAge`. Long enough to
* cover a user approving the Google consent prompt; short enough that an
* cover a user approving the Fitbit consent prompt; short enough that an
* abandoned handshake doesn't strand a row for hours.
*/
export const FITBIT_OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
+55 -48
View File
@@ -1,32 +1,29 @@
/**
* Fitbit / Google Health activity-bundle sync (v1.12.0, W5).
* Fitbit Web API activity-bundle sync.
*
* Reads the daily cumulative-activity data types from the
* `activity_and_fitness.readonly` Restricted bundle and upserts each mapped
* daily total as `source = FITBIT`:
* Reads the daily cumulative-activity time series + VO2 max from the classic
* Fitbit Web API and upserts each mapped daily total as `source = FITBIT`:
*
* - steps → ACTIVITY_STEPS (count)
* - distance → WALKING_RUNNING_DISTANCE (metres)
* - active-calories → ACTIVE_ENERGY_BURNED (kcal — ACTIVE portion only)
* - floors → FLIGHTS_CLIMBED (count)
* - vo2-max → VO2_MAX (mL/(kg·min); daily latest-wins)
* - activities/steps → ACTIVITY_STEPS (count)
* - activities/distance → WALKING_RUNNING_DISTANCE (metres, km→m)
* - activities/activityCalories → ACTIVE_ENERGY_BURNED (kcal, ACTIVE only)
* - activities/floors → FLIGHTS_CLIMBED (count)
* - cardioscore → VO2_MAX (mL/(kg·min))
*
* These are per-day summaries (one value per calendar day). The externalId is
* minted with the `stats:` daily-total prefix — `stats:<fieldTag>:<YYYY-MM-DD>`
* — so a re-fetched day OVERWRITES the existing row rather than minting a
* duplicate, matching the Apple-Health `stats:<HK>:<YYYY-MM-DD>` overwrite
* contract. A day of rest legitimately records 0 steps / 0 floors / 0 active
* kcal, so the cumulative mappers preserve a zero (dropping it would leave a
* hole the chart misreads as missing data); VO2 max stays strictly positive.
* — so a re-fetched day OVERWRITES the existing row, matching the Apple-Health
* `stats:<HK>:<YYYY-MM-DD>` overwrite contract. A rest day records a real 0
* (dropping it would leave a chart gap); VO2 max stays strictly positive.
*
* A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection
* connected) — the six Restricted bundles are granted independently.
* Each endpoint is a date-RANGE call chunked into ≤30-day windows. A per-endpoint
* 403 soft-skips THAT metric only.
*/
import {
FITBIT_DATA_TYPES,
type FitbitDataType,
FITBIT_RANGE_DAYS,
type FitbitMappedMeasurement,
fetchDataPoints,
fetchActivitySeries,
fetchVo2MaxRange,
mapActiveCalories,
mapDistance,
mapFloors,
@@ -34,6 +31,7 @@ import {
mapVo2Max,
} from "./client";
import {
chunkDateRanges,
getValidToken,
handleCollectionFetchError,
upsertFitbitMeasurements,
@@ -42,34 +40,42 @@ import {
} from "./sync";
import { annotate } from "@/lib/logging/context";
/** One mappable activity metric: its data-type encoding + the per-point mapper + a verb. */
/** One mappable activity metric: a range fetcher + a body mapper + a verb. */
interface ActivityResource {
dataType: FitbitDataType;
map: (point: Record<string, unknown>) => FitbitMappedMeasurement[];
fetch: (accessToken: string, start: Date, end: Date) => Promise<unknown>;
map: (body: unknown) => FitbitMappedMeasurement[];
verb: string;
}
const ACTIVITY_RESOURCES: ActivityResource[] = [
{ dataType: FITBIT_DATA_TYPES.steps, map: mapSteps, verb: "fetchSteps" },
{
dataType: FITBIT_DATA_TYPES.distance,
fetch: (t, s, e) => fetchActivitySeries("steps", t, s, e),
map: mapSteps,
verb: "fetchSteps",
},
{
fetch: (t, s, e) => fetchActivitySeries("distance", t, s, e),
map: mapDistance,
verb: "fetchDistance",
},
{
dataType: FITBIT_DATA_TYPES.activeCalories,
fetch: (t, s, e) => fetchActivitySeries("activityCalories", t, s, e),
map: mapActiveCalories,
verb: "fetchActiveCalories",
},
{ dataType: FITBIT_DATA_TYPES.floors, map: mapFloors, verb: "fetchFloors" },
{ dataType: FITBIT_DATA_TYPES.vo2Max, map: mapVo2Max, verb: "fetchVo2Max" },
{
fetch: (t, s, e) => fetchActivitySeries("floors", t, s, e),
map: mapFloors,
verb: "fetchFloors",
},
{ fetch: fetchVo2MaxRange, map: mapVo2Max, verb: "fetchVo2Max" },
];
/**
* Assemble the stored externalId for a mapped activity reading. A cumulative
* daily metric carries the `cumulativeDaily` flag from its mapper — its
* externalId gets the `stats:` daily-total prefix so a re-fetched day overwrites
* in place (the mapper's `fieldTag` is already the `<tag>:<YYYY-MM-DD>` form).
* daily metric carries the `cumulativeDaily` flag — its externalId gets the
* `stats:` daily-total prefix so a re-fetched day overwrites in place (the
* mapper's `fieldTag` is already the `<tag>:<YYYY-MM-DD>` form).
*/
function externalIdFor(m: FitbitMappedMeasurement): string {
return m.cumulativeDaily ? `stats:${m.fieldTag}` : m.fieldTag;
@@ -82,28 +88,28 @@ export async function syncUserActivity(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// Cycle-wide watermark snapshotted once by `syncUserFitbit`; undefined on a
// full/backfill run.
const start = opts.start;
const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const end = opts.end ?? new Date();
const windows = chunkDateRanges(start, end, FITBIT_RANGE_DAYS);
let imported = 0;
for (const resource of ACTIVITY_RESOURCES) {
let points: Record<string, unknown>[];
try {
points = await fetchDataPoints(
resource.dataType,
tokenInfo.accessToken,
resource.verb,
{ start },
);
} catch (err) {
imported += await handleCollectionFetchError(resource.verb, userId, err);
continue;
}
const readings: FitbitMeasurementUpsert[] = [];
for (const point of points) {
for (const m of resource.map(point)) {
let forbidden = false;
for (const w of windows) {
let body: unknown;
try {
body = await resource.fetch(tokenInfo.accessToken, w.start, w.end);
} catch (err) {
imported += await handleCollectionFetchError(
resource.verb,
userId,
err,
);
forbidden = true;
break;
}
for (const m of resource.map(body)) {
readings.push({
type: m.type,
value: m.value,
@@ -113,6 +119,7 @@ export async function syncUserActivity(
});
}
}
if (forbidden) continue;
imported += (
await upsertFitbitMeasurements(userId, readings, {
deferRollup: opts.deferRollup,
+58 -124
View File
@@ -1,100 +1,74 @@
/**
* Fitbit / Google Health "health metrics & measurements" bundle sync (v1.12.0).
* Fitbit Web API health-metrics sync.
*
* Reads the launch metric data types from the
* `health_metrics_and_measurements.readonly` Restricted bundle and upserts each
* mapped reading as `source = FITBIT`:
* Reads the launch metric endpoints from the classic Fitbit Web API and upserts
* each mapped reading as `source = FITBIT`:
*
* - weight → WEIGHT
* - body-fat → BODY_FAT
* - daily-oxygen-saturation→ OXYGEN_SATURATION
* - daily-HRV → HEART_RATE_VARIABILITY (SDNN slot; see mapper)
* - daily-resting-HR → RESTING_HEART_RATE
* - daily-respiratory-rate → RESPIRATORY_RATE
* - heart-rate (intraday) → PULSE
* - daily sleep-temp deriv.→ WRIST_TEMPERATURE
* - height → User.heightCm (profile seed, NOT a Measurement)
* - body/log/weight → WEIGHT (kg)
* - body/log/fat → BODY_FAT (%)
* - spo2 summary → OXYGEN_SATURATION (%)
* - hrv summary → HEART_RATE_VARIABILITY (ms, dailyRmssd)
* - activities/heart → RESTING_HEART_RATE (bpm)
* - br summary → RESPIRATORY_RATE (breaths/min)
*
* Each data point yields at most one Measurement row, disambiguated by the
* per-point anchor + field-tag in the externalId (`<anchor>:<fieldTag>`) so a
* re-fetch of the same window overwrites in place rather than duplicating.
* Each endpoint is a date-RANGE call (one request per ≤30-day window), so the
* sync chunks `[start, end]` into per-endpoint windows to respect the API range
* caps and the 150 req/h budget. Each metric is fetched independently so a
* per-endpoint 403 (a scope the user did not grant) soft-skips only that metric.
*
* A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection
* connected) rather than parking the whole integration — the six Restricted
* bundles can be granted independently in the Google consent flow.
*
* Activity / sleep / workout land in W5; this is the first (and W3-only) resource.
* NOTE: intraday heart rate (`PULSE`) is intentionally NOT synced — the classic
* API gates intraday series behind a Personal-app / explicit per-app grant most
* self-hosters do not have, and the daily resting HR is always available. Skin
* temperature is also skipped: the classic reading is a baseline DELTA, not an
* absolute value, so it has no honest canonical slot (see mapping.md).
*/
import {
FITBIT_DATA_TYPES,
type FitbitDataType,
FITBIT_RANGE_DAYS,
type FitbitMappedMeasurement,
fetchDataPoints,
fetchBodyFatRange,
fetchHrvRange,
fetchRespiratoryRateRange,
fetchRestingHeartRateRange,
fetchSpo2Range,
fetchWeightRange,
mapBodyFat,
mapHeartRate,
mapHeartRateVariability,
mapHeightCm,
mapOxygenSaturation,
mapRespiratoryRate,
mapRestingHeartRate,
mapSleepTemperature,
mapWeight,
} from "./client";
import {
chunkDateRanges,
getValidToken,
handleCollectionFetchError,
upsertFitbitMeasurements,
type FitbitMeasurementUpsert,
type FitbitResourceSyncOptions,
} from "./sync";
import { getEvent } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
/** One mappable metric: its data-type encoding + the per-point mapper + a verb. */
/** One mappable metric: a range fetcher + a body mapper + a verb. */
interface MetricResource {
dataType: FitbitDataType;
map: (point: Record<string, unknown>) => FitbitMappedMeasurement[];
fetch: (accessToken: string, start: Date, end: Date) => Promise<unknown>;
map: (body: unknown) => FitbitMappedMeasurement[];
verb: string;
}
/** The launch metric resources (Measurement-producing). Height is handled separately. */
const METRIC_RESOURCES: MetricResource[] = [
{ dataType: FITBIT_DATA_TYPES.weight, map: mapWeight, verb: "fetchWeight" },
{ fetch: fetchWeightRange, map: mapWeight, verb: "fetchWeight" },
{ fetch: fetchBodyFatRange, map: mapBodyFat, verb: "fetchBodyFat" },
{ fetch: fetchSpo2Range, map: mapOxygenSaturation, verb: "fetchSpo2" },
{ fetch: fetchHrvRange, map: mapHeartRateVariability, verb: "fetchHrv" },
{
dataType: FITBIT_DATA_TYPES.bodyFat,
map: mapBodyFat,
verb: "fetchBodyFat",
},
{
dataType: FITBIT_DATA_TYPES.oxygenSaturation,
map: mapOxygenSaturation,
verb: "fetchSpo2",
},
{
dataType: FITBIT_DATA_TYPES.heartRateVariability,
map: mapHeartRateVariability,
verb: "fetchHrv",
},
{
dataType: FITBIT_DATA_TYPES.restingHeartRate,
fetch: fetchRestingHeartRateRange,
map: mapRestingHeartRate,
verb: "fetchRhr",
},
{
dataType: FITBIT_DATA_TYPES.respiratoryRate,
fetch: fetchRespiratoryRateRange,
map: mapRespiratoryRate,
verb: "fetchRespiratoryRate",
},
{
dataType: FITBIT_DATA_TYPES.heartRate,
map: mapHeartRate,
verb: "fetchHeartRate",
},
{
dataType: FITBIT_DATA_TYPES.sleepTemperature,
map: mapSleepTemperature,
verb: "fetchSleepTemperature",
},
];
export async function syncUserMetrics(
@@ -104,32 +78,32 @@ export async function syncUserMetrics(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// The incremental lower bound is the cycle-wide watermark snapshotted once by
// `syncUserFitbit` — never re-read here, so a sibling resource's stamp can't
// shrink this one's window. Undefined on a full/backfill run.
const start = opts.start;
const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const end = opts.end ?? new Date();
const windows = chunkDateRanges(start, end, FITBIT_RANGE_DAYS);
let imported = 0;
// Each metric data type is fetched + mapped independently so a per-class 403
// soft-skips only that class.
// Each metric is fetched + mapped independently so a per-endpoint 403
// soft-skips only that metric. A 403 short-circuits THAT metric (skips its
// remaining windows) without aborting the others.
for (const resource of METRIC_RESOURCES) {
let points: Record<string, unknown>[];
try {
points = await fetchDataPoints(
resource.dataType,
tokenInfo.accessToken,
resource.verb,
{ start },
);
} catch (err) {
imported += await handleCollectionFetchError(resource.verb, userId, err);
continue;
}
const readings: FitbitMeasurementUpsert[] = [];
for (const point of points) {
for (const m of resource.map(point)) {
let forbidden = false;
for (const w of windows) {
let body: unknown;
try {
body = await resource.fetch(tokenInfo.accessToken, w.start, w.end);
} catch (err) {
imported += await handleCollectionFetchError(
resource.verb,
userId,
err,
);
forbidden = true;
break;
}
for (const m of resource.map(body)) {
readings.push({
type: m.type,
value: m.value,
@@ -139,6 +113,7 @@ export async function syncUserMetrics(
});
}
}
if (forbidden) continue;
imported += (
await upsertFitbitMeasurements(userId, readings, {
deferRollup: opts.deferRollup,
@@ -146,47 +121,6 @@ export async function syncUserMetrics(
).imported;
}
// Height → User.heightCm, only when the user has no height yet. Never mint a
// Measurement; never overwrite a user-set value. Mirrors WHOOP's mapBody seed.
try {
let heightPoints: Record<string, unknown>[];
try {
heightPoints = await fetchDataPoints(
FITBIT_DATA_TYPES.height,
tokenInfo.accessToken,
"fetchHeight",
{ start },
);
} catch (err) {
heightPoints = [];
await handleCollectionFetchError("fetchHeight", userId, err);
}
// Latest non-null parsed height wins (the points are time-ordered ascending).
let heightCm: number | null = null;
for (const point of heightPoints) {
const cm = mapHeightCm(point);
if (cm !== null) heightCm = cm;
}
if (heightCm !== null) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { heightCm: true },
});
if (user && user.heightCm === null) {
await prisma.user.update({
where: { id: userId },
data: { heightCm },
});
}
}
} catch (err) {
getEvent()?.addWarning(
`fitbit: failed to seed heightCm for ${userId}: ${err}`,
);
}
// `markSynced` is owned by the orchestrator (`syncUserFitbit`), stamped once
// at the end of the cycle — never here, so the watermark can't move mid-cycle.
// `markSynced` is owned by the orchestrator (`syncUserFitbit`).
return imported;
}
+43 -42
View File
@@ -1,31 +1,30 @@
/**
* Fitbit / Google Health sleep-bundle sync (v1.12.0, W5).
* Fitbit Web API sleep sync.
*
* Reads sleep sessions from the `sleep.readonly` Restricted bundle and maps
* each into per-SEGMENT `SLEEP_DURATION` rows via `mapSleepSession` (one row
* per stage segment; `measuredAt = that segment's END instant; harmonised onto
* the shared `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP that the
* night-total + hypnogram readers already consume for WHOOP / Apple). Google
* carries a real per-segment series, so the rows lay each block at its true
* clock time (a MEASURED timeline, not reconstructed). Upserts as
* `source = FITBIT`.
* Reads sleep sessions from the classic `1.2/sleep/date` log and maps each into
* per-SEGMENT `SLEEP_DURATION` rows via `mapSleepSession` (one row per stage
* segment; `measuredAt = segment START + seconds = segment END`; harmonised onto
* the shared `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP the night-total
* + hypnogram readers already consume for WHOOP / Apple). The 1.2 log carries a
* real per-segment series, so the rows lay each block at its true clock time (a
* MEASURED timeline, not reconstructed). Upserts as `source = FITBIT`.
*
* Each segment carries the `sleepStage` axis and an indexed fieldTag so the
* several segments of one stage stay distinct under the
* `(userId, type, source, externalId)` dedup key. externalId =
* `<session-anchor>:sleep_<stage>:<i>` — a re-scored night overwrites in place.
* A 24 h overlap covers Google's after-the-fact re-score.
* several segments of one stage stay distinct under the dedup key. externalId =
* `<logId>:sleep_<stage>:<i>` — a re-scored night overwrites in place. A 24 h
* overlap covers Fitbit's after-the-fact re-score.
*
* A per-data-class 403 soft-skips the resource — the sleep bundle is granted
* independently of activity / metrics in the Google consent flow.
* A per-endpoint 403 soft-skips the resource — the `sleep` scope is granted
* independently in the consent flow.
*/
import {
FITBIT_ACTIVITY_PAGE_SIZE,
FITBIT_DATA_TYPES,
fetchDataPoints,
FITBIT_SLEEP_RANGE_DAYS,
fetchSleepRange,
mapSleepSession,
readSleepSessions,
} from "./client";
import {
chunkDateRanges,
getValidToken,
handleCollectionFetchError,
upsertFitbitMeasurements,
@@ -33,6 +32,7 @@ import {
type FitbitResourceSyncOptions,
} from "./sync";
import { annotate } from "@/lib/logging/context";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export async function syncUserSleep(
userId: string,
@@ -41,33 +41,34 @@ export async function syncUserSleep(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// Cycle-wide watermark snapshotted once by `syncUserFitbit`; undefined on a
// full/backfill run.
const start = opts.start;
// The classic 1.2 sleep log emits offset-less local wall-clock timestamps;
// anchor them against the user's stored zone so a near-midnight segment END
// lands on the correct wake-day rather than being shifted by the process zone.
const tz = await resolveUserTimezone(userId);
let points: Record<string, unknown>[];
try {
points = await fetchDataPoints(
FITBIT_DATA_TYPES.sleep,
tokenInfo.accessToken,
"fetchSleep",
{ start, pageSize: FITBIT_ACTIVITY_PAGE_SIZE },
);
} catch (err) {
return handleCollectionFetchError("fetchSleep", userId, err);
}
const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const end = opts.end ?? new Date();
const windows = chunkDateRanges(start, end, FITBIT_SLEEP_RANGE_DAYS);
const readings: FitbitMeasurementUpsert[] = [];
for (const point of points) {
for (const m of mapSleepSession(point)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: m.fieldTag,
sleepStage: m.sleepStage ?? null,
});
for (const w of windows) {
let body: unknown;
try {
body = await fetchSleepRange(tokenInfo.accessToken, w.start, w.end);
} catch (err) {
return handleCollectionFetchError("fetchSleep", userId, err);
}
for (const session of readSleepSessions(body)) {
for (const m of mapSleepSession(session, tz)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: m.fieldTag,
sleepStage: m.sleepStage ?? null,
});
}
}
}
+45 -27
View File
@@ -1,24 +1,25 @@
/**
* Fitbit / Google Health exercise-session sync (v1.12.0, W5).
* Fitbit Web API exercise-session sync.
*
* Reads exercise sessions from the `activity_and_fitness.readonly` Restricted
* bundle and upserts each into the `Workout` table as `source = FITBIT`, keyed
* on `(userId, source, externalId)` so a re-fetch overwrites in place.
* Reads exercise sessions from the classic `activities/list` endpoint
* (`afterDate` + ascending offset/limit pagination) and upserts each into the
* `Workout` table as `source = FITBIT`, keyed on `(userId, source, externalId)`
* (externalId = the Fitbit `logId`) so a re-fetch overwrites in place.
*
* A Fitbit run and the same run via Apple Health (or WHOOP) stay distinct
* `Workout` rows (different `source`); the read-time `pickCanonicalWorkoutRows`
* picker collapses the cross-source twin at read time per the user's source
* ladder (FITBIT is already in the default ladder, ranked just below WHOOP).
* There is no ingest-time collapse for a server-owned source pair.
* picker collapses the cross-source twin per the user's source ladder (FITBIT is
* already in the default ladder, ranked just below WHOOP). No ingest-time
* collapse for a server-owned source pair.
*
* A per-data-class 403 soft-skips the resource — the activity/fitness bundle is
* granted independently in the Google consent flow.
* The page-walk is bounded (the 150 req/h budget is tight): a small page cap
* covers the incremental window comfortably; a deep backfill is bounded by the
* backfill horizon `afterDate`. A per-endpoint 403 soft-skips the resource.
*/
import {
FITBIT_ACTIVITY_PAGE_SIZE,
FITBIT_DATA_TYPES,
fetchDataPoints,
fetchActivityList,
mapWorkout,
readActivityList,
type FitbitMappedWorkout,
} from "./client";
import {
@@ -28,6 +29,12 @@ import {
} from "./sync";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/** Activities per page on the `activities/list` walk. */
const WORKOUT_PAGE_SIZE = 100;
/** Hard ceiling on pages walked per cycle (rate-budget guard). */
const WORKOUT_MAX_PAGES = 12;
export async function syncUserWorkout(
userId: string,
@@ -36,25 +43,36 @@ export async function syncUserWorkout(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// Cycle-wide watermark snapshotted once by `syncUserFitbit`; undefined on a
// full/backfill run.
const start = opts.start;
// `startTime` on the activities list is offset-less local wall-clock; anchor
// it to the user's stored zone rather than the process zone.
const tz = await resolveUserTimezone(userId);
let points: Record<string, unknown>[];
try {
points = await fetchDataPoints(
FITBIT_DATA_TYPES.exercise,
tokenInfo.accessToken,
"fetchExercise",
{ start, pageSize: FITBIT_ACTIVITY_PAGE_SIZE },
);
} catch (err) {
return handleCollectionFetchError("fetchExercise", userId, err);
const afterDate =
opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const entries: ReturnType<typeof readActivityList> = [];
let offset = 0;
let pages = 0;
while (pages < WORKOUT_MAX_PAGES) {
let body: unknown;
try {
body = await fetchActivityList(tokenInfo.accessToken, afterDate, {
limit: WORKOUT_PAGE_SIZE,
offset,
});
} catch (err) {
return handleCollectionFetchError("fetchExercise", userId, err);
}
const page = readActivityList(body);
entries.push(...page);
pages += 1;
if (page.length < WORKOUT_PAGE_SIZE) break;
offset += WORKOUT_PAGE_SIZE;
}
let imported = 0;
for (const point of points) {
const w: FitbitMappedWorkout | null = mapWorkout(point);
for (const entry of entries) {
const w: FitbitMappedWorkout | null = mapWorkout(entry, tz);
if (!w) continue; // no usable time span — not a workout
try {
+153 -69
View File
@@ -1,17 +1,21 @@
/**
* Fitbit / Google Health sync service (v1.12.0).
* Fitbit Web API sync service.
*
* This wave (OAuth + credentials) lands the token-management half:
* - `getValidToken` decrypts the stored token, refreshes at
* `tokenExpiresAt - 5 min`, persists the new access token + expiry, and —
* UNLIKE WHOOP — only overwrites the stored refresh token when the response
* carries a fresh one (Google does not rotate refresh tokens).
* `tokenExpiresAt - 5 min`, and persists the new access token + expiry AND
* the rotated refresh token. Classic Fitbit ROTATES refresh tokens
* (one-time use), so the new `refresh_token` MUST replace the stored one —
* the inverse of the prior Google transport, which did not rotate. The
* persist is a compare-and-swap on the stored ciphertext so two overlapping
* syncs cannot park the connection at reauth (see `persistRotatedToken`).
* - `recordFitbitSyncFailure` / `classificationToFailureKind` map a
* classified API error onto the shared integration-status ledger.
*
* The per-resource data sync (`upsertFitbitMeasurements`, `syncUserFitbit`, the
* collection walkers, and the 403 soft-skip orchestration) lands in a later
* wave and extends this file.
* - `upsertFitbitMeasurements` + `syncUserFitbit` own the write tail (dedup,
* overwrite contract, rollup fold) — reused unchanged across the transport
* fork; only the per-resource fetch/map differs.
* - `chunkDateRanges` slices a deep backfill into the per-endpoint date-range
* windows the classic Web API caps each call at (30 days), so the per-resource
* syncs issue one valid request per window.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import pLimit from "p-limit";
@@ -31,6 +35,7 @@ import {
recomputeUserRollups,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { persistRotatedToken } from "@/lib/integrations/oauth-refresh";
import { refreshAccessToken } from "./client";
import { getUserFitbitCredentials } from "./credentials";
import {
@@ -43,13 +48,22 @@ import {
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
/**
* Overlap window for the incremental sync, in ms. Google re-rolls daily
* summaries after the fact (a daily SpO2 / HRV / RHR can finalise hours after
* the night), so the default overlap is a full 24 h to make sure the re-rolled
* row is re-fetched on the next tick. Mirrors WHOOP's recovery/sleep overlap.
* Overlap window for the incremental sync, in ms. Fitbit finalises daily
* summaries after the fact (a daily SpO2 / HRV / RHR can settle hours after the
* night), so the default overlap is a full 24 h to make sure the re-rolled row
* is re-fetched on the next tick. Mirrors WHOOP's recovery/sleep overlap.
*/
export const FITBIT_DEFAULT_OVERLAP_MS = 24 * 60 * 60 * 1000;
/**
* How far back a `fullSync` backfill walks. The classic Web API per-endpoint
* range cap (30 days) means a deep backfill issues many requests against the
* tight 150/h budget, so the backfill horizon is bounded to keep one user's
* backfill inside a couple of hourly windows (~12 chunks/resource). One year of
* daily summaries is the launch horizon.
*/
export const FITBIT_BACKFILL_DAYS = 365;
export interface FitbitTokenInfo {
accessToken: string;
connection: { id: string; fitbitUserId: string };
@@ -61,10 +75,12 @@ export interface FitbitTokenInfo {
* credentials, or the refresh fails (the failure is recorded so scheduled syncs
* back off).
*
* KEY DELTA vs WHOOP: Google does not rotate refresh tokens. On refresh, persist
* the new access token + expiry and overwrite the stored `refreshToken` ONLY
* when the response carries a fresh one — otherwise keep the existing refresh
* token so the next refresh still authenticates.
* Classic Fitbit ROTATES refresh tokens (one-time use): every refresh response
* carries a fresh `refresh_token` that invalidates the old one. Persist the new
* access token + expiry AND the new refresh token unconditionally. If a refresh
* response ever omits the new refresh token (a malformed reply), keep the stored
* one rather than wiping it — a blank refresh token would brick every future
* refresh.
*/
export async function getValidToken(
userId: string,
@@ -100,23 +116,49 @@ export async function getValidToken(
const newTokens = await refreshAccessToken(refreshToken, creds);
const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000);
// Google does NOT rotate refresh tokens: a routine refresh returns a new
// access token but usually omits `refresh_token`. Persist the new access
// token + expiry and only overwrite the stored refresh token when the
// response actually carries a fresh one — otherwise keep the existing one.
await prisma.fitbitConnection.update({
where: { id: connection.id },
data: {
accessToken: encrypt(newTokens.access_token),
tokenExpiresAt: expiresAt,
...(newTokens.refresh_token
? { refreshToken: encrypt(newTokens.refresh_token) }
: {}),
// Classic Fitbit ROTATES refresh tokens: the refresh response carries a
// fresh `refresh_token` that replaces the (now-invalid) stored one. Persist
// with a compare-and-swap on the stored ciphertext so a concurrent sync
// that already rotated the token does not get its work clobbered — and so
// THIS caller, if it lost the race, reuses the peer's rotated access token
// instead of parking the connection at reauth. Guard the malformed-reply
// case: if the response omits the new refresh token, keep the existing one
// rather than writing a blank, which would brick every future refresh.
const persistedAccessToken = await persistRotatedToken(
newTokens.access_token,
{
conditionalUpdate: async () => {
const { count } = await prisma.fitbitConnection.updateMany({
// `connection.refreshToken` is the exact ciphertext we read +
// spent; matching it is the CAS guard (a re-encrypt would differ).
where: {
id: connection.id,
refreshToken: connection.refreshToken,
},
data: {
accessToken: encrypt(newTokens.access_token),
tokenExpiresAt: expiresAt,
...(newTokens.refresh_token
? { refreshToken: encrypt(newTokens.refresh_token) }
: {}),
},
});
return count;
},
readPeerAccessToken: async () => {
const fresh = await prisma.fitbitConnection.findUnique({
where: { id: connection.id },
select: { accessToken: true },
});
return fresh ? decrypt(fresh.accessToken) : null;
},
},
});
);
if (!persistedAccessToken) return null;
return {
accessToken: newTokens.access_token,
accessToken: persistedAccessToken,
connection: {
id: connection.id,
fitbitUserId: connection.fitbitUserId,
@@ -141,14 +183,14 @@ export async function getValidToken(
}
/**
* True when a caught error is a per-data-class 403 (forbidden). The six Google
* Health Restricted bundles are granted independently in the consent flow, so a
* 403 on ONE data class is a scope gate on THAT class — soft-skip it and keep
* the connection connected rather than parking the whole integration at
* `error_reauth`. Connection-wide reauth is reserved for a 401 (token rejected)
* and for a 403 on the token-refresh / profile path (a genuine grant revoke),
* which run outside the per-resource catch blocks. Matters more here than for
* WHOOP because partial grants are likely with six independent bundles.
* True when a caught error is a per-endpoint 403 (forbidden). The classic Fitbit
* scopes are granted independently in the consent flow (and intraday needs an
* extra per-app grant), so a 403 on ONE endpoint is a scope gate on THAT metric
* — soft-skip it and keep the connection connected rather than parking the whole
* integration at `error_reauth`. Connection-wide reauth is reserved for a 401
* (token rejected) and a 403 on the token-refresh / profile path (a genuine
* grant revoke), which run outside the per-resource catch blocks. Matters here
* because partial grants are likely across the independent scopes.
*/
export function isCollectionForbidden(err: unknown): boolean {
return err instanceof FitbitApiError && err.httpStatus === 403;
@@ -203,16 +245,18 @@ export async function handleCollectionFetchError(
/**
* Options threaded from `syncUserFitbit` to each per-resource sync. The `start`
* watermark is snapshotted ONCE by the orchestrator (a single `lastSyncedAt`
* read) so every resource fetches from the same lower bound; no resource re-reads
* `lastSyncedAt` or stamps `markSynced` itself. On a full/backfill run `start`
* is undefined (no lower bound). The orchestrator owns the single end-of-cycle
* `markSynced`.
* + `end` window is snapshotted ONCE by the orchestrator (a single `lastSyncedAt`
* read + a single `now`) so every resource fetches the same window; no resource
* re-reads `lastSyncedAt` or stamps `markSynced` itself. The per-resource sync
* chunks `[start, end]` into the per-endpoint date-range cap. The orchestrator
* owns the single end-of-cycle `markSynced`.
*/
export interface FitbitResourceSyncOptions {
fullSync?: boolean;
/** The incremental lower bound, snapshotted once by the orchestrator. */
/** The lower bound of the fetch window, snapshotted once by the orchestrator. */
start?: Date;
/** The upper bound of the fetch window (now), snapshotted once. */
end?: Date;
/**
* When true, `upsertFitbitMeasurements` writes the rows but SKIPS the inline
* per-(type,day) DAY-rollup recompute + status-insight invalidate, returning
@@ -227,19 +271,53 @@ export interface FitbitResourceSyncOptions {
}
/**
* Compute the incremental `start` for a resource sync. `fullSync` returns
* undefined (the backfill walks deep history without a lower bound). Otherwise
* start from `lastSyncedAt - overlap`, or 30 days back on the very first
* incremental tick. Mirrors WHOOP's `incrementalStart`.
* Compute the incremental `start` for a resource sync. `fullSync` walks back the
* bounded backfill horizon (the classic Web API caps each call's date range, so
* an unbounded backfill would blow the 150/h budget). Otherwise start from
* `lastSyncedAt - overlap`, or 30 days back on the very first incremental tick.
*/
export function incrementalStart(
lastSyncedAt: Date | null,
opts: { fullSync?: boolean; overlapMs?: number } = {},
): Date | undefined {
if (opts.fullSync) return undefined;
opts: { fullSync?: boolean; overlapMs?: number; now?: Date } = {},
): Date {
const now = opts.now ?? new Date();
if (opts.fullSync) {
return new Date(now.getTime() - FITBIT_BACKFILL_DAYS * 24 * 60 * 60 * 1000);
}
const overlap = opts.overlapMs ?? FITBIT_DEFAULT_OVERLAP_MS;
if (lastSyncedAt) return new Date(lastSyncedAt.getTime() - overlap);
return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
}
/**
* Slice `[start, end]` into consecutive inclusive windows no wider than
* `maxDays` calendar days, the per-endpoint range cap the classic Web API
* enforces. Each window is `{ start, end }` with `end` no later than the overall
* `end`; the per-resource syncs issue one request per window. Returns a single
* window when the span already fits.
*/
export function chunkDateRanges(
start: Date,
end: Date,
maxDays: number,
): Array<{ start: Date; end: Date }> {
const windows: Array<{ start: Date; end: Date }> = [];
if (end.getTime() < start.getTime()) return windows;
const stepMs = maxDays * 24 * 60 * 60 * 1000;
let cursor = start.getTime();
const endMs = end.getTime();
// Guard against a pathological span minting an unbounded loop.
let guard = 0;
const MAX_WINDOWS = 1000;
while (cursor <= endMs && guard < MAX_WINDOWS) {
// Each window spans up to `maxDays` days inclusive, so subtract one day from
// the step to keep the inclusive end inside the cap.
const windowEnd = Math.min(cursor + stepMs - 24 * 60 * 60 * 1000, endMs);
windows.push({ start: new Date(cursor), end: new Date(windowEnd) });
cursor = windowEnd + 24 * 60 * 60 * 1000;
guard += 1;
}
return windows;
}
/**
@@ -486,14 +564,13 @@ export async function markSynced(userId: string): Promise<void> {
* and exercise workouts (W5). Each runs independently so a per-resource 403
* (a Restricted bundle the user did not grant) soft-skips only that resource.
*
* WATERMARK: the incremental `start` is snapshotted ONCE here (from the single
* `lastSyncedAt` read at the top of the cycle) and threaded to every resource,
* and `markSynced` is stamped ONCE at the end. A per-resource read+stamp would
* let the first resource move `lastSyncedAt` to now() so later resources only
* see the last overlap window — silently dropping the gap after an outage longer
* than the overlap. The full/backfill path passes `fullSync: true`, which makes
* `incrementalStart` return undefined regardless of the snapshot, so the deep
* history walk is unaffected.
* WATERMARK: the incremental `[start, end]` window is snapshotted ONCE here
* (from the single `lastSyncedAt` read + a single `now` at the top of the cycle)
* and threaded to every resource, and `markSynced` is stamped ONCE at the end. A
* per-resource read+stamp would let the first resource move `lastSyncedAt` to
* now() so later resources only see the last overlap window — silently dropping
* the gap after an outage longer than the overlap. The full/backfill path passes
* `fullSync: true`, which widens `start` back to the bounded backfill horizon.
*/
export async function syncUserFitbit(
userId: string,
@@ -506,21 +583,23 @@ export async function syncUserFitbit(
return 0;
}
// Snapshot the incremental watermark ONCE for the whole cycle. Every resource
// sees the same `start`; no resource re-reads `lastSyncedAt` mid-cycle. On a
// full/backfill run `incrementalStart` ignores the snapshot and returns
// undefined (no lower bound).
// Snapshot the incremental window ONCE for the whole cycle. Every resource
// sees the same `[start, end]`; no resource re-reads `lastSyncedAt` mid-cycle.
// A full/backfill run widens `start` back to the bounded backfill horizon.
const connection = await prisma.fitbitConnection.findUnique({
where: { userId },
select: { lastSyncedAt: true },
});
if (!connection) return 0;
const end = new Date();
const start = incrementalStart(connection.lastSyncedAt, {
fullSync: opts.fullSync,
now: end,
});
const resourceOpts: FitbitResourceSyncOptions = {
fullSync: opts.fullSync,
start,
end,
// A backfill walks years of daily summaries — defer the per-(type,day)
// rollup hook on every write and run ONE range-recompute at the end of the
// cycle. The hourly incremental keeps the inline hook (small touched set).
@@ -656,12 +735,17 @@ export function classificationToFailureKind(
/**
* Bounded fan-out width for the hourly Fitbit cohort poll. The cron tick carries
* no `userId`, so the worker resolves every connection and hands the cohort here.
* A small pool (rather than a strict serial loop) means one slow Google response
* A small pool (rather than a strict serial loop) means one slow Fitbit response
* can't stall the whole cohort, while the cap keeps the burst of per-user
* resource fetches + rollup writes from crowding the worker's DB pool — matching
* the `p-limit` ceilings the analytics fan-out + insight warm pass use elsewhere.
* resource fetches + rollup writes from crowding the worker's DB pool.
*
* Tuned to 2 for the classic Web API: each per-user cycle issues ~a dozen
* date-range requests against a tight 150 req/h-per-user budget, so a narrower
* pool keeps the concurrent outbound burst (and the DB-write tail behind it)
* modest. The per-user budget is independent, so the cap is about worker
* pressure, not the API ceiling — but 2 keeps both comfortable.
*/
export const FITBIT_POLL_CONCURRENCY = 4;
export const FITBIT_POLL_CONCURRENCY = 2;
/**
* Run a Fitbit hourly-poll cohort with bounded concurrency + per-user error
@@ -65,6 +65,9 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
readBestGranularityRollups: rollup.readBestGranularityRollups,
}));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
import { computeEpisodeCorrelation } from "../correlation-read";
import { ILLNESS_SCAN_TYPES } from "../correlation";
@@ -302,3 +305,120 @@ describe("computeEpisodeCorrelation — contamination-guard read window", () =>
}
});
});
describe("computeEpisodeCorrelation — sleep-as-context read", () => {
it("reconstructs baseline + episode nights and surfaces the observation, deduping multi-source nights", async () => {
rollup.probeRollupCoverage.mockResolvedValue(
new Map<string, boolean>(
ILLNESS_SCAN_TYPES.map((t) => [String(t), false]),
),
);
rollup.readBestGranularityRollups.mockResolvedValue(null);
// A banded RHR vital (flat-ish at 55) clears the engine coverage floor.
const rhrRows: Array<{ value: number; measuredAt: Date }> = [];
{
let cursor = Date.parse("2025-12-05T12:00:00Z");
const jit = [-2, -1, 0, 1, 2];
for (let i = 0; i < 40; i++) {
rhrRows.push({
value: 55 + jit[i % jit.length],
measuredAt: new Date(cursor),
});
cursor += 24 * 60 * 60 * 1000;
}
}
// Sleep rows. Baseline nights ~420 min (7h); episode nights ~510 min
// (8.5h) → +90 min. The episode 2026-01-11 night is DOUBLE-WRITTEN by two
// sources (WHOOP + Apple Health); the canonical reconstruction must count
// it ONCE, not sum it to ~1020 min and inflate the delta.
// A "night" is one asleep block whose wake-day is the local calendar day.
type SleepRow = {
value: number;
measuredAt: Date;
sleepStage: null;
source: string;
deviceType: string | null;
};
const sleepRows: SleepRow[] = [];
const asleepNight = (
wakeDay: string,
minutes: number,
source: string,
): SleepRow => ({
// The block ENDS at 07:00Z on the wake-day; one ASLEEP sample of `minutes`.
value: minutes,
measuredAt: new Date(`${wakeDay}T07:00:00Z`),
sleepStage: null,
source,
deviceType: source,
});
// Baseline nights (well before the pre-onset lookback start 2026-01-03).
for (const day of [
"2025-12-20",
"2025-12-21",
"2025-12-22",
"2025-12-23",
"2025-12-24",
"2025-12-25",
]) {
sleepRows.push(asleepNight(day, 420, "WHOOP"));
}
// Episode nights (active span ≥ onset 2026-01-10).
sleepRows.push(asleepNight("2026-01-10", 510, "WHOOP"));
// Double-written night — same wake-day, two writers.
sleepRows.push(asleepNight("2026-01-11", 510, "WHOOP"));
sleepRows.push(asleepNight("2026-01-11", 510, "APPLE_HEALTH"));
sleepRows.push(asleepNight("2026-01-12", 510, "WHOOP"));
db.measurement.findMany.mockImplementation(
async ({
where,
}: {
where: {
type: string;
measuredAt: { gte: Date; lt?: Date; lte?: Date };
};
}) => {
const from = where.measuredAt.gte.getTime();
const to =
where.measuredAt.lt?.getTime() ??
where.measuredAt.lte?.getTime() ??
NOW.getTime();
if (where.type === "RESTING_HEART_RATE") {
return rhrRows.filter(
(r) =>
r.measuredAt.getTime() >= from && r.measuredAt.getTime() <= to,
);
}
if (where.type === "SLEEP_DURATION") {
return sleepRows.filter(
(r) =>
r.measuredAt.getTime() >= from && r.measuredAt.getTime() <= to,
);
}
return [];
},
);
const episode = {
id: "ep-sleep",
onsetAt: new Date("2026-01-10T08:00:00Z"),
resolvedAt: null as Date | null,
lifecycle: "ACUTE",
};
const out = await computeEpisodeCorrelation("u1", episode, "UTC", NOW);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const sleep = out.value.sleepContext;
expect(sleep).not.toBeNull();
expect(sleep?.baselineMeanMinutes).toBe(420);
// The double-written 01-11 night is counted ONCE → episode mean ≈ 510, not
// a doubled ~680 that summing two writers would produce.
expect(sleep?.episodeMeanMinutes).toBe(510);
expect(sleep?.deltaMinutes).toBe(90);
expect(sleep?.nightsCounted).toBe(3);
});
});
@@ -20,7 +20,9 @@ import {
isAdverseDeviation,
MIN_BASELINE_DAYS,
MIN_EPISODE_COVERAGE_DAYS,
SLEEP_CONTEXT_MIN_DELTA_MINUTES,
type IllnessCorrelationInput,
type SleepNightPoint,
type SymptomBurdenPoint,
type VitalDayPoint,
} from "../correlation";
@@ -229,6 +231,105 @@ describe("computeIllnessCorrelation — golden recovery-gap", () => {
expect(out.value.returns[0]?.returnedDay).toBeNull();
});
it("still reports the settle day when a single transient out-of-band reading trails the recovery", () => {
// R-REG-1: a non-relapse episode that settles for ≥ RETURN_STABILITY_DAYS
// and then has ONE noisy out-of-band reading on the last logged day. A lone
// trailing outlier is measurement noise, not a relapse, so the recovery day
// must NOT be dropped to null. RHR settles 01-18..01-21 (4 in-band days),
// then a single 63 spike on 01-22.
const type: MeasurementType = "RESTING_HEART_RATE";
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 70 }, // onset, out of band
{ day: "2026-01-12", mean: 75 }, // nadir
{ day: "2026-01-17", mean: 64 }, // felt-better day, still elevated
{ day: "2026-01-18", mean: 55 }, // settle starts
{ day: "2026-01-19", mean: 55 },
{ day: "2026-01-20", mean: 55 },
{ day: "2026-01-21", mean: 55 }, // 4 stable in-band days
{ day: "2026-01-22", mean: 63 }, // lone trailing outlier (transient)
];
const out = computeIllnessCorrelation(
input({ series: [{ type, baselineDays, episodeDays }] }),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.returns[0]?.returnedDay).toBe("2026-01-18");
// returned 01-18, felt better 01-17 → gap +1 (a non-null number)
expect(out.value.recoveryGapDays).toBe(dayDiff("2026-01-17", "2026-01-18"));
expect(out.value.recoveryGapDays).toBe(1);
});
it("reports the FINAL settle day after a genuine relapse that re-settles", () => {
// R-REG-1: settle → genuine sustained re-deviation (a real flare, not noise)
// → re-settle. The LAST sustained in-band run wins. RHR settles 01-13..01-15,
// flares out-of-band 01-16..01-18 (3 days = a genuine relapse), then settles
// again 01-19..01-21. The return is the SECOND settle, 01-19.
const type: MeasurementType = "RESTING_HEART_RATE";
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 72 }, // onset, out of band
{ day: "2026-01-12", mean: 75 }, // nadir
{ day: "2026-01-13", mean: 55 }, // first settle starts
{ day: "2026-01-14", mean: 55 },
{ day: "2026-01-15", mean: 55 }, // 3 in-band days
{ day: "2026-01-16", mean: 68 }, // genuine relapse (sustained)
{ day: "2026-01-17", mean: 67 },
{ day: "2026-01-18", mean: 66 }, // 3 out-of-band days
{ day: "2026-01-19", mean: 55 }, // second settle starts
{ day: "2026-01-20", mean: 55 },
{ day: "2026-01-21", mean: 55 }, // 3 in-band days
];
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [{ type, baselineDays, episodeDays }],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.returns[0]?.returnedDay).toBe("2026-01-19");
expect(out.value.recoveryGapDays).toBe(dayDiff("2026-01-14", "2026-01-19"));
expect(out.value.recoveryGapDays).toBe(5);
});
it("returns null when a sustained re-deviation follows the settle with no re-settle (still elevated)", () => {
// R-REG-1 boundary: the noise tolerance must NOT swallow a genuine ongoing
// relapse. Settle 01-13..01-15, then a SUSTAINED out-of-band tail
// (01-16..01-18, ≥ RETURN_STABILITY_DAYS) with no re-settle → the vital is
// still elevated at the end → null, exactly as a still-active episode.
const type: MeasurementType = "RESTING_HEART_RATE";
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 72 },
{ day: "2026-01-12", mean: 75 },
{ day: "2026-01-13", mean: 55 }, // settle
{ day: "2026-01-14", mean: 55 },
{ day: "2026-01-15", mean: 55 },
{ day: "2026-01-16", mean: 68 }, // sustained re-deviation, no re-settle
{ day: "2026-01-17", mean: 67 },
{ day: "2026-01-18", mean: 66 },
];
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [{ type, baselineDays, episodeDays }],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.returns[0]?.returnedDay).toBeNull();
expect(out.value.recoveryGapDays).toBeNull();
});
it("flags a return adverse only when the vital deviated in the adverse direction", () => {
// A neutral-direction vital (WEIGHT) can deviate out of band and stably
// return — it produces a return finding, but `adverse` must stay false so
@@ -481,6 +582,93 @@ describe("computeIllnessCorrelation — red-flag escalation", () => {
expect(fever).toBeDefined();
expect(fever?.days).toBe(3);
});
it("does NOT mis-escalate when the thermometer and day-log fever interleave (false-positive guard)", () => {
// The fever red-flag unions two individually-sorted sources — the passive
// BODY_TEMPERATURE series and the day-log feverC — into a Map. Unless the
// unioned points are re-sorted by day, the run scan sees Map-insertion
// order, not chronology. Here the thermometer covers {01-10, 01-12} and the
// day-log covers {01-11, 01-13, 01-14}. True chronology
// 01-10..01-14 = 39,39,37,39,39 → the fever breaks on 01-12, so the longest
// run ≥38.5 is 2 → NO escalation. Read in insertion order (temp first:
// 39,37 then fever: 39,39,39) it would read as a spurious 3-day run.
const temp: MeasurementType = "BODY_TEMPERATURE";
const rhr: MeasurementType = "RESTING_HEART_RATE";
const out = computeIllnessCorrelation(
input({
series: [
{
type: temp,
baselineDays: jitteredBaseline(36.6, 0.2, 21, "2026-01-02"),
episodeDays: [
{ day: "2026-01-10", mean: 39.0 },
{ day: "2026-01-12", mean: 37.0 }, // fever broke here
],
},
{
type: rhr,
baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"),
episodeDays: flatBaseline(55, 5, "2026-01-15"),
},
],
dayLogFever: [
{ day: "2026-01-11", feverC: 39.0 },
{ day: "2026-01-13", feverC: 39.0 },
{ day: "2026-01-14", feverC: 39.0 },
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const fever = out.value.redFlags.find(
(f) => f.reason === "sustained_fever",
);
expect(fever).toBeUndefined();
});
it("escalates a genuine cross-source run that insertion order would hide (false-negative guard)", () => {
// The mirror case: a real consecutive 3-day fever run (01-11..01-13) spans
// both sources, but Map-insertion order scatters the days and breaks it.
// The thermometer covers {01-12 (fever), 01-10 (broke)} and the day-log
// covers {01-11, 01-13}. True chronology 01-10..01-13 = 37,39,39,39 → a
// real 3-day run ≥38.5. In Map-insertion order (temp first: 01-12=39,
// 01-10=37; then day-log: 01-11=39, 01-13=39) the 01-10 reset lands BETWEEN
// the fever days → longest run 2 → the genuine escalation is hidden. The
// explicit chronological sort is what recovers the streak.
const temp: MeasurementType = "BODY_TEMPERATURE";
const rhr: MeasurementType = "RESTING_HEART_RATE";
const out = computeIllnessCorrelation(
input({
series: [
{
type: temp,
baselineDays: jitteredBaseline(36.6, 0.2, 21, "2026-01-02"),
episodeDays: [
{ day: "2026-01-12", mean: 39.0 }, // fever, listed first
{ day: "2026-01-10", mean: 37.0 }, // broke, listed second
],
},
{
type: rhr,
baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"),
episodeDays: flatBaseline(55, 5, "2026-01-15"),
},
],
dayLogFever: [
{ day: "2026-01-11", feverC: 39.0 },
{ day: "2026-01-13", feverC: 39.0 },
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const fever = out.value.redFlags.find(
(f) => f.reason === "sustained_fever",
);
expect(fever).toBeDefined();
expect(fever?.days).toBe(3);
expect(fever?.worstValue).toBe(39.0);
});
});
describe("isAdverseDeviation", () => {
@@ -810,3 +998,343 @@ describe("computeIllnessCorrelation — symptom-burden return track", () => {
).toBeUndefined();
});
});
describe("computeIllnessCorrelation — relapse-aware return (last sustained)", () => {
it("reports the LAST sustained return when the vital relapses mid-episode", () => {
// Deviate 01-10, settle in-band 01-13..01-15 (a 3-day run that the OLD
// first-return rule would have called the return), re-deviate 01-16..01-18,
// then settle again 01-20..01-22 and hold. felt-better 01-14. The honest
// answer is the SECOND settle (01-20), not the first — the relapse pushes
// the return later and extends the gap.
const type: MeasurementType = "RESTING_HEART_RATE";
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 75 }, // first deviation
{ day: "2026-01-12", mean: 74 },
{ day: "2026-01-13", mean: 55 }, // first settle (old return)
{ day: "2026-01-14", mean: 55 },
{ day: "2026-01-15", mean: 55 }, // 3-day in-band run
{ day: "2026-01-16", mean: 75 }, // RELAPSE
{ day: "2026-01-17", mean: 74 },
{ day: "2026-01-18", mean: 73 },
{ day: "2026-01-20", mean: 55 }, // final settle start
{ day: "2026-01-21", mean: 55 },
{ day: "2026-01-22", mean: 55 }, // holds to end
];
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [{ type, baselineDays, episodeDays }],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.returns[0]?.returnedDay).toBe("2026-01-20");
// Gap = 01-20 − 01-14 = +6 (the old first-return would have given −1).
expect(out.value.recoveryGapDays).toBe(dayDiff("2026-01-14", "2026-01-20"));
expect(out.value.recoveryGapDays).toBe(6);
});
it("reports the LAST sustained symptom return when the curve relapses", () => {
// Parity with the vital track on the symptom curve: impact eases to 0 for 3
// logged days, flares back to 2, eases to 0 again for 3 logged days → the
// return is the SECOND easing, not the first.
const rhr: MeasurementType = "RESTING_HEART_RATE";
const rhrBaseline = jitteredBaseline(55, 1, 21, "2026-01-02");
const rhrFlat: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 55 },
{ day: "2026-01-11", mean: 55 },
{ day: "2026-01-12", mean: 55 },
{ day: "2026-01-13", mean: 55 },
{ day: "2026-01-14", mean: 55 },
];
const symptomBurden: SymptomBurdenPoint[] = [
{ day: "2026-01-10", impact: 3 },
{ day: "2026-01-12", impact: 2 },
{ day: "2026-01-13", impact: 0 }, // first easing
{ day: "2026-01-14", impact: 0 },
{ day: "2026-01-15", impact: 0 }, // 3 logged in-band days
{ day: "2026-01-16", impact: 2 }, // FLARE
{ day: "2026-01-18", impact: 0 }, // final easing start
{ day: "2026-01-19", impact: 0 },
{ day: "2026-01-20", impact: 0 }, // 3 logged in-band days, holds
];
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
],
symptomBurden,
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const sym = out.value.returns.find(
(r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY,
);
expect(sym?.returnedDay).toBe("2026-01-18");
expect(sym?.gapDays).toBe(dayDiff("2026-01-14", "2026-01-18"));
expect(out.value.recoveryGapDays).toBe(4);
});
it("withholds when the final in-band run is too short after a relapse", () => {
// Deviate, settle, relapse, then only 2 in-band days to the window end —
// the final run is shorter than RETURN_STABILITY_DAYS, so no return is
// fabricated off the too-short tail.
const type: MeasurementType = "RESTING_HEART_RATE";
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 75 }, // deviation
{ day: "2026-01-12", mean: 55 }, // settle
{ day: "2026-01-13", mean: 55 },
{ day: "2026-01-14", mean: 55 }, // 3-day run
{ day: "2026-01-16", mean: 75 }, // RELAPSE
{ day: "2026-01-17", mean: 74 },
{ day: "2026-01-19", mean: 55 }, // only 2 in-band days to end
{ day: "2026-01-20", mean: 55 },
];
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [{ type, baselineDays, episodeDays }],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.returns[0]?.returnedDay).toBeNull();
expect(out.value.recoveryGapDays).toBeNull();
});
it("picks the FINAL settle across multiple relapses", () => {
// settle → flare → settle → flare → settle: the last contiguous in-band
// span wins regardless of how many intervening cycles.
const type: MeasurementType = "RESTING_HEART_RATE";
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 75 }, // deviation
{ day: "2026-01-11", mean: 55 }, // settle 1
{ day: "2026-01-12", mean: 55 },
{ day: "2026-01-13", mean: 55 },
{ day: "2026-01-14", mean: 75 }, // flare 1
{ day: "2026-01-15", mean: 55 }, // settle 2
{ day: "2026-01-16", mean: 55 },
{ day: "2026-01-17", mean: 55 },
{ day: "2026-01-18", mean: 75 }, // flare 2
{ day: "2026-01-20", mean: 55 }, // FINAL settle start
{ day: "2026-01-21", mean: 55 },
{ day: "2026-01-22", mean: 55 }, // holds to end
];
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [{ type, baselineDays, episodeDays }],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.returns[0]?.returnedDay).toBe("2026-01-20");
expect(out.value.recoveryGapDays).toBe(6);
});
it("returns null when the episode ends out-of-band after a relapse", () => {
// Deviate, settle, relapse, never resolve (ends elevated) → no sustained
// final return → null gap. Honest: it did not finally settle in-window.
const type: MeasurementType = "RESTING_HEART_RATE";
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 75 }, // deviation
{ day: "2026-01-12", mean: 55 }, // settle
{ day: "2026-01-13", mean: 55 },
{ day: "2026-01-14", mean: 55 }, // 3-day run
{ day: "2026-01-16", mean: 75 }, // RELAPSE
{ day: "2026-01-18", mean: 74 },
{ day: "2026-01-20", mean: 73 }, // still elevated at the end
];
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [{ type, baselineDays, episodeDays }],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.returns[0]?.returnedDay).toBeNull();
expect(out.value.recoveryGapDays).toBeNull();
});
});
describe("computeIllnessCorrelation — sleep-as-context observation", () => {
// A banded RHR vital clears the engine coverage floor in every case; the
// sleep context is computed independently from the night arrays.
const rhr: MeasurementType = "RESTING_HEART_RATE";
const rhrBaseline = jitteredBaseline(55, 1, 21, "2026-01-02");
const rhrFlat = flatBaseline(55, 5, "2026-01-15");
/** N baseline nights at `minutes`, ending the day before onset. */
function nights(
minutes: number,
count: number,
endDay: string,
): SleepNightPoint[] {
const out: SleepNightPoint[] = [];
let cursor = Date.parse(`${endDay}T00:00:00Z`);
for (let i = 0; i < count; i++) {
out.unshift({
day: new Date(cursor).toISOString().slice(0, 10),
asleepMinutes: minutes,
});
cursor -= 24 * 60 * 60 * 1000;
}
return out;
}
it("surfaces a slept-more observation when the episode delta clears the floor", () => {
// Baseline ~420 min (7h), episode ~510 min (8.5h) → +90 min, well past the
// 30-min floor. Episode nights are inside the active span.
const out = computeIllnessCorrelation(
input({
series: [
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
],
baselineNights: nights(420, 6, "2026-01-02"),
episodeNights: [
{ day: "2026-01-10", asleepMinutes: 510 },
{ day: "2026-01-11", asleepMinutes: 505 },
{ day: "2026-01-12", asleepMinutes: 515 },
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.sleepContext).not.toBeNull();
expect(out.value.sleepContext?.baselineMeanMinutes).toBe(420);
expect(out.value.sleepContext?.deltaMinutes).toBeGreaterThanOrEqual(
SLEEP_CONTEXT_MIN_DELTA_MINUTES,
);
expect(out.value.sleepContext?.nightsCounted).toBe(3);
// It never touches the recovery-gap.
expect(out.value.recoveryGapDays).toBeNull();
});
it("withholds when the delta is below the magnitude floor", () => {
// Baseline 420, episode 430 → +10 min, sub-floor jitter → no observation.
const out = computeIllnessCorrelation(
input({
series: [
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
],
baselineNights: nights(420, 6, "2026-01-02"),
episodeNights: [
{ day: "2026-01-10", asleepMinutes: 430 },
{ day: "2026-01-11", asleepMinutes: 425 },
{ day: "2026-01-12", asleepMinutes: 435 },
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.sleepContext).toBeNull();
});
it("withholds when too few episode nights are scorable", () => {
const out = computeIllnessCorrelation(
input({
series: [
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
],
baselineNights: nights(420, 6, "2026-01-02"),
episodeNights: [
{ day: "2026-01-10", asleepMinutes: 520 },
{ day: "2026-01-11", asleepMinutes: 525 }, // only 2 episode nights
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.sleepContext).toBeNull();
});
it("withholds when the baseline night count is too thin", () => {
const out = computeIllnessCorrelation(
input({
series: [
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
],
baselineNights: nights(420, 3, "2026-01-02"), // only 3 baseline nights
episodeNights: [
{ day: "2026-01-10", asleepMinutes: 520 },
{ day: "2026-01-11", asleepMinutes: 525 },
{ day: "2026-01-12", asleepMinutes: 515 },
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.sleepContext).toBeNull();
});
it("surfaces a slept-less observation with a negative delta", () => {
const out = computeIllnessCorrelation(
input({
series: [
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
],
baselineNights: nights(480, 6, "2026-01-02"),
episodeNights: [
{ day: "2026-01-10", asleepMinutes: 360 },
{ day: "2026-01-11", asleepMinutes: 370 },
{ day: "2026-01-12", asleepMinutes: 350 },
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.sleepContext?.deltaMinutes).toBeLessThanOrEqual(
-SLEEP_CONTEXT_MIN_DELTA_MINUTES,
);
});
it("excludes pre-onset nights from the episode mean", () => {
// A pre-onset night (before onsetDay 2026-01-10) must not count toward the
// episode mean — only the active-span nights do.
const out = computeIllnessCorrelation(
input({
series: [
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
],
baselineNights: nights(420, 6, "2026-01-02"),
episodeNights: [
{ day: "2026-01-08", asleepMinutes: 999 }, // pre-onset — must be dropped
{ day: "2026-01-10", asleepMinutes: 510 },
{ day: "2026-01-11", asleepMinutes: 510 },
{ day: "2026-01-12", asleepMinutes: 510 },
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.sleepContext?.episodeMeanMinutes).toBe(510);
expect(out.value.sleepContext?.nightsCounted).toBe(3);
});
});
@@ -15,6 +15,30 @@ import {
MIN_TYPICAL_GAP_MAGNITUDE_DAYS,
type RetrospectiveEpisode,
} from "../retrospective";
import { computeIllnessCorrelation, type VitalDayPoint } from "../correlation";
/** A baseline with a non-zero, stable MAD (mirrors the correlation suite). */
function jitteredBaseline(
center: number,
jitter: number,
days: number,
endDay: string,
): VitalDayPoint[] {
const out: VitalDayPoint[] = [];
let cursor = Date.parse(`${endDay}T00:00:00Z`);
const offsets = [-jitter, -jitter / 2, 0, jitter / 2, jitter];
for (let i = 0; i < days; i++) {
out.unshift({
day: new Date(cursor).toISOString().slice(0, 10),
mean: center,
});
cursor -= 24 * 60 * 60 * 1000;
}
return out.map((p, i) => ({
...p,
mean: center + offsets[i % offsets.length],
}));
}
function ep(over: Partial<RetrospectiveEpisode>): RetrospectiveEpisode {
return {
@@ -207,4 +231,68 @@ describe("summarizeIllnessRetrospective", () => {
]);
expect(out.typicalRecoveryGapDays).toBe(-3);
});
it("threads the larger last-return gap through the cross-episode median", () => {
// Integration guard for the relapse-aware (last-sustained) return change:
// one episode relapses mid-course, so its ENGINE-computed gap grows from
// the old first-return value (−1) to the last-return value (+6). The
// retrospective consumes that engine number verbatim — confirm the larger
// value pulls the cross-episode median up rather than the stale first
// return. No retrospective code changes; this guards the integration.
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const relapseEpisode: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 75 }, // first deviation
{ day: "2026-01-13", mean: 55 }, // first settle (old return)
{ day: "2026-01-14", mean: 55 },
{ day: "2026-01-15", mean: 55 }, // 3-day run
{ day: "2026-01-16", mean: 75 }, // RELAPSE
{ day: "2026-01-18", mean: 73 },
{ day: "2026-01-20", mean: 55 }, // final settle start
{ day: "2026-01-21", mean: 55 },
{ day: "2026-01-22", mean: 55 }, // holds to end
];
const engine = computeIllnessCorrelation({
episodeId: "relapse",
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [
{
type: "RESTING_HEART_RATE",
baselineDays,
episodeDays: relapseEpisode,
},
],
source: "DAY",
now: new Date("2026-02-01T00:00:00Z"),
});
expect(engine.status).toBe("ok");
if (engine.status !== "ok") return;
// Last-return = 01-20, gap = +6 (not the old first-return −1).
expect(engine.value.recoveryGapDays).toBe(6);
const out = summarizeIllnessRetrospective([
ep({
id: "relapse",
recoveryGapDays: engine.value.recoveryGapDays,
gapMeasurementDays: engine.coverage.historyDays,
gapReturnTypes: ["RESTING_HEART_RATE"],
}),
ep({
id: "b",
recoveryGapDays: 4,
gapReturnTypes: ["RESTING_HEART_RATE"],
}),
ep({
id: "c",
recoveryGapDays: 5,
gapReturnTypes: ["RESTING_HEART_RATE"],
}),
]);
// Median of [6, 4, 5] = 5 — the relapse episode's grown gap is in the mix.
expect(out.typicalRecoveryGapDays).toBe(5);
expect(out.gapDriverType).toBe("RESTING_HEART_RATE");
});
});
+93 -3
View File
@@ -31,6 +31,8 @@ import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
import { reconstructNights } from "@/lib/insights/derived/sleep-score";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import type { Derived } from "@/lib/insights/derived/types";
import {
BASELINE_WINDOW_DAYS,
@@ -39,6 +41,7 @@ import {
computeIllnessCorrelation,
type DayLogFeverPoint,
type IllnessCorrelationValue,
type SleepNightPoint,
type SymptomBurdenPoint,
type VitalSeries,
} from "./correlation";
@@ -96,15 +99,20 @@ export async function computeEpisodeCorrelation(
: null;
// One coverage probe shared across every vital read (pool-contention
// mitigation — the same pattern the derived route uses).
const coverage = await probeRollupCoverage(userId);
// mitigation — the same pattern the derived route uses). The source priority
// feeds the canonical sleep-night writer-dedup (same ladder every sleep
// surface uses), resolved once.
const [coverage, sleepPriorityJson] = await Promise.all([
probeRollupCoverage(userId),
loadUserSourcePriority(userId),
]);
// Attempt every scan type under a bounded concurrency cap. The two
// windows per type (baseline + episode) are independent reads, so they
// run paired rather than serially (halving the per-type round-trips).
// The day-log fever read shares the same fan-out budget.
const limit = pLimit(VITAL_SCAN_CONCURRENCY);
const [perType, dayLogFever, symptomBurden] = await Promise.all([
const [perType, dayLogFever, symptomBurden, sleepNights] = await Promise.all([
Promise.all(
ILLNESS_SCAN_TYPES.map((type) =>
limit(async () => {
@@ -134,6 +142,14 @@ export async function computeEpisodeCorrelation(
),
limit(() => readDayLogFever(episode.id, preOnsetStart, end, tz)),
limit(() => readDayLogSymptomBurden(episode.id, preOnsetStart, end, tz)),
limit(() =>
readEpisodeSleepNights(
userId,
{ baselineStart, baselineEnd, preOnsetStart, end },
tz,
sleepPriorityJson,
),
),
]);
const series: VitalSeries[] = [];
@@ -170,6 +186,8 @@ export async function computeEpisodeCorrelation(
series,
dayLogFever,
symptomBurden,
baselineNights: sleepNights.baselineNights,
episodeNights: sleepNights.episodeNights,
source,
now,
});
@@ -331,3 +349,75 @@ async function readDayLogSymptomBurden(
}
return points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
}
/**
* Reconstruct per-night asleep totals over the well baseline window AND the
* episode window for the sleep-as-context observation. Reuses the canonical
* `reconstructNights` engine wholesale (session-clustering, local-wake-day
* keying, multi-source writer-dedup — a WHOOP + Apple Health night is counted
* ONCE) so the figures match every other sleep surface. The night's `night`
* key is ALREADY the local wake-day string, so it joins the engine's
* onset/feltBetter day-space directly — no extra tz conversion.
*
* Two windows mirror the vital reads' contamination-guard seam:
* baselineNights : [baselineStart, baselineEnd) → the user's well baseline
* episodeNights : [preOnsetStart, end] → the episode span
*
* Each is a single bounded `findMany` over the raw per-stage SLEEP_DURATION
* rows; the engine withholds the observation entirely on thin data.
*/
async function readEpisodeSleepNights(
userId: string,
windows: {
baselineStart: Date;
baselineEnd: Date;
preOnsetStart: Date;
end: Date;
},
tz: string,
priorityJson: unknown,
): Promise<{
baselineNights: SleepNightPoint[];
episodeNights: SleepNightPoint[];
}> {
const select = {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
} as const;
const [baselineRows, episodeRows] = await Promise.all([
prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION" satisfies MeasurementType,
deletedAt: null,
measuredAt: { gte: windows.baselineStart, lt: windows.baselineEnd },
},
orderBy: { measuredAt: "asc" },
select,
}),
prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION" satisfies MeasurementType,
deletedAt: null,
measuredAt: { gte: windows.preOnsetStart, lte: windows.end },
},
orderBy: { measuredAt: "asc" },
select,
}),
]);
const toPoints = (rows: typeof baselineRows): SleepNightPoint[] =>
reconstructNights(rows, tz, priorityJson)
.filter((n) => n.asleepMinutes > 0)
.map((n) => ({ day: n.night, asleepMinutes: n.asleepMinutes }));
return {
baselineNights: toPoints(baselineRows),
episodeNights: toPoints(episodeRows),
};
}
+206 -34
View File
@@ -83,6 +83,20 @@ export const FUNCTIONAL_IMPACT_RETURN_KEY = "FUNCTIONAL_IMPACT" as const;
export const FUNCTIONAL_IMPACT_ADVERSE_FLOOR = 1;
/** Minimum episode days with ANY vital coverage before the gap is asserted. */
export const MIN_EPISODE_COVERAGE_DAYS = 4;
/**
* Minimum scorable baseline nights before the sleep-context observation is
* trusted (mirrors the sleep-debt engine's `minNights` floor). Below this the
* "usual" asleep figure is too fragile to compare against.
*/
export const SLEEP_CONTEXT_MIN_BASELINE_NIGHTS = 4;
/** Minimum scorable episode nights before the sleep-context line surfaces. */
export const SLEEP_CONTEXT_MIN_EPISODE_NIGHTS = 3;
/**
* Minimum |episode − baseline| asleep-minutes delta before the sleep-context
* line is shown. Day-to-day jitter below this floor is not narrated as a
* finding (honest withholding, never a fabricated observation).
*/
export const SLEEP_CONTEXT_MIN_DELTA_MINUTES = 30;
/**
* Per-metric adverse direction — which way a reading moves when the body is
@@ -184,6 +198,21 @@ export interface SymptomBurdenPoint {
impact: number;
}
/**
* One reconstructed night's asleep total, keyed by the local WAKE-DAY (the same
* `YYYY-MM-DD` day-space the engine's onset/feltBetter markers live in — a
* canonical `NightSummary.night`). The caller reconstructs nights from the raw
* per-stage SLEEP_DURATION rows (writer-deduped, session-clustered) and passes
* the asleep totals in; the engine never touches raw rows. Used ONLY for the
* sleep-context observation, NEVER as a recovery-gap return track.
*/
export interface SleepNightPoint {
/** Local wake-day key, `YYYY-MM-DD`. */
day: string;
/** Asleep minutes for the night (canonical, multi-source-deduped). */
asleepMinutes: number;
}
/** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */
export interface EpisodeWindow {
/** Onset day. */
@@ -216,6 +245,20 @@ export interface IllnessCorrelationInput {
* passive-vital-thin episode. Optional.
*/
symptomBurden?: SymptomBurdenPoint[];
/**
* Reconstructed per-night asleep totals over the user's WELL baseline window
* (the span ending before the pre-onset lookback). Their median is the user's
* usual asleep figure the episode is compared against. Optional — absent /
* thin → no sleep-context observation. NEVER feeds the recovery-gap.
*/
baselineNights?: SleepNightPoint[];
/**
* Reconstructed per-night asleep totals over the episode window (`onset −
* lookback` through end). Compared against the baseline median as a neutral
* CONTEXT observation (slept more / less than usual), never a return track and
* never in the gap median. Optional — absent / thin → no observation.
*/
episodeNights?: SleepNightPoint[];
/** Provenance source the dominant read resolved against. */
source: "DAY" | "live" | "none";
/** Compute time (injected for deterministic tests). */
@@ -276,6 +319,25 @@ export function isAdverseDeviation(
);
}
/**
* A neutral sleep-context observation for the episode — the user's median
* asleep minutes over the well baseline vs the mean over the episode window. A
* pure OBSERVATION ("you slept more / less than usual"), never a recovery
* return, never in the gap median, never a causal/recovery claim. Null when the
* sleep data is too thin (below the night-count floors) or the delta is below
* the magnitude floor — honest withholding, never a fabricated finding.
*/
export interface SleepContext {
/** Median asleep minutes over the well baseline window. */
baselineMeanMinutes: number;
/** Mean asleep minutes over the episode window. */
episodeMeanMinutes: number;
/** `episode − baseline` asleep minutes (positive = slept more than usual). */
deltaMinutes: number;
/** Scorable episode nights the observation is built from (transparency). */
nightsCounted: number;
}
export interface IllnessCorrelationValue {
episodeId: string;
/** "How did it announce itself" — notable pre-onset deviations. */
@@ -317,6 +379,13 @@ export interface IllnessCorrelationValue {
* recurs, seek care"), NEVER reassure. Empty when nothing tripped.
*/
redFlags: IllnessRedFlag[];
/**
* Sleep-as-context observation — slept more / less than usual during the
* episode — or null when the sleep data is too thin or the delta sub-floor.
* A neutral observation surfaced ALONGSIDE the gap, NEVER a return track and
* NEVER part of the headline recovery-gap number. Optional/additive.
*/
sleepContext: SleepContext | null;
}
export interface IllnessRedFlag {
@@ -508,7 +577,7 @@ export function computeIllnessCorrelation(
// deviation (which yielded a spurious negative gap). Excluded for
// CHRONIC_ONGOING (no recovered date by design).
if (firstDeviationIndex >= 0 && window.lifecycle !== "CHRONIC_ONGOING") {
const returnedDay = firstStableReturn(
const returnedDay = lastStableReturn(
active,
center,
spread,
@@ -593,6 +662,15 @@ export function computeIllnessCorrelation(
fullHistoryDays: Math.max(MIN_EPISODE_COVERAGE_DAYS, 14),
});
// Sleep-as-context — a neutral observation surfaced alongside the gap, NEVER a
// return track and NEVER in the recovery-gap median. Withholds (null) on thin
// data or a sub-floor delta.
const sleepContext = computeSleepContext(
input.baselineNights ?? [],
input.episodeNights ?? [],
window,
);
return buildOk<IllnessCorrelationValue>({
value: {
episodeId: input.episodeId,
@@ -608,6 +686,7 @@ export function computeIllnessCorrelation(
feltBetterDay: window.feltBetterDay,
gapDriverType: gapDriverType === null ? null : String(gapDriverType),
redFlags,
sleepContext,
},
coverage,
confidence,
@@ -616,13 +695,26 @@ export function computeIllnessCorrelation(
}
/**
* First day in `active` (chronological), starting at `fromIndex`, where the
* vital is in-band AND stays in-band for the next `RETURN_STABILITY_DAYS`
* observed days. Null when it never settles. "In-band" = |deviation| <
* NOTABLE_SD. `fromIndex` pins the search to AFTER the first deviation so an
* early in-band run (the run-up to the illness) is never mistaken for a return.
* Start of the FINAL sustained in-band run in `active` (chronological) at/after
* `fromIndex` — the day the vital *finally* settled and held. "In-band" =
* |deviation| < NOTABLE_SD. Null when it never settles, when the final settle
* is shorter than `RETURN_STABILITY_DAYS` observed days, or when a GENUINE
* sustained re-deviation (≥ `RETURN_STABILITY_DAYS` out-of-band days) follows
* the last settle with no re-settle (the vital is still elevated at the end).
* `fromIndex` pins the search to AFTER the first deviation so an early in-band
* run (the run-up to the illness) is never mistaken for a return.
*
* Relapse-aware AND noise-tolerant: an episode that settles, re-deviates (a
* mid-episode flare), and settles again reports the LAST settle. A LONE
* trailing out-of-band reading (or any tail shorter than the stability window)
* after a real settle is transient measurement noise, NOT a relapse — it does
* not null the return. Only a sustained out-of-band tail (≥ the stability
* window) is read as a genuine unrecovered re-deviation and clears the return.
* This keeps the change monotone for clean single-settle series (first == last,
* unaffected) while not regressing a settle that happens to end on one noisy
* out-of-band sample.
*/
function firstStableReturn(
function lastStableReturn(
active: VitalDayPoint[],
center: number,
spread: number,
@@ -632,13 +724,27 @@ function firstStableReturn(
day: p.day,
in: Math.abs(deviationSd(p.mean, center, spread)) < NOTABLE_SD,
}));
for (let i = Math.max(0, fromIndex); i < inBand.length; i++) {
if (!inBand[i].in) continue;
let run = 0;
for (let j = i; j < inBand.length && inBand[j].in; j++) run++;
if (run >= RETURN_STABILITY_DAYS) return inBand[i].day;
if (inBand.length === 0) return null;
// A sustained out-of-band TAIL (≥ stability window) means the vital genuinely
// re-deviated after the last settle and never came back — still elevated, no
// final return. A shorter trailing out-of-band tail is transient noise and is
// ignored so a real settle that ends on a noisy sample is not lost.
let tailOut = 0;
while (tailOut < inBand.length && !inBand[inBand.length - 1 - tailOut].in) {
tailOut++;
}
return null;
if (tailOut >= RETURN_STABILITY_DAYS) return null;
// Walk back from the last IN-band reading to the start of its maximal run.
const end = inBand.length - 1 - tailOut;
let start = end;
while (start - 1 >= 0 && inBand[start - 1].in) start--;
const run = end - start + 1;
// The final settle must be long enough AND begin at/after the first deviation.
if (run < RETURN_STABILITY_DAYS || start < Math.max(0, fromIndex))
return null;
return inBand[start].day;
}
/**
@@ -652,11 +758,16 @@ function firstStableReturn(
* - The anchor is the first logged adverse day (same role as the vital
* `firstDeviationIndex`): a return search starts AFTER it, so a run-up is
* never read as a return.
* - A "return" is the first logged day at/after the anchor that is in-band AND
* stays in-band for the next `RETURN_STABILITY_DAYS` LOGGED days. LOGGED-days
* stability (not calendar days) is the honest-withholding handler for sparse
* journals: a single trailing impact-0 log never satisfies the run, so the
* track WITHHOLDS rather than fabricating recovery from absence of rows.
* - A "return" is the START of the FINAL sustained in-band logged run at/after
* the anchor — the day symptoms *finally* eased and stayed eased, requiring
* `RETURN_STABILITY_DAYS` LOGGED days. Relapse-aware AND noise-tolerant
* (parity with the vital `lastStableReturn`): if symptoms ease, flare again,
* then ease again, the SECOND easing is the return; a trailing adverse tail
* SHORTER than the stability window is a single noisy late log and is
* tolerated, while a sustained adverse tail (≥ the window) is a genuine
* re-flare with no re-settle and WITHHOLDS the return. LOGGED-days stability
* (not calendar days) is the honest-withholding handler for sparse journals:
* a single trailing impact-0 log never satisfies the run on its own.
* - The gap is `dayDiff(feltBetterDay, returnedDay)`, same signed semantic.
* - Always `adverse:true` (a logged symptom curve is illness-relevant), so it
* feeds `adverseCoverageDays` and can drive the headline gap.
@@ -688,21 +799,27 @@ function computeSymptomReturn(
const firstAdverseIndex = active.findIndex(
(p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR,
);
// Start of the FINAL sustained in-band logged run (relapse-aware "last
// return"): walk back from the last logged day. Mirrors the vital
// `lastStableReturn` — a trailing adverse tail SHORTER than the stability
// window is tolerated (a single noisy late log does not null a real ease),
// while a sustained adverse tail (≥ the window) is a genuine re-flare with no
// re-settle → null (honest withholding).
const inBand = active.map((p) => p.impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR);
let returnedDay: string | null = null;
for (let i = firstAdverseIndex; i < active.length; i++) {
if (active[i].impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR) continue;
// Count consecutive in-band LOGGED days from here.
let run = 0;
for (
let j = i;
j < active.length && active[j].impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR;
j++
) {
run++;
if (inBand.length > 0) {
let tailOut = 0;
while (tailOut < inBand.length && !inBand[inBand.length - 1 - tailOut]) {
tailOut++;
}
if (run >= RETURN_STABILITY_DAYS) {
returnedDay = active[i].day;
break;
if (tailOut < RETURN_STABILITY_DAYS) {
const end = inBand.length - 1 - tailOut;
let start = end;
while (start - 1 >= 0 && inBand[start - 1]) start--;
const run = end - start + 1;
if (run >= RETURN_STABILITY_DAYS && start >= firstAdverseIndex) {
returnedDay = active[start].day;
}
}
}
@@ -722,6 +839,56 @@ function computeSymptomReturn(
};
}
/**
* Compute the sleep-as-context observation: the user's MEDIAN asleep minutes
* over the well baseline vs the MEAN over the episode's active span. A neutral
* OBSERVATION only — never a recovery return, never folded into the gap median,
* never a causal claim. Mirrors the engine's withholding ethos:
*
* - Baseline needs ≥ `SLEEP_CONTEXT_MIN_BASELINE_NIGHTS` scorable nights AND
* the episode needs ≥ `SLEEP_CONTEXT_MIN_EPISODE_NIGHTS` (thin sleep data
* produces no observation — say nothing rather than guess).
* - The |delta| must reach `SLEEP_CONTEXT_MIN_DELTA_MINUTES` so day-to-day
* jitter is never narrated as a finding.
*
* Episode nights are clipped to the active span (`>= onsetDay`) so the
* comparison reflects the illness, not the pre-onset lookback. Returns null
* (says nothing) below either floor.
*/
function computeSleepContext(
baselineNights: SleepNightPoint[],
episodeNights: SleepNightPoint[],
window: EpisodeWindow,
): SleepContext | null {
const baseMinutes = baselineNights.map((n) => n.asleepMinutes);
const episodeMinutes = episodeNights
.filter((n) => n.day >= window.onsetDay)
.map((n) => n.asleepMinutes);
if (
baseMinutes.length < SLEEP_CONTEXT_MIN_BASELINE_NIGHTS ||
episodeMinutes.length < SLEEP_CONTEXT_MIN_EPISODE_NIGHTS
) {
return null;
}
const baselineMeanMinutes = Math.round(median(baseMinutes));
const episodeMeanMinutes = Math.round(
episodeMinutes.reduce((s, v) => s + v, 0) / episodeMinutes.length,
);
const deltaMinutes = episodeMeanMinutes - baselineMeanMinutes;
// Sub-floor delta is jitter, not a finding → say nothing.
if (Math.abs(deltaMinutes) < SLEEP_CONTEXT_MIN_DELTA_MINUTES) return null;
return {
baselineMeanMinutes,
episodeMeanMinutes,
deltaMinutes,
nightsCounted: episodeMinutes.length,
};
}
/**
* Detect the retrospective red flags — DECOUPLED from the banded loop and the
* own-baseline band entirely. SpO2 and temperature escalate against ABSOLUTE
@@ -768,9 +935,14 @@ function detectRedFlags(input: IllnessCorrelationInput): IllnessRedFlag[] {
);
}
if (feverByDay.size > 0) {
const fevPoints: VitalDayPoint[] = [...feverByDay.entries()].map(
([day, mean]) => ({ day, mean }),
);
// `runFlag` counts CONSECUTIVE-day runs, so it needs chronological input.
// The Map unions two individually-sorted sources (passive temperature, then
// day-log feverC) in insertion order, NOT global day order — sort by day
// before the run scan or an interleaved dual-source episode mis-counts the
// run (a false sustained_fever escalation, or a real run hidden).
const fevPoints: VitalDayPoint[] = [...feverByDay.entries()]
.map(([day, mean]) => ({ day, mean }))
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
const flag = runFlag(
fevPoints,
(v) => v >= FEVER_RED_FLAG,
@@ -75,6 +75,11 @@ vi.mock("@/lib/analytics/source-rank-sql", () => {
import { prisma } from "@/lib/db";
import { buildComprehensiveAggregate } from "../comprehensive-aggregator";
import {
composeWindowedRegression,
type AccumulatorBucketRow,
} from "@/lib/rollups/measurement-read";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
const UNSAFE = prisma.$queryRawUnsafe as unknown as ReturnType<typeof vi.fn>;
@@ -139,12 +144,8 @@ describe("buildComprehensiveAggregate", () => {
avg7: 81.9,
avg30: 82.1,
avg30_last_month: 83.0,
slope7: -0.014,
r2_7: 0.65,
slope30: -0.005,
r2_30: 0.42,
slope90: 0.001,
r2_90: 0.12,
// v1.20.0 F6 — slope / r² no longer ride the narrow aggregate;
// they compose from the accumulator buckets below.
},
]).mockResolvedValueOnce([
{ type: "WEIGHT", value: 81.4, measured_at: now },
@@ -156,26 +157,61 @@ describe("buildComprehensiveAggregate", () => {
// now carries a `source` so `partitionBucketsByType` →
// `collapseRollupRowsBySource` can resolve dual-source days; with
// a single source per day every bucket passes through unchanged.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
type: "WEIGHT",
source: "APPLE_HEALTH",
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 20,
mean: 81.0,
minValue: 79.2,
maxValue: 82.5,
},
{
type: "WEIGHT",
source: "APPLE_HEALTH",
bucketStart: new Date("2026-05-11T00:00:00.000Z"),
count: 22,
mean: 83.0,
minValue: 80.0,
maxValue: 84.1,
},
]);
//
// v1.20.0 F6 — buckets are placed on the two most-recent UTC days so
// they fall inside the 7-day slope window, and carry the regression
// accumulators the windowed slope / r² compose from. Each bucket
// models its `count` readings as all landing at the bucket's day
// midnight (x = bucketStart epoch-day) with value = mean, which is a
// valid, self-consistent accumulator set.
const dayB = startOfUtcDay(now);
const dayA = startOfUtcDay(new Date(now.getTime() - 86_400_000));
const xA = dayA.getTime() / 86_400_000;
const xB = dayB.getTime() / 86_400_000;
const bucketA = {
type: "WEIGHT",
source: "APPLE_HEALTH",
bucketStart: dayA,
count: 20,
mean: 81.0,
minValue: 79.2,
maxValue: 82.5,
sumX: 20 * xA,
sumXy: 20 * xA * 81.0,
sumXx: 20 * xA * xA,
sumYy: 20 * 81.0 * 81.0,
};
const bucketB = {
type: "WEIGHT",
source: "APPLE_HEALTH",
bucketStart: dayB,
count: 22,
mean: 83.0,
minValue: 80.0,
maxValue: 84.1,
sumX: 22 * xB,
sumXy: 22 * xB * 83.0,
sumXx: 22 * xB * xB,
sumYy: 22 * 83.0 * 83.0,
};
ROLLUP_FIND_MANY.mockResolvedValueOnce([bucketA, bucketB]);
// Expected slope7 — composed from the same accumulators the reader
// sees (the buildSlope contract rounds slope to 3 dp, confidence to
// 2 dp). Computed here from composeWindowedRegression so the test
// tracks the closed form rather than a hand-typed magic number.
const accBuckets: AccumulatorBucketRow[] = [bucketA, bucketB].map(
(b) => ({
bucketStart: b.bucketStart,
count: b.count,
mean: b.mean,
sumX: b.sumX,
sumXy: b.sumXy,
sumXx: b.sumXx,
sumYy: b.sumYy,
}),
);
const since7 = startOfUtcDay(new Date(Date.now() - 7 * 86_400_000));
const expectedReg7 = composeWindowedRegression(accBuckets, since7);
// v1.18.10 I-1 — bpRawRows now reads source-collapsed rows via
// `$queryRawUnsafe` (3rd UNSAFE call: narrow, latests, bpRaw). Empty
@@ -199,14 +235,21 @@ describe("buildComprehensiveAggregate", () => {
expect(weight.avg30).toBe(82.1);
expect(weight.avg30LastMonth).toBe(83.0);
expect(weight.anomalyCount).toBe(3);
// v1.20.0 F6 — slope7 composes from the accumulator buckets. The two
// recent buckets (means 81 → 83 one day apart) yield a positive,
// perfect-fit slope; assert against the closed form the reader uses.
expect(expectedReg7.slope).not.toBeNull();
expect(weight.slope7).toEqual({
slope: -0.014,
direction: "down",
confidence: 0.65,
slope: Math.round((expectedReg7.slope as number) * 1000) / 1000,
direction: "up",
confidence:
expectedReg7.r2 === null
? 0
: Math.round(expectedReg7.r2 * 100) / 100,
});
expect(result.dailyByType.WEIGHT).toEqual([
{ day: "2026-05-10", value: 81 },
{ day: "2026-05-11", value: 83 },
{ day: dayA.toISOString().slice(0, 10), value: 81 },
{ day: dayB.toISOString().slice(0, 10), value: 83 },
]);
// Contract pin — the heavy aggregate path is NOT exercised on the
@@ -104,9 +104,11 @@ describe("comprehensive JSON-retry", () => {
expect(outcome).toEqual({ status: "generated", providerType: "openai" });
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(2);
// The retry call appends the correction to the user prompt.
// The retry call appends the correction to the user message.
const retryParams = runRawCompletionWithFallback.mock.calls[1][0].params;
expect(retryParams.userPrompt).toContain("did not satisfy the required");
expect(retryParams.messages[0].content).toContain(
"did not satisfy the required",
);
});
it("fails with invalid-json when both attempts miss", async () => {
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more