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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 13:08:36 +02:00
1 parent 9913734961
commit 4558271b3e
4 files changed
+175 -3

No files matched your search

@@ -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<string, unknown>;
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",
@@ -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<string, unknown> | null {
const currentStats: Record<string, Record<string, number>> = {};
const changes: Record<string, Record<string, unknown>> = {};
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",
};
}
+1 -1
View File
@@ -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.",
},
},
},
+24 -2
View File
@@ -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<string, unknown> | 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,
};
}