From 6c6d363bba0c41503b8f8cbc850fbaccec98ebd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:52:44 +0200 Subject: [PATCH] fix(insights): write metric status notes in the reader's units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-metric status note quoted glucose in mg/dL, body composition in kg, temperatures in °C and walking distance in metres, whatever the reader had chosen; the deterministic floor without a provider did the same. The generator now converts the series, the reference band and the signal into the reader's units before they reach the prompt or the floor, and prints the guideline band table in that unit while still classifying on the canonical value. A changed glucose unit or metric/imperial preference (own account or a managed record) now re-warms the stored notes and narratives through the hash-gated refill, so today's text does not keep the old unit. Refs #1067 --- .../me/glucose-unit/__tests__/route.test.ts | 35 +++++ src/app/api/auth/me/glucose-unit/route.ts | 7 + .../unit-preference/__tests__/route.test.ts | 35 +++++ src/app/api/auth/me/unit-preference/route.ts | 7 + src/app/api/record-settings/[family]/route.ts | 4 + .../__tests__/interpretation-block.test.ts | 23 +++ src/lib/ai/prompts/interpretation-block.ts | 32 ++-- .../insights/__tests__/metric-status.test.ts | 131 ++++++++++++++++ .../__tests__/unit-change-refresh.test.ts | 60 ++++++++ src/lib/insights/graded-series.ts | 29 ++++ src/lib/insights/metric-status.ts | 145 ++++++++++++++++-- src/lib/insights/no-key-fallbacks.ts | 8 +- src/lib/insights/unit-change-refresh.ts | 63 ++++++++ 13 files changed, 551 insertions(+), 28 deletions(-) create mode 100644 src/lib/insights/__tests__/unit-change-refresh.test.ts create mode 100644 src/lib/insights/unit-change-refresh.ts diff --git a/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts index a699059cb..1e19944a9 100644 --- a/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts +++ b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts @@ -23,6 +23,10 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/insights/unit-change-refresh", () => ({ + refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); @@ -56,6 +60,7 @@ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; import { checkRateLimit } from "@/lib/rate-limit"; const SESSION_OK = { @@ -210,3 +215,33 @@ describe("PATCH /api/auth/me/glucose-unit", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); }); + +describe("PATCH re-warms the texts that quote the unit", () => { + it("re-warms the stored notes when the unit actually changes", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ glucoseUnit: "mmol/L" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1"); + }); + + it("leaves them alone when the same unit is saved again", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: "mmol/L", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ glucoseUnit: "mmol/L" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/glucose-unit/route.ts b/src/app/api/auth/me/glucose-unit/route.ts index 144fa8df6..b11373224 100644 --- a/src/app/api/auth/me/glucose-unit/route.ts +++ b/src/app/api/auth/me/glucose-unit/route.ts @@ -38,6 +38,7 @@ import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose"; import { glucoseUnitPatchSchema } from "@/lib/validations/user-prefs"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; export const dynamic = "force-dynamic"; @@ -112,6 +113,12 @@ export const PATCH = apiHandler(async (req: Request) => { }, }); + // The status notes and narratives quote glucose in this unit; re-warm + // them so today's stored text does not keep the old one. + if (resolveGlucoseUnit(previous?.glucoseUnit) !== next) { + void refreshTextsAfterUnitChange(user.id); + } + annotate({ action: { name: "auth.me.glucose-unit.patch" }, meta: { glucoseUnit: next }, diff --git a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts index 17f9e74a3..430a05879 100644 --- a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts +++ b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts @@ -11,6 +11,10 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/insights/unit-change-refresh", () => ({ + refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); @@ -43,6 +47,7 @@ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -197,3 +202,33 @@ describe("PATCH /api/auth/me/unit-preference", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); }); + +describe("PATCH re-warms the texts that quote the unit", () => { + it("re-warms the stored notes when the unit actually changes", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + unitPreference: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ unitPreference: "imperial" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1"); + }); + + it("leaves them alone when the same unit is saved again", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + unitPreference: "imperial", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ unitPreference: "imperial" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/unit-preference/route.ts b/src/app/api/auth/me/unit-preference/route.ts index ae7b459d7..fe17915fa 100644 --- a/src/app/api/auth/me/unit-preference/route.ts +++ b/src/app/api/auth/me/unit-preference/route.ts @@ -38,6 +38,7 @@ import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { DEFAULT_UNIT_PREFERENCE } from "@/lib/measurements/display-transform"; import { unitPreferencePatchSchema } from "@/lib/validations/user-prefs"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; export const dynamic = "force-dynamic"; @@ -118,6 +119,12 @@ export const PATCH = apiHandler(async (req: Request) => { }, }); + // The status notes and narratives quote mass, length and temperature in + // this branch; re-warm them so today's stored text follows the switch. + if (resolve(previous?.unitPreference) !== next) { + void refreshTextsAfterUnitChange(user.id); + } + annotate({ action: { name: "auth.me.unit-preference.patch" }, meta: { unitPreference: next }, diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts index 5b91fa3de..f72f62af9 100644 --- a/src/app/api/record-settings/[family]/route.ts +++ b/src/app/api/record-settings/[family]/route.ts @@ -41,6 +41,7 @@ import { parseNotificationPrefs, resolveNotificationPrefs, } from "@/lib/validations/notification-prefs"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; export const dynamic = "force-dynamic"; @@ -230,6 +231,9 @@ export const PATCH = apiHandler( }, }); invalidateUserProfile(access.recordId); + if (patch.unitPreference !== undefined) { + void refreshTextsAfterUnitChange(access.recordId); + } if (patch.timezone !== undefined) { invalidateUserMedications(access.recordId, { evict: true }); } diff --git a/src/lib/ai/prompts/__tests__/interpretation-block.test.ts b/src/lib/ai/prompts/__tests__/interpretation-block.test.ts index 31a04abca..010100fb2 100644 --- a/src/lib/ai/prompts/__tests__/interpretation-block.test.ts +++ b/src/lib/ai/prompts/__tests__/interpretation-block.test.ts @@ -9,6 +9,29 @@ import { describe, expect, it } from "vitest"; import { buildInterpretationBlock } from "../interpretation-block"; describe("buildInterpretationBlock", () => { + it("classifies on the canonical value but prints in the reader's unit", () => { + const toF = (c: number) => Math.round((c * 1.8 + 32) * 10) / 10; + const block = buildInterpretationBlock({ + metricKey: "BODY_TEMPERATURE", + value: 36.8, + sex: null, + locale: "en", + display: { unit: "°F", convert: toF }, + }); + expect(block).toBeDefined(); + expect(block).toContain("Current value 98.2 °F"); + expect(block).not.toContain("°C"); + // Same band as the canonical block names. + const canonical = buildInterpretationBlock({ + metricKey: "BODY_TEMPERATURE", + value: 36.8, + sex: null, + locale: "en", + })!; + const band = (text: string) => text.match(/is in the "([^"]+)" band/)![1]; + expect(band(block!)).toBe(band(canonical)); + }); + it("renders the band position for visceral fat 2.7 (the headline case)", () => { const en = buildInterpretationBlock({ metricKey: "VISCERAL_FAT", diff --git a/src/lib/ai/prompts/interpretation-block.ts b/src/lib/ai/prompts/interpretation-block.ts index 84bad478e..7e42185a6 100644 --- a/src/lib/ai/prompts/interpretation-block.ts +++ b/src/lib/ai/prompts/interpretation-block.ts @@ -130,9 +130,17 @@ function proximityPhrase( */ export function buildInterpretationBlock(args: { metricKey: string; + /** The value in the guideline's own (canonical) unit — what is classified. */ value: number; sex: "MALE" | "FEMALE" | null | undefined; locale: Locale; + /** + * Print the block in the reader's unit. Classification always runs on the + * canonical value against the canonical bands; only the printed numbers and + * the unit symbol are converted, so a prompt whose snapshot reads °F does + * not carry a band table in °C beside it. + */ + display?: { unit: string; convert: (canonical: number) => number }; }): string | undefined { const resolved = resolveInterpretation(args.metricKey, args.sex ?? null); if (!resolved) return undefined; @@ -140,22 +148,24 @@ export function buildInterpretationBlock(args: { const loc: "en" | "de" = instructionLocale(args.locale); const pos = classifyBandPosition(args.value, resolved.bands); - const rows = resolved.bands + const show = (n: number) => (args.display ? args.display.convert(n) : n); + const unit = args.display ? args.display.unit : resolved.unit; + const shownBands = resolved.bands.map((band) => ({ + ...band, + upTo: band.upTo === null ? null : show(band.upTo), + })); + + const rows = shownBands .map((band, i) => - bandRow( - band, - i > 0 ? resolved.bands[i - 1].upTo : null, - resolved.unit, - loc, - ), + bandRow(band, i > 0 ? shownBands[i - 1].upTo : null, unit, loc), ) .map((r) => ` - ${r}`) .join("\n"); const proximity = proximityPhrase( pos.proximity, - pos.nearestEdge, - resolved.unit, + pos.nearestEdge === null ? null : show(pos.nearestEdge), + unit, loc, ); const valence = valencePhrase(pos.band.valence, loc); @@ -164,14 +174,14 @@ export function buildInterpretationBlock(args: { if (loc === "en") { return `INTERPRETATION CONTEXT (guideline bands — computed server-side; state, do NOT recompute): -- Current value ${args.value} ${resolved.unit} is in the "${pos.band.label}" band, and ${proximity}. +- Current value ${show(args.value)} ${unit} is in the "${pos.band.label}" band, and ${proximity}. - Guideline bands (${resolved.source}; ${direction}): ${rows} - Values in the "${pos.band.label}" band are considered ${valence} per these guidelines.${caveat} - Name where the value sits and what that band means in plain words. Judge any trend BY this position: a shift deep inside a favourable band is a footnote; the same shift approaching or crossing a boundary is the headline. Frame consequence without diagnosis ("values in this range are considered …", never "you have / are at risk of …").`; } return `EINORDNUNGS-KONTEXT (Leitlinien-Bänder — serverseitig berechnet; nennen, NICHT neu rechnen): -- Der aktuelle Wert ${args.value} ${resolved.unit} liegt im Band "${pos.band.label}" und ${proximity}. +- Der aktuelle Wert ${show(args.value)} ${unit} liegt im Band "${pos.band.label}" und ${proximity}. - Leitlinien-Bänder (${resolved.source}; ${direction}): ${rows} - Werte im Band "${pos.band.label}" gelten laut diesen Leitlinien als ${valence}.${caveat} diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 0f7fe5be4..74903b025 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -507,3 +507,134 @@ describe("generateMetricStatus — generation path", () => { expect(result.text).toContain("Steady."); }); }); + +describe("generateMetricStatus — the note is written in the reader's units", () => { + function seed(values: (day: number) => number, days = 120) { + const now = new Date(); + const records: Array<{ value: number; measuredAt: Date }> = []; + for (let day = 0; day < days; day++) { + records.push({ + value: values(day), + measuredAt: new Date(now.getTime() - day * dayMs), + }); + } + vi.mocked(prisma.measurement.count).mockResolvedValue(days as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); + } + + function snapshotOf(userPrompt: string) { + return JSON.parse(userPrompt.match(/\{[\s\S]*\}/)![0]); + } + + it("states glucose, its band and its series in mmol/L for an mmol/L reader", async () => { + seed((day) => 99 + (day % 4) * 3); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: "metric", + glucoseUnit: "mmol/L", + } as never); + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Steady."}', captured); + + await generateMetricStatus({ + metric: "BLOOD_GLUCOSE", + userId: "user-1", + locale: "en", + }); + + expect(captured.systemPrompt).toContain("unit: mmol/L"); + // 70–140 mg/dL is 3.9–7.8 mmol/L. + expect(captured.systemPrompt).toContain("3.9–7.8 mmol/L"); + // The shared safety contract quotes its own fixed thresholds; the metric + // block itself must not carry the canonical band. + expect(captured.systemPrompt).not.toContain("70–140 mg/dL"); + const snapshot = snapshotOf(captured.userPrompt!); + expect(snapshot.metric.unit).toBe("mmol/L"); + expect(snapshot.metric.normalRange).toMatchObject({ low: 3.9, high: 7.8 }); + const recentMeans = snapshot.BLOOD_GLUCOSE.series.recent.map( + (b: { mean: number }) => b.mean, + ); + expect(recentMeans.length).toBeGreaterThan(0); + for (const mean of recentMeans) { + expect(mean).toBeGreaterThan(5); + expect(mean).toBeLessThan(6.5); + } + expect(captured.userPrompt).not.toContain("mg/dL"); + }); + + it("states body temperature in °F for an imperial reader, guideline band included", async () => { + seed((day) => 36.6 + (day % 3) * 0.1); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: "imperial", + glucoseUnit: null, + } as never); + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Steady."}', captured); + + await generateMetricStatus({ + metric: "BODY_TEMPERATURE", + userId: "user-1", + locale: "en", + }); + + expect(captured.systemPrompt).toContain("unit: °F"); + expect(captured.systemPrompt).not.toContain("36.1–37.2 °C"); + // The interpretation block classifies in °C but prints in °F. + expect(captured.userPrompt).toContain("INTERPRETATION CONTEXT"); + expect(captured.userPrompt).not.toContain("°C"); + expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("°F"); + }); + + it("writes the no-provider floor in the reader's glucose unit", async () => { + seed((day) => 99 + (day % 4) * 3); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: "metric", + glucoseUnit: "mmol/L", + } as never); + vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" } as never); + + const result = await generateMetricStatus({ + metric: "BLOOD_GLUCOSE", + userId: "user-1", + locale: "en", + }); + + expect(result.text).toMatch(/\d\.\d mmol\/L/); + expect(result.text).not.toContain("mg/dL"); + }); + + it("leaves a metric reader on the default units byte-identical", async () => { + seed((day) => 99 + (day % 4) * 3); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: null, + glucoseUnit: null, + } as never); + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Steady."}', captured); + + await generateMetricStatus({ + metric: "BLOOD_GLUCOSE", + userId: "user-1", + locale: "en", + }); + + expect(captured.systemPrompt).toContain("70–140 mg/dL"); + expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("mg/dL"); + }); +}); diff --git a/src/lib/insights/__tests__/unit-change-refresh.test.ts b/src/lib/insights/__tests__/unit-change-refresh.test.ts new file mode 100644 index 000000000..15a0eac27 --- /dev/null +++ b/src/lib/insights/__tests__/unit-change-refresh.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + insightNarrative: { findMany: vi.fn() }, + }, +})); +vi.mock("@/lib/insights/status-invalidation", () => ({ + enqueueStatusRefillForUser: vi.fn(), +})); +vi.mock("@/lib/jobs/period-narrative-shared", () => ({ + enqueueNarrativeWarm: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation"; +import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared"; +import { refreshTextsAfterUnitChange } from "../unit-change-refresh"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("refreshTextsAfterUnitChange", () => { + it("refills the status cards in the reader's locale and re-warms each stored narrative", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "de", + } as never); + vi.mocked(enqueueStatusRefillForUser).mockResolvedValue(12); + vi.mocked(prisma.insightNarrative.findMany).mockResolvedValue([ + { period: "week", locale: "de" }, + { period: "month", locale: "en" }, + { period: "year", locale: "de" }, + { period: "week", locale: "xx" }, + ] as never); + + await refreshTextsAfterUnitChange("user-1"); + + expect(enqueueStatusRefillForUser).toHaveBeenCalledWith("user-1", "de"); + expect(enqueueNarrativeWarm).toHaveBeenCalledTimes(2); + expect(enqueueNarrativeWarm).toHaveBeenCalledWith({ + userId: "user-1", + period: "week", + locale: "de", + }); + expect(enqueueNarrativeWarm).toHaveBeenCalledWith({ + userId: "user-1", + period: "month", + locale: "en", + }); + }); + + it("never throws into the preference write", async () => { + vi.mocked(prisma.user.findUnique).mockRejectedValue(new Error("db down")); + await expect(refreshTextsAfterUnitChange("user-1")).resolves.toBe( + undefined, + ); + }); +}); diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts index b67298080..73e1938f8 100644 --- a/src/lib/insights/graded-series.ts +++ b/src/lib/insights/graded-series.ts @@ -598,6 +598,35 @@ export function scaleGradedSeries( }; } +/** + * Re-express a canonical graded series in a display unit: absolute bucket + * values (`min / max / mean`) take the full affine conversion, the yearly + * slope — a rate, not a level — takes the factor alone, exactly as + * `applyDisplayTransformDelta` treats a delta. Bucket keys and counts are + * untouched. Used where a series is narrated in the reader's unit; the + * series it is built from stays canonical. + */ +export function convertGradedSeries( + series: GradedSeries, + conversion: { factor: number; offset?: number }, + digits = 2, +): GradedSeries { + const offset = conversion.offset ?? 0; + const abs = (v: number) => round(v * conversion.factor + offset, digits); + const bucket = ( + b: T, + ): T => ({ ...b, min: abs(b.min), max: abs(b.max), mean: abs(b.mean) }); + return { + recent: series.recent.map(bucket), + weekly: series.weekly.map(bucket), + monthly: series.monthly.map(bucket), + yearly: series.yearly.map((b) => ({ + ...bucket(b), + slope: b.slope === null ? null : round(b.slope * conversion.factor, 4), + })), + }; +} + /** * Assembled-snapshot soft char cap, mirroring the Coach's * `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index a7d922cf3..b937b689c 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -58,7 +58,9 @@ import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { buildGradedSeriesFromPoints, buildGradedSeriesWithRollups, + convertGradedSeries, degradeStatusSnapshotToBudget, + type GradedSeries, } from "@/lib/insights/graded-series"; import { type SupportedLocale, @@ -84,6 +86,16 @@ import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { binaryReferenceSex } from "@/lib/profile/sex"; +import { + applyDisplayTransform, + getReadingTransform, + invertDisplayTransform, + isUnitSensitiveType, + resolveUnitPreferences, + transformRescales, + type DisplayTransform, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; export interface MetricStatusResult { hasProvider: boolean; @@ -222,9 +234,40 @@ export async function generateMetricStatus(args: { const user = await prisma.user.findUnique({ where: { id: args.userId }, - select: { dateOfBirth: true, gender: true }, + select: { + dateOfBirth: true, + gender: true, + unitPreference: true, + glucoseUnit: true, + }, }); + // The note is written in the reader's units. The series, the reference band + // and the signal are converted once, here, before any of them reaches the + // prompt or the deterministic floor; the registry entry, the rollups and + // the guideline classification stay canonical. An untransformed metric (and + // every metric on the default preferences that does not rescale) passes + // through unchanged, so its snapshot hash does not move. + const reading = readingDisplay( + meta, + resolveUnitPreferences({ + unitPreference: user?.unitPreference, + glucoseUnit: user?.glucoseUnit, + }), + ); + const shownMeta: MetricStatusMeta = { + ...meta, + unit: reading.unit, + ...(meta.normalRange + ? { + normalRange: { + low: reading.toDisplay(meta.normalRange.low), + high: reading.toDisplay(meta.normalRange.high), + }, + } + : {}), + }; + const now = new Date(); // Bounded raw read for the derived recent stats; the monthly/yearly @@ -295,7 +338,10 @@ export async function generateMetricStatus(args: { }) .then((r) => r.reverse()); for (const m of rows) { - points.push({ measuredAt: m.measuredAt, value: m.value }); + points.push({ + measuredAt: m.measuredAt, + value: reading.toDisplay(m.value), + }); } measurements = rows.map((m) => ({ measuredAt: m.measuredAt })); } @@ -309,11 +355,13 @@ export async function generateMetricStatus(args: { // recent/weekly/monthly/yearly bucket is a night total, not a stage sum. const graded = isSleep ? buildGradedSeriesFromPoints(points, now, userTz) - : await buildGradedSeriesWithRollups( - args.userId, - meta.measurementType, - now, - userTz, + : reading.convertGraded( + await buildGradedSeriesWithRollups( + args.userId, + meta.measurementType, + now, + userTz, + ), ); const summary = summarizeSeries( series.daily.map((bucket) => ({ value: bucket.value })), @@ -344,7 +392,14 @@ export async function generateMetricStatus(args: { // metric + profile; otherwise keep the existing flat anchor (strictly // additive, never a regression). Age + sex only — no ancestry/region. const sharpenedRange = lookupNormalRange(meta.id, ageYears, sex); - const normalRange = sharpenedRange ?? meta.normalRange; + const canonicalRange = sharpenedRange ?? meta.normalRange; + const normalRange = canonicalRange + ? { + ...canonicalRange, + low: reading.toDisplay(canonicalRange.low), + high: reading.toDisplay(canonicalRange.high), + } + : canonicalRange; // v1.13.x — the SIGNAL block: the recent-vs-baseline comparison + the // normal-swing verdict, computed server-side so the model states it rather @@ -353,7 +408,7 @@ export async function generateMetricStatus(args: { // when the recent window is empty, in which case it is omitted. const signal = buildMetricSignal({ metric: meta.displayName, - unit: meta.unit, + unit: shownMeta.unit, direction: meta.direction, graded, normalRange: normalRange ?? null, @@ -369,7 +424,7 @@ export async function generateMetricStatus(args: { metric: { id: meta.id, displayName: meta.displayName, - unit: meta.unit, + unit: shownMeta.unit, direction: meta.direction, ...(normalRange ? { normalRange } : {}), ...(sharpenedRange ? { normalRangeSource: "age-sex-adjusted" } : {}), @@ -477,14 +532,24 @@ export async function generateMetricStatus(args: { // window exists); absent for uncovered metrics (fail-soft to personal- // relative). Sex-split bands (waist) resolve only when the profile carries a // sex. + // The guideline bands are canonical, so the band position is classified on + // the canonical value; the block then prints in the reader's unit. const interpretationValue = signal?.current ?? latest?.value ?? null; const interpretationBlock = interpretationValue !== null ? buildInterpretationBlock({ metricKey: meta.id, - value: interpretationValue, + value: reading.toCanonical(interpretationValue), sex, locale, + ...(reading.converts + ? { + display: { + unit: reading.unit, + convert: reading.toDisplay, + }, + } + : {}), }) : undefined; @@ -502,9 +567,9 @@ export async function generateMetricStatus(args: { userId: args.userId, cacheAction, capability: "statusText", - systemPrompt: getMetricArchetypeSystemPrompt(meta, locale), + systemPrompt: getMetricArchetypeSystemPrompt(shownMeta, locale), userPrompt: getMetricArchetypeUserPrompt( - meta, + shownMeta, snapshotJson, todayKey, locale, @@ -526,7 +591,7 @@ export async function generateMetricStatus(args: { if (outcome.kind === "none") { return { hasProvider: false, - text: getNoKeyMetricStatusText(locale, signal), + text: getNoKeyMetricStatusText(locale, signal, reading.decimals), cached: true, updatedAt: null, }; @@ -537,7 +602,7 @@ export async function generateMetricStatus(args: { reason: outcome.kind, userId: args.userId, todayKey, - stubText: getNoKeyMetricStatusText(locale, signal), + stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals), }); } @@ -553,7 +618,7 @@ export async function generateMetricStatus(args: { reason: "screened", userId: args.userId, todayKey, - stubText: getNoKeyMetricStatusText(locale, signal), + stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals), }); } const text = screened.text; @@ -577,3 +642,51 @@ export function resolveMetricStatusLocale( ): SupportedLocale { return normalizeLocale(locale); } + +/** + * How one metric's numbers are written for this reader: the unit symbol, the + * conversions in both directions, and the decimals a value is read at. + * + * Only a unit-sensitive type (`isUnitSensitiveType`: glucose and the + * metric/imperial set) converts; every other metric keeps its registry unit + * and its stored numbers. A transform that does not rescale (`converts` + * false) is the identity, so the default preferences leave a snapshot + * byte-identical for the metrics whose registry unit already is the display + * unit. + */ +function readingDisplay( + meta: MetricStatusMeta, + preferences: UnitPreferences, +): { + unit: string; + decimals: number | undefined; + converts: boolean; + toDisplay: (canonical: number) => number; + toCanonical: (display: number) => number; + convertGraded: (series: GradedSeries) => GradedSeries; +} { + if (!isUnitSensitiveType(meta.measurementType)) { + return { + unit: meta.unit, + decimals: undefined, + converts: false, + toDisplay: (v) => v, + toCanonical: (v) => v, + convertGraded: (series) => series, + }; + } + const transform: DisplayTransform = getReadingTransform( + meta.measurementType, + preferences, + ); + const converts = transform.factor !== 1 || (transform.offset ?? 0) !== 0; + return { + unit: transform.displayUnit, + decimals: transform.decimals, + converts, + toDisplay: (v) => applyDisplayTransform(v, transform), + toCanonical: (v) => invertDisplayTransform(v, transform), + convertGraded: (series) => + converts ? convertGradedSeries(series, transform) : series, + }; +} diff --git a/src/lib/insights/no-key-fallbacks.ts b/src/lib/insights/no-key-fallbacks.ts index 7be0863dd..fd54f0cf3 100644 --- a/src/lib/insights/no-key-fallbacks.ts +++ b/src/lib/insights/no-key-fallbacks.ts @@ -439,6 +439,12 @@ const GENERIC_METRIC_POINTER: Localized = { export function getNoKeyMetricStatusText( locale: InsightLocale, signal?: MetricSignal | null, + /** + * Decimals the signal's unit is read at (0 for mg/dL, 1 for mmol/L or + * pounds). Defaults to one, which was the only precision before the + * signal could arrive in the reader's unit. + */ + digits = 1, ): string { const grounded = signal && Number.isFinite(signal.current) @@ -449,7 +455,7 @@ export function getNoKeyMetricStatusText( // localised upstream, so it stands for every locale. label: { en: signal.metric }, ...(signal.unit ? { unit: ` ${signal.unit}` } : {}), - digits: 1, + digits, pointer: GENERIC_METRIC_POINTER, }, locale, diff --git a/src/lib/insights/unit-change-refresh.ts b/src/lib/insights/unit-change-refresh.ts new file mode 100644 index 000000000..de820ceed --- /dev/null +++ b/src/lib/insights/unit-change-refresh.ts @@ -0,0 +1,63 @@ +/** + * Re-warm the stored texts that quote numbers after a unit preference changes. + * + * A status note and a period narrative are written once and served all day + * (the narrative for up to twenty hours). Both now state their figures in the + * reader's units, so a note written before a switch from mg/dL to mmol/L — or + * from metric to imperial — keeps saying the old unit until something + * regenerates it. Nothing did: neither preference write touched the stored + * text. + * + * The refill goes through the same hash-gated path as the manual regenerate: + * every card the record has is enqueued, the worker forces a fresh snapshot, + * and a card whose snapshot did not change (a metric that does not depend on + * the preference) gets a timestamp refresh and no model call. Narratives are + * re-warmed only where one already exists. Best-effort and fire-and-forget: + * the preference write never waits on it and never fails because of it. + */ +import { prisma } from "@/lib/db"; +import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared"; +import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation"; +import { normalizeLocale } from "@/lib/insights/status-shared"; +import { locales, type Locale } from "@/lib/i18n/config"; +import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative"; +import { annotate } from "@/lib/logging/context"; + +export async function refreshTextsAfterUnitChange( + userId: string, +): Promise { + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + const statusScopes = await enqueueStatusRefillForUser( + userId, + normalizeLocale(user?.locale), + ); + + const narratives = await prisma.insightNarrative.findMany({ + where: { userId }, + select: { period: true, locale: true }, + }); + let narrativeCount = 0; + for (const row of narratives) { + if (row.period !== "week" && row.period !== "month") continue; + if (!(locales as readonly string[]).includes(row.locale)) continue; + void enqueueNarrativeWarm({ + userId, + period: row.period as NarrativePeriod, + locale: row.locale as Locale, + }); + narrativeCount++; + } + + annotate({ + action: { name: "insights.unit-change.refresh" }, + meta: { status_scopes: statusScopes, narratives: narrativeCount }, + }); + } catch { + // Best-effort: the nightly warm and the next ingest still converge the + // texts; a failed enqueue must not surface on the preference write. + } +}