mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
refactor(coach): keep the exclusion gate in its own module beside the snapshot builder
This commit is contained in:
1 parent
daf386fe7f
commit
b1629e2ad3
3 files changed
+106
-98
No files matched your search
@@ -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<ModuleKey, CoachScopeSource[]>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 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<CoachPrefs, "excludeMetrics">,
|
||||
moduleMap: Readonly<Record<ModuleKey, boolean>>,
|
||||
): ReadonlySet<CoachExcludeMetric> {
|
||||
const excluded = new Set<CoachExcludeMetric>(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<CoachScopeSource>,
|
||||
excluded: ReadonlySet<CoachExcludeMetric>,
|
||||
): Set<CoachScopeSource> {
|
||||
const sources = new Set<CoachScopeSource>();
|
||||
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;
|
||||
}
|
||||
@@ -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<CoachDataCluster> = new Set<CoachDataCluster>([
|
||||
"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<ModuleKey, CoachScopeSource[]>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 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<CoachPrefs, "excludeMetrics">,
|
||||
moduleMap: Readonly<Record<ModuleKey, boolean>>,
|
||||
): ReadonlySet<CoachExcludeMetric> {
|
||||
const excluded = new Set<CoachExcludeMetric>(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<CoachScopeSource>,
|
||||
excluded: ReadonlySet<CoachExcludeMetric>,
|
||||
): Set<CoachScopeSource> {
|
||||
const sources = new Set<CoachScopeSource>();
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user