feat(coach): snapshot includes day-level readings with weekday labels

The Coach snapshot used to ship only aggregated statistics (mean,
slope, SD, range, count) per metric. A turn that asked
"why was BP higher on Monday?" had no day-level rows to ground in
and the model fell back to apologetic aggregate-only phrasing
("from the snapshot I cannot isolate the specific Monday").

Extend buildCoachSnapshot to fold in:
  - timeline.recent: one row per UTC day for the last 14 days, each
    tagged with its weekday so the model can answer day or weekday
    questions directly. BP rows pair systolic + diastolic; days with
    only one side measured are dropped to avoid fabricating a
    complement.
  - timeline.weekly: ISO-week buckets covering days 15..N where N is
    the configured window. Keeps the prompt budget tight on 30/90-day
    windows while still letting the Coach cite older weeks.
  - per-day medication adherence rows from MedicationIntakeEvent so
    the Coach can answer "did I miss my dose Tuesday?" without
    inventing the schedule.

Also add the optional `scope` field to coachChatRequestSchema (sources
+ window) so the upcoming sources-rail picker can narrow the snapshot
without breaking older clients — missing fields fall through to
all-source last30days.

A typical 30-day, 5-source snapshot lands around 2.4-2.8 KB JSON
(≈700-900 prompt tokens) — comfortably inside the 25k/day Coach token
budget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 19:21:48 +02:00
1 parent 7921ffc8e6
commit ed61b17f57
4 files changed
+581 -24

No files matched your search

