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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 12:25:30 +02:00
1 parent 83f0b5ae86
commit 1e9a03bee5
12 files changed
+225 -22

No files matched your search

+9 -3
View File
@@ -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
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
@@ -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({
</ul>
</div>
{/* 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. */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsContextLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsContextHint")}
</p>
<ul
data-slot="coach-prefs-context-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{CONTEXT_OPTIONS.map((metric) => {
const id = `coach-prefs-context-${metric}`;
const checked = draft.excludeMetrics.includes(metric);
return (
<li
key={metric}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<Label htmlFor={id} className="cursor-pointer text-xs">
{t(`insights.coach.metric.${metric}`)}
</Label>
<Switch
id={id}
data-slot={`coach-prefs-context-${metric}`}
checked={checked}
onCheckedChange={(next) => toggleExclude(metric, next)}
/>
</li>
);
})}
</ul>
</div>
{/* 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
@@ -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 } },
+49 -7
View File
@@ -395,15 +395,30 @@ async function buildCoachSnapshotImpl(
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE;
const excluded = new Set<CoachExcludeMetric>(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<CoachScopeSource>();
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) {
@@ -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", () => {
+9 -1
View File
@@ -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<typeof coachExcludeMetricEnum>;
@@ -83,7 +91,7 @@ export type CoachDefaultWindow = z.infer<typeof coachDefaultWindowEnum>;
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"),
});