From 10588597301ecca47d0473c8586bb5f44be15cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:55:19 +0200 Subject: [PATCH] fix(narrative): narrate period changes in the reader's units The period narrative pinned kilograms and mg/dL on its change list, and its personal-range lines carried no unit at all. Every delta and band edge is now stated in the reader's units (computed canonically, converted at the end, percent left unit-free), and each band transition carries its unit into the prompt. The sleep channel, a per-night total in minutes, was labelled hours and is now converted to them. Refs #1067 --- .../__tests__/period-narrative-db.test.ts | 8 +- .../period-narrative-deterministic.test.ts | 1 + .../__tests__/period-narrative.test.ts | 117 +++++++++++++++ .../narrative/period-narrative-generate.ts | 3 +- .../insights/narrative/period-narrative.ts | 134 ++++++++++++++---- 5 files changed, 234 insertions(+), 29 deletions(-) diff --git a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts index b742c12ac..29462480f 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts @@ -225,9 +225,11 @@ describe("buildPeriodNarrativeContext — grain (QA F3)", () => { expect(sleepDelta).toBeDefined(); // The pre-fix local `toDailyMeans` twin would have averaged the three // segment durations: (240 + 90 + 80) / 3 ≈ 137 — a number with no - // clinical meaning. The correct grain is the night's TOTAL (410). - expect(sleepDelta!.current).toBe(410); - expect(sleepDelta!.current).not.toBeCloseTo((240 + 90 + 80) / 3, 0); + // clinical meaning. The correct grain is the night's TOTAL (410 min), + // narrated in the hours its unit names: this used to read "410 h". + expect(sleepDelta!.unit).toBe("h"); + expect(sleepDelta!.current).toBe(6.83); + expect(sleepDelta!.current).not.toBeCloseTo((240 + 90 + 80) / 3 / 60, 1); }); it("orders the measurement read DESC + cap so the CURRENT period survives a capped window (QA F2)", async () => { diff --git a/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts index 3eae6875d..af39d5e72 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts @@ -113,6 +113,7 @@ describe("buildDeterministicNarrative", () => { bandTransitions: [ { type: "BLOOD_PRESSURE_SYS", + unit: "mmHg", center: 140, bandLow: 110, bandHigh: 130, diff --git a/src/lib/insights/narrative/__tests__/period-narrative.test.ts b/src/lib/insights/narrative/__tests__/period-narrative.test.ts index 292aa268c..99e3c7e68 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative.test.ts @@ -9,6 +9,9 @@ import type { DailySeriesPoint, NamedSeries, } from "@/lib/insights/correlation-discovery"; +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; +import { buildDeterministicNarrative } from "../period-narrative-deterministic"; +import { buildNarrativeUserPrompt } from "../period-narrative-generate"; /** * Build a contiguous daily series ending at `endDay` (YYYY-MM-DD), one point @@ -42,6 +45,7 @@ function monthInput( discoverySeries, locale: "en", computedAt: "2026-04-30T12:00:00.000Z", + units: DEFAULT_UNIT_PREFERENCES, }; } @@ -122,6 +126,119 @@ describe("assemblePeriodNarrativeContext — metric deltas", () => { }); }); +describe("assemblePeriodNarrativeContext — the reader's units", () => { + function glucoseAndWeight() { + const m = new Map(); + // Prior 30 days at 90 mg/dL / 80 kg, current 30 at 108 mg/dL / 82 kg. + m.set( + "BLOOD_GLUCOSE", + seriesEndingAt( + [...Array(30).fill(90), ...Array(30).fill(108)], + "2026-04-30", + ), + ); + m.set( + "WEIGHT", + seriesEndingAt( + [...Array(30).fill(80), ...Array(30).fill(82)], + "2026-04-30", + ), + ); + return m; + } + + it("narrates glucose in mmol/L and weight in pounds when the reader chose them", () => { + const ctx = assertReady( + assemblePeriodNarrativeContext({ + ...monthInput(glucoseAndWeight()), + units: { system: "imperial", glucoseUnit: "mmol/L" }, + }), + ); + const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!; + expect(g.unit).toBe("mmol/L"); + expect(g.current).toBe(5.99); + expect(g.prior).toBe(4.99); + expect(g.delta).toBe(1); + // Percent is unit-free. + expect(g.deltaPercent).toBe(20); + const w = ctx.metricDeltas.find((d) => d.type === "WEIGHT")!; + expect(w.unit).toBe("lb"); + expect(w.current).toBe(180.78); + expect(w.delta).toBe(4.41); + + const prose = buildDeterministicNarrative(ctx, "en"); + expect(prose).toContain("mmol/L"); + expect(prose).toContain("lb"); + expect(prose).not.toContain("mg/dL"); + expect(prose).not.toContain("kg"); + const prompt = buildNarrativeUserPrompt(ctx, "en"); + expect(prompt).toContain("BLOOD_GLUCOSE: 5.99 mmol/L"); + expect(prompt).not.toContain("mg/dL"); + }); + + it("keeps mg/dL and kilograms on the default preferences", () => { + const ctx = assertReady( + assemblePeriodNarrativeContext(monthInput(glucoseAndWeight())), + ); + const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!; + expect(g.unit).toBe("mg/dL"); + expect(g.current).toBe(108); + expect(ctx.metricDeltas.find((d) => d.type === "WEIGHT")!.unit).toBe("kg"); + }); + + it("narrates sleep minutes as hours, the unit it always claimed", () => { + const m = new Map(); + m.set( + "SLEEP_DURATION", + seriesEndingAt( + [...Array(30).fill(420), ...Array(30).fill(450)], + "2026-04-30", + ), + ); + m.set( + "WEIGHT", + seriesEndingAt( + [...Array(30).fill(80), ...Array(30).fill(82)], + "2026-04-30", + ), + ); + const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m))); + const s = ctx.metricDeltas.find((d) => d.type === "SLEEP_DURATION")!; + expect(s.unit).toBe("h"); + expect(s.current).toBe(7.5); + expect(s.delta).toBe(0.5); + }); + + it("states a band transition with its unit, converted", () => { + const m = new Map(); + const prior = Array.from({ length: 30 }, (_, i) => 90 + (i % 3)); + m.set( + "BLOOD_GLUCOSE", + seriesEndingAt([...prior, ...Array(30).fill(140)], "2026-04-30"), + ); + m.set( + "WEIGHT", + seriesEndingAt( + [...Array(30).fill(80), ...Array(30).fill(82)], + "2026-04-30", + ), + ); + const ctx = assertReady( + assemblePeriodNarrativeContext({ + ...monthInput(m), + units: { system: "metric", glucoseUnit: "mmol/L" }, + }), + ); + const t = ctx.bandTransitions.find((b) => b.type === "BLOOD_GLUCOSE")!; + expect(t.unit).toBe("mmol/L"); + expect(t.center).toBe(7.77); + expect(t.movedOut).toBe(true); + expect(buildNarrativeUserPrompt(ctx, "en")).toContain( + "BLOOD_GLUCOSE: 7.77 mmol/L", + ); + }); +}); + describe("assemblePeriodNarrativeContext — band transitions", () => { it("flags a vital whose current center moved above its prior band", () => { const m = new Map(); diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index 0d50e9dc3..22a33b2fc 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -259,8 +259,9 @@ export function buildNarrativeUserPrompt( : locale === "de" ? "im Bereich" : "in range"; + const unit = b.unit ? ` ${b.unit}` : ""; lines.push( - `- ${b.type}: ${b.center} (${b.bandLow}–${b.bandHigh}) → ${where}`, + `- ${b.type}: ${b.center}${unit} (${b.bandLow}–${b.bandHigh}${unit}) → ${where}`, ); } } diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index c5cab2167..c03deab01 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -56,6 +56,13 @@ import { VITALS_BASELINE_TYPES } from "@/lib/insights/derived/registry"; import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; import { resolveModuleMap } from "@/lib/modules/gate"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { + applyDisplayTransformUnrounded, + getReadingTransform, + isUnitSensitiveType, + resolveUnitPreferences, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -74,20 +81,78 @@ export const MIN_COVERED_DAYS_PER_METRIC = 3; /** A band must rest on at least this many prior-period days to be trusted. */ export const MIN_BASELINE_DAYS = 7; -/** The metrics the period-delta beat scans, with their display unit. */ -const DELTA_METRICS: Array<{ type: MeasurementType; unit: string }> = [ - { type: "WEIGHT", unit: "kg" }, - { type: "BLOOD_PRESSURE_SYS", unit: "mmHg" }, - { type: "BLOOD_PRESSURE_DIA", unit: "mmHg" }, - { type: "PULSE", unit: "bpm" }, - { type: "RESTING_HEART_RATE", unit: "bpm" }, - { type: "HEART_RATE_VARIABILITY", unit: "ms" }, - { type: "SLEEP_DURATION", unit: "h" }, - { type: "ACTIVITY_STEPS", unit: "" }, - { type: "BODY_FAT", unit: "%" }, - { type: "BLOOD_GLUCOSE", unit: "mg/dL" }, +/** + * The metrics the period-delta beat scans. Their unit is not listed here: a + * reading is narrated in the reader's unit (see `narratedUnit`), and the list + * used to pin kilograms and mg/dL for everyone. + */ +const DELTA_METRICS: MeasurementType[] = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "SLEEP_DURATION", + "ACTIVITY_STEPS", + "BODY_FAT", + "BLOOD_GLUCOSE", ]; +/** + * Unit symbols of the narrated types whose unit no preference changes. The + * sleep channel is a per-night total in minutes and is narrated in hours. + */ +const FIXED_NARRATED_UNITS: Partial> = { + BLOOD_PRESSURE_SYS: "mmHg", + BLOOD_PRESSURE_DIA: "mmHg", + PULSE: "bpm", + RESTING_HEART_RATE: "bpm", + HEART_RATE_VARIABILITY: "ms", + RESPIRATORY_RATE: "breaths/min", + OXYGEN_SATURATION: "%", + SLEEP_DURATION: "h", + ACTIVITY_STEPS: "", + BODY_FAT: "%", +}; + +/** + * How one narrated type is written for this reader: its unit symbol, the + * conversion of an absolute value (a mean, a band edge) and of a difference + * (a delta — factor only, never an affine offset). The computation itself + * stays canonical, so the personal-band floors and the percent change do not + * depend on the unit the reader chose. + */ +function narratedUnit( + type: MeasurementType, + units: UnitPreferences, +): { + unit: string; + abs: (canonical: number) => number; + delta: (canonical: number) => number; +} { + if (isUnitSensitiveType(type)) { + const transform = getReadingTransform(type, units); + return { + unit: transform.displayUnit, + abs: (v) => round2(applyDisplayTransformUnrounded(v, transform)), + delta: (v) => round2(v * transform.factor), + }; + } + if (type === "SLEEP_DURATION") { + return { + unit: "h", + abs: (v) => round2(v / 60), + delta: (v) => round2(v / 60), + }; + } + return { + unit: FIXED_NARRATED_UNITS[type] ?? "", + abs: round2, + delta: round2, + }; +} + // ── context shape ─────────────────────────────────────────────────────── /** One metric's current-period mean vs the prior period of equal length. */ @@ -114,6 +179,8 @@ export interface MetricDelta { */ export interface BandTransition { type: MeasurementType; + /** Unit of `center` / `bandLow` / `bandHigh`, in the reader's preference. */ + unit: string; /** Current-period robust center (median of per-day means). */ center: number; /** Prior-period band edges. */ @@ -243,6 +310,12 @@ export interface AssembleInput { locale: Locale; /** Compute time (ISO). */ computedAt: string; + /** + * The reader's unit preferences. Required for the same reason the locale + * is: the context carries numbers a page and a prompt print verbatim, and a + * caller that stays quiet must not get kilograms and mg/dL by default. + */ + units: UnitPreferences; } /** @@ -262,12 +335,14 @@ export function assemblePeriodNarrativeContext( discoverySeries, locale, computedAt, + units, } = input; // ── metric deltas (current period vs prior period of equal length) ────── const metricDeltas: MetricDelta[] = []; const metricsWithCoverage: string[] = []; - for (const { type, unit } of DELTA_METRICS) { + for (const type of DELTA_METRICS) { + const shown = narratedUnit(type, units); const points = seriesByMetric.get(type) ?? []; const { current, prior } = splitByPeriod(points, currentFrom, priorFrom); const currentAvg = meanOrNull(current); @@ -280,16 +355,21 @@ export function assemblePeriodNarrativeContext( currentAvg !== null && priorAvg !== null ? round2(currentAvg - priorAvg) : null; + // Percent on the canonical values: it is unit-free for a pure rescale, + // and the one affine unit (°F) would otherwise move it. const deltaPercent = delta !== null && priorAvg !== null && priorAvg !== 0 ? Math.round((delta / Math.abs(priorAvg)) * 1000) / 10 : null; metricDeltas.push({ type, - unit, - current: currentAvg === null ? null : round2(currentAvg), - prior: priorAvg === null ? null : round2(priorAvg), - delta, + unit: shown.unit, + current: currentAvg === null ? null : shown.abs(currentAvg), + prior: priorAvg === null ? null : shown.abs(priorAvg), + delta: + currentAvg !== null && priorAvg !== null + ? shown.delta(currentAvg - priorAvg) + : null, deltaPercent, currentDays: current.length, priorDays: prior.length, @@ -324,11 +404,13 @@ export function assemblePeriodNarrativeContext( const center = median(current); const above = center > band.high; const below = center < band.low; + const shown = narratedUnit(type, units); bandTransitions.push({ type, - center: round2(center), - bandLow: round2(band.low), - bandHigh: round2(band.high), + unit: shown.unit, + center: shown.abs(center), + bandLow: shown.abs(band.low), + bandHigh: shown.abs(band.high), direction: above ? "above" : below ? "below" : "in", movedOut: above || below, baselineDays: prior.length, @@ -479,9 +561,13 @@ export async function buildPeriodNarrativeContext( const profile = await prisma.user.findUnique({ where: { id: userId }, - select: { timezone: true }, + select: { timezone: true, unitPreference: true, glucoseUnit: true }, }); const tz = profile?.timezone ?? DEFAULT_TIMEZONE; + const units = resolveUnitPreferences({ + unitPreference: profile?.unitPreference, + glucoseUnit: profile?.glucoseUnit, + }); const currentFrom = tzDayKey( new Date(now.getTime() - periodDays * MS_PER_DAY), @@ -523,10 +609,7 @@ export async function buildPeriodNarrativeContext( // comment for why it is one and not four. includeMoodFactors: true, extraMeasurementTypes: Array.from( - new Set([ - ...DELTA_METRICS.map((m) => m.type), - ...VITALS_BASELINE_TYPES, - ]), + new Set([...DELTA_METRICS, ...VITALS_BASELINE_TYPES]), ), }); @@ -553,5 +636,6 @@ export async function buildPeriodNarrativeContext( discoverySeries, locale: opts.locale, computedAt: now.toISOString(), + units, }); }