mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The top of an answer is one line that says what the Coach is doing right now (thinking, fetching a metric, summarising the readings, writing), live from the activity frames, with the seconds so far. It never opens by itself: the trail stays closed while the turn runs, when it asks, when it stops early, after the answer and after a reload, and opens only on a tap. Once the answer is in, the line settles into one summary of lookups and seconds. A table read during the turn shows as a one-line preview with a sparkline until the answer takes it over. Clarifying choices, follow-ups and the answers to a fact or plan proposal share one pattern: quiet pills in the answer's column whose tap sends the label as the person's own message. The clarification card and the in-thread plan card are gone. A fact the Coach kept is one meta line with Undo, an assumption one line with Change. The remember button on a message now keeps it in the Coach's memory.
250 lines
8.5 KiB
TypeScript
250 lines
8.5 KiB
TypeScript
/**
|
|
* A Coach series read over a long window on an account with years of scale
|
|
* data must hand the model numbers.
|
|
*
|
|
* Inside a chat turn every tool read lands on the turn's shared full-source
|
|
* snapshot when its window matches the turn's. On an account with many synced
|
|
* series that snapshot passes the prompt budget, and the budget pass collapses
|
|
* the body-composition blocks first to a bare unit and then to an
|
|
* `{ omitted }` marker. The tool used to slice that marker out and answer
|
|
* `present: true` with it, so the model was told the readings exist and got
|
|
* no figure: "the data are there, but the numbers were not handed to me".
|
|
*
|
|
* The account here holds four years of daily scale readings (every body
|
|
* composition type a scale reports) plus a year of steps and pulse, which is
|
|
* enough for the full-source build to collapse those blocks. The tool must
|
|
* still return the series with its values, and the step the person sees must
|
|
* name what was read.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
|
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
|
|
import {
|
|
__resetCoachSnapshotCacheForTests,
|
|
buildCoachSnapshot,
|
|
} from "@/lib/ai/coach/snapshot";
|
|
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
|
|
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
|
import { toStep } from "@/lib/ai/coach/turn/steps";
|
|
import { describeStep } from "@/components/insights/coach-panel/turn-activity";
|
|
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
|
import { pluralKey } from "@/lib/i18n/plural";
|
|
import type { MeasurementType } from "@/generated/prisma/client";
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
vi.mock("@/lib/jobs/boss-instance", () => ({
|
|
getGlobalBoss: vi.fn(() => null),
|
|
}));
|
|
|
|
const DAY = 86_400_000;
|
|
const YEARS = 4;
|
|
|
|
const SCALE: Array<{
|
|
type: MeasurementType;
|
|
unit: string;
|
|
value: (d: number) => number;
|
|
}> = [
|
|
{ type: "WEIGHT", unit: "kg", value: (d) => 84 - d * 0.002 },
|
|
{ type: "BODY_FAT", unit: "%", value: (d) => 24 - d * 0.001 },
|
|
{ type: "FAT_MASS", unit: "kg", value: (d) => 20.1 - d * 0.001 },
|
|
{ type: "FAT_FREE_MASS", unit: "kg", value: (d) => 63.4 + (d % 5) * 0.1 },
|
|
{ type: "MUSCLE_MASS", unit: "kg", value: (d) => 60.2 + (d % 7) * 0.1 },
|
|
{ type: "LEAN_BODY_MASS", unit: "kg", value: (d) => 64.1 + (d % 3) * 0.1 },
|
|
{ type: "BONE_MASS", unit: "kg", value: (d) => 3.2 + (d % 2) * 0.1 },
|
|
{ type: "TOTAL_BODY_WATER", unit: "kg", value: (d) => 46.3 + (d % 4) * 0.1 },
|
|
{ type: "BODY_MASS_INDEX", unit: "kg/m2", value: (d) => 26 - d * 0.0005 },
|
|
{ type: "VISCERAL_FAT", unit: "", value: (d) => 9 + (d % 3) },
|
|
];
|
|
|
|
let userId = "";
|
|
|
|
function call(
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
sharedScope?: {
|
|
sources?: string[];
|
|
window?: string;
|
|
},
|
|
) {
|
|
return executeCoachTool({
|
|
userId,
|
|
name,
|
|
rawArguments: JSON.stringify(args),
|
|
fallbackWindow: "allTime",
|
|
sharedScope: sharedScope as never,
|
|
reach: UNBOUNDED_REACH,
|
|
});
|
|
}
|
|
|
|
/** Every finite number anywhere in a payload. */
|
|
function numbersIn(value: unknown): number[] {
|
|
if (typeof value === "number") return Number.isFinite(value) ? [value] : [];
|
|
if (Array.isArray(value)) return value.flatMap(numbersIn);
|
|
if (value && typeof value === "object") {
|
|
return Object.values(value).flatMap(numbersIn);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const prisma = getPrismaClient();
|
|
await truncateAllTables(prisma);
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "coach-series-long-window",
|
|
email: "coach-series-long-window@example.test",
|
|
timezone: "Europe/Berlin",
|
|
dateOfBirth: new Date("1980-05-01T00:00:00Z"),
|
|
heightCm: 180,
|
|
createdAt: new Date(Date.now() - (YEARS * 365 + 10) * DAY),
|
|
},
|
|
});
|
|
userId = user.id;
|
|
const now = Date.now();
|
|
const rows: Array<{
|
|
userId: string;
|
|
type: MeasurementType;
|
|
value: number;
|
|
unit: string;
|
|
source: "WITHINGS" | "APPLE_HEALTH";
|
|
measuredAt: Date;
|
|
externalId?: string;
|
|
}> = [];
|
|
for (let d = 0; d < YEARS * 365; d++) {
|
|
const at = new Date(now - d * DAY - 6 * 3_600_000);
|
|
for (const s of SCALE) {
|
|
rows.push({
|
|
userId,
|
|
type: s.type,
|
|
value: Math.round(s.value(d) * 10) / 10,
|
|
unit: s.unit,
|
|
source: "WITHINGS",
|
|
measuredAt: at,
|
|
});
|
|
}
|
|
}
|
|
for (let d = 1; d <= 365; d++) {
|
|
rows.push({
|
|
userId,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 6000 + ((d * 977) % 5000),
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: new Date(now - d * DAY),
|
|
externalId: `stats:HKQuantityTypeIdentifierStepCount:${d}`,
|
|
});
|
|
for (const [type, unit, base] of [
|
|
["PULSE", "bpm", 62],
|
|
["RESTING_HEART_RATE", "bpm", 54],
|
|
["HEART_RATE_VARIABILITY", "ms", 41],
|
|
["ACTIVE_ENERGY_BURNED", "kcal", 480],
|
|
["WALKING_RUNNING_DISTANCE", "km", 5],
|
|
["WALKING_SPEED", "km/h", 4.8],
|
|
["RESPIRATORY_RATE", "breaths/min", 14],
|
|
["OXYGEN_SATURATION", "%", 96],
|
|
] as const) {
|
|
rows.push({
|
|
userId,
|
|
type,
|
|
value: base + (d % 5),
|
|
unit,
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: new Date(now - d * DAY - 9 * 3_600_000),
|
|
});
|
|
}
|
|
}
|
|
for (let i = 0; i < rows.length; i += 5_000) {
|
|
await prisma.measurement.createMany({ data: rows.slice(i, i + 5_000) });
|
|
}
|
|
await recomputeUserRollups(userId, {
|
|
from: new Date(now - (YEARS * 365 + 5) * DAY),
|
|
to: new Date(now),
|
|
});
|
|
}, 240_000);
|
|
|
|
afterAll(() => {
|
|
__resetCoachSnapshotCacheForTests();
|
|
});
|
|
|
|
describe("a body-composition series over all time inside a chat turn", () => {
|
|
it("collapses the scale blocks in the shared full-source build (the precondition)", async () => {
|
|
__resetCoachSnapshotCacheForTests();
|
|
const inventory = await buildCoachDataInventory(
|
|
userId,
|
|
{ window: "allTime" },
|
|
UNBOUNDED_REACH,
|
|
);
|
|
const shared = await buildCoachSnapshot(userId, inventory.probeScope, {
|
|
reach: UNBOUNDED_REACH,
|
|
});
|
|
// Without this the test below would not exercise the defect at all.
|
|
expect(numbersIn(shared.sections.fatMass)).toEqual([]);
|
|
});
|
|
|
|
it.each(["fat_mass", "body_fat", "weight", "muscle_mass"] as const)(
|
|
"%s returns its values, not a bare 'present'",
|
|
async (metric) => {
|
|
__resetCoachSnapshotCacheForTests();
|
|
const inventory = await buildCoachDataInventory(
|
|
userId,
|
|
{ window: "allTime" },
|
|
UNBOUNDED_REACH,
|
|
);
|
|
const result = await call(
|
|
"get_metric_series",
|
|
{ metric, window: "allTime" },
|
|
inventory.probeScope,
|
|
);
|
|
expect(result.present).toBe(true);
|
|
const data = result.data as { section: Record<string, unknown> };
|
|
expect(data.section).not.toHaveProperty("omitted");
|
|
const timeline = data.section.timeline as
|
|
{ recent?: unknown[]; weekly?: unknown[] } | undefined;
|
|
expect(timeline?.recent?.length ?? 0).toBeGreaterThan(0);
|
|
expect(timeline?.weekly?.length ?? 0).toBeGreaterThan(40);
|
|
expect(numbersIn(data.section).length).toBeGreaterThan(100);
|
|
// A long window says what it holds and where the rest is.
|
|
expect((result.data as { coverage?: string }).coverage).toContain(
|
|
"get_metric_table",
|
|
);
|
|
},
|
|
);
|
|
|
|
it("labels the step with the domain and the window, separated cleanly", async () => {
|
|
__resetCoachSnapshotCacheForTests();
|
|
const inventory = await buildCoachDataInventory(
|
|
userId,
|
|
{ window: "allTime" },
|
|
UNBOUNDED_REACH,
|
|
);
|
|
const args = { metric: "fat_mass", window: "allTime" };
|
|
const result = await call("get_metric_series", args, inventory.probeScope);
|
|
const step = toStep({
|
|
call: {
|
|
id: "c1",
|
|
name: "get_metric_series",
|
|
arguments: JSON.stringify(args),
|
|
},
|
|
index: 0,
|
|
parsedArgs: args,
|
|
result,
|
|
locale: "de",
|
|
fallbackWindow: "allTime",
|
|
});
|
|
expect(step).not.toBeNull();
|
|
expect(step!.status).toBe("done");
|
|
expect(step!.label).toBe("Prüfe: Fettmasse, gesamter Zeitraum");
|
|
const { t } = getServerTranslator("de");
|
|
const tCount = (base: string, count: number) =>
|
|
t(pluralKey(base, count, "de"), { count });
|
|
const { title, meta } = describeStep(step!, t, tCount);
|
|
expect([title, ...meta].join(" · ")).toMatch(
|
|
/^Fettmasse · gesamter Zeitraum · \d+ Messwerte$/,
|
|
);
|
|
});
|
|
});
|