mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(coach): give a series read its numbers when the shared build cut its block
Inside a chat turn every tool read lands on the turn's shared full-source
snapshot when its window matches. On an account with many synced series
that build passes the prompt budget, and the budget pass collapses blocks
to a bare unit and then to an `{ omitted }` marker. get_metric_series
sliced the marker out and answered `present: true` with it, so the model
was told the readings exist and was given no figure. The same held for
sleep, glucose, compliance and workouts.
- The snapshot result names the blocks the budget pass cut. A tool whose
block was cut in the shared build reads it again from its own source at
the same window.
- A read scoped to one source condenses its own blocks last and never
swaps them for the marker: it keeps a summary (first, last, min, max,
mean, change) taken before anything is cut, the newest daily values,
weekly means folded into monthly means, and names each cut.
- get_metric_series never answers present with a block holding no figure;
that read reports retrieval_failed instead.
- A chat series read carries the readings it counted, so the step shows
them, and on a long or condensed window a note on what it covers. The
tool description and a new tool-mode rule tell the model to call
get_metric_table itself for longer history instead of asking.
- The screen-reader comma between step parts is unselectable, so a copied
row no longer reads "Weight · ,all time".
MCP output is unchanged: the source-snapshot equality suite stays green.
This commit is contained in:
1 parent
6cea4be2a1
commit
ac48e0d0ed
10 files changed
+1049
-46
No files matched your search
@@ -243,7 +243,10 @@ describe("<CoachTurnStepList>", () => {
|
||||
expect(html).toContain('data-status="done"');
|
||||
expect(html).toContain('data-status="empty"');
|
||||
expect(html).toContain('data-status="running"');
|
||||
let text = html.replace(/<span class="sr-only">, <\/span>/g, "");
|
||||
let text = html.replace(
|
||||
/<span class="sr-only select-none">, <\/span>/g,
|
||||
"",
|
||||
);
|
||||
// Strip tags until nothing changes, so no fragment can survive one pass.
|
||||
for (let prev = ""; prev !== text;) {
|
||||
prev = text;
|
||||
@@ -349,3 +352,27 @@ describe("legacyAreaLabels", () => {
|
||||
).toContain("1 Bereich angesehen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a step row copied out of the list", () => {
|
||||
it("reads 'domain · window' without the screen reader's comma", () => {
|
||||
const html = render(
|
||||
<CoachTurnStepList
|
||||
steps={[{ ...BP, domain: "weight", count: undefined }]}
|
||||
active={false}
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
// What a selection copies: every text node except the unselectable ones.
|
||||
const copied = html
|
||||
.replace(
|
||||
/<span[^>]*class="[^"]*\bselect-none\b[^"]*"[^>]*>[^<]*<\/span>/g,
|
||||
"",
|
||||
)
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
expect(copied).toBe("Gewicht · letzte 90 Tage");
|
||||
// The comma is still there for a screen reader.
|
||||
expect(html).toMatch(/class="sr-only select-none">, <\/span>/);
|
||||
});
|
||||
});
|
||||
@@ -314,7 +314,10 @@ export function CoachTurnStepList({
|
||||
{meta.map((part, i) => (
|
||||
<span key={i}>
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span className="sr-only">, </span>
|
||||
{/* The comma is for a screen reader only. `select-none`
|
||||
keeps it out of a copied row, which otherwise read
|
||||
"Weight · ,all time". */}
|
||||
<span className="sr-only select-none">, </span>
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* A series read never answers "present" without numbers.
|
||||
*
|
||||
* Two layers: the condensing a requested block goes through when its own
|
||||
* read passes the prompt budget (`series-condense.ts`), and the executor,
|
||||
* which re-reads a block the turn's shared full-source build cut for its
|
||||
* prompt, refuses to hand on a block with no figure in it, and tells the
|
||||
* model where the rest of a long history is.
|
||||
*/
|
||||
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
|
||||
import {
|
||||
KEEP_DAILY_ROWS,
|
||||
KEEP_MONTHLY_ROWS,
|
||||
condenseSeriesBlock,
|
||||
isoWeekMonth,
|
||||
summarisePoints,
|
||||
weeklyToMonthly,
|
||||
} from "@/lib/ai/coach/series-condense";
|
||||
|
||||
const buildCoachSnapshot =
|
||||
vi.fn<(userId: string, scope?: unknown) => Promise<CoachSnapshotResult>>();
|
||||
vi.mock("@/lib/ai/coach/snapshot", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
buildCoachSnapshot: (userId: string, scope?: unknown) =>
|
||||
buildCoachSnapshot(userId, scope),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
groupBy: () => Promise.resolve([]),
|
||||
findMany: () => Promise.resolve([]),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
||||
import { buildToolModeAddendum } from "@/lib/ai/coach/tools/system-addendum";
|
||||
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
|
||||
|
||||
function weeks(count: number, from = 2023, value = (i: number) => 20 + i / 10) {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const year = from + Math.floor(i / 52);
|
||||
const week = (i % 52) + 1;
|
||||
return {
|
||||
weekISO: `${year}-W${String(week).padStart(2, "0")}`,
|
||||
mean: Math.round(value(i) * 10) / 10,
|
||||
count: 7,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function recent(count: number, value = (i: number) => 30 + i) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
date: `2026-09-${String(i + 1).padStart(2, "0")}`,
|
||||
weekday: "Mon",
|
||||
value: value(i),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("series-condense", () => {
|
||||
it("places an ISO week in the month of its Thursday", () => {
|
||||
expect(isoWeekMonth("2026-W01")).toBe("2026-01");
|
||||
expect(isoWeekMonth("2025-W01")).toBe("2025-01"); // Thu 2 Jan 2025
|
||||
expect(isoWeekMonth("2026-W53")).toBe("2026-12");
|
||||
expect(isoWeekMonth("2026-W05")).toBe("2026-01"); // Thu 29 Jan
|
||||
expect(isoWeekMonth("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("folds weekly means into count-weighted monthly means", () => {
|
||||
expect(
|
||||
weeklyToMonthly([
|
||||
{ weekISO: "2026-W01", mean: 10, count: 1 },
|
||||
{ weekISO: "2026-W02", mean: 20, count: 3 },
|
||||
{ weekISO: "2026-W06", mean: 5, count: 2 },
|
||||
]),
|
||||
).toEqual([
|
||||
{ month: "2026-01", mean: 17.5, count: 4 },
|
||||
{ month: "2026-02", mean: 5, count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("summarises points in time order", () => {
|
||||
expect(
|
||||
summarisePoints([
|
||||
{ at: "2026-09-02", value: 18 },
|
||||
{ at: "2025-W10", value: 21 },
|
||||
{ at: "2026-09-01", value: 17 },
|
||||
]),
|
||||
).toEqual({
|
||||
from: "2025-W10",
|
||||
to: "2026-09-02",
|
||||
first: 21,
|
||||
last: 18,
|
||||
min: 17,
|
||||
max: 21,
|
||||
mean: 18.67,
|
||||
change: -3,
|
||||
points: 3,
|
||||
});
|
||||
expect(summarisePoints([])).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps numbers through every step and names what it cut", () => {
|
||||
const block = {
|
||||
unit: "kg",
|
||||
timeline: { recent: recent(14), weekly: weeks(150) },
|
||||
};
|
||||
expect(condenseSeriesBlock(block, 1)).toBe(true);
|
||||
expect(block.timeline.recent).toHaveLength(KEEP_DAILY_ROWS);
|
||||
expect(block.timeline.recent.at(-1)?.value).toBe(43);
|
||||
expect(condenseSeriesBlock(block, 2)).toBe(true);
|
||||
expect(block.timeline).not.toHaveProperty("weekly");
|
||||
const monthly = (block.timeline as { monthly?: unknown[] }).monthly;
|
||||
expect(monthly!.length).toBeGreaterThan(30);
|
||||
expect(condenseSeriesBlock(block, 3)).toBe(true);
|
||||
expect((block.timeline as { monthly?: unknown[] }).monthly).toHaveLength(
|
||||
KEEP_MONTHLY_ROWS,
|
||||
);
|
||||
// The summary was taken before anything was cut: every week and day.
|
||||
const summary = (block as { summary?: Record<string, unknown> }).summary;
|
||||
expect(summary).toMatchObject({
|
||||
from: "2023-W01",
|
||||
to: "2026-09-14",
|
||||
first: 20,
|
||||
last: 43,
|
||||
points: 164,
|
||||
});
|
||||
expect((block as { condensed?: string[] }).condensed).toEqual([
|
||||
`daily values: newest ${KEEP_DAILY_ROWS} days kept`,
|
||||
"weekly means folded into monthly means",
|
||||
`monthly means: newest ${KEEP_MONTHLY_ROWS} months kept`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("summarises blood pressure per series", () => {
|
||||
const block = {
|
||||
timeline: {
|
||||
recent: [{ date: "2026-09-01", weekday: "Tue", sys: 121, dia: 79 }],
|
||||
weeklySys: [{ weekISO: "2026-W30", mean: 130, count: 4 }],
|
||||
weeklyDia: [{ weekISO: "2026-W30", mean: 85, count: 4 }],
|
||||
},
|
||||
};
|
||||
condenseSeriesBlock(block, 2);
|
||||
expect(block).toMatchObject({
|
||||
summary: {
|
||||
sys: { first: 130, last: 121, change: -9 },
|
||||
dia: { first: 85, last: 79, change: -6 },
|
||||
},
|
||||
timeline: {
|
||||
monthlySys: [{ month: "2026-07", mean: 130, count: 4 }],
|
||||
monthlyDia: [{ month: "2026-07", mean: 85, count: 4 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a block without a timeline alone", () => {
|
||||
const block = { aggregate: { mean: 1 } };
|
||||
expect(condenseSeriesBlock(block, 1)).toBe(false);
|
||||
expect(block).toEqual({ aggregate: { mean: 1 } });
|
||||
});
|
||||
});
|
||||
|
||||
function snapshot(
|
||||
sections: Record<string, unknown>,
|
||||
extra: Partial<CoachSnapshotResult> = {},
|
||||
): CoachSnapshotResult {
|
||||
return {
|
||||
snapshotJson: JSON.stringify(sections),
|
||||
sections,
|
||||
provenance: { windows: [], metrics: [] },
|
||||
referenceGrounding: null,
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const SHARED = { sources: ["weight", "fat_mass", "steps"], window: "allTime" };
|
||||
|
||||
function series(args: Record<string, unknown>) {
|
||||
return executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_metric_series",
|
||||
rawArguments: JSON.stringify(args),
|
||||
fallbackWindow: "allTime",
|
||||
sharedScope: SHARED as never,
|
||||
reach: UNBOUNDED_REACH,
|
||||
});
|
||||
}
|
||||
|
||||
const FAT_MASS = {
|
||||
unit: "kg",
|
||||
timeline: { recent: recent(14, () => 18.2), weekly: weeks(50) },
|
||||
};
|
||||
|
||||
describe("get_metric_series inside a chat turn", () => {
|
||||
beforeEach(() => {
|
||||
buildCoachSnapshot.mockReset();
|
||||
});
|
||||
|
||||
it("re-reads a block the shared build cut, from its own source", async () => {
|
||||
buildCoachSnapshot.mockImplementation(async (_u, scope) =>
|
||||
scope === SHARED
|
||||
? snapshot(
|
||||
{ fatMass: { omitted: "trimmed for prompt budget" } },
|
||||
{ degradedBlocks: ["fatMass", "steps"] },
|
||||
)
|
||||
: snapshot(
|
||||
{ fatMass: FAT_MASS },
|
||||
{
|
||||
provenance: {
|
||||
windows: [],
|
||||
metrics: ["fat_mass"],
|
||||
counts: { fat_mass: 1460 },
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
const result = await series({ metric: "fat_mass", window: "allTime" });
|
||||
expect(buildCoachSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(buildCoachSnapshot.mock.calls[1][1]).toEqual({
|
||||
sources: ["fat_mass"],
|
||||
window: "allTime",
|
||||
});
|
||||
expect(result.present).toBe(true);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.section).toEqual(FAT_MASS);
|
||||
expect(data.readings).toBe(1460);
|
||||
expect(data.coverage).toContain("call get_metric_table yourself");
|
||||
});
|
||||
|
||||
it("keeps the shared build when the block was not cut there", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(
|
||||
snapshot({ fatMass: FAT_MASS }, { degradedBlocks: ["steps"] }),
|
||||
);
|
||||
const result = await series({ metric: "fat_mass" });
|
||||
expect(buildCoachSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(result.present).toBe(true);
|
||||
});
|
||||
|
||||
it("never answers present with a block that holds no figure", async () => {
|
||||
for (const empty of [
|
||||
{ unit: "kg" },
|
||||
{ omitted: "trimmed for prompt budget" },
|
||||
]) {
|
||||
buildCoachSnapshot.mockReset();
|
||||
buildCoachSnapshot.mockResolvedValue(snapshot({ fatMass: empty }));
|
||||
const result = await series({ metric: "fat_mass", window: "last30days" });
|
||||
expect(result).toEqual({ present: false, reason: "retrieval_failed" });
|
||||
}
|
||||
});
|
||||
|
||||
it("states no coverage note on a short window", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(snapshot({ fatMass: FAT_MASS }));
|
||||
const result = await series({ metric: "fat_mass", window: "last30days" });
|
||||
expect(result.data).not.toHaveProperty("coverage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tool-mode rules", () => {
|
||||
it.each(["en", "de"] as const)(
|
||||
"tell the model to fetch the table itself (%s)",
|
||||
(locale) => {
|
||||
const text = buildToolModeAddendum(locale);
|
||||
expect(text).toMatch(/^7\. .*get_metric_table/m);
|
||||
expect(text).toContain("summary");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("the prompt-budget pass on a single-source read", () => {
|
||||
it("condenses the requested block last and never swaps it for a marker", async () => {
|
||||
const { degradeToBudget } = await vi.importActual<
|
||||
typeof import("@/lib/ai/coach/snapshot")
|
||||
>("@/lib/ai/coach/snapshot");
|
||||
const big = () => ({
|
||||
unit: "kg",
|
||||
timeline: { recent: recent(14), weekly: weeks(520, 2016) },
|
||||
});
|
||||
const snap: Record<string, unknown> = {
|
||||
fatMass: big(),
|
||||
steps: big(),
|
||||
walkingSpeed: big(),
|
||||
};
|
||||
const clusters = new Map([
|
||||
["fatMass", "body"],
|
||||
["steps", "activity"],
|
||||
["walkingSpeed", "mobility"],
|
||||
] as const);
|
||||
const degraded = degradeToBudget(
|
||||
snap,
|
||||
new Map(clusters),
|
||||
new Set(["fatMass"]),
|
||||
);
|
||||
expect(snap.steps).toEqual({ omitted: "trimmed for prompt budget" });
|
||||
expect(snap.walkingSpeed).toEqual({ omitted: "trimmed for prompt budget" });
|
||||
const fat = snap.fatMass as Record<string, unknown>;
|
||||
expect(fat).not.toHaveProperty("omitted");
|
||||
expect(fat.summary).toMatchObject({ points: 534, last: 43 });
|
||||
expect(degraded.map((d) => d.key).at(-1)).toBe("fatMass");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Condensing a requested series block to the prompt budget without emptying
|
||||
* it.
|
||||
*
|
||||
* The snapshot's budget pass sheds detail from a block in three steps: the
|
||||
* daily rows, then the weekly means, then the whole block for an
|
||||
* `{ omitted }` marker. For a block the read did not ask for that is right;
|
||||
* for the one it did ask for it leaves the model a block that says the
|
||||
* readings exist and holds no figure, and the model then says exactly that.
|
||||
*
|
||||
* A requested block is condensed instead. Before anything is cut it gains a
|
||||
* `summary` (first, last, min, max, mean and change over every point it held)
|
||||
* and each step after that keeps numbers:
|
||||
*
|
||||
* 1. the daily rows keep their newest `KEEP_DAILY_ROWS`;
|
||||
* 2. the weekly means fold into monthly means (count-weighted);
|
||||
* 3. the monthly means keep their newest `KEEP_MONTHLY_ROWS`, and the
|
||||
* coarse monthly band and anomaly envelope go (the yearly band stays).
|
||||
*
|
||||
* Every step it took is named in `condensed`, so the block says what it no
|
||||
* longer carries. Pure over its input: no I/O, no clock.
|
||||
*/
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
/** Daily rows a condensed block keeps, newest first in time order. */
|
||||
export const KEEP_DAILY_ROWS = 7;
|
||||
/** Monthly means a fully condensed block keeps. */
|
||||
export const KEEP_MONTHLY_ROWS = 6;
|
||||
|
||||
export interface SeriesSummary {
|
||||
/** Earliest point's period: a day (`YYYY-MM-DD`) or an ISO week. */
|
||||
from: string;
|
||||
/** Newest point's period. */
|
||||
to: string;
|
||||
first: number;
|
||||
last: number;
|
||||
min: number;
|
||||
max: number;
|
||||
/** Mean of the points (daily and weekly means alike), not of readings. */
|
||||
mean: number;
|
||||
/** `last - first`. */
|
||||
change: number;
|
||||
/** How many points the figures above were taken over. */
|
||||
points: number;
|
||||
}
|
||||
|
||||
interface WeeklyBucket {
|
||||
weekISO: string;
|
||||
mean: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MonthlyBucket {
|
||||
month: string;
|
||||
mean: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is Rec {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function isWeekly(value: unknown): value is WeeklyBucket[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(b) =>
|
||||
isRecord(b) &&
|
||||
typeof b.weekISO === "string" &&
|
||||
typeof b.mean === "number" &&
|
||||
typeof b.count === "number",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** ISO week `YYYY-Www` → the Monday that starts it, as `YYYY-MM-DD`. */
|
||||
function isoWeekMonday(weekISO: string): string | null {
|
||||
const match = /^(\d{4})-W(\d{2})$/.exec(weekISO);
|
||||
if (!match) return null;
|
||||
const jan4 = new Date(Date.UTC(Number(match[1]), 0, 4));
|
||||
const monday = new Date(jan4);
|
||||
monday.setUTCDate(
|
||||
jan4.getUTCDate() -
|
||||
(jan4.getUTCDay() || 7) +
|
||||
1 +
|
||||
(Number(match[2]) - 1) * 7,
|
||||
);
|
||||
return dateOnlyKey(monday);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sortable key for a point's period. A week and a day do not compare as
|
||||
* strings ("2026-W30" sorts after "2026-09-01"), so a week sorts by its
|
||||
* Monday.
|
||||
*/
|
||||
function timeKey(at: string): string {
|
||||
return isoWeekMonday(at) ?? at;
|
||||
}
|
||||
|
||||
/** Summary over points (any order), or null when there are none. */
|
||||
export function summarisePoints(
|
||||
points: ReadonlyArray<{ at: string; value: number }>,
|
||||
): SeriesSummary | null {
|
||||
const finite = points.filter((p) => Number.isFinite(p.value));
|
||||
if (finite.length === 0) return null;
|
||||
const sorted = [...finite].sort((a, b) =>
|
||||
timeKey(a.at).localeCompare(timeKey(b.at)),
|
||||
);
|
||||
const values = sorted.map((p) => p.value);
|
||||
const first = values[0];
|
||||
const last = values[values.length - 1];
|
||||
return {
|
||||
from: sorted[0].at,
|
||||
to: sorted[sorted.length - 1].at,
|
||||
first,
|
||||
last,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
mean: round(values.reduce((s, v) => s + v, 0) / values.length),
|
||||
change: round(last - first),
|
||||
points: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar month (`YYYY-MM`) an ISO week belongs to, by its Thursday —
|
||||
* the same day ISO 8601 uses to place a week in its year.
|
||||
*/
|
||||
export function isoWeekMonth(weekISO: string): string | null {
|
||||
const match = /^(\d{4})-W(\d{2})$/.exec(weekISO);
|
||||
if (!match) return null;
|
||||
const year = Number(match[1]);
|
||||
const week = Number(match[2]);
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4));
|
||||
const jan4Day = jan4.getUTCDay() || 7;
|
||||
const thursday = new Date(jan4);
|
||||
thursday.setUTCDate(jan4.getUTCDate() - jan4Day + 4 + (week - 1) * 7);
|
||||
return dateOnlyKey(thursday).slice(0, 7);
|
||||
}
|
||||
|
||||
/** Weekly means folded into count-weighted monthly means, in time order. */
|
||||
export function weeklyToMonthly(
|
||||
weekly: ReadonlyArray<WeeklyBucket>,
|
||||
): MonthlyBucket[] {
|
||||
const months = new Map<string, { sum: number; count: number }>();
|
||||
for (const bucket of weekly) {
|
||||
const month = isoWeekMonth(bucket.weekISO);
|
||||
if (!month || bucket.count <= 0) continue;
|
||||
const acc = months.get(month) ?? { sum: 0, count: 0 };
|
||||
acc.sum += bucket.mean * bucket.count;
|
||||
acc.count += bucket.count;
|
||||
months.set(month, acc);
|
||||
}
|
||||
return Array.from(months.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, { sum, count }]) => ({
|
||||
month,
|
||||
mean: Math.round((sum / count) * 10) / 10,
|
||||
count,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The value series a timeline carries: `value` over `weekly` for a single
|
||||
* series, `sys`/`dia` over `weeklySys`/`weeklyDia` for blood pressure.
|
||||
*/
|
||||
const SERIES: ReadonlyArray<{
|
||||
name: string | null;
|
||||
field: string;
|
||||
weekly: string;
|
||||
monthly: string;
|
||||
}> = [
|
||||
{ name: null, field: "value", weekly: "weekly", monthly: "monthly" },
|
||||
{ name: "sys", field: "sys", weekly: "weeklySys", monthly: "monthlySys" },
|
||||
{ name: "dia", field: "dia", weekly: "weeklyDia", monthly: "monthlyDia" },
|
||||
];
|
||||
|
||||
function blockSummary(timeline: Rec): unknown {
|
||||
const recent = Array.isArray(timeline.recent) ? timeline.recent : [];
|
||||
const out: Rec = {};
|
||||
for (const s of SERIES) {
|
||||
const points: Array<{ at: string; value: number }> = [];
|
||||
const weekly = timeline[s.weekly];
|
||||
if (isWeekly(weekly)) {
|
||||
for (const b of weekly) points.push({ at: b.weekISO, value: b.mean });
|
||||
}
|
||||
for (const row of recent) {
|
||||
if (isRecord(row) && typeof row.date === "string") {
|
||||
const value = row[s.field];
|
||||
if (typeof value === "number") points.push({ at: row.date, value });
|
||||
}
|
||||
}
|
||||
const summary = summarisePoints(points);
|
||||
if (summary) {
|
||||
if (s.name === null) return summary;
|
||||
out[s.name] = summary;
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function note(block: Rec, text: string): void {
|
||||
const list = Array.isArray(block.condensed) ? block.condensed : [];
|
||||
if (!list.includes(text)) list.push(text);
|
||||
block.condensed = list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take one condensing step on a block that carries a `timeline`. Returns
|
||||
* whether anything changed; a block without a timeline is left alone and
|
||||
* reports false.
|
||||
*/
|
||||
export function condenseSeriesBlock(block: unknown, step: 1 | 2 | 3): boolean {
|
||||
if (!isRecord(block) || !isRecord(block.timeline)) return false;
|
||||
const timeline = block.timeline;
|
||||
if (block.summary === undefined) {
|
||||
const summary = blockSummary(timeline);
|
||||
if (summary !== undefined) block.summary = summary;
|
||||
}
|
||||
let changed = false;
|
||||
if (step === 1) {
|
||||
if (
|
||||
Array.isArray(timeline.recent) &&
|
||||
timeline.recent.length > KEEP_DAILY_ROWS
|
||||
) {
|
||||
timeline.recent = timeline.recent.slice(-KEEP_DAILY_ROWS);
|
||||
note(block, `daily values: newest ${KEEP_DAILY_ROWS} days kept`);
|
||||
changed = true;
|
||||
}
|
||||
} else if (step === 2) {
|
||||
for (const s of SERIES) {
|
||||
const weekly = timeline[s.weekly];
|
||||
if (!isWeekly(weekly)) continue;
|
||||
timeline[s.monthly] = weeklyToMonthly(weekly);
|
||||
delete timeline[s.weekly];
|
||||
note(block, "weekly means folded into monthly means");
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
for (const s of SERIES) {
|
||||
const monthly = timeline[s.monthly];
|
||||
if (Array.isArray(monthly) && monthly.length > KEEP_MONTHLY_ROWS) {
|
||||
timeline[s.monthly] = monthly.slice(-KEEP_MONTHLY_ROWS);
|
||||
note(block, `monthly means: newest ${KEEP_MONTHLY_ROWS} months kept`);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
const coarse = timeline.coarse;
|
||||
if (isRecord(coarse) && ("monthly" in coarse || "anomalies" in coarse)) {
|
||||
delete coarse.monthly;
|
||||
delete coarse.anomalies;
|
||||
note(block, "coarse monthly band and anomalies left out");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
resolveSnapshotPrelude,
|
||||
} from "./snapshot-prelude";
|
||||
import { annotateSnapshotFreshness } from "./snapshot-freshness";
|
||||
import { condenseSeriesBlock } from "./series-condense";
|
||||
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
|
||||
import { buildDerivedSnapshotBlock } from "./derived-snapshot";
|
||||
import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot";
|
||||
@@ -136,6 +137,12 @@ export interface CoachSnapshotResult {
|
||||
* units, so a reply never mixes the two.
|
||||
*/
|
||||
units: UnitPreferences;
|
||||
/**
|
||||
* The blocks the prompt-budget pass cut or condensed, absent when it cut
|
||||
* nothing. A tool reading one block out of a shared build checks this: a
|
||||
* block cut for somebody else's prompt is no answer to its own read.
|
||||
*/
|
||||
degradedBlocks?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,9 +332,13 @@ async function buildCoachSnapshotImpl(
|
||||
// Snapshot top-level keys that carry an `aggregate` companion — the
|
||||
// degrader can drop `timeline.recent` from these and still leave the
|
||||
// aggregate for the Coach to reason from.
|
||||
// The source each block was built for, so a single-source read can tell
|
||||
// which blocks it asked for (see `degradeToBudget`).
|
||||
const blockSources = new Map<string, CoachScopeSource>();
|
||||
const registerBlock = (key: string, source: CoachScopeSource) => {
|
||||
const cluster = sourceCluster(source);
|
||||
if (cluster) blockClusters.set(key, cluster);
|
||||
blockSources.set(key, source);
|
||||
};
|
||||
|
||||
// v1.7.0 — record which clusters resolved active for this build so
|
||||
@@ -1046,7 +1057,26 @@ async function buildCoachSnapshotImpl(
|
||||
// `MAX_SNAPSHOT_CHARS`. The `scope` block is exempt — the model
|
||||
// needs it to know what is in-bounds. The helper emits its own
|
||||
// `coach.snapshot.truncated` annotation when it sheds anything.
|
||||
degradeToBudget(compactSnapshot, blockClusters);
|
||||
//
|
||||
// A read scoped to one source (every retrieval tool, the MCP metric reads,
|
||||
// a metric page) asked for that source's blocks: those are condensed, never
|
||||
// emptied, and only after everything else has been shed.
|
||||
const requestedSource =
|
||||
Array.isArray(scope?.sources) && scope.sources.length === 1
|
||||
? scope.sources[0]
|
||||
: null;
|
||||
const requestedBlocks = new Set(
|
||||
requestedSource === null
|
||||
? []
|
||||
: Array.from(blockSources.entries())
|
||||
.filter(([, source]) => source === requestedSource)
|
||||
.map(([key]) => key),
|
||||
);
|
||||
const degraded = degradeToBudget(
|
||||
compactSnapshot,
|
||||
blockClusters,
|
||||
requestedBlocks,
|
||||
);
|
||||
|
||||
// v1.18.6 (W7) — build the citation-aware reference-grounding block from the
|
||||
// representative scalars collected above. Deterministic + pure; the route
|
||||
@@ -1088,6 +1118,9 @@ async function buildCoachSnapshotImpl(
|
||||
},
|
||||
referenceGrounding,
|
||||
units,
|
||||
...(degraded.length > 0
|
||||
? { degradedBlocks: Array.from(new Set(degraded.map((d) => d.key))) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1105,9 +1138,10 @@ async function buildCoachSnapshotImpl(
|
||||
* Returns the list of `{ key, cluster, pass }` it degraded — empty when
|
||||
* the snapshot already fit.
|
||||
*/
|
||||
function degradeToBudget(
|
||||
export function degradeToBudget(
|
||||
snapshot: Record<string, unknown>,
|
||||
blockClusters: Map<string, CoachDataCluster>,
|
||||
requested: ReadonlySet<string> = new Set(),
|
||||
): Array<{ key: string; cluster: CoachDataCluster; pass: number }> {
|
||||
const degraded: Array<{
|
||||
key: string;
|
||||
@@ -1128,6 +1162,10 @@ function degradeToBudget(
|
||||
const orderedKeys = Array.from(blockClusters.entries()).sort((a, b) => {
|
||||
const pa = priorityIndex.get(a[1]) ?? -1;
|
||||
const pb = priorityIndex.get(b[1]) ?? -1;
|
||||
// A block the read asked for goes last, whatever its cluster.
|
||||
const ra = requested.has(a[0]) ? 1 : 0;
|
||||
const rb = requested.has(b[0]) ? 1 : 0;
|
||||
if (ra !== rb) return ra - rb;
|
||||
// Higher priority index = lower signal = degrade first.
|
||||
return pb - pa;
|
||||
});
|
||||
@@ -1258,10 +1296,22 @@ function degradeToBudget(
|
||||
// until it is genuinely the last lever left.
|
||||
for (const [key, cluster] of orderedKeys) {
|
||||
if (size() <= MAX_SNAPSHOT_CHARS) break;
|
||||
// A requested series is condensed step by step and keeps its numbers
|
||||
// (`series-condense.ts`); it is never swapped for the `omitted` marker.
|
||||
if (requested.has(key) && asRecord(asRecord(snapshot[key])?.timeline)) {
|
||||
for (const step of [1, 2, 3] as const) {
|
||||
if (size() <= MAX_SNAPSHOT_CHARS) break;
|
||||
if (condenseSeriesBlock(snapshot[key], step)) {
|
||||
degraded.push({ key, cluster, pass: step });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (dropRecent(key)) degraded.push({ key, cluster, pass: 1 });
|
||||
if (size() <= MAX_SNAPSHOT_CHARS) break;
|
||||
if (dropWeekly(key)) degraded.push({ key, cluster, pass: 2 });
|
||||
if (size() <= MAX_SNAPSHOT_CHARS) break;
|
||||
if (requested.has(key)) continue;
|
||||
if (dropBlock(key)) degraded.push({ key, cluster, pass: 3 });
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
|
||||
{
|
||||
name: "get_metric_series",
|
||||
description:
|
||||
"Fetch the user's own time series for ONE metric: blood pressure (bp), weight, pulse, or any synced series (hrv, resting_hr, steps, sleep duration, body composition, gait, audio exposure, vo2_max, …). Returns an aggregate plus a recent-daily and weekly timeline. Call once per metric you need; call several in parallel for a multi-metric question. Returns { present: false } with a reason when nothing came back: no_data (never recorded), outside_window (recorded, but older than the window searched — the result carries the count, date range and aggregate, and the window to re-call with), unavailable_in_scope, or no_data_unconfirmed.",
|
||||
"Fetch the user's own time series for ONE metric: blood pressure (bp), weight, pulse, or any synced series (hrv, resting_hr, steps, sleep duration, body composition, gait, audio exposure, vo2_max, …). Returns an aggregate plus a recent-daily and weekly timeline reaching back at most 12 months (older history only as the coarse monthly and yearly means); for a longer history or a table, call get_metric_table yourself. Call once per metric you need; call several in parallel for a multi-metric question. Returns { present: false } with a reason when nothing came back: no_data (never recorded), outside_window (recorded, but older than the window searched — the result carries the count, date range and aggregate, and the window to re-call with), unavailable_in_scope, or no_data_unconfirmed.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -46,7 +46,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import {
|
||||
buildCoachSnapshot,
|
||||
type CoachSnapshotResult,
|
||||
} from "@/lib/ai/coach/snapshot";
|
||||
import { buildCoachSourceSnapshot } from "@/lib/ai/coach/source-snapshot";
|
||||
import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate";
|
||||
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
@@ -252,6 +255,44 @@ function scopeFor(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot a tool slices its block(s) out of.
|
||||
*
|
||||
* Reads under `scopeFor` first, which lands on the turn's shared full-source
|
||||
* build when the windows match. That build is cut to the prompt budget for a
|
||||
* prompt this read never sends: on an account with many synced series the
|
||||
* budget pass collapses whole blocks to an `{ omitted }` marker, and slicing
|
||||
* the marker out answered `present: true` with no figure in it. So when a
|
||||
* block this read needs was cut there, it is read again from its own sources
|
||||
* at the same window, where it is the block that was asked for.
|
||||
*/
|
||||
async function readToolSnapshot(
|
||||
userId: string,
|
||||
sources: CoachScope["sources"],
|
||||
sectionKeys: ReadonlyArray<string>,
|
||||
window: CoachScopeWindow | undefined,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
sharedScope: CoachScope | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
) {
|
||||
const scope = scopeFor(sources, window, fallbackWindow, sharedScope, reach);
|
||||
const snapshot = await buildCoachSnapshot(userId, scope, { reach });
|
||||
if (scope !== sharedScope) return snapshot;
|
||||
const cut = sectionKeys.filter((key) =>
|
||||
snapshot.degradedBlocks?.includes(key),
|
||||
);
|
||||
if (cut.length === 0) return snapshot;
|
||||
annotate({
|
||||
action: { name: "coach.tool.shared_block_cut" },
|
||||
meta: { blocks: cut, window: scope.window ?? DEFAULT_WINDOW },
|
||||
});
|
||||
return buildCoachSnapshot(
|
||||
userId,
|
||||
{ sources, window: scope.window },
|
||||
{ reach },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one tool call. `rawArguments` is the model's raw JSON-string
|
||||
* arguments (parsed + validated here, never trusted blindly). Returns a
|
||||
@@ -507,10 +548,14 @@ async function getMetricSeries(
|
||||
// reads without the Coach's lookback limit, so it takes no reach.
|
||||
const snapshot = sourceSnapshot
|
||||
? await buildCoachSourceSnapshot(userId, metric, window ?? fallbackWindow)
|
||||
: await buildCoachSnapshot(
|
||||
: await readToolSnapshot(
|
||||
userId,
|
||||
scopeFor([metric], window, fallbackWindow, sharedScope, reach),
|
||||
{ reach },
|
||||
[metric],
|
||||
[sectionKey],
|
||||
window,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
);
|
||||
const section = pickSection(snapshot.sections, sectionKey);
|
||||
if (section === undefined) {
|
||||
@@ -522,13 +567,66 @@ async function getMetricSeries(
|
||||
reach,
|
||||
);
|
||||
}
|
||||
// A block without a single figure (a bare unit, an `omitted` marker) is
|
||||
// not an answer: "present" with nothing in it is what the model then
|
||||
// repeats to the person. Say the read failed instead.
|
||||
if (!carriesFigures(section)) {
|
||||
annotate({
|
||||
action: { name: "coach.tool.section_without_values" },
|
||||
meta: { tool: "get_metric_series", metric },
|
||||
});
|
||||
return { present: false, reason: "retrieval_failed" };
|
||||
}
|
||||
if (sourceSnapshot) {
|
||||
// MCP: the section exactly as it always was.
|
||||
return {
|
||||
present: true,
|
||||
data: { metric, section },
|
||||
grounding: snapshot.referenceGrounding ?? undefined,
|
||||
};
|
||||
}
|
||||
// Outside MCP the read is a Coach snapshot, which counts what it read.
|
||||
const counts = (snapshot as Partial<CoachSnapshotResult>).provenance
|
||||
?.counts as Record<string, number | undefined> | undefined;
|
||||
const readings = counts?.[metric];
|
||||
const effectiveWindow = limitWindow(window ?? fallbackWindow, reach);
|
||||
const condensed = isRecord(section) && Array.isArray(section.condensed);
|
||||
const long = effectiveWindow === "allTime" || effectiveWindow === "lastYear";
|
||||
return {
|
||||
present: true,
|
||||
data: { metric, section },
|
||||
data: {
|
||||
metric,
|
||||
section,
|
||||
...(typeof readings === "number" ? { readings } : {}),
|
||||
...(long || condensed ? { coverage: SERIES_COVERAGE_NOTE } : {}),
|
||||
},
|
||||
grounding: snapshot.referenceGrounding ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What a long or condensed series read carries, and where the rest is. Rides
|
||||
* the result so the model switches to the table tool on its own instead of
|
||||
* asking the person whether it should.
|
||||
*/
|
||||
const SERIES_COVERAGE_NOTE =
|
||||
"Daily values for the newest 14 days, weekly means before that, back at most 12 months; " +
|
||||
"timeline.coarse (when present) adds monthly and yearly means for older history. " +
|
||||
"A 'condensed' list names what was cut to fit, and 'summary' holds first, last, min, max, mean and change over every point. " +
|
||||
"For the full history or a finer table, call get_metric_table yourself in this answer (window allTime, granularity month or week); do not ask the person first.";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Whether a payload holds at least one finite number anywhere. */
|
||||
function carriesFigures(value: unknown): boolean {
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value)) return value.some(carriesFigures);
|
||||
if (isRecord(value)) return Object.values(value).some(carriesFigures);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getGlucosePanel(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
@@ -538,16 +636,14 @@ async function getGlucosePanel(
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getGlucosePanelArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_glucose_panel", parsed.error);
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
const snapshot = await readToolSnapshot(
|
||||
userId,
|
||||
scopeFor(
|
||||
["glucose"],
|
||||
parsed.data.window,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
),
|
||||
{ reach },
|
||||
["glucose"],
|
||||
["glucose"],
|
||||
parsed.data.window,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
);
|
||||
const section = pickSection(snapshot.sections, "glucose");
|
||||
if (section === undefined) {
|
||||
@@ -575,10 +671,14 @@ async function getSleep(
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getSleepArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_sleep", parsed.error);
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
const snapshot = await readToolSnapshot(
|
||||
userId,
|
||||
scopeFor(["sleep"], parsed.data.window, fallbackWindow, sharedScope, reach),
|
||||
{ reach },
|
||||
["sleep"],
|
||||
["sleep", "sleepRhythm"],
|
||||
parsed.data.window,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
);
|
||||
const nights = pickSection(snapshot.sections, "sleep");
|
||||
const rhythm = pickSection(snapshot.sections, "sleepRhythm");
|
||||
@@ -612,16 +712,14 @@ async function getMedicationCompliance(
|
||||
if (!parsed.success) {
|
||||
return badArgs("get_medication_compliance", parsed.error);
|
||||
}
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
const snapshot = await readToolSnapshot(
|
||||
userId,
|
||||
scopeFor(
|
||||
["compliance"],
|
||||
parsed.data.window,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
),
|
||||
{ reach },
|
||||
["compliance"],
|
||||
["compliance", "weeklyContext"],
|
||||
parsed.data.window,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
);
|
||||
const compliance = pickSection(snapshot.sections, "compliance");
|
||||
// GLP-1 context rides the `weeklyContext` block.
|
||||
@@ -789,16 +887,14 @@ async function getWorkouts(
|
||||
if (!parsed.success) return badArgs("get_workouts", parsed.error);
|
||||
// The workouts block builds when the `workouts` cluster is active AND the
|
||||
// user has workout rows in the window. Scope the read to that single source.
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
const snapshot = await readToolSnapshot(
|
||||
userId,
|
||||
scopeFor(
|
||||
["workouts"],
|
||||
parsed.data.window,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
),
|
||||
{ reach },
|
||||
["workouts"],
|
||||
["workouts"],
|
||||
parsed.data.window,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
);
|
||||
const workouts = pickSection(snapshot.sections, "workouts");
|
||||
if (workouts === undefined) {
|
||||
|
||||
@@ -45,7 +45,8 @@ You have read-only retrieval tools and a DATA INVENTORY listing what the user ha
|
||||
In every case: do NOT infer, estimate, or fabricate a value, and do NOT ask the user to supply readings the record already holds.
|
||||
4. Stay on the metric the user asked about. A miss on that metric is an answer ABOUT that metric — never answer a question about one metric by reporting a different one instead. On a vague follow-up ("check again", "how were they?"), the subject is still the metric the conversation was already on.
|
||||
5. If you can answer without data (a definition, a "what can you help with?"), just answer — do not call a tool.
|
||||
6. Keep using the EVIDENCE (---KEYVALUES---) block exactly as before, citing only numbers you fetched this turn.`;
|
||||
6. Keep using the EVIDENCE (---KEYVALUES---) block exactly as before, citing only numbers you fetched this turn.
|
||||
7. A present get_metric_series result always carries numbers. When it is "condensed", cite its "summary" and the points it kept. When the question needs more history or finer detail than a series carries (all time, every month, a table), call get_metric_table yourself in this same answer. Never ask the user whether you should fetch it, and never tell them the numbers were not given to you.`;
|
||||
|
||||
const DE = `TOOL-BASIERTE ABFRAGE (diese Unterhaltung)
|
||||
|
||||
@@ -62,7 +63,8 @@ Du hast schreibgeschützte Abfrage-Tools und ein DATA INVENTORY, das auflistet,
|
||||
In jedem Fall: leite nichts ab, schätze nichts, erfinde keinen Wert — und bitte den Nutzer nicht, Werte nachzuliefern, die längst erfasst sind.
|
||||
4. Bleibe bei der Metrik, nach der gefragt wurde. Ein Fehlschlag zu dieser Metrik ist eine Antwort ÜBER diese Metrik — beantworte eine Frage zu einer Metrik niemals dadurch, dass du stattdessen über eine andere berichtest. Bei einer vagen Rückfrage („schau nochmal", „wie waren sie?") bleibt das Thema die Metrik, um die es gerade ging.
|
||||
5. Wenn du ohne Daten antworten kannst (eine Definition, „Wobei kannst du helfen?"), antworte einfach — rufe kein Tool auf.
|
||||
6. Nutze den EVIDENZ-Block (---KEYVALUES---) genau wie zuvor und zitiere nur Zahlen, die du in diesem Zug abgerufen hast.`;
|
||||
6. Nutze den EVIDENZ-Block (---KEYVALUES---) genau wie zuvor und zitiere nur Zahlen, die du in diesem Zug abgerufen hast.
|
||||
7. Ein vorhandenes Ergebnis von get_metric_series enthält immer Zahlen. Ist es „condensed", nenne die Werte aus „summary" und die Punkte, die es behalten hat. Braucht die Frage mehr Verlauf oder feinere Werte, als eine Serie trägt (gesamter Zeitraum, jeder Monat, eine Tabelle), rufe get_metric_table in derselben Antwort selbst auf. Frage den Nutzer nie, ob du das abrufen sollst, und sage nie, die Zahlen seien dir nicht mitgegeben worden.`;
|
||||
|
||||
export function buildToolModeAddendum(locale: Locale): string {
|
||||
return locale === "de" ? DE : EN;
|
||||
|
||||
@@ -173,7 +173,8 @@ function tableSummaryCount(data: Record<string, unknown>): number | undefined {
|
||||
/**
|
||||
* How many readings or rows a present result covered, when the result says
|
||||
* so in a field the server counted:
|
||||
* - a metric series: the aggregate's coverage count;
|
||||
* - a metric series: the aggregate's coverage count, else the readings the
|
||||
* snapshot counted for it;
|
||||
* - a metric table: the readings its periods fold;
|
||||
* - workouts: the sessions in the window;
|
||||
* - labs: the biomarkers returned (one row each).
|
||||
@@ -186,9 +187,17 @@ function presentCount(tool: CoachToolName, data: unknown): number | undefined {
|
||||
return tableSummaryCount(data);
|
||||
case "get_metric_series": {
|
||||
const section = data.section;
|
||||
if (!isRecord(section) || !isRecord(section.aggregate)) return undefined;
|
||||
const coverage = section.aggregate.coverage;
|
||||
return isRecord(coverage) ? asCount(coverage.count) : undefined;
|
||||
const coverage =
|
||||
isRecord(section) && isRecord(section.aggregate)
|
||||
? section.aggregate.coverage
|
||||
: undefined;
|
||||
// The aggregate's count where the block has one; a series without an
|
||||
// aggregate (body composition, the synced series) carries the readings
|
||||
// the snapshot counted beside the section.
|
||||
return (
|
||||
(isRecord(coverage) ? asCount(coverage.count) : undefined) ??
|
||||
asCount(data.readings)
|
||||
);
|
||||
}
|
||||
case "get_workouts":
|
||||
return asCount(data.totalInWindow);
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 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-steps";
|
||||
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$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user