fix(coach): state reference and time-in-range bands in the reader's units

The reference grounding printed the fasting glucose bands in mg/dL and
the body-temperature band in °C, and the system prompt described time
in range as 70-180 mg/dL. For a reader on mmol/L or Fahrenheit the
model was handed one quantity in two units: the glucose block in
mmol/L, the band beside it in mg/dL.

The grounding block now takes both unit preferences and prints the
general, the diabetes goal and the temperature band in them, while the
placement is still classified on the canonical value. The glucose
block carries the time-in-range band in its own unit, and the prompt
points the model at it instead of naming a unit.

Refs #1067
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 13:23:17 +02:00
1 parent 6dbf57f4f4
commit d1b2539b1f
8 files changed
+195 -15

No files matched your search

@@ -113,6 +113,39 @@ describe("buildReferenceGroundingBlock", () => {
expect(outOfGoal).toContain("outside the typical diabetes management goal");
});
it("states the glucose bands in the reader's glucose unit and still places on mg/dL", () => {
const units = { system: "metric", glucoseUnit: "mmol/L" } as const;
const general = buildReferenceGroundingBlock({
metrics: [{ metric: "BLOOD_GLUCOSE", value: 92 }],
hasDiabetes: false,
units,
})!;
expect(general).toContain("≤5.5 mmol/L");
expect(general).not.toContain("mg/dL");
// 92 mg/dL is inside the fasting band: the placement is canonical.
expect(general).toContain("sits inside the general reference band");
const goal = buildReferenceGroundingBlock({
metrics: [{ metric: "BLOOD_GLUCOSE", value: 200 }],
hasDiabetes: true,
units,
})!;
expect(goal).toContain("4.4–7.2 mmol/L");
expect(goal).not.toContain("mg/dL");
expect(goal).toContain("outside the typical diabetes management goal");
});
it("states the body-temperature band in °F for an imperial reader", () => {
const block = buildReferenceGroundingBlock({
metrics: [{ metric: "BODY_TEMPERATURE", value: 36.8 }],
hasDiabetes: false,
units: { system: "imperial", glucoseUnit: "mg/dL" },
})!;
expect(block).toContain("96.3–99.3 °F");
expect(block).not.toContain("°C");
expect(block).toContain("sits inside the general reference band");
});
it("grounds BMI against the WHO normal band, not the first (Underweight) band", () => {
// The headline band rendered must be the 18.5–24.9 normal range — the
// index-0 bug surfaced the Underweight "≤18.5" ceiling instead.
@@ -113,6 +113,13 @@ describe("getCoachSystemPrompt — EN", () => {
expect(prompt).toMatch(/isSpotEstimate/);
});
it("names the time-in-range band through the block, never in one fixed unit", () => {
// The glucose block is in the reader's unit; a band stated here in
// mg/dL would sit beside mmol/L numbers for a mmol/L reader.
expect(prompt).toMatch(/"tirRange"/);
expect(prompt).not.toMatch(/70–180 mg\/dL/);
});
it("documents the WHOOP-native dayStrain block vs the computed proxy", () => {
// v1.17.0 — native 0–21 strain preferred over the computed 0–100 proxy.
expect(prompt).toMatch(/dayStrain/);
+69 -12
View File
@@ -47,6 +47,12 @@ import {
type ReferenceMetric,
type ReferencePlacement,
} from "@/lib/reference-ranges";
import {
applyDisplayTransform,
DEFAULT_UNIT_PREFERENCES,
getReadingTransform,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
/**
* One metric the snapshot carries, paired with the user's current
@@ -74,6 +80,44 @@ export interface ReferenceGroundingInput {
* for the glucose line ONLY. Never inferred from a reading.
*/
readonly hasDiabetes: boolean;
/**
* The reader's two unit choices. The band text is printed in them, so the
* line sits beside the glucose block (already in the reader's glucose unit)
* without handing the model a second unit for the same quantity. The
* placement is still classified on the canonical value. Defaults to metric
* and mg/dL.
*/
readonly units?: UnitPreferences;
}
/**
* The reference metrics whose band is a stored measurement read in a
* preference-dependent unit, keyed to that measurement type. Every other
* band (mmHg, bpm, %, h, kg/m²) reads the same for everyone.
*/
const UNIT_SENSITIVE_REFERENCE_TYPE: Partial<Record<ReferenceMetric, string>> =
{
BLOOD_GLUCOSE: "BLOOD_GLUCOSE",
BODY_TEMPERATURE: "BODY_TEMPERATURE",
};
/** A canonical band edge and the unit symbol, in the reader's units. */
function bandEdges(
metric: ReferenceMetric,
units: UnitPreferences,
): { edge: (value: number) => number; unit: string } {
const type = UNIT_SENSITIVE_REFERENCE_TYPE[metric];
if (!type) {
return {
edge: (value) => value,
unit: getReferenceRange(metric)?.unit ?? "",
};
}
const transform = getReadingTransform(type, units);
return {
edge: (value) => applyDisplayTransform(value, transform),
unit: transform.displayUnit,
};
}
/**
@@ -140,17 +184,20 @@ function placementPhrase(placement: ReferencePlacement): string {
* pulse-pressure Narrow). Open-ended bounds render with a leading/trailing
* ≤ / ≥.
*/
function headlineBandText(metric: ReferenceMetric): string | null {
function headlineBandText(
metric: ReferenceMetric,
units: UnitPreferences,
): string | null {
const range = getReferenceRange(metric);
if (!range || range.bands.length === 0) return null;
const idx = normalBandIndex(metric);
const band = range.bands[idx];
const unit = range.unit;
const { edge, unit } = bandEdges(metric, units);
if (band.low != null && band.high != null) {
return `${band.low}–${band.high} ${unit}`;
return `${edge(band.low)}–${edge(band.high)} ${unit}`;
}
if (band.high != null) return `≤${band.high} ${unit}`;
if (band.low != null) return `≥${band.low} ${unit}`;
if (band.high != null) return `≤${edge(band.high)} ${unit}`;
if (band.low != null) return `≥${edge(band.low)} ${unit}`;
return null;
}
@@ -160,9 +207,14 @@ function headlineBandText(metric: ReferenceMetric): string | null {
* GOAL band when the user declared diabetes. The diabetic branch frames the
* band as a clinician-set GOAL, never a screening threshold.
*/
function glucoseLine(value: number | null, hasDiabetes: boolean): string {
function glucoseLine(
value: number | null,
hasDiabetes: boolean,
units: UnitPreferences,
): string {
const label = METRIC_LABEL.BLOOD_GLUCOSE;
if (hasDiabetes) {
const { edge, unit } = bandEdges("BLOOD_GLUCOSE", units);
const { low, high } = ADA_DIABETIC_FASTING_GOAL;
// Placement against the diabetic GOAL band, framed as a target the
// user's clinician individualises — never a diagnostic call.
@@ -174,11 +226,11 @@ function glucoseLine(value: number | null, hasDiabetes: boolean): string {
} else {
where = "is outside the typical diabetes management goal";
}
return `- ${label}: a common diabetes management goal is ${low}–${high} mg/dL fasting (${referenceLabel(
return `- ${label}: a common diabetes management goal is ${edge(low)}–${edge(high)} ${unit} fasting (${referenceLabel(
"BLOOD_GLUCOSE",
)} — clinician-set goal, individualised, not a screening line). Yours ${where}.`;
}
const band = headlineBandText("BLOOD_GLUCOSE");
const band = headlineBandText("BLOOD_GLUCOSE", units);
const placement = classifyReference("BLOOD_GLUCOSE", value);
return `- ${label}: general non-diabetic normal is ${band} (${referenceLabel(
"BLOOD_GLUCOSE",
@@ -186,9 +238,13 @@ function glucoseLine(value: number | null, hasDiabetes: boolean): string {
}
/** Build one standard (non-glucose) grounding line for a metric. */
function standardLine(metric: ReferenceMetric, value: number | null): string {
function standardLine(
metric: ReferenceMetric,
value: number | null,
units: UnitPreferences,
): string {
const label = METRIC_LABEL[metric];
const band = headlineBandText(metric);
const band = headlineBandText(metric, units);
const placement = classifyReference(metric, value);
const cite = referenceLabel(metric);
// Blood pressure cites ESH 2023 by construction (the reference-range
@@ -234,10 +290,11 @@ export function buildReferenceGroundingBlock(
}
if (ordered.length === 0) return null;
const units = input.units ?? DEFAULT_UNIT_PREFERENCES;
const lines = ordered.map((m) =>
m.metric === "BLOOD_GLUCOSE"
? glucoseLine(m.value, input.hasDiabetes)
: standardLine(m.metric, m.value),
? glucoseLine(m.value, input.hasDiabetes, units)
: standardLine(m.metric, m.value, units),
);
// The preamble + closing caveat carry the firm general-guidance framing.
@@ -0,0 +1,62 @@
/**
* The glucose block states its time-in-range band in the block's own unit,
* so the prompt never has to name the band in mg/dL beside mmol/L numbers.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { buildGlucoseBlock } from "../glucose-block";
import type { GlucoseUnit } from "@/lib/glucose";
const NOW = new Date("2026-06-14T12:00:00Z");
const DAY = 24 * 60 * 60 * 1000;
const VALUES = [
60, 90, 120, 150, 180, 200, 100, 110, 95, 130, 170, 220, 80, 140, 160, 105,
115, 125, 135, 145,
];
function build(glucoseUnit: GlucoseUnit) {
const rows = VALUES.map((value, i) => ({
type: "BLOOD_GLUCOSE",
value,
measuredAt: new Date(NOW.getTime() - (VALUES.length - 1 - i) * DAY),
glucoseContext: null,
}));
const snapshot: Record<string, unknown> = {};
buildGlucoseBlock({
measurementRows: rows,
glucoseCutoff: new Date(NOW.getTime() - 60 * DAY),
glucoseClinicalRows: rows.map(({ value, measuredAt }) => ({
value,
measuredAt,
})),
glucoseUnit,
recentCutoff: new Date(NOW.getTime() - 7 * DAY),
userTz: "UTC",
now: NOW,
snapshot,
metrics: new Set(),
counts: {},
registerBlock: () => {},
groundingValues: new Map(),
});
return snapshot.glucose as {
unit: string;
clinical: { stillLearning: boolean; tirRange?: unknown };
};
}
describe("buildGlucoseBlock — time-in-range band", () => {
it("carries the band in mmol/L for a mmol/L reader", () => {
const block = build("mmol/L");
expect(block.unit).toBe("mmol/L");
expect(block.clinical.stillLearning).toBe(false);
expect(block.clinical.tirRange).toEqual({ low: 3.9, high: 10 });
});
it("carries the band in mg/dL for a mg/dL reader", () => {
const block = build("mg/dL");
expect(block.clinical.tirRange).toEqual({ low: 70, high: 180 });
});
});
@@ -20,6 +20,7 @@ import {
import {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
TIME_IN_RANGE_MGDL,
} from "@/lib/analytics/glucose-metrics";
import { annotate } from "@/lib/logging/context";
import type { ReferenceMetric } from "@/lib/reference-ranges";
@@ -162,6 +163,12 @@ export function buildGlucoseBlock(ctx: Readonly<GlucoseBlockContext>): void {
tirPercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tir * 100)
: null,
// The band the percentage counts, in the block's unit, so the
// prompt never states it in a unit the reader does not use.
tirRange: {
low: convertGlucose(TIME_IN_RANGE_MGDL.low, glucoseUnit),
high: convertGlucose(TIME_IN_RANGE_MGDL.high, glucoseUnit),
},
timeBelowPercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tbrLevel1 * 100)
: null,
+10 -2
View File
@@ -23,7 +23,7 @@ import {
} from "@/lib/validations/coach-prefs";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { resolveUnitPreferences } 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";
@@ -303,6 +303,9 @@ async function buildCoachSnapshotImpl(
// display unit so the Coach reads the same number every other surface
// shows. Read it on this existing prefs hop (no extra round-trip).
glucoseUnit: true,
// The metric/imperial choice: the grounding bands and the blocks
// that carry a mass, length or temperature are written in it.
unitPreference: true,
// v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects
// the tighter ADA glycemic GOAL band for the glucose reference-grounding
// line only; never inferred from a reading, never a diagnosis. Read on
@@ -326,7 +329,11 @@ async function buildCoachSnapshotImpl(
clusterDefault,
);
const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE;
const glucoseUnit = resolveGlucoseUnit(prefsRow?.glucoseUnit ?? null);
const units = resolveUnitPreferences({
unitPreference: prefsRow?.unitPreference,
glucoseUnit: prefsRow?.glucoseUnit,
});
const glucoseUnit = units.glucoseUnit;
// v1.18.0 — fold disabled data-domain modules into the system exclusion.
// `moduleMap[key] === false` means the user turned that module off; the
// gate has already resolved every delegation (cycle/coach) so this map
@@ -1206,6 +1213,7 @@ async function buildCoachSnapshotImpl(
const referenceGrounding = buildReferenceGroundingBlock({
metrics: groundingMetrics,
hasDiabetes: prefsRow?.hasDiabetes ?? false,
units,
});
if (referenceGrounding) {
annotate({
+2 -1
View File
@@ -382,7 +382,8 @@ ISO-week means.
- The glucose block MAY carry a "clinical" sub-key with the same panel
numbers the app's glucose page renders over a fixed 30-day window — so
cite them as the user sees them and never recompute. It holds:
- "tirPercent": time-in-range (70–180 mg/dL) as a percentage.
- "tirPercent": time-in-range as a percentage — the share of readings
inside "tirRange", whose bounds are in the block's "unit".
- "gmi": the glucose-management indicator (a %, an HbA1c estimate
derived from mean glucose) and "estimatedA1c": the eA1C in % — read
both as estimates, never as a lab HbA1c.
+5
View File
@@ -85,6 +85,11 @@ const TBR_LEVEL1_MAX = 70; // low: G < 70 (level-1 sub-band is 54–69)
const TAR_LEVEL1_MIN = 180; // high: G > 180 (level-1 sub-band is 181–250)
const TAR_LEVEL2_MIN = 250; // very high: G > 250
// Time-in-range target band is [70, 180] inclusive.
/** The time-in-range band, mg/dL, inclusive at both ends. */
export const TIME_IN_RANGE_MGDL = {
low: TBR_LEVEL1_MAX,
high: TAR_LEVEL1_MIN,
} as const;
/** Monnier 2017 variability cutoff: CV% ≥ 36 is "unstable". */
export const CV_INSTABILITY_THRESHOLD = 36;