Files
HealthLog/tests/integration/coach-metric-table.test.ts
T
Marc-André Bombeck a967ccac44 feat(coach): metric tables and show_result for the chat tools
get_metric_table reads one metric as days, weeks or months through the
chart reader, cut in the user's zone and collapsed to one source per day,
with null for a period without a reading. The model reads a bounded
summary; the full table rides the turn, encrypted at rest. show_result
shows a table an earlier reply of the same conversation fetched, resolved
only against that conversation's own messages. Workouts, labs, sleep and
adherence results project into tables, earlier tables are named in the
context by metadata alone, and result:rN marks are stripped from the reply.
2026-09-27 11:54:30 +02:00

291 lines
9.0 KiB
TypeScript

/**
* v1.39.4 — a Coach result table holds the chart's numbers.
*
* Seeds real rows for a user east of UTC — readings either side of local
* midnight and a day two sources both measured — and pins that the table
* `get_metric_table` builds equals `readDailySeries` for the same window, day
* by day, and that its weeks are the chart's own weekly fold of those days.
* Then pins that `show_result` reaches a stored table only inside the
* conversation it was written to.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { readDailySeries } from "@/lib/measurements/daily-series-read";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { bucketTimeSeries } from "@/lib/charts/bucket-time-series";
import { userDayKey } from "@/lib/tz/format";
import {
readMetricTable,
resolveTableRange,
} from "@/lib/ai/coach/results/metric-table-tool";
import { createResultRefAllocator } from "@/lib/ai/coach/results/refs";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
import { appendMessage, createConversation } from "@/lib/ai/coach/persistence";
import type { CoachResultTable } from "@/lib/ai/coach/types";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(() => null),
}));
// The snapshot the tool asks for its gate needs no feature extraction.
vi.mock("@/lib/insights/features", () => ({
extractFeatures: vi.fn(async () => ({})),
}));
const TZ = "Pacific/Auckland";
// 13:00 on 15 March in Auckland (NZDT, UTC+13).
const NOW = new Date("2026-03-15T00:00:00.000Z");
let seq = 0;
async function seedUser() {
seq += 1;
return getPrismaClient().user.create({
data: {
username: `metric-table-${seq}`,
email: `metric-table-${seq}@example.test`,
role: "USER",
timezone: TZ,
},
});
}
async function seedPulse(
userId: string,
rows: Array<{
at: string;
value: number;
source?: "MANUAL" | "WITHINGS" | "APPLE_HEALTH";
}>,
) {
await getPrismaClient().measurement.createMany({
data: rows.map((r) => ({
userId,
type: "PULSE" as const,
value: r.value,
unit: "bpm",
source: r.source ?? "MANUAL",
measuredAt: new Date(r.at),
})),
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("get_metric_table — the chart's numbers", () => {
it("equals readDailySeries day by day, across local midnight and duplicate sources", async () => {
const user = await seedUser();
await seedPulse(user.id, [
// 23:30 on 10 March in Auckland — still the 10th there.
{ at: "2026-03-10T10:30:00.000Z", value: 70 },
// 00:30 on 11 March in Auckland — the 10th in UTC, the 11th locally.
{ at: "2026-03-10T11:30:00.000Z", value: 90 },
// Two sources measured the 12th: the table keeps one, not their mean.
{ at: "2026-03-11T19:00:00.000Z", value: 60, source: "MANUAL" },
{ at: "2026-03-11T19:05:00.000Z", value: 80, source: "WITHINGS" },
// Early in the window, and outside it.
{ at: "2026-02-15T20:00:00.000Z", value: 66 },
{ at: "2026-02-01T20:00:00.000Z", value: 99 },
]);
const table = await readMetricTable({
userId: user.id,
metric: "pulse",
window: "last30days",
period: "current",
granularity: "day",
timeZone: TZ,
locale: "en",
ref: "r1",
now: NOW,
});
expect(table).not.toBeNull();
const range = resolveTableRange({
window: "last30days",
period: "current",
timeZone: TZ,
now: NOW,
});
const chart = await readDailySeries({
userId: user.id,
type: "PULSE",
from: range.from,
to: range.to,
priorityJson: await loadUserSourcePriority(user.id),
timeZone: TZ,
});
const chartByDay = new Map(
chart.map((row) => [userDayKey(new Date(row.measuredAt), TZ), row.value]),
);
expect(table!.rows).toHaveLength(30);
for (const row of table!.rows) {
expect(row[1], String(row[0])).toBe(
chartByDay.get(row[0] as string) ?? null,
);
}
const byDay = new Map(table!.rows.map((row) => [row[0], row]));
expect(byDay.get("2026-03-10")?.[1]).toBe(70);
expect(byDay.get("2026-03-11")?.[1]).toBe(90);
expect([60, 80]).toContain(byDay.get("2026-03-12")?.[1]);
expect(byDay.get("2026-03-12")?.[2]).toBe(1);
expect(byDay.get("2026-03-13")?.[1]).toBeNull();
expect(byDay.has("2026-02-02")).toBe(false);
});
it("folds weeks exactly as the chart folds the same days", async () => {
const user = await seedUser();
const rows = Array.from({ length: 80 }, (_, i) => ({
at: new Date(
NOW.getTime() - i * 86_400_000 - 3 * 3_600_000,
).toISOString(),
value: 55 + (i % 11),
}));
await seedPulse(user.id, rows);
const table = await readMetricTable({
userId: user.id,
metric: "pulse",
window: "last90days",
period: "current",
granularity: "week",
timeZone: TZ,
locale: "en",
ref: "r1",
now: NOW,
});
const range = resolveTableRange({
window: "last90days",
period: "current",
timeZone: TZ,
now: NOW,
});
const days = await readDailySeries({
userId: user.id,
type: "PULSE",
from: range.from,
to: range.to,
priorityJson: null,
timeZone: TZ,
});
const weeks = bucketTimeSeries(
days.map((row) => ({
timestamp: new Date(row.measuredAt),
values: { PULSE: row.value },
})),
{ bucket: "week", timeZone: TZ },
);
const byWeek = new Map(table!.rows.map((row) => [row[0], row[1]]));
expect(weeks.points.length).toBeGreaterThan(10);
for (const point of weeks.points) {
const monday = new Date(point.timestamp).toISOString().slice(0, 10);
expect(byWeek.get(monday), monday).toBe(point.values.PULSE);
}
});
it("serves the same table through the tool, named for the turn", async () => {
const user = await seedUser();
await seedPulse(user.id, [{ at: "2026-03-14T20:00:00.000Z", value: 64 }]);
const conversation = await createConversation({
userId: user.id,
title: "pulse",
});
const result = await executeCoachTool({
userId: user.id,
name: "get_metric_table",
rawArguments: JSON.stringify({ metric: "pulse", window: "last7days" }),
turn: {
conversationId: conversation.id,
locale: "en",
priorResults: [],
refs: createResultRefAllocator(),
now: NOW,
},
});
expect(result.present).toBe(true);
expect(result.resultRef).toBe("r1");
expect(result.table?.rows.at(-1)).toEqual(["2026-03-15", 64, 1]);
expect(JSON.stringify(result.data)).not.toContain("rowsNote");
});
});
describe("show_result — only within the conversation", () => {
it("resolves a stored table of this conversation and nothing of another", async () => {
const user = await seedUser();
const other = await seedUser();
await seedPulse(user.id, [{ at: "2026-03-14T20:00:00.000Z", value: 64 }]);
const table = (await readMetricTable({
userId: user.id,
metric: "pulse",
window: "last7days",
period: "current",
granularity: "day",
timeZone: TZ,
locale: "en",
ref: "r1",
now: NOW,
})) as CoachResultTable;
const {
chart: _chart,
columns: _c,
rows: _r,
truncated: _t,
...meta
} = table;
const home = await createConversation({ userId: user.id, title: "a" });
const elsewhere = await createConversation({ userId: user.id, title: "b" });
const foreign = await createConversation({ userId: other.id, title: "c" });
const stored = await appendMessage({
conversationId: home.id,
role: "assistant",
content: "Here is your pulse.",
metricSource: { windows: [], metrics: [], results: [meta] },
results: [table],
});
const prior = [{ messageId: stored.id, turnIndex: 1, results: [meta] }];
const run = (conversationId: string, userId = user.id) =>
executeCoachTool({
userId,
name: "show_result",
rawArguments: JSON.stringify({ ref: "m1.r1" }),
turn: {
conversationId,
locale: "en",
priorResults: prior,
refs: createResultRefAllocator(),
now: NOW,
},
});
const shown = await run(home.id);
expect(shown.present).toBe(true);
expect(shown.table?.rows).toEqual(table.rows);
expect(shown.table?.reusedFrom).toEqual({
messageId: stored.id,
ref: "r1",
});
// The same name, handed to a turn of another conversation of the same
// account, or of another account, reaches nothing.
expect(await run(elsewhere.id)).toEqual({
present: false,
reason: "unknown_result",
});
expect(await run(foreign.id, other.id)).toEqual({
present: false,
reason: "unknown_result",
});
});
});