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
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 13:23:17 +02:00
1 parent d1b2539b1f
commit a7e6b89672
15 files changed
+794 -21

No files matched your search

@@ -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
+18 -1
View File
@@ -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
@@ -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,
});
});
});
@@ -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<string, unknown>),
features: compactSections(
featuresInReaderUnits(
FEATURES as never,
DEFAULT_UNIT_PREFERENCES,
) as unknown as Record<string, unknown>,
),
aboutMe: null,
comparisonBaseline: "none",
generationLocale: "de",
@@ -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<string, unknown>),
features: compactSections(
featuresInReaderUnits(
FEATURES as never,
DEFAULT_UNIT_PREFERENCES,
) as unknown as Record<string, unknown>,
),
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<string, unknown> }).data.insightsCachedText !==
undefined,
);
const data = (write![0] as { data: Record<string, unknown> }).data;
expect(data.insightsSnapshotHash).toBe(
hashInsightSnapshot({
features: compactSections(
featuresInReaderUnits(FEATURES as never, {
system: "imperial",
glucoseUnit: "mmol/L",
}) as unknown as Record<string, unknown>,
),
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",
@@ -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, Record<string, unknown> | 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);
});
});
+3 -2
View File
@@ -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",
});
@@ -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(
{
@@ -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(
+52 -4
View File
@@ -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<ComparisonSnapshot | null> {
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<string, string> = {
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<string, unknown>,
);
@@ -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);
+228
View File
@@ -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<Record<SignalOfDay["metric"], string>> = {
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<AggregatedFeatures["weight"]>,
units: UnitPreferences,
): NonNullable<AggregatedFeatures["weight"]> & { 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<RawFeatures> = { ...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<AggregatedFeatures["historicalComparison"]>["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;
}
+12 -2
View File
@@ -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"
+23 -3
View File
@@ -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
@@ -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");
});
});
+24 -5
View File
@@ -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,