From 1e9a03bee51ca6f52b0e8f158c8e9e254df45ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 12:25:30 +0200 Subject: [PATCH] feat(coach): medications + anthropometrics exclusion toggles Extend the coach exclude-metrics enum with two optional-context tokens and surface them in the settings sheet under a separate "Include context" group. The snapshot gates the GLP-1 weeklyContext block on the medications toggle (skipping the Prisma lookup entirely when excluded) and gates the new anthropometrics block on the anthropometrics toggle plus at least one non-null profile field. Empty blocks are never emitted. --- docs/api/openapi.yaml | 12 +++- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 8 ++- messages/fr.json | 8 ++- messages/it.json | 8 ++- messages/pl.json | 8 ++- .../coach-panel/coach-settings-sheet.tsx | 53 +++++++++++++++++ src/lib/ai/coach/__tests__/snapshot.test.ts | 59 +++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 56 +++++++++++++++--- .../validations/__tests__/coach-prefs.test.ts | 13 +++- src/lib/validations/coach-prefs.ts | 10 +++- 12 files changed, 225 insertions(+), 22 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5754fbd42..84daaf2dc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -353,8 +353,10 @@ components: - sleep - resting_hr - steps + - medications + - anthropometrics type: string - maxItems: 9 + maxItems: 11 type: array showEvidenceByDefault: default: false @@ -1076,8 +1078,10 @@ components: - sleep - resting_hr - steps + - medications + - anthropometrics type: string - maxItems: 9 + maxItems: 11 type: array showEvidenceByDefault: default: false @@ -1880,8 +1884,10 @@ paths: - sleep - resting_hr - steps + - medications + - anthropometrics type: string - maxItems: 9 + maxItems: 11 type: array showEvidenceByDefault: default: false diff --git a/messages/de.json b/messages/de.json index 2911299e6..bdab5ce76 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1081,7 +1081,9 @@ "flights": "Stockwerke", "distance": "Strecke", "vo2_max": "VO₂ max", - "body_temp": "Körpertemperatur" + "body_temp": "Körpertemperatur", + "medications": "Medikamente", + "anthropometrics": "Profil (Größe, Alter, Geschlecht)" }, "window": { "last7days": "letzte 7 Tage", @@ -1104,6 +1106,8 @@ "settingsVerbosityDetailed": "Ausführlich", "settingsExcludeLabel": "Metriken ausschließen", "settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.", + "settingsContextLabel": "Kontext mitschicken", + "settingsContextHint": "Optionale Blöcke, auf die sich der Coach stützen kann. Schalte einen ab, wird der Block in jeder Antwort weggelassen.", "settingsEvidenceLabel": "Belege standardmäßig zeigen", "settingsEvidenceHint": "Die Belege-Box unter jeder Antwort automatisch öffnen.", "settingsDefaultWindowLabel": "Standard-Analysezeitraum", diff --git a/messages/en.json b/messages/en.json index 428451006..f05009440 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1081,7 +1081,9 @@ "flights": "Flights climbed", "distance": "Distance", "vo2_max": "VO₂ max", - "body_temp": "Body temperature" + "body_temp": "Body temperature", + "medications": "Medications", + "anthropometrics": "Profile (height, age, gender)" }, "window": { "last7days": "last 7 days", @@ -1104,6 +1106,8 @@ "settingsVerbosityDetailed": "Detailed", "settingsExcludeLabel": "Exclude metrics", "settingsExcludeHint": "The Coach will not see data for selected metrics.", + "settingsContextLabel": "Include context", + "settingsContextHint": "Optional blocks the Coach can lean on. Switch one off and the block is dropped from every turn.", "settingsEvidenceLabel": "Show evidence by default", "settingsEvidenceHint": "Open the disclosure under every reply automatically.", "settingsDefaultWindowLabel": "Default analysis window", diff --git a/messages/es.json b/messages/es.json index ecaa6acd4..04805cd8c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1081,7 +1081,9 @@ "flights": "Flights climbed", "distance": "Distance", "vo2_max": "VO₂ max", - "body_temp": "Body temperature" + "body_temp": "Body temperature", + "medications": "Medications", + "anthropometrics": "Profile (height, age, gender)" }, "window": { "last7days": "Últimos 7 días", @@ -1117,7 +1119,9 @@ "dailyLimitTitle": "Daily limit reached", "dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.", "providerRateLimitTitle": "Provider rate-limited", - "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min." + "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.", + "settingsContextLabel": "Include context", + "settingsContextHint": "Optional blocks the Coach can lean on. Switch one off and the block is dropped from every turn." }, "relativeJustNow": "just now", "relativeMinutesAgo": "{count} minutes ago", diff --git a/messages/fr.json b/messages/fr.json index d2388dc47..8afaf44e1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1081,7 +1081,9 @@ "flights": "Flights climbed", "distance": "Distance", "vo2_max": "VO₂ max", - "body_temp": "Body temperature" + "body_temp": "Body temperature", + "medications": "Medications", + "anthropometrics": "Profile (height, age, gender)" }, "window": { "last7days": "7 derniers jours", @@ -1117,7 +1119,9 @@ "dailyLimitTitle": "Daily limit reached", "dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.", "providerRateLimitTitle": "Provider rate-limited", - "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min." + "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.", + "settingsContextLabel": "Include context", + "settingsContextHint": "Optional blocks the Coach can lean on. Switch one off and the block is dropped from every turn." }, "relativeJustNow": "just now", "relativeMinutesAgo": "{count} minutes ago", diff --git a/messages/it.json b/messages/it.json index b843fb629..5485270fa 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1081,7 +1081,9 @@ "flights": "Flights climbed", "distance": "Distance", "vo2_max": "VO₂ max", - "body_temp": "Body temperature" + "body_temp": "Body temperature", + "medications": "Medications", + "anthropometrics": "Profile (height, age, gender)" }, "window": { "last7days": "Ultimi 7 giorni", @@ -1117,7 +1119,9 @@ "dailyLimitTitle": "Daily limit reached", "dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.", "providerRateLimitTitle": "Provider rate-limited", - "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min." + "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.", + "settingsContextLabel": "Include context", + "settingsContextHint": "Optional blocks the Coach can lean on. Switch one off and the block is dropped from every turn." }, "relativeJustNow": "just now", "relativeMinutesAgo": "{count} minutes ago", diff --git a/messages/pl.json b/messages/pl.json index 9b1fcca3c..89e6390c5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1081,7 +1081,9 @@ "flights": "Flights climbed", "distance": "Distance", "vo2_max": "VO₂ max", - "body_temp": "Body temperature" + "body_temp": "Body temperature", + "medications": "Medications", + "anthropometrics": "Profile (height, age, gender)" }, "window": { "last7days": "Ostatnie 7 dni", @@ -1117,7 +1119,9 @@ "dailyLimitTitle": "Daily limit reached", "dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.", "providerRateLimitTitle": "Provider rate-limited", - "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min." + "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.", + "settingsContextLabel": "Include context", + "settingsContextHint": "Optional blocks the Coach can lean on. Switch one off and the block is dropped from every turn." }, "relativeJustNow": "just now", "relativeMinutesAgo": "{count} minutes ago", diff --git a/src/components/insights/coach-panel/coach-settings-sheet.tsx b/src/components/insights/coach-panel/coach-settings-sheet.tsx index 6a963f0d5..5d442cf36 100644 --- a/src/components/insights/coach-panel/coach-settings-sheet.tsx +++ b/src/components/insights/coach-panel/coach-settings-sheet.tsx @@ -75,6 +75,18 @@ const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [ "steps", ]; +// v1.4.36 W3 T2 — optional context blocks the user can opt out of. +// Surfaced as a separate group below the per-metric exclude list so +// the intent ("don't ship my profile / medication context to the +// model") reads distinctly from the per-metric data toggles above. +// Each row gates a single labelled block at the snapshot/feature layer +// so empty blocks never reach the prompt. +const CONTEXT_OPTIONS: CoachExcludeMetric[] = [ + "sleep", + "medications", + "anthropometrics", +]; + // v1.4.25 W5 — picker options for the new `defaultWindow` preference. // Mirrors `CoachScopeWindow` so the chat route can fold the selection // straight into `scope.window` when the client didn't supply one. @@ -349,6 +361,47 @@ export function CoachSettingsSheet({ + {/* v1.4.36 W3 T2 — optional context blocks. Mirrors the + EXCLUDE_OPTIONS list mechanically (same on/off switch + pattern, same toggleExclude handler) but groups the + profile / medication context toggles in their own + section so the intent reads distinctly. Empty blocks + are dropped at the snapshot/feature layer when the + toggle is on. */} +
+ +

