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")}
+
+
+ {CONTEXT_OPTIONS.map((metric) => {
+ const id = `coach-prefs-context-${metric}`;
+ const checked = draft.excludeMetrics.includes(metric);
+ return (
+ -
+
+ toggleExclude(metric, next)}
+ />
+
+ );
+ })}
+
+
+
{/* 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"),
});