Merge pull request #1077 from MBombeck/fix/v1397-units2

AI and Coach texts in the reader's units
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-01 14:09:35 +02:00
commit bef17ee570
67 files changed
+2216 -263

No files matched your search

+3 -7
View File
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "Gehgeschwindigkeit",
"description": "Deine durchschnittliche Gehgeschwindigkeit im Zeitverlauf, in km/h.",
"chartTitle": "Gehgeschwindigkeit (km/h)",
"chartTitle": "Gehgeschwindigkeit",
"emptyState": {
"title": "Noch keine Gehgeschwindigkeitsdaten",
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "Handgelenktemperatur",
"description": "Deine nächtliche Handgelenktemperatur, gemessen an deinem eigenen Ausgangswert.",
"chartTitle": "Handgelenktemperatur (°C)",
"chartTitle": "Handgelenktemperatur",
"emptyState": {
"title": "Noch keine Handgelenktemperatur-Daten",
"description": "Werte erscheinen hier, sobald Apple Health das nächtliche Tragen synchronisiert."
@@ -8287,9 +8287,6 @@
"unitSteps": "Schritte",
"unitBpm": "S/min",
"unitMs": "ms",
"unitKg": "kg",
"unitCelsius": "°C",
"unitGlucose": "mg/dL",
"unitMood": "Pkt.",
"confidenceLow": "Geringe Sicherheit",
"confidenceMedium": "Mittlere Sicherheit",
@@ -8464,7 +8461,6 @@
"flow": "Stärke",
"symptoms": "Symptome",
"temperature": "Basaltemperatur",
"temperaturePlaceholder": "°C",
"temperatureDisturbed": "Messwert als gestört markieren",
"temperatureDisturbedHint": "Fieber, Krankheit, Alkohol oder eine späte Messung — wird von der Temperaturauswertung ausgeschlossen.",
"ovulationTest": "Ovulationstest",
@@ -8556,7 +8552,7 @@
"empty": "Noch keine Temperaturwerte",
"emptyHint": "Miss deine Basaltemperatur jeden Morgen, um den Temperaturanstieg deines Zyklus zu sehen.",
"ovulationEstimate": "Gesch. Eisprung",
"tooltipTemp": "{temp} °C",
"tooltipTemp": "{temp} {unit}",
"ovulationConfirmed": "Bestätigter Eisprung"
},
"fieldInfo": {
+3 -7
View File
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "Walking Speed",
"description": "Your average walking speed over time, in km/h.",
"chartTitle": "Walking speed (km/h)",
"chartTitle": "Walking speed",
"emptyState": {
"title": "No walking-speed data yet",
"description": "Walking-speed readings appear here once Apple Health syncs."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "Wrist Temperature",
"description": "Your overnight wrist temperature, tracked against your own baseline.",
"chartTitle": "Wrist temperature (°C)",
"chartTitle": "Wrist temperature",
"emptyState": {
"title": "No wrist-temperature data yet",
"description": "Wrist-temperature readings appear here once Apple Health syncs overnight wear."
@@ -8287,9 +8287,6 @@
"unitSteps": "steps",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitCelsius": "°C",
"unitGlucose": "mg/dL",
"unitMood": "pts",
"confidenceLow": "Low confidence",
"confidenceMedium": "Medium confidence",
@@ -8464,7 +8461,6 @@
"flow": "Flow",
"symptoms": "Symptoms",
"temperature": "Basal body temperature",
"temperaturePlaceholder": "°C",
"temperatureDisturbed": "Mark reading as disturbed",
"temperatureDisturbedHint": "Fever, illness, alcohol or a late measurement — excluded from the temperature evaluation.",
"ovulationTest": "Ovulation test",
@@ -8556,7 +8552,7 @@
"empty": "No temperature readings yet",
"emptyHint": "Log your basal body temperature each morning to see your cycle's temperature shift.",
"ovulationEstimate": "Est. ovulation",
"tooltipTemp": "{temp} °C",
"tooltipTemp": "{temp} {unit}",
"ovulationConfirmed": "Confirmed ovulation"
},
"fieldInfo": {
+3 -7
View File
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "Velocidad al andar",
"description": "Tu velocidad media al andar en el tiempo, en km/h.",
"chartTitle": "Velocidad al andar (km/h)",
"chartTitle": "Velocidad al andar",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando Apple Health sincroniza."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "Temperatura de muñeca",
"description": "Tu temperatura de muñeca durante la noche, comparada con tu valor de referencia.",
"chartTitle": "Temperatura de muñeca (°C)",
"chartTitle": "Temperatura de muñeca",
"emptyState": {
"title": "Aún no hay datos de temperatura de muñeca",
"description": "Los valores aparecen aquí cuando Apple Health sincroniza el uso nocturno."
@@ -8287,9 +8287,6 @@
"unitSteps": "pasos",
"unitBpm": "lpm",
"unitMs": "ms",
"unitKg": "kg",
"unitCelsius": "°C",
"unitGlucose": "mg/dL",
"unitMood": "pts",
"confidenceLow": "Confianza baja",
"confidenceMedium": "Confianza media",
@@ -8464,7 +8461,6 @@
"flow": "Flujo",
"symptoms": "Síntomas",
"temperature": "Temperatura basal",
"temperaturePlaceholder": "°C",
"temperatureDisturbed": "Marcar la medición como alterada",
"temperatureDisturbedHint": "Fiebre, enfermedad, alcohol o una medición tardía: se excluye de la evaluación de temperatura.",
"ovulationTest": "Test de ovulación",
@@ -8556,7 +8552,7 @@
"empty": "Aún no hay mediciones de temperatura",
"emptyHint": "Registra tu temperatura basal cada mañana para ver el cambio de temperatura de tu ciclo.",
"ovulationEstimate": "Ovulación est.",
"tooltipTemp": "{temp} °C",
"tooltipTemp": "{temp} {unit}",
"ovulationConfirmed": "Ovulación confirmada"
},
"fieldInfo": {
+3 -7
View File
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "Vitesse de marche",
"description": "Votre vitesse de marche moyenne au fil du temps, en km/h.",
"chartTitle": "Vitesse de marche (km/h)",
"chartTitle": "Vitesse de marche",
"emptyState": {
"title": "Aucune donnée",
"description": "Les relevés apparaissent ici dès qu’Apple Health se synchronise."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "Température du poignet",
"description": "Votre température du poignet pendant la nuit, comparée à votre valeur de référence.",
"chartTitle": "Température du poignet (°C)",
"chartTitle": "Température du poignet",
"emptyState": {
"title": "Pas encore de données de température du poignet",
"description": "Les valeurs apparaissent ici dès qu'Apple Santé synchronise le port nocturne."
@@ -8287,9 +8287,6 @@
"unitSteps": "pas",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitCelsius": "°C",
"unitGlucose": "mg/dL",
"unitMood": "pts",
"confidenceLow": "Confiance faible",
"confidenceMedium": "Confiance moyenne",
@@ -8464,7 +8461,6 @@
"flow": "Flux",
"symptoms": "Symptômes",
"temperature": "Température basale",
"temperaturePlaceholder": "°C",
"temperatureDisturbed": "Marquer la mesure comme perturbée",
"temperatureDisturbedHint": "Fièvre, maladie, alcool ou mesure tardive — exclue de l'évaluation de la température.",
"ovulationTest": "Test d'ovulation",
@@ -8556,7 +8552,7 @@
"empty": "Pas encore de relevés de température",
"emptyHint": "Notez votre température basale chaque matin pour voir le décalage de température de votre cycle.",
"ovulationEstimate": "Ovulation est.",
"tooltipTemp": "{temp} °C",
"tooltipTemp": "{temp} {unit}",
"ovulationConfirmed": "Ovulation confirmée"
},
"fieldInfo": {
+3 -7
View File
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "Velocità di cammino",
"description": "La tua velocità media di cammino nel tempo, in km/h.",
"chartTitle": "Velocità di cammino (km/h)",
"chartTitle": "Velocità di cammino",
"emptyState": {
"title": "Nessun dato",
"description": "Le letture appaiono qui quando Apple Health si sincronizza."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "Temperatura del polso",
"description": "La temperatura del polso durante la notte, rispetto al tuo valore di riferimento.",
"chartTitle": "Temperatura del polso (°C)",
"chartTitle": "Temperatura del polso",
"emptyState": {
"title": "Nessun dato sulla temperatura del polso",
"description": "I valori compaiono qui quando Apple Health sincronizza l'uso notturno."
@@ -8287,9 +8287,6 @@
"unitSteps": "passi",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitCelsius": "°C",
"unitGlucose": "mg/dL",
"unitMood": "pts",
"confidenceLow": "Affidabilità bassa",
"confidenceMedium": "Affidabilità media",
@@ -8464,7 +8461,6 @@
"flow": "Flusso",
"symptoms": "Sintomi",
"temperature": "Temperatura basale",
"temperaturePlaceholder": "°C",
"temperatureDisturbed": "Segna la misurazione come disturbata",
"temperatureDisturbedHint": "Febbre, malattia, alcol o una misurazione tardiva: esclusa dalla valutazione della temperatura.",
"ovulationTest": "Test di ovulazione",
@@ -8556,7 +8552,7 @@
"empty": "Ancora nessuna misurazione della temperatura",
"emptyHint": "Registra la temperatura basale ogni mattina per vedere il rialzo termico del tuo ciclo.",
"ovulationEstimate": "Ovulazione stim.",
"tooltipTemp": "{temp} °C",
"tooltipTemp": "{temp} {unit}",
"ovulationConfirmed": "Ovulazione confermata"
},
"fieldInfo": {
+3 -7
View File
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "보행 속도",
"description": "시간에 따른 평균 보행 속도예요(km/h).",
"chartTitle": "보행 속도(km/h)",
"chartTitle": "보행 속도",
"emptyState": {
"title": "아직 보행 속도 데이터가 없어요",
"description": "Apple Health가 동기화되면 보행 속도 측정값이 여기에 나타나요."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "손목 온도",
"description": "내 기준값과 비교한 밤사이 손목 온도예요.",
"chartTitle": "손목 온도 (°C)",
"chartTitle": "손목 온도",
"emptyState": {
"title": "아직 손목 온도 데이터가 없어요",
"description": "Apple Health가 밤사이 착용 기록을 동기화하면 손목 온도 측정값이 여기에 표시돼요."
@@ -8287,9 +8287,6 @@
"unitSteps": "걸음",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitCelsius": "°C",
"unitGlucose": "mg/dL",
"unitMood": "점",
"confidenceLow": "신뢰도 낮음",
"confidenceMedium": "신뢰도 보통",
@@ -8464,7 +8461,6 @@
"flow": "생리량",
"symptoms": "증상",
"temperature": "기초 체온",
"temperaturePlaceholder": "°C",
"temperatureDisturbed": "측정값을 방해받음으로 표시",
"temperatureDisturbedHint": "발열, 아픈 날, 음주, 늦은 측정 — 체온 평가에서 제외돼요.",
"ovulationTest": "배란 테스트",
@@ -8556,7 +8552,7 @@
"empty": "아직 체온 기록이 없어요",
"emptyHint": "매일 아침 기초 체온을 기록하면 주기의 체온 변화를 볼 수 있어요.",
"ovulationEstimate": "예상 배란",
"tooltipTemp": "{temp}°C",
"tooltipTemp": "{temp}{unit}",
"ovulationConfirmed": "확인된 배란"
},
"fieldInfo": {
+3 -7
View File
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "Prędkość chodu",
"description": "Twoja średnia prędkość chodu w czasie, w km/h.",
"chartTitle": "Prędkość chodu (km/h)",
"chartTitle": "Prędkość chodu",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji Apple Health."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "Temperatura nadgarstka",
"description": "Nocna temperatura nadgarstka, odniesiona do Twojej wartości bazowej.",
"chartTitle": "Temperatura nadgarstka (°C)",
"chartTitle": "Temperatura nadgarstka",
"emptyState": {
"title": "Brak danych o temperaturze nadgarstka",
"description": "Wartości pojawią się tutaj, gdy Apple Health zsynchronizuje nocne noszenie."
@@ -8287,9 +8287,6 @@
"unitSteps": "kroki",
"unitBpm": "ud./min",
"unitMs": "ms",
"unitKg": "kg",
"unitCelsius": "°C",
"unitGlucose": "mg/dL",
"unitMood": "pkt",
"confidenceLow": "Niska pewność",
"confidenceMedium": "Średnia pewność",
@@ -8464,7 +8461,6 @@
"flow": "Krwawienie",
"symptoms": "Objawy",
"temperature": "Podstawowa temperatura ciała",
"temperaturePlaceholder": "°C",
"temperatureDisturbed": "Oznacz pomiar jako zakłócony",
"temperatureDisturbedHint": "Gorączka, choroba, alkohol lub spóźniony pomiar — wykluczony z oceny temperatury.",
"ovulationTest": "Test owulacyjny",
@@ -8556,7 +8552,7 @@
"empty": "Brak pomiarów temperatury",
"emptyHint": "Zapisuj podstawową temperaturę ciała każdego ranka, aby zobaczyć przesunięcie temperatury w cyklu.",
"ovulationEstimate": "Szac. owulacja",
"tooltipTemp": "{temp} °C",
"tooltipTemp": "{temp} {unit}",
"ovulationConfirmed": "Potwierdzona owulacja"
},
"fieldInfo": {
@@ -0,0 +1,42 @@
/**
* A real `UnitDisplay` for one fixed preference, for component tests that
* render without a QueryClient (`useUnitDisplay` reads the account through
* react-query). Built on the production transforms, so a test asserts the
* same numbers and symbols the hook would give.
*
* vi.mock("@/hooks/use-unit-display", async () => {
* const { unitDisplayFor } = await import("@/__tests__/helpers/unit-display-mock");
* return { useUnitDisplay: () => unitDisplayFor("metric") };
* });
*/
import type { UnitDisplay } from "@/hooks/use-unit-display";
import type { GlucoseUnit } from "@/lib/glucose";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getDisplayTransform,
hasDisplayTransform,
invertDisplayTransform,
type UnitPreference,
} from "@/lib/measurements/display-transform";
export function unitDisplayFor(
preference: UnitPreference,
glucoseUnit: GlucoseUnit = "mg/dL",
): UnitDisplay {
// The hook resolves mass, length and temperature through the
// metric/imperial branch alone; glucose is converted where it renders.
const forType = (type: string) => getDisplayTransform(type, preference);
return {
preference,
glucoseUnit,
transformFor: forType,
toDisplay: (type, value) => applyDisplayTransform(value, forType(type)),
fromDisplay: (type, value) => invertDisplayTransform(value, forType(type)),
toDisplayDelta: (type, value) =>
applyDisplayTransformDelta(value, forType(type)),
unitFor: (type) => forType(type).displayUnit,
decimalsFor: (type) => forType(type).decimals,
isTransformed: (type) => hasDisplayTransform(type),
};
}
@@ -87,7 +87,6 @@ const LEGIT_IDENTICAL = new Set<string>([
"common.period",
// Clinical abbreviations / SI units — identical across locales.
"charts.vo2Max",
"cycle.insights.crosstab.unitGlucose",
"dashboard.metric.unit.bmi",
"dashboard.metric.unit.mood",
"dashboard.metric.unit.glucose",
@@ -226,10 +226,19 @@ describe("unit-preference display guard", () => {
* fails. A listed file that drops a line fails too, so the budget follows
* the code down instead of leaving headroom for the next literal.
*
* The text the AI and the Coach write is swept the same way: the prompt and
* snapshot builders (`lib/ai/`), the jobs that write stored text
* (`lib/jobs/`), the target and daily-digest producers. A prompt that
* states a threshold or a band in kilograms, °C or mg/dL hands a reader on
* pounds, °F or mmol/L two units for one quantity, and the model mixes
* them. Prose units ("5 metres", "kg/week") are matched as well, because a
* prompt is prose.
*
* The limit, stated: this is a line scanner. A unit spelled through a
* variable named after it, or a literal split across lines, passes. The
* message bundles are not swept either (the fever and cycle-glucose labels
* lived there).
* lived there). The producer check below covers what a scanner cannot: a
* text producer that prints a canonical number with no unit at all.
*/
const WIDE_SWEEP_ROOTS = [
"components/",
@@ -237,6 +246,10 @@ const WIDE_SWEEP_ROOTS = [
"lib/insights/",
"lib/doctor-report",
"lib/export",
"lib/ai/",
"lib/jobs/",
"lib/targets/",
"lib/daily/",
];
const UNIT_LITERAL_PATTERNS: readonly RegExp[] = [
@@ -252,6 +265,10 @@ const UNIT_LITERAL_PATTERNS: readonly RegExp[] = [
/\s(?:kg|km|°C)(?=["'`<])/,
// A per-kilometre pace.
/\/km\b/,
// A rate per kilogram written into prose: "kg/week" (BMI's kg/m² excepted).
/\bkg\/(?!m²)/,
// A unit spelled out after a number or an interpolation: "5 metres".
/(?:\d|\})\s?(?:metres?|kilograms?)\b/,
];
/**
@@ -346,22 +363,48 @@ const UNIT_LITERAL_ALLOWLIST: Record<
lines: 2,
reason: "prose naming both glucose units the app offers",
},
// ── Still canonical; the prompt-unit pass for these is open (#1067) ──
"lib/insights/comprehensive-generate.ts": {
// ── The AI and Coach producers ──
"lib/ai/insight-interpretation.ts": {
lines: 1,
reason: "comprehensive prompt still states weight in kg",
reason:
"canonical band registry; interpretation-block prints the band in the reader's unit",
},
"lib/insights/signals-of-day.ts": {
lines: 1,
reason: "briefing signal still states weight in kg",
"lib/ai/prompts/shared-contracts.ts": {
lines: 3,
reason:
"the acute red-flag floors, stated in both units on purpose so the rule fires on what the person writes",
},
"lib/insights/weight-status.ts": {
lines: 1,
reason: "weight status snapshot still states kg",
"lib/ai/coach/snapshot-blocks/glucose-block.ts": {
lines: 4,
reason:
"branches on the resolved glucose unit; a mg/dL reader's block stays byte-identical",
},
"lib/insights/glp1-plateau.ts": {
"lib/ai/coach/results/chart-spec.ts": {
lines: 2,
reason: "GLP-1 plateau prompt context still states kg",
reason:
"bin widths keyed to a column unit, applied only when the table is in that unit",
},
"lib/ai/coach/coach-prose-grounding.ts": {
lines: 1,
reason:
"the reply checker's list of unit tokens it recognises in model prose",
},
"lib/ai/coach/eval/golden-cases.ts": {
lines: 3,
reason: "evaluation fixtures: scripted replies and ideal answers",
},
"lib/ai/coach/eval/red-team.ts": {
lines: 2,
reason: "adversarial evaluation inputs, written as a person would type",
},
"lib/targets/target-unit-display.ts": {
lines: 4,
reason: "the target adapter itself: picks the glucose branch",
},
"lib/targets/vitals-builder.ts": {
lines: 1,
reason:
"canonical targets DTO; every client converts through the target adapter",
},
};
@@ -372,7 +415,7 @@ function unitLiteralLines(): Map<string, string[]> {
!rel.includes("__tests__") &&
!/\.test\.tsx?$/.test(rel),
);
if (files.length < 1200) {
if (files.length < 1700) {
throw new Error(
`unit sweep narrowed to ${files.length} files; a sweep this small is a broken filter`,
);
@@ -424,3 +467,51 @@ describe("unit-preference display guard — the wide sweep", () => {
expect(stale).toEqual([]);
});
});
/**
* The producers of text a model reads or writes from. A line scanner sees a
* unit literal; it cannot see a canonical number printed with no unit at
* all, which is how the Coach's weight block and the briefing's glucose
* signal reached readers on pounds and mmol/L. So each producer that carries
* a mass, length, temperature, speed, distance or glucose figure into a
* prompt, a snapshot or a stored sentence is named here and must call one of
* the resolvers that state a figure in the reader's units. Dropping the call
* (or the file) fails.
*/
const READER_UNIT_PRODUCERS = [
"lib/ai/coach/reference-grounding.ts",
"lib/ai/coach/snapshot-blocks/core-metrics-block.ts",
"lib/ai/coach/snapshot-blocks/value-series-blocks.ts",
"lib/ai/coach/snapshot-blocks/workouts-block.ts",
"lib/ai/coach/cycle-snapshot.ts",
"lib/ai/coach/results/metric-table-tool.ts",
"lib/ai/prompts/weight.ts",
"lib/insights/features-units.ts",
"lib/insights/comprehensive-generate.ts",
"app/api/insights/generate/route.ts",
"lib/insights/weight-status.ts",
"lib/insights/metric-status.ts",
"lib/insights/narrative/period-narrative.ts",
"lib/insights/glp1-plateau.ts",
"lib/jobs/reaction-line.ts",
"lib/targets/weight-trend.ts",
];
const READER_UNIT_RESOLVER =
/\b(?:getReadingTransform|getQuantityTransform|getDisplayTransform|featuresInReaderUnits|weightFeatureInReaderUnits)\(/;
describe("unit-preference display guard — AI and Coach producers", () => {
it("names enough producers to be a check (a list that shrinks proves nothing)", () => {
expect(READER_UNIT_PRODUCERS.length).toBeGreaterThanOrEqual(16);
});
it("every producer states its figures through a reader-unit resolver", () => {
const offenders = READER_UNIT_PRODUCERS.filter(
(rel) =>
!READER_UNIT_RESOLVER.test(
stripComments(readFileSync(join(SRC, rel), "utf8")),
),
);
expect(offenders).toEqual([]);
});
});
@@ -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
@@ -1,7 +1,20 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { BbtChart } from "../bbt-chart";
// `useUnitDisplay` reads the account through react-query; these static
// renders have no QueryClient, so the hook is a real display for a fixed
// preference the test sets.
const reader = vi.hoisted(() => ({
preference: "metric" as "metric" | "imperial",
}));
vi.mock("@/hooks/use-unit-display", async () => {
const { unitDisplayFor } =
await import("@/__tests__/helpers/unit-display-mock");
return { useUnitDisplay: () => unitDisplayFor(reader.preference) };
});
import { BbtChart, BbtTooltip, bbtPoints } from "../bbt-chart";
import { unitDisplayFor } from "@/__tests__/helpers/unit-display-mock";
import { I18nProvider } from "@/lib/i18n/context";
import type { CalendarDay } from "../types";
@@ -82,3 +95,46 @@ describe("<BbtChart>", () => {
expect(html).toContain('data-slot="cycle-bbt-caption"');
});
});
describe("<BbtChart> — the reader's temperature unit", () => {
it("plots each reading in °F for an imperial reader", () => {
const display = unitDisplayFor("imperial");
const points = bbtPoints({
days: [
day("2026-06-05", { basalBodyTempC: 36.5 }),
day("2026-06-09", { basalBodyTempC: 36.9 }),
],
fromDate: "2026-06-01",
today: "2026-06-10",
rawChartMode: false,
toDisplay: (c) => display.toDisplay("BODY_TEMPERATURE", c),
});
expect(points.map((p) => p.temp)).toEqual([97.7, 98.4]);
});
it("labels the tooltip with the reader's unit", () => {
reader.preference = "imperial";
try {
const html = render(
<BbtTooltip
active
payload={[
{
payload: {
t: Date.parse("2026-06-09T12:00:00Z"),
temp: 98.4,
phaseHue: "red",
mucus: null,
ovulationTest: null,
},
},
]}
/>,
);
expect(html).toContain("98.4 °F");
expect(html).not.toContain("°C");
} finally {
reader.preference = "metric";
}
});
});
@@ -7,6 +7,14 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// The BBT chart reads the unit preference through react-query; no
// QueryClient here, so a fixed metric display stands in.
vi.mock("@/hooks/use-unit-display", async () => {
const { unitDisplayFor } =
await import("@/__tests__/helpers/unit-display-mock");
return { useUnitDisplay: () => unitDisplayFor("metric") };
});
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
@@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
import {
buildDayLogPatch,
buildDayLogInput,
bbtTextToCelsius,
symptomsCount,
temperatureCount,
intimacyCount,
@@ -330,3 +331,30 @@ describe("sheetDayContext (#1004)", () => {
expect(ctx.periodEndable).toBe(true);
});
});
describe("bbtTextToCelsius — the field reads in the reader's unit", () => {
const fromFahrenheit = (f: number) => (f - 32) / 1.8;
it("stores a typed Fahrenheit reading as Celsius", () => {
expect(bbtTextToCelsius("98.2", null, fromFahrenheit)).toBe(36.78);
});
it("returns the stored value for text the sheet seeded, so a save never drifts it", () => {
const seed = { text: "98.2", celsius: 36.75 };
expect(bbtTextToCelsius("98.2", seed, fromFahrenheit)).toBe(36.75);
expect(bbtTextToCelsius("98.4", seed, fromFahrenheit)).toBe(36.89);
});
it("is null for an empty or unreadable field", () => {
expect(bbtTextToCelsius("", null, fromFahrenheit)).toBeNull();
expect(bbtTextToCelsius("abc", null, fromFahrenheit)).toBeNull();
});
it("feeds the payload through the converter it is given", () => {
const patch = buildDayLogPatch({ ...blank(), bbt: "98.2" }, (text) =>
bbtTextToCelsius(text, null, fromFahrenheit),
);
expect(patch.basalBodyTempC).toBe(36.78);
expect(patch.temperatureExcluded).toBe(false);
});
});
+67 -28
View File
@@ -40,6 +40,7 @@ import {
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { cn } from "@/lib/utils";
import type { CalendarDay, CervicalMucus, OvulationTest } from "./types";
@@ -81,6 +82,39 @@ function ymdToMs(d: string): number {
return Date.parse(`${d}T12:00:00Z`);
}
/**
* The plotted readings of the window, oldest first, each temperature in the
* reader's unit (`toDisplay` converts the stored degrees Celsius).
*/
export function bbtPoints(args: {
days: CalendarDay[];
fromDate: string;
today: string;
rawChartMode: boolean;
toDisplay: (celsius: number) => number;
}): BbtPoint[] {
const { days, fromDate, today, rawChartMode, toDisplay } = args;
const fromMs = ymdToMs(fromDate);
const todayMs = ymdToMs(today);
return days
.filter((d) => {
if (d.basalBodyTempC == null || !Number.isFinite(d.basalBodyTempC)) {
return false;
}
const ms = ymdToMs(d.date);
return ms >= fromMs && ms <= todayMs;
})
.map((d) => ({
t: ymdToMs(d.date),
temp: toDisplay(d.basalBodyTempC as number),
phaseHue:
rawChartMode || d.phase == null ? LINE_COLOR : PHASE_HUE[d.phase],
mucus: d.cervicalMucus,
ovulationTest: d.ovulationTest,
}))
.sort((a, b) => a.t - b.t);
}
export function BbtChart({
days,
today,
@@ -91,6 +125,9 @@ export function BbtChart({
}: BbtChartProps) {
const { t } = useTranslations();
const fmt = useFormatters();
// Stored in °C, read in the reader's unit: every point, the padding and
// the empty-state domain go through the same transform.
const unitDisplay = useUnitDisplay();
const animationsEnabled = !prefersReducedMotion();
// Scope to the current cycle using the server's resolved start; fall back to
@@ -101,27 +138,18 @@ export function BbtChart({
return shiftDateKey(today, -FALLBACK_WINDOW_DAYS);
}, [cycleStartDate, today]);
const points = useMemo<BbtPoint[]>(() => {
const fromMs = ymdToMs(fromDate);
const todayMs = ymdToMs(today);
return days
.filter((d) => {
if (d.basalBodyTempC == null || !Number.isFinite(d.basalBodyTempC)) {
return false;
}
const ms = ymdToMs(d.date);
return ms >= fromMs && ms <= todayMs;
})
.map((d) => ({
t: ymdToMs(d.date),
temp: d.basalBodyTempC as number,
phaseHue:
rawChartMode || d.phase == null ? LINE_COLOR : PHASE_HUE[d.phase],
mucus: d.cervicalMucus,
ovulationTest: d.ovulationTest,
}))
.sort((a, b) => a.t - b.t);
}, [days, fromDate, today, rawChartMode]);
const points = useMemo(
() =>
bbtPoints({
days,
fromDate,
today,
rawChartMode,
toDisplay: (celsius) =>
unitDisplay.toDisplay("BODY_TEMPERATURE", celsius),
}),
[days, fromDate, today, rawChartMode, unitDisplay],
);
const ovulationMs = useMemo(() => {
if (rawChartMode || !predictedOvulation) return null;
@@ -133,16 +161,23 @@ export function BbtChart({
}, [predictedOvulation, points, rawChartMode]);
const yDomain = useMemo<[number, number]>(() => {
if (points.length === 0) return [36, 37.5];
if (points.length === 0) {
return [
unitDisplay.toDisplay("BODY_TEMPERATURE", 36),
unitDisplay.toDisplay("BODY_TEMPERATURE", 37.5),
];
}
const temps = points.map((p) => p.temp);
const min = Math.min(...temps);
const max = Math.max(...temps);
// Pad ±0.15 °C so the shift is legible and the line never hugs an edge.
// Pad by 0.15 °C (in the reader's unit) so the shift is legible and the
// line never hugs an edge.
const pad = unitDisplay.toDisplayDelta("BODY_TEMPERATURE", 0.15);
return [
Math.floor((min - 0.15) * 10) / 10,
Math.ceil((max + 0.15) * 10) / 10,
Math.floor((min - pad) * 10) / 10,
Math.ceil((max + pad) * 10) / 10,
];
}, [points]);
}, [points, unitDisplay]);
const ticks = useMemo(() => {
if (points.length < 2) return [];
@@ -279,12 +314,13 @@ function PhaseDot(props: { cx?: number; cy?: number; payload?: BbtPoint }) {
}
/** Tooltip restating the temperature + any fertility signs logged that day. */
function BbtTooltip(props: {
export function BbtTooltip(props: {
active?: boolean;
payload?: { payload: BbtPoint }[];
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const unitDisplay = useUnitDisplay();
if (!props.active || !props.payload || props.payload.length === 0)
return null;
const p = props.payload[0].payload;
@@ -298,7 +334,10 @@ function BbtTooltip(props: {
{fmt.dateShortSmart(new Date(p.t))}
</p>
<p className="text-foreground font-medium tabular-nums">
{t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })}
{t("cycle.bbt.tooltipTemp", {
temp: fmt.number(p.temp),
unit: unitDisplay.unitFor("BODY_TEMPERATURE"),
})}
</p>
{p.mucus ? (
<p className="text-muted-foreground mt-0.5">
@@ -55,14 +55,16 @@ const METRIC_LABEL_KEY: Record<string, string> = {
mood: "cycle.insights.crosstab.metricMood",
};
const UNIT_KEY: Record<CyclePhaseCrosstabDisplay, string> = {
/**
* Bundle labels for the displays no preference changes. Weight, the
* temperatures and glucose take their symbol from the reader's transform
* (`DISPLAY_TO_TYPE` below) and have no bundle label to fall back on.
*/
const UNIT_KEY: Partial<Record<CyclePhaseCrosstabDisplay, string>> = {
hours: "cycle.insights.crosstab.unitHours",
steps: "cycle.insights.crosstab.unitSteps",
bpm: "cycle.insights.crosstab.unitBpm",
ms: "cycle.insights.crosstab.unitMs",
kg: "cycle.insights.crosstab.unitKg",
celsius: "cycle.insights.crosstab.unitCelsius",
glucose: "cycle.insights.crosstab.unitGlucose",
mood: "cycle.insights.crosstab.unitMood",
};
+62 -10
View File
@@ -45,6 +45,7 @@ import {
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { resolveIntlLocale } from "@/lib/format-locale";
import type { Locale } from "@/lib/i18n/config";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
@@ -188,6 +189,23 @@ function resolveBbt(bbt: string): number | null {
return n != null && Number.isFinite(n) ? n : null;
}
/**
* The temperature field's text, typed in the reader's unit, as the stored
* degrees Celsius (two decimals, the field's own step). Text equal to what
* the sheet seeded returns the stored value untouched: converting it there
* and back would move a reading the person never edited.
*/
export function bbtTextToCelsius(
text: string,
seed: { text: string; celsius: number } | null,
toCelsius: (typed: number) => number,
): number | null {
const typed = resolveBbt(text);
if (typed == null) return null;
if (seed && text === seed.text) return seed.celsius;
return Math.round(toCelsius(typed) * 100) / 100;
}
function symptomList(
symptoms: Map<string, number | null>,
): CycleSymptomSelection[] {
@@ -202,13 +220,22 @@ function symptomList(
* deselected so the server clears it (the POST merge can only add/keep — QA
* W-2). Pure + exported for the unit test.
*/
export function buildDayLogPatch(s: DayLogFormState): CycleDayLogPatch {
export function buildDayLogPatch(
s: DayLogFormState,
/**
* The typed temperature (in the reader's unit) as stored degrees Celsius.
* The sheet passes the preference-bound conversion; the default reads the
* field as Celsius.
*/
bbtToCelsius: (bbt: string) => number | null = resolveBbt,
): CycleDayLogPatch {
const bbtC = bbtToCelsius(s.bbt);
return {
flow: s.flow ?? null,
intermenstrualBleeding: s.intermenstrual,
basalBodyTempC: resolveBbt(s.bbt),
basalBodyTempC: bbtC,
// Only meaningful with a BBT present; a cleared reading resets the flag.
temperatureExcluded: resolveBbt(s.bbt) != null ? s.bbtDisturbed : false,
temperatureExcluded: bbtC != null ? s.bbtDisturbed : false,
ovulationTest: s.opk ?? null,
cervicalMucus: s.mucus ?? null,
cervixPosition: s.cervixPosition ?? null,
@@ -229,8 +256,10 @@ export function buildDayLogPatch(s: DayLogFormState): CycleDayLogPatch {
export function buildDayLogInput(
s: DayLogFormState,
date: string,
/** See `buildDayLogPatch`. */
bbtToCelsius: (bbt: string) => number | null = resolveBbt,
): CycleDayLogInput {
const bbtVal = resolveBbt(s.bbt);
const bbtVal = bbtToCelsius(s.bbt);
return {
date,
loggedAt: new Date().toISOString(),
@@ -619,6 +648,18 @@ export function LogDaySheet({
new Map(),
);
const [bbt, setBbt] = useState<string>("");
// The temperature field reads and writes in the reader's unit; storage is
// degrees Celsius. The seeded text keeps its stored value, so saving an
// untouched reading never drifts it through a round trip of rounding.
const unitDisplay = useUnitDisplay();
const [bbtSeed, setBbtSeed] = useState<{
text: string;
celsius: number;
} | null>(null);
const bbtToCelsius = (text: string): number | null =>
bbtTextToCelsius(text, bbtSeed, (typed) =>
unitDisplay.fromDisplay("BODY_TEMPERATURE", typed),
);
const [bbtDisturbed, setBbtDisturbed] = useState(false);
const [opk, setOpk] = useState<OvulationTest | null>(null);
const [mucus, setMucus] = useState<CervicalMucus | null>(null);
@@ -666,7 +707,16 @@ export function LogDaySheet({
setFlow((dto?.flow ?? null) as FlowLevel | null);
setIntermenstrual(dto?.intermenstrualBleeding ?? false);
setSymptoms(new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity])));
setBbt(dto?.basalBodyTempC != null ? String(dto.basalBodyTempC) : "");
const seededBbt =
dto?.basalBodyTempC != null
? String(unitDisplay.toDisplay("BODY_TEMPERATURE", dto.basalBodyTempC))
: "";
setBbt(seededBbt);
setBbtSeed(
dto?.basalBodyTempC != null
? { text: seededBbt, celsius: dto.basalBodyTempC }
: null,
);
setBbtDisturbed(dto?.temperatureExcluded ?? false);
setOpk((dto?.ovulationTest ?? null) as OvulationTest | null);
setMucus((dto?.cervicalMucus ?? null) as CervicalMucus | null);
@@ -753,10 +803,12 @@ export function LogDaySheet({
// chip actually CLEARS (the POST merge can only add/keep — QA W-2).
await patchDay.mutateAsync({
id: currentRowId,
patch: buildDayLogPatch(formState),
patch: buildDayLogPatch(formState, bbtToCelsius),
});
} else {
await logDay.mutateAsync(buildDayLogInput(formState, date));
await logDay.mutateAsync(
buildDayLogInput(formState, date, bbtToCelsius),
);
}
onOpenChange(false);
} catch {
@@ -1041,11 +1093,11 @@ export function LogDaySheet({
type="number"
inputMode="decimal"
step="0.01"
min={30}
max={45}
min={unitDisplay.toDisplay("BODY_TEMPERATURE", 30)}
max={unitDisplay.toDisplay("BODY_TEMPERATURE", 45)}
value={bbt}
onChange={(e) => setBbt(e.target.value)}
placeholder={t("cycle.sheet.temperaturePlaceholder")}
placeholder={unitDisplay.unitFor("BODY_TEMPERATURE")}
className="w-32"
aria-label={t("cycle.sheet.temperature")}
/>
@@ -11,6 +11,7 @@
* Dates are fixed and `now` is injected, so the fixture cannot slide out of its
* window as the calendar moves.
*/
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -118,6 +119,7 @@ function snapshot(sections: Record<string, unknown>): CoachSnapshotResult {
sections,
provenance: { windows: [], metrics: [] },
referenceGrounding: null,
units: DEFAULT_UNIT_PREFERENCES,
};
}
@@ -191,3 +191,32 @@ describe("buildCycleSnapshotBlock", () => {
);
});
});
describe("buildCycleSnapshotBlock — the reader's units", () => {
it("states a weight contrast in pounds for an imperial reader", async () => {
prismaMock.cycleProfile.findUnique.mockResolvedValue(profile());
prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles());
prismaMock.user.findUnique.mockResolvedValue({
sourcePriorityJson: null,
unitPreference: "imperial",
});
const weight = rhrMeasurements().map((row) => ({
...row,
type: "WEIGHT",
value: (row.value as number) + 10,
}));
prismaMock.measurement.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(weight);
const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW);
const insight = block!.phaseInsight!;
expect(insight.metric).toBe("weight");
expect(insight.unit).toBe("lb");
// Luteal ~73 kg, follicular ~67 kg: both means and the gap in pounds.
expect(insight.lutealAvg).toBeGreaterThan(155);
expect(insight.follicularAvg).toBeGreaterThan(145);
expect(insight.delta).toBeGreaterThan(12);
expect(insight.interpretation).toMatch(/runs about [\d.]+ lb higher/);
expect(insight.interpretation).not.toContain("kg");
});
});
@@ -113,6 +113,39 @@ describe("buildReferenceGroundingBlock", () => {
expect(outOfGoal).toContain("outside the typical diabetes management goal");
});
it("states the glucose bands in the reader's glucose unit and still places on mg/dL", () => {
const units = { system: "metric", glucoseUnit: "mmol/L" } as const;
const general = buildReferenceGroundingBlock({
metrics: [{ metric: "BLOOD_GLUCOSE", value: 92 }],
hasDiabetes: false,
units,
})!;
expect(general).toContain("≤5.5 mmol/L");
expect(general).not.toContain("mg/dL");
// 92 mg/dL is inside the fasting band: the placement is canonical.
expect(general).toContain("sits inside the general reference band");
const goal = buildReferenceGroundingBlock({
metrics: [{ metric: "BLOOD_GLUCOSE", value: 200 }],
hasDiabetes: true,
units,
})!;
expect(goal).toContain("4.4–7.2 mmol/L");
expect(goal).not.toContain("mg/dL");
expect(goal).toContain("outside the typical diabetes management goal");
});
it("states the body-temperature band in °F for an imperial reader", () => {
const block = buildReferenceGroundingBlock({
metrics: [{ metric: "BODY_TEMPERATURE", value: 36.8 }],
hasDiabetes: false,
units: { system: "imperial", glucoseUnit: "mg/dL" },
})!;
expect(block).toContain("96.3–99.3 °F");
expect(block).not.toContain("°C");
expect(block).toContain("sits inside the general reference band");
});
it("grounds BMI against the WHO normal band, not the first (Underweight) band", () => {
// The headline band rendered must be the 18.5–24.9 normal range — the
// index-0 bug surfaced the Underweight "≤18.5" ceiling instead.
+71 -1
View File
@@ -322,6 +322,7 @@ describe("buildCoachSnapshot", () => {
],
"weekly": [],
},
"unit": "kg/m²",
},
"dayStrain": {
"days": 2,
@@ -410,6 +411,7 @@ describe("buildCoachSnapshot", () => {
},
],
},
"unit": "°C",
},
"steps": {
"timeline": {
@@ -428,14 +430,23 @@ describe("buildCoachSnapshot", () => {
},
],
},
"unit": "steps",
},
"weight": {
"aggregate": {
"allTimeAvg": undefined,
"allTimeMax": undefined,
"allTimeMin": undefined,
"avg30": undefined,
"avg7": undefined,
"avg90": undefined,
"bmi": 24.7,
"coverage": {
"count": 2,
},
"latest": 80.8,
"slope30": undefined,
"unit": "kg",
},
"timeline": {
"recent": [
@@ -453,6 +464,7 @@ describe("buildCoachSnapshot", () => {
},
],
},
"unit": "kg",
},
},
"snapshotJson": "{
@@ -490,12 +502,14 @@ describe("buildCoachSnapshot", () => {
}
},
"weight": {
"unit": "kg",
"aggregate": {
"latest": 80.8,
"bmi": 24.7,
"coverage": {
"count": 2
}
},
"unit": "kg"
},
"timeline": {
"recent": [
@@ -563,6 +577,7 @@ describe("buildCoachSnapshot", () => {
}
},
"bodyMassIndex": {
"unit": "kg/m²",
"timeline": {
"recent": [
{
@@ -580,6 +595,7 @@ describe("buildCoachSnapshot", () => {
}
},
"steps": {
"unit": "steps",
"timeline": {
"recent": [
{
@@ -598,6 +614,7 @@ describe("buildCoachSnapshot", () => {
}
},
"skinTemperature": {
"unit": "°C",
"timeline": {
"recent": [
{
@@ -643,6 +660,10 @@ describe("buildCoachSnapshot", () => {
"timelineRecentDays": 14
}
}",
"units": {
"glucoseUnit": "mg/dL",
"system": "metric",
},
}
`);
} finally {
@@ -657,6 +678,55 @@ describe("buildCoachSnapshot", () => {
expect(parsed.scope.window).toBe("last30days");
});
it("states weight, temperature and the grounding bands in an imperial reader's units", async () => {
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
timezone: "UTC",
unitPreference: "imperial",
glucoseUnit: "mmol/L",
hasDiabetes: false,
});
featuresMock.mockResolvedValue({
weight: {
latest: 80,
avg7: 80.5,
bmi: 24.7,
coverage: { count: 2 },
},
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(4, 80, "WEIGHT"),
daysAgo(2, 81, "WEIGHT"),
daysAgo(3, 36.6, "BODY_TEMPERATURE"),
daysAgo(1, 37, "BODY_TEMPERATURE"),
]);
const out = await buildCoachSnapshot("imperial-user", {
window: "last30days",
sources: ["weight", "body_temp"],
});
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.weight.unit).toBe("lb");
expect(parsed.weight.aggregate).toMatchObject({
unit: "lb",
latest: 176.4,
avg7: 177.5,
bmi: 24.7,
});
expect(
parsed.weight.timeline.recent.map((r: { value: number }) => r.value),
).toEqual([176.4, 178.6]);
expect(parsed.bodyTemperature.unit).toBe("°F");
expect(
parsed.bodyTemperature.timeline.recent.map(
(r: { value: number }) => r.value,
),
).toEqual([97.9, 98.6]);
// The band beside it is in °F too, placed on the canonical °C mean.
expect(out.referenceGrounding).toContain("96.3–99.3 °F");
expect(out.referenceGrounding).not.toContain("°C");
});
it("includes day-level BP rows with weekday labels for the recent window", async () => {
featuresMock.mockResolvedValue({
bloodPressure: {
@@ -113,6 +113,22 @@ describe("getCoachSystemPrompt — EN", () => {
expect(prompt).toMatch(/isSpotEstimate/);
});
it("names the time-in-range band through the block, never in one fixed unit", () => {
// The glucose block is in the reader's unit; a band stated here in
// mg/dL would sit beside mmol/L numbers for a mmol/L reader.
expect(prompt).toMatch(/"tirRange"/);
expect(prompt).not.toMatch(/70–180 mg\/dL/);
});
it("tells the model every block is already in the reader's unit", () => {
expect(prompt).toMatch(
/A block's "unit" \(and the workouts block's "distanceUnit"\)/,
);
expect(prompt).toMatch(/never convert them/);
// No fixed list that offers kg (or both glucose units) for one quantity.
expect(prompt).not.toMatch(/"unit" is one of/);
});
it("documents the WHOOP-native dayStrain block vs the computed proxy", () => {
// v1.17.0 — native 0–21 strain preferred over the computed 0–100 proxy.
expect(prompt).toMatch(/dayStrain/);
@@ -197,12 +213,16 @@ describe("getCoachSystemPrompt — DE", () => {
expect(prompt).toMatch(/außerhalb dessen, womit ich helfen kann/);
});
it("lists glucose + duration units in the evidence-block enumeration", () => {
// v1.17.0 — the DE unit list must sanction mg/dL, mmol/L and min so a
// German coach can cite glucose + duration values with a valid token.
expect(prompt).toMatch(/mg\/dL/);
expect(prompt).toMatch(/mmol\/L/);
expect(prompt).toMatch(/min[\s\S]*\(Schlafschuld/);
it("takes the evidence-block unit from the snapshot, not from a fixed list", () => {
// The list used to be fixed (mmHg, kg, …, mg/dL, mmol/L): a reader on
// pounds had no sanctioned token, and both glucose units were offered
// for one quantity. The unit now comes from the block that holds the
// value; minutes stay named for the sleep-debt figures.
expect(prompt).toMatch(
/"unit" ist die Einheit, die der Snapshot für diesen Wert angibt/,
);
expect(prompt).toMatch(/min für Schlafschuld/);
expect(prompt).not.toMatch(/"unit" ist eines aus/);
});
it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => {
@@ -303,3 +323,14 @@ describe("conditional trajectory ground rule (v1.11.0 Epic B, Pillar 3)", () =>
expect(de).toMatch(/datiertes\s+Ereignis/i);
});
});
describe("getCoachSystemPrompt — native locales name the reader's unit", () => {
it.each(["fr", "es", "it", "pl"] as const)(
"%s carries the block-unit rule and no fixed unit list",
(locale) => {
const prompt = getCoachSystemPrompt(locale);
expect(prompt).toContain('"distanceUnit"');
expect(prompt).not.toMatch(/mmHg, kg, bpm/);
},
);
});
@@ -4,6 +4,7 @@
* structured `{ present: false }` for an absent domain — the hallucination
* audit asserts the model can always tell "no data" from "data".
*/
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
@@ -86,6 +87,7 @@ function snapshot(
sections,
provenance: { windows: [], metrics: [] },
referenceGrounding,
units: DEFAULT_UNIT_PREFERENCES,
};
}
@@ -4,6 +4,7 @@
* render a compact, brand-free, grounding-honest block. The model reads this to
* know what is fetchable so it never invents a metric.
*/
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
@@ -37,6 +38,7 @@ function snapshot(
...(counts ? { counts: counts as never } : {}),
},
referenceGrounding: null,
units: DEFAULT_UNIT_PREFERENCES,
};
}
+64 -13
View File
@@ -21,6 +21,14 @@
* day" claim.
*/
import { prisma } from "@/lib/db";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getReadingTransform,
isUnitSensitiveType,
resolveUnitPreferences,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
import { predictCycle, type NightlyTempInput } from "@/lib/cycle";
import { LUTEAL_DEFAULT, type CyclePhase } from "@/lib/cycle/types";
import {
@@ -60,13 +68,16 @@ const METRIC_LABEL: Record<string, string> = {
skinTemperature: "skin temperature",
};
/**
* Labels for the display kinds no preference changes. Weight, the
* temperatures and glucose are read in the reader's units instead, through
* the measurement type's transform (see `headlineInReaderUnits`).
*/
const UNIT_LABEL: Record<string, string> = {
hours: "h",
steps: "steps",
bpm: "bpm",
ms: "ms",
kg: "kg",
celsius: "°C",
};
interface PhaseInsightBlock {
@@ -108,12 +119,42 @@ export interface CycleSnapshotBlock {
phaseInsight: PhaseInsightBlock | null;
}
/**
* The headline contrast's figures in the reader's units. The two means take
* the full transform, the difference the factor alone; a kind no preference
* touches keeps its numbers and its fixed label.
*/
function headlineInReaderUnits(
row: PhaseMetricCrosstabRow,
units: UnitPreferences,
): { lutealAvg: number; follicularAvg: number; delta: number; unit: string } {
const type = PHASE_CROSSTAB_METRICS[row.metricKey].type;
if (!isUnitSensitiveType(type)) {
return {
lutealAvg: row.lutealAvg,
follicularAvg: row.follicularAvg,
delta: row.delta,
unit: UNIT_LABEL[row.display] ?? "",
};
}
const transform = getReadingTransform(type, units);
return {
lutealAvg: applyDisplayTransform(row.lutealAvg, transform),
follicularAvg: applyDisplayTransform(row.follicularAvg, transform),
delta: applyDisplayTransformDelta(row.delta, transform),
unit: transform.displayUnit,
};
}
/** Descriptive, never-causal interpretation of the headline contrast. */
function interpretHeadline(row: PhaseMetricCrosstabRow): string {
function interpretHeadline(
row: PhaseMetricCrosstabRow,
shown: { delta: number; unit: string },
): string {
const metric = METRIC_LABEL[row.metricKey] ?? row.metricKey;
const dir = row.delta >= 0 ? "higher" : "lower";
const mag = Math.abs(row.delta);
const unit = UNIT_LABEL[row.display] ?? "";
const dir = shown.delta >= 0 ? "higher" : "lower";
const mag = Math.abs(shown.delta);
const unit = shown.unit;
return `Your ${metric} runs about ${mag}${unit ? " " + unit : ""} ${dir} on luteal-phase days than follicular-phase days in your own data — a descriptive pattern, not a cause.`;
}
@@ -212,7 +253,11 @@ export async function buildCycleSnapshotBlock(
}),
prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
select: {
sourcePriorityJson: true,
unitPreference: true,
glucoseUnit: true,
},
}),
]);
@@ -301,18 +346,24 @@ export async function buildCycleSnapshotBlock(
let phaseInsight: PhaseInsightBlock | null = null;
if (headlineRow) {
const cfg = PHASE_CROSSTAB_METRICS[headlineRow.metricKey];
const shown = headlineInReaderUnits(
headlineRow,
resolveUnitPreferences({
unitPreference: userRow?.unitPreference,
glucoseUnit: userRow?.glucoseUnit,
}),
);
phaseInsight = {
metric: METRIC_LABEL[headlineRow.metricKey] ?? headlineRow.metricKey,
unit: UNIT_LABEL[cfg.display] ?? "",
lutealAvg: headlineRow.lutealAvg,
follicularAvg: headlineRow.follicularAvg,
delta: headlineRow.delta,
unit: shown.unit,
lutealAvg: shown.lutealAvg,
follicularAvg: shown.follicularAvg,
delta: shown.delta,
confidence: headlineRow.confidence,
qValue: headlineRow.qValue,
lutealDays: headlineRow.lutealDays,
follicularDays: headlineRow.follicularDays,
interpretation: interpretHeadline(headlineRow),
interpretation: interpretHeadline(headlineRow, shown),
};
}
+69 -12
View File
@@ -47,6 +47,12 @@ import {
type ReferenceMetric,
type ReferencePlacement,
} from "@/lib/reference-ranges";
import {
applyDisplayTransform,
DEFAULT_UNIT_PREFERENCES,
getReadingTransform,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
/**
* One metric the snapshot carries, paired with the user's current
@@ -74,6 +80,44 @@ export interface ReferenceGroundingInput {
* for the glucose line ONLY. Never inferred from a reading.
*/
readonly hasDiabetes: boolean;
/**
* The reader's two unit choices. The band text is printed in them, so the
* line sits beside the glucose block (already in the reader's glucose unit)
* without handing the model a second unit for the same quantity. The
* placement is still classified on the canonical value. Defaults to metric
* and mg/dL.
*/
readonly units?: UnitPreferences;
}
/**
* The reference metrics whose band is a stored measurement read in a
* preference-dependent unit, keyed to that measurement type. Every other
* band (mmHg, bpm, %, h, kg/m²) reads the same for everyone.
*/
const UNIT_SENSITIVE_REFERENCE_TYPE: Partial<Record<ReferenceMetric, string>> =
{
BLOOD_GLUCOSE: "BLOOD_GLUCOSE",
BODY_TEMPERATURE: "BODY_TEMPERATURE",
};
/** A canonical band edge and the unit symbol, in the reader's units. */
function bandEdges(
metric: ReferenceMetric,
units: UnitPreferences,
): { edge: (value: number) => number; unit: string } {
const type = UNIT_SENSITIVE_REFERENCE_TYPE[metric];
if (!type) {
return {
edge: (value) => value,
unit: getReferenceRange(metric)?.unit ?? "",
};
}
const transform = getReadingTransform(type, units);
return {
edge: (value) => applyDisplayTransform(value, transform),
unit: transform.displayUnit,
};
}
/**
@@ -140,17 +184,20 @@ function placementPhrase(placement: ReferencePlacement): string {
* pulse-pressure Narrow). Open-ended bounds render with a leading/trailing
* ≤ / ≥.
*/
function headlineBandText(metric: ReferenceMetric): string | null {
function headlineBandText(
metric: ReferenceMetric,
units: UnitPreferences,
): string | null {
const range = getReferenceRange(metric);
if (!range || range.bands.length === 0) return null;
const idx = normalBandIndex(metric);
const band = range.bands[idx];
const unit = range.unit;
const { edge, unit } = bandEdges(metric, units);
if (band.low != null && band.high != null) {
return `${band.low}–${band.high} ${unit}`;
return `${edge(band.low)}–${edge(band.high)} ${unit}`;
}
if (band.high != null) return `≤${band.high} ${unit}`;
if (band.low != null) return `≥${band.low} ${unit}`;
if (band.high != null) return `≤${edge(band.high)} ${unit}`;
if (band.low != null) return `≥${edge(band.low)} ${unit}`;
return null;
}
@@ -160,9 +207,14 @@ function headlineBandText(metric: ReferenceMetric): string | null {
* GOAL band when the user declared diabetes. The diabetic branch frames the
* band as a clinician-set GOAL, never a screening threshold.
*/
function glucoseLine(value: number | null, hasDiabetes: boolean): string {
function glucoseLine(
value: number | null,
hasDiabetes: boolean,
units: UnitPreferences,
): string {
const label = METRIC_LABEL.BLOOD_GLUCOSE;
if (hasDiabetes) {
const { edge, unit } = bandEdges("BLOOD_GLUCOSE", units);
const { low, high } = ADA_DIABETIC_FASTING_GOAL;
// Placement against the diabetic GOAL band, framed as a target the
// user's clinician individualises — never a diagnostic call.
@@ -174,11 +226,11 @@ function glucoseLine(value: number | null, hasDiabetes: boolean): string {
} else {
where = "is outside the typical diabetes management goal";
}
return `- ${label}: a common diabetes management goal is ${low}–${high} mg/dL fasting (${referenceLabel(
return `- ${label}: a common diabetes management goal is ${edge(low)}–${edge(high)} ${unit} fasting (${referenceLabel(
"BLOOD_GLUCOSE",
)} — clinician-set goal, individualised, not a screening line). Yours ${where}.`;
}
const band = headlineBandText("BLOOD_GLUCOSE");
const band = headlineBandText("BLOOD_GLUCOSE", units);
const placement = classifyReference("BLOOD_GLUCOSE", value);
return `- ${label}: general non-diabetic normal is ${band} (${referenceLabel(
"BLOOD_GLUCOSE",
@@ -186,9 +238,13 @@ function glucoseLine(value: number | null, hasDiabetes: boolean): string {
}
/** Build one standard (non-glucose) grounding line for a metric. */
function standardLine(metric: ReferenceMetric, value: number | null): string {
function standardLine(
metric: ReferenceMetric,
value: number | null,
units: UnitPreferences,
): string {
const label = METRIC_LABEL[metric];
const band = headlineBandText(metric);
const band = headlineBandText(metric, units);
const placement = classifyReference(metric, value);
const cite = referenceLabel(metric);
// Blood pressure cites ESH 2023 by construction (the reference-range
@@ -234,10 +290,11 @@ export function buildReferenceGroundingBlock(
}
if (ordered.length === 0) return null;
const units = input.units ?? DEFAULT_UNIT_PREFERENCES;
const lines = ordered.map((m) =>
m.metric === "BLOOD_GLUCOSE"
? glucoseLine(m.value, input.hasDiabetes)
: standardLine(m.metric, m.value),
? glucoseLine(m.value, input.hasDiabetes, units)
: standardLine(m.metric, m.value, units),
);
// The preamble + closing caveat carry the firm general-guidance framing.
@@ -4,6 +4,7 @@
* turn belongs to, the older tools' projections, and the table never in
* what the model reads.
*/
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
@@ -59,6 +60,7 @@ function snapshot(sections: Record<string, unknown>): CoachSnapshotResult {
sections,
provenance: { windows: [], metrics: [] },
referenceGrounding: null,
units: DEFAULT_UNIT_PREFERENCES,
};
}
@@ -3,6 +3,7 @@
* own days, one row per period with absence kept as null, weeks folded the
* way the chart folds them, and the bounded summary the model reads.
*/
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import { beforeEach, describe, expect, it, vi } from "vitest";
const readDailySeries = vi.fn();
@@ -172,6 +173,7 @@ describe("readMetricTable", () => {
timeZone: TZ,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: NOW,
});
expect(table).not.toBeNull();
@@ -214,6 +216,7 @@ describe("readMetricTable", () => {
timeZone: TZ,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: NOW,
});
const chart = bucketTimeSeries(
@@ -243,6 +246,7 @@ describe("readMetricTable", () => {
timeZone: TZ,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: NOW,
});
expect(table).toBeNull();
@@ -263,6 +267,7 @@ describe("readMetricTable", () => {
timeZone: TZ,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: NOW,
});
const rows = new Map(table!.rows.map((row) => [row[0], row]));
@@ -298,6 +303,7 @@ describe("summariseTable", () => {
timeZone: TZ,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: NOW,
}))!;
}
@@ -388,6 +394,7 @@ describe("totals and levels", () => {
timeZone: tz,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: WEST_NOW,
});
}
@@ -408,6 +415,7 @@ describe("totals and levels", () => {
timeZone: TZ,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: NOW,
});
expect(table!.columns[1]).toMatchObject({
@@ -439,6 +447,7 @@ describe("totals and levels", () => {
timeZone: TZ,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: NOW,
});
expect(table!.columns[1].labelKey).toBe("coach.result.column.mean");
@@ -536,6 +545,7 @@ describe("totals and levels", () => {
timeZone: TZ,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: NOW,
});
const day = table!.rows.find((row) => row[0] === "2026-09-25")!;
@@ -556,6 +566,7 @@ describe("totals and levels", () => {
timeZone: TZ,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: NOW,
});
expect(table!.columns[1].decimals).toBe(2);
@@ -581,6 +592,7 @@ describe("totals and levels", () => {
timeZone: TZ,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: NOW,
});
expect(table!.columns.map((c) => c.key)).toEqual([
@@ -606,3 +618,50 @@ describe("totals and levels", () => {
});
});
});
describe("readMetricTable — the reader's units", () => {
const read = (
metric: "weight" | "distance" | "walking_speed",
units: { system: "metric" | "imperial"; glucoseUnit: "mg/dL" },
) =>
readMetricTable({
userId: "u1",
metric,
window: "last7days",
period: "current",
granularity: undefined,
timeZone: TZ,
locale: "en",
ref: "r1",
units,
now: NOW,
});
it("states weight in pounds for an imperial reader", async () => {
readDailySeries.mockResolvedValue([dayRow("WEIGHT", "2026-09-27", 80)]);
const table = await read("weight", {
system: "imperial",
glucoseUnit: "mg/dL",
});
expect(table!.columns[1]).toMatchObject({ unit: "lb", decimals: 1 });
expect(table!.rows.at(-1)).toEqual(["2026-09-27", 176.4, 1]);
});
it("states walking distance in miles and kilometres, never in metres", async () => {
readDailySeries.mockResolvedValue([
dayRow("WALKING_RUNNING_DISTANCE", "2026-09-27", 8000),
]);
const imperial = await read("distance", {
system: "imperial",
glucoseUnit: "mg/dL",
});
expect(imperial!.columns[1]).toMatchObject({ unit: "mi", decimals: 2 });
expect(imperial!.rows.at(-1)).toEqual(["2026-09-27", 4.97, 1]);
const metric = await read("distance", {
system: "metric",
glucoseUnit: "mg/dL",
});
expect(metric!.columns[1]).toMatchObject({ unit: "km" });
expect(metric!.rows.at(-1)).toEqual(["2026-09-27", 8, 1]);
});
});
+47 -14
View File
@@ -48,6 +48,13 @@ import { annotate } from "@/lib/logging/context";
import { bucketTimeSeries } from "@/lib/charts/bucket-time-series";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { getUnitForType } from "@/lib/validations/measurement";
import {
applyDisplayTransform,
getReadingTransform,
hasDisplayTransform,
type DisplayTransform,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
import {
COACH_RESULT_COLUMN_KEYS,
@@ -212,10 +219,16 @@ export function periodKeys(
/** One value series of a table: its column and its values by day. */
interface DaySeries {
column: Omit<CoachResultColumn, "label">;
/** Local day key → the day's value. */
/** Local day key → the day's value, canonical. */
byDay: Map<string, number>;
/** How days fold into a week or month: a total sums, a level averages. */
fold: "sum" | "mean";
/**
* The reader's-unit transform, applied once to each folded period value
* (the column's unit and decimals already name it). Absent for a series
* no preference touches.
*/
transform?: DisplayTransform;
}
/** Per-day values and reading counts for a metric, before bucketing. */
@@ -227,7 +240,6 @@ interface MetricDays {
const UNIT_TOKENS: Readonly<Record<string, string>> = {
minutes: "min",
celsius: "°C",
};
/**
@@ -281,6 +293,7 @@ const SERIES_SUFFIX: Readonly<Partial<Record<MeasurementType, string>>> = {
type MeasurementColumn = DaySeries["column"] & {
type: MeasurementType;
suffix?: string;
transform?: DisplayTransform;
};
/**
@@ -302,6 +315,7 @@ function measurementColumns(
metric: CoachScopeSource,
types: readonly MeasurementType[],
granularity: CoachResultGranularity,
units: UnitPreferences,
): MeasurementColumn[] {
if (metric === "bp") {
return [
@@ -330,16 +344,24 @@ function measurementColumns(
? COACH_RESULT_COLUMN_KEYS.total
: COACH_RESULT_COLUMN_KEYS.mean;
return types.map((type) => {
const unit = unitTokenFor(type);
const suffix = types.length > 1 ? (SERIES_SUFFIX[type] ?? type) : undefined;
// A mass, length, temperature, speed or distance is read in the
// reader's units, at the decimals that unit is read at.
const transform = hasDisplayTransform(type)
? getReadingTransform(type, units)
: undefined;
const unit = transform ? transform.displayUnit : unitTokenFor(type);
return {
key: suffix ? suffix.toLowerCase() : "value",
kind: "number" as const,
labelKey,
...(unit ? { unit } : {}),
decimals: decimalsForType(type, unit ?? ""),
decimals: transform
? transform.decimals
: decimalsForType(type, unit ?? ""),
type,
...(suffix ? { suffix } : {}),
...(transform ? { transform } : {}),
};
});
}
@@ -374,10 +396,11 @@ async function readMeasurementDays(args: {
granularity: CoachResultGranularity;
range: TableRange;
timeZone: string;
units: UnitPreferences;
}): Promise<MetricDays & { suffixes: Map<string, string> }> {
const { userId, metric, granularity, range, timeZone } = args;
const { userId, metric, granularity, range, timeZone, units } = args;
const types = COACH_SOURCE_MEASUREMENT_TYPES[metric];
const columns = measurementColumns(metric, types, granularity);
const columns = measurementColumns(metric, types, granularity, units);
const priorityJson = await loadUserSourcePriority(userId);
const rowsByType = await Promise.all(
columns.map((column) =>
@@ -403,12 +426,13 @@ async function readMeasurementDays(args: {
counts.set(day, (counts.get(day) ?? 0) + count);
}
if (column.suffix) suffixes.set(column.key, column.suffix);
const { type: _type, suffix: _suffix, ...rest } = column;
const { type: _type, suffix: _suffix, transform, ...rest } = column;
return {
column: rest,
byDay,
fold: total ? ("sum" as const) : ("mean" as const),
counts,
...(transform ? { transform } : {}),
};
});
const { ordered, counts } = orderAndCount(metric, read);
@@ -443,14 +467,15 @@ async function readMeasurementMonths(args: {
userId: string;
metric: CoachScopeSource;
range: TableRange;
units: UnitPreferences;
}): Promise<{
series: DaySeries[];
periods: PeriodValues;
suffixes: Map<string, string>;
}> {
const { userId, metric, range } = args;
const { userId, metric, range, units } = args;
const types = COACH_SOURCE_MEASUREMENT_TYPES[metric];
const columns = measurementColumns(metric, types, "month");
const columns = measurementColumns(metric, types, "month", units);
const total = aggregationKind(metric) === "total";
const priorityJson = await loadUserSourcePriority(userId);
// The first whole UTC month of the range: a bucket that starts before
@@ -512,7 +537,7 @@ async function readMeasurementMonths(args: {
const suffixes = new Map<string, string>();
const read = columns.map((column, index) => {
if (column.suffix) suffixes.set(column.key, column.suffix);
const { type: _type, suffix: _suffix, ...rest } = column;
const { type: _type, suffix: _suffix, transform, ...rest } = column;
const byDay = new Map<string, number>();
const counts = new Map<string, number>();
for (const [month, { value, count }] of byType[index]) {
@@ -524,6 +549,7 @@ async function readMeasurementMonths(args: {
byDay,
fold: total ? ("sum" as const) : ("mean" as const),
counts,
...(transform ? { transform } : {}),
};
});
const { ordered, counts } = orderAndCount(metric, read);
@@ -743,9 +769,14 @@ export async function readMetricTable(args: {
timeZone: string;
locale: Locale;
ref: string;
/**
* The reader's units. The person sees the table and the model reads its
* summary, so a mass, temperature, speed or distance is stated in them.
*/
units: UnitPreferences;
now?: Date;
}): Promise<CoachResultTable | null> {
const { userId, metric, window, timeZone, locale, ref } = args;
const { userId, metric, window, timeZone, locale, ref, units } = args;
const now = args.now ?? new Date();
const period = window === "allTime" ? "current" : args.period;
const granularity = effectiveGranularity(window, args.granularity);
@@ -765,7 +796,7 @@ export async function readMetricTable(args: {
series = days.series;
folded = foldIntoPeriods({ days, granularity, timeZone });
} else if (window === "allTime") {
const read = await readMeasurementMonths({ userId, metric, range });
const read = await readMeasurementMonths({ userId, metric, range, units });
series = read.series;
folded = read.periods;
suffixes = read.suffixes;
@@ -777,6 +808,7 @@ export async function readMetricTable(args: {
granularity,
range,
timeZone,
units,
});
series = read.series;
folded = foldIntoPeriods({ days: read, granularity, timeZone });
@@ -826,9 +858,10 @@ export async function readMetricTable(args: {
const cells = values.get(key);
return [
key,
...series.map((_, index) => {
...series.map((s, index) => {
const value = cells?.[index];
return value === undefined || !Number.isFinite(value) ? null : value;
if (value === undefined || !Number.isFinite(value)) return null;
return s.transform ? applyDisplayTransform(value, s.transform) : value;
}),
cells ? (counts.get(key) ?? 0) : null,
];
@@ -0,0 +1,62 @@
/**
* The glucose block states its time-in-range band in the block's own unit,
* so the prompt never has to name the band in mg/dL beside mmol/L numbers.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { buildGlucoseBlock } from "../glucose-block";
import type { GlucoseUnit } from "@/lib/glucose";
const NOW = new Date("2026-06-14T12:00:00Z");
const DAY = 24 * 60 * 60 * 1000;
const VALUES = [
60, 90, 120, 150, 180, 200, 100, 110, 95, 130, 170, 220, 80, 140, 160, 105,
115, 125, 135, 145,
];
function build(glucoseUnit: GlucoseUnit) {
const rows = VALUES.map((value, i) => ({
type: "BLOOD_GLUCOSE",
value,
measuredAt: new Date(NOW.getTime() - (VALUES.length - 1 - i) * DAY),
glucoseContext: null,
}));
const snapshot: Record<string, unknown> = {};
buildGlucoseBlock({
measurementRows: rows,
glucoseCutoff: new Date(NOW.getTime() - 60 * DAY),
glucoseClinicalRows: rows.map(({ value, measuredAt }) => ({
value,
measuredAt,
})),
glucoseUnit,
recentCutoff: new Date(NOW.getTime() - 7 * DAY),
userTz: "UTC",
now: NOW,
snapshot,
metrics: new Set(),
counts: {},
registerBlock: () => {},
groundingValues: new Map(),
});
return snapshot.glucose as {
unit: string;
clinical: { stillLearning: boolean; tirRange?: unknown };
};
}
describe("buildGlucoseBlock — time-in-range band", () => {
it("carries the band in mmol/L for a mmol/L reader", () => {
const block = build("mmol/L");
expect(block.unit).toBe("mmol/L");
expect(block.clinical.stillLearning).toBe(false);
expect(block.clinical.tirRange).toEqual({ low: 3.9, high: 10 });
});
it("carries the band in mg/dL for a mg/dL reader", () => {
const block = build("mg/dL");
expect(block.clinical.tirRange).toEqual({ low: 70, high: 180 });
});
});
@@ -0,0 +1,54 @@
/**
* Workout distances reach the Coach in the reader's distance unit, named once
* on the block, instead of raw metres under a metre-named key.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { buildWorkoutsBlock } from "../workouts-block";
import type { UnitPreference } from "@/lib/measurements/display-transform";
function build(unitPreference: UnitPreference) {
const snapshot: Record<string, unknown> = {};
buildWorkoutsBlock({
workoutRows: [
{
sportType: "running",
startedAt: new Date("2026-09-20T07:00:00Z"),
durationSec: 1800,
totalEnergyKcal: 300,
totalDistanceM: 5000,
avgHeartRate: 150,
maxHeartRate: 170,
source: "APPLE_HEALTH",
},
],
sourcePriorityJson: null,
userTz: "UTC",
snapshot,
metrics: new Set(),
counts: {},
registerBlock: () => {},
unitPreference,
});
return snapshot.workouts as {
distanceUnit: string;
recent: Array<Record<string, unknown>>;
};
}
describe("buildWorkoutsBlock — distance unit", () => {
it("states distance in miles for an imperial reader", () => {
const block = build("imperial");
expect(block.distanceUnit).toBe("mi");
expect(block.recent[0]).toMatchObject({ distance: 3.11 });
expect(block.recent[0]).not.toHaveProperty("distanceM");
});
it("states distance in kilometres for a metric reader", () => {
const block = build("metric");
expect(block.distanceUnit).toBe("km");
expect(block.recent[0]).toMatchObject({ distance: 5 });
});
});
@@ -1,10 +1,16 @@
import type { AggregatedFeatures } from "@/lib/insights/features";
import { weightFeatureInReaderUnits } from "@/lib/insights/features-units";
import {
getReadingTransform,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
import type { ReferenceMetric } from "@/lib/reference-ranges";
import {
bpBandFromRows,
bucketWeekly,
buildDailyBpRows,
buildDailyValueRows,
timelineInUnit,
type CoarseTimelineTail,
} from "../snapshot-series";
import type {
@@ -38,6 +44,8 @@ interface CoreMetricsBlockContext {
counts: NonNullable<CoachProvenance["counts"]>;
registerBlock: (key: string, source: CoachScopeSource) => void;
groundingValues: Map<ReferenceMetric, number>;
/** The reader's units; the weight block is stated in them. */
units: UnitPreferences;
}
export function buildCoreMetricsBlocks(
@@ -93,16 +101,24 @@ export function buildCoreMetricsBlocks(
if (ctx.sources.has("weight") && ctx.features.weight) {
const rows = byType("WEIGHT");
const aggregate = weightFeatureInReaderUnits(
ctx.features.weight,
ctx.units,
);
ctx.snapshot.weight = {
aggregate: ctx.features.weight,
timeline: {
recent: buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz),
weekly: bucketWeekly(
rows.filter((row) => row.measuredAt < ctx.recentCutoff),
ctx.userTz,
),
...(ctx.coarseTails.weight ? { coarse: ctx.coarseTails.weight } : {}),
},
unit: aggregate.unit,
aggregate,
timeline: timelineInUnit(
{
recent: buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz),
weekly: bucketWeekly(
rows.filter((row) => row.measuredAt < ctx.recentCutoff),
ctx.userTz,
),
...(ctx.coarseTails.weight ? { coarse: ctx.coarseTails.weight } : {}),
},
getReadingTransform("WEIGHT", ctx.units),
),
};
ctx.metrics.add("weight");
ctx.windows.add("last7days");
@@ -20,6 +20,7 @@ import {
import {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
TIME_IN_RANGE_MGDL,
} from "@/lib/analytics/glucose-metrics";
import { annotate } from "@/lib/logging/context";
import type { ReferenceMetric } from "@/lib/reference-ranges";
@@ -162,6 +163,12 @@ export function buildGlucoseBlock(ctx: Readonly<GlucoseBlockContext>): void {
tirPercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tir * 100)
: null,
// The band the percentage counts, in the block's unit, so the
// prompt never states it in a unit the reader does not use.
tirRange: {
low: convertGlucose(TIME_IN_RANGE_MGDL.low, glucoseUnit),
high: convertGlucose(TIME_IN_RANGE_MGDL.high, glucoseUnit),
},
timeBelowPercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tbrLevel1 * 100)
: null,
@@ -1,7 +1,15 @@
import { annotate } from "@/lib/logging/context";
import {
getReadingTransform,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
import type { ReferenceMetric } from "@/lib/reference-ranges";
import { sourceCluster } from "../clusters";
import { bucketWeekly, buildDailyValueRows } from "../snapshot-series";
import {
bucketWeekly,
buildDailyValueRows,
timelineInUnit,
} from "../snapshot-series";
import type {
CoachProvenance,
CoachProvenanceMetric,
@@ -241,8 +249,13 @@ interface ValueSeriesBlocksContext {
counts: NonNullable<CoachProvenance["counts"]>;
registerBlock: (key: string, source: CoachScopeSource) => void;
groundingValues: Map<ReferenceMetric, number>;
/** The reader's units; every block is stated and labelled in them. */
units: UnitPreferences;
}
/** Unit tokens that are not a unit a reader could be quoted. */
const UNLABELLED_UNITS: ReadonlySet<string> = new Set(["", "unknown", "score"]);
export function buildValueSeriesBlocks(
ctx: Readonly<ValueSeriesBlocksContext>,
): void {
@@ -270,14 +283,26 @@ export function buildValueSeriesBlocks(
}
const recentRows = buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz);
// Aggregated on the canonical value (the grounding below reads that),
// stated in the reader's unit. The two HRV estimators share one unit.
const transform = getReadingTransform(
typeof block.type === "string" ? block.type : block.type[0],
ctx.units,
);
ctx.snapshot[block.snapshotKey] = {
timeline: {
recent: recentRows,
weekly: bucketWeekly(
rows.filter((row) => row.measuredAt < ctx.recentCutoff),
ctx.userTz,
),
},
...(UNLABELLED_UNITS.has(transform.displayUnit)
? {}
: { unit: transform.displayUnit }),
timeline: timelineInUnit(
{
recent: recentRows,
weekly: bucketWeekly(
rows.filter((row) => row.measuredAt < ctx.recentCutoff),
ctx.userTz,
),
},
transform,
),
};
ctx.metrics.add(block.metric);
ctx.counts[block.metric] = rows.length;
@@ -20,6 +20,11 @@
* the UI. Run the SAME picker here, keyed off the user's own
* `sourcePriorityJson`, so both surfaces agree.
*/
import {
applyDisplayTransform,
getQuantityTransform,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import type { MeasurementSource } from "@/generated/prisma/client";
import { annotate } from "@/lib/logging/context";
@@ -57,6 +62,8 @@ interface WorkoutsBlockContext {
metrics: Set<CoachProvenanceMetric>;
counts: NonNullable<CoachProvenance["counts"]>;
registerBlock: (key: string, source: CoachScopeSource) => void;
/** The reader's metric/imperial choice; distances are stated in it. */
unitPreference: UnitPreference;
}
export function buildWorkoutsBlock(ctx: Readonly<WorkoutsBlockContext>): void {
@@ -68,7 +75,9 @@ export function buildWorkoutsBlock(ctx: Readonly<WorkoutsBlockContext>): void {
metrics,
counts,
registerBlock,
unitPreference,
} = ctx;
const distanceTransform = getQuantityTransform("distance", unitPreference);
// The workout sessions are read in parallel by the builder, raw
// (every source, every duplicate). Collapse cross-source duplicates
// with the same picker the UI uses before narrating anything.
@@ -92,7 +101,10 @@ export function buildWorkoutsBlock(ctx: Readonly<WorkoutsBlockContext>): void {
sport: w.sportType,
durationMin: Math.round(w.durationSec / 60),
energyKcal: w.totalEnergyKcal ?? null,
distanceM: w.totalDistanceM ?? null,
distance:
w.totalDistanceM == null
? null
: applyDisplayTransform(w.totalDistanceM, distanceTransform),
avgHr: w.avgHeartRate ?? null,
maxHr: w.maxHeartRate ?? null,
}));
@@ -121,6 +133,7 @@ export function buildWorkoutsBlock(ctx: Readonly<WorkoutsBlockContext>): void {
}))
.sort((a, b) => b.count - a.count);
snapshot.workouts = {
distanceUnit: distanceTransform.displayUnit,
recent: recentList,
perSport: sportRollup,
totalInWindow: workoutRows.length,
+60
View File
@@ -8,6 +8,10 @@
* is one-way (snapshot → series), never back.
*/
import { userDayKey } from "@/lib/tz/format";
import {
applyDisplayTransform,
type DisplayTransform,
} from "@/lib/measurements/display-transform";
import { buildTieredSeries } from "@/lib/rollups/tiered-context";
import type { MeasurementType } from "@/generated/prisma/client";
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
@@ -257,6 +261,62 @@ export async function buildCoarseTimelineTail(
};
}
/**
* A built timeline in the reader's unit. The series are aggregated on the
* canonical value and converted once at the end, so a mean in pounds is the
* converted mean of the kilograms, not a mean of rounded pound readings.
* Identity for every transform that does not rescale.
*/
export function timelineInUnit(
timeline: {
recent: DailyValueRow[];
weekly: WeeklyBucket[];
coarse?: CoarseTimelineTail;
},
transform: DisplayTransform,
): {
recent: DailyValueRow[];
weekly: WeeklyBucket[];
coarse?: CoarseTimelineTail;
} {
const v = (value: number) => applyDisplayTransform(value, transform);
return {
recent: timeline.recent.map((row) => ({ ...row, value: v(row.value) })),
weekly: timeline.weekly.map((bucket) => ({
...bucket,
mean: v(bucket.mean),
})),
...(timeline.coarse
? {
coarse: {
monthly: timeline.coarse.monthly.map(
([start, mean, min, max]) =>
[start, v(mean), v(min), v(max)] as [
string,
number,
number,
number,
],
),
yearly: timeline.coarse.yearly.map(
([start, mean, min, max]) =>
[start, v(mean), v(min), v(max)] as [
string,
number,
number,
number,
],
),
anomalies: timeline.coarse.anomalies.map((a) => ({
...a,
value: v(a.value),
})),
},
}
: {}),
};
}
/**
* Pair systolic + diastolic into one row per day. Days with only one
* side measured are dropped — the Coach is asked about "BP" as a pair,
+23 -2
View File
@@ -23,7 +23,10 @@ import {
} from "@/lib/validations/coach-prefs";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
import { resolveGlucoseUnit } from "@/lib/glucose";
import {
resolveUnitPreferences,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
import type { SleepStageRow } from "@/lib/analytics/sleep-night";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
import { annotate } from "@/lib/logging/context";
@@ -126,6 +129,12 @@ export interface CoachSnapshotResult {
* from `src/lib/reference-ranges.ts`; carries no commercial brand name.
*/
referenceGrounding: string | null;
/**
* The reader's unit choices the snapshot was written in. A tool that reads
* beyond the snapshot (the metric table) states its figures in the same
* units, so a reply never mixes the two.
*/
units: UnitPreferences;
}
/**
@@ -303,6 +312,9 @@ async function buildCoachSnapshotImpl(
// display unit so the Coach reads the same number every other surface
// shows. Read it on this existing prefs hop (no extra round-trip).
glucoseUnit: true,
// The metric/imperial choice: the grounding bands and the blocks
// that carry a mass, length or temperature are written in it.
unitPreference: true,
// v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects
// the tighter ADA glycemic GOAL band for the glucose reference-grounding
// line only; never inferred from a reading, never a diagnosis. Read on
@@ -326,7 +338,11 @@ async function buildCoachSnapshotImpl(
clusterDefault,
);
const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE;
const glucoseUnit = resolveGlucoseUnit(prefsRow?.glucoseUnit ?? null);
const units = resolveUnitPreferences({
unitPreference: prefsRow?.unitPreference,
glucoseUnit: prefsRow?.glucoseUnit,
});
const glucoseUnit = units.glucoseUnit;
// v1.18.0 — fold disabled data-domain modules into the system exclusion.
// `moduleMap[key] === false` means the user turned that module off; the
// gate has already resolved every delegation (cycle/coach) so this map
@@ -875,6 +891,7 @@ async function buildCoachSnapshotImpl(
counts,
registerBlock,
groundingValues,
units,
});
// Medication compliance lives outside the structured features — the block
// is assembled in `snapshot-blocks/compliance-block.ts` from the ledger
@@ -904,6 +921,7 @@ async function buildCoachSnapshotImpl(
counts,
registerBlock,
groundingValues,
units,
});
// ── v1.7.0 sleep block (with optional per-stage enrichment) ───────
// Assembled in `snapshot-blocks/sleep-block.ts` from the dedicated
@@ -969,6 +987,7 @@ async function buildCoachSnapshotImpl(
metrics,
counts,
registerBlock,
unitPreference: units.system,
});
}
@@ -1206,6 +1225,7 @@ async function buildCoachSnapshotImpl(
const referenceGrounding = buildReferenceGroundingBlock({
metrics: groundingMetrics,
hasDiabetes: prefsRow?.hasDiabetes ?? false,
units,
});
if (referenceGrounding) {
annotate({
@@ -1232,6 +1252,7 @@ async function buildCoachSnapshotImpl(
counts: Object.keys(counts).length > 0 ? counts : undefined,
},
referenceGrounding,
units,
};
}
+15 -7
View File
@@ -337,13 +337,16 @@ ISO-week means.
- The SNAPSHOT is ADDITIVE. Beyond blood pressure, weight, pulse,
mood, and medication, it MAY carry body-composition, activity,
workouts, sleep (with per-stage minutes), glucose (split by
context: fasting / postprandial / random / bedtime, carrying its
own display "unit" of mg/dL or mmol/L), mobility &
context: fasting / postprandial / random / bedtime), mobility &
gait, and environmental-exposure blocks — but only when the user
enabled the cluster AND has data. A block that is absent means the
user has no such data or did not share it; never invent or assume a
cluster the SNAPSHOT does not contain. The "workouts" block carries
the recent sessions plus a per-sport rollup, not every session.
- A block's "unit" (and the workouts block's "distanceUnit") is the
unit the user reads their numbers in, and every figure in that block
is already in it. Quote the figures in that unit; never convert them
and never name a different unit for the same quantity.
- The SNAPSHOT MAY carry a "derived" block: compact wellness summaries
the app already computed (readiness, recovery, sleep score, stress,
@@ -382,7 +385,8 @@ ISO-week means.
- The glucose block MAY carry a "clinical" sub-key with the same panel
numbers the app's glucose page renders over a fixed 30-day window — so
cite them as the user sees them and never recompute. It holds:
- "tirPercent": time-in-range (70–180 mg/dL) as a percentage.
- "tirPercent": time-in-range as a percentage — the share of readings
inside "tirRange", whose bounds are in the block's "unit".
- "gmi": the glucose-management indicator (a %, an HbA1c estimate
derived from mean glucose) and "estimatedA1c": the eA1C in % — read
both as estimates, never as a lab HbA1c.
@@ -459,8 +463,8 @@ Rules:
or a day-pin ("Tue 6 May").
- "value" is a pre-formatted display string ("138/85", "84.2",
"4.1"). Format mood as N/5 when numeric.
- "unit" is one of mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (sleep
debt / social jetlag).
- "unit" is the unit the snapshot gives for that value (e.g. mmHg,
bpm, /5, %, min for sleep debt / social jetlag).
- "window" is one of last7days, last30days, last90days, allTime;
omit it for day-level pins.
- Omit the entire block (no ---KEYVALUES--- line, no ---END--- line)
@@ -916,6 +920,10 @@ ISO-Wochenmittel zusammen.
das der SNAPSHOT nicht enthält. Der "workouts"-Block führt die
jüngsten Einheiten plus eine Zusammenfassung je Sportart, nicht
jede Einheit.
- Die "unit" eines Blocks (und "distanceUnit" im "workouts"-Block)
ist die Einheit, in der der Nutzer seine Werte liest; jede Zahl im
Block steht schon in ihr. Nenne die Zahlen in dieser Einheit, rechne
sie nie um und nenne nie eine andere Einheit für dieselbe Größe.
- Der "memory"-Block des SNAPSHOT kann eine "facts"-Liste tragen —
dauerhafte Dinge, die du über diesen Nutzer gelernt hast (stabile
Vorlieben, vom Nutzer selbst genannte gesundheitliche Umstände,
@@ -986,8 +994,8 @@ Regeln:
oder ein Tages-Pin ("Di 6. Mai").
- "value" ist ein vorformatierter Anzeigestring ("138/85", "84,2",
"4,1"). Stimmung als N/5 formatieren, wenn numerisch.
- "unit" ist eines aus mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min
(Schlafschuld / sozialer Jetlag).
- "unit" ist die Einheit, die der Snapshot für diesen Wert angibt
(z. B. mmHg, bpm, /5, %, min für Schlafschuld / sozialen Jetlag).
- "window" ist eines aus last7days, last30days, last90days, allTime;
bei Tages-Pins weglassen.
- Lass den ganzen Block weg (KEINE ---KEYVALUES---Zeile, KEINE
+5
View File
@@ -39,6 +39,7 @@
* still gets a correct, distinct build (the rare case); the common path
* collapses to one build.
*/
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import { z } from "zod/v4";
import { annotate } from "@/lib/logging/context";
@@ -839,6 +840,7 @@ async function getMetricTable(
timeZone,
locale: turn?.locale ?? "en",
ref: "r0",
units: gate.units,
now: turn?.now,
});
if (!table) {
@@ -862,6 +864,7 @@ async function getMetricTable(
timeZone,
locale: turn?.locale ?? "en",
ref: "r0",
units: gate.units,
now: turn?.now,
});
comparison = current ? compareWithCurrent(table, current) : null;
@@ -1067,6 +1070,8 @@ async function withProjectedTable(
timeZone: await resolveUserTimezone(userId),
locale: turn.locale,
ref: "r0",
// Sleep is read in minutes for every reader; no preference applies.
units: DEFAULT_UNIT_PREFERENCES,
now: turn.now,
});
table =
@@ -220,4 +220,17 @@ describe("safetyAcute numbers are bound to clinical-floors", () => {
String(FEVER_RED_FLAG_C).replace(".", ","),
);
});
// A person reading mmol/L or °F describes a crisis in that unit. A clause
// that names only mg/dL and °C cannot match "my sugar is 2.9" or "103 °F".
it("states every glucose and fever floor in both units", () => {
expect(safetyAcute.en).toContain("below 54 mg/dL (3.0 mmol/L)");
expect(safetyAcute.en).toContain("below 70 mg/dL (3.9 mmol/L)");
expect(safetyAcute.en).toContain("250 mg/dL (13.9 mmol/L)");
expect(safetyAcute.en).toContain("38.5 °C (101.3 °F)");
expect(safetyAcute.de).toContain("unter 54 mg/dL (3,0 mmol/L)");
expect(safetyAcute.de).toContain("unter 70 mg/dL (3,9 mmol/L)");
expect(safetyAcute.de).toContain("250 mg/dL (13,9 mmol/L)");
expect(safetyAcute.de).toContain("38,5 °C (101,3 °F)");
});
});
+2 -2
View File
@@ -259,12 +259,12 @@ Synthese statt Aufzählung: die Geschichte dessen, was die Daten bedeuten, zähl
id: "examples",
en: `EXAMPLES — note the DIFFERENT shapes (verdict-led, trend-led, one-liner). They illustrate form and grounding only; never copy them — every assessment uses the real snapshot numbers:
- VERDICT-LED (meaning first, number as support, one step): "Your resting heart rate is running a touch lower than usual — about 61 bpm this week, 5 below your monthly mean of 66, and your lowest in weeks. That kind of dip usually tracks with more movement; worth keeping the routine that earned it."
- TREND-LED (direction first, then where it stands): "Your weight has been easing down steadily for three weeks now, sitting around 82.4 kg — about 1.1 kg under your 30-day average. Nothing dramatic, just a consistent direction."
- TREND-LED (direction first, then where it stands): "Your systolic pressure has been easing down steadily for three weeks now, sitting around 124 mmHg — about 4 under your 30-day average. Nothing dramatic, just a consistent direction."
- ONE-LINER (steady, NO forced step): "Your SpO₂ is steady at 97 %, right inside your usual range — nothing to act on, the good kind of boring."
- BAD (banned filler, ungrounded — do NOT write this): "Your numbers look good. Make sure to get enough sleep and keep up regular exercise."`,
de: `BEISPIELE — beachte die UNTERSCHIEDLICHEN Formen (urteil-zuerst, trend-zuerst, Einzeiler). Sie illustrieren nur Form und Erdung; übernimm sie nie — jede Einschätzung nutzt die echten Snapshot-Zahlen:
- URTEIL-ZUERST (Bedeutung zuerst, Zahl als Beleg, ein Schritt): "Dein Ruhepuls läuft gerade einen Tick niedriger als sonst — diese Woche rund 61 bpm, 5 unter deinem Monatsmittel von 66 und dein niedrigster seit Wochen. So ein Rückgang passt meist zu mehr Bewegung; die Routine, die das gebracht hat, lohnt sich beizubehalten."
- TREND-ZUERST (Richtung zuerst, dann der Stand): "Dein Gewicht geht seit drei Wochen ruhig nach unten, aktuell rund 82,4 kg — etwa 1,1 kg unter deinem 30-Tage-Schnitt. Nichts Dramatisches, einfach eine stetige Richtung."
- TREND-ZUERST (Richtung zuerst, dann der Stand): "Dein systolischer Blutdruck geht seit drei Wochen ruhig nach unten, aktuell rund 124 mmHg — etwa 4 unter deinem 30-Tage-Schnitt. Nichts Dramatisches, einfach eine stetige Richtung."
- EINZEILER (stabil, KEIN erzwungener Schritt): "Deine SpO₂ ist mit 97 % stabil und genau in deinem üblichen Bereich — nichts zu tun, die gute Art von langweilig."
- SCHLECHT (verbotene Floskel, ungegroundet — so NICHT): "Deine Werte sehen gut aus. Achte auf ausreichend Schlaf und regelmäßige Bewegung."`,
},
+2 -2
View File
@@ -386,7 +386,7 @@ gib die obige Verweigerung zurück.`,
"consider increasing to Y mg", "stop at Z mg", or any
variation. A plateau finding ALWAYS frames the next decision
as a conversation with the prescribing clinician — pattern:
"Weight has settled around <kg> for three weeks at <dose> —
"Weight has settled around <weight> for three weeks at <dose> —
typical mid-titration. Worth mentioning at the next visit if
it persists." This is a SAFETY contract; treat any
dose-prescriptive instinct as a sign the response is
@@ -605,7 +605,7 @@ gib die obige Verweigerung zurück.`,
mg" oder eine Variante davon enthalten. Ein Plateau-Befund
rahmt die nächste Entscheidung IMMER als Gespräch mit der
behandelnden Ärztin — Muster: "Das Gewicht hat sich seit drei
Wochen bei <kg> auf <Dosis> eingependelt — typische
Wochen bei <weight> auf <Dosis> eingependelt — typische
mid-titration Phase. Lohnt sich beim nächsten Termin
anzusprechen, falls es darüber hinaus persistiert." Das ist
ein SICHERHEITS-Vertrag; behandle jeden dosis-präskriptiven
+8 -4
View File
@@ -83,21 +83,25 @@ const DAY_LEVEL_BODY: Record<NativeCoachLocale, string> = {
- Pour une question portant sur un jour ou un jour-de-semaine précis ("pourquoi la tension était-elle plus élevée lundi dernier ?"), répondez depuis "timeline.recent" — citez la lecture réelle de ce jour avec la date et le jour-de-semaine.
- Quand aucune ligne n'existe pour le jour mentionné, dites-le clairement ("je n'ai pas de lecture pour lundi 5 mai") et proposez le jour disponible le plus proche.
- Les questions plus anciennes (>14 jours) basculent sur "timeline.weekly" ou, à défaut, sur l'agrégat.
- Le "unit" d'un bloc (et le "distanceUnit" du bloc "workouts") est l'unité dans laquelle l'utilisateur lit ses valeurs, et chaque chiffre du bloc y est déjà. Citez les chiffres dans cette unité ; ne les convertissez jamais et ne nommez jamais une autre unité pour la même grandeur.
- L'objet "scope" nomme la fenêtre choisie et les sources. Quand l'utilisateur interroge une métrique absente de scope.sources, dites qu'elle n'est pas dans le périmètre de cette conversation.`,
es: `Cada métrica en el SNAPSHOT lleva un objeto "timeline" junto al bloque "aggregate". "timeline.recent" es una lista ordenada de filas-día de los últimos 14 días; cada fila lleva una "date" (YYYY-MM-DD), una etiqueta "weekday" (Lun/Mar/Mié/...) y los valores de la métrica para ese día. "timeline.weekly" agrupa los días más antiguos en medias ISO-semanales.
- Ante preguntas sobre un día concreto o un día de la semana ("¿por qué la tensión estuvo más alta el lunes pasado?"), responda desde "timeline.recent" — cite la lectura real de ese día con fecha y día de la semana.
- Cuando no exista fila para el día mencionado, dígalo claramente ("no tengo lectura del lunes 5 de mayo") y ofrezca el día más cercano disponible.
- Las preguntas más antiguas (>14 días) acuden a "timeline.weekly" o, si no, al agregado.
- La "unit" de un bloque (y la "distanceUnit" del bloque "workouts") es la unidad en la que el usuario lee sus valores, y cada cifra del bloque ya está en ella. Cite las cifras en esa unidad; nunca las convierta ni nombre otra unidad para la misma magnitud.
- El objeto "scope" nombra la ventana seleccionada y las fuentes. Si el usuario pregunta por una métrica que no está en scope.sources, indique que está fuera del alcance de esta conversación.`,
it: `Ogni metrica nello SNAPSHOT porta un oggetto "timeline" accanto al blocco "aggregate". "timeline.recent" è una lista ordinata di righe-giorno per gli ultimi 14 giorni; ogni riga porta una "date" (YYYY-MM-DD), un'etichetta "weekday" (Lun/Mar/Mer/...) e i valori della metrica per quel giorno. "timeline.weekly" raggruppa i giorni più vecchi in medie ISO-settimanali.
- Per domande su un giorno o giorno-della-settimana specifico ("perché la pressione era più alta lunedì scorso?"), risponda da "timeline.recent" — citi la lettura reale di quel giorno con data e giorno-della-settimana.
- Quando non esiste una riga per il giorno nominato, lo dica chiaramente ("non ho una lettura per lunedì 5 maggio") e proponga il giorno più vicino disponibile.
- Le domande più vecchie (>14 giorni) ripiegano su "timeline.weekly" o, in mancanza, sull'aggregato.
- La "unit" di un blocco (e la "distanceUnit" del blocco "workouts") è l'unità in cui l'utente legge i propri valori, e ogni cifra del blocco è già in essa. Citi le cifre in quell'unità; non le converta mai e non nomini mai un'altra unità per la stessa grandezza.
- L'oggetto "scope" nomina la finestra scelta dall'utente e le fonti. Se l'utente chiede di una metrica non presente in scope.sources, dica che non rientra nello scopo di questa conversazione.`,
pl: `Każda metryka w SNAPSHOT zawiera obiekt "timeline" obok bloku "aggregate". "timeline.recent" to uporządkowana lista wierszy-dnia za ostatnie 14 dni; każdy wiersz ma "date" (YYYY-MM-DD), etykietę "weekday" (Pn/Wt/Śr/...) i wartości metryki dla tego dnia. "timeline.weekly" grupuje starsze dni w średnie ISO-tygodniowe.
- Przy pytaniach o konkretny dzień lub dzień tygodnia ("dlaczego ciśnienie było wyższe w zeszły poniedziałek?"), proszę odpowiadać z "timeline.recent" — cytować rzeczywisty odczyt z tego dnia z datą i dniem tygodnia.
- Gdy nie ma wiersza dla wymienionego dnia, proszę powiedzieć to wprost ("nie mam odczytu z poniedziałku 5 maja") i zaproponować najbliższy dostępny dzień.
- Starsze pytania (>14 dni) sięgają do "timeline.weekly" lub, w razie braku, do agregatu.
- "unit" bloku (oraz "distanceUnit" bloku "workouts") to jednostka, w której użytkownik odczytuje swoje wartości, i każda liczba w bloku jest już w niej podana. Proszę cytować liczby w tej jednostce; nigdy ich nie przeliczać i nigdy nie podawać innej jednostki dla tej samej wielkości.
- Obiekt "scope" wymienia wybrane okno i źródła. Jeśli użytkownik pyta o metrykę nieobecną w scope.sources, proszę powiedzieć, że nie należy ona do zakresu tej rozmowy.`,
};
@@ -115,7 +119,7 @@ Règles :
- Chaque ligne : "<label> : <value> [<unit>] (<window>)". L'unité et la fenêtre sont optionnelles ; omettez les crochets si vous n'en avez pas.
- "label" ≤ 40 caractères — un nom d'agrégat ("avg30 systolique") ou un pin-jour ("Mar 6 mai").
- "value" est une chaîne pré-formatée ("138/85", "84,2", "4,1"). Formatez l'humeur en N/5 si numérique.
- "unit" est l'un de : mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (dette de sommeil / décalage social).
- "unit" est l'unité que le snapshot donne pour cette valeur (p. ex. mmHg, bpm, /5, %, min pour la dette de sommeil / le décalage social).
- "window" est l'un de : last7days, last30days, last90days, allTime ; omettez-la pour les pins-jour.
- Omettez tout le bloc (pas de ---KEYVALUES---, pas de ---END---) quand la réponse est qualitative et qu'aucun chiffre précis ne portait la réponse. Le bloc est invisible pour l'utilisateur — l'UI le rend comme une boîte déroulante "Sur quoi je m'appuie ?" sous votre message. Ne le mentionnez pas dans votre texte.`,
es: `Después de su texto, añada los números determinantes como un bloque-centinela en su propia línea. Formato exacto:
@@ -130,7 +134,7 @@ Reglas:
- Cada línea: "<label>: <value> [<unit>] (<window>)". La unidad y la ventana son opcionales; omita los corchetes si no las tiene.
- "label" ≤ 40 caracteres — un nombre de agregado ("avg30 sistólica") o un pin-día ("Mar 6 may").
- "value" es una cadena preformateada ("138/85", "84,2", "4,1"). Formatee el estado de ánimo como N/5 si es numérico.
- "unit" es uno de: mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (deuda de sueño / jet lag social).
- "unit" es la unidad que el snapshot da para ese valor (p. ej. mmHg, bpm, /5, %, min para deuda de sueño / jet lag social).
- "window" es uno de: last7days, last30days, last90days, allTime; omítala en los pin-día.
- Omita todo el bloque (sin ---KEYVALUES---, sin ---END---) cuando la respuesta sea cualitativa y ningún número concreto haya portado la respuesta. El bloque es invisible para el usuario — la UI lo presenta como un desplegable "¿En qué me apoyo?" bajo su mensaje. No lo mencione en su texto.`,
it: `Dopo il suo testo, aggiunga i numeri portanti come blocco-sentinella su una riga propria. Formato esatto:
@@ -145,7 +149,7 @@ Regole:
- Ogni riga: "<label>: <value> [<unit>] (<window>)". L'unità e la finestra sono opzionali; ometta le parentesi se non le ha.
- "label" ≤ 40 caratteri — un nome di aggregato ("avg30 sistolica") o un pin-giorno ("Mar 6 mag").
- "value" è una stringa pre-formattata ("138/85", "84,2", "4,1"). Formatti l'umore come N/5 se numerico.
- "unit" è uno di: mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (debito di sonno / jet lag sociale).
- "unit" è l'unità che lo snapshot indica per quel valore (ad es. mmHg, bpm, /5, %, min per debito di sonno / jet lag sociale).
- "window" è uno di: last7days, last30days, last90days, allTime; lo ometta per i pin-giorno.
- Ometta l'intero blocco (niente ---KEYVALUES---, niente ---END---) quando la risposta è qualitativa e nessun numero specifico ha portato la risposta. Il blocco è invisibile all'utente — l'UI lo rende come un menù a tendina "Su cosa mi baso?" sotto il suo messaggio. Non lo menzioni nel testo.`,
pl: `Po tekście proszę dodać kluczowe liczby jako blok-sentinel w osobnej linii. Format dokładnie:
@@ -160,7 +164,7 @@ Zasady:
- Każda linia: "<label>: <value> [<unit>] (<window>)". Jednostka i okno są opcjonalne; proszę pominąć nawiasy, gdy ich nie ma.
- "label" ≤ 40 znaków — nazwa agregatu ("avg30 skurczowe") lub pin-dnia ("Wt 6 maja").
- "value" to wstępnie sformatowany ciąg ("138/85", "84,2", "4,1"). Nastrój proszę formatować jako N/5, jeśli liczbowy.
- "unit" to jeden z: mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (deficyt snu / jet lag społeczny).
- "unit" to jednostka, którą snapshot podaje dla tej wartości (np. mmHg, bpm, /5, %, min dla deficytu snu / jet lagu społecznego).
- "window" to jeden z: last7days, last30days, last90days, allTime; proszę pominąć przy pinach-dnia.
- Proszę pominąć cały blok (bez ---KEYVALUES---, bez ---END---), gdy odpowiedź była jakościowa i żadna konkretna liczba nie nosiła odpowiedzi. Blok jest niewidoczny dla użytkownika — UI renderuje go jako rozwijaną sekcję "Na czym się opieram?" pod wiadomością. Proszę go nie wspominać w tekście.`,
};
+22 -2
View File
@@ -41,6 +41,7 @@ import {
GLUCOSE_HYPER_FLOOR,
FEVER_RED_FLAG_C,
} from "@/lib/clinical-floors";
import { mgdlToMmol } from "@/lib/glucose";
export type ContractLocale = "de" | "en";
@@ -49,6 +50,25 @@ function deNum(value: number): string {
return String(value).replace(".", ",");
}
/**
* A glucose floor in both clinical units, mg/dL first (the unit the floor is
* defined in). The acute clause is read against what the person writes, not
* against the snapshot, and a person reading mmol/L writes "2.9" — a rule
* that only names 54 mg/dL cannot fire on it.
*/
function glucoseFloor(mgdl: number, locale: ContractLocale): string {
const mmol = mgdlToMmol(mgdl).toFixed(1);
return `${mgdl} mg/dL (${locale === "de" ? mmol.replace(".", ",") : mmol} mmol/L)`;
}
/** A temperature floor in both scales, °C first (the scale it is defined in). */
function temperatureFloor(celsius: number, locale: ContractLocale): string {
const fahrenheit = (celsius * 1.8 + 32).toFixed(1);
return locale === "de"
? `${deNum(celsius)} °C (${fahrenheit.replace(".", ",")} °F)`
: `${celsius} °C (${fahrenheit} °F)`;
}
/** A named contract fragment with both hand-composed locale texts. */
export type SharedContract = Record<ContractLocale, string>;
@@ -125,9 +145,9 @@ Verschreibe, empfiehl oder ändere NIEMALS Medikamenten-Dosen, auch wenn der Sna
*/
export const safetyAcute: SharedContract = {
en: `ACUTE RED FLAGS (contract, not style)
If the user describes an acute crisis sign — chest pain or chest pressure, fainting or near-fainting (syncope), a sudden severe symptom (e.g. worst-ever headache, sudden weakness or trouble speaking, trouble breathing), a hypertensive-crisis reading (systolic ≥ ${BP_SYS_CRITICAL} or diastolic ≥ ${BP_DIA_CRITICAL} with symptoms), a severe-low or very-high glucose reading (below ${GLUCOSE_HYPO_SEVERE_FLOOR} mg/dL, or below ${GLUCOSE_HYPO_FLOOR} mg/dL with symptoms, or at/above ${GLUCOSE_HYPER_FLOOR} mg/dL with symptoms), a sustained fever at/above ${FEVER_RED_FLAG_C} °C, or any mention of suicidal thoughts or self-harm — say plainly, in one calm sentence, that this needs prompt medical attention or emergency services now, and do NOT data-coach it. Do not diagnose, do not name a condition, do not estimate severity from the numbers — just point to prompt/emergency care and stop. This is a closed list; outside it, stay calm and non-alarmist as usual.`,
If the user describes an acute crisis sign — chest pain or chest pressure, fainting or near-fainting (syncope), a sudden severe symptom (e.g. worst-ever headache, sudden weakness or trouble speaking, trouble breathing), a hypertensive-crisis reading (systolic ≥ ${BP_SYS_CRITICAL} or diastolic ≥ ${BP_DIA_CRITICAL} with symptoms), a severe-low or very-high glucose reading (below ${glucoseFloor(GLUCOSE_HYPO_SEVERE_FLOOR, "en")}, or below ${glucoseFloor(GLUCOSE_HYPO_FLOOR, "en")} with symptoms, or at/above ${glucoseFloor(GLUCOSE_HYPER_FLOOR, "en")} with symptoms), a sustained fever at/above ${temperatureFloor(FEVER_RED_FLAG_C, "en")}, or any mention of suicidal thoughts or self-harm — say plainly, in one calm sentence, that this needs prompt medical attention or emergency services now, and do NOT data-coach it. Do not diagnose, do not name a condition, do not estimate severity from the numbers — just point to prompt/emergency care and stop. This is a closed list; outside it, stay calm and non-alarmist as usual.`,
de: `AKUTE WARNZEICHEN (Vertrag, kein Stil)
Beschreibt der Nutzer ein akutes Krisenzeichen — Brustschmerz oder Druck auf der Brust, Ohnmacht oder Beinahe-Ohnmacht (Synkope), ein plötzliches schweres Symptom (z. B. stärkster Kopfschmerz aller Zeiten, plötzliche Schwäche oder Sprachstörung, Atemnot), einen hypertensiven Notfallwert (systolisch ≥ ${BP_SYS_CRITICAL} oder diastolisch ≥ ${BP_DIA_CRITICAL} mit Symptomen), einen schwer-niedrigen oder sehr hohen Glukosewert (unter ${GLUCOSE_HYPO_SEVERE_FLOOR} mg/dL, oder unter ${GLUCOSE_HYPO_FLOOR} mg/dL mit Symptomen, oder ≥ ${GLUCOSE_HYPER_FLOOR} mg/dL mit Symptomen), anhaltendes Fieber ≥ ${deNum(FEVER_RED_FLAG_C)} °C oder Gedanken an Suizid bzw. Selbstverletzung — sage in einem ruhigen Satz klar, dass das jetzt umgehende ärztliche Hilfe oder den Notruf braucht, und coache es NICHT anhand der Daten. Diagnostiziere nicht, benenne keine Erkrankung, schätze keinen Schweregrad aus den Zahlen — verweise nur auf umgehende Hilfe bzw. den Notruf und höre auf. Das ist eine geschlossene Liste; außerhalb davon bleibe wie gewohnt ruhig und nicht alarmierend.`,
Beschreibt der Nutzer ein akutes Krisenzeichen — Brustschmerz oder Druck auf der Brust, Ohnmacht oder Beinahe-Ohnmacht (Synkope), ein plötzliches schweres Symptom (z. B. stärkster Kopfschmerz aller Zeiten, plötzliche Schwäche oder Sprachstörung, Atemnot), einen hypertensiven Notfallwert (systolisch ≥ ${BP_SYS_CRITICAL} oder diastolisch ≥ ${BP_DIA_CRITICAL} mit Symptomen), einen schwer-niedrigen oder sehr hohen Glukosewert (unter ${glucoseFloor(GLUCOSE_HYPO_SEVERE_FLOOR, "de")}, oder unter ${glucoseFloor(GLUCOSE_HYPO_FLOOR, "de")} mit Symptomen, oder ≥ ${glucoseFloor(GLUCOSE_HYPER_FLOOR, "de")} mit Symptomen), anhaltendes Fieber ≥ ${temperatureFloor(FEVER_RED_FLAG_C, "de")} oder Gedanken an Suizid bzw. Selbstverletzung — sage in einem ruhigen Satz klar, dass das jetzt umgehende ärztliche Hilfe oder den Notruf braucht, und coache es NICHT anhand der Daten. Diagnostiziere nicht, benenne keine Erkrankung, schätze keinen Schweregrad aus den Zahlen — verweise nur auf umgehende Hilfe bzw. den Notruf und höre auf. Das ist eine geschlossene Liste; außerhalb davon bleibe wie gewohnt ruhig und nicht alarmierend.`,
};
/**
+36 -14
View File
@@ -2,36 +2,58 @@ import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPromptBody } from "./base-system";
import { withOutputLanguage } from "./output-language";
import { instructionLocale } from "./output-language";
import {
applyDisplayTransform,
DEFAULT_UNIT_PREFERENCE,
getDisplayTransform,
type UnitPreference,
} from "@/lib/measurements/display-transform";
const WEIGHT_SECTION_DE = `METRIK — GEWICHT:
function weightSectionDe(q: (kg: number) => string): string {
return `METRIK — GEWICHT:
- Der Snapshot trägt weight.signal (der fertige Vergleich) + weight.summary + weight.series (graded). weight.latestDayFocus zeigt den jüngsten Tageswert, den Schritt zum vorherigen Messtag und ggf. den Blutdruck desselben Tages.
- Fokus dieser Karte ist der kontinuierliche TREND und das TEMPO über die Zeit (kg/Woche, Plateaus, Meilensteine), nicht die WHO-Band-Einordnung — die trägt die BMI-Karte. Den BMI-Wert nur als Nebenbezug nennen, nie das Band als zentrale Aussage hier.
- Wochen-/Monatstrend zählt, nicht der Einzelwert: Führe mit weight.signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet. NENNE sie, rechne sie NICHT neu. Tageschwankungen von 1-2 kg (Wasser, Tageszeit) sind normal und kein Befund.
- Fokus dieser Karte ist der kontinuierliche TREND und das TEMPO über die Zeit (Tempo pro Woche, Plateaus, Meilensteine), nicht die WHO-Band-Einordnung — die trägt die BMI-Karte. Den BMI-Wert nur als Nebenbezug nennen, nie das Band als zentrale Aussage hier.
- Wochen-/Monatstrend zählt, nicht der Einzelwert: Führe mit weight.signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet. NENNE sie, rechne sie NICHT neu. Tageschwankungen von ${q(1)}-${q(2)} (Wasser, Tageszeit) sind normal und kein Befund.
- Eigenes Ziel: Trägt der Snapshot weight.target, ist das der Zielbereich, den die Person selbst gesetzt hat; weight.target.progress sagt, welche Richtung Fortschritt ist (gaining = zunehmen, losing = abnehmen, holding steady = halten). Beurteile jede Veränderung GEGEN dieses Ziel: Liegt die Person darunter, ist eine Zunahme Fortschritt und nie ein Rückschritt. Die Hinweise zu Tempo und Meilensteinen beim Abnehmen unten gelten nur, wenn Abnehmen die Fortschrittsrichtung ist oder kein Ziel vorliegt.
- Plateau: Verändert sich das Mittel über > ~2 Wochen um < 0.5 kg, ist das ein Plateau — bei einem Abnehmenden ehrlich benennen, ohne zu entmutigen.
- Tempo: Nachhaltiger Verlust liegt bei ~0.5-1.0 kg/Woche (DGE). Anhaltend > 1 kg/Woche ist eher Wasser/Muskel als Fett — vorsichtig einordnen.
- Plateau: Verändert sich das Mittel über > ~2 Wochen um < ${q(0.5)}, ist das ein Plateau — bei einem Abnehmenden ehrlich benennen, ohne zu entmutigen.
- Tempo: Nachhaltiger Verlust liegt bei ~${q(0.5)}-${q(1)} pro Woche (DGE). Anhaltend > ${q(1)} pro Woche ist eher Wasser/Muskel als Fett — vorsichtig einordnen.
- Meilensteine gegen das eigene Maximum: ~5 % Verlust vom höchsten Wert (series-Max) bringt bereits metabolischen Nutzen, ~10 % einen deutlichen — wenn erreicht, ausdrücklich anerkennen.
- BMI-Bezug nur als knapper Nebensatz, wenn die Größe im Profil vorliegt (sonst nicht behaupten); die WHO-Band-Einordnung selbst gehört auf die BMI-Karte, nicht hierher.
- Zusammenhänge: weightVsSystolic.correlation / weightVsMeanBloodPressure.correlation und moodContext.moodVsWeightCorrelation nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache.
- Eine Botschaft: Schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. bei einem Plateau zur selben Tageszeit wiegen, um den echten Trend zu sehen, statt am Tagesrauschen zu hängen). Ist der Trend stabil und gibt es nichts Sinnvolles zu tun, erkenne das ehrlich an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`;
}
const WEIGHT_SECTION_EN = `METRIC — WEIGHT:
function weightSectionEn(q: (kg: number) => string): string {
return `METRIC — WEIGHT:
- The snapshot carries weight.signal (the finished comparison) + weight.summary + weight.series (graded). weight.latestDayFocus shows the latest daily value, the step from the previous measured day and, where present, the same-day blood pressure.
- This card's focus is the continuous TREND and PACE over time (kg/week, plateaus, milestones), not WHO-band placement — the BMI card carries that. Mention the BMI value only as a side reference, never make the band the central message here.
- The weekly/monthly trend matters, not the single value: lead from weight.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Day-to-day swings of 1-2 kg (water, time of day) are normal and not a finding.
- This card's focus is the continuous TREND and PACE over time (pace per week, plateaus, milestones), not WHO-band placement — the BMI card carries that. Mention the BMI value only as a side reference, never make the band the central message here.
- The weekly/monthly trend matters, not the single value: lead from weight.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Day-to-day swings of ${q(1)}-${q(2)} (water, time of day) are normal and not a finding.
- Own target: when the snapshot carries weight.target, that is the target band the person set themselves, and weight.target.progress says which way is progress (gaining, losing, or holding steady). Judge every change AGAINST that target: below it, a gain is progress and never a setback or a regression. The loss pace and milestone guidance below applies only when losing is the progress direction or no target is set.
- Plateau: if the mean moves < 0.5 kg over > ~2 weeks, that is a plateau — name it honestly for someone who is losing, without discouraging.
- Pace: sustainable loss is ~0.5-1.0 kg/week (DGE). Sustained > 1 kg/week is more likely water/muscle than fat — frame cautiously.
- Plateau: if the mean moves < ${q(0.5)} over > ~2 weeks, that is a plateau — name it honestly for someone who is losing, without discouraging.
- Pace: sustainable loss is ~${q(0.5)}-${q(1)} per week (DGE). Sustained > ${q(1)} per week is more likely water/muscle than fat — frame cautiously.
- Milestones against their own maximum: ~5% loss from the highest value (series max) already brings metabolic benefit, ~10% a substantial one — acknowledge explicitly when reached.
- BMI reference only as a brief aside when height is in the profile (do not claim it otherwise); the WHO-band placement itself belongs on the BMI card, not here.
- Associations: mention weightVsSystolic.correlation / weightVsMeanBloodPressure.correlation and moodContext.moodVsWeightCorrelation only when present and |r| > 0.4 — as an association, never a cause.
- One message: close with ONE doable step that fits the direction ONLY when the finding implies one (e.g. on a plateau, weigh at the same time of day to see the real trend rather than the daily noise). When the trend is steady and there is nothing useful to do, affirm it honestly and name one thing worth keeping an eye on instead of manufacturing a step.`;
}
export function getWeightSystemPrompt(locale: Locale): string {
export function getWeightSystemPrompt(
locale: Locale,
/**
* The reader's metric/imperial choice. The snapshot is in that unit, so
* the swing, plateau and pace thresholds are stated in it too.
*/
system: UnitPreference = DEFAULT_UNIT_PREFERENCE,
): string {
const mass = getDisplayTransform("WEIGHT", system);
const q = (kg: number) =>
`${applyDisplayTransform(kg, mass)} ${mass.displayUnit}`;
// fr/es/it/pl compose the ENGLISH body (the base prompt names their
// language and appends their own directive); only de takes the German one.
const section =
instructionLocale(locale) === "en" ? WEIGHT_SECTION_EN : WEIGHT_SECTION_DE;
instructionLocale(locale) === "en"
? weightSectionEn(q)
: weightSectionDe(q);
return withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
@@ -64,12 +86,12 @@ export function getWeightUserPrompt(
: "";
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey}${openerLine}
Write one short assessment of this person's weight. Open with what the trend MEANS in plain words — the direction and momentum, not the number (e.g. "easing down steadily", "holding right where it's settled") — then bring in ONE concrete number from the snapshot right after as support, read as a continuous trend and pace against their own weekly/monthly baseline (kg/week, plateau, milestone — not the single value, and not the WHO band, which the BMI card covers); never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}
Write one short assessment of this person's weight. Open with what the trend MEANS in plain words — the direction and momentum, not the number (e.g. "easing down steadily", "holding right where it's settled") — then bring in ONE concrete number from the snapshot right after as support, read as a continuous trend and pace against their own weekly/monthly baseline (pace per week, plateau, milestone — not the single value, and not the WHO band, which the BMI card covers); never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey}${openerLine}
Schreibe eine kurze Einschätzung zum Gewicht dieser Person. Beginne mit der BEDEUTUNG in klaren Worten — Richtung und Tempo, nicht der Zahl (z. B. "geht ruhig nach unten", "hält sich genau da, wo es sich eingependelt hat") — und bring danach EINE konkrete Zahl aus dem Snapshot als Beleg, als kontinuierlichen Trend und Tempo gegen die eigene Wochen-/Monats-Baseline gelesen (kg/Woche, Plateau, Meilenstein — nicht der Einzelwert und nicht das WHO-Band, das die BMI-Karte trägt); führe nie mit dem Wert. Schließe nur dann mit einem machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
Schreibe eine kurze Einschätzung zum Gewicht dieser Person. Beginne mit der BEDEUTUNG in klaren Worten — Richtung und Tempo, nicht der Zahl (z. B. "geht ruhig nach unten", "hält sich genau da, wo es sich eingependelt hat") — und bring danach EINE konkrete Zahl aus dem Snapshot als Beleg, als kontinuierlichen Trend und Tempo gegen die eigene Wochen-/Monats-Baseline gelesen (Tempo pro Woche, Plateau, Meilenstein — nicht der Einzelwert und nicht das WHO-Band, das die BMI-Karte trägt); führe nie mit dem Wert. Schließe nur dann mit einem machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
+5
View File
@@ -85,6 +85,11 @@ const TBR_LEVEL1_MAX = 70; // low: G < 70 (level-1 sub-band is 54–69)
const TAR_LEVEL1_MIN = 180; // high: G > 180 (level-1 sub-band is 181–250)
const TAR_LEVEL2_MIN = 250; // very high: G > 250
// Time-in-range target band is [70, 180] inclusive.
/** The time-in-range band, mg/dL, inclusive at both ends. */
export const TIME_IN_RANGE_MGDL = {
low: TBR_LEVEL1_MAX,
high: TAR_LEVEL1_MIN,
} as const;
/** Monnier 2017 variability cutoff: CV% ≥ 36 is "unstable". */
export const CV_INSTABILITY_THRESHOLD = 36;
@@ -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(
@@ -438,3 +438,50 @@ describe("generateWeightStatusForUser — judged against the stored target (#100
expect(snapshot.weight.signal.direction).toBe("target-band");
});
});
describe("generateWeightStatusForUser — the reader's mass unit", () => {
it("hands an imperial reader every weight figure and threshold in pounds", async () => {
const now = new Date();
const records = Array.from({ length: 40 }, (_, day) => ({
type: "WEIGHT",
value: 80,
measuredAt: new Date(now.getTime() - day * dayMs),
}));
vi.mocked(prisma.user.findUnique).mockResolvedValue({
thresholdsJson: { WEIGHT: { min: 65, max: 70 } },
unitPreference: "imperial",
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
let sent: { systemPrompt: string; userPrompt: string } | null = null;
vi.mocked(runStatusCompletion).mockImplementation(async (args) => {
sent = args as never;
return {
kind: "ok",
content: '{"summary":"OK"}',
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
});
await generateWeightStatusForUser("user-1", { locale: "en" });
const snapshot = JSON.parse(sent!.userPrompt.match(/\{[\s\S]*\}/)![0]);
expect(snapshot.weight.unit).toBe("lb");
expect(snapshot.weight.signal.unit).toBe("lb");
expect(snapshot.weight.latestDayFocus.value).toBe(176.4);
expect(snapshot.weight.summary.mean).toBe(176.4);
expect(snapshot.weight.series.recent[0].mean).toBe(176.4);
expect(snapshot.weight.target).toMatchObject({
min: 143.3,
max: 154.3,
unit: "lb",
});
expect(JSON.stringify(snapshot.weight)).not.toContain("kg");
// The card's own thresholds are stated in pounds as well.
expect(sent!.systemPrompt).toContain("< 1.1 lb over");
expect(sent!.systemPrompt).toContain("~1.1 lb-2.2 lb per week");
expect(sent!.systemPrompt).not.toMatch(/\d kg/);
});
});
+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"
@@ -74,10 +74,13 @@ function promptHash(locale: "de" | "en"): string {
* Hashes captured from the prompts as they stood at NARRATIVE_PROMPT_VERSION
* 1.13.0, before the output-language change. If either moves, the de or en
* prompt drifted — which this change promised would not happen.
*
* Re-captured once since: the shared acute red-flag clause now states its
* glucose and fever floors in both units.
*/
const FROZEN_PROMPT_HASHES: Record<"de" | "en", string> = {
de: "be667f16beeb44f51a81fedd929716b2249579fe914b45bb8cba0cd9e64a8c6d",
en: "bb0b080e21a2b9729bdf0047ae221c326ec29ecd74d0999f19ea8deabcbf078a",
de: "1bb2246e991fcc28f9a009996284529beebf0d469b29b157cbad273b822626c6",
en: "d34fa0fcb4a855f805f1a30338d309c19b07d5a54533f75de412cbe107b4f772",
};
describe("period-narrative output language", () => {
+5 -3
View File
@@ -202,8 +202,7 @@ export function computeSignalsOfDay(
"mmHg",
),
);
// Glucose uses the canonical stored value (the model reads the snapshot,
// not display units); absent data simply produces no signal.
// Absent data simply produces no signal.
push(buildSignal("glucose", "blood glucose", byType("BLOOD_GLUCOSE"), now));
push(
buildSignal(
@@ -215,7 +214,10 @@ export function computeSignalsOfDay(
),
);
push(buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm"));
push(buildSignal("weight", "weight", byType("WEIGHT"), now, "kg"));
// Weight and glucose are canonical here and carry no unit: the reader's
// unit is attached where the signal is converted for the prompt
// (`featuresInReaderUnits`), so no canonical symbol can leak into one.
push(buildSignal("weight", "weight", byType("WEIGHT"), now));
// Sleep is stored one row per stage per night, so a raw "latest" point
// would mis-sum; the sleep aggregates carry that signal already. Steps
// ingest as many intraday `stats:`-prefixed samples, so the newest raw row
+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
+54 -25
View File
@@ -30,8 +30,16 @@ import {
import {
buildGradedSeriesFromPoints,
buildGradedSeriesWithRollups,
convertGradedSeries,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getReadingTransform,
resolveUnitPreferences,
transformRescales,
} from "@/lib/measurements/display-transform";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
@@ -236,20 +244,27 @@ export async function prepareWeightStatusForUser(
// v1.39 (#1006) — the person's own weight target decides which way is
// progress. Folded into the input fingerprint so editing the target flips
// the gate even when no weight row moved.
const weightTarget = resolveWeightTargetOverride(
(
await prisma.user.findUnique({
where: { id: userId },
select: { thresholdsJson: true },
})
)?.thresholdsJson,
);
const userRow = await prisma.user.findUnique({
where: { id: userId },
select: { thresholdsJson: true, unitPreference: true },
});
const weightTarget = resolveWeightTargetOverride(userRow?.thresholdsJson);
// The note is written in the reader's mass unit. Every weight figure the
// snapshot carries is converted once, after the canonical arithmetic; the
// correlations are scale-free and stay as computed.
const units = resolveUnitPreferences({
unitPreference: userRow?.unitPreference,
});
const mass = getReadingTransform("WEIGHT", units);
const toMass = (kg: number) => applyDisplayTransform(kg, mass);
const inputHash = await computeStatusInputFingerprint({
userId,
types: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
extra: {
weightTargetMin: weightTarget?.min ?? null,
weightTargetMax: weightTarget?.max ?? null,
// A unit switch changes every figure the note quotes.
unitPreference: units.system,
},
includeMood: true,
// v1.18.11 (P6-tighten) — WEIGHT is an FDR discovery OUTCOME channel and
@@ -370,11 +385,14 @@ export async function prepareWeightStatusForUser(
// the MONTH / YEAR rollup tier (with a full-history in-memory fallback
// on a cold-tier coverage miss). Mood has no rollup tier, so it stays
// an in-memory fold.
const [weightGraded, sysGraded, diaGraded] = await Promise.all([
const [canonicalWeightGraded, sysGraded, diaGraded] = await Promise.all([
buildGradedSeriesWithRollups(userId, "WEIGHT", now, userTz),
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now, userTz),
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now, userTz),
]);
const weightGraded = transformRescales(mass)
? convertGradedSeries(canonicalWeightGraded, mass, mass.decimals)
: canonicalWeightGraded;
const moodGraded = buildGradedSeriesFromPoints(
moodSeries.daily.map((b) => ({
measuredAt: canonicalDailyTimestamp(
@@ -473,17 +491,21 @@ export async function prepareWeightStatusForUser(
const recentWeights = weightSeries.daily
.filter((bucket) => bucket.dayOffset < 7)
.map((bucket) => bucket.value);
const weightTargetFeature = buildWeightTargetFeature(weightTarget, {
avg7:
recentWeights.length > 0
? recentWeights.reduce((sum, value) => sum + value, 0) /
recentWeights.length
: null,
latest: latestWeight?.value ?? null,
});
const weightTargetFeature = buildWeightTargetFeature(
weightTarget,
{
avg7:
recentWeights.length > 0
? recentWeights.reduce((sum, value) => sum + value, 0) /
recentWeights.length
: null,
latest: latestWeight?.value ?? null,
},
units.system,
);
const weightSignal = buildMetricSignal({
metric: locale === "en" ? "your weight" : "dein Gewicht",
unit: "kg",
unit: mass.displayUnit,
direction:
weightTargetFeature?.position === "below"
? "higher-better"
@@ -504,21 +526,25 @@ export async function prepareWeightStatusForUser(
newestMeasurementDaysAgo,
},
weight: {
unit: mass.displayUnit,
...(weightSignal ? { signal: weightSignal } : {}),
...(weightTargetFeature ? { target: weightTargetFeature } : {}),
summary: summarizeSeries(
weightSeries.daily.map((bucket) => ({ value: bucket.value })),
weightSeries.daily.map((bucket) => ({ value: toMass(bucket.value) })),
),
series: weightGraded,
latestDayFocus: latestWeight
? {
day: latestWeightDay,
dayOffset: latestWeight.dayOffset,
value: latestWeight.value,
value: toMass(latestWeight.value),
deltaToPreviousDailyPoint:
previousWeight == null
? null
: round(latestWeight.value - previousWeight.value, 2),
: applyDisplayTransformDelta(
round(latestWeight.value - previousWeight.value, 2),
mass,
),
sameDayBloodPressure: sameDayBp,
}
: null,
@@ -547,7 +573,7 @@ export async function prepareWeightStatusForUser(
.slice(-CORRELATION_PAIR_CAP)
.map((entry) => ({
day: entry.dayKey,
weight: round(entry.a, 2),
weight: toMass(round(entry.a, 2)),
systolic: round(entry.b, 2),
})),
},
@@ -555,7 +581,7 @@ export async function prepareWeightStatusForUser(
correlation: weightVsMeanBpCorrelation,
pairs: weightVsMeanBpPairs.slice(-CORRELATION_PAIR_CAP).map((entry) => ({
day: entry.dayKey,
weight: round(entry.a, 2),
weight: toMass(round(entry.a, 2)),
meanBloodPressure: round(entry.b, 2),
})),
},
@@ -626,7 +652,10 @@ export async function prepareWeightStatusForUser(
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
const varietyLead = pickVarietyLead(userId, "weight", todayKey);
const steadyRun = computeSteadyRun(weightGraded.weekly, weightGraded.monthly);
const steadyRun = computeSteadyRun(
canonicalWeightGraded.weekly,
canonicalWeightGraded.monthly,
);
const relations = await getRelevantCorrelationsForMetric(
userId,
"WEIGHT",
@@ -654,7 +683,7 @@ export async function prepareWeightStatusForUser(
metric: "weight",
userId,
cacheAction,
systemPrompt: getWeightSystemPrompt(locale),
systemPrompt: getWeightSystemPrompt(locale, units.system),
userPrompt: getWeightUserPrompt(
snapshotJson,
todayKey,
@@ -0,0 +1,80 @@
/**
* The datum a reaction line reacts to is handed to the model in the reader's
* units, so the line cannot quote kilograms to a reader on pounds.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const measurementFindMany = vi.fn();
const workoutFindFirst = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: (...a: unknown[]) => measurementFindMany(...a),
},
workout: { findFirst: (...a: unknown[]) => workoutFindFirst(...a) },
},
}));
import { loadArrivalEvidence } from "../reaction-line";
const row = { occurredAt: new Date("2026-09-30T07:00:00Z"), refId: "w1" };
const imperial = {
timezone: "UTC",
sourcePriorityJson: null,
unitPreference: "imperial",
glucoseUnit: null,
};
beforeEach(() => {
measurementFindMany.mockReset();
workoutFindFirst.mockReset();
});
describe("loadArrivalEvidence — the reader's units", () => {
it("states a new weight in pounds for an imperial reader", async () => {
measurementFindMany.mockResolvedValue([{ type: "WEIGHT", value: 80 }]);
const evidence = await loadArrivalEvidence(
{ userId: "u1", kind: "weight", localDate: "2026-09-30", revision: "r" },
row,
imperial,
);
expect(evidence).toBe("- Newly arrived reading: WEIGHT 176.4 lb.");
});
it("states blood pressure in mmHg for every reader", async () => {
measurementFindMany.mockResolvedValue([
{ type: "BLOOD_PRESSURE_DIA", value: 80 },
{ type: "BLOOD_PRESSURE_SYS", value: 125 },
]);
const evidence = await loadArrivalEvidence(
{
userId: "u1",
kind: "blood_pressure",
localDate: "2026-09-30",
revision: "r",
},
row,
imperial,
);
expect(evidence).toContain("BLOOD_PRESSURE_SYS 125 mmHg.");
});
it("states a workout distance in miles, not metres", async () => {
measurementFindMany.mockResolvedValue([]);
workoutFindFirst.mockResolvedValue({
sportType: "running",
durationSec: 1800,
totalDistanceM: 5000,
totalEnergyKcal: 300,
avgHeartRate: 150,
});
const evidence = await loadArrivalEvidence(
{ userId: "u1", kind: "workout", localDate: "2026-09-30", revision: "r" },
row,
imperial,
);
expect(evidence).toContain("distance 3.11 mi;");
expect(evidence).not.toContain("metres");
});
});
+27 -8
View File
@@ -25,6 +25,12 @@
* The deterministic digest adds only the same computed context rendered next
* to the reaction.
*/
import {
applyDisplayTransform,
getQuantityTransform,
getReadingTransform,
resolveUnitPreferences,
} from "@/lib/measurements/display-transform";
import type { Job } from "pg-boss";
import { prisma } from "@/lib/db";
@@ -140,11 +146,19 @@ export function sanitiseReactionLine(
* intentionally a narrow, labels-and-numbers-only projection: no notes or
* other free text can enter the provider prompt through this path.
*/
async function loadArrivalEvidence(
export async function loadArrivalEvidence(
job: ReactionLineJob,
row: { occurredAt: Date; refId: string | null },
user: { timezone: string; sourcePriorityJson: unknown },
user: {
timezone: string;
sourcePriorityJson: unknown;
unitPreference: string | null;
glucoseUnit: string | null;
},
): Promise<string> {
// The line is written in the reader's units, so the new datum it reacts to
// is handed over in them.
const units = resolveUnitPreferences(user);
if (job.kind === "weight" || job.kind === "blood_pressure") {
const types =
job.kind === "weight"
@@ -157,15 +171,15 @@ async function loadArrivalEvidence(
measuredAt: row.occurredAt,
deletedAt: null,
},
select: { type: true, value: true, unit: true },
select: { type: true, value: true },
orderBy: { type: "asc" },
});
if (readings.length > 0) {
return readings
.map(
(reading) =>
`- Newly arrived reading: ${reading.type} ${reading.value} ${reading.unit}.`,
)
.map((reading) => {
const transform = getReadingTransform(reading.type, units);
return `- Newly arrived reading: ${reading.type} ${applyDisplayTransform(reading.value, transform)} ${transform.displayUnit}.`;
})
.join("\n");
}
}
@@ -241,7 +255,12 @@ async function loadArrivalEvidence(
},
});
if (workout) {
return `- Newly arrived workout: ${workout.sportType}; duration ${workout.durationSec} seconds; distance ${workout.totalDistanceM ?? "not reported"} metres; energy ${workout.totalEnergyKcal ?? "not reported"} kcal; average heart rate ${workout.avgHeartRate ?? "not reported"} bpm.`;
const distance = getQuantityTransform("distance", units.system);
const distanceText =
workout.totalDistanceM == null
? "not reported"
: `${applyDisplayTransform(workout.totalDistanceM, distance)} ${distance.displayUnit}`;
return `- Newly arrived workout: ${workout.sportType}; duration ${workout.durationSec} seconds; distance ${distanceText}; energy ${workout.totalEnergyKcal ?? "not reported"} kcal; average heart rate ${workout.avgHeartRate ?? "not reported"} bpm.`;
}
}
@@ -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,
@@ -8,6 +8,7 @@
* Then pins that `show_result` reaches a stored table only inside the
* conversation it was written to.
*/
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -106,6 +107,7 @@ describe("get_metric_table — the chart's numbers", () => {
timeZone: TZ,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: NOW,
});
expect(table).not.toBeNull();
@@ -162,6 +164,7 @@ describe("get_metric_table — the chart's numbers", () => {
timeZone: TZ,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: NOW,
});
const range = resolveTableRange({
@@ -280,6 +283,7 @@ describe("get_metric_table — all time", () => {
timeZone: WEST,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: WEST_NOW,
});
@@ -338,6 +342,7 @@ describe("show_result — only within the conversation", () => {
timeZone: TZ,
locale: "en",
ref: "r1",
units: DEFAULT_UNIT_PREFERENCES,
now: NOW,
})) as CoachResultTable;
const {