From d11c165675f8409285d2ff324c50261f2771330e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 13:01:51 +0200 Subject: [PATCH] fix(coach): read the cycle contrast and the metric table in the reader's units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cycle block's headline contrast labelled weight in kg and the temperatures in °C, and left a glucose contrast without any unit. The metric table, which the person sees and whose summary the model reads, listed weight in kilograms, temperatures in degrees Celsius, walking speed in metres per second and distance in metres. The cycle contrast now converts both means and the gap into the reader's units (the gap by the factor alone) and names the unit in its sentence. The table resolves each column's unit and decimals from the reader's preferences and converts every folded period value once; the snapshot carries the preferences it was written in, so the table and the snapshot of a turn always agree. Refs #1067 --- .../coach-availability-window-honesty.test.ts | 2 + .../ai/coach/__tests__/cycle-snapshot.test.ts | 29 +++++++ src/lib/ai/coach/__tests__/snapshot.test.ts | 4 + .../ai/coach/__tests__/tools-executor.test.ts | 2 + .../coach/__tests__/tools-inventory.test.ts | 2 + src/lib/ai/coach/cycle-snapshot.ts | 77 +++++++++++++++---- .../__tests__/executor-results.test.ts | 2 + .../__tests__/metric-table-tool.test.ts | 59 ++++++++++++++ src/lib/ai/coach/results/metric-table-tool.ts | 61 +++++++++++---- src/lib/ai/coach/snapshot.ts | 12 ++- src/lib/ai/coach/tools/executor.ts | 5 ++ tests/integration/coach-metric-table.test.ts | 5 ++ 12 files changed, 232 insertions(+), 28 deletions(-) diff --git a/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts index 800cba90b..06634c8c7 100644 --- a/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts +++ b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts @@ -11,6 +11,7 @@ * Dates are fixed and `now` is injected, so the fixture cannot slide out of its * window as the calendar moves. */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -118,6 +119,7 @@ function snapshot(sections: Record): CoachSnapshotResult { sections, provenance: { windows: [], metrics: [] }, referenceGrounding: null, + units: DEFAULT_UNIT_PREFERENCES, }; } diff --git a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts index 25869b76d..9b4aa84fc 100644 --- a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts @@ -191,3 +191,32 @@ describe("buildCycleSnapshotBlock", () => { ); }); }); + +describe("buildCycleSnapshotBlock — the reader's units", () => { + it("states a weight contrast in pounds for an imperial reader", async () => { + prismaMock.cycleProfile.findUnique.mockResolvedValue(profile()); + prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles()); + prismaMock.user.findUnique.mockResolvedValue({ + sourcePriorityJson: null, + unitPreference: "imperial", + }); + const weight = rhrMeasurements().map((row) => ({ + ...row, + type: "WEIGHT", + value: (row.value as number) + 10, + })); + prismaMock.measurement.findMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(weight); + const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW); + const insight = block!.phaseInsight!; + expect(insight.metric).toBe("weight"); + expect(insight.unit).toBe("lb"); + // Luteal ~73 kg, follicular ~67 kg: both means and the gap in pounds. + expect(insight.lutealAvg).toBeGreaterThan(155); + expect(insight.follicularAvg).toBeGreaterThan(145); + expect(insight.delta).toBeGreaterThan(12); + expect(insight.interpretation).toMatch(/runs about [\d.]+ lb higher/); + expect(insight.interpretation).not.toContain("kg"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 4ca883b76..29611e146 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -660,6 +660,10 @@ describe("buildCoachSnapshot", () => { "timelineRecentDays": 14 } }", + "units": { + "glucoseUnit": "mg/dL", + "system": "metric", + }, } `); } finally { diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index ff751c4ab..e7d22ab59 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -4,6 +4,7 @@ * structured `{ present: false }` for an absent domain — the hallucination * audit asserts the model can always tell "no data" from "data". */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; import { describe, expect, it, vi, beforeEach } from "vitest"; import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; @@ -86,6 +87,7 @@ function snapshot( sections, provenance: { windows: [], metrics: [] }, referenceGrounding, + units: DEFAULT_UNIT_PREFERENCES, }; } diff --git a/src/lib/ai/coach/__tests__/tools-inventory.test.ts b/src/lib/ai/coach/__tests__/tools-inventory.test.ts index f4d9a3947..2206540fb 100644 --- a/src/lib/ai/coach/__tests__/tools-inventory.test.ts +++ b/src/lib/ai/coach/__tests__/tools-inventory.test.ts @@ -4,6 +4,7 @@ * render a compact, brand-free, grounding-honest block. The model reads this to * know what is fetchable so it never invents a metric. */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; import { describe, expect, it, vi, beforeEach } from "vitest"; import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; @@ -37,6 +38,7 @@ function snapshot( ...(counts ? { counts: counts as never } : {}), }, referenceGrounding: null, + units: DEFAULT_UNIT_PREFERENCES, }; } diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts index 510638c23..fe6ee6202 100644 --- a/src/lib/ai/coach/cycle-snapshot.ts +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -21,6 +21,14 @@ * day" claim. */ import { prisma } from "@/lib/db"; +import { + applyDisplayTransform, + applyDisplayTransformDelta, + getReadingTransform, + isUnitSensitiveType, + resolveUnitPreferences, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; import { predictCycle, type NightlyTempInput } from "@/lib/cycle"; import { LUTEAL_DEFAULT, type CyclePhase } from "@/lib/cycle/types"; import { @@ -60,13 +68,16 @@ const METRIC_LABEL: Record = { skinTemperature: "skin temperature", }; +/** + * Labels for the display kinds no preference changes. Weight, the + * temperatures and glucose are read in the reader's units instead, through + * the measurement type's transform (see `headlineInReaderUnits`). + */ const UNIT_LABEL: Record = { hours: "h", steps: "steps", bpm: "bpm", ms: "ms", - kg: "kg", - celsius: "°C", }; interface PhaseInsightBlock { @@ -108,12 +119,42 @@ export interface CycleSnapshotBlock { phaseInsight: PhaseInsightBlock | null; } +/** + * The headline contrast's figures in the reader's units. The two means take + * the full transform, the difference the factor alone; a kind no preference + * touches keeps its numbers and its fixed label. + */ +function headlineInReaderUnits( + row: PhaseMetricCrosstabRow, + units: UnitPreferences, +): { lutealAvg: number; follicularAvg: number; delta: number; unit: string } { + const type = PHASE_CROSSTAB_METRICS[row.metricKey].type; + if (!isUnitSensitiveType(type)) { + return { + lutealAvg: row.lutealAvg, + follicularAvg: row.follicularAvg, + delta: row.delta, + unit: UNIT_LABEL[row.display] ?? "", + }; + } + const transform = getReadingTransform(type, units); + return { + lutealAvg: applyDisplayTransform(row.lutealAvg, transform), + follicularAvg: applyDisplayTransform(row.follicularAvg, transform), + delta: applyDisplayTransformDelta(row.delta, transform), + unit: transform.displayUnit, + }; +} + /** Descriptive, never-causal interpretation of the headline contrast. */ -function interpretHeadline(row: PhaseMetricCrosstabRow): string { +function interpretHeadline( + row: PhaseMetricCrosstabRow, + shown: { delta: number; unit: string }, +): string { const metric = METRIC_LABEL[row.metricKey] ?? row.metricKey; - const dir = row.delta >= 0 ? "higher" : "lower"; - const mag = Math.abs(row.delta); - const unit = UNIT_LABEL[row.display] ?? ""; + const dir = shown.delta >= 0 ? "higher" : "lower"; + const mag = Math.abs(shown.delta); + const unit = shown.unit; return `Your ${metric} runs about ${mag}${unit ? " " + unit : ""} ${dir} on luteal-phase days than follicular-phase days in your own data — a descriptive pattern, not a cause.`; } @@ -212,7 +253,11 @@ export async function buildCycleSnapshotBlock( }), prisma.user.findUnique({ where: { id: userId }, - select: { sourcePriorityJson: true }, + select: { + sourcePriorityJson: true, + unitPreference: true, + glucoseUnit: true, + }, }), ]); @@ -301,18 +346,24 @@ export async function buildCycleSnapshotBlock( let phaseInsight: PhaseInsightBlock | null = null; if (headlineRow) { - const cfg = PHASE_CROSSTAB_METRICS[headlineRow.metricKey]; + const shown = headlineInReaderUnits( + headlineRow, + resolveUnitPreferences({ + unitPreference: userRow?.unitPreference, + glucoseUnit: userRow?.glucoseUnit, + }), + ); phaseInsight = { metric: METRIC_LABEL[headlineRow.metricKey] ?? headlineRow.metricKey, - unit: UNIT_LABEL[cfg.display] ?? "", - lutealAvg: headlineRow.lutealAvg, - follicularAvg: headlineRow.follicularAvg, - delta: headlineRow.delta, + unit: shown.unit, + lutealAvg: shown.lutealAvg, + follicularAvg: shown.follicularAvg, + delta: shown.delta, confidence: headlineRow.confidence, qValue: headlineRow.qValue, lutealDays: headlineRow.lutealDays, follicularDays: headlineRow.follicularDays, - interpretation: interpretHeadline(headlineRow), + interpretation: interpretHeadline(headlineRow, shown), }; } 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 3ea6c0338..e7d2fe1f6 100644 --- a/src/lib/ai/coach/results/__tests__/executor-results.test.ts +++ b/src/lib/ai/coach/results/__tests__/executor-results.test.ts @@ -4,6 +4,7 @@ * turn belongs to, the older tools' projections, and the table never in * what the model reads. */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; @@ -59,6 +60,7 @@ function snapshot(sections: Record): CoachSnapshotResult { sections, provenance: { windows: [], metrics: [] }, referenceGrounding: null, + units: DEFAULT_UNIT_PREFERENCES, }; } diff --git a/src/lib/ai/coach/results/__tests__/metric-table-tool.test.ts b/src/lib/ai/coach/results/__tests__/metric-table-tool.test.ts index 3441728d8..715886bac 100644 --- a/src/lib/ai/coach/results/__tests__/metric-table-tool.test.ts +++ b/src/lib/ai/coach/results/__tests__/metric-table-tool.test.ts @@ -3,6 +3,7 @@ * own days, one row per period with absence kept as null, weeks folded the * way the chart folds them, and the bounded summary the model reads. */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; import { beforeEach, describe, expect, it, vi } from "vitest"; const readDailySeries = vi.fn(); @@ -172,6 +173,7 @@ describe("readMetricTable", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); expect(table).not.toBeNull(); @@ -214,6 +216,7 @@ describe("readMetricTable", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); const chart = bucketTimeSeries( @@ -243,6 +246,7 @@ describe("readMetricTable", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); expect(table).toBeNull(); @@ -263,6 +267,7 @@ describe("readMetricTable", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); const rows = new Map(table!.rows.map((row) => [row[0], row])); @@ -298,6 +303,7 @@ describe("summariseTable", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }))!; } @@ -388,6 +394,7 @@ describe("totals and levels", () => { timeZone: tz, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: WEST_NOW, }); } @@ -408,6 +415,7 @@ describe("totals and levels", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); expect(table!.columns[1]).toMatchObject({ @@ -439,6 +447,7 @@ describe("totals and levels", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); expect(table!.columns[1].labelKey).toBe("coach.result.column.mean"); @@ -536,6 +545,7 @@ describe("totals and levels", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); const day = table!.rows.find((row) => row[0] === "2026-09-25")!; @@ -556,6 +566,7 @@ describe("totals and levels", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); expect(table!.columns[1].decimals).toBe(2); @@ -581,6 +592,7 @@ describe("totals and levels", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); expect(table!.columns.map((c) => c.key)).toEqual([ @@ -606,3 +618,50 @@ describe("totals and levels", () => { }); }); }); + +describe("readMetricTable — the reader's units", () => { + const read = ( + metric: "weight" | "distance" | "walking_speed", + units: { system: "metric" | "imperial"; glucoseUnit: "mg/dL" }, + ) => + readMetricTable({ + userId: "u1", + metric, + window: "last7days", + period: "current", + granularity: undefined, + timeZone: TZ, + locale: "en", + ref: "r1", + units, + now: NOW, + }); + + it("states weight in pounds for an imperial reader", async () => { + readDailySeries.mockResolvedValue([dayRow("WEIGHT", "2026-09-27", 80)]); + const table = await read("weight", { + system: "imperial", + glucoseUnit: "mg/dL", + }); + expect(table!.columns[1]).toMatchObject({ unit: "lb", decimals: 1 }); + expect(table!.rows.at(-1)).toEqual(["2026-09-27", 176.4, 1]); + }); + + it("states walking distance in miles and kilometres, never in metres", async () => { + readDailySeries.mockResolvedValue([ + dayRow("WALKING_RUNNING_DISTANCE", "2026-09-27", 8000), + ]); + const imperial = await read("distance", { + system: "imperial", + glucoseUnit: "mg/dL", + }); + expect(imperial!.columns[1]).toMatchObject({ unit: "mi", decimals: 2 }); + expect(imperial!.rows.at(-1)).toEqual(["2026-09-27", 4.97, 1]); + const metric = await read("distance", { + system: "metric", + glucoseUnit: "mg/dL", + }); + expect(metric!.columns[1]).toMatchObject({ unit: "km" }); + expect(metric!.rows.at(-1)).toEqual(["2026-09-27", 8, 1]); + }); +}); diff --git a/src/lib/ai/coach/results/metric-table-tool.ts b/src/lib/ai/coach/results/metric-table-tool.ts index 23d230ed9..1ea4b50a2 100644 --- a/src/lib/ai/coach/results/metric-table-tool.ts +++ b/src/lib/ai/coach/results/metric-table-tool.ts @@ -48,6 +48,13 @@ import { annotate } from "@/lib/logging/context"; import { bucketTimeSeries } from "@/lib/charts/bucket-time-series"; import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; import { getUnitForType } from "@/lib/validations/measurement"; +import { + applyDisplayTransform, + getReadingTransform, + hasDisplayTransform, + type DisplayTransform, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types"; import { COACH_RESULT_COLUMN_KEYS, @@ -212,10 +219,16 @@ export function periodKeys( /** One value series of a table: its column and its values by day. */ interface DaySeries { column: Omit; - /** Local day key → the day's value. */ + /** Local day key → the day's value, canonical. */ byDay: Map; /** How days fold into a week or month: a total sums, a level averages. */ fold: "sum" | "mean"; + /** + * The reader's-unit transform, applied once to each folded period value + * (the column's unit and decimals already name it). Absent for a series + * no preference touches. + */ + transform?: DisplayTransform; } /** Per-day values and reading counts for a metric, before bucketing. */ @@ -227,7 +240,6 @@ interface MetricDays { const UNIT_TOKENS: Readonly> = { minutes: "min", - celsius: "°C", }; /** @@ -281,6 +293,7 @@ const SERIES_SUFFIX: Readonly>> = { type MeasurementColumn = DaySeries["column"] & { type: MeasurementType; suffix?: string; + transform?: DisplayTransform; }; /** @@ -302,6 +315,7 @@ function measurementColumns( metric: CoachScopeSource, types: readonly MeasurementType[], granularity: CoachResultGranularity, + units: UnitPreferences, ): MeasurementColumn[] { if (metric === "bp") { return [ @@ -330,16 +344,24 @@ function measurementColumns( ? COACH_RESULT_COLUMN_KEYS.total : COACH_RESULT_COLUMN_KEYS.mean; return types.map((type) => { - const unit = unitTokenFor(type); const suffix = types.length > 1 ? (SERIES_SUFFIX[type] ?? type) : undefined; + // A mass, length, temperature, speed or distance is read in the + // reader's units, at the decimals that unit is read at. + const transform = hasDisplayTransform(type) + ? getReadingTransform(type, units) + : undefined; + const unit = transform ? transform.displayUnit : unitTokenFor(type); return { key: suffix ? suffix.toLowerCase() : "value", kind: "number" as const, labelKey, ...(unit ? { unit } : {}), - decimals: decimalsForType(type, unit ?? ""), + decimals: transform + ? transform.decimals + : decimalsForType(type, unit ?? ""), type, ...(suffix ? { suffix } : {}), + ...(transform ? { transform } : {}), }; }); } @@ -374,10 +396,11 @@ async function readMeasurementDays(args: { granularity: CoachResultGranularity; range: TableRange; timeZone: string; + units: UnitPreferences; }): Promise }> { - const { userId, metric, granularity, range, timeZone } = args; + const { userId, metric, granularity, range, timeZone, units } = args; const types = COACH_SOURCE_MEASUREMENT_TYPES[metric]; - const columns = measurementColumns(metric, types, granularity); + const columns = measurementColumns(metric, types, granularity, units); const priorityJson = await loadUserSourcePriority(userId); const rowsByType = await Promise.all( columns.map((column) => @@ -403,12 +426,13 @@ async function readMeasurementDays(args: { counts.set(day, (counts.get(day) ?? 0) + count); } if (column.suffix) suffixes.set(column.key, column.suffix); - const { type: _type, suffix: _suffix, ...rest } = column; + const { type: _type, suffix: _suffix, transform, ...rest } = column; return { column: rest, byDay, fold: total ? ("sum" as const) : ("mean" as const), counts, + ...(transform ? { transform } : {}), }; }); const { ordered, counts } = orderAndCount(metric, read); @@ -443,14 +467,15 @@ async function readMeasurementMonths(args: { userId: string; metric: CoachScopeSource; range: TableRange; + units: UnitPreferences; }): Promise<{ series: DaySeries[]; periods: PeriodValues; suffixes: Map; }> { - const { userId, metric, range } = args; + const { userId, metric, range, units } = args; const types = COACH_SOURCE_MEASUREMENT_TYPES[metric]; - const columns = measurementColumns(metric, types, "month"); + const columns = measurementColumns(metric, types, "month", units); const total = aggregationKind(metric) === "total"; const priorityJson = await loadUserSourcePriority(userId); // The first whole UTC month of the range: a bucket that starts before @@ -512,7 +537,7 @@ async function readMeasurementMonths(args: { const suffixes = new Map(); const read = columns.map((column, index) => { if (column.suffix) suffixes.set(column.key, column.suffix); - const { type: _type, suffix: _suffix, ...rest } = column; + const { type: _type, suffix: _suffix, transform, ...rest } = column; const byDay = new Map(); const counts = new Map(); for (const [month, { value, count }] of byType[index]) { @@ -524,6 +549,7 @@ async function readMeasurementMonths(args: { byDay, fold: total ? ("sum" as const) : ("mean" as const), counts, + ...(transform ? { transform } : {}), }; }); const { ordered, counts } = orderAndCount(metric, read); @@ -743,9 +769,14 @@ export async function readMetricTable(args: { timeZone: string; locale: Locale; ref: string; + /** + * The reader's units. The person sees the table and the model reads its + * summary, so a mass, temperature, speed or distance is stated in them. + */ + units: UnitPreferences; now?: Date; }): Promise { - const { userId, metric, window, timeZone, locale, ref } = args; + const { userId, metric, window, timeZone, locale, ref, units } = args; const now = args.now ?? new Date(); const period = window === "allTime" ? "current" : args.period; const granularity = effectiveGranularity(window, args.granularity); @@ -765,7 +796,7 @@ export async function readMetricTable(args: { series = days.series; folded = foldIntoPeriods({ days, granularity, timeZone }); } else if (window === "allTime") { - const read = await readMeasurementMonths({ userId, metric, range }); + const read = await readMeasurementMonths({ userId, metric, range, units }); series = read.series; folded = read.periods; suffixes = read.suffixes; @@ -777,6 +808,7 @@ export async function readMetricTable(args: { granularity, range, timeZone, + units, }); series = read.series; folded = foldIntoPeriods({ days: read, granularity, timeZone }); @@ -826,9 +858,10 @@ export async function readMetricTable(args: { const cells = values.get(key); return [ key, - ...series.map((_, index) => { + ...series.map((s, index) => { const value = cells?.[index]; - return value === undefined || !Number.isFinite(value) ? null : value; + if (value === undefined || !Number.isFinite(value)) return null; + return s.transform ? applyDisplayTransform(value, s.transform) : value; }), cells ? (counts.get(key) ?? 0) : null, ]; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 4f7ed8b79..666ae143c 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -23,7 +23,10 @@ import { } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { locales, defaultLocale, type Locale } from "@/lib/i18n/config"; -import { resolveUnitPreferences } from "@/lib/measurements/display-transform"; +import { + resolveUnitPreferences, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; import type { SleepStageRow } from "@/lib/analytics/sleep-night"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { annotate } from "@/lib/logging/context"; @@ -126,6 +129,12 @@ export interface CoachSnapshotResult { * from `src/lib/reference-ranges.ts`; carries no commercial brand name. */ referenceGrounding: string | null; + /** + * The reader's unit choices the snapshot was written in. A tool that reads + * beyond the snapshot (the metric table) states its figures in the same + * units, so a reply never mixes the two. + */ + units: UnitPreferences; } /** @@ -1243,6 +1252,7 @@ async function buildCoachSnapshotImpl( counts: Object.keys(counts).length > 0 ? counts : undefined, }, referenceGrounding, + units, }; } diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index ed4b09da2..814842567 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -39,6 +39,7 @@ * still gets a correct, distinct build (the rare case); the common path * collapses to one build. */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; import { z } from "zod/v4"; import { annotate } from "@/lib/logging/context"; @@ -839,6 +840,7 @@ async function getMetricTable( timeZone, locale: turn?.locale ?? "en", ref: "r0", + units: gate.units, now: turn?.now, }); if (!table) { @@ -862,6 +864,7 @@ async function getMetricTable( timeZone, locale: turn?.locale ?? "en", ref: "r0", + units: gate.units, now: turn?.now, }); comparison = current ? compareWithCurrent(table, current) : null; @@ -1067,6 +1070,8 @@ async function withProjectedTable( timeZone: await resolveUserTimezone(userId), locale: turn.locale, ref: "r0", + // Sleep is read in minutes for every reader; no preference applies. + units: DEFAULT_UNIT_PREFERENCES, now: turn.now, }); table = diff --git a/tests/integration/coach-metric-table.test.ts b/tests/integration/coach-metric-table.test.ts index 803e9e041..479725ea0 100644 --- a/tests/integration/coach-metric-table.test.ts +++ b/tests/integration/coach-metric-table.test.ts @@ -8,6 +8,7 @@ * Then pins that `show_result` reaches a stored table only inside the * conversation it was written to. */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -106,6 +107,7 @@ describe("get_metric_table — the chart's numbers", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); expect(table).not.toBeNull(); @@ -162,6 +164,7 @@ describe("get_metric_table — the chart's numbers", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); const range = resolveTableRange({ @@ -280,6 +283,7 @@ describe("get_metric_table — all time", () => { timeZone: WEST, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: WEST_NOW, }); @@ -338,6 +342,7 @@ describe("show_result — only within the conversation", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, })) as CoachResultTable; const {