feat(coach): thread sleep-debt and chronotype into the snapshot

Surface the server-authoritative sleep-rhythm read in the coach snapshot
so a reply can reference cumulative sleep debt and the chronotype band the
Sleep page and dashboard already show. The values come from the same
assembler those surfaces use over a fixed trailing-42-day window, never a
recompute, so the coach can only quote what the page renders.

Hold the learning gate: the chronotype band and social jetlag are emitted
only once the signal is ready; a calibrating chronotype is surfaced as such
and never asserted as a band. Document the new fields and minute units in
the system prompt.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 19:34:14 +02:00
1 parent e561f8d893
commit d3ea8d4470
3 files changed
+239 -1

No files matched your search

+118
View File
@@ -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<typeof vi.fn> };
@@ -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();
});
});
// ────────────────────────────────────────────────────────────────────
+103
View File
@@ -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
+18 -1
View File
@@ -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)