mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(coach): take a condensed series summary from its daily values
The summary of a condensed block was taken over the block's mix of daily values and weekly means: over a year the newest fourteen days weighed as much as fourteen weeks, min and max were extremes of means, and change was one day minus a weekly mean. Each series block now hands the budget pass its daily values over the whole read, computed only when the block is condensed. The summary states the first and last day, the lowest and highest day, the mean of the days, and the change from the first calendar week to the latest one. A block without daily values gets no summary rather than one of what is left. The coverage note says how the summary is taken.
This commit is contained in:
1 parent
756c3fb845
commit
143a39e369
12 files changed
+335
-139
No files matched your search
+4
-2
@@ -11,8 +11,10 @@ dashboard card fits a phone; text fields lose the purple focus ring.
|
||||
shared per-turn snapshot capped at 24,000 characters; on accounts with many
|
||||
series whole blocks became a placeholder and the tool still answered
|
||||
`present: true`. A trimmed block is now re-read for the tool's own source,
|
||||
a Coach tool's read of one source is condensed (summary, newest days,
|
||||
weekly and monthly means) instead of emptied, a block without numbers
|
||||
a Coach tool's read of one source is condensed instead of emptied (a
|
||||
summary from the daily values with the latest week against the first,
|
||||
the newest days, monthly means, each block in its own fields), a block
|
||||
without numbers
|
||||
answers `retrieval_failed` to the Coach, and the Coach calls the table tool
|
||||
itself for long histories. MCP reads are unchanged. The step list shows
|
||||
the reading count; no stray comma on copy.
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
KEEP_SESSIONS,
|
||||
condenseSeriesBlock,
|
||||
isoWeekMonth,
|
||||
summarisePoints,
|
||||
summariseDaily,
|
||||
weeklyToMonthly,
|
||||
type DailyPoint,
|
||||
} from "@/lib/ai/coach/series-condense";
|
||||
|
||||
const buildCoachSnapshot =
|
||||
@@ -71,6 +72,19 @@ function recent(count: number, value = (i: number) => 30 + i) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** `count` consecutive days from `start`, valued by `value(i)`. */
|
||||
function days(
|
||||
count: number,
|
||||
start: string,
|
||||
value: (i: number) => number,
|
||||
): DailyPoint[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const at = new Date(`${start}T00:00:00Z`);
|
||||
at.setUTCDate(at.getUTCDate() + i);
|
||||
return { date: at.toISOString().slice(0, 10), value: value(i) };
|
||||
});
|
||||
}
|
||||
|
||||
describe("series-condense", () => {
|
||||
it("places an ISO week in the month of its Thursday", () => {
|
||||
expect(isoWeekMonth("2026-W01")).toBe("2026-01");
|
||||
@@ -93,25 +107,50 @@ describe("series-condense", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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,
|
||||
it("summarises the daily values, each day weighing the same", () => {
|
||||
// A year at 20, then a fortnight at 30: the fortnight is 14 of 379 days.
|
||||
const year = days(379, "2025-09-01", (i) => (i < 365 ? 20 : 30));
|
||||
const summary = summariseDaily([...year].reverse());
|
||||
expect(summary).toEqual({
|
||||
from: "2025-09-01",
|
||||
to: "2026-09-14",
|
||||
days: 379,
|
||||
first: 20,
|
||||
last: 30,
|
||||
min: 20,
|
||||
max: 30,
|
||||
mean: 20.37,
|
||||
firstWeekMean: 20,
|
||||
latestWeekMean: 30,
|
||||
change: 10,
|
||||
});
|
||||
expect(summarisePoints([])).toBeNull();
|
||||
expect(summariseDaily([])).toBeNull();
|
||||
});
|
||||
|
||||
it("compares the first and latest calendar week, not two single days", () => {
|
||||
// Noisy days: first day low, last day high, both weeks average 50.
|
||||
const series = days(60, "2026-01-01", (i) =>
|
||||
i === 0
|
||||
? 40
|
||||
: i === 59
|
||||
? 60
|
||||
: 50 + (i % 2 === 0 ? 1 : -1) * (i < 7 || i > 52 ? 0 : 3),
|
||||
);
|
||||
const summary = summariseDaily(series)!;
|
||||
expect(summary.first).toBe(40);
|
||||
expect(summary.last).toBe(60);
|
||||
expect(summary.firstWeekMean).toBe(48.57);
|
||||
expect(summary.latestWeekMean).toBe(51.43);
|
||||
expect(summary.change).toBe(2.86);
|
||||
});
|
||||
|
||||
it("gives a block no summary without its daily values", () => {
|
||||
const block = {
|
||||
unit: "kg",
|
||||
timeline: { recent: recent(14), weekly: weeks(150) },
|
||||
};
|
||||
condenseSeriesBlock(block, 1, "fatMass");
|
||||
expect(block).not.toHaveProperty("summary");
|
||||
});
|
||||
|
||||
it("keeps numbers through every step and names what it cut", () => {
|
||||
@@ -119,25 +158,25 @@ describe("series-condense", () => {
|
||||
unit: "kg",
|
||||
timeline: { recent: recent(14), weekly: weeks(150) },
|
||||
};
|
||||
expect(condenseSeriesBlock(block, 1, "fatMass")).toBe(true);
|
||||
const daily = { value: days(1050, "2023-10-25", (i) => (2000 + i) / 100) };
|
||||
expect(condenseSeriesBlock(block, 1, "fatMass", daily)).toBe(true);
|
||||
expect(block.timeline.recent).toHaveLength(KEEP_DAILY_ROWS);
|
||||
expect(block.timeline.recent.at(-1)?.value).toBe(43);
|
||||
expect(condenseSeriesBlock(block, 2, "fatMass")).toBe(true);
|
||||
expect(condenseSeriesBlock(block, 2, "fatMass", daily)).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, "fatMass")).toBe(true);
|
||||
expect(condenseSeriesBlock(block, 3, "fatMass", daily)).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.
|
||||
// The summary is the daily values', whatever the block still holds.
|
||||
const summary = (block as { summary?: Record<string, unknown> }).summary;
|
||||
expect(summary).toMatchObject({
|
||||
from: "2023-W01",
|
||||
to: "2026-09-14",
|
||||
from: "2023-10-25",
|
||||
days: 1050,
|
||||
first: 20,
|
||||
last: 43,
|
||||
points: 164,
|
||||
last: 30.49,
|
||||
});
|
||||
expect((block as { condensed?: string[] }).condensed).toEqual([
|
||||
`daily values: newest ${KEEP_DAILY_ROWS} days kept`,
|
||||
@@ -154,7 +193,10 @@ describe("series-condense", () => {
|
||||
weeklyDia: [{ weekISO: "2026-W30", mean: 85, count: 4 }],
|
||||
},
|
||||
};
|
||||
condenseSeriesBlock(block, 2, "bloodPressure");
|
||||
condenseSeriesBlock(block, 2, "bloodPressure", {
|
||||
sys: days(40, "2026-07-24", (i) => (i < 33 ? 130 : 121)),
|
||||
dia: days(40, "2026-07-24", (i) => (i < 33 ? 85 : 79)),
|
||||
});
|
||||
expect(block).toMatchObject({
|
||||
summary: {
|
||||
sys: { first: 130, last: 121, change: -9 },
|
||||
@@ -240,6 +282,10 @@ describe("get_metric_series inside a chat turn", () => {
|
||||
expect(data.section).toEqual(FAT_MASS);
|
||||
expect(data.readings).toBe(1460);
|
||||
expect(data.coverage).toContain("call get_metric_table yourself");
|
||||
// The note says how the summary was taken, and claims no more.
|
||||
expect(data.coverage).toContain("daily values over the whole read");
|
||||
expect(data.coverage).toContain("latestWeekMean");
|
||||
expect(data.coverage).not.toContain("every point");
|
||||
});
|
||||
|
||||
it("keeps the shared build when the block was not cut there", async () => {
|
||||
@@ -303,13 +349,19 @@ describe("series-condense reads each block in its own fields", () => {
|
||||
})),
|
||||
},
|
||||
};
|
||||
condenseSeriesBlock(block, 1, "sleep");
|
||||
const nights = {
|
||||
value: [
|
||||
...days(49, "2026-07-20", () => 320),
|
||||
...days(14, "2026-09-10", (i) => 400 + i),
|
||||
],
|
||||
};
|
||||
condenseSeriesBlock(block, 1, "sleep", nights);
|
||||
expect(block).toMatchObject({
|
||||
summary: { to: "2026-09-23", last: 413 },
|
||||
summary: { to: "2026-09-23", last: 413, days: 63 },
|
||||
});
|
||||
expect(block.timeline.recent).toHaveLength(KEEP_DAILY_ROWS);
|
||||
expect(block.timeline.recent.at(-1)).toMatchObject({ minutes: 413 });
|
||||
condenseSeriesBlock(block, 2, "sleep");
|
||||
condenseSeriesBlock(block, 2, "sleep", nights);
|
||||
expect(
|
||||
(block.timeline as unknown as { monthly: unknown[] }).monthly,
|
||||
).toEqual([
|
||||
@@ -332,7 +384,12 @@ describe("series-condense reads each block in its own fields", () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
condenseSeriesBlock(block, 2, "compliance");
|
||||
condenseSeriesBlock(block, 2, "compliance", {
|
||||
value: [
|
||||
{ date: "2026-09-30", value: 1 },
|
||||
{ date: "2026-10-01", value: 0.5 },
|
||||
],
|
||||
});
|
||||
expect(block).toMatchObject({
|
||||
summary: { to: "2026-10-01", last: 0.5 },
|
||||
timeline: { monthly: [{ month: "2026-09", rate: 0.56, total: 16 }] },
|
||||
@@ -350,8 +407,12 @@ describe("series-condense reads each block in its own fields", () => {
|
||||
panel: { tir: 0.8 },
|
||||
byContext: { FASTING: ctx(95), POST_MEAL: ctx(140) },
|
||||
};
|
||||
expect(condenseSeriesBlock(block, 1, "glucose")).toBe(true);
|
||||
expect(condenseSeriesBlock(block, 2, "glucose")).toBe(true);
|
||||
const daily = {
|
||||
FASTING: days(14, "2026-09-01", (i) => 95 + i),
|
||||
POST_MEAL: days(14, "2026-09-01", (i) => 140 + i),
|
||||
};
|
||||
expect(condenseSeriesBlock(block, 1, "glucose", daily)).toBe(true);
|
||||
expect(condenseSeriesBlock(block, 2, "glucose", daily)).toBe(true);
|
||||
expect(block).toMatchObject({
|
||||
summary: {
|
||||
FASTING: { last: 108, to: "2026-09-14" },
|
||||
@@ -406,12 +467,16 @@ describe("the prompt-budget pass on a single-source read", () => {
|
||||
snap,
|
||||
new Map(clusters),
|
||||
new Set(["fatMass"]),
|
||||
(key) =>
|
||||
key === "fatMass"
|
||||
? { value: days(534, "2025-03-01", (i) => 20 + i / 100) }
|
||||
: undefined,
|
||||
);
|
||||
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(fat.summary).toMatchObject({ days: 534, last: 25.33 });
|
||||
expect(degraded.map((d) => d.key).at(-1)).toBe("fatMass");
|
||||
});
|
||||
});
|
||||
@@ -469,3 +534,30 @@ describe("the MCP read stays as it was", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("dailyPoints", () => {
|
||||
it("states each local day's value in the reader's unit", async () => {
|
||||
const { dailyPoints } = await import("@/lib/ai/coach/snapshot-series");
|
||||
const { getReadingTransform } =
|
||||
await import("@/lib/measurements/display-transform");
|
||||
const rows = [
|
||||
{ measuredAt: new Date("2026-09-01T06:00:00Z"), value: 80 },
|
||||
{ measuredAt: new Date("2026-09-01T20:00:00Z"), value: 82 },
|
||||
{ measuredAt: new Date("2026-09-02T06:00:00Z"), value: 81 },
|
||||
];
|
||||
expect(dailyPoints(rows, "UTC")).toEqual([
|
||||
{ date: "2026-09-01", value: 81 },
|
||||
{ date: "2026-09-02", value: 81 },
|
||||
]);
|
||||
const lb = dailyPoints(
|
||||
rows,
|
||||
"UTC",
|
||||
undefined,
|
||||
getReadingTransform("WEIGHT", {
|
||||
...DEFAULT_UNIT_PREFERENCES,
|
||||
system: "imperial",
|
||||
}),
|
||||
);
|
||||
expect(lb[0].value).toBeCloseTo(178.6, 0);
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,11 @@
|
||||
* 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:
|
||||
* `summary` per series, taken from the series' daily values over the whole
|
||||
* read (not from the block's mix of daily and weekly means, where fourteen
|
||||
* days would weigh as much as fourteen weeks): the first and last day, the
|
||||
* lowest and highest day, the mean of the days, and the change from the
|
||||
* first week to the latest week. 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);
|
||||
@@ -20,28 +23,61 @@
|
||||
* 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";
|
||||
import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import type { CoachScopeSource } from "./types";
|
||||
|
||||
/** 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;
|
||||
|
||||
/** One day's value of a series, in the reader's unit. */
|
||||
export interface DailyPoint {
|
||||
/** `YYYY-MM-DD`, the person's local day. */
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A block's daily values per series: `value` for a block with one series,
|
||||
* else keyed like the summary (`sys`/`dia`, a glucose context).
|
||||
*/
|
||||
export type DailySeries = Readonly<Record<string, ReadonlyArray<DailyPoint>>>;
|
||||
|
||||
/**
|
||||
* How a block builder registers its block with the budget pass, and, for a
|
||||
* series block, how to read its daily values should the block be condensed.
|
||||
* The values are only computed then.
|
||||
*/
|
||||
export type RegisterBlock = (
|
||||
key: string,
|
||||
source: CoachScopeSource,
|
||||
daily?: () => DailySeries,
|
||||
) => void;
|
||||
|
||||
/** Days the first-week and latest-week means each span. */
|
||||
export const COMPARE_DAYS = 7;
|
||||
|
||||
export interface SeriesSummary {
|
||||
/** Earliest point's period: a day (`YYYY-MM-DD`) or an ISO week. */
|
||||
/** First and last day with a value. */
|
||||
from: string;
|
||||
/** Newest point's period. */
|
||||
to: string;
|
||||
/** Days with a value; every figure below is over these days. */
|
||||
days: number;
|
||||
/** The first day's and the last day's value. */
|
||||
first: number;
|
||||
last: number;
|
||||
/** The lowest and highest daily value. */
|
||||
min: number;
|
||||
max: number;
|
||||
/** Mean of the points (daily and weekly means alike), not of readings. */
|
||||
/** Mean of the daily values. */
|
||||
mean: number;
|
||||
/** `last - first`. */
|
||||
/** Mean of the days in the first `COMPARE_DAYS` calendar days. */
|
||||
firstWeekMean: number;
|
||||
/** Mean of the days in the latest `COMPARE_DAYS` calendar days. */
|
||||
latestWeekMean: number;
|
||||
/** `latestWeekMean - firstWeekMean`. */
|
||||
change: number;
|
||||
/** How many points the figures above were taken over. */
|
||||
points: number;
|
||||
}
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
@@ -54,52 +90,47 @@ function round(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
/** The day `days` after a `YYYY-MM-DD` key, as a key. */
|
||||
function shiftDay(key: string, days: number): string {
|
||||
const at = dayKeyAsUtcMidnight(key);
|
||||
at.setUTCDate(at.getUTCDate() + days);
|
||||
return dateOnlyKey(at);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }>,
|
||||
/** Summary over a series' daily values (any order), or null when empty. */
|
||||
export function summariseDaily(
|
||||
points: ReadonlyArray<DailyPoint>,
|
||||
): 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 days = points
|
||||
.filter((p) => Number.isFinite(p.value))
|
||||
.slice()
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
if (days.length === 0) return null;
|
||||
const values = days.map((d) => d.value);
|
||||
const mean = (list: number[]) =>
|
||||
list.reduce((s, v) => s + v, 0) / list.length;
|
||||
const from = days[0].date;
|
||||
const to = days[days.length - 1].date;
|
||||
const firstEnd = shiftDay(from, COMPARE_DAYS - 1);
|
||||
const latestStart = shiftDay(to, -(COMPARE_DAYS - 1));
|
||||
const firstWeek = mean(
|
||||
days.filter((d) => d.date <= firstEnd).map((d) => d.value),
|
||||
);
|
||||
const latestWeek = mean(
|
||||
days.filter((d) => d.date >= latestStart).map((d) => d.value),
|
||||
);
|
||||
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,
|
||||
from,
|
||||
to,
|
||||
days: days.length,
|
||||
first: values[0],
|
||||
last: values[values.length - 1],
|
||||
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,
|
||||
mean: round(mean(values)),
|
||||
firstWeekMean: round(firstWeek),
|
||||
latestWeekMean: round(latestWeek),
|
||||
change: round(latestWeek - firstWeek),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -244,31 +275,13 @@ export function isCondensable(key: string, block: unknown): boolean {
|
||||
return tracksOf(kind, block).length > 0;
|
||||
}
|
||||
|
||||
function trackSummary(track: Track): SeriesSummary | null {
|
||||
const points: Array<{ at: string; value: number }> = [];
|
||||
const weekly = track.container[track.weekly];
|
||||
if (Array.isArray(weekly)) {
|
||||
for (const b of weekly) {
|
||||
if (!isRecord(b) || typeof b.weekISO !== "string") continue;
|
||||
const value = b[track.weeklyValue];
|
||||
if (typeof value === "number") points.push({ at: b.weekISO, value });
|
||||
}
|
||||
}
|
||||
const recent = track.container.recent;
|
||||
if (Array.isArray(recent)) {
|
||||
for (const row of recent) {
|
||||
if (!isRecord(row) || typeof row.date !== "string") continue;
|
||||
const value = row[track.field];
|
||||
if (typeof value === "number") points.push({ at: row.date, value });
|
||||
}
|
||||
}
|
||||
return summarisePoints(points);
|
||||
}
|
||||
|
||||
function blockSummary(tracks: ReadonlyArray<Track>): unknown {
|
||||
function blockSummary(
|
||||
tracks: ReadonlyArray<Track>,
|
||||
daily: DailySeries,
|
||||
): unknown {
|
||||
const out: Rec = {};
|
||||
for (const track of tracks) {
|
||||
const summary = trackSummary(track);
|
||||
const summary = summariseDaily(daily[track.name ?? "value"] ?? []);
|
||||
if (!summary) continue;
|
||||
if (track.name === null) return summary;
|
||||
out[track.name] = summary;
|
||||
@@ -294,6 +307,12 @@ export function condenseSeriesBlock(
|
||||
block: unknown,
|
||||
step: 1 | 2 | 3,
|
||||
key: string,
|
||||
/**
|
||||
* The block's daily values over the whole read, for its summary. Without
|
||||
* them the block gets no summary: a summary of what is left would not be
|
||||
* a summary of the series.
|
||||
*/
|
||||
daily?: DailySeries,
|
||||
): boolean {
|
||||
if (!isRecord(block)) return false;
|
||||
const kind = seriesBlockKind(key);
|
||||
@@ -316,8 +335,8 @@ export function condenseSeriesBlock(
|
||||
}
|
||||
const tracks = tracksOf(kind, block);
|
||||
if (tracks.length === 0) return false;
|
||||
if (block.summary === undefined) {
|
||||
const summary = blockSummary(tracks);
|
||||
if (block.summary === undefined && daily) {
|
||||
const summary = blockSummary(tracks, daily);
|
||||
if (summary !== undefined) block.summary = summary;
|
||||
}
|
||||
const containers = new Set(tracks.map((t) => t.container));
|
||||
|
||||
@@ -26,11 +26,8 @@ import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { courseStatusOn } from "@/lib/medications/course-window";
|
||||
import { isoWeekKey, tzDayKey, tzWeekday } from "../snapshot-series";
|
||||
import type {
|
||||
CoachProvenance,
|
||||
CoachProvenanceMetric,
|
||||
CoachScopeSource,
|
||||
} from "../types";
|
||||
import type { RegisterBlock } from "../series-condense";
|
||||
import type { CoachProvenance, CoachProvenanceMetric } from "../types";
|
||||
|
||||
/**
|
||||
* The medication row shape the block consumes — exactly what the
|
||||
@@ -53,7 +50,7 @@ interface ComplianceBlockContext {
|
||||
snapshot: Record<string, unknown>;
|
||||
metrics: Set<CoachProvenanceMetric>;
|
||||
counts: NonNullable<CoachProvenance["counts"]>;
|
||||
registerBlock: (key: string, source: CoachScopeSource) => void;
|
||||
registerBlock: RegisterBlock;
|
||||
}
|
||||
|
||||
function courseSummary(
|
||||
@@ -208,7 +205,23 @@ export function buildComplianceBlock(
|
||||
};
|
||||
metrics.add("compliance");
|
||||
counts.compliance = countable.length;
|
||||
registerBlock("compliance", "compliance");
|
||||
registerBlock("compliance", "compliance", () => {
|
||||
// Every day's rate over the whole window, as the recent rows state it.
|
||||
const byDay = new Map<string, { taken: number; total: number }>();
|
||||
for (const r of countable) {
|
||||
const key = tzDayKey(r.at, userTz);
|
||||
const e = byDay.get(key) ?? { taken: 0, total: 0 };
|
||||
e.total += 1;
|
||||
if (isTaken(r)) e.taken += 1;
|
||||
byDay.set(key, e);
|
||||
}
|
||||
return {
|
||||
value: Array.from(byDay.entries()).map(([date, e]) => ({
|
||||
date,
|
||||
value: Math.round((e.taken / e.total) * 100) / 100,
|
||||
})),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// v1.7.0 — toggled-on cluster with no rows. Annotate so the
|
||||
// dashboards can distinguish "user has no medication data" from
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
bucketWeekly,
|
||||
buildDailyBpRows,
|
||||
buildDailyValueRows,
|
||||
dailyPoints,
|
||||
timelineInUnit,
|
||||
type CoarseTimelineTail,
|
||||
} from "../snapshot-series";
|
||||
import type { RegisterBlock } from "../series-condense";
|
||||
import type {
|
||||
CoachProvenance,
|
||||
CoachProvenanceMetric,
|
||||
@@ -42,7 +44,7 @@ interface CoreMetricsBlockContext {
|
||||
windows: Set<CoachProvenance["windows"][number]>;
|
||||
metrics: Set<CoachProvenanceMetric>;
|
||||
counts: NonNullable<CoachProvenance["counts"]>;
|
||||
registerBlock: (key: string, source: CoachScopeSource) => void;
|
||||
registerBlock: RegisterBlock;
|
||||
groundingValues: Map<ReferenceMetric, number>;
|
||||
/** The reader's units; the weight block is stated in them. */
|
||||
units: UnitPreferences;
|
||||
@@ -91,7 +93,13 @@ export function buildCoreMetricsBlocks(
|
||||
ctx.windows.add("last30days");
|
||||
ctx.windows.add("last90days");
|
||||
ctx.counts.bp = ctx.features.bloodPressure.coverage?.count ?? undefined;
|
||||
ctx.registerBlock("bloodPressure", "bp");
|
||||
ctx.registerBlock("bloodPressure", "bp", () => {
|
||||
const days = buildDailyBpRows(sysRows, diaRows, new Date(0), ctx.userTz);
|
||||
return {
|
||||
sys: days.map((d) => ({ date: d.date, value: d.sys })),
|
||||
dia: days.map((d) => ({ date: d.date, value: d.dia })),
|
||||
};
|
||||
});
|
||||
|
||||
const sys30 =
|
||||
ctx.features.bloodPressure.avgSys30 ??
|
||||
@@ -124,7 +132,14 @@ export function buildCoreMetricsBlocks(
|
||||
ctx.windows.add("last7days");
|
||||
ctx.windows.add("last30days");
|
||||
ctx.counts.weight = ctx.features.weight.coverage?.count ?? undefined;
|
||||
ctx.registerBlock("weight", "weight");
|
||||
ctx.registerBlock("weight", "weight", () => ({
|
||||
value: dailyPoints(
|
||||
rows,
|
||||
ctx.userTz,
|
||||
undefined,
|
||||
getReadingTransform("WEIGHT", ctx.units),
|
||||
),
|
||||
}));
|
||||
|
||||
if (ctx.features.weight.bmi != null) {
|
||||
ctx.groundingValues.set("BMI", ctx.features.weight.bmi);
|
||||
@@ -157,7 +172,9 @@ export function buildCoreMetricsBlocks(
|
||||
ctx.windows.add("last30days");
|
||||
ctx.windows.add("last90days");
|
||||
ctx.counts.pulse = ctx.features.pulse.coverage?.count ?? undefined;
|
||||
ctx.registerBlock("pulse", "pulse");
|
||||
ctx.registerBlock("pulse", "pulse", () => ({
|
||||
value: dailyPoints(rows, ctx.userTz, "PULSE"),
|
||||
}));
|
||||
}
|
||||
|
||||
if (ctx.sources.has("mood") && ctx.features.mood && ctx.moodRows) {
|
||||
@@ -179,6 +196,8 @@ export function buildCoreMetricsBlocks(
|
||||
ctx.windows.add("last7days");
|
||||
ctx.windows.add("last30days");
|
||||
ctx.counts.mood = ctx.features.mood.coverage?.count ?? undefined;
|
||||
ctx.registerBlock("mood", "mood");
|
||||
ctx.registerBlock("mood", "mood", () => ({
|
||||
value: dailyPoints(normalised, ctx.userTz),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,8 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { ReferenceMetric } from "@/lib/reference-ranges";
|
||||
import { bucketWeekly, buildDailyValueRows } from "../snapshot-series";
|
||||
import type {
|
||||
CoachProvenance,
|
||||
CoachProvenanceMetric,
|
||||
CoachScopeSource,
|
||||
} from "../types";
|
||||
import type { RegisterBlock } from "../series-condense";
|
||||
import type { CoachProvenance, CoachProvenanceMetric } from "../types";
|
||||
|
||||
/**
|
||||
* v1.17.0 — the glucose clinical panel is a fixed trailing-30-day artifact,
|
||||
@@ -57,7 +54,7 @@ interface GlucoseBlockContext {
|
||||
snapshot: Record<string, unknown>;
|
||||
metrics: Set<CoachProvenanceMetric>;
|
||||
counts: NonNullable<CoachProvenance["counts"]>;
|
||||
registerBlock: (key: string, source: CoachScopeSource) => void;
|
||||
registerBlock: RegisterBlock;
|
||||
groundingValues: Map<ReferenceMetric, number>;
|
||||
}
|
||||
|
||||
@@ -102,6 +99,10 @@ export function buildGlucoseBlock(ctx: Readonly<GlucoseBlockContext>): void {
|
||||
byContext.set(ctxKey, list);
|
||||
}
|
||||
const contexts: Record<string, unknown> = {};
|
||||
const dailyByContext: Record<
|
||||
string,
|
||||
Array<{ date: string; value: number }>
|
||||
> = {};
|
||||
for (const [ctxKey, rows] of byContext) {
|
||||
// v1.16.16 — glucose is stored canonical mg/dL. A mmol/L-preference
|
||||
// user's Coach must read the same number every other surface shows
|
||||
@@ -123,6 +124,15 @@ export function buildGlucoseBlock(ctx: Readonly<GlucoseBlockContext>): void {
|
||||
: w,
|
||||
);
|
||||
contexts[ctxKey] = { recent, weekly };
|
||||
dailyByContext[ctxKey] = buildDailyValueRows(
|
||||
rows,
|
||||
new Date(0),
|
||||
userTz,
|
||||
).map((d) => ({
|
||||
date: d.date,
|
||||
// The reader's unit; a mg/dL day is a whole number, as the app states it.
|
||||
value: convertGlucose(d.value, glucoseUnit),
|
||||
}));
|
||||
}
|
||||
// v1.17.0 — clinical panel summary from the ONE literature-locked engine
|
||||
// the insights panel + doctor report also consume, computed over the SAME
|
||||
@@ -203,7 +213,7 @@ export function buildGlucoseBlock(ctx: Readonly<GlucoseBlockContext>): void {
|
||||
};
|
||||
metrics.add("glucose");
|
||||
counts.glucose = glucoseRows.length;
|
||||
registerBlock("glucose", "glucose");
|
||||
registerBlock("glucose", "glucose", () => dailyByContext);
|
||||
// W7 grounding: fasting glucose mean in RAW mg/dL (the reference band's
|
||||
// unit), independent of the user's mmol/L display preference. The
|
||||
// grounding line's band selection respects the W6 `hasDiabetes` opt-in;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { ReferenceMetric } from "@/lib/reference-ranges";
|
||||
import { bucketWeekly, tzWeekday } from "../snapshot-series";
|
||||
import type { RegisterBlock } from "../series-condense";
|
||||
import type {
|
||||
CoachProvenance,
|
||||
CoachProvenanceMetric,
|
||||
@@ -42,7 +43,7 @@ interface SleepTimelineBlockContext {
|
||||
snapshot: Record<string, unknown>;
|
||||
metrics: Set<CoachProvenanceMetric>;
|
||||
counts: NonNullable<CoachProvenance["counts"]>;
|
||||
registerBlock: (key: string, source: CoachScopeSource) => void;
|
||||
registerBlock: RegisterBlock;
|
||||
groundingValues: Map<ReferenceMetric, number>;
|
||||
}
|
||||
|
||||
@@ -130,7 +131,12 @@ export function buildSleepTimelineBlock(
|
||||
};
|
||||
metrics.add("sleep");
|
||||
counts.sleep = sleepRows.length;
|
||||
registerBlock("sleep", "sleep");
|
||||
registerBlock("sleep", "sleep", () => ({
|
||||
value: nights.map((n) => ({
|
||||
date: n.night,
|
||||
value: Math.round(n.asleepMinutes),
|
||||
})),
|
||||
}));
|
||||
// W7 grounding: recent nightly asleep duration in HOURS against the
|
||||
// AASM 7–9 h band (the reference unit is hours; the snapshot stores
|
||||
// minutes). Mean over the recent nights the block already reconstructed.
|
||||
|
||||
@@ -8,8 +8,10 @@ import { sourceCluster } from "../clusters";
|
||||
import {
|
||||
bucketWeekly,
|
||||
buildDailyValueRows,
|
||||
dailyPoints,
|
||||
timelineInUnit,
|
||||
} from "../snapshot-series";
|
||||
import type { RegisterBlock } from "../series-condense";
|
||||
import type {
|
||||
CoachProvenance,
|
||||
CoachProvenanceMetric,
|
||||
@@ -252,7 +254,7 @@ interface ValueSeriesBlocksContext {
|
||||
snapshot: Record<string, unknown>;
|
||||
metrics: Set<CoachProvenanceMetric>;
|
||||
counts: NonNullable<CoachProvenance["counts"]>;
|
||||
registerBlock: (key: string, source: CoachScopeSource) => void;
|
||||
registerBlock: RegisterBlock;
|
||||
groundingValues: Map<ReferenceMetric, number>;
|
||||
/** The reader's units; every block is stated and labelled in them. */
|
||||
units: UnitPreferences;
|
||||
@@ -311,7 +313,9 @@ export function buildValueSeriesBlocks(
|
||||
};
|
||||
ctx.metrics.add(block.metric);
|
||||
ctx.counts[block.metric] = rows.length;
|
||||
ctx.registerBlock(block.snapshotKey, block.source);
|
||||
ctx.registerBlock(block.snapshotKey, block.source, () => ({
|
||||
value: dailyPoints(rows, ctx.userTz, undefined, transform),
|
||||
}));
|
||||
|
||||
const referenceMetric =
|
||||
typeof block.type === "string"
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
|
||||
import { DEFAULT_WINDOW } from "./snapshot-cache";
|
||||
import { COACH_DAILY_DETAIL_DAYS } from "./history-reach";
|
||||
import type { DailyPoint } from "./series-condense";
|
||||
import type { CoachScope, CoachScopeSource, CoachScopeWindow } from "./types";
|
||||
|
||||
const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
|
||||
@@ -322,6 +323,23 @@ export async function buildCoarseTimelineTail(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every day's value over `rows`, the same day value the timeline's daily
|
||||
* rows use, in the reader's unit when a transform is given. Feeds the
|
||||
* summary of a condensed block (`series-condense.ts`).
|
||||
*/
|
||||
export function dailyPoints(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
tz: string,
|
||||
type?: string,
|
||||
transform?: DisplayTransform,
|
||||
): DailyPoint[] {
|
||||
return buildDailyValueRows(rows, new Date(0), tz, type).map((row) => ({
|
||||
date: row.date,
|
||||
value: transform ? applyDisplayTransform(row.value, transform) : row.value,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A built timeline in the reader's unit. The series are aggregated on the
|
||||
* canonical value and converted once at the end, so a mean in pounds is the
|
||||
|
||||
@@ -33,7 +33,12 @@ import {
|
||||
resolveSnapshotPrelude,
|
||||
} from "./snapshot-prelude";
|
||||
import { annotateSnapshotFreshness } from "./snapshot-freshness";
|
||||
import { condenseSeriesBlock, isCondensable } from "./series-condense";
|
||||
import {
|
||||
condenseSeriesBlock,
|
||||
isCondensable,
|
||||
type DailySeries,
|
||||
type RegisterBlock,
|
||||
} from "./series-condense";
|
||||
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
|
||||
import { buildDerivedSnapshotBlock } from "./derived-snapshot";
|
||||
import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot";
|
||||
@@ -349,10 +354,13 @@ async function buildCoachSnapshotImpl(
|
||||
// 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) => {
|
||||
// A series block's daily values, read only if the block is condensed.
|
||||
const blockDaily = new Map<string, () => DailySeries>();
|
||||
const registerBlock: RegisterBlock = (key, source, daily) => {
|
||||
const cluster = sourceCluster(source);
|
||||
if (cluster) blockClusters.set(key, cluster);
|
||||
blockSources.set(key, source);
|
||||
if (daily) blockDaily.set(key, daily);
|
||||
};
|
||||
|
||||
// v1.7.0 — record which clusters resolved active for this build so
|
||||
@@ -1092,6 +1100,7 @@ async function buildCoachSnapshotImpl(
|
||||
compactSnapshot,
|
||||
blockClusters,
|
||||
requestedBlocks,
|
||||
(key) => blockDaily.get(key)?.(),
|
||||
);
|
||||
|
||||
// v1.18.6 (W7) — build the citation-aware reference-grounding block from the
|
||||
@@ -1158,6 +1167,8 @@ export function degradeToBudget(
|
||||
snapshot: Record<string, unknown>,
|
||||
blockClusters: Map<string, CoachDataCluster>,
|
||||
requested: ReadonlySet<string> = new Set(),
|
||||
/** A requested block's daily values, for its condensed summary. */
|
||||
dailyOf: (key: string) => DailySeries | undefined = () => undefined,
|
||||
): Array<{ key: string; cluster: CoachDataCluster; pass: number }> {
|
||||
const degraded: Array<{
|
||||
key: string;
|
||||
@@ -1315,9 +1326,10 @@ export function degradeToBudget(
|
||||
// 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) && isCondensable(key, snapshot[key])) {
|
||||
const daily = dailyOf(key);
|
||||
for (const step of [1, 2, 3] as const) {
|
||||
if (size() <= MAX_SNAPSHOT_CHARS) break;
|
||||
if (condenseSeriesBlock(snapshot[key], step, key)) {
|
||||
if (condenseSeriesBlock(snapshot[key], step, key, daily)) {
|
||||
degraded.push({ key, cluster, pass: step });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,7 +619,8 @@ async function getMetricSeries(
|
||||
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. " +
|
||||
"A 'condensed' list names what was cut to fit. A 'summary', when present, is taken from the series' daily values over the whole read ('from' to 'to', 'days'): " +
|
||||
"the first and last day, the lowest and highest day, the mean of the days, and 'change' = 'latestWeekMean' minus 'firstWeekMean' (the latest 7 calendar days against the first 7). " +
|
||||
"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> {
|
||||
|
||||
@@ -46,7 +46,7 @@ You have read-only retrieval tools and a DATA INVENTORY listing what the user ha
|
||||
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.
|
||||
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.`;
|
||||
7. A present get_metric_series result always carries numbers. When it is "condensed", cite its "summary" (when it has one) 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)
|
||||
|
||||
@@ -64,7 +64,7 @@ Du hast schreibgeschützte Abfrage-Tools und ein DATA INVENTORY, das auflistet,
|
||||
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.
|
||||
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.`;
|
||||
7. Ein vorhandenes Ergebnis von get_metric_series enthält immer Zahlen. Ist es „condensed", nenne die Werte aus „summary" (falls vorhanden) 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;
|
||||
|
||||
Reference in new issue
Block a user