From 6dbf57f4f438531ff6310df865fe2eb81206db0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 10:48:57 +0200 Subject: [PATCH 1/9] fix(ai): name the acute glucose and fever floors in both units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acute red-flag clause stated the glucose floors only in mg/dL and the sustained-fever floor only in °C. The clause is matched against what the person writes, so a reading of 2.9 mmol/L or 103 °F did not meet any of its numbers. Each floor now carries both units, mg/dL and °C first, in the English and German contracts. Refs #1067 --- .../shared-contracts-coverage.test.ts | 13 ++++++++++ src/lib/ai/prompts/shared-contracts.ts | 24 +++++++++++++++++-- .../__tests__/period-narrative-locale.test.ts | 7 ++++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts index 252ff888f..7c60c41d1 100644 --- a/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts +++ b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts @@ -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)"); + }); }); diff --git a/src/lib/ai/prompts/shared-contracts.ts b/src/lib/ai/prompts/shared-contracts.ts index 9dd2e0176..a09319ac8 100644 --- a/src/lib/ai/prompts/shared-contracts.ts +++ b/src/lib/ai/prompts/shared-contracts.ts @@ -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; @@ -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.`, }; /** diff --git a/src/lib/insights/narrative/__tests__/period-narrative-locale.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-locale.test.ts index c2531088e..0b9bed96e 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-locale.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-locale.test.ts @@ -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", () => { From d1b2539b1f63cb68e6bd2ed4eca17ec6a20a4b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 10:52:47 +0200 Subject: [PATCH 2/9] fix(coach): state reference and time-in-range bands in the reader's units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference grounding printed the fasting glucose bands in mg/dL and the body-temperature band in °C, and the system prompt described time in range as 70-180 mg/dL. For a reader on mmol/L or Fahrenheit the model was handed one quantity in two units: the glucose block in mmol/L, the band beside it in mg/dL. The grounding block now takes both unit preferences and prints the general, the diabetes goal and the temperature band in them, while the placement is still classified on the canonical value. The glucose block carries the time-in-range band in its own unit, and the prompt points the model at it instead of naming a unit. Refs #1067 --- .../__tests__/reference-grounding.test.ts | 33 ++++++++ .../ai/coach/__tests__/system-prompt.test.ts | 7 ++ src/lib/ai/coach/reference-grounding.ts | 81 ++++++++++++++++--- .../__tests__/glucose-block-units.test.ts | 62 ++++++++++++++ .../ai/coach/snapshot-blocks/glucose-block.ts | 7 ++ src/lib/ai/coach/snapshot.ts | 12 ++- src/lib/ai/coach/system-prompt.ts | 3 +- src/lib/analytics/glucose-metrics.ts | 5 ++ 8 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 src/lib/ai/coach/snapshot-blocks/__tests__/glucose-block-units.test.ts diff --git a/src/lib/ai/coach/__tests__/reference-grounding.test.ts b/src/lib/ai/coach/__tests__/reference-grounding.test.ts index 8881d7b52..f867e815c 100644 --- a/src/lib/ai/coach/__tests__/reference-grounding.test.ts +++ b/src/lib/ai/coach/__tests__/reference-grounding.test.ts @@ -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. diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts index 50208afb9..41f47706c 100644 --- a/src/lib/ai/coach/__tests__/system-prompt.test.ts +++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts @@ -113,6 +113,13 @@ 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("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/); diff --git a/src/lib/ai/coach/reference-grounding.ts b/src/lib/ai/coach/reference-grounding.ts index 00af268fe..166405427 100644 --- a/src/lib/ai/coach/reference-grounding.ts +++ b/src/lib/ai/coach/reference-grounding.ts @@ -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> = + { + 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. diff --git a/src/lib/ai/coach/snapshot-blocks/__tests__/glucose-block-units.test.ts b/src/lib/ai/coach/snapshot-blocks/__tests__/glucose-block-units.test.ts new file mode 100644 index 000000000..d42febc45 --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/__tests__/glucose-block-units.test.ts @@ -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 = {}; + 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 }); + }); +}); diff --git a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts index cd8ac806f..e95d4e3b3 100644 --- a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts @@ -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): 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, diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index be95be3fc..bcc502e9f 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -23,7 +23,7 @@ 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 } 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"; @@ -303,6 +303,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 +329,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 @@ -1206,6 +1213,7 @@ async function buildCoachSnapshotImpl( const referenceGrounding = buildReferenceGroundingBlock({ metrics: groundingMetrics, hasDiabetes: prefsRow?.hasDiabetes ?? false, + units, }); if (referenceGrounding) { annotate({ diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 939b0ae4c..d1e9427fe 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -382,7 +382,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. diff --git a/src/lib/analytics/glucose-metrics.ts b/src/lib/analytics/glucose-metrics.ts index 94a7d833f..332ef484f 100644 --- a/src/lib/analytics/glucose-metrics.ts +++ b/src/lib/analytics/glucose-metrics.ts @@ -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; From a7e6b89672628bf4e867e9f7365549df152f375a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 12:54:41 +0200 Subject: [PATCH 3/9] fix(insights): hand the briefing its figures in the reader's units The daily briefing serialised the aggregated feature set as computed: weight, grip strength and the weight target in kilograms, waist in centimetres, glucose and the signals of the day in mg/dL (the glucose signal carried no unit at all), workout distances in kilometres. A reader on pounds or mmol/L got a briefing in units they do not use. One step now converts the feature set after all canonical arithmetic, on both the nightly and the on-demand path: absolute values through the full transform, differences and slopes through the factor alone, every converted block labelled, workout distances under unit-neutral keys. The prompt, the stored fingerprint and the grounding check all read the converted set, so a unit switch moves the fingerprint and the grounding check compares the reply against the figures the model was shown. The weight target sentence, the comparison block and the GLP-1 plateau context quote weight in the reader's unit; the comparison block also labelled sleep as hours while carrying minutes. A unit switch now also re-warms a cached briefing alongside the status notes and narratives. Refs #1067 --- .../insights/generate/__tests__/route.test.ts | 41 +++- src/app/api/insights/generate/route.ts | 19 +- .../comparison-snapshot-window.test.ts | 38 +++ .../comprehensive-briefing-reroll.test.ts | 10 +- .../comprehensive-generate-force.test.ts | 68 +++++- .../insights/__tests__/features-units.test.ts | 205 ++++++++++++++++ src/lib/insights/__tests__/features.test.ts | 5 +- .../insights/__tests__/glp1-plateau.test.ts | 17 ++ .../__tests__/unit-change-refresh.test.ts | 32 +++ src/lib/insights/comprehensive-generate.ts | 56 ++++- src/lib/insights/features-units.ts | 228 ++++++++++++++++++ src/lib/insights/glp1-plateau.ts | 14 +- src/lib/insights/unit-change-refresh.ts | 26 +- .../targets/__tests__/weight-trend.test.ts | 27 +++ src/lib/targets/weight-trend.ts | 29 ++- 15 files changed, 794 insertions(+), 21 deletions(-) create mode 100644 src/lib/insights/__tests__/features-units.test.ts create mode 100644 src/lib/insights/features-units.ts diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index c81ed66ff..f77381dad 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -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 diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 266d77959..2180ace0c 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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 diff --git a/src/lib/insights/__tests__/comparison-snapshot-window.test.ts b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts index 17411e666..98adc7732 100644 --- a/src/lib/insights/__tests__/comparison-snapshot-window.test.ts +++ b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts @@ -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, + }); + }); }); diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index e40fa860c..b805f1e75 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -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), + features: compactSections( + featuresInReaderUnits( + FEATURES as never, + DEFAULT_UNIT_PREFERENCES, + ) as unknown as Record, + ), aboutMe: null, comparisonBaseline: "none", generationLocale: "de", diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index c9165b0c9..1bf266d3f 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -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), + features: compactSections( + featuresInReaderUnits( + FEATURES as never, + DEFAULT_UNIT_PREFERENCES, + ) as unknown as Record, + ), 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 }).data.insightsCachedText !== + undefined, + ); + const data = (write![0] as { data: Record }).data; + expect(data.insightsSnapshotHash).toBe( + hashInsightSnapshot({ + features: compactSections( + featuresInReaderUnits(FEATURES as never, { + system: "imperial", + glucoseUnit: "mmol/L", + }) as unknown as Record, + ), + 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", diff --git a/src/lib/insights/__tests__/features-units.test.ts b/src/lib/insights/__tests__/features-units.test.ts new file mode 100644 index 000000000..4f706ccd5 --- /dev/null +++ b/src/lib/insights/__tests__/features-units.test.ts @@ -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>; + }; + 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); + }); +}); diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 39cda5375..94ad4694e 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -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", }); diff --git a/src/lib/insights/__tests__/glp1-plateau.test.ts b/src/lib/insights/__tests__/glp1-plateau.test.ts index 53ad62cb3..4ee91d935 100644 --- a/src/lib/insights/__tests__/glp1-plateau.test.ts +++ b/src/lib/insights/__tests__/glp1-plateau.test.ts @@ -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( { diff --git a/src/lib/insights/__tests__/unit-change-refresh.test.ts b/src/lib/insights/__tests__/unit-change-refresh.test.ts index 15a0eac27..40fdc851c 100644 --- a/src/lib/insights/__tests__/unit-change-refresh.test.ts +++ b/src/lib/insights/__tests__/unit-change-refresh.test.ts @@ -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( diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 79d81f52d..dd98110c5 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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 { 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 = { - 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, ); @@ -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); diff --git a/src/lib/insights/features-units.ts b/src/lib/insights/features-units.ts new file mode 100644 index 000000000..874142a82 --- /dev/null +++ b/src/lib/insights/features-units.ts @@ -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> = { + 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, + units: UnitPreferences, +): NonNullable & { 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 = { ...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["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; +} diff --git a/src/lib/insights/glp1-plateau.ts b/src/lib/insights/glp1-plateau.ts index 3bdc36ea3..ee0af43ee 100644 --- a/src/lib/insights/glp1-plateau.ts +++ b/src/lib/insights/glp1-plateau.ts @@ -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" diff --git a/src/lib/insights/unit-change-refresh.ts b/src/lib/insights/unit-change-refresh.ts index de820ceed..f186aec85 100644 --- a/src/lib/insights/unit-change-refresh.ts +++ b/src/lib/insights/unit-change-refresh.ts @@ -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 diff --git a/src/lib/targets/__tests__/weight-trend.test.ts b/src/lib/targets/__tests__/weight-trend.test.ts index 5aaf8e667..b7e99fd8e 100644 --- a/src/lib/targets/__tests__/weight-trend.test.ts +++ b/src/lib/targets/__tests__/weight-trend.test.ts @@ -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"); + }); +}); diff --git a/src/lib/targets/weight-trend.ts b/src/lib/targets/weight-trend.ts index df44dd17b..cbb32f3aa 100644 --- a/src/lib/targets/weight-trend.ts +++ b/src/lib/targets/weight-trend.ts @@ -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, From ca5ce518d8b9aa632dc0b07f91d9aa41b6434e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 12:55:13 +0200 Subject: [PATCH 4/9] fix(coach): state the snapshot's weight, body and distance figures in the reader's units The Coach snapshot carried weight, body composition, temperatures and walking speed and distance as stored: kilograms, degrees Celsius, metres per second and metres, with no unit on the block. The evidence-block rules then listed kg and both glucose units as the units to cite. A reader on pounds, Fahrenheit or miles was answered in the units they do not use. Every value block now converts its timeline (and the weight block its aggregate and coarse tail) into the reader's units after aggregating on the canonical value, and names the unit on the block; the grounding scalars stay canonical. Workout distances arrive in kilometres or miles with the unit named once. The prompt in every locale tells the model that a block's figures are already in the reader's unit and takes the evidence-block unit from the block instead of a fixed list. Refs #1067 --- src/lib/ai/coach/__tests__/snapshot.test.ts | 68 ++++++++++++++++++- .../ai/coach/__tests__/system-prompt.test.ts | 36 ++++++++-- .../__tests__/workouts-block-units.test.ts | 54 +++++++++++++++ .../snapshot-blocks/core-metrics-block.ts | 34 +++++++--- .../snapshot-blocks/value-series-blocks.ts | 41 ++++++++--- .../coach/snapshot-blocks/workouts-block.ts | 15 +++- src/lib/ai/coach/snapshot-series.ts | 60 ++++++++++++++++ src/lib/ai/coach/snapshot.ts | 3 + src/lib/ai/coach/system-prompt.ts | 19 ++++-- src/lib/ai/prompts/native-prompts.ts | 12 ++-- 10 files changed, 307 insertions(+), 35 deletions(-) create mode 100644 src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index e2355d6e8..4ca883b76 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -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": [ { @@ -657,6 +674,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: { diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts index 41f47706c..d40440bc0 100644 --- a/src/lib/ai/coach/__tests__/system-prompt.test.ts +++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts @@ -120,6 +120,15 @@ describe("getCoachSystemPrompt — EN", () => { 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/); @@ -204,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)", () => { @@ -310,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/); + }, + ); +}); diff --git a/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts b/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts new file mode 100644 index 000000000..9ed3415a4 --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts @@ -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 = {}; + 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>; + }; +} + +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 }); + }); +}); diff --git a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts index 2a3e23493..eda25227a 100644 --- a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts @@ -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; registerBlock: (key: string, source: CoachScopeSource) => void; groundingValues: Map; + /** 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"); diff --git a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts index df6c25b88..a31da2d04 100644 --- a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts +++ b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts @@ -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; registerBlock: (key: string, source: CoachScopeSource) => void; groundingValues: Map; + /** 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 = new Set(["", "unknown", "score"]); + export function buildValueSeriesBlocks( ctx: Readonly, ): 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; diff --git a/src/lib/ai/coach/snapshot-blocks/workouts-block.ts b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts index 2a396428d..c44a8be52 100644 --- a/src/lib/ai/coach/snapshot-blocks/workouts-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts @@ -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; counts: NonNullable; registerBlock: (key: string, source: CoachScopeSource) => void; + /** The reader's metric/imperial choice; distances are stated in it. */ + unitPreference: UnitPreference; } export function buildWorkoutsBlock(ctx: Readonly): void { @@ -68,7 +75,9 @@ export function buildWorkoutsBlock(ctx: Readonly): 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): 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): void { })) .sort((a, b) => b.count - a.count); snapshot.workouts = { + distanceUnit: distanceTransform.displayUnit, recent: recentList, perSport: sportRollup, totalInWindow: workoutRows.length, diff --git a/src/lib/ai/coach/snapshot-series.ts b/src/lib/ai/coach/snapshot-series.ts index 4df20de6e..f06fc3328 100644 --- a/src/lib/ai/coach/snapshot-series.ts +++ b/src/lib/ai/coach/snapshot-series.ts @@ -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, diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index bcc502e9f..4f7ed8b79 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -882,6 +882,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 @@ -911,6 +912,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 @@ -976,6 +978,7 @@ async function buildCoachSnapshotImpl( metrics, counts, registerBlock, + unitPreference: units.system, }); } diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index d1e9427fe..349a100a3 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -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, @@ -460,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) @@ -917,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, @@ -987,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 diff --git a/src/lib/ai/prompts/native-prompts.ts b/src/lib/ai/prompts/native-prompts.ts index 8f8e76d77..aa0a79503 100644 --- a/src/lib/ai/prompts/native-prompts.ts +++ b/src/lib/ai/prompts/native-prompts.ts @@ -83,21 +83,25 @@ const DAY_LEVEL_BODY: Record = { - 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 : "