diff --git a/src/lib/ai/coach/results/__tests__/chart-spec.test.ts b/src/lib/ai/coach/results/__tests__/chart-spec.test.ts new file mode 100644 index 000000000..33c723f88 --- /dev/null +++ b/src/lib/ai/coach/results/__tests__/chart-spec.test.ts @@ -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, + 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, + 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" }); + }); +}); diff --git a/src/lib/ai/coach/results/__tests__/executor-results.test.ts b/src/lib/ai/coach/results/__tests__/executor-results.test.ts index be0355b7b..2b6b70087 100644 --- a/src/lib/ai/coach/results/__tests__/executor-results.test.ts +++ b/src/lib/ai/coach/results/__tests__/executor-results.test.ts @@ -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({ diff --git a/src/lib/ai/coach/results/chart-spec.ts b/src/lib/ai/coach/results/chart-spec.ts index aa3b6e67e..02151bf10 100644 --- a/src/lib/ai/coach/results/chart-spec.ts +++ b/src/lib/ai/coach/results/chart-spec.ts @@ -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, -): 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 +> = { + 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>)[ + 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> +> = { + 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 & { + 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> + )[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, + spec: Extract, + 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, + }, + ]; } diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts index bbe047f9c..0c21f57be 100644 --- a/src/lib/ai/coach/tools/definitions.ts +++ b/src/lib/ai/coach/tools/definitions.ts @@ -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.", }, }, }, diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index bd7f46a9f..f08c1785a 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -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, diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index 347d75af8..77828d80a 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -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 }; }