+7 -2
View File
@@ -143,7 +143,12 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
{ status: 422 },
);
}
const { conversationId, message, locale: bodyLocale } = parsed.data;
const {
conversationId,
message,
locale: bodyLocale,
scope,
} = parsed.data;
await enforceBudget(userId);
@@ -201,7 +206,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// Build the prompt: system + (optional) snapshot + recent history +
// the new user message.
const snapshot = await buildCoachSnapshot(userId);
const snapshot = await buildCoachSnapshot(userId, scope);
const systemPrompt = getCoachSystemPrompt(locale);
const window = buildHistoryWindow([
...priorTurns,
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { buildCoachSnapshot } from "../snapshot";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/insights/features", () => ({
extractFeatures: vi.fn(),
}));
import { prisma } from "@/lib/db";
import { extractFeatures } from "@/lib/insights/features";
const prismaMock = prisma as unknown as {
measurement: { findMany: ReturnType<typeof vi.fn> };
moodEntry: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
};
const featuresMock = extractFeatures as unknown as ReturnType<typeof vi.fn>;
/**
* Helper: produce a measurement row N days before "now" at 09:00 UTC.
*/
function daysAgo(n: number, value: number, type: string): {
type: string;
value: number;
measuredAt: Date;
} {
const ms = Date.now() - n * 24 * 60 * 60 * 1000;
const d = new Date(ms);
d.setUTCHours(9, 0, 0, 0);
return { type, value, measuredAt: d };
}
describe("buildCoachSnapshot", () => {
beforeEach(() => {
vi.clearAllMocks();
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
featuresMock.mockResolvedValue({
bloodPressure: undefined,
weight: undefined,
pulse: undefined,
mood: undefined,
});
});
it("returns a 'general'-only provenance when nothing is in the log", async () => {
const out = await buildCoachSnapshot("user-1");
expect(out.provenance.metrics).toContain("general");
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.scope.window).toBe("last30days");
});
it("includes day-level BP rows with weekday labels for the recent window", async () => {
featuresMock.mockResolvedValue({
bloodPressure: {
avgSys30: 138,
avgDia30: 85,
coverage: { count: 4 },
},
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 142, "BLOOD_PRESSURE_SYS"),
daysAgo(2, 92, "BLOOD_PRESSURE_DIA"),
daysAgo(5, 130, "BLOOD_PRESSURE_SYS"),
daysAgo(5, 80, "BLOOD_PRESSURE_DIA"),
]);
const out = await buildCoachSnapshot("user-1");
const parsed = JSON.parse(out.snapshotJson);
const recent = parsed.bloodPressure.timeline.recent as Array<{
date: string;
weekday: string;
sys: number;
dia: number;
}>;
expect(recent.length).toBe(2);
expect(recent[0]).toMatchObject({ sys: expect.any(Number) });
expect(["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]).toContain(
recent[0].weekday,
);
expect(out.provenance.metrics).toContain("bp");
});
it("respects the scope.sources filter — excluded metrics drop out", async () => {
featuresMock.mockResolvedValue({
bloodPressure: { avgSys30: 138, coverage: { count: 4 } },
weight: { latest: 80, coverage: { count: 4 } },
});
const out = await buildCoachSnapshot("user-1", {
sources: ["weight"],
window: "last30days",
});
expect(out.provenance.metrics).toContain("weight");
expect(out.provenance.metrics).not.toContain("bp");
// Snapshot shouldn't mention BP either
expect(out.snapshotJson).not.toContain("bloodPressure");
});
it("respects the scope.window — last7days yields a tighter window", async () => {
featuresMock.mockResolvedValue({
pulse: { avg7: 70, coverage: { count: 4 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 70, "PULSE"),
]);
const out = await buildCoachSnapshot("user-1", {
sources: ["pulse"],
window: "last7days",
});
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.scope.window).toBe("last7days");
expect(parsed.pulse.timeline.recent.length).toBe(1);
});
it("defaults to all-source last30days when no scope is provided", async () => {
const out = await buildCoachSnapshot("user-1");
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.scope.sources).toEqual([
"bp",
"weight",
"pulse",
"mood",
"compliance",
]);
expect(parsed.scope.window).toBe("last30days");
});
});
+402 -22
View File
@@ -6,9 +6,23 @@
* truth for every "your avg30 BP is …" claim. The output is a compact
* JSON block the system + user prompt frame as the SNAPSHOT for the
* model to ground its reply in.
*
* v1.4.20.1 — extended with a day-level `timeline` block. The earlier
* shipping shape carried only aggregated statistics (mean, slope, SD,
* range, count) per metric, so a Coach turn could not answer questions
* keyed to a specific day or weekday ("why was BP higher last
* Monday?"). The timeline now ships the last 14 days as raw daily
* values with weekday labels and aggregates the older window into
* weekly buckets so the prompt budget stays tight.
*/
import { prisma } from "@/lib/db";
import { extractFeatures } from "@/lib/insights/features";
import type { CoachProvenance } from "./types";
import type {
CoachProvenance,
CoachScope,
CoachScopeSource,
CoachScopeWindow,
} from "./types";
export interface CoachSnapshotResult {
snapshotJson: string;
@@ -20,25 +34,234 @@ export interface CoachSnapshotResult {
provenance: CoachProvenance;
}
/**
* Day-level cap for the raw timeline. Days within this window are kept
* verbatim (one entry per day with weekday). Older days inside the
* snapshot window are folded into weekly means so a 90-day window
* lands at ~14 day-rows + ~11 week-rows ≈ 25 rows per metric — well
* under the 3 000-token Coach turn budget on a 5-metric snapshot.
*/
const DAILY_TIMELINE_DAYS = 14;
/** Default window when the caller doesn't pass a scope. */
const DEFAULT_WINDOW: CoachScopeWindow = "last30days";
const DEFAULT_SOURCES: ReadonlyArray<CoachScopeSource> = [
"bp",
"weight",
"pulse",
"mood",
"compliance",
];
const WEEKDAY_KEYS = [
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
] as const;
function windowToDays(window: CoachScopeWindow): number {
switch (window) {
case "last7days":
return 7;
case "last30days":
return 30;
case "last90days":
return 90;
case "allTime":
// Cap "allTime" at one year for the timeline. The aggregate
// section already cites multi-year ranges via the features
// pipeline; the timeline stays tight to keep token budget sane.
return 365;
}
}
/** UTC YYYY-MM-DD key — the same partition the dashboard uses. */
function utcDayKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
/** 0..6 → "Sun".."Sat" using UTC weekday so display is timezone-stable. */
function utcWeekday(date: Date): string {
return WEEKDAY_KEYS[date.getUTCDay()];
}
/** ISO week key like 2026-W19 — used for the weekly-bucket section. */
function isoWeekKey(date: Date): string {
// Copy date and align to Thursday of the same ISO week so the
// year-week pair matches the standard ISO 8601 calendar.
const copy = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
);
const dayNum = copy.getUTCDay() || 7;
copy.setUTCDate(copy.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(copy.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil(
((copy.getTime() - yearStart.getTime()) / 86400000 + 1) / 7,
);
return `${copy.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
}
interface DailyValueRow {
date: string;
weekday: string;
value: number;
}
interface DailyBpRow {
date: string;
weekday: string;
sys: number;
dia: number;
}
interface WeeklyBucket {
weekISO: string;
mean: number;
count: number;
}
/**
* Group raw measurements (one per row, possibly several per day) into
* one entry per UTC day. Multiple readings on the same day are folded
* into the daily mean — clinically the morning reading is what the
* Coach is usually asked about, but the snapshot stays pre-clinical
* and takes the straight mean to avoid presenting a fabricated number.
*/
function dailyMeans<T extends { measuredAt: Date; value: number }>(
rows: T[],
): Map<string, { date: Date; values: number[] }> {
const grouped = new Map<string, { date: Date; values: number[] }>();
for (const r of rows) {
const key = utcDayKey(r.measuredAt);
const existing = grouped.get(key);
if (existing) {
existing.values.push(r.value);
} else {
grouped.set(key, { date: r.measuredAt, values: [r.value] });
}
}
return grouped;
}
function bucketWeekly(
rows: Array<{ measuredAt: Date; value: number }>,
): WeeklyBucket[] {
const grouped = new Map<string, number[]>();
for (const r of rows) {
const key = isoWeekKey(r.measuredAt);
const list = grouped.get(key);
if (list) {
list.push(r.value);
} else {
grouped.set(key, [r.value]);
}
}
return Array.from(grouped.entries())
.map(([weekISO, values]) => ({
weekISO,
mean: Math.round((values.reduce((s, v) => s + v, 0) / values.length) * 10) / 10,
count: values.length,
}))
.sort((a, b) => a.weekISO.localeCompare(b.weekISO));
}
function buildDailyValueRows(
rows: Array<{ measuredAt: Date; value: number }>,
recentCutoff: Date,
): DailyValueRow[] {
const recent = rows.filter((r) => r.measuredAt >= recentCutoff);
const grouped = dailyMeans(recent);
return Array.from(grouped.entries())
.map(([date, info]) => {
const mean =
info.values.reduce((s, v) => s + v, 0) / info.values.length;
return {
date,
weekday: utcWeekday(info.date),
value: Math.round(mean * 10) / 10,
};
})
.sort((a, b) => a.date.localeCompare(b.date));
}
/**
* Pair systolic + diastolic into one row per day. Days with only one
* side measured are dropped — the Coach is asked about "BP" as a pair,
* and a half-measured day would invite a fabricated complement.
*/
function buildDailyBpRows(
sysRows: Array<{ measuredAt: Date; value: number }>,
diaRows: Array<{ measuredAt: Date; value: number }>,
recentCutoff: Date,
): DailyBpRow[] {
const sysRecent = sysRows.filter((r) => r.measuredAt >= recentCutoff);
const diaRecent = diaRows.filter((r) => r.measuredAt >= recentCutoff);
const sysByDay = dailyMeans(sysRecent);
const diaByDay = dailyMeans(diaRecent);
const out: DailyBpRow[] = [];
for (const [day, info] of sysByDay) {
const dia = diaByDay.get(day);
if (!dia) continue;
const sysMean =
info.values.reduce((s, v) => s + v, 0) / info.values.length;
const diaMean =
dia.values.reduce((s, v) => s + v, 0) / dia.values.length;
out.push({
date: day,
weekday: utcWeekday(info.date),
sys: Math.round(sysMean),
dia: Math.round(diaMean),
});
}
return out.sort((a, b) => a.date.localeCompare(b.date));
}
/**
* Resolve the working scope. Missing fields fall through to defaults
* so older native clients (no scope picker) keep getting the full
* 30-day, all-source snapshot they had before v1.4.20.1.
*/
function resolveScope(scope?: CoachScope): {
sources: ReadonlySet<CoachScopeSource>;
window: CoachScopeWindow;
} {
const sources =
scope?.sources && scope.sources.length > 0
? new Set(scope.sources)
: new Set(DEFAULT_SOURCES);
return {
sources,
window: scope?.window ?? DEFAULT_WINDOW,
};
}
/**
* Build the Coach prompt snapshot for `userId`. Always uses
* `includeRaw=false` because the Coach replies are conversational and
* the user is asking the model — they should never depend on raw
* measurement timestamps that the privacy mode controls.
*
* Bounded to the last 90 days. Coach narrates the same windows the
* Daily Briefing renders (last7 / last30 / last90); fetching every
* historical measurement on each turn was unbounded I/O for power
* users with multi-year Withings imports. The 90-day floor is the
* widest window any Coach metric currently consumes.
* The snapshot now carries two sections per active metric:
* - aggregate: the v1.4.20 shape (mean, slope, SD, range, count)
* - timeline.recent: last `DAILY_TIMELINE_DAYS` days as raw daily
* values with weekday labels so the Coach can answer
* day/weekday-specific questions
* - timeline.weekly: ISO-week buckets covering the rest of the
* window so the Coach can still cite older weeks without ballooning
* the prompt
*/
const COACH_SNAPSHOT_WINDOW_DAYS = 90;
export async function buildCoachSnapshot(
userId: string,
scope?: CoachScope,
): Promise<CoachSnapshotResult> {
const { sources, window } = resolveScope(scope);
const windowDays = windowToDays(window);
const features = await extractFeatures(userId, false, {
sinceDays: COACH_SNAPSHOT_WINDOW_DAYS,
sinceDays: windowDays,
});
// Trim down to the metrics the Coach narrates. extractFeatures
@@ -50,45 +273,202 @@ export async function buildCoachSnapshot(
const metrics = new Set<CoachProvenance["metrics"][number]>();
const counts: NonNullable<CoachProvenance["counts"]> = {};
if (features.bloodPressure) {
snapshot.bloodPressure = features.bloodPressure;
// Pull raw measurement rows once for the configured window so day
// and week buckets share a single I/O hop. Mood + compliance live in
// separate tables and are loaded conditionally below.
const now = new Date();
const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
const recentCutoff = new Date(
now.getTime() - DAILY_TIMELINE_DAYS * 24 * 60 * 60 * 1000,
);
const wantsBp = sources.has("bp");
const wantsWeight = sources.has("weight");
const wantsPulse = sources.has("pulse");
const wantsMood = sources.has("mood");
const wantsCompliance = sources.has("compliance");
// Single fetch for all measurement types — Prisma's filter pushes
// the type list into one SQL `WHERE type IN (…)` so we don't pay
// per-metric round-trips.
const wantedTypes: string[] = [];
if (wantsBp) wantedTypes.push("BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA");
if (wantsWeight) wantedTypes.push("WEIGHT");
if (wantsPulse) wantedTypes.push("PULSE");
const measurementRows =
wantedTypes.length > 0
? await prisma.measurement.findMany({
where: {
userId,
type: { in: wantedTypes as never[] },
measuredAt: { gte: cutoff },
},
orderBy: { measuredAt: "asc" },
select: { type: true, value: true, measuredAt: true },
})
: [];
const byType = (t: string) =>
measurementRows.filter((r) => r.type === t).map((r) => ({
measuredAt: r.measuredAt,
value: r.value,
}));
if (wantsBp && features.bloodPressure) {
const sysRows = byType("BLOOD_PRESSURE_SYS");
const diaRows = byType("BLOOD_PRESSURE_DIA");
const recentDaily = buildDailyBpRows(sysRows, diaRows, recentCutoff);
const olderSys = sysRows.filter((r) => r.measuredAt < recentCutoff);
const olderDia = diaRows.filter((r) => r.measuredAt < recentCutoff);
snapshot.bloodPressure = {
aggregate: features.bloodPressure,
timeline: {
recent: recentDaily,
weeklySys: bucketWeekly(olderSys),
weeklyDia: bucketWeekly(olderDia),
},
};
metrics.add("bp");
windows.add("last30days");
windows.add("last90days");
counts.bp = features.bloodPressure.coverage?.count ?? undefined;
}
if (features.weight) {
snapshot.weight = features.weight;
if (wantsWeight && features.weight) {
const rows = byType("WEIGHT");
snapshot.weight = {
aggregate: features.weight,
timeline: {
recent: buildDailyValueRows(rows, recentCutoff),
weekly: bucketWeekly(rows.filter((r) => r.measuredAt < recentCutoff)),
},
};
metrics.add("weight");
windows.add("last7days");
windows.add("last30days");
counts.weight = features.weight.coverage?.count ?? undefined;
}
if (features.pulse) {
snapshot.pulse = features.pulse;
if (wantsPulse && features.pulse) {
const rows = byType("PULSE");
snapshot.pulse = {
aggregate: features.pulse,
timeline: {
recent: buildDailyValueRows(rows, recentCutoff),
weekly: bucketWeekly(rows.filter((r) => r.measuredAt < recentCutoff)),
},
};
metrics.add("pulse");
windows.add("last7days");
windows.add("last30days");
windows.add("last90days");
counts.pulse = features.pulse.coverage?.count ?? undefined;
}
if (features.mood) {
snapshot.mood = features.mood;
if (wantsMood && features.mood) {
// Mood entries live on a separate model. Pull only the recent
// window for the day-level rows + bucket the rest.
const moodRows = await prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: cutoff } },
orderBy: { moodLoggedAt: "asc" },
select: { moodLoggedAt: true, score: true },
});
const normalised = moodRows.map((m) => ({
measuredAt: m.moodLoggedAt,
value: m.score,
}));
snapshot.mood = {
aggregate: features.mood,
timeline: {
recent: buildDailyValueRows(normalised, recentCutoff),
weekly: bucketWeekly(
normalised.filter((r) => r.measuredAt < recentCutoff),
),
},
};
metrics.add("mood");
windows.add("last7days");
windows.add("last30days");
counts.mood = features.mood.coverage?.count ?? undefined;
}
// Medication compliance lives outside the structured features today;
// it is surfaced via a separate route. Mark it as "general" provenance
// so the chip row indicates "no compliance data in the snapshot" when
// the Coach is asked about it. Future iteration can pull a compliance
// window into the snapshot directly.
if (wantsCompliance) {
// Medication compliance lives outside the structured features
// today — the legacy Coach surface labelled it as "general"
// provenance only. v1.4.20.1 ships a per-day adherence row built
// from the intake-event log so the Coach can answer "did I miss
// my dose on Tuesday?" without inventing the schedule.
const intakeRows = await prisma.medicationIntakeEvent.findMany({
where: { userId, scheduledFor: { gte: cutoff } },
orderBy: { scheduledFor: "asc" },
select: { scheduledFor: true, takenAt: true, skipped: true },
});
if (intakeRows.length > 0) {
// Per-day adherence rate within the recent window. Older days
// collapse into a single weekly bucket.
const recent = intakeRows.filter((r) => r.scheduledFor >= recentCutoff);
const olderRows = intakeRows.filter(
(r) => r.scheduledFor < recentCutoff,
);
const recentByDay = new Map<
string,
{ date: Date; total: number; taken: number }
>();
for (const r of recent) {
const key = utcDayKey(r.scheduledFor);
const e = recentByDay.get(key) ?? {
date: r.scheduledFor,
total: 0,
taken: 0,
};
e.total += 1;
if (r.takenAt && !r.skipped) e.taken += 1;
recentByDay.set(key, e);
}
const recentRows = Array.from(recentByDay.entries())
.map(([date, info]) => ({
date,
weekday: utcWeekday(info.date),
rate: Math.round((info.taken / info.total) * 100) / 100,
taken: info.taken,
total: info.total,
}))
.sort((a, b) => a.date.localeCompare(b.date));
const olderByWeek = new Map<string, { taken: number; total: number }>();
for (const r of olderRows) {
const key = isoWeekKey(r.scheduledFor);
const e = olderByWeek.get(key) ?? { taken: 0, total: 0 };
e.total += 1;
if (r.takenAt && !r.skipped) e.taken += 1;
olderByWeek.set(key, e);
}
const weeklyRows = Array.from(olderByWeek.entries())
.map(([weekISO, v]) => ({
weekISO,
rate: v.total > 0 ? Math.round((v.taken / v.total) * 100) / 100 : 0,
taken: v.taken,
total: v.total,
}))
.sort((a, b) => a.weekISO.localeCompare(b.weekISO));
snapshot.compliance = {
timeline: { recent: recentRows, weekly: weeklyRows },
};
metrics.add("compliance");
counts.compliance = intakeRows.length;
}
}
if (Object.keys(snapshot).length === 0) {
metrics.add("general");
}
// Pin the scope onto the snapshot itself so the model knows which
// windows + sources are in-bounds for the reply. The system prompt
// tells the model to read from this block for day-level questions.
snapshot.scope = {
window,
sources: Array.from(sources),
timelineRecentDays: DAILY_TIMELINE_DAYS,
};
return {
snapshotJson: JSON.stringify(snapshot, null, 2),
provenance: {
+34
View File
@@ -30,11 +30,45 @@ export type CoachMessageRole = z.infer<typeof coachMessageRoleSchema>;
* - `locale` lets the route render the refusal copy in the user's
* language without having to re-resolve it from cookies.
*/
/**
* v1.4.20.1 — optional scope picker shipped with the per-source toggles
* + window selector on the Coach drawer's sources rail. The body lets
* the user narrow which metrics the snapshot ships and which window the
* timeline covers. Server defaults fill in any missing field so older
* native clients keep working — the field is fully back-compat.
*/
export const coachScopeWindowSchema = z.enum([
"last7days",
"last30days",
"last90days",
"allTime",
]);
export const coachScopeSourceSchema = z.enum([
"bp",
"weight",
"pulse",
"mood",
"compliance",
]);
export const coachScopeSchema = z.object({
/** Which sources the snapshot may include. Empty array → no metrics. */
sources: z.array(coachScopeSourceSchema).max(5).optional(),
/** Window the day-level timeline covers. Defaults to last30days. */
window: coachScopeWindowSchema.optional(),
});
export type CoachScope = z.infer<typeof coachScopeSchema>;
export type CoachScopeSource = z.infer<typeof coachScopeSourceSchema>;
export type CoachScopeWindow = z.infer<typeof coachScopeWindowSchema>;
export const coachChatRequestSchema = z.object({
conversationId: z.string().min(1).max(64).optional(),
message: z.string().min(1).max(4000),
prefill: z.string().max(2000).optional(),
locale: z.enum(["en", "de"]).optional(),
scope: coachScopeSchema.optional(),
});
export type CoachChatRequest = z.infer<typeof coachChatRequestSchema>;