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:
Marc-André Bombeck committed 2026-06-16 21:17:51 +02:00
1 parent b6796eee96
commit 12f4bc24e8
29 files changed
+1538 -9

No files matched your search

+3
View File
@@ -25,6 +25,9 @@ regexes = [
# i18n caption keys for the briefing-driven trend charts are dotted message
# keys, not secrets — one trips the generic-api-key entropy heuristic.
'''insights\.trendsRow\.caption''',
# Coach cadence-suggestion label keys are dotted message keys, not secrets;
# one trips the generic-api-key entropy heuristic.
'''coach\.reminderSuggestion\.cadence''',
]
# Specific commit fingerprints from prior false-positive findings (kept for reproducibility).
+94 -1
View File
@@ -3041,6 +3041,28 @@ paths:
- medication
- mobility
- environment
reminderSuggestions:
type: object
properties:
enabled:
default: true
type: boolean
stopped:
default: false
type: boolean
dismissedCadences:
default: []
maxItems: 32
type: array
items:
type: string
maxLength: 64
lastSuggestedAt:
default: null
anyOf:
- type: string
maxLength: 40
- type: "null"
responses:
"200":
description: Saved preferences echoed back.
@@ -12227,6 +12249,34 @@ components:
- medication
- mobility
- environment
reminderSuggestions:
type: object
properties:
enabled:
default: true
type: boolean
stopped:
default: false
type: boolean
dismissedCadences:
default: []
maxItems: 32
type: array
items:
type: string
maxLength: 64
lastSuggestedAt:
default: null
anyOf:
- type: string
maxLength: 40
- type: "null"
required:
- enabled
- stopped
- dismissedCadences
- lastSuggestedAt
additionalProperties: false
required:
- tone
- verbosity
@@ -12311,6 +12361,34 @@ components:
- medication
- mobility
- environment
reminderSuggestions:
type: object
properties:
enabled:
default: true
type: boolean
stopped:
default: false
type: boolean
dismissedCadences:
default: []
maxItems: 32
type: array
items:
type: string
maxLength: 64
lastSuggestedAt:
default: null
anyOf:
- type: string
maxLength: 40
- type: "null"
required:
- enabled
- stopped
- dismissedCadences
- lastSuggestedAt
additionalProperties: false
required:
- tone
- verbosity
@@ -16941,6 +17019,17 @@ components:
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
endsOn:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
origin:
type: string
enum:
- VORSORGE
- COACH
notifyHour:
type: integer
minimum: -9007199254740991
@@ -16978,6 +17067,8 @@ components:
- intervalDays
- rrule
- anchorDate
- endsOn
- origin
- notifyHour
- location
- nextDueAt
@@ -16987,7 +17078,9 @@ components:
- updatedAt
additionalProperties: false
description: A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries
measurementType=null and resolves only on a manual satisfy.
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).
MeasurementReminderCreatedEnvelope:
type: object
properties:
+21
View File
@@ -6619,5 +6619,26 @@
"label": "Medikamente",
"description": "Pläne, Einnahmen und Compliance."
}
},
"coach": {
"reminderSuggestion": {
"title": "Erinnerung einrichten?",
"accept": "Erinnerung setzen",
"dismiss": "Jetzt nicht",
"stop": "Ich messe genug",
"accepted": "Erinnerung gesetzt",
"dismissed": "Okay, das schlage ich nicht noch einmal vor",
"stopped": "Verstanden – ich schlage keine Erinnerungen mehr vor",
"failed": "Hat nicht geklappt – bitte erneut versuchen",
"cadence": {
"weightDaily": "Wieg dich täglich, ungefähr zur selben Zeit",
"bp722": "Miss deinen Blutdruck eine Woche lang zweimal täglich",
"glucoseStructured": "Miss deine Glukose nach einem strukturierten Tagesplan"
}
},
"settings": {
"reminderSuggestionsLabel": "Kadenz-Vorschläge",
"reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen."
}
}
}
+21
View File
@@ -6619,5 +6619,26 @@
"label": "Medications",
"description": "Schedules, intakes, and compliance."
}
},
"coach": {
"reminderSuggestion": {
"title": "Set a reminder?",
"accept": "Set reminder",
"dismiss": "Not now",
"stop": "I measure enough",
"accepted": "Reminder set",
"dismissed": "Okay, I won't suggest this again",
"stopped": "Got it — I'll stop suggesting reminders",
"failed": "Couldn't set that up — please try again",
"cadence": {
"weightDaily": "Weigh yourself daily, around the same time",
"bp722": "Measure your blood pressure twice a day for a week",
"glucoseStructured": "Test your glucose on a structured daily schedule"
}
},
"settings": {
"reminderSuggestionsLabel": "Cadence suggestions",
"reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly."
}
}
}
+21
View File
@@ -6619,5 +6619,26 @@
"label": "Medicamentos",
"description": "Pautas, tomas y cumplimiento."
}
},
"coach": {
"reminderSuggestion": {
"title": "¿Crear un recordatorio?",
"accept": "Crear recordatorio",
"dismiss": "Ahora no",
"stop": "Ya mido suficiente",
"accepted": "Recordatorio creado",
"dismissed": "De acuerdo, no lo volveré a sugerir",
"stopped": "Entendido, dejaré de sugerir recordatorios",
"failed": "No se pudo crear, inténtalo de nuevo",
"cadence": {
"weightDaily": "Pésate a diario, más o menos a la misma hora",
"bp722": "Mide tu tensión dos veces al día durante una semana",
"glucoseStructured": "Mide tu glucosa con un horario diario estructurado"
}
},
"settings": {
"reminderSuggestionsLabel": "Sugerencias de frecuencia",
"reminderSuggestionsDescription": "Permite que el Coach ofrezca de vez en cuando un recordatorio basado en evidencia para medir una métrica con más regularidad."
}
}
}
+21
View File
@@ -6619,5 +6619,26 @@
"label": "Médicaments",
"description": "Schémas, prises et observance."
}
},
"coach": {
"reminderSuggestion": {
"title": "Créer un rappel ?",
"accept": "Créer le rappel",
"dismiss": "Pas maintenant",
"stop": "Je mesure assez",
"accepted": "Rappel créé",
"dismissed": "D'accord, je ne le proposerai plus",
"stopped": "Compris, j'arrête de proposer des rappels",
"failed": "Échec de la création, veuillez réessayer",
"cadence": {
"weightDaily": "Pesez-vous chaque jour, à peu près à la même heure",
"bp722": "Mesurez votre tension deux fois par jour pendant une semaine",
"glucoseStructured": "Mesurez votre glycémie selon un horaire quotidien structuré"
}
},
"settings": {
"reminderSuggestionsLabel": "Suggestions de fréquence",
"reminderSuggestionsDescription": "Laissez le Coach proposer de temps en temps un rappel fondé sur des preuves pour mesurer une donnée plus régulièrement."
}
}
}
+21
View File
@@ -6619,5 +6619,26 @@
"label": "Farmaci",
"description": "Schemi, assunzioni e aderenza."
}
},
"coach": {
"reminderSuggestion": {
"title": "Impostare un promemoria?",
"accept": "Imposta promemoria",
"dismiss": "Non ora",
"stop": "Misuro abbastanza",
"accepted": "Promemoria impostato",
"dismissed": "Va bene, non lo suggerirò più",
"stopped": "Capito, smetto di suggerire promemoria",
"failed": "Impossibile impostarlo, riprova",
"cadence": {
"weightDaily": "Pesati ogni giorno, più o meno alla stessa ora",
"bp722": "Misura la pressione due volte al giorno per una settimana",
"glucoseStructured": "Misura la glicemia con un programma giornaliero strutturato"
}
},
"settings": {
"reminderSuggestionsLabel": "Suggerimenti di cadenza",
"reminderSuggestionsDescription": "Lascia che il Coach proponga ogni tanto un promemoria basato sull'evidenza per misurare una metrica più regolarmente."
}
}
}
+21
View File
@@ -6619,5 +6619,26 @@
"label": "Leki",
"description": "Schematy, przyjęcia i zgodność."
}
},
"coach": {
"reminderSuggestion": {
"title": "Ustawić przypomnienie?",
"accept": "Ustaw przypomnienie",
"dismiss": "Nie teraz",
"stop": "Mierzę wystarczająco",
"accepted": "Przypomnienie ustawione",
"dismissed": "Dobrze, nie zaproponuję tego ponownie",
"stopped": "Rozumiem, przestanę proponować przypomnienia",
"failed": "Nie udało się ustawić, spróbuj ponownie",
"cadence": {
"weightDaily": "Waż się codziennie, mniej więcej o tej samej porze",
"bp722": "Mierz ciśnienie dwa razy dziennie przez tydzień",
"glucoseStructured": "Mierz glukozę według ustrukturyzowanego planu dziennego"
}
},
"settings": {
"reminderSuggestionsLabel": "Sugestie częstotliwości",
"reminderSuggestionsDescription": "Pozwól Coachowi od czasu do czasu zaproponować oparte na dowodach przypomnienie, aby mierzyć daną wartość bardziej regularnie."
}
}
}
@@ -0,0 +1,193 @@
/**
* POST /api/coach/reminder-suggestions — act on a Coach cadence suggestion.
*
* v1.18.1 (Workstream C). The Coach proposes an evidence-based measurement
* cadence (the `---SUGGEST-REMINDER---` sentinel → `suggestion` SSE frame →
* action card). This endpoint is what the card's buttons call:
*
* - `accept` → create a `MeasurementReminder` with `origin: COACH`,
* routed through the SAME engine + endpoint semantics as the
* Vorsorge surface. The server resolves the cadence from the
* closed catalog (metric + interval/rrule + course window) —
* the client sends only the cadence id, never a schedule.
* - `dismiss` → dismissal memory (never re-suggest this cadence).
* - `stop` → the explicit "you measure enough — stop" path.
*
* Triple-dedup completion: the CREATE path here refuses a second live
* COACH reminder for the same metric, so a re-tap or a stale card cannot
* double-create even though the SUGGEST gate already deduped at emit time.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
parseCoachPrefs,
DEFAULT_REMINDER_SUGGESTION_PREFS,
} from "@/lib/validations/coach-prefs";
import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion";
import {
CADENCE_CATALOG,
isCadenceId,
} from "@/lib/ai/coach/suggest-reminder";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
const DEFAULT_TIMEZONE = "Europe/Berlin";
async function postAction(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// The Coach surface gates the suggestion card; mirror the gate here so a
// disabled-coach account cannot drive the endpoint directly.
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachReminderSuggestionActionSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const { cadenceId, action } = parsed.data;
if (!isCadenceId(cadenceId)) {
return apiError("coach.suggestion.unknownCadence", 422);
}
const cadence = CADENCE_CATALOG[cadenceId];
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { coachPrefsJson: true, timezone: true },
});
const coachPrefs = parseCoachPrefs(row?.coachPrefsJson);
const suggestionPrefs =
coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS;
// ── dismiss / stop: prefs-only writes ────────────────────────
if (action === "dismiss" || action === "stop") {
const next = {
...suggestionPrefs,
...(action === "stop" ? { stopped: true } : {}),
dismissedCadences:
action === "dismiss" &&
!suggestionPrefs.dismissedCadences.includes(cadenceId)
? [...suggestionPrefs.dismissedCadences, cadenceId].slice(0, 32)
: suggestionPrefs.dismissedCadences,
};
await prisma.user.update({
where: { id: user.id },
data: {
coachPrefsJson: { ...coachPrefs, reminderSuggestions: next },
},
});
annotate({
action: { name: `coach.reminder.${action}` },
meta: { cadenceId },
});
return apiSuccess({ ok: true, action });
}
// ── accept: create the reminder (origin: COACH) ──────────────
// Module-toggle re-check at create time (defence in depth — the gate
// already checked at emit, but a stale card could outlive a module flip).
if (cadence.module) {
const moduleGate = await requireModuleEnabled(user.id, cadence.module);
if (!moduleGate.enabled) return moduleGate.response;
}
// Third dedup: refuse a second live COACH reminder for the same metric.
const existing = await prisma.measurementReminder.findFirst({
where: {
userId: user.id,
origin: "COACH",
measurementType: cadence.measurementType,
deletedAt: null,
},
select: { id: true },
});
if (existing) {
annotate({
action: { name: "coach.reminder.accept.duplicate" },
meta: { cadenceId, reminderId: existing.id },
});
return apiSuccess(
{ ok: true, action: "accept", reminder: null, duplicate: true },
200,
);
}
const timezone = row?.timezone || DEFAULT_TIMEZONE;
const now = new Date();
const endsOn =
cadence.courseDays != null
? new Date(now.getTime() + cadence.courseDays * 24 * 60 * 60 * 1000)
: null;
const scheduleInput: ReminderScheduleInput = {
intervalDays: cadence.intervalDays,
rrule: cadence.rrule,
anchorDate: null,
notifyHour: cadence.notifyHour,
lastSatisfiedAt: null,
createdAt: now,
endsOn,
};
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
// Field-by-field — no mass assignment. `origin: COACH` is the
// provenance the UI labels and the SUGGEST gate dedupes against.
const created = await prisma.measurementReminder.create({
data: {
userId: user.id,
label: cadence.labelKey,
measurementType: cadence.measurementType,
intervalDays: cadence.intervalDays,
rrule: cadence.rrule,
anchorDate: null,
endsOn,
origin: "COACH",
notifyHour: cadence.notifyHour,
location: null,
enabled: true,
nextDueAt,
},
});
await auditLog("measurementReminder.create.coach", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: created.id, cadenceId },
});
annotate({
action: { name: "coach.reminder.accept" },
meta: { cadenceId, reminderId: created.id },
});
return apiSuccess(
{
ok: true,
action: "accept",
reminder: toMeasurementReminderDto(created),
duplicate: false,
},
201,
);
}
export const POST = apiHandler(postAction);
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
+74 -6
View File
@@ -69,7 +69,13 @@ import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder";
import { gateSuggestion } from "@/lib/ai/coach/suggest-gate";
import {
parseCoachPrefs,
DEFAULT_REMINDER_SUGGESTION_PREFS,
} from "@/lib/validations/coach-prefs";
import type { CoachSuggestion } from "@/lib/ai/coach/types";
import { createSseStream } from "@/lib/sse/create-stream";
/**
@@ -498,11 +504,66 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
});
return streamProviderError({ code: "coach.provider.empty" });
}
const replyText = proseAfterStrip;
const enrichedProvenance =
sentinel.keyValues.length > 0
? { ...snapshot.provenance, keyValues: sentinel.keyValues }
: snapshot.provenance;
// v1.18.1 (Workstream C) — strip the optional `---SUGGEST-REMINDER---`
// block out of the prose-after-keyvalues. The model proposes a cadence;
// the gate decides whether it actually surfaces (module-toggle + opt-out
// + dismissal memory + cooldown + dedup against a live COACH reminder).
// A suppressed proposal leaves the prose unchanged and emits no card.
const suggestParse = parseSuggestReminder(proseAfterStrip);
const replyText = suggestParse.prose.trim() || proseAfterStrip;
let surfacedSuggestion: CoachSuggestion | null = null;
if (suggestParse.cadence) {
const decision = await gateSuggestion({
prisma,
userId,
cadence: suggestParse.cadence,
prefs: coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS,
});
if (decision.surface) {
const cadence = suggestParse.cadence;
surfacedSuggestion = {
cadenceId: cadence.id,
measurementType: cadence.measurementType,
label: cadence.labelKey,
};
// Stamp the cooldown anchor (frequency cap) onto the prefs blob.
const nextSuggestionPrefs = {
...(coachPrefs.reminderSuggestions ??
DEFAULT_REMINDER_SUGGESTION_PREFS),
lastSuggestedAt: new Date().toISOString(),
};
void prisma.user
.update({
where: { id: userId },
data: {
coachPrefsJson: {
...coachPrefs,
reminderSuggestions: nextSuggestionPrefs,
},
},
})
.catch(() => {
// Cooldown stamp is best-effort: a write failure at worst lets a
// second suggestion through sooner, never breaks the chat turn.
});
annotate({
action: { name: "coach.reminder.suggested" },
meta: { cadenceId: cadence.id, metric: cadence.measurementType },
});
} else {
annotate({
action: { name: "coach.reminder.suppressed" },
meta: { cadenceId: suggestParse.cadence.id, reason: decision.reason },
});
}
}
const enrichedProvenance: typeof snapshot.provenance = {
...snapshot.provenance,
...(sentinel.keyValues.length > 0
? { keyValues: sentinel.keyValues }
: {}),
...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}),
};
if (sentinel.malformed) {
// Graceful degrade: log so ops can spot a provider whose
// sentinel format has drifted, but pass the prose through
@@ -585,6 +646,13 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
metricSource: enrichedProvenance,
}),
);
// v1.18.1 (Workstream C) — additive `suggestion` frame. Older clients
// ignore it; newer ones render the one-tap action card.
if (surfacedSuggestion) {
controller.enqueue(
encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }),
);
}
controller.enqueue(
encodeFrame({
type: "done",
@@ -147,6 +147,9 @@ export const PATCH = apiHandler(
(updateData.notifyHour as number | undefined) ?? existing.notifyHour,
lastSatisfiedAt: existing.lastSatisfiedAt,
createdAt: existing.createdAt,
// v1.18.1 — preserve the course window so an edit of a finite
// (Coach-suggested) cadence keeps self-expiring.
endsOn: existing.endsOn,
};
const after =
existing.lastSatisfiedAt && existing.lastSatisfiedAt > now
@@ -146,6 +146,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -170,6 +171,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "Drafting…",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -187,6 +189,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -203,6 +206,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "errorProvider",
@@ -243,6 +247,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: "m3-streaming",
errorCode: null,
@@ -269,6 +274,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -467,6 +473,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -570,6 +577,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.budget.exceeded",
@@ -590,6 +598,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.provider.rate_limited",
@@ -618,6 +627,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -24,6 +24,7 @@ import { useAuth } from "@/hooks/use-auth";
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
import { SourceChips } from "./source-chips";
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import type {
CoachConversationDetailDTO,
CoachOptimisticUserMessage,
@@ -406,6 +407,7 @@ export function MessageThread({
role="assistant"
content={streaming.content}
metricSource={streaming.metricSource}
suggestion={streaming.suggestion}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
@@ -425,6 +427,12 @@ interface ChatBubbleProps {
role: "user" | "assistant";
content: string;
metricSource?: import("@/lib/ai/coach/types").CoachProvenance | null;
/**
* v1.18.1 (Workstream C) — live cadence suggestion from the streaming
* hook. Persisted messages carry it on `metricSource.suggestion`
* instead; the bubble falls back to that so the card survives reload.
*/
suggestion?: import("@/lib/ai/coach/types").CoachSuggestion | null;
providerType?: string | null;
inProgress?: boolean;
errorCode?: string | null;
@@ -440,6 +448,7 @@ function ChatBubble({
role,
content,
metricSource,
suggestion,
providerType,
inProgress,
errorCode,
@@ -699,6 +708,16 @@ function ChatBubble({
</div>
</details>
)}
{/* v1.18.1 (Workstream C) — one-tap cadence-suggestion action
card. Live from the streaming hook, or restored from the
persisted message provenance on reload. Not shown on
in-flight or errored turns. */}
{!inProgress &&
!errorCode &&
(() => {
const sug = suggestion ?? metricSource?.suggestion ?? null;
return sug ? <ReminderSuggestionCard suggestion={sug} /> : null;
})()}
{/* v1.4.23 H7 — per-message thumbs feedback. Only persisted
assistant messages get the row (skipped for refusals,
errors, in-flight stream bubbles). The aggregator buckets
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { BellPlus, Check, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { apiPost } from "@/lib/api/api-fetch";
import type { CoachSuggestion } from "@/lib/ai/coach/types";
/**
* v1.18.1 (Workstream C) — the one-tap cadence-suggestion action card.
*
* Rendered under an assistant bubble when the turn carried a `suggestion`
* (live from the SSE frame, or persisted on the message provenance). Three
* actions, all routed through `POST /api/coach/reminder-suggestions`:
*
* - "Set reminder" → creates a `MeasurementReminder` (origin: COACH).
* - "Not now" → dismissal memory (never re-suggest this cadence).
* - "I measure enough" → the explicit stop path (no more suggestions).
*
* RENDER dedup (the middle leg of the triple dedup): once the user settles
* the card it collapses to a confirmation and never re-renders an action,
* so a stale stream + persisted twin can't double-prompt.
*/
export function ReminderSuggestionCard({
suggestion,
}: {
suggestion: CoachSuggestion;
}) {
const { t } = useTranslations();
const [settled, setSettled] = useState<
"accepted" | "dismissed" | "stopped" | null
>(null);
const act = useMutation({
mutationFn: async (action: "accept" | "dismiss" | "stop") => {
await apiPost("/api/coach/reminder-suggestions", {
cadenceId: suggestion.cadenceId,
action,
});
return action;
},
onSuccess: (action) => {
setSettled(
action === "accept"
? "accepted"
: action === "stop"
? "stopped"
: "dismissed",
);
},
onError: () => {
toast.error(t("coach.reminderSuggestion.failed"));
},
});
if (settled) {
return (
<p
role="status"
data-slot="coach-reminder-suggestion-done"
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Check className="text-dracula-green size-3.5" aria-hidden="true" />
{t(
settled === "accepted"
? "coach.reminderSuggestion.accepted"
: settled === "stopped"
? "coach.reminderSuggestion.stopped"
: "coach.reminderSuggestion.dismissed",
)}
</p>
);
}
const busy = act.isPending;
return (
<div
data-slot="coach-reminder-suggestion-card"
className={cn(
"border-border/60 bg-muted/40 flex flex-col gap-2.5 rounded-xl border",
"px-3.5 py-3 text-sm",
)}
>
<div className="flex items-start gap-2">
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<BellPlus className="text-background size-3.5" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-muted-foreground text-xs font-medium">
{t("coach.reminderSuggestion.title")}
</span>
{/* The label is a localised cadence string (the i18n key the
server hands back in `suggestion.label`). */}
<span className="text-foreground leading-relaxed">
{t(suggestion.label)}
</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
data-slot="coach-reminder-suggestion-accept"
onClick={() => act.mutate("accept")}
disabled={busy}
className={cn(
"bg-dracula-purple/90 text-background hover:bg-dracula-purple",
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5",
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
"focus-visible:ring-2 disabled:opacity-50",
)}
>
{busy ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<BellPlus className="size-3.5" aria-hidden="true" />
)}
{t("coach.reminderSuggestion.accept")}
</button>
<button
type="button"
data-slot="coach-reminder-suggestion-dismiss"
onClick={() => act.mutate("dismiss")}
disabled={busy}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
{t("coach.reminderSuggestion.dismiss")}
</button>
<button
type="button"
data-slot="coach-reminder-suggestion-stop"
onClick={() => act.mutate("stop")}
disabled={busy}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
{t("coach.reminderSuggestion.stop")}
</button>
</div>
</div>
);
}
@@ -10,6 +10,7 @@ import type {
CoachProvenance,
CoachScope,
CoachStreamEvent,
CoachSuggestion,
} from "@/lib/ai/coach/types";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -178,6 +179,11 @@ export interface CoachStreamingMessage {
content: string;
/** Provenance frame — populated once the server emits it. */
metricSource: CoachProvenance | null;
/**
* v1.18.1 (Workstream C) — cadence suggestion from the additive
* `suggestion` frame, if the turn carried one. Null otherwise.
*/
suggestion: CoachSuggestion | null;
/** True until the `done` frame closes the stream. */
inProgress: boolean;
/** Final messageId once `done` lands; null otherwise. */
@@ -220,6 +226,7 @@ export interface CoachOptimisticUserMessage {
const EMPTY_STREAMING: CoachStreamingMessage = {
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -314,6 +321,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
setStreaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -326,6 +334,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
setStreaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.network",
@@ -358,6 +367,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
setStreaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.network",
@@ -369,6 +379,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
setStreaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: `coach.http.${response.status}`,
@@ -405,6 +416,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
setStreaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: structured ?? `coach.http.${response.status}`,
@@ -423,6 +435,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
let resolvedConversationId: string | null = null;
let collectedContent = "";
let collectedProvenance: CoachProvenance | null = null;
let collectedSuggestion: CoachSuggestion | null = null;
let lastError: string | null = null;
let messageId: string | null = null;
@@ -449,6 +462,13 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
metricSource: evt.metricSource,
}));
break;
case "suggestion":
collectedSuggestion = evt.suggestion;
setStreaming((prev) => ({
...prev,
suggestion: evt.suggestion,
}));
break;
case "done":
resolvedConversationId = evt.conversationId;
messageId = evt.messageId;
@@ -478,6 +498,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
setStreaming({
content: collectedContent,
metricSource: collectedProvenance,
suggestion: collectedSuggestion,
inProgress: false,
messageId,
errorCode: lastError,
@@ -529,4 +550,5 @@ export type {
CoachConversationDTO,
CoachConversationDetailDTO,
CoachProvenance,
CoachSuggestion,
};
@@ -296,6 +296,42 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
})}
</ul>
</div>
{/* v1.18.1 (Workstream C) — cadence-suggestion opt-out. The
Coach offers an occasional, evidence-based reminder to
measure a metric more regularly; this is the master switch
for that surface. Off ⇒ no suggestion cards ever surface. */}
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1">
<Label
htmlFor="coach-prefs-reminder-suggestions"
className="cursor-pointer text-xs font-medium"
>
{t("coach.settings.reminderSuggestionsLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("coach.settings.reminderSuggestionsDescription")}
</p>
</div>
<Switch
id="coach-prefs-reminder-suggestions"
data-slot="coach-prefs-reminder-suggestions"
checked={draft.reminderSuggestions?.enabled ?? true}
onCheckedChange={(next) =>
setDraft((prev) => ({
...prev,
reminderSuggestions: {
enabled: next,
stopped: prev.reminderSuggestions?.stopped ?? false,
dismissedCadences:
prev.reminderSuggestions?.dismissedCadences ?? [],
lastSuggestedAt:
prev.reminderSuggestions?.lastSuggestedAt ?? null,
},
}))
}
/>
</div>
</div>
)}
<div className="mt-4 flex justify-end pl-7">
@@ -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");
});
});
+116
View File
@@ -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 };
}
+188
View File
@@ -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 };
}
+72
View File
@@ -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>
+31
View File
@@ -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();
});
});
+13
View File
@@ -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,
+8 -1
View File
@@ -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",
);
});
});
+43
View File
@@ -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
>;
+3 -1
View File
@@ -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).",
});