mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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