diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index ed0662da6..57856c54e 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -32,6 +32,11 @@ vi.mock("../memory-snapshot", () => ({ import { prisma } from "@/lib/db"; import { extractFeatures } from "@/lib/insights/features"; +import { + reconstructNights, + sleepNeedMinutes, +} from "@/lib/insights/derived/sleep-score"; +import { computeSleepRhythmFromNights } from "@/lib/insights/derived/sleep-rhythm"; const prismaMock = prisma as unknown as { measurement: { findMany: ReturnType }; @@ -404,6 +409,119 @@ describe("buildCoachSnapshot", () => { // Two distinct window keys → two cache slots → two Prisma reads. expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(2); }); + + // ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ────────── + // + // The coach reads the SAME computed values the Sleep page + dashboard + // show: it hands the canonical per-night reconstruction to the SAME + // assembler (`computeSleepRhythmFromNights`) the dashboard route uses, + // never recomputing sleep-debt or chronotype inline. These tests pin + // the reuse contract + the learning-gate honesty. + + /** + * One night's bare-ASLEEP block, wake instant in UTC. `reconstructNights` + * reads asleep = `asleepMinutes` and a midpoint at wake − asleep/2 — the + * same fixture shape the sleep-rhythm unit suite uses. + */ + function nightRow(wakeIso: string, asleepMinutes: number) { + return { + type: "SLEEP_DURATION", + value: asleepMinutes, + measuredAt: new Date(wakeIso), + sleepStage: "ASLEEP" as const, + source: null, + deviceType: null, + }; + } + + it("emits a ready sleepRhythm block equal to the assembler's DTO", async () => { + // 14 consecutive nights across two weekends → debt ready (≥7 nights) + // and chronotype ready (≥3 free/weekend nights). 2026-06-01 is a Monday. + const rows = Array.from({ length: 14 }, (_, i) => { + const day = 1 + i; // 2026-06-01 .. 2026-06-14 + const dd = String(day).padStart(2, "0"); + return nightRow(`2026-06-${dd}T06:00:00Z`, 360 + (i % 3) * 20); + }); + prismaMock.measurement.findMany.mockResolvedValue(rows); + featuresMock.mockResolvedValue({ + context: { heightCm: null, ageYears: 40, gender: null }, + }); + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: null, + timezone: "UTC", + }); + + const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] }); + const parsed = JSON.parse(out.snapshotJson); + + // Independently run the SAME engine over the SAME rows + need — the + // snapshot must equal it field-for-field (reuse, never recompute). + const expected = computeSleepRhythmFromNights( + reconstructNights(rows, "UTC", null), + sleepNeedMinutes(40), + ); + expect(expected.sleepDebt.state).toBe("ready"); + expect(expected.chronotype.state).toBe("ready"); + expect(expected.chronotype.band).not.toBeNull(); + + expect(parsed.sleepRhythm.sleepDebt).toEqual({ + state: expected.sleepDebt.state, + debtMinutes: expected.sleepDebt.debtMinutes, + needMinutes: expected.sleepDebt.needMinutes, + }); + expect(parsed.sleepRhythm.chronotype).toEqual({ + state: expected.chronotype.state, + band: expected.chronotype.band, + socialJetlagMinutes: expected.chronotype.socialJetlagMinutes, + }); + }); + + it("never asserts a band for a learning chronotype", async () => { + // 8 weekday nights (2026-06-15 Mon .. 2026-06-22 Mon, weekends 06-20/21 + // are the only free days) — enough for debt to be ready but the + // chronotype stays learning until it has ≥3 free-day nights. With only + // two weekend nights here, the band must NOT be asserted. + const weekdayRows = [15, 16, 17, 18, 19, 22].map((d) => + nightRow(`2026-06-${String(d).padStart(2, "0")}T06:00:00Z`, 380), + ); + prismaMock.measurement.findMany.mockResolvedValue(weekdayRows); + featuresMock.mockResolvedValue({ + context: { heightCm: null, ageYears: 40, gender: null }, + }); + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: null, + timezone: "UTC", + }); + + const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] }); + const parsed = JSON.parse(out.snapshotJson); + + const expected = computeSleepRhythmFromNights( + reconstructNights(weekdayRows, "UTC", null), + sleepNeedMinutes(40), + ); + expect(expected.chronotype.state).toBe("learning"); + + expect(parsed.sleepRhythm.chronotype.state).toBe("learning"); + // A learning chronotype carries no band the data supports. + expect(parsed.sleepRhythm.chronotype.band).toBeNull(); + expect(parsed.sleepRhythm.chronotype.socialJetlagMinutes).toBeNull(); + }); + + it("omits the sleepRhythm block when no sleep rows exist", async () => { + prismaMock.measurement.findMany.mockResolvedValue([]); + featuresMock.mockResolvedValue({ + context: { heightCm: null, ageYears: 40, gender: null }, + }); + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: null, + timezone: "UTC", + }); + + const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] }); + const parsed = JSON.parse(out.snapshotJson); + expect(parsed.sleepRhythm).toBeUndefined(); + }); }); // ──────────────────────────────────────────────────────────────────── diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index bbab2efc4..7fbdba570 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -29,6 +29,14 @@ import { reconstructSleepNights, type SleepStageRow, } from "@/lib/analytics/sleep-night"; +import { + reconstructNights, + sleepNeedMinutes, +} from "@/lib/insights/derived/sleep-score"; +import { + computeSleepRhythmFromNights, + type SleepRhythmDto, +} from "@/lib/insights/derived/sleep-rhythm"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { annotate } from "@/lib/logging/context"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; @@ -90,6 +98,18 @@ const DEFAULT_WINDOW: CoachScopeWindow = "last30days"; */ const GLUCOSE_CLINICAL_WINDOW_DAYS = 30; +/** + * v1.17.0 — the sleep-rhythm read (sleep-debt + chronotype) is a fixed + * trailing-window artifact, identical across the Sleep page, the dashboard + * summary, and (here) the coach. Pinned independently of the coach's variable + * narration window (7/30/90/365) so the coach's debt + chronotype band always + * equal what the page renders. Mirrors `DEFAULT_WINDOW_DAYS` in + * `sleep-rhythm.ts`: 42 days gives the 14-night debt window full coverage and + * ~12 weekend nights for a stable MSF — the assembler self-caps each signal to + * its own window, so feeding the same 42-day rows yields the page's DTO. + */ +const SLEEP_RHYTHM_WINDOW_DAYS = 42; + /** * v1.7.0 — assembled-snapshot soft char cap. After the snapshot is * built we measure `JSON.stringify(snapshot).length` as a ~4-chars-per- @@ -841,6 +861,37 @@ async function buildCoachSnapshotImpl( }, }) : null; + // v1.17.0 — sleep-rhythm rows. The sleep-debt + chronotype DTO is a fixed + // trailing-42-day artifact (the Sleep page + dashboard read the same window), + // so it must NOT ride the coach's variable narration window or the + // multi-cluster timeline cap, or the coach would quote a debt / chronotype + // band the page never shows. Read the rhythm's own trailing-42-day rows + // directly (one indexed query, only when the sleep cluster is active) and + // hand them to the SAME assembler the dashboard route uses. The per-stage + // narration timeline above keeps using the coach-window `sleepRows`. + const sleepRhythmCutoff = new Date( + now.getTime() - SLEEP_RHYTHM_WINDOW_DAYS * 24 * 60 * 60 * 1000, + ); + const sleepRhythmRowsPromise = sources.has("sleep") + ? prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION" as never, + measuredAt: { gte: sleepRhythmCutoff }, + deletedAt: null, + }, + orderBy: { measuredAt: "asc" }, + // source + deviceType feed the canonical writer-dedup so a multi-source + // night is counted ONCE, matching every other sleep surface. + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, + }) + : null; const workoutRowsPromise = sources.has("workouts") ? prisma.workout.findMany({ where: { userId, startedAt: { gte: additiveCutoff("workouts") } }, @@ -911,6 +962,7 @@ async function buildCoachSnapshotImpl( moodRows, complianceMeds, sleepRows, + sleepRhythmRows, workoutRows, glucoseClinicalRows, glp1Block, @@ -922,6 +974,7 @@ async function buildCoachSnapshotImpl( moodRowsPromise, complianceMedsPromise, sleepRowsPromise, + sleepRhythmRowsPromise, workoutRowsPromise, glucoseClinicalRowsPromise, glp1BlockPromise, @@ -1327,6 +1380,56 @@ async function buildCoachSnapshotImpl( } } + // ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ────────── + // + // The two server-authoritative timing signals the Sleep page + the + // dashboard summary render — cumulative sleep debt and the MCTQ + // chronotype band + social jetlag. Built from the SAME assembler the + // dashboard route uses (`reconstructNights` → `computeSleepRhythmFromNights`), + // over the rhythm's OWN fixed trailing-42-day rows, so the coach quotes the + // exact debt + band the page shows regardless of the coach's narration + // window. ONE ENGINE: this never recomputes sleep-debt or chronotype inline — + // the math lives in `sleep-debt.ts` / `chronotype.ts`, reached through the + // assembler. The `needMinutes` is the SAME age-resolved need the derived + // block + Sleep Score read (`sleepNeedMinutes(ageYears)`), and the + // `sourcePriorityJson` is the one already loaded for the per-stage sleep + // block above — no extra read beyond the rhythm rows. + // + // LEARNING-GATE HONESTY: both signals carry a calm `partial` / `learning` + // state below their night thresholds. The chronotype `band` is emitted ONLY + // when the state is `ready` — a learning chronotype is surfaced as + // "still calibrating", never asserted as a band the data can't support. + if (sources.has("sleep") && sleepRhythmRows && sleepRhythmRows.length > 0) { + const rhythm: SleepRhythmDto = computeSleepRhythmFromNights( + reconstructNights( + sleepRhythmRows as SleepStageRow[], + userTz, + prefsRow?.sourcePriorityJson ?? null, + ), + sleepNeedMinutes(derivedProfile.ageYears), + ); + const chronotypeReady = rhythm.chronotype.state === "ready"; + snapshot.sleepRhythm = { + sleepDebt: { + state: rhythm.sleepDebt.state, + debtMinutes: rhythm.sleepDebt.debtMinutes, + needMinutes: rhythm.sleepDebt.needMinutes, + }, + chronotype: { + state: rhythm.chronotype.state, + // Only assert a band + social jetlag once the chronotype is `ready`. + // A `learning` chronotype carries no band the data supports — the + // model treats it as "still calibrating", never a typed assertion. + band: chronotypeReady ? rhythm.chronotype.band : null, + socialJetlagMinutes: chronotypeReady + ? rhythm.chronotype.socialJetlagMinutes + : null, + }, + }; + metrics.add("sleep"); + registerBlock("sleepRhythm", "sleep"); + } + // ── v1.7.0 glucose block (per-context daily means) ──────────────── // // Glucose is summarised per `GlucoseContext` so the Coach can tell diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 4298db3b8..f97fe3e5f 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -231,6 +231,22 @@ ISO-week means. 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. +- 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: + - "sleepDebt": { state, debtMinutes, needMinutes }. "debtMinutes" is + the cumulative deficit against the age-based nightly need over the + trailing window, "needMinutes" the target per night — both in + MINUTES (convert to hours/minutes when you speak, e.g. 320 → "5h + 20m of sleep debt"). When "state" is "partial" the window is still + short: treat the figure as still calibrating, not a firm number. + - "chronotype": { state, band, socialJetlagMinutes }. "band" is one of + extreme_early / early / intermediate / late / extreme_late and + "socialJetlagMinutes" is the free-day vs work-day midpoint gap in + MINUTES. Cite the band plainly ("your rhythm reads intermediate"). + 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 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, @@ -258,7 +274,8 @@ Rules: or a day-pin ("Tue 6 May"). - "value" is a pre-formatted display string ("138/85", "84.2", "4.1"). Format mood as N/5 when numeric. -- "unit" is one of mmHg, kg, bpm, /5, %, mg/dL, mmol/L. +- "unit" is one of mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (sleep + debt / social jetlag). - "window" is one of last7days, last30days, last90days, allTime; omit it for day-level pins. - Omit the entire block (no ---KEYVALUES--- line, no ---END--- line)