+ {t("insights.coach.settingsContextHint")} +

+ +
+ {/* v1.4.27 F14 — the `showEvidenceByDefault` switch was retired. The evidence disclosure is now always closed by default and the user expands by click; surfacing diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index fcc65f630..14c1710bc 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -170,6 +170,65 @@ describe("buildCoachSnapshot", () => { expect(parsed.scope.window).toBe("last30days"); }); + // v1.4.36 W3 T2 — `medications` exclusion drops the GLP-1 weeklyContext + // block + the compliance source so no medication data reaches the + // prompt. Empty-data behaviour: when the user has no GLP-1 medication + // the block is absent anyway (this test only proves the toggle path). + it("omits weeklyContext.glp1 when excludeMetrics contains 'medications'", async () => { + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: { excludeMetrics: ["medications"] }, + timezone: "Europe/Berlin", + }); + const out = await buildCoachSnapshot("user-1"); + const parsed = JSON.parse(out.snapshotJson); + expect(parsed.weeklyContext).toBeUndefined(); + expect(parsed.compliance).toBeUndefined(); + expect(parsed.scope.sources).not.toContain("compliance"); + }); + + // v1.4.36 W3 T2 — `anthropometrics` exclusion drops the profile + // block even when features.context has populated fields. + it("omits anthropometrics when excludeMetrics contains 'anthropometrics'", async () => { + featuresMock.mockResolvedValue({ + context: { heightCm: 180, ageYears: 45, gender: "MALE" }, + }); + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: { excludeMetrics: ["anthropometrics"] }, + timezone: "Europe/Berlin", + }); + const out = await buildCoachSnapshot("user-1"); + const parsed = JSON.parse(out.snapshotJson); + expect(parsed.anthropometrics).toBeUndefined(); + }); + + // v1.4.36 W3 T2 — anthropometrics block is added when features.context + // has at least one non-null field AND the exclusion is off. + it("includes anthropometrics when context has data and exclusion is off", async () => { + featuresMock.mockResolvedValue({ + context: { heightCm: 180, ageYears: 45, gender: "MALE" }, + }); + const out = await buildCoachSnapshot("user-1"); + const parsed = JSON.parse(out.snapshotJson); + expect(parsed.anthropometrics).toEqual({ + heightCm: 180, + ageYears: 45, + gender: "MALE", + }); + }); + + // v1.4.36 W3 T2 — empty-block omit: when every anthropometric field + // is null the block is dropped entirely, not emitted as a labelled + // null-trio that would render as `Hier sind die Profildaten: [keine]` + // in the eventual prompt. + it("drops anthropometrics when every field is null", async () => { + featuresMock.mockResolvedValue({ + context: { heightCm: null, ageYears: null, gender: null }, + }); + const out = await buildCoachSnapshot("user-1"); + const parsed = JSON.parse(out.snapshotJson); + expect(parsed.anthropometrics).toBeUndefined(); + }); + it("scope-only-mood pulls just mood data, no measurements query", async () => { featuresMock.mockResolvedValue({ mood: { avg30: 4.2, coverage: { count: 12 } }, diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index f8d694b62..9c46c9c5d 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -395,15 +395,30 @@ async function buildCoachSnapshotImpl( const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson); const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE; const excluded = new Set(prefs.excludeMetrics); + // v1.4.36 W3 T2 — `medications` and `anthropometrics` are + // exclude-only toggles (not in `CoachScopeSource`); they gate the + // GLP-1 weeklyContext / compliance branch and the anthropometrics + // block respectively. The mapping below treats them as additive to + // the existing source-level exclusions. + const excludesMedications = excluded.has("medications"); + const excludesAnthropometrics = excluded.has("anthropometrics"); const sources = new Set(); for (const src of scopedSources) { - // The `excludeMetrics` enum is a strict subset of `CoachScopeSource` - // (every excludable key is also a valid scope source); this cast is - // safe and the runtime check is just defence-in-depth. + // The `excludeMetrics` enum is a superset of `CoachScopeSource` now + // (medications + anthropometrics live on the exclude-only side); + // the cast was safe before v1.4.36 because the enums matched 1:1, + // and the runtime `excluded.has` check still only catches the + // overlapping members. if (!excluded.has(src as unknown as CoachExcludeMetric)) { sources.add(src); } } + // `medications` exclusion also drops the compliance source so the + // intake-event branch below short-circuits — keeps the contract + // consistent (excluding medications == no medication data at all). + if (excludesMedications) { + sources.delete("compliance"); + } const windowDays = windowToDays(window); const features = await extractFeatures(userId, false, { @@ -756,10 +771,37 @@ async function buildCoachSnapshotImpl( // Coach's GROUND RULE 9 forbids dose prescriptions — this block // exists so the reply can SAY "your Mounjaro 7.5 mg" instead of // "your medication", never to make recommendations. - const glp1Block = await buildGlp1SnapshotBlock(userId, now); - if (glp1Block) { - snapshot.weeklyContext = { glp1: glp1Block }; - metrics.add("compliance"); + // v1.4.36 W3 T2 — gated on `medications` exclusion. When excluded + // we skip the Prisma lookup entirely so the read cost vanishes too. + if (!excludesMedications) { + const glp1Block = await buildGlp1SnapshotBlock(userId, now); + if (glp1Block) { + snapshot.weeklyContext = { glp1: glp1Block }; + metrics.add("compliance"); + } + } + + // v1.4.36 W3 T2 — anthropometrics block (height / age / gender). + // Sourced from `features.context`, which already reads + // `User.heightCm / dateOfBirth / gender`. Gated on the + // `anthropometrics` exclusion AND on at least one non-null field + // — accounts with no profile info never see an empty block. The + // `ctx?` guard tolerates mocked feature shapes in the test suite + // that don't populate the `context` object. + if (!excludesAnthropometrics) { + const ctx = features.context; + if ( + ctx && + (ctx.heightCm !== null || + ctx.ageYears !== null || + ctx.gender !== null) + ) { + snapshot.anthropometrics = { + heightCm: ctx.heightCm, + ageYears: ctx.ageYears, + gender: ctx.gender, + }; + } } if (Object.keys(snapshot).length === 0) { diff --git a/src/lib/validations/__tests__/coach-prefs.test.ts b/src/lib/validations/__tests__/coach-prefs.test.ts index 854251868..0222e55b0 100644 --- a/src/lib/validations/__tests__/coach-prefs.test.ts +++ b/src/lib/validations/__tests__/coach-prefs.test.ts @@ -56,7 +56,9 @@ describe("coachPrefsSchema", () => { expect(result.success).toBe(false); }); - it("caps excludeMetrics at 9 entries", () => { + it("caps excludeMetrics at 11 entries", () => { + // v1.4.36 W3 T2 — cap raised from 9 to 11 to admit the two new + // optional-context toggles (medications, anthropometrics). const result = coachPrefsSchema.safeParse({ excludeMetrics: [ "bp", @@ -68,11 +70,20 @@ describe("coachPrefsSchema", () => { "sleep", "resting_hr", "steps", + "medications", + "anthropometrics", "bp", ], }); expect(result.success).toBe(false); }); + + it("accepts the new medications + anthropometrics tokens", () => { + const out = coachPrefsSchema.parse({ + excludeMetrics: ["medications", "anthropometrics"], + }); + expect(out.excludeMetrics).toEqual(["medications", "anthropometrics"]); + }); }); describe("parseCoachPrefs", () => { diff --git a/src/lib/validations/coach-prefs.ts b/src/lib/validations/coach-prefs.ts index 7222c9cca..d94981669 100644 --- a/src/lib/validations/coach-prefs.ts +++ b/src/lib/validations/coach-prefs.ts @@ -55,6 +55,14 @@ export const coachExcludeMetricEnum = z.enum([ "sleep", "resting_hr", "steps", + // v1.4.36 W3 T2 — optional context blocks the user can opt out of. + // `medications` covers the snapshot's compliance + GLP-1 weeklyContext; + // `anthropometrics` covers height / age / gender on `context`. Each + // gates a single labelled block at the snapshot/feature layer, so an + // excluded block never lands in the prompt at all (not even as a + // labelled-empty key). + "medications", + "anthropometrics", ]); export type CoachExcludeMetric = z.infer; @@ -83,7 +91,7 @@ export type CoachDefaultWindow = z.infer; export const coachPrefsSchema = z.object({ tone: coachToneEnum.default("warm"), verbosity: coachVerbosityEnum.default("default"), - excludeMetrics: z.array(coachExcludeMetricEnum).max(9).default([]), + excludeMetrics: z.array(coachExcludeMetricEnum).max(11).default([]), showEvidenceByDefault: z.boolean().default(false), defaultWindow: coachDefaultWindowEnum.default("allTime"), });