From a7e6b89672628bf4e867e9f7365549df152f375a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 12:54:41 +0200 Subject: [PATCH] fix(insights): hand the briefing its figures in the reader's units The daily briefing serialised the aggregated feature set as computed: weight, grip strength and the weight target in kilograms, waist in centimetres, glucose and the signals of the day in mg/dL (the glucose signal carried no unit at all), workout distances in kilometres. A reader on pounds or mmol/L got a briefing in units they do not use. One step now converts the feature set after all canonical arithmetic, on both the nightly and the on-demand path: absolute values through the full transform, differences and slopes through the factor alone, every converted block labelled, workout distances under unit-neutral keys. The prompt, the stored fingerprint and the grounding check all read the converted set, so a unit switch moves the fingerprint and the grounding check compares the reply against the figures the model was shown. The weight target sentence, the comparison block and the GLP-1 plateau context quote weight in the reader's unit; the comparison block also labelled sleep as hours while carrying minutes. A unit switch now also re-warms a cached briefing alongside the status notes and narratives. Refs #1067 --- .../insights/generate/__tests__/route.test.ts | 41 +++- src/app/api/insights/generate/route.ts | 19 +- .../comparison-snapshot-window.test.ts | 38 +++ .../comprehensive-briefing-reroll.test.ts | 10 +- .../comprehensive-generate-force.test.ts | 68 +++++- .../insights/__tests__/features-units.test.ts | 205 ++++++++++++++++ src/lib/insights/__tests__/features.test.ts | 5 +- .../insights/__tests__/glp1-plateau.test.ts | 17 ++ .../__tests__/unit-change-refresh.test.ts | 32 +++ src/lib/insights/comprehensive-generate.ts | 56 ++++- src/lib/insights/features-units.ts | 228 ++++++++++++++++++ src/lib/insights/glp1-plateau.ts | 14 +- src/lib/insights/unit-change-refresh.ts | 26 +- .../targets/__tests__/weight-trend.test.ts | 27 +++ src/lib/targets/weight-trend.ts | 29 ++- 15 files changed, 794 insertions(+), 21 deletions(-) create mode 100644 src/lib/insights/__tests__/features-units.test.ts create mode 100644 src/lib/insights/features-units.ts diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index c81ed66ff..f77381dad 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -12,7 +12,7 @@ * - 429 from the provider → 429 (passthrough, not 5xx) * - any other status → 422 (generic provider-connection failure) */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, onTestFinished } from "vitest"; // Mocks must be hoisted before importing the route. vi.mock("@/lib/api-handler", () => ({ @@ -171,6 +171,7 @@ import { FeaturesPayloadTooLargeError, } from "@/lib/insights/features"; import { annotate } from "@/lib/logging/context"; +import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt"; import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; beforeEach(() => { @@ -460,6 +461,44 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { expect(args.data.insightsCachedLocale).toBe("en"); }); + it("serialises the feature set in the reader's units", async () => { + makeWorkingProvider(); + const user = { + insightsPrivacyMode: "aggregated", + insightsCachedAt: null, + insightsCachedText: null, + locale: "en", + unitPreference: "imperial", + glucoseUnit: "mmol/L", + }; + const findUnique = vi.mocked(prisma.user.findUnique); + const original = findUnique.getMockImplementation(); + findUnique.mockResolvedValue(user as never); + onTestFinished(() => { + findUnique.mockImplementation(original!); + }); + vi.mocked(extractFeatures).mockResolvedValueOnce({ + weight: { latest: 80, avg7: 80.5 }, + glucose: { latest: 126, avg30: 99 }, + } as never); + + const res = await POST(jsonRequest({ force: true }) as never); + expect(res.status).toBe(200); + + const featuresJson = vi.mocked(buildUserPrompt).mock.calls[0][0]; + const features = JSON.parse(featuresJson); + expect(features.weight).toMatchObject({ + unit: "lb", + latest: 176.4, + avg7: 177.5, + }); + expect(features.glucose).toMatchObject({ + unit: "mmol/L", + latest: 7, + avg30: 5.5, + }); + }); + it("refuses a stale cache write when AI profile inclusion changes during generation", async () => { makeWorkingProvider(); selfContextText diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 266d77959..2180ace0c 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -87,6 +87,8 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { normalizeLocale } from "@/lib/insights/status-shared"; import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { featuresInReaderUnits } from "@/lib/insights/features-units"; +import { resolveUnitPreferences } from "@/lib/measurements/display-transform"; import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; // v1.28.25 — the comparison-snapshot builder was a private near-copy of // the lib export (drifted only in comments); the route now shares the @@ -340,6 +342,10 @@ export const POST = apiHandler((request: NextRequest) => // v1.25 — per-user response-timeout (seconds), threaded onto the // generation calls so a slow self-hosted backend honours the setting. aiResponseTimeoutSeconds: true, + // The reader's units; the feature set is converted into them before + // it is serialised, hashed and checked for grounding. + unitPreference: true, + glucoseUnit: true, }, }); const modeAtRead = dbUser?.insightsPrivacyMode ?? "aggregated"; @@ -610,6 +616,13 @@ export const POST = apiHandler((request: NextRequest) => // BEFORE serialisation so the model never sees the dropped blocks. const excludeList = dbUser?.insightsExcludeMetrics ?? []; features = applyInsightsExcludeFilter(features, excludeList); + // The reader's units, after all canonical arithmetic: the prompt, the + // stored hash and the grounding check read the same converted figures. + const units = resolveUnitPreferences({ + unitPreference: dbUser?.unitPreference, + glucoseUnit: dbUser?.glucoseUnit, + }); + features = featuresInReaderUnits(features, units); // v1.4.36 W3 T4 — drop zero-row blocks so the prompt never carries // labelled-empty sections (`"sleep": []`, `"medications": []`, // etc.). Prevents the model from narrating "there are no @@ -651,7 +664,11 @@ export const POST = apiHandler((request: NextRequest) => comparisonSnapshot ?? undefined, ); if (plateauContext) { - userPrompt += buildGlp1PlateauPrompt(plateauContext, locale); + userPrompt += buildGlp1PlateauPrompt( + plateauContext, + locale, + units.system, + ); } // v1.15.20 — fold the user-authored "about me" self-description // (Settings → AI) into the briefing as a delimited, user-provided diff --git a/src/lib/insights/__tests__/comparison-snapshot-window.test.ts b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts index 17411e666..98adc7732 100644 --- a/src/lib/insights/__tests__/comparison-snapshot-window.test.ts +++ b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts @@ -263,4 +263,42 @@ describe("buildComparisonSnapshotForUser — output-equivalence to the raw-avera expect(measurementAggregate).not.toHaveBeenCalled(); expect(measurementFindMany).not.toHaveBeenCalled(); }); + + it("states weight in the reader's mass unit and labels sleep in minutes", async () => { + findUnique.mockResolvedValue({ + dashboardWidgetsJson: { + version: 1, + widgets: [], + comparisonBaseline: "lastMonth", + }, + unitPreference: "imperial", + }); + measurementAggregate.mockImplementation( + async (args: { where: { type: string; measuredAt: { lte?: Date } } }) => + args.where.type === "WEIGHT" + ? { _avg: { value: args.where.measuredAt.lte ? 82 : 80 } } + : { _avg: { value: null } }, + ); + // Seven-hour nights, stored in minutes. + measurementFindMany.mockResolvedValue( + Array.from({ length: 5 }, (_, i) => ({ + measuredAt: new Date(Date.now() - (i + 2) * DAY), + value: 420, + sleepStage: null, + source: "APPLE_HEALTH", + deviceType: null, + })), + ); + const snapshot = await buildComparisonSnapshotForUser("u1"); + const sleep = snapshot!.metrics.find((m) => m.type === "sleep"); + expect(sleep).toMatchObject({ unit: "min", currentAvg: 420 }); + const weight = snapshot!.metrics.find((m) => m.type === "weight"); + expect(weight).toMatchObject({ + unit: "lb", + currentAvg: 176.4, + baselineAvg: 180.8, + delta: -4.4, + deltaPercent: -2.4, + }); + }); }); diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index e40fa860c..b805f1e75 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -94,10 +94,18 @@ vi.mock("@/lib/cache/invalidate", () => ({ import { generateComprehensiveInsight } from "../comprehensive-generate"; import { hashInsightSnapshot } from "../snapshot-hash"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; +import { featuresInReaderUnits } from "../features-units"; +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; const FEATURES = { weight: { count: 12, latest: 81.4, mean30: 82.1 } }; +// Hashed as the prompt reads it: in the reader's units (defaults here). const FEATURES_HASH = hashInsightSnapshot({ - features: compactSections(FEATURES as unknown as Record), + features: compactSections( + featuresInReaderUnits( + FEATURES as never, + DEFAULT_UNIT_PREFERENCES, + ) as unknown as Record, + ), aboutMe: null, comparisonBaseline: "none", generationLocale: "de", diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index c9165b0c9..1bf266d3f 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -94,6 +94,8 @@ vi.mock("@/lib/ai/coach/about-me", () => ({ import { generateComprehensiveInsight } from "../comprehensive-generate"; import { hashInsightSnapshot } from "../snapshot-hash"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; +import { featuresInReaderUnits } from "../features-units"; +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; /** A small data-bearing feature set that survives `compactSections`. */ const FEATURES = { @@ -103,9 +105,16 @@ const FEATURES = { // (null here — the about-me module is mocked to no text), the // comparison-baseline setting ("none" — the mocked user row carries no // dashboardWidgetsJson) and the generation locale, matching the -// composite shape both the gate and the POST route hash. +// composite shape both the gate and the POST route hash. The features are +// hashed as the prompt reads them: in the reader's units (the mocked user +// row carries no preference, so the defaults). const FEATURES_HASH = hashInsightSnapshot({ - features: compactSections(FEATURES as unknown as Record), + features: compactSections( + featuresInReaderUnits( + FEATURES as never, + DEFAULT_UNIT_PREFERENCES, + ) as unknown as Record, + ), aboutMe: null, comparisonBaseline: "none", generationLocale: "de", @@ -305,6 +314,61 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { expect(auditDeleteMany).not.toHaveBeenCalled(); }); + it("hands the model the feature set in the reader's units and fingerprints that", async () => { + makeByokChain(); + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsExcludeMetrics: [], + insightsSnapshotHash: FEATURES_HASH, + unitPreference: "imperial", + glucoseUnit: "mmol/L", + }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { + content: JSON.stringify({ dailyBriefing: { p: "new" } }), + tokensUsed: 10, + providerType: "openai", + model: "m", + }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + + // The kilogram fingerprint no longer matches: a unit switch regenerates. + const outcome = await generateComprehensiveInsight("u1", { + locale: "de", + force: true, + }); + expect(outcome).toEqual({ status: "generated", providerType: "openai" }); + + const prompt = JSON.stringify(runRawCompletionWithFallback.mock.calls[0]); + expect(prompt).toContain('\\"unit\\": \\"lb\\"'); + expect(prompt).toContain("179.5"); + expect(prompt).not.toContain("81.4"); + + const write = userUpdate.mock.calls.find( + (c) => + (c[0] as { data: Record }).data.insightsCachedText !== + undefined, + ); + const data = (write![0] as { data: Record }).data; + expect(data.insightsSnapshotHash).toBe( + hashInsightSnapshot({ + features: compactSections( + featuresInReaderUnits(FEATURES as never, { + system: "imperial", + glucoseUnit: "mmol/L", + }) as unknown as Record, + ), + aboutMe: null, + comparisonBaseline: "none", + generationLocale: "de", + }), + ); + }); + it("a fresh cache tagged with another language does not short-circuit an unforced run", async () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", diff --git a/src/lib/insights/__tests__/features-units.test.ts b/src/lib/insights/__tests__/features-units.test.ts new file mode 100644 index 000000000..4f706ccd5 --- /dev/null +++ b/src/lib/insights/__tests__/features-units.test.ts @@ -0,0 +1,205 @@ +/** + * The feature set a briefing or the Coach reads is stated in the reader's + * units: absolute values through the full transform, differences and slopes + * through the factor alone, every converted block labelled. + */ +import { describe, expect, it } from "vitest"; + +import type { AggregatedFeatures } from "../features"; +import { featuresInReaderUnits } from "../features-units"; + +const coverage = { count: 10 } as unknown as NonNullable< + AggregatedFeatures["weight"] +>["coverage"]; + +const FEATURES = { + weight: { + latest: 80, + avg7: 80, + avg30: 81, + avg90: null, + allTimeAvg: 82, + allTimeMin: 70, + allTimeMax: 90, + slope30: -0.1, + outlierCount: 0, + bmi: 24.7, + coverage, + target: { + min: 65, + max: 70, + unit: "kg", + position: "above", + progress: "losing", + reading: "The person set their own weight target at 65–70 kg …", + }, + }, + glucose: { + avg7: 108, + avg30: 99, + avg90: null, + latest: 126, + latestDaysAgo: 0, + slope30: 1.8, + coverage, + }, + signalsOfDay: [ + { + metric: "weight", + label: "weight", + unit: "kg", + latest: 80, + latestDaysAgo: 0, + avg7: 80.5, + avg30: 81, + deltaVs7: -0.5, + deltaVs30: -1, + spread30: 0.6, + outsideNormalSwing: true, + emergingTrend: "falling", + recentAnomaly: { kind: "trough", value: 79, anomalyDaysAgo: 2 }, + }, + { + metric: "glucose", + label: "blood glucose", + latest: 90, + latestDaysAgo: 0, + avg7: 99, + avg30: 108, + deltaVs7: -9, + deltaVs30: -18, + spread30: 9, + outsideNormalSwing: true, + emergingTrend: "flat", + recentAnomaly: null, + }, + { + metric: "bp", + label: "blood pressure (systolic)", + unit: "mmHg", + latest: 128, + latestDaysAgo: 0, + avg7: 125, + avg30: 124, + deltaVs7: 3, + deltaVs30: 4, + spread30: 5, + outsideNormalSwing: false, + emergingTrend: "flat", + recentAnomaly: null, + }, + ], + historicalComparison: { + weight: { current7dAvg: 80, previous30dAvg: 82, change: -2 }, + }, + workouts: { + last7: { count: 2, totalDurationMin: 90, totalDistanceKm: 10 }, + last30: { count: 6, totalDurationMin: 300, totalDistanceKm: null }, + latest: { + sportType: "running", + daysAgo: 1, + durationMin: 45, + distanceKm: 5, + }, + }, + context: {}, +} as unknown as AggregatedFeatures; + +describe("featuresInReaderUnits", () => { + it("states weight in pounds for an imperial reader, labelled", () => { + const out = featuresInReaderUnits(FEATURES, { + system: "imperial", + glucoseUnit: "mg/dL", + }); + expect(out.weight).toMatchObject({ + unit: "lb", + latest: 176.4, + avg30: 178.6, + avg90: null, + allTimeMin: 154.3, + slope30: -0.2, + bmi: 24.7, + }); + // The target band is quoted in pounds and still judged in kilograms. + expect(out.weight?.target).toMatchObject({ + min: 143.3, + max: 154.3, + unit: "lb", + position: "above", + }); + expect(out.weight?.target?.reading).toContain("143.3–154.3 lb"); + expect(out.historicalComparison?.weight).toMatchObject({ + current7dAvg: 176.4, + change: -4.4, + unit: "lb", + }); + }); + + it("converts every figure of a weight signal, deltas by the factor alone", () => { + const [weight, glucose, bp] = featuresInReaderUnits(FEATURES, { + system: "imperial", + glucoseUnit: "mmol/L", + }).signalsOfDay!; + expect(weight).toMatchObject({ + unit: "lb", + latest: 176.4, + deltaVs7: -1.1, + spread30: 1.3, + recentAnomaly: { value: 174.2 }, + }); + expect(glucose).toMatchObject({ + unit: "mmol/L", + latest: 5, + avg30: 6, + deltaVs30: -1, + }); + expect(bp).toEqual(FEATURES.signalsOfDay![2]); + }); + + it("states glucose in mmol/L for a mmol/L reader", () => { + const out = featuresInReaderUnits(FEATURES, { + system: "metric", + glucoseUnit: "mmol/L", + }); + expect(out.glucose).toMatchObject({ + unit: "mmol/L", + avg7: 6, + avg30: 5.5, + latest: 7, + slope30: 0.1, + }); + }); + + it("states workout distance in the reader's unit under unit-neutral keys", () => { + const out = featuresInReaderUnits(FEATURES, { + system: "imperial", + glucoseUnit: "mg/dL", + }) as unknown as { + workouts: Record | string>; + }; + expect(out.workouts.distanceUnit).toBe("mi"); + expect(out.workouts.last7).toMatchObject({ totalDistance: 6.21 }); + expect(out.workouts.last7).not.toHaveProperty("totalDistanceKm"); + expect(out.workouts.last30).toMatchObject({ totalDistance: null }); + expect(out.workouts.latest).toMatchObject({ distance: 3.11 }); + }); + + it("keeps every number for a metric mg/dL reader and only adds labels", () => { + const out = featuresInReaderUnits(FEATURES, { + system: "metric", + glucoseUnit: "mg/dL", + }); + expect(out.weight).toMatchObject({ unit: "kg", latest: 80, avg30: 81 }); + expect(out.glucose).toMatchObject({ unit: "mg/dL", avg7: 108 }); + expect(out.signalsOfDay![1]).toMatchObject({ unit: "mg/dL", latest: 90 }); + }); + + it("leaves the input untouched", () => { + const before = JSON.stringify(FEATURES); + featuresInReaderUnits(FEATURES, { + system: "imperial", + glucoseUnit: "mmol/L", + }); + expect(JSON.stringify(FEATURES)).toBe(before); + }); +}); diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 39cda5375..94ad4694e 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -392,8 +392,9 @@ describe("extractFeatures — weight target direction (#1006)", () => { prismaMock.measurement.findMany.mockResolvedValue(weightRows); const f = await extractFeatures("user-1", false); expect(f.weight?.target).toMatchObject({ - minKg: 65, - maxKg: 70, + min: 65, + max: 70, + unit: "kg", position: "below", progress: "gaining", }); diff --git a/src/lib/insights/__tests__/glp1-plateau.test.ts b/src/lib/insights/__tests__/glp1-plateau.test.ts index 53ad62cb3..4ee91d935 100644 --- a/src/lib/insights/__tests__/glp1-plateau.test.ts +++ b/src/lib/insights/__tests__/glp1-plateau.test.ts @@ -215,6 +215,23 @@ describe("glp1-plateau", () => { expect(prompt).toContain("GRUNDREGEL 13"); }); + it("states the weight change in pounds for an imperial reader", () => { + const ctx = { + drug: "Mounjaro", + doseValue: 7.5, + doseUnit: "mg", + doseSince: "2026-04-01", + daysOnDose: 30, + weightDeltaKg: -0.4, + readingsCount: 9, + }; + const en = buildGlp1PlateauPrompt(ctx, "en", "imperial"); + expect(en).toContain("shifted by -0.9 lb"); + expect(en).not.toMatch(/\bkg\b/); + const de = buildGlp1PlateauPrompt(ctx, "de", "imperial"); + expect(de).toContain("nur um -0.9 lb verändert"); + }); + it("computes week number from days on dose", () => { const prompt = buildGlp1PlateauPrompt( { diff --git a/src/lib/insights/__tests__/unit-change-refresh.test.ts b/src/lib/insights/__tests__/unit-change-refresh.test.ts index 15a0eac27..40fdc851c 100644 --- a/src/lib/insights/__tests__/unit-change-refresh.test.ts +++ b/src/lib/insights/__tests__/unit-change-refresh.test.ts @@ -12,10 +12,14 @@ vi.mock("@/lib/insights/status-invalidation", () => ({ vi.mock("@/lib/jobs/period-narrative-shared", () => ({ enqueueNarrativeWarm: vi.fn(), })); +vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({ + enqueueForceWarm: vi.fn(), +})); import { prisma } from "@/lib/db"; import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation"; import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared"; +import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; import { refreshTextsAfterUnitChange } from "../unit-change-refresh"; beforeEach(() => { @@ -51,6 +55,34 @@ describe("refreshTextsAfterUnitChange", () => { }); }); + it("re-warms a cached briefing in the language it was written in", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "de", + insightsCachedAt: new Date(), + insightsCachedLocale: "en", + } as never); + vi.mocked(prisma.insightNarrative.findMany).mockResolvedValue([]); + + await refreshTextsAfterUnitChange("user-1"); + + expect(enqueueForceWarm).toHaveBeenCalledWith({ + userId: "user-1", + locale: "en", + }); + }); + + it("leaves the briefing alone when none was ever written", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "de", + insightsCachedAt: null, + } as never); + vi.mocked(prisma.insightNarrative.findMany).mockResolvedValue([]); + + await refreshTextsAfterUnitChange("user-1"); + + expect(enqueueForceWarm).not.toHaveBeenCalled(); + }); + 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( diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 79d81f52d..dd98110c5 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -41,6 +41,13 @@ import { buildBriefingIllnessCyclePrompt, } from "@/lib/insights/illness-cycle-briefing"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; +import { featuresInReaderUnits } from "@/lib/insights/features-units"; +import { + applyDisplayTransform, + applyDisplayTransformDelta, + getReadingTransform, + resolveUnitPreferences, +} from "@/lib/measurements/display-transform"; import { detectGlp1Plateau, buildGlp1PlateauPrompt, @@ -324,7 +331,11 @@ export async function buildComparisonSnapshotForUser( ): Promise { const row = await prisma.user.findUnique({ where: { id: userId }, - select: { dashboardWidgetsJson: true }, + select: { + dashboardWidgetsJson: true, + unitPreference: true, + glucoseUnit: true, + }, }); const layout = resolveDashboardLayout(row?.dashboardWidgetsJson); const baseline: ComparisonBaseline = layout.comparisonBaseline ?? "none"; @@ -339,15 +350,23 @@ export async function buildComparisonSnapshotForUser( SLEEP_DURATION: "sleep", ACTIVITY_STEPS: "steps", }; + // Weight is converted into the reader's mass unit below; the rest read the + // same for everyone. Sleep comes off the night reconstruction in minutes. const typeUnits: Record = { - WEIGHT: "kg", BLOOD_PRESSURE_SYS: "mmHg", BLOOD_PRESSURE_DIA: "mmHg", PULSE: "bpm", BODY_FAT: "%", - SLEEP_DURATION: "h", + SLEEP_DURATION: "min", ACTIVITY_STEPS: "", }; + const mass = getReadingTransform( + "WEIGHT", + resolveUnitPreferences({ + unitPreference: row?.unitPreference, + glucoseUnit: row?.glucoseUnit, + }), + ); const types = Object.keys(typeToSnapshotKey) as MeasurementType[]; // The snapshot only ever reads two of summarize()'s means per call: avg30 @@ -460,6 +479,23 @@ export async function buildComparisonSnapshotForUser( delta !== null && baselineAvg !== null && baselineAvg !== 0 ? Math.round((delta / Math.abs(baselineAvg)) * 100 * 10) / 10 : null; + if (type === "WEIGHT") { + return { + type: typeToSnapshotKey[type], + currentAvg: + currentAvg === null + ? null + : applyDisplayTransform(currentAvg, mass), + baselineAvg: + baselineAvg === null + ? null + : applyDisplayTransform(baselineAvg, mass), + delta: + delta === null ? null : applyDisplayTransformDelta(delta, mass), + deltaPercent, + unit: mass.displayUnit, + }; + } return { type: typeToSnapshotKey[type] ?? type, currentAvg, @@ -646,6 +682,10 @@ export async function generateComprehensiveInsight( // provider call below so a raised value for a slow self-hosted backend // actually applies to the briefing, not just to Coach. aiResponseTimeoutSeconds: true, + // The reader's units: the feature set is converted into them before it + // is hashed and serialised, so a unit switch regenerates the briefing. + unitPreference: true, + glucoseUnit: true, }, }); @@ -800,6 +840,14 @@ export async function generateComprehensiveInsight( displayName: dbUser?.displayName ?? null, }; features = applyInsightsExcludeFilter(features, excludeList); + // Every figure below the canonical arithmetic is stated in the reader's + // units: the prompt, the content hash and the grounding check all read the + // converted set, so they agree with each other and with the reader. + const units = resolveUnitPreferences({ + unitPreference: dbUser?.unitPreference, + glucoseUnit: dbUser?.glucoseUnit, + }); + features = featuresInReaderUnits(features, units); const compactFeatures = compactSections( features as unknown as Record, ); @@ -863,7 +911,7 @@ export async function generateComprehensiveInsight( comparisonSnapshot ?? undefined, ); if (plateauContext) { - userPrompt += buildGlp1PlateauPrompt(plateauContext, locale); + userPrompt += buildGlp1PlateauPrompt(plateauContext, locale, units.system); } if (aboutMe) { userPrompt += buildAboutMeInsightBlock(aboutMe, locale); diff --git a/src/lib/insights/features-units.ts b/src/lib/insights/features-units.ts new file mode 100644 index 000000000..874142a82 --- /dev/null +++ b/src/lib/insights/features-units.ts @@ -0,0 +1,228 @@ +/** + * The aggregated feature set as a model reads it: every quantity whose unit + * depends on a preference converted into the reader's units, and labelled. + * + * `extractFeatures` computes in canonical units (kilograms, centimetres, + * metres, mg/dL) because the classifications, the correlations and the + * cached feature set are shared by every surface. The briefing and the + * Coach then serialised that canonical set straight into their prompts, so a + * reader on pounds or mmol/L got a narrative in kilograms or mg/dL. This is + * the one step between the two: it runs after all canonical arithmetic and + * before the snapshot is hashed, serialised and checked for grounding, so + * the hash moves with a unit switch and the grounding check compares the + * model's numbers against the same figures the model was shown. + * + * Absolute values take the full transform, differences and slopes the + * factor alone. A metric reader on mg/dL gets every number back unchanged, + * with a unit label added. + */ +import { + applyDisplayTransform, + applyDisplayTransformDelta, + getQuantityTransform, + getReadingTransform, + type DisplayTransform, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; +import { buildWeightTargetFeature } from "@/lib/targets/weight-trend"; + +import type { + AggregatedFeatures, + BucketedSeries, + RawFeatures, +} from "./features"; +import type { SignalOfDay } from "./signals-of-day"; + +type Num = number | null; + +function abs(value: Num, transform: DisplayTransform): Num { + return typeof value === "number" + ? applyDisplayTransform(value, transform) + : value; +} + +function delta(value: Num, transform: DisplayTransform): Num { + return typeof value === "number" + ? applyDisplayTransformDelta(value, transform) + : value; +} + +/** The measurement type each signal of the day is read from. */ +const SIGNAL_TYPE: Partial> = { + weight: "WEIGHT", + glucose: "BLOOD_GLUCOSE", +}; + +/** One signal of the day in the reader's units. */ +export function signalInReaderUnits( + signal: SignalOfDay, + units: UnitPreferences, +): SignalOfDay { + const type = SIGNAL_TYPE[signal.metric]; + if (!type) return signal; + const t = getReadingTransform(type, units); + return { + ...signal, + unit: t.displayUnit, + latest: applyDisplayTransform(signal.latest, t), + avg7: abs(signal.avg7, t), + avg30: abs(signal.avg30, t), + deltaVs7: delta(signal.deltaVs7, t), + deltaVs30: delta(signal.deltaVs30, t), + spread30: delta(signal.spread30, t), + recentAnomaly: signal.recentAnomaly + ? { + ...signal.recentAnomaly, + value: applyDisplayTransform(signal.recentAnomaly.value, t), + } + : null, + }; +} + +/** The weight aggregate in the reader's units, its target included. */ +export function weightFeatureInReaderUnits( + weight: NonNullable, + units: UnitPreferences, +): NonNullable & { unit: string } { + const t = getReadingTransform("WEIGHT", units); + // The target is rebuilt from the canonical band and the canonical trend + // reference, so its position is judged exactly as before and only the + // band it quotes changes unit. + const target = weight.target + ? (buildWeightTargetFeature( + { min: weight.target.min, max: weight.target.max }, + { avg7: weight.avg7, latest: weight.latest }, + units.system, + ) ?? undefined) + : undefined; + return { + ...weight, + unit: t.displayUnit, + latest: applyDisplayTransform(weight.latest, t), + avg7: abs(weight.avg7, t), + avg30: abs(weight.avg30, t), + avg90: abs(weight.avg90, t), + allTimeAvg: abs(weight.allTimeAvg, t), + allTimeMin: abs(weight.allTimeMin, t), + allTimeMax: abs(weight.allTimeMax, t), + slope30: delta(weight.slope30, t), + ...(target ? { target } : {}), + }; +} + +function bucketedInReaderUnits( + series: BucketedSeries, + units: UnitPreferences, +): BucketedSeries & { unit: string } { + const t = getReadingTransform(series.type, units); + return { + ...series, + unit: t.displayUnit, + buckets: series.buckets.map((bucket) => ({ + ...bucket, + mean: applyDisplayTransform(bucket.mean, t), + })), + }; +} + +/** + * The whole feature set in the reader's units. Pure: returns a new object + * and leaves the (possibly cached and shared) input untouched. + */ +export function featuresInReaderUnits< + T extends AggregatedFeatures | RawFeatures, +>(features: T, units: UnitPreferences): T { + const out: AggregatedFeatures & Partial = { ...features }; + + if (features.weight) { + out.weight = weightFeatureInReaderUnits(features.weight, units); + } + + if (features.gripStrength) { + const t = getReadingTransform("GRIP_STRENGTH", units); + out.gripStrength = { + ...features.gripStrength, + latest: abs(features.gripStrength.latest, t), + avg30: abs(features.gripStrength.avg30, t), + slope30: delta(features.gripStrength.slope30, t), + unit: t.displayUnit, + } as AggregatedFeatures["gripStrength"]; + } + + if (features.waist) { + const t = getReadingTransform("WAIST_CIRCUMFERENCE", units); + out.waist = { + ...features.waist, + latest: abs(features.waist.latest, t), + avg30: abs(features.waist.avg30, t), + slope30: delta(features.waist.slope30, t), + unit: t.displayUnit, + } as AggregatedFeatures["waist"]; + } + + if (features.glucose) { + const t = getReadingTransform("BLOOD_GLUCOSE", units); + out.glucose = { + ...features.glucose, + avg7: abs(features.glucose.avg7, t), + avg30: abs(features.glucose.avg30, t), + avg90: abs(features.glucose.avg90, t), + latest: abs(features.glucose.latest, t), + slope30: delta(features.glucose.slope30, t), + unit: t.displayUnit, + } as AggregatedFeatures["glucose"]; + } + + const weightHistory = features.historicalComparison?.weight; + if (features.historicalComparison && weightHistory) { + const t = getReadingTransform("WEIGHT", units); + out.historicalComparison = { + ...features.historicalComparison, + weight: { + current7dAvg: abs(weightHistory.current7dAvg, t), + previous30dAvg: abs(weightHistory.previous30dAvg, t), + change: delta(weightHistory.change, t), + unit: t.displayUnit, + } as NonNullable["weight"], + }; + } + + if (features.signalsOfDay) { + out.signalsOfDay = features.signalsOfDay.map((signal) => + signalInReaderUnits(signal, units), + ); + } + + if (features.workouts) { + const t = getQuantityTransform("distance", units.system); + // The block is computed in kilometres; the transform takes metres. The + // keys lose their "Km" so a mile figure never sits under a kilometre + // name. + const distance = (value: Num) => + abs(typeof value === "number" ? value * 1000 : value, t); + const { last7, last30, latest } = features.workouts; + const period = ({ totalDistanceKm, ...rest }: typeof last7) => ({ + ...rest, + totalDistance: distance(totalDistanceKm), + }); + out.workouts = { + last7: period(last7), + last30: period(last30), + latest: latest + ? (({ distanceKm, ...rest }) => ({ + ...rest, + distance: distance(distanceKm), + }))(latest) + : latest, + distanceUnit: t.displayUnit, + } as unknown as AggregatedFeatures["workouts"]; + } + + if ("bucketedMeasurements" in features) { + out.bucketedMeasurements = features.bucketedMeasurements.map((series) => + bucketedInReaderUnits(series, units), + ); + } + + return out as T; +} diff --git a/src/lib/insights/glp1-plateau.ts b/src/lib/insights/glp1-plateau.ts index 3bdc36ea3..ee0af43ee 100644 --- a/src/lib/insights/glp1-plateau.ts +++ b/src/lib/insights/glp1-plateau.ts @@ -19,6 +19,12 @@ import { prisma } from "@/lib/db"; import type { Locale } from "@/lib/i18n/config"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; +import { + applyDisplayTransformDelta, + DEFAULT_UNIT_PREFERENCE, + getDisplayTransform, + type UnitPreference, +} from "@/lib/measurements/display-transform"; /** * Trailing window (days) the detector compares weight across. Exported @@ -122,7 +128,11 @@ export async function detectGlp1Plateau( export function buildGlp1PlateauPrompt( ctx: Glp1PlateauContext, locale: Locale, + /** The reader's metric/imperial choice; the weight change is stated in it. */ + system: UnitPreference = DEFAULT_UNIT_PREFERENCE, ): string { + const mass = getDisplayTransform("WEIGHT", system); + const weightDelta = `${applyDisplayTransformDelta(ctx.weightDeltaKg, mass)} ${mass.displayUnit}`; // v1.4.25 W10 reconcile (security H-1): sanitize every user-controlled // string before interpolating into the LLM prompt. `Medication.name` // and `MedicationDoseChange.doseUnit` are free-text columns the user @@ -143,7 +153,7 @@ export function buildGlp1PlateauPrompt( SYSTEM CONTEXT — GLP-1-PLATEAU AKTIV -Der Nutzer nimmt aktuell ${doseLabel} (seit ${ctx.doseSince}, ${ctx.daysOnDose} Tage). In den letzten ${PLATEAU_WINDOW_DAYS} Tagen hat sich das Gewicht nur um ${ctx.weightDeltaKg} kg verändert (n=${ctx.readingsCount} Messungen). Das ist das klinische Plateau-Muster für GLP-1-Rezeptoragonisten. +Der Nutzer nimmt aktuell ${doseLabel} (seit ${ctx.doseSince}, ${ctx.daysOnDose} Tage). In den letzten ${PLATEAU_WINDOW_DAYS} Tagen hat sich das Gewicht nur um ${weightDelta} verändert (n=${ctx.readingsCount} Messungen). Das ist das klinische Plateau-Muster für GLP-1-Rezeptoragonisten. Wenn du einen dailyBriefing-keyFinding zu diesem Plateau emittierst: - sourceMetric: "glp1_plateau" @@ -157,7 +167,7 @@ Wenn du einen dailyBriefing-keyFinding zu diesem Plateau emittierst: SYSTEM CONTEXT — GLP-1 PLATEAU ACTIVE -The user is currently on ${doseLabel} (since ${ctx.doseSince}, ${ctx.daysOnDose} days). In the last ${PLATEAU_WINDOW_DAYS} days their weight has shifted by ${ctx.weightDeltaKg} kg (n=${ctx.readingsCount} readings). That is the clinical plateau pattern for GLP-1 receptor agonists. +The user is currently on ${doseLabel} (since ${ctx.doseSince}, ${ctx.daysOnDose} days). In the last ${PLATEAU_WINDOW_DAYS} days their weight has shifted by ${weightDelta} (n=${ctx.readingsCount} readings). That is the clinical plateau pattern for GLP-1 receptor agonists. If you emit a dailyBriefing keyFinding for this plateau: - sourceMetric: "glp1_plateau" diff --git a/src/lib/insights/unit-change-refresh.ts b/src/lib/insights/unit-change-refresh.ts index de820ceed..f186aec85 100644 --- a/src/lib/insights/unit-change-refresh.ts +++ b/src/lib/insights/unit-change-refresh.ts @@ -12,11 +12,15 @@ * 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: + * re-warmed only where one already exists. The daily briefing (and with it + * the Coach-adjacent overview text) is re-warmed when one is cached: its + * fingerprint covers the converted feature set, so the forced warm writes a + * new one in the new unit. 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 { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation"; import { normalizeLocale } from "@/lib/insights/status-shared"; import { locales, type Locale } from "@/lib/i18n/config"; @@ -29,7 +33,11 @@ export async function refreshTextsAfterUnitChange( try { const user = await prisma.user.findUnique({ where: { id: userId }, - select: { locale: true }, + select: { + locale: true, + insightsCachedAt: true, + insightsCachedLocale: true, + }, }); const statusScopes = await enqueueStatusRefillForUser( userId, @@ -52,9 +60,21 @@ export async function refreshTextsAfterUnitChange( narrativeCount++; } + const briefing = user?.insightsCachedAt != null; + if (briefing) { + void enqueueForceWarm({ + userId, + locale: normalizeLocale(user?.insightsCachedLocale ?? user?.locale), + }); + } + annotate({ action: { name: "insights.unit-change.refresh" }, - meta: { status_scopes: statusScopes, narratives: narrativeCount }, + meta: { + status_scopes: statusScopes, + narratives: narrativeCount, + briefing, + }, }); } catch { // Best-effort: the nightly warm and the next ingest still converge the diff --git a/src/lib/targets/__tests__/weight-trend.test.ts b/src/lib/targets/__tests__/weight-trend.test.ts index 5aaf8e667..b7e99fd8e 100644 --- a/src/lib/targets/__tests__/weight-trend.test.ts +++ b/src/lib/targets/__tests__/weight-trend.test.ts @@ -111,3 +111,30 @@ describe("buildWeightTargetFeature", () => { ).toBeNull(); }); }); + +describe("buildWeightTargetFeature — the reader's mass unit", () => { + it("quotes the band in pounds for an imperial reader and judges it in kilograms", () => { + const feature = buildWeightTargetFeature( + { min: 65, max: 70 }, + { avg7: 66, latest: 66 }, + "imperial", + ); + expect(feature).toMatchObject({ + min: 143.3, + max: 154.3, + unit: "lb", + position: "inside", + }); + expect(feature?.reading).toContain("143.3–154.3 lb"); + expect(feature?.reading).not.toContain("kg"); + }); + + it("keeps kilograms for a metric reader", () => { + const feature = buildWeightTargetFeature( + { min: 65, max: 70 }, + { avg7: 72, latest: 72 }, + ); + expect(feature).toMatchObject({ min: 65, max: 70, unit: "kg" }); + expect(feature?.reading).toContain("65–70 kg"); + }); +}); diff --git a/src/lib/targets/weight-trend.ts b/src/lib/targets/weight-trend.ts index df44dd17b..cbb32f3aa 100644 --- a/src/lib/targets/weight-trend.ts +++ b/src/lib/targets/weight-trend.ts @@ -28,6 +28,12 @@ * Pure and client-safe: no DB read, no clock. */ import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; +import { + applyDisplayTransform, + DEFAULT_UNIT_PREFERENCE, + getDisplayTransform, + type UnitPreference, +} from "@/lib/measurements/display-transform"; /** Where a weight sits against the person's own target band. */ export type WeightTargetPosition = "below" | "inside" | "above"; @@ -99,8 +105,12 @@ export function resolveWeightTrend( * colour, the prose makes no promise about which way is better). */ export interface WeightTargetFeature { - minKg: number; - maxKg: number; + /** The band's lower edge, in `unit`. */ + min: number; + /** The band's upper edge, in `unit`. */ + max: number; + /** The reader's mass unit; the band and the reading sentence are in it. */ + unit: string; position: WeightTargetPosition; progress: "gaining" | "losing" | "holding steady"; reading: string; @@ -118,13 +128,21 @@ const PROGRESS_BY_POSITION: Record< export function buildWeightTargetFeature( target: { min: number; max: number } | null, summary: { avg7: number | null; latest: number | null } | null | undefined, + /** + * The reader's metric/imperial choice. The position is judged on the + * canonical kilograms either way; only the band the text quotes converts. + */ + system: UnitPreference = DEFAULT_UNIT_PREFERENCE, ): WeightTargetFeature | null { const position = weightTargetPosition( target, weightTrendReferenceKg(summary), ); if (!target || !position) return null; - const band = `${target.min}–${target.max} kg`; + const transform = getDisplayTransform("WEIGHT", system); + const min = applyDisplayTransform(target.min, transform); + const max = applyDisplayTransform(target.max, transform); + const band = `${min}–${max} ${transform.displayUnit}`; const reading = position === "below" ? `The person set their own weight target at ${band} and is currently below it, so gaining weight is progress and losing weight moves away from the goal. Judge weight changes against this target; never describe a gain as a setback or a regression.` @@ -132,8 +150,9 @@ export function buildWeightTargetFeature( ? `The person set their own weight target at ${band} and is currently above it, so losing weight is progress and gaining moves away from the goal.` : `The person set their own weight target at ${band} and is currently inside it, so holding steady is progress; a small move either way within the band is not a setback.`; return { - minKg: target.min, - maxKg: target.max, + min, + max, + unit: transform.displayUnit, position, progress: PROGRESS_BY_POSITION[position], reading,