mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(coach): evidence-based cadence suggestions with one-tap in-chat reminder
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.
This commit is contained in:
1 parent
b6796eee96
commit
12f4bc24e8
29 files changed
+1538
-9
No files matched your search
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import {
|
||||
decideFromPrefs,
|
||||
gateSuggestion,
|
||||
SUGGESTION_COOLDOWN_DAYS,
|
||||
} from "@/lib/ai/coach/suggest-gate";
|
||||
import { CADENCE_CATALOG } from "@/lib/ai/coach/suggest-reminder";
|
||||
import { DEFAULT_REMINDER_SUGGESTION_PREFS } from "@/lib/validations/coach-prefs";
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const NOW = new Date("2026-06-16T12:00:00.000Z");
|
||||
|
||||
describe("decideFromPrefs", () => {
|
||||
it("surfaces with default prefs", () => {
|
||||
expect(
|
||||
decideFromPrefs(DEFAULT_REMINDER_SUGGESTION_PREFS, "weight_daily", NOW),
|
||||
).toEqual({ surface: true });
|
||||
});
|
||||
|
||||
it("suppresses when disabled", () => {
|
||||
const d = decideFromPrefs(
|
||||
{ ...DEFAULT_REMINDER_SUGGESTION_PREFS, enabled: false },
|
||||
"weight_daily",
|
||||
NOW,
|
||||
);
|
||||
expect(d).toEqual({ surface: false, reason: "disabled" });
|
||||
});
|
||||
|
||||
it("suppresses when the user said stop", () => {
|
||||
const d = decideFromPrefs(
|
||||
{ ...DEFAULT_REMINDER_SUGGESTION_PREFS, stopped: true },
|
||||
"weight_daily",
|
||||
NOW,
|
||||
);
|
||||
expect(d).toEqual({ surface: false, reason: "stopped" });
|
||||
});
|
||||
|
||||
it("suppresses a dismissed cadence (dismissal memory)", () => {
|
||||
const d = decideFromPrefs(
|
||||
{ ...DEFAULT_REMINDER_SUGGESTION_PREFS, dismissedCadences: ["weight_daily"] },
|
||||
"weight_daily",
|
||||
NOW,
|
||||
);
|
||||
expect(d).toEqual({ surface: false, reason: "dismissed" });
|
||||
});
|
||||
|
||||
it("suppresses inside the cooldown window and surfaces after it", () => {
|
||||
const justNow = new Date(NOW.getTime() - 60_000).toISOString();
|
||||
expect(
|
||||
decideFromPrefs(
|
||||
{ ...DEFAULT_REMINDER_SUGGESTION_PREFS, lastSuggestedAt: justNow },
|
||||
"weight_daily",
|
||||
NOW,
|
||||
),
|
||||
).toEqual({ surface: false, reason: "cooldown" });
|
||||
|
||||
const longAgo = new Date(
|
||||
NOW.getTime() - (SUGGESTION_COOLDOWN_DAYS + 1) * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
expect(
|
||||
decideFromPrefs(
|
||||
{ ...DEFAULT_REMINDER_SUGGESTION_PREFS, lastSuggestedAt: longAgo },
|
||||
"weight_daily",
|
||||
NOW,
|
||||
),
|
||||
).toEqual({ surface: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateSuggestion", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(isModuleEnabled).mockReset();
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
function fakePrisma(existing: { id: string } | null) {
|
||||
return {
|
||||
measurementReminder: {
|
||||
findFirst: vi.fn(async () => existing),
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
it("surfaces a core-domain cadence with no live duplicate", async () => {
|
||||
const prisma = fakePrisma(null);
|
||||
const d = await gateSuggestion({
|
||||
prisma,
|
||||
userId: "u1",
|
||||
cadence: CADENCE_CATALOG.weight_daily,
|
||||
now: NOW,
|
||||
});
|
||||
expect(d).toEqual({ surface: true });
|
||||
});
|
||||
|
||||
it("dedups against a live COACH reminder for the metric", async () => {
|
||||
const prisma = fakePrisma({ id: "r1" });
|
||||
const d = await gateSuggestion({
|
||||
prisma,
|
||||
userId: "u1",
|
||||
cadence: CADENCE_CATALOG.weight_daily,
|
||||
now: NOW,
|
||||
});
|
||||
expect(d).toEqual({ surface: false, reason: "duplicate" });
|
||||
});
|
||||
|
||||
it("suppresses a module-gated cadence when its module is disabled", async () => {
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(false);
|
||||
const prisma = fakePrisma(null);
|
||||
const d = await gateSuggestion({
|
||||
prisma,
|
||||
userId: "u1",
|
||||
cadence: CADENCE_CATALOG.glucose_structured,
|
||||
now: NOW,
|
||||
});
|
||||
expect(d).toEqual({ surface: false, reason: "module_disabled" });
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose");
|
||||
});
|
||||
|
||||
it("never queries the DB when the pref decision already suppresses", async () => {
|
||||
const prisma = fakePrisma(null);
|
||||
await gateSuggestion({
|
||||
prisma,
|
||||
userId: "u1",
|
||||
cadence: CADENCE_CATALOG.weight_daily,
|
||||
prefs: { ...DEFAULT_REMINDER_SUGGESTION_PREFS, stopped: true },
|
||||
now: NOW,
|
||||
});
|
||||
expect(
|
||||
(prisma as unknown as { measurementReminder: { findFirst: ReturnType<typeof vi.fn> } })
|
||||
.measurementReminder.findFirst,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
parseSuggestReminder,
|
||||
isCadenceId,
|
||||
CADENCE_CATALOG,
|
||||
} from "@/lib/ai/coach/suggest-reminder";
|
||||
|
||||
describe("parseSuggestReminder", () => {
|
||||
it("returns the prose unchanged when no sentinel is present", () => {
|
||||
const r = parseSuggestReminder("Just a normal reply.");
|
||||
expect(r.prose).toBe("Just a normal reply.");
|
||||
expect(r.cadence).toBeNull();
|
||||
expect(r.malformed).toBe(false);
|
||||
});
|
||||
|
||||
it("strips the block and resolves a valid cadence id", () => {
|
||||
const raw =
|
||||
"Maybe take a proper week of readings.\n" +
|
||||
"---SUGGEST-REMINDER---\n" +
|
||||
"cadence: bp_7_2_2\n" +
|
||||
"---END---";
|
||||
const r = parseSuggestReminder(raw);
|
||||
expect(r.prose).toBe("Maybe take a proper week of readings.");
|
||||
expect(r.cadence?.id).toBe("bp_7_2_2");
|
||||
expect(r.malformed).toBe(false);
|
||||
});
|
||||
|
||||
it("strips quotes/backticks the model may add around the id", () => {
|
||||
const raw =
|
||||
"text\n---SUGGEST-REMINDER---\ncadence: `weight_daily`\n---END---";
|
||||
expect(parseSuggestReminder(raw).cadence?.id).toBe("weight_daily");
|
||||
});
|
||||
|
||||
it("ignores informational reason lines", () => {
|
||||
const raw =
|
||||
"text\n---SUGGEST-REMINDER---\nreason: bp erratic\ncadence: bp_7_2_2\n---END---";
|
||||
expect(parseSuggestReminder(raw).cadence?.id).toBe("bp_7_2_2");
|
||||
});
|
||||
|
||||
it("flags malformed and strips the block on an unknown cadence id", () => {
|
||||
const raw =
|
||||
"text\n---SUGGEST-REMINDER---\ncadence: bp_hourly\n---END---";
|
||||
const r = parseSuggestReminder(raw);
|
||||
expect(r.cadence).toBeNull();
|
||||
expect(r.malformed).toBe(true);
|
||||
// The raw marker must never leak into the prose shown to the user.
|
||||
expect(r.prose).not.toContain("SUGGEST-REMINDER");
|
||||
expect(r.prose).toBe("text");
|
||||
});
|
||||
|
||||
it("tolerates a missing close marker (caps + still parses)", () => {
|
||||
const raw = "text\n---SUGGEST-REMINDER---\ncadence: weight_daily\n";
|
||||
expect(parseSuggestReminder(raw).cadence?.id).toBe("weight_daily");
|
||||
});
|
||||
|
||||
it("caps an oversized payload before parsing", () => {
|
||||
const raw =
|
||||
"text\n---SUGGEST-REMINDER---\n" +
|
||||
"x".repeat(2000) +
|
||||
"\ncadence: weight_daily\n---END---";
|
||||
// The cadence line sits past the 512-byte cap → not found → malformed.
|
||||
const r = parseSuggestReminder(raw);
|
||||
expect(r.cadence).toBeNull();
|
||||
expect(r.malformed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CADENCE_CATALOG", () => {
|
||||
it("isCadenceId only accepts catalog tokens", () => {
|
||||
expect(isCadenceId("bp_7_2_2")).toBe(true);
|
||||
expect(isCadenceId("weight_daily")).toBe(true);
|
||||
expect(isCadenceId("hrv_daily")).toBe(false);
|
||||
expect(isCadenceId("__proto__")).toBe(false);
|
||||
});
|
||||
|
||||
it("every preset sets exactly one of intervalDays / rrule", () => {
|
||||
for (const c of Object.values(CADENCE_CATALOG)) {
|
||||
const hasInterval = c.intervalDays != null;
|
||||
const hasRrule = c.rrule != null;
|
||||
expect(hasInterval !== hasRrule).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("the BP protocol is finite (course-windowed) and the others open-ended", () => {
|
||||
expect(CADENCE_CATALOG.bp_7_2_2.courseDays).toBe(7);
|
||||
expect(CADENCE_CATALOG.weight_daily.courseDays).toBeNull();
|
||||
expect(CADENCE_CATALOG.glucose_structured.courseDays).toBeNull();
|
||||
});
|
||||
|
||||
it("does not include passive RHR/HRV cadences", () => {
|
||||
const types = Object.values(CADENCE_CATALOG).map((c) => c.measurementType);
|
||||
expect(types).not.toContain("RESTING_HEART_RATE");
|
||||
expect(types).not.toContain("HEART_RATE_VARIABILITY");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* v1.18.1 (Workstream C) — the non-naggy gate for Coach cadence
|
||||
* suggestions.
|
||||
*
|
||||
* The model can PROPOSE a cadence (the `---SUGGEST-REMINDER---` sentinel);
|
||||
* this module is the SERVER's decision on whether that proposal actually
|
||||
* surfaces as an action card. It is the first of the TRIPLE dedup:
|
||||
*
|
||||
* 1. SUGGEST gate (here): module-toggle + opt-out/stop + dismissal
|
||||
* memory + cooldown + dedup against a live COACH reminder for the
|
||||
* metric. A proposal that fails any of these is dropped silently — no
|
||||
* `suggestion` frame.
|
||||
* 2. RENDER (client): the card renders once per assistant message.
|
||||
* 3. CREATE (`POST /api/measurement-reminders`): the create path refuses
|
||||
* a second live COACH reminder for the same metric (a re-tap or a
|
||||
* stale card cannot double-create).
|
||||
*
|
||||
* Pure decision functions + one DB-reading orchestrator so the gate logic
|
||||
* is unit-testable without a database.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import type { CadencePreset } from "./suggest-reminder";
|
||||
import type { CoachReminderSuggestionPrefs } from "@/lib/validations/coach-prefs";
|
||||
import { DEFAULT_REMINDER_SUGGESTION_PREFS } from "@/lib/validations/coach-prefs";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
/**
|
||||
* Cooldown between two cadence suggestions. A frequency cap, not a
|
||||
* per-cadence cap: once the Coach suggested anything, it stays quiet for
|
||||
* this window regardless of which cadence comes up next, so a chatty
|
||||
* session never stacks cards.
|
||||
*/
|
||||
export const SUGGESTION_COOLDOWN_DAYS = 14;
|
||||
const SUGGESTION_COOLDOWN_MS = SUGGESTION_COOLDOWN_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Why a proposal was suppressed (annotation meta; never user-facing). */
|
||||
export type SuggestionSuppressReason =
|
||||
| "disabled"
|
||||
| "stopped"
|
||||
| "dismissed"
|
||||
| "cooldown"
|
||||
| "module_disabled"
|
||||
| "duplicate";
|
||||
|
||||
export type SuggestionDecision =
|
||||
| { surface: true }
|
||||
| { surface: false; reason: SuggestionSuppressReason };
|
||||
|
||||
/**
|
||||
* Pure pref-level decision (no DB). Applies opt-out, the explicit stop
|
||||
* path, dismissal memory, and the cooldown. The caller layers the
|
||||
* module-toggle + dedup checks (which need a DB) on top.
|
||||
*/
|
||||
export function decideFromPrefs(
|
||||
prefs: CoachReminderSuggestionPrefs,
|
||||
cadenceId: string,
|
||||
now: Date,
|
||||
): SuggestionDecision {
|
||||
if (!prefs.enabled) return { surface: false, reason: "disabled" };
|
||||
if (prefs.stopped) return { surface: false, reason: "stopped" };
|
||||
if (prefs.dismissedCadences.includes(cadenceId)) {
|
||||
return { surface: false, reason: "dismissed" };
|
||||
}
|
||||
if (prefs.lastSuggestedAt) {
|
||||
const last = new Date(prefs.lastSuggestedAt).getTime();
|
||||
if (
|
||||
Number.isFinite(last) &&
|
||||
now.getTime() - last < SUGGESTION_COOLDOWN_MS
|
||||
) {
|
||||
return { surface: false, reason: "cooldown" };
|
||||
}
|
||||
}
|
||||
return { surface: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Full gate: pref decision, then the module-toggle, then dedup against a
|
||||
* live COACH reminder for the cadence's metric. Returns the surface
|
||||
* decision; the route emits the `suggestion` frame only when
|
||||
* `surface === true` and stamps `lastSuggestedAt` so the cooldown starts.
|
||||
*/
|
||||
export async function gateSuggestion(args: {
|
||||
prisma: PrismaClient;
|
||||
userId: string;
|
||||
cadence: CadencePreset;
|
||||
prefs?: CoachReminderSuggestionPrefs;
|
||||
now?: Date;
|
||||
}): Promise<SuggestionDecision> {
|
||||
const prefs = args.prefs ?? DEFAULT_REMINDER_SUGGESTION_PREFS;
|
||||
const now = args.now ?? new Date();
|
||||
|
||||
const prefDecision = decideFromPrefs(prefs, args.cadence.id, now);
|
||||
if (!prefDecision.surface) return prefDecision;
|
||||
|
||||
// Module-toggle: a cadence for a disabled module never surfaces. Core
|
||||
// domains (`module: null`) are always available.
|
||||
if (args.cadence.module) {
|
||||
const enabled = await isModuleEnabled(args.userId, args.cadence.module);
|
||||
if (!enabled) return { surface: false, reason: "module_disabled" };
|
||||
}
|
||||
|
||||
// Dedup: a live (non-deleted) COACH reminder for the same metric means
|
||||
// the user already accepted this cadence — never suggest it again.
|
||||
const existing = await args.prisma.measurementReminder.findFirst({
|
||||
where: {
|
||||
userId: args.userId,
|
||||
origin: "COACH",
|
||||
measurementType: args.cadence.measurementType,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return { surface: false, reason: "duplicate" };
|
||||
|
||||
return { surface: true };
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* v1.18.1 (Workstream C) — Coach cadence suggestions.
|
||||
*
|
||||
* The Coach can append a `---SUGGEST-REMINDER---` … `---END---` sentinel
|
||||
* block to its reply when an evidence-based measurement cadence is worth
|
||||
* proposing (a new/changed med, erratic readings, a fast trend, a target
|
||||
* off-track, or a measurement gap). The route strips the block out of the
|
||||
* prose — exactly like the `---KEYVALUES---` evidence block — parses one
|
||||
* cadence id out of it, validates it against the closed CADENCE_CATALOG,
|
||||
* and emits an ADDITIVE `suggestion` SSE frame the client renders as a
|
||||
* one-tap action card. Accepting the card POSTs to
|
||||
* `POST /api/measurement-reminders` with `origin: COACH`; the SERVER
|
||||
* resolves the actual cadence (intervalDays / rrule / endsOn) — the model
|
||||
* only names the metric + the preset.
|
||||
*
|
||||
* Why a closed catalog and not free-form cadence fields from the model:
|
||||
* - Evidence-based cadences ONLY. The model picks from a fixed set
|
||||
* grounded in ESH/AHA + general consistency guidance; it cannot mint a
|
||||
* "measure your BP 6×/day" nag.
|
||||
* - Server-authoritative scheduling. The reminder rows are computed by
|
||||
* the same recurrence engine the Vorsorge surface uses; web ↔ iOS read
|
||||
* identical numbers.
|
||||
*
|
||||
* Defence-in-depth mirrors `keyvalues.ts`: 512-byte cap, one cadence id
|
||||
* kept, validated against the catalog, malformed → dropped (no card).
|
||||
*/
|
||||
import type { MeasurementReminderType } from "@/lib/validations/measurement-reminders";
|
||||
|
||||
const OPEN_SENTINEL = "---SUGGEST-REMINDER---";
|
||||
const CLOSE_SENTINEL = "---END---";
|
||||
/** Hard cap on the sentinel payload before parsing (prompt-injection guard). */
|
||||
const SENTINEL_BYTE_CAP = 512;
|
||||
|
||||
/**
|
||||
* A cadence the Coach may propose. `id` is the stable token the model
|
||||
* emits; everything else is server-owned so the model cannot widen a
|
||||
* cadence or invent a metric.
|
||||
*
|
||||
* `measurementType` is the auto-resolve target the reminder row carries
|
||||
* (the eventful engine advances `lastSatisfiedAt` when a matching reading
|
||||
* lands). `module` is the module-gate key that must be enabled for the
|
||||
* suggestion to surface — disabled module ⇒ no suggestion.
|
||||
*
|
||||
* Exactly one of `intervalDays` / `rrule` is set, matching the reminder
|
||||
* create contract. `courseDays`, when set, bounds a finite course window
|
||||
* (`endsOn = now + courseDays`) for cadences that are deliberately
|
||||
* time-boxed (the ESH/AHA 7-day BP protocol).
|
||||
*/
|
||||
export interface CadencePreset {
|
||||
id: string;
|
||||
measurementType: MeasurementReminderType;
|
||||
/** Module-gate key; null ⇒ a core domain (never gated). */
|
||||
module: "glucose" | null;
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
/** Finite course window in days; null ⇒ open-ended cadence. */
|
||||
courseDays: number | null;
|
||||
/** i18n key for the action-card label ("Measure your BP twice daily…"). */
|
||||
labelKey: string;
|
||||
/** Notify hour default (0–23). Mirrors the reminder create default. */
|
||||
notifyHour: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed catalog. Evidence-based cadences only:
|
||||
* - weight: daily consistency (a stable cadence beats sporadic spikes).
|
||||
* - bp_7_2_2: ESH/AHA home-BP protocol — twice daily, morning + evening,
|
||||
* for 7 days. Time-boxed (courseDays:7) so it self-expires.
|
||||
* - glucose_structured: structured testing on a daily cadence (only when
|
||||
* the glucose module is enabled).
|
||||
*
|
||||
* Deliberately NOT here: RHR / HRV (passive nightly samples — the Coach
|
||||
* surfaces the baseline, it does not nudge "go measure" them). See the
|
||||
* Workstream-C plan.
|
||||
*/
|
||||
export const CADENCE_CATALOG: Readonly<Record<string, CadencePreset>> =
|
||||
Object.freeze({
|
||||
weight_daily: {
|
||||
id: "weight_daily",
|
||||
measurementType: "WEIGHT",
|
||||
module: null,
|
||||
intervalDays: 1,
|
||||
rrule: null,
|
||||
courseDays: null,
|
||||
labelKey: "coach.reminderSuggestion.cadence.weightDaily",
|
||||
notifyHour: 8,
|
||||
},
|
||||
bp_7_2_2: {
|
||||
id: "bp_7_2_2",
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
module: null,
|
||||
// Twice daily, morning + evening, on a finite 7-day course (ESH/AHA).
|
||||
intervalDays: null,
|
||||
rrule: "FREQ=DAILY;BYHOUR=7,19;INTERVAL=1",
|
||||
courseDays: 7,
|
||||
labelKey: "coach.reminderSuggestion.cadence.bp722",
|
||||
notifyHour: 7,
|
||||
},
|
||||
glucose_structured: {
|
||||
id: "glucose_structured",
|
||||
measurementType: "BLOOD_GLUCOSE",
|
||||
module: "glucose",
|
||||
intervalDays: 1,
|
||||
rrule: null,
|
||||
courseDays: null,
|
||||
labelKey: "coach.reminderSuggestion.cadence.glucoseStructured",
|
||||
notifyHour: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export type CadenceId = keyof typeof CADENCE_CATALOG;
|
||||
|
||||
/** True for a string the model emitted that names a real catalog cadence. */
|
||||
export function isCadenceId(value: string): value is CadenceId {
|
||||
return Object.prototype.hasOwnProperty.call(CADENCE_CATALOG, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of stripping the `---SUGGEST-REMINDER---` block out of a reply.
|
||||
*
|
||||
* `prose` is the reply with the block removed (the KEYVALUES strip runs
|
||||
* first; this strips what remains). `cadence` is the resolved preset when
|
||||
* the model named a valid, in-catalog cadence; `null` otherwise (no block,
|
||||
* malformed block, or an unknown cadence id — all degrade to "no card").
|
||||
*/
|
||||
export interface SuggestReminderParseResult {
|
||||
prose: string;
|
||||
cadence: CadencePreset | null;
|
||||
/** True when an opening sentinel was seen but yielded no valid cadence. */
|
||||
malformed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one body line of the sentinel block. The contract is a single
|
||||
* `cadence: <id>` line (extra lines are ignored). We tolerate the model
|
||||
* emitting a `reason:` line too — it is informational and dropped.
|
||||
*/
|
||||
function extractCadenceId(body: string): string | null {
|
||||
for (const rawLine of body.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const colon = line.indexOf(":");
|
||||
if (colon < 1) continue;
|
||||
const key = line.slice(0, colon).trim().toLowerCase();
|
||||
if (key !== "cadence") continue;
|
||||
const value = line
|
||||
.slice(colon + 1)
|
||||
.trim()
|
||||
// Strip surrounding quotes / backticks the model sometimes adds.
|
||||
.replace(/^["'`]|["'`]$/g, "");
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a reply into `{ prose, cadence }`, stripping the
|
||||
* `---SUGGEST-REMINDER---` block. Run this AFTER `parseKeyValuesSentinel`
|
||||
* so the prose it receives is already free of the evidence block.
|
||||
*/
|
||||
export function parseSuggestReminder(raw: string): SuggestReminderParseResult {
|
||||
if (!raw) return { prose: "", cadence: null, malformed: false };
|
||||
|
||||
const openIdx = raw.indexOf(OPEN_SENTINEL);
|
||||
if (openIdx === -1) {
|
||||
return { prose: raw, cadence: null, malformed: false };
|
||||
}
|
||||
|
||||
const prose = raw.slice(0, openIdx).replace(/\s+$/u, "");
|
||||
const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length);
|
||||
const closeRel = afterOpen.indexOf(CLOSE_SENTINEL);
|
||||
// Cap the payload before parsing regardless of whether the close marker
|
||||
// is present (a missing close marker treats the rest as the body).
|
||||
const bodyRaw =
|
||||
closeRel === -1 ? afterOpen : afterOpen.slice(0, closeRel);
|
||||
const body =
|
||||
bodyRaw.length > SENTINEL_BYTE_CAP
|
||||
? bodyRaw.slice(0, SENTINEL_BYTE_CAP)
|
||||
: bodyRaw;
|
||||
|
||||
const cadenceId = extractCadenceId(body);
|
||||
if (cadenceId && isCadenceId(cadenceId)) {
|
||||
return { prose, cadence: CADENCE_CATALOG[cadenceId], malformed: false };
|
||||
}
|
||||
// Opening sentinel present but no valid cadence — strip the block from
|
||||
// the prose (the user must never see the raw marker) and flag malformed.
|
||||
return { prose, cadence: null, malformed: true };
|
||||
}
|
||||
@@ -307,6 +307,42 @@ Rules:
|
||||
a collapsible "What I'm looking at" disclosure under your message,
|
||||
so don't reference it in your prose.
|
||||
|
||||
SUGGEST A MEASUREMENT CADENCE (optional)
|
||||
|
||||
When — and ONLY when — a real state change makes a regular measurement
|
||||
cadence genuinely worth it, you may append ONE cadence-suggestion block
|
||||
AFTER the evidence block. A "real state change" is: the user mentions a
|
||||
new or changed medication, the readings you can see are erratic, a trend
|
||||
is moving fast, a target is off-track, or there is a clear data gap (the
|
||||
user stopped logging a metric they used to track). Never suggest a cadence
|
||||
just because the conversation is going well, and never suggest more than
|
||||
one per reply.
|
||||
|
||||
Format exactly (the user never sees this block — the app renders it as a
|
||||
one-tap "set a reminder" card):
|
||||
|
||||
---SUGGEST-REMINDER---
|
||||
cadence: <id>
|
||||
---END---
|
||||
|
||||
<id> is EXACTLY one of these evidence-based cadences — never invent one,
|
||||
never change its frequency:
|
||||
- weight_daily — daily weight, for consistency (weigh at the same time).
|
||||
- bp_7_2_2 — the ESH/AHA home-BP protocol: twice daily (morning + evening)
|
||||
for 7 days. Suggest this when blood pressure is the topic and a proper
|
||||
reading series would help.
|
||||
- glucose_structured — structured daily glucose testing. Only when glucose
|
||||
is the topic.
|
||||
|
||||
Rules:
|
||||
- Mention the idea naturally in your prose ("it might help to take a proper
|
||||
week of readings"), then append the block. Do NOT describe the block
|
||||
itself or name the id in your prose.
|
||||
- NEVER suggest a cadence for resting heart rate or HRV — those are passive
|
||||
nightly readings, not something the user goes and measures.
|
||||
- Omit the block entirely on any turn without a real state change. Silence
|
||||
is the default; a suggestion is the exception.
|
||||
|
||||
EXAMPLES — TONE CALIBRATION
|
||||
|
||||
<example>
|
||||
@@ -619,6 +655,42 @@ Regeln:
|
||||
"Worauf bezieht sich das?"-Box unter deiner Nachricht. Verweise im
|
||||
Fließtext nicht auf den Block.
|
||||
|
||||
EINE MESS-KADENZ VORSCHLAGEN (optional)
|
||||
|
||||
Wenn — und NUR wenn — eine echte Veränderung eine regelmäßige Mess-Kadenz
|
||||
wirklich sinnvoll macht, darfst du EINEN Kadenz-Vorschlagsblock NACH dem
|
||||
Evidenz-Block anhängen. Eine "echte Veränderung" ist: der Nutzer nennt ein
|
||||
neues oder geändertes Medikament, die sichtbaren Werte sind unregelmäßig,
|
||||
ein Trend bewegt sich schnell, ein Zielwert ist aus dem Ruder, oder es gibt
|
||||
eine klare Datenlücke (der Nutzer hat aufgehört, eine zuvor getrackte
|
||||
Metrik zu erfassen). Schlage nie eine Kadenz vor, nur weil das Gespräch gut
|
||||
läuft, und nie mehr als eine pro Antwort.
|
||||
|
||||
Format genau (der Nutzer sieht diesen Block nicht — die App rendert ihn als
|
||||
"Erinnerung einrichten"-Karte mit einem Tap):
|
||||
|
||||
---SUGGEST-REMINDER---
|
||||
cadence: <id>
|
||||
---END---
|
||||
|
||||
<id> ist GENAU eine dieser evidenzbasierten Kadenzen — erfinde nie eine,
|
||||
ändere nie die Frequenz:
|
||||
- weight_daily — tägliches Gewicht, für Konstanz (zur selben Zeit wiegen).
|
||||
- bp_7_2_2 — das ESH/AHA-Heim-Blutdruck-Protokoll: zweimal täglich (morgens
|
||||
+ abends) über 7 Tage. Schlage es vor, wenn Blutdruck das Thema ist und
|
||||
eine ordentliche Messreihe helfen würde.
|
||||
- glucose_structured — strukturiertes tägliches Glukose-Testen. Nur wenn
|
||||
Glukose das Thema ist.
|
||||
|
||||
Regeln:
|
||||
- Erwähne die Idee natürlich im Fließtext ("es könnte helfen, eine richtige
|
||||
Woche zu messen"), dann häng den Block an. Beschreibe den Block NICHT und
|
||||
nenne die id NICHT im Fließtext.
|
||||
- Schlage NIE eine Kadenz für Ruhepuls oder HRV vor — das sind passive
|
||||
nächtliche Werte, nichts, das der Nutzer aktiv misst.
|
||||
- Lass den Block auf jeder Antwort ohne echte Veränderung komplett weg.
|
||||
Stille ist die Voreinstellung; ein Vorschlag ist die Ausnahme.
|
||||
|
||||
BEISPIELE — TONFALL
|
||||
|
||||
<example>
|
||||
|
||||
@@ -144,11 +144,35 @@ export const coachChatRequestSchema = z.object({
|
||||
guidedQuestion: z.string().min(1).max(500).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.18.1 (Workstream C) — a Coach cadence suggestion surfaced as a
|
||||
* one-tap action card. The model proposes the cadence via a sentinel
|
||||
* block; the server resolves it against the closed cadence catalog and
|
||||
* gates it (module-toggle + opt-out + dismissal + cooldown + dedup). When
|
||||
* it surfaces, this DTO rides an additive `suggestion` SSE frame AND is
|
||||
* persisted onto the assistant message's provenance so the card survives a
|
||||
* conversation reload.
|
||||
*
|
||||
* `cadenceId` is the catalog token; `measurementType` the auto-resolve
|
||||
* target; `label` the localised card copy. Accepting the card POSTs to
|
||||
* `POST /api/measurement-reminders` with `origin: COACH` + the cadence's
|
||||
* server-resolved schedule — the client sends only `cadenceId`, the server
|
||||
* looks up the rest, so the client can never widen a cadence.
|
||||
*/
|
||||
export interface CoachSuggestion {
|
||||
cadenceId: string;
|
||||
measurementType: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE event shapes emitted by the streaming endpoint.
|
||||
*
|
||||
* The route writes one `data: <json>\n\n` frame per event. Clients
|
||||
* dispatch on `type` and ignore unknown variants — additive evolution.
|
||||
* The `suggestion` frame (v1.18.1) is additive: older web + iOS clients
|
||||
* that don't know it drop it on the floor (the parser keeps only frames
|
||||
* whose `type` it handles), so the chat stays backwards-compatible.
|
||||
*/
|
||||
export type CoachStreamEvent =
|
||||
| { type: "token"; token: string }
|
||||
@@ -156,6 +180,7 @@ export type CoachStreamEvent =
|
||||
type: "provenance";
|
||||
metricSource: CoachProvenance;
|
||||
}
|
||||
| { type: "suggestion"; suggestion: CoachSuggestion }
|
||||
| { type: "done"; conversationId: string; messageId: string }
|
||||
| { type: "error"; code: string; message: string };
|
||||
|
||||
@@ -277,6 +302,12 @@ export interface CoachProvenance {
|
||||
* snapshot was empty. Hard cap 8 entries to keep the block scannable.
|
||||
*/
|
||||
keyValues?: ReadonlyArray<CoachKeyValue>;
|
||||
/**
|
||||
* v1.18.1 (Workstream C) — a cadence suggestion attached to this turn.
|
||||
* Persisted alongside the message so the one-tap action card re-renders
|
||||
* on a conversation reload. Absent on turns that carry no suggestion.
|
||||
*/
|
||||
suggestion?: CoachSuggestion;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
import type { MeasurementReminder } from "@/generated/prisma/client";
|
||||
|
||||
function row(overrides: Partial<MeasurementReminder> = {}): MeasurementReminder {
|
||||
const now = new Date("2026-06-16T08:00:00.000Z");
|
||||
return {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
label: "Measure your blood pressure twice a day for a week",
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
intervalDays: null,
|
||||
rrule: "FREQ=DAILY;BYHOUR=7,19;INTERVAL=1",
|
||||
anchorDate: null,
|
||||
endsOn: new Date("2026-06-23T08:00:00.000Z"),
|
||||
origin: "COACH",
|
||||
notifyHour: 7,
|
||||
location: null,
|
||||
nextDueAt: new Date("2026-06-16T19:00:00.000Z"),
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
} as MeasurementReminder;
|
||||
}
|
||||
|
||||
describe("toMeasurementReminderDto", () => {
|
||||
it("echoes origin and endsOn (iOS contract)", () => {
|
||||
const dto = toMeasurementReminderDto(row());
|
||||
expect(dto.origin).toBe("COACH");
|
||||
expect(dto.endsOn).toBe("2026-06-23T08:00:00.000Z");
|
||||
});
|
||||
|
||||
it("serialises a default VORSORGE open-ended reminder", () => {
|
||||
const dto = toMeasurementReminderDto(
|
||||
row({ origin: "VORSORGE", endsOn: null }),
|
||||
);
|
||||
expect(dto.origin).toBe("VORSORGE");
|
||||
expect(dto.endsOn).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,17 @@ export interface MeasurementReminderDtoShape {
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
anchorDate: string | null;
|
||||
/**
|
||||
* v1.18.1 — optional course-window end (ISO-8601). NULL ⇒ open-ended.
|
||||
* A Coach-suggested time-boxed protocol carries a non-NULL value and
|
||||
* self-expires.
|
||||
*/
|
||||
endsOn: string | null;
|
||||
/**
|
||||
* v1.18.1 — provenance: `VORSORGE` (user-created) or `COACH` (minted from
|
||||
* a Coach cadence suggestion). The UI labels the source.
|
||||
*/
|
||||
origin: "VORSORGE" | "COACH";
|
||||
notifyHour: number;
|
||||
location: string | null;
|
||||
nextDueAt: string | null;
|
||||
@@ -34,6 +45,8 @@ export function toMeasurementReminderDto(
|
||||
intervalDays: row.intervalDays,
|
||||
rrule: row.rrule,
|
||||
anchorDate: row.anchorDate ? row.anchorDate.toISOString() : null,
|
||||
endsOn: row.endsOn ? row.endsOn.toISOString() : null,
|
||||
origin: row.origin,
|
||||
notifyHour: row.notifyHour,
|
||||
location: row.location,
|
||||
nextDueAt: row.nextDueAt ? row.nextDueAt.toISOString() : null,
|
||||
|
||||
@@ -41,6 +41,13 @@ export interface ReminderScheduleInput {
|
||||
notifyHour: number;
|
||||
lastSatisfiedAt: Date | null;
|
||||
createdAt: Date;
|
||||
/**
|
||||
* v1.18.1 (Workstream C) — optional course-window end. NULL ⇒ open-ended
|
||||
* (the existing behaviour). Non-NULL bounds a finite cadence: the
|
||||
* recurrence engine stops producing occurrences past this instant, so a
|
||||
* Coach-suggested time-boxed protocol (ESH/AHA 7-day BP) self-expires.
|
||||
*/
|
||||
endsOn?: Date | null;
|
||||
}
|
||||
|
||||
function hourToHhmm(hour: number): string {
|
||||
@@ -86,7 +93,7 @@ export function buildReminderRecurrence(
|
||||
medication: {
|
||||
id: "measurement-reminder",
|
||||
startsOn,
|
||||
endsOn: null,
|
||||
endsOn: reminder.endsOn ?? null,
|
||||
oneShot: false,
|
||||
createdAt: reminder.createdAt,
|
||||
},
|
||||
|
||||
@@ -171,4 +171,35 @@ describe("parseCoachPrefs", () => {
|
||||
"mood",
|
||||
]);
|
||||
});
|
||||
|
||||
// v1.18.1 (Workstream C) — reminder-suggestion sub-shape.
|
||||
it("a legacy blob (no reminderSuggestions) parses with the key absent", () => {
|
||||
const out = parseCoachPrefs({ tone: "warm" });
|
||||
expect(out.reminderSuggestions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fills the reminderSuggestions defaults when the key is an empty object", () => {
|
||||
const out = coachPrefsSchema.parse({ reminderSuggestions: {} });
|
||||
expect(out.reminderSuggestions).toEqual({
|
||||
enabled: true,
|
||||
stopped: false,
|
||||
dismissedCadences: [],
|
||||
lastSuggestedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips a populated reminderSuggestions block", () => {
|
||||
const out = coachPrefsSchema.parse({
|
||||
reminderSuggestions: {
|
||||
enabled: true,
|
||||
stopped: false,
|
||||
dismissedCadences: ["bp_7_2_2"],
|
||||
lastSuggestedAt: "2026-06-16T12:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(out.reminderSuggestions?.dismissedCadences).toEqual(["bp_7_2_2"]);
|
||||
expect(out.reminderSuggestions?.lastSuggestedAt).toBe(
|
||||
"2026-06-16T12:00:00.000Z",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,43 @@ export const DEFAULT_COACH_CLUSTERS: ReadonlyArray<CoachDataCluster> = [
|
||||
"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
|
||||
@@ -144,6 +181,12 @@ export const coachPrefsSchema = z.object({
|
||||
// — 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>;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* v1.18.1 (Workstream C) — request body for the Coach cadence-suggestion
|
||||
* action endpoint (`POST /api/coach/reminder-suggestions`).
|
||||
*
|
||||
* The client sends ONLY the cadence id and the action it took; the server
|
||||
* resolves the actual cadence (metric + schedule + course window) from the
|
||||
* closed catalog, so the client can never widen a cadence or inject a
|
||||
* schedule.
|
||||
*
|
||||
* - `accept` → create a `MeasurementReminder` with `origin: COACH`.
|
||||
* - `dismiss` → record dismissal memory (never re-suggest this cadence).
|
||||
* - `stop` → the explicit "you measure enough — stop" path (suppress
|
||||
* every future cadence suggestion until re-enabled).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const coachReminderSuggestionActionSchema = z.object({
|
||||
cadenceId: z.string().trim().min(1).max(64),
|
||||
action: z.enum(["accept", "dismiss", "stop"]),
|
||||
});
|
||||
|
||||
export type CoachReminderSuggestionAction = z.infer<
|
||||
typeof coachReminderSuggestionActionSchema
|
||||
>;
|
||||
@@ -169,6 +169,8 @@ export const measurementReminderDto = z
|
||||
intervalDays: z.number().int().nullable(),
|
||||
rrule: z.string().nullable(),
|
||||
anchorDate: z.iso.datetime({ offset: true }).nullable(),
|
||||
endsOn: z.iso.datetime({ offset: true }).nullable(),
|
||||
origin: z.enum(["VORSORGE", "COACH"]),
|
||||
notifyHour: z.number().int(),
|
||||
location: z.string().nullable(),
|
||||
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
@@ -180,5 +182,5 @@ export const measurementReminderDto = z
|
||||
.meta({
|
||||
id: "MeasurementReminderDTO",
|
||||
description:
|
||||
"A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy.",
|
||||
"A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended).",
|
||||
});
|
||||
Reference in new issue
Block a user