mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user