mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
The Coach can append a ---SUGGEST-REMINDER--- sentinel to propose a measurement cadence (weight daily, the ESH/AHA 7-2-2 home-BP protocol, structured glucose). The server resolves the cadence from a closed catalog and emits an additive 'suggestion' SSE frame the chat renders as a one-tap action card; accepting it creates a MeasurementReminder with origin COACH through the same engine the Vorsorge surface uses (server-authoritative schedule, finite course window self-expires via endsOn). Non-naggy by construction: a frequency-cap cooldown, dismissal memory, a master opt-out under Coach settings, and an explicit 'I measure enough' stop path, all in the coach-prefs blob. Triple dedup at suggest, render, and create so a cadence is never double-suggested or double-created, and suggestions for a disabled module never surface. RHR/HRV stay passive -- the Coach surfaces the baseline, it never nudges to 'go measure' them. The reminder DTO now echoes origin and endsOn for the iOS client.
220 lines
8.9 KiB
TypeScript
220 lines
8.9 KiB
TypeScript
/**
|
|
* v1.4.23 H4 — per-user Coach prompt-tuning preferences.
|
|
*
|
|
* Persisted as a Json blob on `User.coachPrefsJson`. Null = legacy
|
|
* defaults (tone="warm", verbosity="default", no metrics excluded,
|
|
* evidence disclosure closed by default). The Coach prompt builder +
|
|
* snapshot builder both read this shape — the snapshot must filter on
|
|
* `excludeMetrics` BEFORE landing in the system prompt so the model
|
|
* never sees data the user opted out of.
|
|
*
|
|
* v1.4.25 W5 — extended with `defaultWindow`. The picker lives in the
|
|
* settings sheet; the drawer header carries a per-conversation override
|
|
* pill so a single chat can narrow the window without flipping the
|
|
* global default. The chat route folds the saved preference into the
|
|
* snapshot scope when the client didn't supply a window override.
|
|
*/
|
|
import { z } from "zod/v4";
|
|
|
|
/**
|
|
* Tone presets the Coach system-prompt prefix toggles between. v1.4.22
|
|
* landed `warm` as the default; `neutral` strips the warmth language
|
|
* for users who prefer a clinical-adjacent style; `concise` caps
|
|
* verbosity and skips the optional motivational-interviewing micro-
|
|
* moves entirely.
|
|
*/
|
|
export const coachToneEnum = z.enum(["warm", "neutral", "concise"]);
|
|
export type CoachTone = z.infer<typeof coachToneEnum>;
|
|
|
|
/**
|
|
* Verbosity presets. Maps onto the prompt's "60-180 words" guidance:
|
|
* `brief` ≈ 30-90 words, `default` keeps the v1.4.22 range, `detailed`
|
|
* lifts the cap to 250 words. The `concise` tone overrides verbosity
|
|
* down to `brief` regardless of the verbosity selection — same
|
|
* intuition as concise == short, the picker just keeps the controls
|
|
* orthogonal in the UI for clarity.
|
|
*/
|
|
export const coachVerbosityEnum = z.enum(["brief", "default", "detailed"]);
|
|
export type CoachVerbosity = z.infer<typeof coachVerbosityEnum>;
|
|
|
|
/**
|
|
* Metric scopes the user can exclude from every Coach turn. Matches
|
|
* `CoachScopeSource` (`src/lib/ai/coach/types.ts`) so the snapshot
|
|
* builder can filter without a translation step. Apple Health-only
|
|
* metrics (hrv / sleep / resting_hr / steps) are listed alongside the
|
|
* core five so iOS users have a single surface to manage their privacy
|
|
* preferences.
|
|
*/
|
|
export const coachExcludeMetricEnum = z.enum([
|
|
"bp",
|
|
"weight",
|
|
"pulse",
|
|
"mood",
|
|
"compliance",
|
|
"hrv",
|
|
"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>;
|
|
|
|
/**
|
|
* v1.4.25 W5 — default analysis window the Coach uses when the client
|
|
* doesn't supply a per-conversation override. Mirrors
|
|
* `CoachScopeWindow` (`src/lib/ai/coach/types.ts`) so the chat route
|
|
* can fold the preference into `scope.window` without a translation
|
|
* layer. The default stays `allTime` to preserve the v1.4.24 behaviour
|
|
* — every legacy row reads as "no opinion" until the user explicitly
|
|
* picks a tighter default.
|
|
*/
|
|
export const coachDefaultWindowEnum = z.enum([
|
|
"last7days",
|
|
"last30days",
|
|
"last90days",
|
|
"allTime",
|
|
]);
|
|
export type CoachDefaultWindow = z.infer<typeof coachDefaultWindowEnum>;
|
|
|
|
/**
|
|
* v1.7.0 — clustered, opt-in Coach data sources. Each cluster groups a
|
|
* set of `CoachScopeSource` / `MeasurementType` / model reads behind a
|
|
* single toggle in the Coach settings sheet. The snapshot builder
|
|
* expands the enabled clusters into the source set when the request
|
|
* does not carry an explicit `scope.sources` list, then subtracts
|
|
* `excludeMetrics` as a post-filter (a cluster can be on while a single
|
|
* metric inside it is excluded).
|
|
*
|
|
* The cluster set is the opt-in source of truth going forward;
|
|
* `excludeMetrics` stays valid for back-compat and only narrows.
|
|
*/
|
|
export const coachDataClusterEnum = z.enum([
|
|
"cardio",
|
|
"body",
|
|
"activity",
|
|
"workouts",
|
|
"sleep",
|
|
"mood",
|
|
"glucose",
|
|
"medication",
|
|
"mobility",
|
|
"environment",
|
|
]);
|
|
export type CoachDataCluster = z.infer<typeof coachDataClusterEnum>;
|
|
|
|
/**
|
|
* Clusters enabled when the user has never touched the cluster picker
|
|
* (`dataClusters === undefined`). These four reproduce today's legacy
|
|
* five domains: cardio carries BP + pulse, body carries weight, mood
|
|
* and medication map straight through. The additive members riding
|
|
* inside cardio/body (HRV, resting HR, body-composition) only surface
|
|
* when the user actually has rows for them — empty blocks are dropped —
|
|
* so a web-only account stays close to the legacy 5-domain output while
|
|
* an iOS account quietly gains the extra signals it already stores.
|
|
*/
|
|
export const DEFAULT_COACH_CLUSTERS: ReadonlyArray<CoachDataCluster> = [
|
|
"cardio",
|
|
"body",
|
|
"mood",
|
|
"medication",
|
|
];
|
|
|
|
/**
|
|
* v1.18.1 (Workstream C) — Coach cadence-suggestion state. Persisted
|
|
* inside the same `coachPrefsJson` blob so the suggestion engine has a
|
|
* single, atomic place for the non-naggy machinery:
|
|
*
|
|
* - `enabled`: master opt-out for cadence suggestions (default ON; the
|
|
* toggle lives under Coach / Reminders settings).
|
|
* - `stopped`: the explicit "you measure enough — stop" path. Once the
|
|
* user picks it the Coach never suggests a cadence again until they
|
|
* re-enable. Distinct from `enabled:false` only in provenance (the user
|
|
* said "stop" from the card vs flipped the settings toggle); both
|
|
* suppress.
|
|
* - `dismissedCadences`: cadence ids the user dismissed. A dismissed
|
|
* cadence is never re-suggested (dismissal memory).
|
|
* - `lastSuggestedAt`: ISO instant of the last suggestion actually shown.
|
|
* Drives the cooldown (no second suggestion within the cooldown window).
|
|
*
|
|
* The shape is fully optional + defaulted so every legacy blob parses as
|
|
* "suggestions on, nothing dismissed".
|
|
*/
|
|
export const coachReminderSuggestionPrefsSchema = z.object({
|
|
enabled: z.boolean().default(true),
|
|
stopped: z.boolean().default(false),
|
|
dismissedCadences: z.array(z.string().max(64)).max(32).default([]),
|
|
lastSuggestedAt: z.string().max(40).nullable().default(null),
|
|
});
|
|
export type CoachReminderSuggestionPrefs = z.infer<
|
|
typeof coachReminderSuggestionPrefsSchema
|
|
>;
|
|
|
|
export const DEFAULT_REMINDER_SUGGESTION_PREFS: CoachReminderSuggestionPrefs = {
|
|
enabled: true,
|
|
stopped: false,
|
|
dismissedCadences: [],
|
|
lastSuggestedAt: null,
|
|
};
|
|
|
|
/**
|
|
* Full preferences shape. Defaults are inlined into the schema so a
|
|
* `safeParse({})` call returns the legacy v1.4.22 defaults — saves a
|
|
* sprinkle of `?? defaultX` calls at the call sites.
|
|
*/
|
|
export const coachPrefsSchema = z.object({
|
|
tone: coachToneEnum.default("warm"),
|
|
verbosity: coachVerbosityEnum.default("default"),
|
|
excludeMetrics: z.array(coachExcludeMetricEnum).max(11).default([]),
|
|
showEvidenceByDefault: z.boolean().default(false),
|
|
defaultWindow: coachDefaultWindowEnum.default("allTime"),
|
|
// v1.7.0 — opt-in cluster selection. `undefined` (key absent) is the
|
|
// back-compat sentinel: the snapshot builder expands
|
|
// `DEFAULT_COACH_CLUSTERS` so a legacy user who never opened the
|
|
// picker keeps the legacy domains. We deliberately do NOT `.default([])`
|
|
// — an empty array means "the user turned everything off", which is a
|
|
// distinct, valid state from "never picked".
|
|
dataClusters: z.array(coachDataClusterEnum).max(10).optional(),
|
|
// v1.18.1 (Workstream C) — cadence-suggestion state. Optional with NO
|
|
// top-level default so a legacy blob (and `parse({})`) stays byte-
|
|
// identical: an absent key reads as `undefined`, and call sites fall
|
|
// back to `DEFAULT_REMINDER_SUGGESTION_PREFS`. When the key IS present,
|
|
// its inner fields default (so `{}` fills to the all-on shape).
|
|
reminderSuggestions: coachReminderSuggestionPrefsSchema.optional(),
|
|
});
|
|
|
|
export type CoachPrefs = z.infer<typeof coachPrefsSchema>;
|
|
|
|
/**
|
|
* Default preferences applied when the user has never opened the
|
|
* settings cog. Equivalent to `coachPrefsSchema.parse({})` but
|
|
* returned as a plain object so call sites can compare references
|
|
* (e.g., short-circuit a snapshot rebuild when prefs match defaults).
|
|
*/
|
|
export const DEFAULT_COACH_PREFS: CoachPrefs = {
|
|
tone: "warm",
|
|
verbosity: "default",
|
|
excludeMetrics: [],
|
|
showEvidenceByDefault: false,
|
|
defaultWindow: "allTime",
|
|
};
|
|
|
|
/**
|
|
* Parse a row's `coachPrefsJson` Json blob into a typed `CoachPrefs`,
|
|
* falling back to defaults when the row is null OR the persisted shape
|
|
* has drifted (a forward-compat field rename, an admin-side hand-edit,
|
|
* etc.). Keeps the call sites at `src/lib/ai/coach/snapshot.ts` and
|
|
* `src/lib/ai/coach/system-prompt.ts` free of the null/parse plumbing.
|
|
*/
|
|
export function parseCoachPrefs(raw: unknown): CoachPrefs {
|
|
if (raw == null) return DEFAULT_COACH_PREFS;
|
|
const parsed = coachPrefsSchema.safeParse(raw);
|
|
return parsed.success ? parsed.data : DEFAULT_COACH_PREFS;
|
|
}
|