mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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",
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user