From 5f85d20f0212fae87830d422c5de1e0f6165d196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 21:27:12 +0200 Subject: [PATCH] feat(coach): feed native day strain, document glucose panel + center its window Add WHOOP-native DAY_STRAIN (0-21) to the Coach snapshot as a dedicated dayStrain block, kept distinct from the computed STRAIN_SCORE (0-100) and preferred native-over-computed, mirroring recovery resolution. Document the glucose.clinical panel (TIR/GMI/eA1C/CV) plus the stillLearning and isSpotEstimate gates in the system prompt, and the native dayStrain scale in the derived paragraph. Extend the DE and FR/ES/IT/PL evidence-block unit lists with mg/dL, mmol/L and min so a non-English coach can cite glucose and duration values. Export GLUCOSE_PANEL_WINDOW_DAYS as the single source for the 30-day clinical-panel window and import it across the analytics route, dashboard snapshot, and Coach snapshot. Compute the derived metrics concurrently. --- src/app/api/analytics/route.ts | 7 +- .../__tests__/snapshot-new-metrics.test.ts | 74 +++++++++++++++++++ .../ai/coach/__tests__/system-prompt.test.ts | 28 +++++++ src/lib/ai/coach/derived-snapshot.ts | 25 ++++--- src/lib/ai/coach/snapshot.ts | 53 ++++++++++++- src/lib/ai/coach/system-prompt.ts | 28 ++++++- src/lib/ai/prompts/native-prompts.ts | 8 +- .../__tests__/glucose-panel-window.test.ts | 50 +++++++++++++ src/lib/analytics/glucose-metrics.ts | 10 +++ src/lib/dashboard/snapshot.ts | 7 +- 10 files changed, 269 insertions(+), 21 deletions(-) create mode 100644 src/lib/analytics/__tests__/glucose-panel-window.test.ts diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 39643f8a1..628a28b4a 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -22,6 +22,7 @@ import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; import { computeGlucoseClinicalMetrics, + GLUCOSE_PANEL_WINDOW_DAYS, type GlucoseClinicalMetrics, } from "@/lib/analytics/glucose-metrics"; @@ -346,7 +347,9 @@ async function buildAnalyticsResponse(user: AuthedUser) { // v1.4.29 H2 — bound to the trailing 30 days. The dashboard tile // path only reads the trailing window; pre-bound this query // walked every persisted BG row a multi-year user has written. - const glucoseSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const glucoseSince = new Date( + Date.now() - GLUCOSE_PANEL_WINDOW_DAYS * 24 * 60 * 60 * 1000, + ); const glucoseRows = await prisma.measurement.findMany({ where: { userId: user.id, @@ -379,7 +382,7 @@ async function buildAnalyticsResponse(user: AuthedUser) { // "still learning" state from a populated object rather than a missing key. const glucoseClinical: GlucoseClinicalMetrics = computeGlucoseClinicalMetrics( glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })), - { windowDays: 30, now: new Date() }, + { windowDays: GLUCOSE_PANEL_WINDOW_DAYS, now: new Date() }, ); // v1.4.20 phase B3 — three pre-defined correlation hypotheses. diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index 64762479b..2b692a2bc 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -333,3 +333,77 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => expect(snapshot.bodyFat).toBeDefined(); }); }); + +/** + * v1.17.0 — WHOOP-native day strain (0–21) reaches the Coach snapshot as its + * own `dayStrain` block, distinct from the computed `derived.STRAIN_SCORE`. + * The read is a SEPARATE `where.type === "DAY_STRAIN"` query, so the fixtures + * here discriminate the mock by type rather than returning one flat array. + */ +describe("buildCoachSnapshot — WHOOP-native day strain (v1.17.0)", () => { + beforeEach(() => { + vi.clearAllMocks(); + __resetCoachSnapshotCacheForTests(); + prismaMock.moodEntry.findMany.mockResolvedValue([]); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medication.findMany.mockResolvedValue([]); + prismaMock.workout.findMany.mockResolvedValue([]); + prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null }); + featuresMock.mockResolvedValue({ + bloodPressure: undefined, + weight: undefined, + pulse: undefined, + mood: undefined, + }); + }); + + /** Route each findMany to rows matching its `where.type` filter. */ + function mockByType(byType: Record[]>) { + prismaMock.measurement.findMany.mockImplementation((args: unknown) => { + const where = (args as { where?: { type?: unknown } }).where; + const t = where?.type; + if (typeof t === "string") return Promise.resolve(byType[t] ?? []); + if (t && typeof t === "object" && "in" in t) { + const wanted = (t as { in: string[] }).in; + return Promise.resolve( + wanted.flatMap((typeName) => byType[typeName] ?? []), + ); + } + return Promise.resolve([]); + }); + } + + it("emits a native dayStrain block on DAY_STRAIN rows for a WHOOP user", async () => { + mockByType({ + HEART_RATE_VARIABILITY: [daysAgo(2, 64, "HEART_RATE_VARIABILITY")], + // Ascending (oldest first), mirroring the `orderBy: measuredAt asc` read. + DAY_STRAIN: [daysAgo(3, 11.8, "DAY_STRAIN"), daysAgo(1, 14.2, "DAY_STRAIN")], + }); + + const out = await buildCoachSnapshot("user-1", { + sources: ["hrv"], + window: "last30days", + }); + const snapshot = JSON.parse(out.snapshotJson) as Record; + const dayStrain = snapshot.dayStrain as Record | undefined; + + expect(dayStrain).toBeDefined(); + expect(dayStrain?.scale).toBe("0-21"); + expect(dayStrain?.latest).toBe(14.2); + expect(dayStrain?.days).toBe(2); + }); + + it("omits dayStrain when the account has no DAY_STRAIN rows", async () => { + mockByType({ + HEART_RATE_VARIABILITY: [daysAgo(2, 64, "HEART_RATE_VARIABILITY")], + }); + + const out = await buildCoachSnapshot("user-1", { + sources: ["hrv"], + window: "last30days", + }); + const snapshot = JSON.parse(out.snapshotJson) as Record; + + expect(snapshot.dayStrain).toBeUndefined(); + }); +}); diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts index 0ecd4e005..b9f887683 100644 --- a/src/lib/ai/coach/__tests__/system-prompt.test.ts +++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts @@ -98,6 +98,26 @@ describe("getCoachSystemPrompt — EN", () => { // appear. expect(prompt).not.toMatch(/@.+\.(com|net|org|de|test)/); }); + + it("documents the glucose.clinical panel sub-key and its gates", () => { + // v1.17.0 — the model must know the `clinical` sub-key exists, how to + // read TIR/GMI/eA1C/CV, and must NOT assert a clinical figure when the + // learning-gate or the spot-estimate caveat is set. + expect(prompt).toMatch(/"clinical"/); + expect(prompt).toMatch(/tirPercent/); + expect(prompt).toMatch(/\bgmi\b/); + expect(prompt).toMatch(/estimatedA1c/); + expect(prompt).toMatch(/cvPercent/); + expect(prompt).toMatch(/stillLearning/); + expect(prompt).toMatch(/isSpotEstimate/); + }); + + it("documents the WHOOP-native dayStrain block vs the computed proxy", () => { + // v1.17.0 — native 0–21 strain preferred over the computed 0–100 proxy. + expect(prompt).toMatch(/dayStrain/); + expect(prompt).toMatch(/0–21/); + expect(prompt).toMatch(/0–100/); + }); }); describe("getCoachSystemPrompt — DE", () => { @@ -153,6 +173,14 @@ describe("getCoachSystemPrompt — DE", () => { expect(prompt).toMatch(/Bewegung sehe ich in deinem Tracking gerade nicht/); expect(prompt).toMatch(/außerhalb dessen, womit ich helfen kann/); }); + + it("lists glucose + duration units in the evidence-block enumeration", () => { + // v1.17.0 — the DE unit list must sanction mg/dL, mmol/L and min so a + // German coach can cite glucose + duration values with a valid token. + expect(prompt).toMatch(/mg\/dL/); + expect(prompt).toMatch(/mmol\/L/); + expect(prompt).toMatch(/min[\s\S]*\(Schlafdefizit/); + }); }); /** diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts index 3e33a3b22..48deece63 100644 --- a/src/lib/ai/coach/derived-snapshot.ts +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -112,15 +112,22 @@ export async function buildDerivedSnapshotBlock( ): Promise | null> { const block: Record = {}; - for (const metric of SNAPSHOT_METRICS) { - // Per-metric fault isolation: a transient compute failure on one metric - // must never sink the whole Coach turn — drop it and carry on. - let derived; - try { - derived = await computeDerivedMetric({ metric, userId, profile, now }); - } catch { - continue; - } + // The metrics are independent passthrough reads off the one shared profile — + // no ordering dependency — so compute them concurrently. Per-metric fault + // isolation: a transient failure on one must never sink the whole Coach turn, + // so each compute resolves to null on throw rather than rejecting the batch. + const computed = await Promise.all( + SNAPSHOT_METRICS.map(async (metric) => { + try { + return { metric, derived: await computeDerivedMetric({ metric, userId, profile, now }) }; + } catch { + return { metric, derived: null }; + } + }), + ); + + for (const { metric, derived } of computed) { + if (derived === null) continue; if (!isDerivedOk(derived)) continue; // omit insufficient — no noise const summary = summariseValue(metric, derived.value); if (!summary) continue; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index d5221b81e..cb0d7ac39 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -24,7 +24,10 @@ import { } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; -import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import { + computeGlucoseClinicalMetrics, + GLUCOSE_PANEL_WINDOW_DAYS, +} from "@/lib/analytics/glucose-metrics"; import { reconstructSleepNights, type SleepStageRow, @@ -96,7 +99,7 @@ const DEFAULT_WINDOW: CoachScopeWindow = "last30days"; * narration window so the coach's TIR/GMI/CV% always equals what the panel * renders. */ -const GLUCOSE_CLINICAL_WINDOW_DAYS = 30; +const GLUCOSE_CLINICAL_WINDOW_DAYS = GLUCOSE_PANEL_WINDOW_DAYS; /** * v1.17.0 — the sleep-rhythm read (sleep-debt + chronotype) is a fixed @@ -936,6 +939,23 @@ async function buildCoachSnapshotImpl( const derivedBlockPromise = derivedActive ? buildDerivedSnapshotBlock(userId, derivedProfile, now) : null; + // v1.17.0 — WHOOP-native day strain (0–21), distinct from the COMPUTED + // STRAIN_SCORE (0–100) the derived block carries. Gated on the same + // wellness/activity signals so it rides the existing parallel batch; the + // block is omitted when the account has no DAY_STRAIN rows (every + // non-WHOOP account). Native-over-computed mirrors how recovery resolves. + const dayStrainRowsPromise = derivedActive + ? prisma.measurement.findMany({ + where: { + userId, + type: "DAY_STRAIN", + measuredAt: { gte: cutoff }, + deletedAt: null, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true }, + }) + : null; const trajectoryBlockPromise = derivedActive ? buildTrajectorySnapshotBlock(userId, derivedProfile, now) : null; @@ -967,6 +987,7 @@ async function buildCoachSnapshotImpl( glucoseClinicalRows, glp1Block, derivedBlock, + dayStrainRows, trajectoryBlock, memoryBlock, cycleBlock, @@ -979,6 +1000,7 @@ async function buildCoachSnapshotImpl( glucoseClinicalRowsPromise, glp1BlockPromise, derivedBlockPromise, + dayStrainRowsPromise, trajectoryBlockPromise, memoryBlockPromise, cycleBlockPromise, @@ -1677,6 +1699,33 @@ async function buildCoachSnapshotImpl( registerBlock("derived", "hrv"); } + // ── v1.17.0 — WHOOP-native day strain ──────────────────────────────── + // The device's gold-standard strain on its native 0–21 scale, kept + // distinct from the COMPUTED `derived.STRAIN_SCORE` (0–100). When both + // exist we surface the native number AND flag it as the device signal + // so the model prefers it (native-over-computed, mirroring recovery). + // Omitted for every account without a DAY_STRAIN row. + if (dayStrainRows && dayStrainRows.length > 0) { + const latest = dayStrainRows[dayStrainRows.length - 1]; + const recent = dayStrainRows.filter((r) => r.measuredAt >= recentCutoff); + const mean = + recent.length > 0 + ? Math.round( + (recent.reduce((sum, r) => sum + r.value, 0) / recent.length) * + 10, + ) / 10 + : Math.round(latest.value * 10) / 10; + snapshot.dayStrain = { + source: "WHOOP-native", + scale: "0-21", + latest: Math.round(latest.value * 10) / 10, + recentMean: mean, + days: dayStrainRows.length, + note: "Device-native day strain; prefer over derived.STRAIN_SCORE (computed 0-100 proxy).", + }; + registerBlock("dayStrain", "hrv"); + } + // ── v1.11.0 (Epic B, Pillar 3) — short-horizon trajectory block ────── // Additive, lowest-signal block: per in-scope metric a compact // direction + slope + projected horizon-end-with-band, computed by the diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index f97fe3e5f..5ac0874bf 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -230,7 +230,14 @@ ISO-week means. yellow band") to ground a reply. These are DESCRIPTIVE composites, not clinical assessments or diagnoses — never frame a band as a medical finding, and lean on lower-confidence / few-day entries - cautiously. Never recompute or second-guess the number. + cautiously. Never recompute or second-guess the number. The "strain" + entry inside "derived" is a COMPUTED proxy on a 0–100 scale. When the + SNAPSHOT ALSO carries a top-level "dayStrain" block, that is the + device's NATIVE day strain (WHOOP) on its own 0–21 scale ({ latest, + recentMean, scale: "0-21", days }) — prefer the native dayStrain + number for any strain reply and name its 0–21 scale, the same way you + prefer a device-native recovery over the computed one. Never conflate + the two scales (a 14 on 0–21 is not a 14 on 0–100). - The SNAPSHOT MAY carry a "sleepRhythm" block with two server-computed timing signals — the SAME values the Sleep page and dashboard show, so cite them as the user sees them and never recompute. It holds: @@ -247,6 +254,22 @@ ISO-week means. When "state" is "learning" the band is null — say the chronotype is still calibrating and DO NOT assert any band or social-jetlag figure. These are DESCRIPTIVE timing signals, not a sleep disorder finding. +- The glucose block MAY carry a "clinical" sub-key with the same panel + numbers the app's glucose page renders over a fixed 30-day window — so + cite them as the user sees them and never recompute. It holds: + - "tirPercent": time-in-range (70–180 mg/dL) as a percentage. + - "gmi": the glucose-management indicator (a %, an HbA1c estimate + derived from mean glucose) and "estimatedA1c": the eA1C in % — read + both as estimates, never as a lab HbA1c. + - "cvPercent": coefficient of variation (glucose variability); CV ≥ 36% + reads as "unstable". + - "stillLearning": when true the panel has too few readings or too + short a span to be clinically meaningful — DO NOT assert a TIR / GMI / + eA1C / AGP figure; say the glucose panel is still calibrating. + - "isSpotEstimate": when true the series is sparse spot fingersticks, + not continuous CGM — TIR/CV from spot checks are rough proxies, so + caveat them as spot-based and never present them as a CGM-grade AGP. + These are DESCRIPTIVE panel numbers, not a clinical diagnosis. - The SNAPSHOT's "memory" block MAY carry a "facts" list — durable things you have learned about this user across conversations (stable preferences, conditions they have told you about, goals, constraints, @@ -585,7 +608,8 @@ Regeln: oder ein Tages-Pin ("Di 6. Mai"). - "value" ist ein vorformatierter Anzeigestring ("138/85", "84,2", "4,1"). Stimmung als N/5 formatieren, wenn numerisch. -- "unit" ist eines aus mmHg, kg, bpm, /5, %. +- "unit" ist eines aus mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min + (Schlafdefizit / sozialer Jetlag). - "window" ist eines aus last7days, last30days, last90days, allTime; bei Tages-Pins weglassen. - Lass den ganzen Block weg (KEINE ---KEYVALUES---Zeile, KEINE diff --git a/src/lib/ai/prompts/native-prompts.ts b/src/lib/ai/prompts/native-prompts.ts index 23f3b546d..8f8e76d77 100644 --- a/src/lib/ai/prompts/native-prompts.ts +++ b/src/lib/ai/prompts/native-prompts.ts @@ -115,7 +115,7 @@ Règles : - Chaque ligne : "