mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f2059bee6 | ||
|
|
5e701cebc0 | ||
|
|
cd6fff9730 | ||
|
|
bf29ab1e0e | ||
|
|
ca1cf53d2c | ||
|
|
9c3b119695 | ||
|
|
9bffa80950 | ||
|
|
a65dde00ce | ||
|
|
fc39d9fa13 | ||
|
|
c304268a9e | ||
|
|
4c571b0e04 | ||
|
|
e023571a08 | ||
|
|
1c93a670f0 | ||
|
|
561a0694cf | ||
|
|
fb54b46034 | ||
|
|
e1e1b0e985 | ||
|
|
672e3f0e07 | ||
|
|
3e4cc53ede | ||
|
|
e4c0af0970 | ||
|
|
23cc48475d | ||
|
|
a20e140a2f | ||
|
|
9be4c3530a | ||
|
|
2c112c3323 | ||
|
|
009d923c87 | ||
|
|
6d338eab60 | ||
|
|
1d816d8eae | ||
|
|
f6ab1afe31 | ||
|
|
2c9eacac29 | ||
|
|
7dfa1b1953 | ||
|
|
9030d86f22 | ||
|
|
191221cba6 | ||
|
|
27b0515c24 | ||
|
|
ed96a8a5d1 | ||
|
|
755f7e101b | ||
|
|
89de292a31 | ||
|
|
b36671d8c6 | ||
|
|
b9368b9a69 | ||
|
|
57adfe3117 | ||
|
|
15a129baab | ||
|
|
5cd4a5e6bd | ||
|
|
d3b341f8a7 | ||
|
|
600498da12 | ||
|
|
c3c90c5117 | ||
|
|
0f5e563403 | ||
|
|
b6f0ad61cb | ||
|
|
7b0dcbf560 | ||
|
|
b5f0857a14 | ||
|
|
b31def38c0 | ||
|
|
68c67e4700 | ||
|
|
67d982c1be | ||
|
|
21f6dcfe13 | ||
|
|
c8ebdba43e | ||
|
|
745991a922 | ||
|
|
5c2d3f12cb | ||
|
|
7a9e326e85 | ||
|
|
ec77862286 | ||
|
|
9021b3e0ee | ||
|
|
4d892f6867 | ||
|
|
488ca72d12 | ||
|
|
e748a91a36 | ||
|
|
4ff0e4ca0c | ||
|
|
062354ab57 | ||
|
|
4706120879 | ||
|
|
b07fbcb6fa | ||
|
|
4e777a7e6a | ||
|
|
81d318d7dc | ||
|
|
be37497118 | ||
|
|
7859ca55b3 | ||
|
|
8d6d63cd1a | ||
|
|
f5d259db58 | ||
|
|
ff06689a1b | ||
|
|
b2ff41070c | ||
|
|
efd0470bc1 | ||
|
|
e432b53e0d | ||
|
|
97f34ccde1 | ||
|
|
e377eba545 |
No files matched your search
@@ -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)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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 .
|
||||
|
||||
@@ -2,6 +2,69 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.20.2] — 2026-06-26 — Hardening across insights, safety flags, and integrations
|
||||
|
||||
A patch release. A broad pass over the server tier and the AI surfaces: a few correctness and safety fixes, a numerical hardening of the trend tier, and several smaller robustness and cost improvements. No schema changes.
|
||||
|
||||
### Added
|
||||
|
||||
- The lab-report review screen marks low-confidence rows so an uncertain reading is easy to spot before it is saved.
|
||||
|
||||
### Changed
|
||||
|
||||
- The trend regression (slope, r² and variability) is composed from mean-centered sums, which removes a floating-point cancellation on long, near-flat windows and keeps the rollup result in line with the live computation. Each accumulator now uses the exact stored sum rather than reconstructing it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The symptom journal's "sustained fever" flag — which can raise an urgent prompt to seek care — now counts only consecutive calendar days. A sparse series of isolated febrile entries no longer trips it, so the alert reflects a genuine multi-day run.
|
||||
- Insight generation no longer reads a full measurement history per metric to compute its 30-day comparisons; it reads only the window those averages need. Generating insights on a long-history account is markedly faster, on both the page request and the overnight pre-generation.
|
||||
- Photo and PDF scanning is no longer offered for text-only reasoning models that cannot read an image, so a scan attempt fails clearly up front instead of erroring at the provider.
|
||||
- A notification channel whose stored configuration cannot be read now disables itself immediately with a precise reason, instead of retrying a permanent error several times and ending on a misleading message. This surfaces during an encryption-key rotation gap.
|
||||
- The Coach cancels its in-flight model call when the browser tab disconnects mid-answer, rather than paying the full cost into a closed connection.
|
||||
- Local-OCR text structuring reserves a budget proportionate to its real cost instead of the larger vision-scan ceiling, and refunds cleanly on a failed read.
|
||||
- The admin diagnostic endpoints return a 422 for a malformed query string instead of a 500.
|
||||
|
||||
## [1.20.1] — 2026-06-23 — Dashboard render-loop fix
|
||||
|
||||
A patch release. One fix; no schema changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A user reported the dashboard crashing on returning to a backgrounded tab. The on-focus snapshot refetch re-rendered the page, and each chart's "data ready" notify — keyed on a handler that the page recreated every render — re-fired on every commit, driving the chart row into an unbounded update loop until the browser tripped its render-depth guard and the page fell back to its error card. The notify now fires once, when a chart's initial query settles, regardless of how often the page re-renders.
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
-3
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.19.2
|
||||
version: 1.20.2
|
||||
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
|
||||
@@ -19615,7 +19630,6 @@ components:
|
||||
- type: string
|
||||
enum:
|
||||
- no-provider
|
||||
- text-only-model
|
||||
- enable-local-ocr
|
||||
- type: "null"
|
||||
pdfSupported:
|
||||
@@ -20464,6 +20478,10 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/IllnessRedFlag"
|
||||
sleepContext:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/IllnessSleepContext"
|
||||
- type: "null"
|
||||
required:
|
||||
- episodeId
|
||||
- preOnset
|
||||
@@ -20474,10 +20492,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 +20578,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:
|
||||
|
||||
+17
-14
@@ -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": {
|
||||
@@ -6725,6 +6725,7 @@
|
||||
"linksExisting": "Verknüpft mit vorhandenem {name}",
|
||||
"duplicateWarning": "Ein passender Wert existiert bereits",
|
||||
"lowConfidence": "Bitte diesen Wert überprüfen",
|
||||
"lowConfidenceBadge": "Geringe Sicherheit",
|
||||
"valueUnreadable": "Wert unleserlich — bitte manuell eingeben",
|
||||
"saveSelected": "{count} ausgewählte speichern",
|
||||
"discardAll": "Alle verwerfen",
|
||||
@@ -6891,6 +6892,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.",
|
||||
|
||||
+17
-14
@@ -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": {
|
||||
@@ -6725,6 +6725,7 @@
|
||||
"linksExisting": "Links to existing {name}",
|
||||
"duplicateWarning": "A matching reading already exists",
|
||||
"lowConfidence": "Please double-check this value",
|
||||
"lowConfidenceBadge": "Low confidence",
|
||||
"valueUnreadable": "Value unreadable — enter it manually",
|
||||
"saveSelected": "Save {count} selected",
|
||||
"discardAll": "Discard all",
|
||||
@@ -6891,6 +6892,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.",
|
||||
|
||||
+17
-14
@@ -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": {
|
||||
@@ -6725,6 +6725,7 @@
|
||||
"linksExisting": "Se vincula a {name} existente",
|
||||
"duplicateWarning": "Ya existe un valor coincidente",
|
||||
"lowConfidence": "Verifica este valor",
|
||||
"lowConfidenceBadge": "Confianza baja",
|
||||
"valueUnreadable": "Valor ilegible — introdúcelo manualmente",
|
||||
"saveSelected": "Guardar {count} seleccionados",
|
||||
"discardAll": "Descartar todo",
|
||||
@@ -6891,6 +6892,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.",
|
||||
|
||||
+17
-14
@@ -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": {
|
||||
@@ -6725,6 +6725,7 @@
|
||||
"linksExisting": "Lié au {name} existant",
|
||||
"duplicateWarning": "Une valeur correspondante existe déjà",
|
||||
"lowConfidence": "Veuillez vérifier cette valeur",
|
||||
"lowConfidenceBadge": "Confiance faible",
|
||||
"valueUnreadable": "Valeur illisible — saisissez-la manuellement",
|
||||
"saveSelected": "Enregistrer {count} sélectionnées",
|
||||
"discardAll": "Tout abandonner",
|
||||
@@ -6891,6 +6892,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.",
|
||||
|
||||
+17
-14
@@ -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": {
|
||||
@@ -6725,6 +6725,7 @@
|
||||
"linksExisting": "Collegato a {name} esistente",
|
||||
"duplicateWarning": "Esiste già un valore corrispondente",
|
||||
"lowConfidence": "Controlla questo valore",
|
||||
"lowConfidenceBadge": "Bassa attendibilità",
|
||||
"valueUnreadable": "Valore illeggibile — inseriscilo manualmente",
|
||||
"saveSelected": "Salva {count} selezionati",
|
||||
"discardAll": "Scarta tutto",
|
||||
@@ -6891,6 +6892,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.",
|
||||
|
||||
+17
-14
@@ -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": {
|
||||
@@ -6725,6 +6725,7 @@
|
||||
"linksExisting": "Łączy z istniejącym {name}",
|
||||
"duplicateWarning": "Pasujący wynik już istnieje",
|
||||
"lowConfidence": "Sprawdź tę wartość",
|
||||
"lowConfidenceBadge": "Niska pewność",
|
||||
"valueUnreadable": "Wartość nieczytelna — wprowadź ją ręcznie",
|
||||
"saveSelected": "Zapisz {count} wybranych",
|
||||
"discardAll": "Odrzuć wszystko",
|
||||
@@ -6891,6 +6892,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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.19.2",
|
||||
"version": "1.20.2",
|
||||
"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",
|
||||
|
||||
Generated
+910
-726
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
@@ -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
@@ -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.2";
|
||||
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/*`
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -119,11 +119,20 @@ describe("GET /api/admin/app-logs", () => {
|
||||
expect(json.data.events).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("rejects an invalid level value", async () => {
|
||||
it("rejects an invalid level value with a 422 (not a thrown 500)", async () => {
|
||||
appendLogEvent(makeEvent());
|
||||
// Invalid `level` — Zod parse should throw and the apiHandler test
|
||||
// mock rethrows it (we mock only `requireAdmin`, not the wrapper
|
||||
// error-translator). We simply assert the call rejects.
|
||||
await expect(GET(req("level=critical"))).rejects.toThrow();
|
||||
// Invalid `level` — the query is parsed with `safeParse` and returns the
|
||||
// standard 422 multi-issue envelope rather than throwing (which the
|
||||
// apiHandler catch ladder would otherwise surface as a 500 + incident).
|
||||
const res = await GET(req("level=critical"));
|
||||
expect(res.status).toBe(422);
|
||||
const json = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: { issues: unknown[] };
|
||||
};
|
||||
expect(json.data).toBeNull();
|
||||
expect(json.error).toBe("Validation failed");
|
||||
expect(json.details.issues.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readLogBuffer, LOG_BUFFER_MAX } from "@/lib/logging/in-memory-buffer";
|
||||
import { redactSecrets } from "@/lib/logging/redact";
|
||||
@@ -35,7 +35,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "admin.app-logs.list" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = querySchema.parse({
|
||||
const result = querySchema.safeParse({
|
||||
traceId: searchParams.get("traceId") ?? undefined,
|
||||
level: searchParams.get("level") ?? undefined,
|
||||
action: searchParams.get("action") ?? undefined,
|
||||
@@ -43,6 +43,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
until: searchParams.get("until") ?? undefined,
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
});
|
||||
if (!result.success) return returnAllZodIssues(result.error);
|
||||
const parsed = result.data;
|
||||
|
||||
const events = readLogBuffer({
|
||||
traceId: parsed.traceId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { redactSecrets } from "@/lib/logging/redact";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -56,7 +56,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "admin.audit-log.list" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = querySchema.parse({
|
||||
const result = querySchema.safeParse({
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
offset: searchParams.get("offset") ?? undefined,
|
||||
filter: searchParams.get("filter") ?? undefined,
|
||||
@@ -68,6 +68,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
since: searchParams.get("since") ?? undefined,
|
||||
until: searchParams.get("until") ?? undefined,
|
||||
});
|
||||
if (!result.success) return returnAllZodIssues(result.error);
|
||||
const parsed = result.data;
|
||||
|
||||
// Resolve perPage: clamp to allowed set, fall back to 50.
|
||||
const perPageResolved =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -59,9 +59,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "admin.host-metrics.list" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = querySchema.parse({
|
||||
const result = querySchema.safeParse({
|
||||
since: searchParams.get("since") ?? undefined,
|
||||
});
|
||||
if (!result.success) return returnAllZodIssues(result.error);
|
||||
const parsed = result.data;
|
||||
|
||||
const sinceMs = presetToMs(parsed.since);
|
||||
const cutoff = new Date(Date.now() - sinceMs);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,69 @@ 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,
|
||||
// v1.20.1 — thread the abort signal so a mid-generation disconnect tears
|
||||
// down the per-round provider calls instead of paying the full cost.
|
||||
signal: request.signal,
|
||||
});
|
||||
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,
|
||||
// v1.20.1 — thread the request's abort signal so a mid-generation
|
||||
// client disconnect tears the upstream provider call down instead of
|
||||
// paying the full token cost into a closed connection.
|
||||
signal: request.signal,
|
||||
}),
|
||||
});
|
||||
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 +628,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 +756,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 +806,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 +821,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 +888,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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
@@ -156,6 +160,14 @@ async function buildComparisonSnapshotForUser(
|
||||
ACTIVITY_STEPS: "",
|
||||
};
|
||||
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
|
||||
// The snapshot only feeds summarize()'s avg30 / avg30LastMonth /
|
||||
// avg30LastYear. The widest of those reaches back into the [365, 395)-day
|
||||
// window (ageMs < 395 * DAY in meanOfWindow), so nothing older than 395
|
||||
// days can change a result. Bound the read at 400 days — a 5-day floor over
|
||||
// the strict-less-than boundary — instead of scanning the full history,
|
||||
// which on multi-year accounts is tens of thousands of rows per type on the
|
||||
// page-blocking generate path and the nightly pregenerate cron.
|
||||
const sinceMeasuredAt = new Date(Date.now() - 400 * 86_400_000);
|
||||
const rows = await Promise.all(
|
||||
types.map(async (type) => {
|
||||
// SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the
|
||||
@@ -167,7 +179,12 @@ async function buildComparisonSnapshotForUser(
|
||||
if (type === "SLEEP_DURATION") {
|
||||
const [sleepRows, sleepTz, sleepPriority] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId, type, deletedAt: null },
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: sinceMeasuredAt },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
measuredAt: true,
|
||||
@@ -191,7 +208,12 @@ async function buildComparisonSnapshotForUser(
|
||||
.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes }));
|
||||
} else {
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where: { userId, type, deletedAt: null },
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: sinceMeasuredAt },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
@@ -662,7 +684,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 +696,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 +831,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 },
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Cheap probe (no provider call) the Labs UI uses to decide whether to show
|
||||
* the "Scan a report" affordance. Returns `{ available, reason, pdfSupported }`
|
||||
* for the calling user's configured AI provider. The surface stays dark for
|
||||
* codex-only / text-only-model users.
|
||||
* users with no provider configured and no local-OCR opt-in.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* v1.20.1 — POST /api/labs/ocr/extract, TEXT mode.
|
||||
*
|
||||
* Focus: the text-mode structuring pass reserves the proportionate text budget
|
||||
* ceiling (`AI_BUDGETS.ocrExtractText`), not the far larger vision ceiling, and
|
||||
* on a clean extraction failure it refunds the reservation in full rather than
|
||||
* charging it.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAuth: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveTextProvider: vi.fn(),
|
||||
resolveVisionProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-06-26"),
|
||||
reserveBudget: vi.fn(),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-extract", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/labs/ocr-extract")>(
|
||||
"@/lib/labs/ocr-extract",
|
||||
);
|
||||
return { ...actual, runOcrExtraction: vi.fn() };
|
||||
});
|
||||
|
||||
import { POST } from "../route";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveTextProvider } from "@/lib/labs/ocr-capability";
|
||||
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
|
||||
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function textReq(text = "Glucose 95 mg/dL"): Request {
|
||||
return new Request("http://localhost/api/labs/ocr/extract", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mode: "text", text }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
labsLocalOcrEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(resolveTextProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "openai", instance: {} as never }],
|
||||
pick: {
|
||||
entry: { providerType: "openai", instance: {} as never },
|
||||
providerType: "openai",
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(reserveBudget).mockResolvedValue({
|
||||
allowed: true,
|
||||
reserved: AI_BUDGETS.ocrExtractText.maxTokens ?? 0,
|
||||
totalAfter: AI_BUDGETS.ocrExtractText.maxTokens ?? 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/labs/ocr/extract — text mode budget", () => {
|
||||
it("reserves the cheaper text ceiling, not the vision ceiling", async () => {
|
||||
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
|
||||
|
||||
const res = await POST(textReq());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The reservation must use the text ceiling — proven distinct from the
|
||||
// vision ceiling so a future merge can't silently re-point it.
|
||||
expect(AI_BUDGETS.ocrExtractText.maxTokens).toBeLessThan(
|
||||
AI_BUDGETS.ocrExtract.maxTokens ?? Infinity,
|
||||
);
|
||||
expect(reserveBudget).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
"2026-06-26",
|
||||
);
|
||||
});
|
||||
|
||||
it("refunds the reservation in full on a clean extract failure", async () => {
|
||||
vi.mocked(runOcrExtraction).mockRejectedValue(
|
||||
new OcrExtractError("unreadable"),
|
||||
);
|
||||
|
||||
const res = await POST(textReq());
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
// actual spend reconciles to 0 — the failed structuring pass is refunded,
|
||||
// not charged at the reserved estimate.
|
||||
expect(reconcileSpend).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
0,
|
||||
"2026-06-26",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -138,12 +138,14 @@ async function handleTextExtract(
|
||||
});
|
||||
}
|
||||
|
||||
// Budget (text is cheaper than vision; reuse the same ceiling as the upper
|
||||
// bound — a text-only structuring call always spends at most this).
|
||||
// Budget — text mode is a plain text→JSON structuring pass, far cheaper than
|
||||
// a vision call, so it reserves the proportionate text ceiling rather than
|
||||
// the vision budget. Over-charging the vision rate for a text call would
|
||||
// exhaust the day budget against spend that never happened.
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtract.maxTokens,
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
dateKey,
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
@@ -163,6 +165,8 @@ async function handleTextExtract(
|
||||
providerType: pick.providerType,
|
||||
ocrText: parsed.data.text,
|
||||
});
|
||||
// A clean structuring pass spent (at most) the reservation; the provider
|
||||
// already billed it, so reconcile against the reserved estimate.
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
@@ -171,12 +175,9 @@ async function handleTextExtract(
|
||||
);
|
||||
return apiSuccess(result);
|
||||
} catch (err) {
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
);
|
||||
// A failed structuring call produced no usable rows; mirror the vision
|
||||
// path and refund the reservation in full rather than charging it.
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof OcrExtractError) {
|
||||
return apiError("Couldn't read the report. Try a clearer photo.", 422, {
|
||||
errorCode: "labs.ocr.extractFailed",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
useMemo,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
computeWindowStats,
|
||||
type MetricWindowStats,
|
||||
} from "@/lib/charts/window-stats";
|
||||
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
|
||||
|
||||
const TIME_RANGES_KEYS = [
|
||||
{
|
||||
@@ -1015,9 +1017,34 @@ export function HealthChart({
|
||||
// range-tab change creates a new cache entry but the gate has long
|
||||
// latched by then), so this fires exactly when the first paintable
|
||||
// state — chart, empty-window card, or error fallback — is available.
|
||||
//
|
||||
// v1.20.1 — fire exactly once, gated on a ref rather than on the
|
||||
// `onDataReady` prop identity. The dashboard passes an inline
|
||||
// `() => markChartReady(id)` closure, so `onDataReady` is a NEW
|
||||
// reference on every parent render; keying the effect on it re-ran the
|
||||
// notify on every commit (e.g. a snapshot refetch on tab-resume), and
|
||||
// that per-commit passive effect kept the chart-row's Radix-Popper
|
||||
// anchors re-committing until React tripped its update-depth guard
|
||||
// (#185). The ready signal is monotonic — once a chart has settled it
|
||||
// stays settled — so latch it behind a ref and depend on `isLoading`
|
||||
// alone. `onDataReady` is read through a ref so a late-arriving handler
|
||||
// still fires without re-arming the effect.
|
||||
const onDataReadyRef = useRef(onDataReady);
|
||||
useEffect(() => {
|
||||
if (!isLoading) onDataReady?.();
|
||||
}, [isLoading, onDataReady]);
|
||||
onDataReadyRef.current = onDataReady;
|
||||
}, [onDataReady]);
|
||||
const dataReadyFiredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldFireDataReady({
|
||||
isLoading,
|
||||
alreadyFired: dataReadyFiredRef.current,
|
||||
})
|
||||
)
|
||||
return;
|
||||
dataReadyFiredRef.current = true;
|
||||
onDataReadyRef.current?.();
|
||||
}, [isLoading]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data?.length) return data;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
* the wrapper itself stays intact.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
@@ -63,6 +63,7 @@ import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
|
||||
import { useViewportWidth } from "@/hooks/use-viewport-width";
|
||||
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
|
||||
|
||||
interface DailyCompliancePoint {
|
||||
/** Berlin calendar day, "YYYY-MM-DD". */
|
||||
@@ -255,9 +256,29 @@ export function MedicationComplianceChart({
|
||||
|
||||
// v1.16.0 — report the settled query to the dashboard's shared
|
||||
// reveal gate (see `onDataReady` prop doc).
|
||||
//
|
||||
// v1.20.1 — fire once via a ref instead of keying on the unstable
|
||||
// `onDataReady` prop. See the matching note in `health-chart.tsx`: the
|
||||
// dashboard hands every chart a fresh `() => markChartReady(id)` closure
|
||||
// each render, so depending on it re-ran this notify on every commit and
|
||||
// the per-commit passive effect kept the Radix-Popper tile anchors
|
||||
// re-committing until React's update-depth guard tripped (#185).
|
||||
const onDataReadyRef = useRef(onDataReady);
|
||||
useEffect(() => {
|
||||
if (!isLoading) onDataReady?.();
|
||||
}, [isLoading, onDataReady]);
|
||||
onDataReadyRef.current = onDataReady;
|
||||
}, [onDataReady]);
|
||||
const dataReadyFiredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldFireDataReady({
|
||||
isLoading,
|
||||
alreadyFired: dataReadyFiredRef.current,
|
||||
})
|
||||
)
|
||||
return;
|
||||
dataReadyFiredRef.current = true;
|
||||
onDataReadyRef.current?.();
|
||||
}, [isLoading]);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
@@ -40,6 +40,7 @@ import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
|
||||
import { useViewportWidth } from "@/hooks/use-viewport-width";
|
||||
import { computeTickPositions } from "@/lib/charts/x-axis-density";
|
||||
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
|
||||
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
|
||||
import { moodLabelKeyForScore } from "@/lib/mood/labels";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -361,9 +362,29 @@ export function MoodChart({
|
||||
// gate (see `onDataReady` prop doc). Must run BEFORE the
|
||||
// empty-entries early return below so a no-data mount still unblocks
|
||||
// the row.
|
||||
//
|
||||
// v1.20.1 — fire once via a ref instead of keying on the unstable
|
||||
// `onDataReady` prop. See the matching note in `health-chart.tsx`: the
|
||||
// dashboard hands every chart a fresh `() => markChartReady(id)` closure
|
||||
// each render, so depending on it re-ran this notify on every commit and
|
||||
// the per-commit passive effect kept the Radix-Popper tile anchors
|
||||
// re-committing until React's update-depth guard tripped (#185).
|
||||
const onDataReadyRef = useRef(onDataReady);
|
||||
useEffect(() => {
|
||||
if (!isLoading) onDataReady?.();
|
||||
}, [isLoading, onDataReady]);
|
||||
onDataReadyRef.current = onDataReady;
|
||||
}, [onDataReady]);
|
||||
const dataReadyFiredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldFireDataReady({
|
||||
isLoading,
|
||||
alreadyFired: dataReadyFiredRef.current,
|
||||
})
|
||||
)
|
||||
return;
|
||||
dataReadyFiredRef.current = true;
|
||||
onDataReadyRef.current?.();
|
||||
}, [isLoading]);
|
||||
|
||||
// v1.4.43 W2-CHART-GATE — raw mood-entry count across every day in
|
||||
// the window. `entries[].samples` carries the per-day raw count
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -49,6 +49,17 @@ export function OcrRowEditor({
|
||||
!isQualitative && row.confidence.value < CONFIDENCE_THRESHOLD;
|
||||
const valueUnreadable = !isQualitative && row.value === null;
|
||||
|
||||
// The model self-scores each field it transcribed. Surface a single calm
|
||||
// per-row flag when ANY tracked field came back below the threshold so a
|
||||
// shaky read is visible at a glance — including on qualitative rows, which
|
||||
// have no value-confidence footnote of their own. Informative, not alarming.
|
||||
const lowRowConfidence =
|
||||
row.confidence.analyte < CONFIDENCE_THRESHOLD ||
|
||||
row.confidence.unit < CONFIDENCE_THRESHOLD ||
|
||||
(isQualitative
|
||||
? row.confidence.value < CONFIDENCE_THRESHOLD
|
||||
: lowValueConfidence);
|
||||
|
||||
// v1.18.10 (#5) — a NEW numeric biomarker mints its catalog reference range
|
||||
// from THIS extracted row. The range is therefore load-bearing and worth a
|
||||
// second look. Surface the range confidence prominently for new numeric
|
||||
@@ -106,6 +117,12 @@ export function OcrRowEditor({
|
||||
{t("labs.ocr.duplicateWarning")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{lowRowConfidence ? (
|
||||
<Badge variant="secondary" className="text-muted-foreground">
|
||||
<AlertCircle aria-hidden />
|
||||
{t("labs.ocr.lowConfidenceBadge")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,190 @@ 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" },
|
||||
});
|
||||
});
|
||||
|
||||
it("tags an empty-content reply with sentinel httpStatus 0 + kind for the chain classifier", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "" } }],
|
||||
usage: { total_tokens: 5 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new OpenAIClient({
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o-mini",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
|
||||
await client
|
||||
.generateCompletion(singleUserTurn({ system: "s", user: "u" }))
|
||||
.then(
|
||||
() => {
|
||||
throw new Error("expected an empty-content throw");
|
||||
},
|
||||
(err: unknown) => {
|
||||
// The throw distinguishes an empty 200-OK reply from ECONNRESET:
|
||||
// sentinel httpStatus 0 (still classified as a hard failure, so the
|
||||
// cascade is unchanged) + a `kind` discriminator for observability.
|
||||
expect((err as Error).message).toContain("empty content");
|
||||
expect((err as { httpStatus?: number }).httpStatus).toBe(0);
|
||||
expect((err as { kind?: string }).kind).toBe("empty_response");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("threads the caller's abort signal onto the upstream fetch", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "ok" } }],
|
||||
usage: { total_tokens: 1 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new OpenAIClient({
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o-mini",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
|
||||
const ctrl = new AbortController();
|
||||
await client.generateCompletion(
|
||||
singleUserTurn({ system: "s", user: "u", signal: ctrl.signal }),
|
||||
);
|
||||
|
||||
// safeFetch composes the caller signal with the timeout via AbortSignal.any,
|
||||
// so a signal lands on the dispatched fetch init.
|
||||
const init = mockFetch.mock.calls[0][1];
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
|
||||
// Aborting the caller's controller propagates to the composed signal.
|
||||
ctrl.abort();
|
||||
expect(init.signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,7 +39,10 @@ describe("supportsVisionForConfig", () => {
|
||||
expect(supportsVisionForConfig(providerType, "gpt-4o-mini")).toBe(true);
|
||||
expect(supportsVisionForConfig(providerType, "gpt-4.1")).toBe(true);
|
||||
expect(supportsVisionForConfig(providerType, "gpt-4-turbo")).toBe(true);
|
||||
// Full o-series reasoning models read images.
|
||||
expect(supportsVisionForConfig(providerType, "o1")).toBe(true);
|
||||
expect(supportsVisionForConfig(providerType, "o3")).toBe(true);
|
||||
expect(supportsVisionForConfig(providerType, "o4")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,6 +52,20 @@ describe("supportsVisionForConfig", () => {
|
||||
expect(supportsVisionForConfig("openai", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects text-only o-series variants (-mini / -preview) for openai", () => {
|
||||
// o1-mini / o1-preview / o3-mini accept the `o*` prefix but have no image
|
||||
// input — claiming vision here 400s the user at the image block.
|
||||
for (const providerType of [
|
||||
"openai",
|
||||
"admin-openai",
|
||||
"admin-key",
|
||||
] as const) {
|
||||
expect(supportsVisionForConfig(providerType, "o1-mini")).toBe(false);
|
||||
expect(supportsVisionForConfig(providerType, "o1-preview")).toBe(false);
|
||||
expect(supportsVisionForConfig(providerType, "o3-mini")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("trusts local (operator opted in) regardless of model string", () => {
|
||||
expect(supportsVisionForConfig("local", "llava")).toBe(true);
|
||||
expect(supportsVisionForConfig("local", null)).toBe(true);
|
||||
@@ -72,6 +89,12 @@ describe("supportsVisionForConfig", () => {
|
||||
// The `-codex` specialist slugs are text-only despite the gpt-5 prefix.
|
||||
expect(supportsVisionForConfig("codex", "gpt-5-codex")).toBe(false);
|
||||
expect(supportsVisionForConfig("codex", "gpt-5.1-codex-mini")).toBe(false);
|
||||
// Text-only o-series variants are excluded for codex too.
|
||||
expect(supportsVisionForConfig("codex", "o1-mini")).toBe(false);
|
||||
expect(supportsVisionForConfig("codex", "o3-mini")).toBe(false);
|
||||
expect(supportsVisionForConfig("codex", "o1-preview")).toBe(false);
|
||||
// Full o-series stays vision-capable.
|
||||
expect(supportsVisionForConfig("codex", "o3")).toBe(true);
|
||||
});
|
||||
|
||||
it("never reports vision for none", () => {
|
||||
|
||||
@@ -109,4 +109,16 @@ export const AI_BUDGETS = {
|
||||
* gates with a tight 6/hour rate bucket + reserveBudget + upload-size cap.
|
||||
*/
|
||||
ocrExtract: { temperature: 0, maxTokens: 4000 },
|
||||
|
||||
/**
|
||||
* v1.20.1 — Lab-OCR text-mode structuring. The browser OCR's the image
|
||||
* (tesseract.js) and POSTs only the extracted TEXT, so the provider does a
|
||||
* plain text→JSON structuring pass — no expensive vision input tokens. The
|
||||
* output contract is identical (the same dozen-plus analyte rows), but the
|
||||
* spend ceiling is far lower than the vision path's 4000, so the day-budget
|
||||
* reservation should reflect that rather than over-charging a text call at
|
||||
* the vision rate. 1500 tokens comfortably covers a dense report's JSON
|
||||
* envelope. Temperature 0 — faithful structuring, not generation.
|
||||
*/
|
||||
ocrExtractText: { temperature: 0, maxTokens: 1500 },
|
||||
} as const satisfies Record<string, AiBudget>;
|
||||
+252
-74
@@ -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,32 +246,22 @@ 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.
|
||||
// v1.11.2 — base URL is user/admin-overridable; pin the connect-time DNS
|
||||
// check so a private/metadata address is rejected (SSRF/rebinding).
|
||||
{ timeoutMs: 60_000, requirePublicHost: true },
|
||||
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
|
||||
// a mid-generation abort tears the upstream call down early.
|
||||
{ timeoutMs: 60_000, requirePublicHost: true, signal: params.signal },
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -164,15 +286,50 @@ 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) {
|
||||
throw new Error("Anthropic returned empty content");
|
||||
// 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) {
|
||||
// v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell
|
||||
// an empty 200-OK reply apart from a transport failure. Cascade unchanged
|
||||
// (`status <= 0` is already a hard failure).
|
||||
const err = new Error("Anthropic returned empty content");
|
||||
Object.assign(err, {
|
||||
httpStatus: 0,
|
||||
kind: "empty_response",
|
||||
upstream: "anthropic",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
// When we prefilled the assistant turn with `{`, the model continues
|
||||
@@ -180,19 +337,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 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)`. Each tool scopes to a different source set, so
|
||||
* tool calls for distinct domains do NOT share a cache entry; sharing happens
|
||||
* only when the SAME scope recurs (a repeated call, or a turn that re-enters
|
||||
* the same domain) within the 60s window.
|
||||
*/
|
||||
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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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;
|
||||
/** Aborts the per-round provider calls on client disconnect. */
|
||||
signal?: AbortSignal;
|
||||
}): Promise<CoachToolLoopResult> {
|
||||
const {
|
||||
userId,
|
||||
providers,
|
||||
system,
|
||||
tools,
|
||||
temperature,
|
||||
maxTokens,
|
||||
fallbackWindow,
|
||||
ledger,
|
||||
signal,
|
||||
} = 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,
|
||||
signal,
|
||||
...(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");
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+185
-30
@@ -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
|
||||
@@ -373,7 +467,9 @@ export class CodexClient implements AIProvider {
|
||||
// clients use so a long generation is not clipped by the 15 s
|
||||
// default while still bounding a tar-pit upstream.
|
||||
},
|
||||
{ timeoutMs: 60_000 },
|
||||
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
|
||||
// a mid-generation abort tears the upstream call down early.
|
||||
{ timeoutMs: 60_000, signal: params.signal },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -398,7 +494,15 @@ export class CodexClient implements AIProvider {
|
||||
requestedSlug: string,
|
||||
): Promise<CompletionResult> {
|
||||
if (!res.body) {
|
||||
throw new Error("Codex returned no response body");
|
||||
// v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell
|
||||
// a stream-level failure apart from a connect-time transport error.
|
||||
const err = new Error("Codex returned no response body");
|
||||
Object.assign(err, {
|
||||
httpStatus: 0,
|
||||
kind: "stream_failed",
|
||||
upstream: "codex",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
@@ -407,7 +511,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 +545,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 +579,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": {
|
||||
@@ -480,6 +605,10 @@ export class CodexClient implements AIProvider {
|
||||
const e = new Error(message);
|
||||
Object.assign(e, {
|
||||
upstream: "codex",
|
||||
// v1.20.1 — sentinel httpStatus + kind so the chain classifier
|
||||
// distinguishes a stream-level failure from a transport error.
|
||||
httpStatus: 0,
|
||||
kind: "stream_failed",
|
||||
errorCode: err?.code ?? null,
|
||||
model: serverModel ?? requestedSlug,
|
||||
});
|
||||
@@ -489,13 +618,25 @@ export class CodexClient implements AIProvider {
|
||||
const reason =
|
||||
parsed.response?.incomplete_details?.reason ??
|
||||
"incomplete response";
|
||||
throw new Error(`Codex stream incomplete: ${reason}`);
|
||||
const e = new Error(`Codex stream incomplete: ${reason}`);
|
||||
Object.assign(e, {
|
||||
httpStatus: 0,
|
||||
kind: "stream_failed",
|
||||
upstream: "codex",
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
case "error":
|
||||
case "response.error": {
|
||||
const message =
|
||||
parsed.error?.message ?? "Codex stream returned an error event";
|
||||
throw new Error(message);
|
||||
const e = new Error(message);
|
||||
Object.assign(e, {
|
||||
httpStatus: 0,
|
||||
kind: "stream_failed",
|
||||
upstream: "codex",
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
default:
|
||||
// Unknown event types are tolerated and ignored, same
|
||||
@@ -509,15 +650,29 @@ export class CodexClient implements AIProvider {
|
||||
}
|
||||
|
||||
const content = assembledText || deltaText;
|
||||
if (!content) {
|
||||
throw new Error("Codex returned empty 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) {
|
||||
// v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell
|
||||
// an empty reply apart from a transport failure. Cascade unchanged
|
||||
// (`status <= 0` is already a hard failure).
|
||||
const err = new Error("Codex returned empty content");
|
||||
Object.assign(err, {
|
||||
httpStatus: 0,
|
||||
kind: "empty_response",
|
||||
upstream: "codex",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
tokensUsed,
|
||||
cachedInputTokens,
|
||||
model: serverModel ?? requestedSlug,
|
||||
providerType: "codex",
|
||||
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
finishReason: toolCalls.length > 0 ? "tool_calls" : "stop",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+69
-27
@@ -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 +
|
||||
@@ -98,7 +125,13 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
...(params.responseFormat === "json" ? { format: "json" } : {}),
|
||||
}),
|
||||
},
|
||||
{ timeoutMs: 60_000, requirePublicHost: !allowPrivate },
|
||||
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
|
||||
// a mid-generation abort tears the upstream call down early.
|
||||
{
|
||||
timeoutMs: 60_000,
|
||||
requirePublicHost: !allowPrivate,
|
||||
signal: params.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -128,7 +161,16 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
|
||||
const content = json.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error("Local AI returned empty content");
|
||||
// v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell
|
||||
// an empty 200-OK reply apart from a transport failure. Cascade unchanged
|
||||
// (`status <= 0` is already a hard failure).
|
||||
const err = new Error("Local AI returned empty content");
|
||||
Object.assign(err, {
|
||||
httpStatus: 0,
|
||||
kind: "empty_response",
|
||||
upstream: "local",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+62
-30
@@ -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
|
||||
@@ -79,7 +90,9 @@ export class OpenAIClient implements AIProvider {
|
||||
// v1.11.2 — the base URL is user/admin-overridable (BYO gateway), so pin
|
||||
// the connect-time DNS check: a base URL resolving to a private/metadata
|
||||
// address is rejected, closing the SSRF/rebinding surface.
|
||||
{ timeoutMs: 60_000, requirePublicHost: true },
|
||||
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
|
||||
// a mid-generation abort tears the upstream call down early.
|
||||
{ timeoutMs: 60_000, requirePublicHost: true, signal: params.signal },
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -105,18 +118,37 @@ 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) {
|
||||
throw new Error("OpenAI returned empty 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) {
|
||||
// v1.20.1 — tag the empty-reply throw with a sentinel `httpStatus: 0` +
|
||||
// `kind` so the provider-chain classifier can tell an empty 200-OK reply
|
||||
// apart from a genuine transport failure (ECONNRESET, DNS, timeout) in
|
||||
// observability. The cascade is unchanged: `isHardProviderFailure`
|
||||
// already treats `status <= 0` as a hard failure and walks to the next
|
||||
// provider.
|
||||
const err = new Error("OpenAI returned empty content");
|
||||
Object.assign(err, {
|
||||
httpStatus: 0,
|
||||
kind: "empty_response",
|
||||
upstream: "openai",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
+242
-25
@@ -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,158 @@ 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";
|
||||
/**
|
||||
* v1.20.1 — optional caller-owned cancellation signal. Threaded onto the
|
||||
* client's `safeFetch` so a mid-generation client disconnect (the Coach SSE
|
||||
* route wires `request.signal` here) tears the upstream provider request
|
||||
* down instead of running it to completion and paying the full token cost
|
||||
* into a closed connection. Composed with the per-client timeout inside
|
||||
* `safeFetch`. Omitted → behaviour is unchanged (timeout-only).
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
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";
|
||||
/** v1.20.1 — caller-owned cancel signal threaded to the client fetch. */
|
||||
signal?: AbortSignal;
|
||||
}): 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,
|
||||
signal: p.signal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -41,18 +41,32 @@ function anthropicSupportsVision(model: string): boolean {
|
||||
/**
|
||||
* OpenAI vision models (also covers the operator's admin key + Codex when it
|
||||
* runs an OpenAI model — though Codex is excluded by `providerType`). `gpt-4o`,
|
||||
* `gpt-4.1`, `gpt-4-turbo`, and the `o*` reasoning models read image content;
|
||||
* `gpt-3.5*` and the legacy `gpt-4` (non-turbo) 400 on an image block.
|
||||
* `gpt-4.1`, `gpt-4-turbo`, and the full `o1` / `o3` / `o4` reasoning models
|
||||
* read image content; `gpt-3.5*` and the legacy `gpt-4` (non-turbo) 400 on an
|
||||
* image block.
|
||||
*
|
||||
* The `o*` reasoning line has text-only variants that DO accept the `o*` prefix
|
||||
* but reject an image block: `o1-mini` / `o1-preview` and `o3-mini` have no
|
||||
* vision input (only the full `o1` / `o3` are multimodal). They must be
|
||||
* excluded or a user pinned to one is told OCR works, then the provider 400s at
|
||||
* the image block. We treat an `o1` / `o3` / `o4` slug as vision-capable only
|
||||
* when it is NOT a `-mini` / `-preview` variant — the conservative default for
|
||||
* any unverified narrow slug.
|
||||
*/
|
||||
function isVisionReasoningSlug(m: string): boolean {
|
||||
if (!(m.startsWith("o1") || m.startsWith("o3") || m.startsWith("o4"))) {
|
||||
return false;
|
||||
}
|
||||
return !m.includes("-mini") && !m.includes("-preview");
|
||||
}
|
||||
|
||||
function openaiSupportsVision(model: string): boolean {
|
||||
const m = model.toLowerCase();
|
||||
return (
|
||||
m.startsWith("gpt-4o") ||
|
||||
m.startsWith("gpt-4.1") ||
|
||||
m.startsWith("gpt-4-turbo") ||
|
||||
m.startsWith("o1") ||
|
||||
m.startsWith("o3") ||
|
||||
m.startsWith("o4")
|
||||
isVisionReasoningSlug(m)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,9 +92,9 @@ function codexSupportsVision(model: string): boolean {
|
||||
m.startsWith("gpt-4o") ||
|
||||
m.startsWith("gpt-4.1") ||
|
||||
m.startsWith("gpt-4-turbo") ||
|
||||
m.startsWith("o1") ||
|
||||
m.startsWith("o3") ||
|
||||
m.startsWith("o4")
|
||||
// Same `-mini` / `-preview` text-only exclusion as the OpenAI gate: a
|
||||
// future codex slug rotation onto e.g. `o3-mini` must not claim vision.
|
||||
isVisionReasoningSlug(m)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* v1.20.1 — regression cover for the dashboard chart-row render loop.
|
||||
*
|
||||
* A user reported the dashboard crashing (blank error card) on returning
|
||||
* to a backgrounded tab. The snapshot query refetches on window focus,
|
||||
* re-rendering the page; every chart was handed an inline
|
||||
* `() => markChartReady(id)` closure, so the chart's `onDataReady` notify
|
||||
* effect — keyed on that ever-changing prop — re-fired on every commit.
|
||||
* That per-commit passive effect kept the tile strip's Radix-Popper
|
||||
* anchors re-committing until React tripped its update-depth guard
|
||||
* (minified React error #185).
|
||||
*
|
||||
* The fix latches the notify behind a ref so it fires exactly once on the
|
||||
* first non-loading commit and depends on `isLoading` alone. This pins the
|
||||
* pure decision and the charts' wiring so the latch cannot silently revert
|
||||
* to the per-render notify.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
|
||||
|
||||
describe("shouldFireDataReady", () => {
|
||||
it("holds while the chart's initial query is still loading", () => {
|
||||
expect(shouldFireDataReady({ isLoading: true, alreadyFired: false })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("fires once on the first non-loading commit", () => {
|
||||
expect(shouldFireDataReady({ isLoading: false, alreadyFired: false })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("never re-fires after it has fired once, even though the chart keeps re-rendering", () => {
|
||||
// Models the tab-resume path: the parent re-renders repeatedly (a new
|
||||
// `onDataReady` closure each time) while `isLoading` stays false. The
|
||||
// latch must decline every subsequent commit so the notify cannot
|
||||
// drive an unbounded render loop.
|
||||
let fired = false;
|
||||
let fireCount = 0;
|
||||
for (let commit = 0; commit < 60; commit += 1) {
|
||||
if (shouldFireDataReady({ isLoading: false, alreadyFired: fired })) {
|
||||
fired = true;
|
||||
fireCount += 1;
|
||||
}
|
||||
}
|
||||
expect(fireCount).toBe(1);
|
||||
});
|
||||
|
||||
it("a chart that never loaded data still fires once the moment it settles", () => {
|
||||
// Loading first, then settled — fires on the settle commit, then holds.
|
||||
expect(shouldFireDataReady({ isLoading: true, alreadyFired: false })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldFireDataReady({ isLoading: false, alreadyFired: false })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldFireDataReady({ isLoading: false, alreadyFired: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chart notify wiring", () => {
|
||||
const charts = [
|
||||
"src/components/charts/health-chart.tsx",
|
||||
"src/components/charts/mood-chart.tsx",
|
||||
"src/components/charts/medication-compliance-chart.tsx",
|
||||
];
|
||||
|
||||
for (const rel of charts) {
|
||||
const src = readFileSync(join(process.cwd(), rel), "utf8");
|
||||
|
||||
it(`${rel} latches the data-ready notify once, not per render`, () => {
|
||||
// Routes through the pure latch.
|
||||
expect(src).toContain("shouldFireDataReady(");
|
||||
// Reads the handler through a ref so a changing prop identity never
|
||||
// re-arms the effect.
|
||||
expect(src).toContain("onDataReadyRef.current");
|
||||
// The notify effect depends on `isLoading` ALONE — never on the
|
||||
// unstable `onDataReady` prop (the loop trigger).
|
||||
expect(src).not.toMatch(/\}, \[isLoading, onDataReady\]\)/);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* v1.20.1 — pure decision behind the charts' one-shot `onDataReady`
|
||||
* notify.
|
||||
*
|
||||
* The dashboard's shared reveal gate (`useDashboardChartReveal`) listens
|
||||
* for each chart reporting its data query settled. Every chart receives
|
||||
* an inline `() => markChartReady(id)` closure from the dashboard, so the
|
||||
* `onDataReady` prop is a NEW reference on every parent render. Keying the
|
||||
* notify effect on that prop re-ran it on every commit — and a refetch on
|
||||
* tab-resume (`refetchOnWindowFocus`) re-rendered the page repeatedly,
|
||||
* which kept that per-commit passive effect firing and the chart row's
|
||||
* Radix-Popper anchors re-committing until React tripped its update-depth
|
||||
* guard (the minified React error #185 a user reported on returning to a
|
||||
* backgrounded tab).
|
||||
*
|
||||
* The ready signal is monotonic: once a chart's initial query has settled
|
||||
* it never un-settles (a later range-tab change opens a fresh cache entry,
|
||||
* but the gate has long latched by then). So the notify must fire exactly
|
||||
* once — on the first non-loading commit — and never again, regardless of
|
||||
* the `onDataReady` prop identity.
|
||||
*
|
||||
* This helper is the pure core of that latch, exported so the gate has
|
||||
* direct unit coverage without mounting React (project convention:
|
||||
* SSR-only tests, no DOM runner). The chart wires it to a `useRef` flag.
|
||||
*/
|
||||
export function shouldFireDataReady(input: {
|
||||
/** The chart's query is still on its initial load. */
|
||||
isLoading: boolean;
|
||||
/** Whether the notify has already fired this mount. */
|
||||
alreadyFired: boolean;
|
||||
}): boolean {
|
||||
if (input.isLoading) return false;
|
||||
if (input.alreadyFired) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user