mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
1 parent
7921ffc8e6
commit
ed61b17f57
4 files changed
+581
-24
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user