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
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 08:55:19 +02:00
1 parent 6c6d363bba
commit 1058859730
5 files changed
+234 -29

No files matched your search

@@ -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 () => {
@@ -113,6 +113,7 @@ describe("buildDeterministicNarrative", () => {
bandTransitions: [
{
type: "BLOOD_PRESSURE_SYS",
unit: "mmHg",
center: 140,
bandLow: 110,
bandHigh: 130,
@@ -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<string, DailySeriesPoint[]>();
// 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<string, DailySeriesPoint[]>();
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<string, DailySeriesPoint[]>();
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<string, DailySeriesPoint[]>();
@@ -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}`,
);
}
}
+109 -25
View File
@@ -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<Record<MeasurementType, string>> = {
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<MeasurementType>([
...DELTA_METRICS.map((m) => m.type),
...VITALS_BASELINE_TYPES,
]),
new Set<MeasurementType>([...DELTA_METRICS, ...VITALS_BASELINE_TYPES]),
),
});
@@ -553,5 +636,6 @@ export async function buildPeriodNarrativeContext(
discoverySeries,
locale: opts.locale,
computedAt: now.toISOString(),
units,
});
}