From 4558271b3e4ef916a281c75b15acb53c0bbec2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 13:08:36 +0200 Subject: [PATCH] fix(coach): let a correct comparison with an earlier period stand The grounding ledger derives differences only between figures of one payload, so a correct "up 4 mmHg on last week" drawn from two separate table reads was withheld as unverified. A table of the period before or of a year ago now reads the current window too, and its summary carries the current figures and the change (delta and percent change) beside the earlier ones. A wrong delta is still flagged. --- .../__tests__/executor-results.test.ts | 62 +++++++++++++ src/lib/ai/coach/results/metric-table-tool.ts | 88 +++++++++++++++++++ src/lib/ai/coach/tools/definitions.ts | 2 +- src/lib/ai/coach/tools/executor.ts | 26 +++++- 4 files changed, 175 insertions(+), 3 deletions(-) diff --git a/src/lib/ai/coach/results/__tests__/executor-results.test.ts b/src/lib/ai/coach/results/__tests__/executor-results.test.ts index 2b6b70087..93e0f742f 100644 --- a/src/lib/ai/coach/results/__tests__/executor-results.test.ts +++ b/src/lib/ai/coach/results/__tests__/executor-results.test.ts @@ -47,6 +47,7 @@ import { type CoachToolTurnContext, } from "@/lib/ai/coach/tools/executor"; import { createResultRefAllocator } from "../refs"; +import { findUnverifiedCoachNumbers } from "@/lib/ai/coach/coach-prose-grounding"; const NOW = new Date("2026-09-27T10:00:00Z"); @@ -224,6 +225,67 @@ describe("get_metric_table", () => { expect(result.table).toBeUndefined(); }); + it("hands an earlier period the current one and the change, so a correct delta grounds and a wrong one does not", async () => { + // BP: 20–26 September average 128/82 (the week before), 27 September + // onwards 132/80 — the current week reads higher systolic. + readDailySeries.mockImplementation( + async ({ type, from }: { type: string; from: Date }) => { + const earlier = from.getTime() < Date.parse("2026-09-21T00:00:00Z"); + const [sys, dia] = earlier ? [128, 82] : [132, 80]; + const day = earlier ? "2026-09-18" : "2026-09-25"; + return [ + { + type, + value: type === "BLOOD_PRESSURE_SYS" ? sys : dia, + measuredAt: `${day}T00:00:00.000Z`, + count: 1, + }, + ]; + }, + ); + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_table", + rawArguments: JSON.stringify({ + metric: "bp", + window: "last7days", + period: "previous", + }), + turn: turn(), + }); + const data = result.data as Record; + expect(data.comparison).toMatchObject({ + with: { window: "last7days", period: "current" }, + current: { stats: { systolic: { mean: 132 }, diastolic: { mean: 80 } } }, + change: { + systolic: { mean: { delta: 4, pctChange: 3.1 } }, + diastolic: { mean: { delta: -2, pctChange: -2.4 } }, + }, + }); + expect( + findUnverifiedCoachNumbers( + "Your systolic is up 4 mmHg on the week before, about 3.1%.", + [data], + ), + ).toEqual([]); + const wrong = findUnverifiedCoachNumbers( + "Your systolic is up 9 mmHg on the week before.", + [data], + ); + expect(wrong.map((f) => f.value)).toContain(9); + }); + + it("adds no comparison to a current read", async () => { + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_table", + rawArguments: JSON.stringify({ metric: "pulse", window: "last7days" }), + turn: turn(), + }); + expect(result.data).not.toHaveProperty("comparison"); + expect(readDailySeries).toHaveBeenCalledTimes(1); + }); + it("refuses arguments outside the schema", async () => { const result = await executeCoachTool({ userId: "u1", diff --git a/src/lib/ai/coach/results/metric-table-tool.ts b/src/lib/ai/coach/results/metric-table-tool.ts index 73fa54545..68a4ba84c 100644 --- a/src/lib/ai/coach/results/metric-table-tool.ts +++ b/src/lib/ai/coach/results/metric-table-tool.ts @@ -975,3 +975,91 @@ export function summariseTable( } return view; } + +// ── Comparison with the current window ────────────────────────────────── + +function numericCells(table: CoachResultTable, key: string): number[] { + const index = table.columns.findIndex((column) => column.key === key); + if (index < 0) return []; + return table.rows + .map((row) => row[index]) + .filter( + (cell): cell is number => + typeof cell === "number" && Number.isFinite(cell), + ); +} + +function change( + earlier: number, + current: number, + decimals: number, +): { delta: number; pctChange?: number } { + const delta = round(current - earlier, decimals); + return earlier === 0 + ? { delta } + : { + delta, + pctChange: round(((current - earlier) / Math.abs(earlier)) * 100, 1), + }; +} + +/** + * How an earlier window (the period before, or the same window a year ago) + * compares with the current one, for the model's summary of the earlier + * table. The grounding ledger derives differences only between figures of + * one payload, so a correct "down 4 mmHg from last month" drawn from two + * separate reads was withheld as unverified; with the current figures and + * the change computed here, beside the earlier ones, the reply's delta is a + * figure the model was shown, and a wrong one still is not. + * + * `delta` is the current value minus the earlier one; `pctChange` is that + * change relative to the earlier value, omitted when the earlier value is 0. + */ +export function compareWithCurrent( + earlier: CoachResultTable, + current: CoachResultTable, +): Record | null { + const currentStats: Record> = {}; + const changes: Record> = {}; + for (const column of earlier.columns) { + if (column.kind !== "number") continue; + const before = numericCells(earlier, column.key); + const now = numericCells(current, column.key); + if (before.length === 0 || now.length === 0) continue; + const decimals = column.decimals ?? 1; + const sumBefore = before.reduce((s, v) => s + v, 0); + const sumNow = now.reduce((s, v) => s + v, 0); + const meanBefore = sumBefore / before.length; + const meanNow = sumNow / now.length; + const isTotal = column.labelKey === COACH_RESULT_COLUMN_KEYS.total; + currentStats[column.key] = { + mean: round(meanNow, decimals), + ...(isTotal ? { total: round(sumNow, decimals) } : {}), + }; + changes[column.key] = { + mean: change(meanBefore, meanNow, decimals), + ...(isTotal ? { total: change(sumBefore, sumNow, decimals) } : {}), + }; + } + if (Object.keys(changes).length === 0) return null; + const periodIndex = current.columns.findIndex((c) => c.kind === "period"); + const withValues = current.rows.filter((row) => + row.some( + (cell, index) => index !== periodIndex && typeof cell === "number", + ), + ); + return { + with: { window: current.source.window, period: "current" }, + current: { + ...(withValues.length > 0 + ? { + first: withValues[0][periodIndex], + last: withValues[withValues.length - 1][periodIndex], + } + : {}), + stats: currentStats, + }, + change: changes, + changeIs: "current minus this table; pctChange relative to this table", + }; +} diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts index 0c21f57be..20335214e 100644 --- a/src/lib/ai/coach/tools/definitions.ts +++ b/src/lib/ai/coach/tools/definitions.ts @@ -373,7 +373,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ type: "string", enum: coachResultPeriodSchema.options, description: - "current (default), previous (the window just before), or yearAgo (the same window a year earlier).", + "current (default), previous (the window just before), or yearAgo (the same window a year earlier). An earlier period also returns `comparison`: the current window's figures and the change from this table to them; cite that change rather than subtracting figures yourself.", }, }, }, diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index f08c1785a..f1bc29818 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -55,6 +55,7 @@ import type { } from "@/lib/ai/coach/types"; import { METRIC_TABLE_EXCLUDED_SOURCES, + compareWithCurrent, readMetricTable, summariseTable, } from "@/lib/ai/coach/results/metric-table-tool"; @@ -800,17 +801,38 @@ async function getMetricTable( window, ); } + // An earlier window is read to be compared: read the current one too and + // hand the model both, and the change between them, in one summary. + let comparison: Record | null = null; + if (period !== "current") { + const current = await readMetricTable({ + userId, + metric, + window, + period: "current", + granularity, + timeZone, + locale: turn?.locale ?? "en", + ref: "r0", + now: turn?.now, + }); + comparison = current ? compareWithCurrent(table, current) : null; + } + const summarise = (t: CoachResultTable) => ({ + ...summariseTable(t), + ...(comparison ? { comparison } : {}), + }); const ref = turn?.refs.next() ?? null; if (!ref) { // Outside a chat turn, or past the sixth table: the model still gets // the figures, there is just no table to name. - return { present: true, data: summariseTable(table) }; + return { present: true, data: summarise(table) }; } const named = { ...table, ref }; return { present: true, resultRef: ref, - data: summariseTable(named), + data: summarise(named), table: named, }; }