From b1629e2ad32185d68bd50a02263b39c8df447199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 13:32:58 +0200 Subject: [PATCH] refactor(coach): keep the exclusion gate in its own module beside the snapshot builder --- src/lib/ai/coach/scope-gate.ts | 102 +++++++++++++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 95 +-------------------------- src/lib/ai/coach/tools/executor.ts | 7 +- 3 files changed, 106 insertions(+), 98 deletions(-) create mode 100644 src/lib/ai/coach/scope-gate.ts diff --git a/src/lib/ai/coach/scope-gate.ts b/src/lib/ai/coach/scope-gate.ts new file mode 100644 index 000000000..53dc77914 --- /dev/null +++ b/src/lib/ai/coach/scope-gate.ts @@ -0,0 +1,102 @@ +/** + * The one exclusion gate every Coach read passes: the snapshot builder's + * source narrowing, a table tool's gate, and a stored table shown again. + * Kept apart from the snapshot builder so the gate can be used (and tests + * can stand the builder in) without loading it. + */ +import type { ModuleKey } from "@/lib/modules/registry"; +import { MODULE_SCOPED_SOURCES } from "@/lib/modules/measurement-scope"; +import type { CoachScopeSource } from "@/lib/ai/coach/types"; +import type { + CoachExcludeMetric, + CoachPrefs, +} from "@/lib/validations/coach-prefs"; + +/** + * v1.18.0 — module enable/disable → coach-snapshot domain map. + * + * When a toggleable data-domain module is disabled for the account, the + * domains it owns must never enter the coach context. We reuse the + * existing `excludeMetrics` filtering path (the `excluded` set narrows + * `sources` before any row is read) by folding the disabled modules into + * a SYSTEM-side exclusion that unions with the user's `excludeMetrics`. + * + * Each toggleable data domain maps to the `CoachScopeSource` token(s) + * its snapshot block(s) gate on: + * - `mood` → the mood block (`mood` source). + * - `sleep` → the per-night sleep block + the sleep-rhythm block + * (both gate on the `sleep` source). + * - `glucose` → the glucose per-context + clinical block. + * - `workouts` → the workouts block. + * - `recovery` → the recovery / strain composites. These are the + * derived block (READINESS / RECOVERY_SCORE / STRAIN_SCORE + * / …), the WHOOP-native dayStrain block, and the + * trajectory block — all gated on `derivedActive`, which + * reads HRV / resting-HR / VO₂max. Dropping those source + * tokens drops the raw additive timelines too; the + * composites are additionally gated below so they never + * build off the sleep signal alone. + * - `environment` → the audio-exposure (env / headphone / event), daylight, + * and skin-temperature blocks — the sources the opt-in + * environment cluster owns (`CLUSTER_SOURCES.environment`). + * + * `cycle` is intentionally absent: its block already resolves through the + * fully two-layer cycle gate (`isCycleAvailableForUser` — the per-user + * toggle AND the operator server-wide kill-switch) below, exactly as the + * W1 foundation prescribes. `coach` is the surface being narrated, not + * a data domain. `labs` / `achievements` / `insights` / `doctorReport` + * own no coach-snapshot data domain. + * + * v1.30.22 — the table itself moved to `@/lib/modules/measurement-scope` so + * the reads that deliberately bypass this builder (the MCP rich reads) gate + * off the SAME ownership map instead of inheriting nothing. The narrowing + * below is unchanged; only the definition site moved. + */ +const MODULE_EXCLUDED_SOURCES = MODULE_SCOPED_SOURCES as Partial< + Record +>; + +/** + * What the Coach may not read for this person: their own `excludeMetrics` + * plus every source a switched-off module owns (`moduleMap[key] === false`; + * the gate has already resolved every delegation, so the map is + * authoritative). + */ +export function coachExclusions( + prefs: Pick, + moduleMap: Readonly>, +): ReadonlySet { + const excluded = new Set(prefs.excludeMetrics); + for (const [key, srcs] of Object.entries(MODULE_EXCLUDED_SOURCES)) { + if (moduleMap[key as ModuleKey] === false) { + for (const src of srcs ?? []) { + // Every entry in MODULE_EXCLUDED_SOURCES is a CoachScopeSource that + // also exists in the CoachExcludeMetric enum overlap the + // source-narrowing check reads. + excluded.add(src as unknown as CoachExcludeMetric); + } + } + } + return excluded; +} + +/** + * The sources of a scope the Coach may read: the scope minus the + * exclusions, and no medication compliance when medications are excluded + * (excluding medications means no medication data at all). The one gate + * every read passes, the snapshot's own and the table tools' alike. + */ +export function admitCoachSources( + scoped: Iterable, + excluded: ReadonlySet, +): Set { + const sources = new Set(); + for (const src of scoped) { + // The `excludeMetrics` enum is a superset of `CoachScopeSource` + // (medications + anthropometrics live on the exclude-only side); the + // runtime `has` check only catches the overlapping members. + if (!excluded.has(src as unknown as CoachExcludeMetric)) sources.add(src); + } + if (excluded.has("medications")) sources.delete("compliance"); + return sources; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 51d046718..665925363 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -19,9 +19,7 @@ import { prisma } from "@/lib/db"; import { extractFeatures } from "@/lib/insights/features"; import { parseCoachPrefs, - type CoachExcludeMetric, type CoachDataCluster, - type CoachPrefs, } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { locales, defaultLocale, type Locale } from "@/lib/i18n/config"; @@ -53,8 +51,8 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types"; import type { ReferenceMetric } from "@/lib/reference-ranges"; import { isCycleAvailableForUser } from "@/lib/cycle/gate"; -import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; -import { MODULE_SCOPED_SOURCES } from "@/lib/modules/measurement-scope"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate"; import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import type { BaselineProfile } from "@/lib/insights/derived"; import { toProfileSex } from "@/lib/profile/sex"; @@ -195,50 +193,6 @@ const CORE_CLUSTERS: ReadonlySet = new Set([ "glucose", ]); -/** - * v1.18.0 — module enable/disable → coach-snapshot domain map. - * - * When a toggleable data-domain module is disabled for the account, the - * domains it owns must never enter the coach context. We reuse the - * existing `excludeMetrics` filtering path (the `excluded` set narrows - * `sources` before any row is read) by folding the disabled modules into - * a SYSTEM-side exclusion that unions with the user's `excludeMetrics`. - * - * Each toggleable data domain maps to the `CoachScopeSource` token(s) - * its snapshot block(s) gate on: - * - `mood` → the mood block (`mood` source). - * - `sleep` → the per-night sleep block + the sleep-rhythm block - * (both gate on the `sleep` source). - * - `glucose` → the glucose per-context + clinical block. - * - `workouts` → the workouts block. - * - `recovery` → the recovery / strain composites. These are the - * derived block (READINESS / RECOVERY_SCORE / STRAIN_SCORE - * / …), the WHOOP-native dayStrain block, and the - * trajectory block — all gated on `derivedActive`, which - * reads HRV / resting-HR / VO₂max. Dropping those source - * tokens drops the raw additive timelines too; the - * composites are additionally gated below so they never - * build off the sleep signal alone. - * - `environment` → the audio-exposure (env / headphone / event), daylight, - * and skin-temperature blocks — the sources the opt-in - * environment cluster owns (`CLUSTER_SOURCES.environment`). - * - * `cycle` is intentionally absent: its block already resolves through the - * fully two-layer cycle gate (`isCycleAvailableForUser` — the per-user - * toggle AND the operator server-wide kill-switch) below, exactly as the - * W1 foundation prescribes. `coach` is the surface being narrated, not - * a data domain. `labs` / `achievements` / `insights` / `doctorReport` - * own no coach-snapshot data domain. - * - * v1.30.22 — the table itself moved to `@/lib/modules/measurement-scope` so - * the reads that deliberately bypass this builder (the MCP rich reads) gate - * off the SAME ownership map instead of inheriting nothing. The narrowing - * below is unchanged; only the definition site moved. - */ -const MODULE_EXCLUDED_SOURCES = MODULE_SCOPED_SOURCES as Partial< - Record ->; - /** * Build the Coach prompt snapshot for `userId`. Always uses * `includeRaw=false` because the Coach replies are conversational and @@ -299,51 +253,6 @@ function blockFailed(block: string): (err: unknown) => null { }; } -/** - * What the Coach may not read for this person: their own `excludeMetrics` - * plus every source a switched-off module owns (`moduleMap[key] === false`; - * the gate has already resolved every delegation, so the map is - * authoritative). - */ -export function coachExclusions( - prefs: Pick, - moduleMap: Readonly>, -): ReadonlySet { - const excluded = new Set(prefs.excludeMetrics); - for (const [key, srcs] of Object.entries(MODULE_EXCLUDED_SOURCES)) { - if (moduleMap[key as ModuleKey] === false) { - for (const src of srcs ?? []) { - // Every entry in MODULE_EXCLUDED_SOURCES is a CoachScopeSource that - // also exists in the CoachExcludeMetric enum overlap the - // source-narrowing check reads. - excluded.add(src as unknown as CoachExcludeMetric); - } - } - } - return excluded; -} - -/** - * The sources of a scope the Coach may read: the scope minus the - * exclusions, and no medication compliance when medications are excluded - * (excluding medications means no medication data at all). The one gate - * every read passes, the snapshot's own and the table tools' alike. - */ -export function admitCoachSources( - scoped: Iterable, - excluded: ReadonlySet, -): Set { - const sources = new Set(); - for (const src of scoped) { - // The `excludeMetrics` enum is a superset of `CoachScopeSource` - // (medications + anthropometrics live on the exclude-only side); the - // runtime `has` check only catches the overlapping members. - if (!excluded.has(src as unknown as CoachExcludeMetric)) sources.add(src); - } - if (excluded.has("medications")) sources.delete("compliance"); - return sources; -} - async function buildCoachSnapshotImpl( userId: string, scope?: CoachScope, diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index 3e85d7192..af311b9b0 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -45,11 +45,8 @@ import { annotate } from "@/lib/logging/context"; import type { Locale } from "@/lib/i18n/config"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { resolveModuleMap } from "@/lib/modules/gate"; -import { - admitCoachSources, - buildCoachSnapshot, - coachExclusions, -} from "@/lib/ai/coach/snapshot"; +import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; +import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate"; import type { CoachPrefs } from "@/lib/validations/coach-prefs"; import { readMessageResults } from "@/lib/ai/coach/persistence"; import type {