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