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

@@ -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