feat(coach): choose a chart for each result table on the server

A table over time is drawn as a line for a level metric and as bars
for a total, with at most two series in one unit. Category counts get
bars, horizontal above six categories, the top eight and one other.
Fewer than two or more than 400 rows, or no numeric column, stay table
only. show_result's view now counts: table drops the chart, chart turns
an earlier day table into counts per range with a histogram (fixed
widths for the common metrics, Freedman-Diaconis otherwise, none below
ten values).
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 12:34:06 +02:00
1 parent db10ba714e
commit c4bf5a916d
6 files changed
+1139 -17

No files matched your search

@@ -0,0 +1,537 @@
/**
* The chart a result table gets. Pure and deterministic: every rule of the
* heuristic, and every way a table ends up with no chart.
*/
import { describe, expect, it } from "vitest";
import type {
CoachResultCell,
CoachResultColumn,
CoachResultTable,
CoachStepDomain,
} from "@/lib/ai/coach/types";
import {
CATEGORY_TOP_N,
HISTOGRAM_MAX_BINS,
HISTOGRAM_MIN_BINS,
METRIC_AGGREGATION_KIND,
buildDistributionTable,
buildHistogram,
chartCategoryRows,
deriveChartSpec,
type DistributionLabels,
} from "../chart-spec";
const DAY: CoachResultColumn = {
key: "day",
kind: "period",
labelKey: "coach.result.column.day",
label: "Day",
};
const READINGS: CoachResultColumn = {
key: "readings",
kind: "count",
labelKey: "coach.result.column.readings",
label: "Readings",
};
function valueColumn(
key: string,
unit?: string,
kind: "number" | "count" = "number",
): CoachResultColumn {
return {
key,
kind,
labelKey: `coach.result.column.${key}`,
label: key,
...(unit ? { unit } : {}),
decimals: 0,
};
}
function dayKey(index: number): string {
const date = new Date(Date.UTC(2026, 0, 1 + index));
return date.toISOString().slice(0, 10);
}
function timeSeries(args: {
domain: CoachStepDomain;
columns: CoachResultColumn[];
rows: CoachResultCell[][];
granularity?: "day" | "week" | "month";
}): CoachResultTable {
return {
ref: "r1",
source: {
tool: "get_metric_table",
domain: args.domain,
window: "last90days",
period: "current",
granularity: args.granularity ?? "day",
},
shape: "timeSeries",
titleKey: "coach.result.title.byDay",
title: "By day",
rowCount: args.rows.length,
chartKind: null,
displayed: true,
columns: args.columns,
rows: args.rows,
truncated: false,
chart: null,
};
}
function daySeries(
domain: CoachStepDomain,
values: Array<number | null>,
unit?: string,
): CoachResultTable {
return timeSeries({
domain,
columns: [DAY, valueColumn("value", unit), READINGS],
rows: values.map((value, index) => [
dayKey(index),
value,
value === null ? null : 1,
]),
});
}
function categories(
counts: Array<number | null>,
names?: string[],
): CoachResultTable {
return {
ref: "r2",
source: {
tool: "get_workouts",
domain: "workouts",
window: "last30days",
period: "current",
},
shape: "categoryCounts",
titleKey: "coach.result.title.workoutsBySport",
title: "Workouts by sport",
rowCount: counts.length,
chartKind: null,
displayed: true,
columns: [
{
key: "sport",
kind: "category",
labelKey: "coach.result.column.sport",
label: "Sport",
},
valueColumn("sessions", undefined, "count"),
valueColumn("duration", "min"),
],
rows: counts.map((count, index) => [
names?.[index] ?? `sport-${index + 1}`,
count,
count === null ? null : count * 30,
]),
truncated: false,
chart: null,
};
}
const LABELS: DistributionLabels = {
localeTag: "en-US",
title: "Pulse: how often each range came up",
range: "Range",
count: "Times",
bin: (from, to, unit) => `${from}–${to} ${unit}`,
};
describe("deriveChartSpec: the row floor and ceiling", () => {
it("draws nothing for a single row", () => {
expect(deriveChartSpec(daySeries("pulse", [62]))).toBeNull();
});
it("draws nothing for 401 rows", () => {
const values = Array.from({ length: 401 }, (_, i) => 60 + (i % 10));
expect(deriveChartSpec(daySeries("pulse", values))).toBeNull();
});
it("draws 400 rows", () => {
const values = Array.from({ length: 400 }, (_, i) => 60 + (i % 10));
expect(deriveChartSpec(daySeries("pulse", values))?.kind).toBe("line");
});
it("draws nothing when no column is numeric", () => {
const table = timeSeries({
domain: "pulse",
columns: [DAY, { ...valueColumn("note"), kind: "category" }],
rows: [
[dayKey(0), "a"],
[dayKey(1), "b"],
],
});
expect(deriveChartSpec(table)).toBeNull();
});
it("draws nothing when every period is empty", () => {
expect(deriveChartSpec(daySeries("pulse", [null, null, null]))).toBeNull();
});
it("draws nothing for a time series without a period column", () => {
const table = timeSeries({
domain: "pulse",
columns: [valueColumn("value", "bpm")],
rows: [[60], [61]],
});
expect(deriveChartSpec(table)).toBeNull();
});
});
describe("deriveChartSpec: time series", () => {
it("draws a level metric as a line over the period column", () => {
expect(
deriveChartSpec(daySeries("weight", [80, 80.4, 79.9], "kg")),
).toEqual({ kind: "line", x: "day", series: ["value"] });
});
it("draws a total as vertical bars", () => {
expect(
deriveChartSpec(daySeries("steps", [8000, 12000, 5400], "steps")),
).toEqual({
kind: "bar",
x: "day",
series: ["value"],
orientation: "vertical",
});
});
it("draws blood pressure as two series in one unit", () => {
const table = timeSeries({
domain: "bp",
columns: [
DAY,
valueColumn("systolic", "mmHg"),
valueColumn("diastolic", "mmHg"),
READINGS,
],
rows: [
[dayKey(0), 128, 82, 2],
[dayKey(1), 124, 80, 1],
],
});
expect(deriveChartSpec(table)).toEqual({
kind: "line",
x: "day",
series: ["systolic", "diastolic"],
});
});
it("keeps mixed units off one chart: the first series alone", () => {
const table = timeSeries({
domain: "weight",
columns: [
DAY,
valueColumn("value", "kg"),
valueColumn("fat", "%"),
READINGS,
],
rows: [
[dayKey(0), 80, 22],
[dayKey(1), 79.5, 21.8],
],
});
expect(deriveChartSpec(table)).toEqual({
kind: "line",
x: "day",
series: ["value"],
});
});
it("draws at most two series, the first compatible pair", () => {
const table = timeSeries({
domain: "hrv",
columns: [
DAY,
valueColumn("sdnn", "ms"),
valueColumn("other", "%"),
valueColumn("rmssd", "ms"),
valueColumn("third", "ms"),
],
rows: [
[dayKey(0), 40, 1, 30, 20],
[dayKey(1), 42, 2, 31, 21],
],
});
expect(deriveChartSpec(table)).toEqual({
kind: "line",
x: "day",
series: ["sdnn", "rmssd"],
});
});
it("leaves the reading count off the chart", () => {
const spec = deriveChartSpec(daySeries("pulse", [60, 62], "bpm"));
expect(spec && "series" in spec ? spec.series : []).not.toContain(
"readings",
);
});
it("draws a single reading as a bar, not a lone dot", () => {
expect(
deriveChartSpec(daySeries("pulse", [null, 62, null], "bpm")),
).toEqual({
kind: "bar",
x: "day",
series: ["value"],
orientation: "vertical",
});
});
it("places every scope source as a level or a total", () => {
expect(METRIC_AGGREGATION_KIND.steps).toBe("total");
expect(METRIC_AGGREGATION_KIND.daylight).toBe("total");
expect(METRIC_AGGREGATION_KIND.sleep).toBe("level");
expect(METRIC_AGGREGATION_KIND.mood).toBe("level");
});
});
describe("deriveChartSpec: category counts", () => {
it("draws up to six categories as vertical bars of the count column", () => {
expect(deriveChartSpec(categories([3, 5, 1, 2, 4, 6]))).toEqual({
kind: "bar",
x: "sport",
series: ["sessions"],
orientation: "vertical",
});
});
it("turns horizontal above six categories", () => {
const spec = deriveChartSpec(categories([3, 5, 1, 2, 4, 6, 7]));
expect(spec).toMatchObject({ kind: "bar", orientation: "horizontal" });
});
it("draws nothing for a fractional count", () => {
expect(deriveChartSpec(categories([3, 1.5]))).toBeNull();
});
it("draws nothing for a negative count", () => {
expect(deriveChartSpec(categories([3, -1]))).toBeNull();
});
it("draws nothing without a count", () => {
expect(deriveChartSpec(categories([null, null]))).toBeNull();
});
it("folds everything past the top eight into one other bar", () => {
const counts = [1, 9, 2, 8, 3, 7, 4, 6, 5, 10, 1];
const table = categories(counts);
const spec = deriveChartSpec(table);
if (spec?.kind !== "bar") throw new Error("expected a bar chart");
const rows = chartCategoryRows(table, spec, "Other");
expect(rows).toHaveLength(CATEGORY_TOP_N + 1);
expect(rows.slice(0, 3).map((row) => row.value)).toEqual([10, 9, 8]);
expect(rows[CATEGORY_TOP_N]).toEqual({
label: "Other",
value: 2 + 1 + 1,
other: true,
});
const total = counts.reduce((sum, value) => sum + value, 0);
expect(rows.reduce((sum, row) => sum + row.value, 0)).toBe(total);
});
it("adds no other bar when every category fits", () => {
const table = categories([2, 1, 3]);
const spec = deriveChartSpec(table);
if (spec?.kind !== "bar") throw new Error("expected a bar chart");
expect(
chartCategoryRows(table, spec, "Other").some((row) => row.other),
).toBe(false);
});
});
describe("deriveChartSpec: shapes without a chart of their own", () => {
it("draws nothing for a list of single values (lab results)", () => {
const table: CoachResultTable = {
...categories([1, 2]),
shape: "single",
};
expect(deriveChartSpec(table)).toBeNull();
});
it("draws nothing for a distribution without its bins", () => {
const table: CoachResultTable = {
...categories([1, 2]),
shape: "distribution",
chart: null,
};
expect(deriveChartSpec(table)).toBeNull();
});
});
describe("buildHistogram", () => {
const spread = (from: number, step: number, n: number) =>
Array.from({ length: n }, (_, i) => from + (i % 7) * step);
it("needs at least ten values", () => {
expect(buildHistogram(spread(60, 3, 9), "pulse", "bpm")).toBeNull();
expect(buildHistogram(spread(60, 3, 10), "pulse", "bpm")).not.toBeNull();
});
it.each([
["bp", "mmHg", 110, 5],
["pulse", "bpm", 55, 5],
["weight", "kg", 79, 0.5],
["sleep", "min", 360, 30],
["glucose", "mg/dL", 85, 10],
["steps", "steps", 4000, 1000],
] as const)("bins %s in its fixed width", (domain, unit, base, width) => {
const values = Array.from({ length: 12 }, (_, i) => base + i * width * 0.7);
const bins = buildHistogram(values, domain, unit);
if (!bins) throw new Error("expected bins");
for (const bin of bins) expect(bin.to - bin.from).toBeCloseTo(width, 9);
expect(bins[0].from % width).toBeCloseTo(0, 9);
});
it("counts every value exactly once, the highest in the last bin", () => {
const values = [120, 121, 124.9, 125, 126, 130, 131, 140, 140, 118];
const bins = buildHistogram(values, "bp", "mmHg");
if (!bins) throw new Error("expected bins");
expect(bins.reduce((sum, bin) => sum + bin.count, 0)).toBe(values.length);
expect(bins[0]).toEqual({ from: 115, to: 120, count: 1 });
expect(bins.find((bin) => bin.from === 120)?.count).toBe(3);
expect(bins.find((bin) => bin.from === 125)?.count).toBe(2);
expect(bins[bins.length - 1]).toEqual({ from: 140, to: 145, count: 2 });
});
it("keeps an empty bin between readings as a zero", () => {
const values = [...Array(5).fill(60), ...Array(5).fill(80)];
const bins = buildHistogram(values, "pulse", "bpm");
if (!bins) throw new Error("expected bins");
expect(bins.map((bin) => bin.count)).toEqual([5, 0, 0, 0, 5]);
});
it("widens a fixed width until at most twenty bins remain", () => {
const values = Array.from({ length: 30 }, (_, i) => 1000 + i * 1000);
const bins = buildHistogram(values, "steps", "steps");
if (!bins) throw new Error("expected bins");
expect(bins.length).toBeLessThanOrEqual(HISTOGRAM_MAX_BINS);
expect(bins[0].to - bins[0].from).toBe(2000);
});
it("ignores the glucose width when the table is not in mg/dL", () => {
const values = Array.from({ length: 20 }, (_, i) => 4.5 + i * 0.2);
const bins = buildHistogram(values, "glucose", "mmol/L");
if (!bins) throw new Error("expected bins");
expect(bins[0].to - bins[0].from).toBeLessThan(10);
expect(bins.length).toBeGreaterThanOrEqual(HISTOGRAM_MIN_BINS);
});
it("holds Freedman–Diaconis to five to twenty bins", () => {
// A tight cluster with one far outlier: raw FD would give hundreds.
const wide = [...Array.from({ length: 200 }, (_, i) => 50 + (i % 5)), 900];
const wideBins = buildHistogram(wide, "vo2_max", undefined);
// Evenly spread values over a short range: raw FD would give two.
const narrow = Array.from({ length: 12 }, (_, i) => 30 + i);
const narrowBins = buildHistogram(narrow, "vo2_max", undefined);
for (const bins of [wideBins, narrowBins]) {
if (!bins) throw new Error("expected bins");
expect(bins.length).toBeGreaterThanOrEqual(HISTOGRAM_MIN_BINS);
expect(bins.length).toBeLessThanOrEqual(HISTOGRAM_MAX_BINS);
}
});
it("puts identical values in one bin", () => {
const bins = buildHistogram(Array(12).fill(37.2), "body_temp", "°C");
expect(bins).toHaveLength(1);
expect(bins?.[0].count).toBe(12);
});
it("is deterministic", () => {
const values = Array.from({ length: 50 }, (_, i) => 60 + ((i * 7) % 23));
expect(buildHistogram(values, "pulse", "bpm")).toEqual(
buildHistogram([...values].reverse(), "pulse", "bpm"),
);
});
});
describe("buildDistributionTable", () => {
const values = [58, 61, 62, 64, 66, 66, 67, 70, 71, 74, null, 63];
it("turns a day table into counts per range with a histogram", () => {
const table = buildDistributionTable(
daySeries("pulse", values, "bpm"),
LABELS,
);
if (!table) throw new Error("expected a distribution");
expect(table.shape).toBe("distribution");
expect(table.titleKey).toBe("coach.result.title.distribution");
expect(table.columns.map((column) => column.key)).toEqual([
"range",
"count",
]);
expect(table.rows[0]).toEqual(["55–60 bpm", 1]);
expect(table.chart).toMatchObject({
kind: "histogram",
column: "value",
unit: "bpm",
});
expect(table.chartKind).toBe("histogram");
expect(table.rowCount).toBe(table.rows.length);
const counted = table.rows.reduce(
(sum, row) => sum + (row[1] as number),
0,
);
expect(counted).toBe(values.filter((v) => v !== null).length);
// The stored histogram survives a second pass through the heuristic.
expect(deriveChartSpec(table)).toEqual(table.chart);
});
it("formats bin edges in the reply's locale", () => {
const weights = Array.from({ length: 12 }, (_, i) => 80 + i * 0.3);
const table = buildDistributionTable(daySeries("weight", weights, "kg"), {
...LABELS,
localeTag: "de-DE",
});
expect(table?.rows[0]?.[0]).toBe("80,0–80,5 kg");
});
it("builds nothing from fewer than ten values", () => {
expect(
buildDistributionTable(daySeries("pulse", [60, 61, 62], "bpm"), LABELS),
).toBeNull();
});
it("builds nothing from a table by week", () => {
const table = timeSeries({
domain: "pulse",
granularity: "week",
columns: [DAY, valueColumn("value", "bpm")],
rows: values.map((value, index) => [dayKey(index * 7), value]),
});
expect(buildDistributionTable(table, LABELS)).toBeNull();
});
it("builds nothing from category counts", () => {
expect(
buildDistributionTable(
categories([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
LABELS,
),
).toBeNull();
});
it("names the column it binned when the table has two", () => {
const table = timeSeries({
domain: "bp",
columns: [
DAY,
{ ...valueColumn("systolic", "mmHg"), label: "Systolic" },
{ ...valueColumn("diastolic", "mmHg"), label: "Diastolic" },
],
rows: Array.from({ length: 12 }, (_, i) => [dayKey(i), 120 + i, 80]),
});
const distribution = buildDistributionTable(table, LABELS);
expect(distribution?.columns[0]?.label).toBe("Range (Systolic)");
expect(distribution?.chart).toMatchObject({ column: "systolic" });
});
});
@@ -275,6 +275,70 @@ describe("show_result", () => {
expect(readDailySeries).not.toHaveBeenCalled();
});
it("keeps the server's chart when no view is asked for", async () => {
readMessageResults.mockResolvedValue([STORED]);
const result = await executeCoachTool({
userId: "u1",
name: "show_result",
rawArguments: JSON.stringify({ ref: "m2.r1" }),
turn: turn(),
});
expect(result.table).toMatchObject({
shape: "timeSeries",
chart: { kind: "line", x: "day", series: ["value"] },
chartKind: "line",
});
});
it("shows the table without a chart for view table", async () => {
readMessageResults.mockResolvedValue([STORED]);
const result = await executeCoachTool({
userId: "u1",
name: "show_result",
rawArguments: JSON.stringify({ ref: "m2.r1", view: "table" }),
turn: turn(),
});
expect(result.table).toMatchObject({
rows: STORED.rows,
chart: null,
chartKind: null,
});
});
it("shows a day table as counts per range for view chart", async () => {
const days = [58, 61, 62, 64, 66, 66, 67, 70, 71, 74, 63, 65];
readMessageResults.mockResolvedValue([
{
...STORED,
rowCount: days.length,
rows: days.map((value, index) => [
`2026-09-${String(10 + index).padStart(2, "0")}`,
value,
]),
},
]);
const result = await executeCoachTool({
userId: "u1",
name: "show_result",
rawArguments: JSON.stringify({ ref: "m2.r1", view: "chart" }),
turn: turn(),
});
expect(result.table).toMatchObject({
ref: "r1",
shape: "distribution",
titleKey: "coach.result.title.distribution",
chartKind: "histogram",
chart: { kind: "histogram", column: "value", unit: "bpm" },
reusedFrom: { messageId: "m-a2", ref: "r1" },
});
expect(result.table?.rows[0]).toEqual(["55–60 bpm", 1]);
// The model reads the counts it can talk about, not the days.
expect(result.data).toMatchObject({
shape: "distribution",
stats: { count: { total: days.length } },
});
});
it("answers unknown_result for a name this conversation does not hold", async () => {
// A name that another conversation of the same account might hold.
const result = await executeCoachTool({
+478 -6
View File
@@ -4,12 +4,484 @@
* category counts, a histogram for a distribution, or none (table only)
* when the table is too short, too long or has no numeric column.
*
* Pure. Not built yet: every table stays table-only.
* Pure and deterministic: the same table always gets the same chart, and the
* model never picks one. The model can only ask, through `show_result`'s
* `view`, for an earlier day table as a distribution (`buildDistributionTable`)
* or for the table without a chart.
*
* Imports types only, so the client can use the category view below
* (`chartCategoryRows`) without pulling anything server-side in.
*/
import type { CoachChartSpec, CoachResultTable } from "@/lib/ai/coach/types";
import type {
CoachChartSpec,
CoachResultCell,
CoachResultColumn,
CoachResultTable,
CoachScopeSource,
CoachStepDomain,
} from "@/lib/ai/coach/types";
export function deriveChartSpec(
_table: Omit<CoachResultTable, "chart" | "chartKind">,
): CoachChartSpec | null {
return null;
/** Fewer rows than this: table only. */
export const CHART_MIN_ROWS = 2;
/** More rows than this: table only (the table's own row cap). */
export const CHART_MAX_ROWS = 400;
/** Category bars turn horizontal above this many categories. */
export const CATEGORY_VERTICAL_MAX = 6;
/** Category bars show this many categories; the rest fold into "other". */
export const CATEGORY_TOP_N = 8;
/** A histogram needs at least this many values. */
export const HISTOGRAM_MIN_VALUES = 10;
/** Bin-count bounds for a histogram without a fixed width. */
export const HISTOGRAM_MIN_BINS = 5;
export const HISTOGRAM_MAX_BINS = 20;
/**
* How a metric's periods add up: a level (a reading that stands for the
* period: blood pressure, weight, sleep) is drawn as a line; a total (a sum
* over the period: steps, energy) as bars. A record over every source, so a
* new source does not compile until it is placed.
*/
export const METRIC_AGGREGATION_KIND: Readonly<
Record<CoachScopeSource, "level" | "total">
> = {
bp: "level",
weight: "level",
pulse: "level",
mood: "level",
compliance: "level",
hrv: "level",
sleep: "level",
resting_hr: "level",
steps: "total",
active_energy: "total",
flights: "total",
distance: "total",
vo2_max: "level",
body_temp: "level",
walking_hr: "level",
respiratory_rate: "level",
spo2: "level",
pulse_wave_velocity: "level",
vascular_age: "level",
body_fat: "level",
fat_mass: "level",
fat_free_mass: "level",
muscle_mass: "level",
lean_body_mass: "level",
bone_mass: "level",
total_body_water: "level",
bmi: "level",
visceral_fat: "level",
glucose: "level",
walking_steadiness: "level",
walking_asymmetry: "level",
walking_double_support: "level",
walking_step_length: "level",
walking_speed: "level",
audio_env: "level",
audio_headphone: "level",
audio_event: "total",
daylight: "total",
skin_temp: "level",
workouts: "total",
};
function aggregationKind(domain: CoachStepDomain): "level" | "total" {
return (
(METRIC_AGGREGATION_KIND as Partial<Record<string, "level" | "total">>)[
domain
] ?? "level"
);
}
/**
* Fixed bin widths for the metrics people ask "how often" about, in the
* unit the table carries. Glucose only when the table is in mg/dL; any
* other metric or unit falls back to the Freedman–Diaconis rule.
*/
const NICE_BIN_WIDTH: Readonly<
Partial<Record<CoachScopeSource, { width: number; unit?: string }>>
> = {
bp: { width: 5, unit: "mmHg" },
pulse: { width: 5, unit: "bpm" },
resting_hr: { width: 5, unit: "bpm" },
walking_hr: { width: 5, unit: "bpm" },
weight: { width: 0.5, unit: "kg" },
sleep: { width: 30, unit: "min" },
glucose: { width: 10, unit: "mg/dL" },
steps: { width: 1000 },
};
type ChartInput = Omit<CoachResultTable, "chart" | "chartKind"> & {
chart?: CoachChartSpec | null;
};
function isNumericCell(cell: CoachResultCell | undefined): cell is number {
return typeof cell === "number" && Number.isFinite(cell);
}
function columnIndex(table: ChartInput, key: string): number {
return table.columns.findIndex((column) => column.key === key);
}
/**
* The series a time series is drawn with: its value columns, and only the
* first one plus one partner in the same unit — two axes with different
* units on one chart read as a comparison they are not.
*/
function timeSeriesColumns(table: ChartInput): CoachResultColumn[] {
const values = table.columns.filter((column) => column.kind === "number");
const candidates =
values.length > 0
? values
: table.columns.filter((column) => column.kind === "count");
const [first, ...rest] = candidates;
if (!first) return [];
const partner = rest.find((column) => column.unit === first.unit);
return partner ? [first, partner] : [first];
}
function timeSeriesSpec(table: ChartInput): CoachChartSpec | null {
const x = table.columns.find((column) => column.kind === "period");
if (!x) return null;
const series = timeSeriesColumns(table);
if (series.length === 0) return null;
const indexes = series.map((column) => columnIndex(table, column.key));
const points = table.rows.filter((row) =>
indexes.some((index) => isNumericCell(row[index])),
).length;
if (points === 0) return null;
const keys = series.map((column) => column.key);
// A line needs two points; one reading is a single bar, not a dot.
if (aggregationKind(table.source.domain) === "total" || points < 2) {
return { kind: "bar", x: x.key, series: keys, orientation: "vertical" };
}
return { kind: "line", x: x.key, series: keys };
}
function categorySpec(table: ChartInput): CoachChartSpec | null {
const x = table.columns.find((column) => column.kind === "category");
const counts = table.columns.find((column) => column.kind === "count");
if (!x || !counts) return null;
const index = columnIndex(table, counts.key);
let any = false;
for (const row of table.rows) {
const cell = row[index];
if (cell === null || cell === undefined) continue;
if (!isNumericCell(cell) || cell < 0 || !Number.isInteger(cell)) {
return null;
}
any = true;
}
if (!any) return null;
return {
kind: "bar",
x: x.key,
series: [counts.key],
orientation:
table.rows.length > CATEGORY_VERTICAL_MAX ? "horizontal" : "vertical",
};
}
/**
* The chart for a table, or null for table only.
*
* 1. Fewer than 2 or more than 400 rows, or no numeric column: none.
* 2. A time series: a line for a level metric, vertical bars for a total;
* at most two series, and only in one unit.
* 3. Category counts: bars, horizontal above six categories (the top eight
* and "other" are drawn; `chartCategoryRows`). Counts must be
* non-negative whole numbers.
* 4. A distribution keeps the histogram it was built with
* (`buildDistributionTable`); its bins cannot be read back from the rows.
* 5. Anything else (a list of lab results): none.
*/
export function deriveChartSpec(table: ChartInput): CoachChartSpec | null {
if (
table.rows.length < CHART_MIN_ROWS ||
table.rows.length > CHART_MAX_ROWS
) {
return null;
}
switch (table.shape) {
case "timeSeries":
return timeSeriesSpec(table);
case "categoryCounts":
return categorySpec(table);
case "distribution":
return table.chart?.kind === "histogram" ? table.chart : null;
default:
return null;
}
}
// ── Histogram ─────────────────────────────────────────────────────────────
export interface HistogramBin {
from: number;
to: number;
count: number;
}
/** Strips float noise from a bin edge (0.1 + 0.2 and the like). */
function clean(value: number): number {
return Number(value.toFixed(10));
}
/** Linear-interpolated quantile of a sorted list. */
function quantile(sorted: number[], q: number): number {
const position = (sorted.length - 1) * q;
const lower = Math.floor(position);
const upper = Math.ceil(position);
const low = sorted[lower] ?? 0;
const high = sorted[upper] ?? low;
return low + (high - low) * (position - lower);
}
/** The "nice" widths: 1, 2, 2.5 and 5 times a power of ten. */
const NICE_STEPS = [1, 2, 2.5, 5];
function niceAtLeast(value: number): number {
const power = 10 ** Math.floor(Math.log10(value));
for (const step of NICE_STEPS) {
if (step * power >= value - 1e-12) return clean(step * power);
}
return clean(10 * power);
}
function niceBelow(width: number): number {
const power = 10 ** Math.floor(Math.log10(width));
const mantissa = clean(width / power);
const index = NICE_STEPS.findIndex((step) => step >= mantissa);
if (index > 0) return clean(NICE_STEPS[index - 1] * power);
return clean(5 * (power / 10));
}
function niceAbove(width: number): number {
return niceAtLeast(clean(width * 1.000001));
}
function binCount(min: number, max: number, width: number): number {
const start = Math.floor(clean(min / width)) * width;
return Math.floor(clean((max - start) / width)) + 1;
}
/** Freedman–Diaconis on a nice width, held to 5..20 bins. */
function freedmanDiaconisWidth(sorted: number[]): number {
const min = sorted[0];
const max = sorted[sorted.length - 1];
const range = max - min;
const iqr = quantile(sorted, 0.75) - quantile(sorted, 0.25);
const raw =
iqr > 0 ? (2 * iqr) / Math.cbrt(sorted.length) : range / HISTOGRAM_MIN_BINS;
let width = niceAtLeast(raw);
for (let guard = 0; guard < 64; guard += 1) {
const bins = binCount(min, max, width);
if (bins > HISTOGRAM_MAX_BINS) width = niceAbove(width);
else if (bins < HISTOGRAM_MIN_BINS && width > 1e-9)
width = niceBelow(width);
else break;
}
return width;
}
/**
* Bins for a set of values: a fixed width for the metrics that have one
* (doubled until at most 20 bins remain), Freedman–Diaconis otherwise.
* Bins run from the one holding the lowest value to the one holding the
* highest; empty bins in between stay, with a count of 0. Each bin holds
* `from <= v < to`. Fewer than ten values: null, not a histogram of noise.
*/
export function buildHistogram(
values: readonly number[],
domain: CoachStepDomain,
unit: string | undefined,
): HistogramBin[] | null {
const finite = values.filter((value) => Number.isFinite(value));
if (finite.length < HISTOGRAM_MIN_VALUES) return null;
const sorted = [...finite].sort((a, b) => a - b);
const min = sorted[0];
const max = sorted[sorted.length - 1];
const fixed = (
NICE_BIN_WIDTH as Partial<Record<string, { width: number; unit?: string }>>
)[domain];
let width: number;
if (fixed && (fixed.unit === undefined || fixed.unit === unit)) {
width = fixed.width;
while (binCount(min, max, width) > HISTOGRAM_MAX_BINS) width *= 2;
} else if (max === min) {
width = niceAtLeast(Math.max(Math.abs(min), 1) / 10);
} else {
width = freedmanDiaconisWidth(sorted);
}
const start = clean(Math.floor(clean(min / width)) * width);
const count = binCount(min, max, width);
const bins: HistogramBin[] = Array.from({ length: count }, (_, i) => ({
from: clean(start + i * width),
to: clean(start + (i + 1) * width),
count: 0,
}));
for (const value of sorted) {
const index = Math.min(
count - 1,
Math.max(0, Math.floor(clean((value - start) / width))),
);
bins[index].count += 1;
}
return bins;
}
/** Decimals a bin edge needs to be told apart from its neighbour. */
export function binDecimals(bins: readonly HistogramBin[]): number {
let decimals = 0;
for (const bin of bins) {
for (const edge of [bin.from, bin.to]) {
const text = String(edge);
const dot = text.indexOf(".");
if (dot >= 0) decimals = Math.max(decimals, text.length - dot - 1);
}
}
return Math.min(decimals, 3);
}
/** The words a distribution table is labelled with, in the reply's locale. */
export interface DistributionLabels {
/** BCP 47 tag the bin edges are formatted in. */
localeTag: string;
title: string;
/** Heading of the range column. */
range: string;
/** Heading of the count column. */
count: string;
/** One bin's label, e.g. `120–125 mmHg`. */
bin: (from: string, to: string, unit: string) => string;
}
/**
* A day table as a distribution: how many days fell into each range of its
* first value column. Only a day table (one value per day) qualifies, and
* only with at least ten values; otherwise null and the table keeps its
* own chart.
*/
export function buildDistributionTable(
table: CoachResultTable,
labels: DistributionLabels,
): CoachResultTable | null {
if (table.shape !== "timeSeries" || table.source.granularity !== "day") {
return null;
}
const column = table.columns.find((candidate) => candidate.kind === "number");
if (!column) return null;
const index = columnIndex(table, column.key);
const values = table.rows
.map((row) => row[index])
.filter((cell): cell is number => isNumericCell(cell));
const bins = buildHistogram(values, table.source.domain, column.unit);
if (!bins) return null;
const decimals = binDecimals(bins);
const format = new Intl.NumberFormat(labels.localeTag, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
useGrouping: true,
});
const unit = column.unit ?? "";
const valueColumns = table.columns.filter(
(candidate) => candidate.kind === "number",
);
const rows: CoachResultCell[][] = bins.map((bin) => [
labels.bin(format.format(bin.from), format.format(bin.to), unit).trim(),
bin.count,
]);
const chart: CoachChartSpec = {
kind: "histogram",
column: column.key,
...(column.unit ? { unit: column.unit } : {}),
bins,
};
const withChart = rows.length >= CHART_MIN_ROWS ? chart : null;
return {
ref: table.ref,
source: table.source,
shape: "distribution",
titleKey: "coach.result.title.distribution",
title: labels.title,
rowCount: rows.length,
chartKind: withChart ? "histogram" : null,
displayed: table.displayed,
...(table.reusedFrom ? { reusedFrom: table.reusedFrom } : {}),
columns: [
{
key: "range",
kind: "category",
labelKey: "coach.result.column.range",
label:
valueColumns.length > 1
? `${labels.range} (${column.label})`
: labels.range,
},
{
key: "count",
kind: "count",
labelKey: "coach.result.column.count",
label: labels.count,
},
],
rows,
truncated: false,
chart: withChart,
};
}
// ── The category view (client and server) ─────────────────────────────────
export interface ChartCategoryRow {
label: string;
value: number;
/** True for the folded "other" bar. */
other: boolean;
}
/**
* The bars a category chart draws: the eight largest categories, largest
* first, and the rest summed into one "other" bar. A category without a
* count is left out of the bars (the table still lists it).
*/
export function chartCategoryRows(
table: Pick<CoachResultTable, "columns" | "rows">,
spec: Extract<CoachChartSpec, { kind: "bar" }>,
otherLabel: string,
): ChartCategoryRow[] {
const xIndex = table.columns.findIndex((column) => column.key === spec.x);
const yIndex = table.columns.findIndex(
(column) => column.key === spec.series[0],
);
if (xIndex < 0 || yIndex < 0) return [];
const entries = table.rows
.map((row, order) => ({
label: String(row[xIndex] ?? ""),
value: row[yIndex],
order,
}))
.filter((entry): entry is { label: string; value: number; order: number } =>
isNumericCell(entry.value),
)
.sort((a, b) => b.value - a.value || a.order - b.order);
const top = entries.slice(0, CATEGORY_TOP_N).map((entry) => ({
label: entry.label,
value: entry.value,
other: false,
}));
const rest = entries.slice(CATEGORY_TOP_N);
if (rest.length === 0) return top;
return [
...top,
{
label: otherLabel,
value: rest.reduce((sum, entry) => sum + entry.value, 0),
other: true,
},
];
}
+2 -1
View File
@@ -394,7 +394,8 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
view: {
type: "string",
enum: ["table", "chart"],
description: "How the person asked to see it, when they said.",
description:
"How the person asked to see it, when they said. table: without a chart. chart: with one; an earlier table by day is shown as how often its values fell into each range.",
},
},
},
+52 -9
View File
@@ -70,8 +70,18 @@ import {
type ResultRefAllocator,
} from "@/lib/ai/coach/results/refs";
import { isCoachDomainWithheld } from "@/lib/ai/coach/results/domain-module";
import { COACH_RESULT_TITLE_KEYS } from "@/lib/ai/coach/dialog-keys";
import {
COACH_RESULT_COLUMN_KEYS,
COACH_RESULT_TITLE_KEYS,
COACH_RESULT_UI_KEYS,
coachDomainLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import {
buildDistributionTable,
deriveChartSpec,
} from "@/lib/ai/coach/results/chart-spec";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveIntlLocale } from "@/lib/format-locale";
import { DEFAULT_WINDOW } from "@/lib/ai/coach/snapshot-cache";
import {
getMetricSeriesArgsSchema,
@@ -805,6 +815,37 @@ async function getMetricTable(
};
}
/**
* A table shown again, with the chart its `view` asks for. No view: the chart
* the server picks for any table. `table`: no chart. `chart`: a day table
* becomes how often each range came up (a histogram); any other table, or a
* day table with too few values, keeps the chart the server picks. The model
* then reads the summary of what is shown.
*/
function withResultView(
table: CoachResultTable,
view: "table" | "chart" | undefined,
locale: Locale,
): CoachResultTable {
if (view === "table") return { ...table, chart: null, chartKind: null };
if (view === "chart") {
const { t } = getServerTranslator(locale);
const distribution = buildDistributionTable(table, {
localeTag: resolveIntlLocale(locale),
title: t(COACH_RESULT_TITLE_KEYS.distribution, {
metric: t(coachDomainLabelKey(table.source.domain)),
}),
range: t(COACH_RESULT_COLUMN_KEYS.range),
count: t(COACH_RESULT_COLUMN_KEYS.count),
bin: (from, to, unit) =>
t(COACH_RESULT_UI_KEYS.histogramBin, { from, to, unit }),
});
if (distribution) return distribution;
}
const chart = deriveChartSpec(table);
return { ...table, chart, chartKind: chart?.kind ?? null };
}
/**
* `show_result` — an earlier table of THIS conversation, shown again. The
* name resolves only against the tables the turn's own conversation holds
@@ -850,14 +891,16 @@ async function showResult(
}
const ref = turn.refs.next();
if (!ref) return { present: false, reason: "result_limit" };
const table: CoachResultTable = {
...entry,
ref,
displayed: false,
chart: null,
chartKind: null,
reusedFrom: { messageId: target.messageId, ref: target.ref },
};
const table = withResultView(
{
...entry,
ref,
displayed: false,
reusedFrom: { messageId: target.messageId, ref: target.ref },
},
parsed.data.view,
turn.locale,
);
return {
present: true,
resultRef: ref,
+6 -1
View File
@@ -132,8 +132,13 @@ function stepRecorder(emitter: TurnEmitter): {
};
}
/** v1.39.4 — a projected table with the chart the server chose for it. */
/**
* v1.39.4 — a projected table with the chart the server chose for it. A
* table shown again already carries the chart its `show_result` view asked
* for, and keeps it.
*/
function withChart(table: CoachResultTable): CoachResultTable {
if (table.reusedFrom) return table;
const chart = deriveChartSpec(table);
return { ...table, chart, chartKind: chart?.kind ?? null };
}