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