Files
HealthLog/src/lib/insights/unit-change-refresh.ts
T
Marc-André Bombeck a7e6b89672 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
2026-10-01 13:23:17 +02:00

84 lines
3.1 KiB
TypeScript

/**
* 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. 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";
import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative";
import { annotate } from "@/lib/logging/context";
export async function refreshTextsAfterUnitChange(
userId: string,
): Promise<void> {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
locale: true,
insightsCachedAt: true,
insightsCachedLocale: 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++;
}
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,
briefing,
},
});
} catch {
// Best-effort: the nightly warm and the next ingest still converge the
// texts; a failed enqueue must not surface on the preference write.
}
}