mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
83f0b5ae86
commit
1e9a03bee5
12 files changed
+225
-22
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 } },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user