From 27a8a59d67c7cd65de32a3477ad3f57843cb5cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:38:04 +0200 Subject: [PATCH] feat(coach): capture and resurface "remind me" reminders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the casual "remember this / remind me about X" utterance the Coach used to drop. The model emits a provider-neutral `---REMEMBER---` sentinel that the chat route strips inline on EVERY turn — so a short conversation, where the request usually happens, no longer waits for the long-chat memory worker. The note is the user's own explicit request, so it is stored silently as active; a validated closed grammar (ISO date, relative +Nd/+Nw, or a context cue) sets the trigger, and a malformed block is dropped. Surface the stored reminders two ways. A snapshot recall sub-block injects near, overdue, and already-surfaced reminders so the Coach references them unprompted next turn. A daily sweep flips overdue reminders to due and mints a one-off review reminder from a plan whose review date has passed, finally activating the plan review-date column. In-app surfacing only here; push delivery follows later. Also generalise the propose-then-confirm action card: a closed `---SUGGEST-ACTION---` allowlist (a preventive-care item or a reminder note) rides an additive SSE frame. Nothing is created until the user confirms. --- src/app/api/insights/chat/route.ts | 73 +++++- src/lib/ai/coach/memory-snapshot.ts | 31 ++- src/lib/ai/coach/plans.ts | 93 +++++++ src/lib/ai/coach/reminders.ts | 319 +++++++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 10 + src/lib/ai/coach/suggest-action.ts | 252 ++++++++++++++++++ src/lib/ai/coach/types.ts | 10 + src/lib/jobs/coach-reminder-sweep.ts | 133 ++++++++++ src/lib/jobs/reminder/register-status.ts | 43 +++ 9 files changed, 961 insertions(+), 3 deletions(-) create mode 100644 src/lib/ai/coach/reminders.ts create mode 100644 src/lib/ai/coach/suggest-action.ts create mode 100644 src/lib/jobs/coach-reminder-sweep.ts diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index c43ad8afa..8ba4f7e7f 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -93,6 +93,16 @@ import { import { scrubUnknownLearnLinks } from "@/lib/ai/coach/learn-link-guard"; import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder"; import { gateSuggestion } from "@/lib/ai/coach/suggest-gate"; +import { + parseRememberSentinel, + captureReminderFromSentinel, + buildRememberAddendum, +} from "@/lib/ai/coach/reminders"; +import { + parseSuggestAction, + buildSuggestActionAddendum, + type CoachSuggestedAction, +} from "@/lib/ai/coach/suggest-action"; import { parseCoachPrefs, DEFAULT_REMINDER_SUGGESTION_PREFS, @@ -371,7 +381,12 @@ async function handleChatRequest(request: NextRequest): Promise { // v1.16.0 — composed self-context: structured questionnaire fields // plus age/gender merged in from the User profile. const aboutMe = await getSelfContextTextForUser(userId, locale); - const systemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe); + // v1.22 (B2/F6) — the canonical system-prompt module is owned elsewhere, so + // the two memory/action clauses are appended at assembly time here: one + // teaches the model to emit `---REMEMBER---` (durable "remind me" capture), + // one teaches the closed `---SUGGEST-ACTION---` confirm-card allowlist. Both + // are provider-neutral sentinels stripped from the prose before it streams. + const systemPrompt = `${getCoachSystemPrompt(locale, coachPrefs, aboutMe)}\n\n${buildRememberAddendum(locale)}\n\n${buildSuggestActionAddendum(locale)}`; const allTurns: CoachTurn[] = [ ...priorTurns, { role: "user", content: message }, @@ -755,6 +770,39 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch const suggestParse = parseSuggestReminder(proseAfterStrip); let replyText = suggestParse.prose.trim() || proseAfterStrip; + // v1.22 (B2) — strip the optional `---REMEMBER---` block and capture the + // reminder INLINE on this turn (not in the >20-turn memory worker), so a + // casual "remind me about X" in a SHORT chat is no longer lost. The note is + // the user's OWN explicit request → captured silently as `active` (no confirm + // gate); the model acknowledges it in prose. A missing note / invalid `when` + // drops the block (the user never sees the raw marker). Fire-and-forget: the + // capture write must never break the chat turn. + const rememberParse = parseRememberSentinel(replyText, new Date()); + replyText = rememberParse.prose.trim() || replyText; + if (rememberParse.reminder) { + const capture = rememberParse.reminder; + void captureReminderFromSentinel({ + userId, + conversationId: workingConversationId, + parsed: capture, + }).catch(() => { + // Reminder capture is best-effort; never sink the turn. + }); + } else if (rememberParse.malformed) { + annotate({ + action: { name: "coach.reminder.capture_malformed" }, + meta: { conversationId: workingConversationId }, + }); + } + + // v1.22 (F6) — strip the optional `---SUGGEST-ACTION---` block (the + // generalised confirm→apply moat). The model names ONE action from the closed + // allowlist (`checkup.create` / `reminder.note`); the card surfaces additively + // and NOTHING is created until the user taps confirm (the entity is built + // server-side, field-by-field, by `POST /api/coach/suggested-actions`). + const actionParse = parseSuggestAction(replyText); + replyText = actionParse.prose.trim() || replyText; + // v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant // reply, before persistence and streaming. The inbound `detectRefusal` // guards the user's message; this guards the model's reply for a @@ -888,8 +936,21 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch }); } } + // v1.22 (F6) — surface the confirm-card action when the turn was not blocked. + // Additive: the prose already stands alone; the card only offers the one-tap + // confirm. Nothing is created server-side until the user taps it. + let surfacedAction: CoachSuggestedAction | null = null; + if (!outbound.block && actionParse.action) { + surfacedAction = actionParse.action; + annotate({ + action: { name: "coach.action.suggested" }, + meta: { actionType: actionParse.action.actionType }, + }); + } + const enrichedProvenance: typeof snapshot.provenance = { ...snapshot.provenance, + ...(surfacedAction ? { suggestedAction: surfacedAction } : {}), // v1.18.10 (HIGH-2) — a blocked turn carries the fallback prose, so the // key-values from the discarded reply must not ride along as provenance. ...(!outbound.block && sentinel.keyValues.length > 0 @@ -1018,6 +1079,16 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }), ); } + // v1.22 (F6) — additive `suggestedAction` frame. Older clients ignore it; + // newer ones render the generic one-tap confirm card. + if (surfacedAction) { + controller.enqueue( + encodeFrame({ + type: "suggestedAction", + suggestedAction: surfacedAction, + }), + ); + } // v1.18.9 — additive `usage` envelope on the `done` frame so the client // can paint the quiet per-message token footer the instant the stream // closes. Server-authoritative: the client renders these numbers, never diff --git a/src/lib/ai/coach/memory-snapshot.ts b/src/lib/ai/coach/memory-snapshot.ts index 0875c3c17..5909dfa2f 100644 --- a/src/lib/ai/coach/memory-snapshot.ts +++ b/src/lib/ai/coach/memory-snapshot.ts @@ -39,7 +39,12 @@ import { } from "@/lib/insights/narrative/period-narrative"; import type { BaselineProfile } from "@/lib/insights/derived"; import { buildCoachFactsBlock } from "./facts"; -import { buildCoachPlansBlock, type CoachPlanInjectEntry } from "./plans"; +import { + buildCoachPlansBlock, + buildCoachRemindersBlock, + type CoachPlanInjectEntry, + type CoachReminderInjectEntry, +} from "./plans"; /** The period the rolling profile recalls — month is the high-signal beat. */ const MEMORY_PERIOD: NarrativePeriod = "month"; @@ -85,6 +90,13 @@ export interface CoachMemoryBlock { * still awaiting the user's confirmation. Absent when none. */ plans?: CoachPlanInjectEntry[]; + /** + * v1.22 (B2/B3) — the user's near/overdue or already-surfaced episodic + * reminders ("remind me about X"), top-N soonest first. Lets the Coach + * reference them unprompted next turn — the progress-reflection reflex. + * Absent when none. + */ + reminders?: CoachReminderInjectEntry[]; } /** Pull the headline + driver recall off the latest period narrative. */ @@ -185,11 +197,25 @@ export async function buildCoachMemoryBlock( plans = undefined; } + // Sub-source 5 (v1.22 B2/B3): the user's near/overdue or surfaced episodic + // reminders. Fault-isolated like the others; the read half lives in plans.ts + // alongside the plan-injection block. + let reminders: CoachReminderInjectEntry[] | undefined; + try { + const remindersBlock = await buildCoachRemindersBlock(userId, now); + if (remindersBlock && remindersBlock.reminders.length > 0) { + reminders = remindersBlock.reminders; + } + } catch { + reminders = undefined; + } + if ( !priorNarrative && Object.keys(trendMemory).length === 0 && !facts && - !plans + !plans && + !reminders ) { return null; } @@ -198,5 +224,6 @@ export async function buildCoachMemoryBlock( if (priorNarrative) block.priorNarrative = priorNarrative; if (facts) block.facts = facts; if (plans) block.plans = plans; + if (reminders) block.reminders = reminders; return block; } diff --git a/src/lib/ai/coach/plans.ts b/src/lib/ai/coach/plans.ts index 78ef99474..67a276eec 100644 --- a/src/lib/ai/coach/plans.ts +++ b/src/lib/ai/coach/plans.ts @@ -39,6 +39,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider"; import { annotate } from "@/lib/logging/context"; import { decryptFromBytes, encryptToBytes } from "./bytes-codec"; +import { REMINDERS_INJECT_TOP_N } from "./reminders"; /** App-side closed status enum (NOT a DB enum — matches the schema column). */ export const COACH_PLAN_STATUSES = [ @@ -60,6 +61,7 @@ const RECENT_TURNS_CAP = 10; type RunCompletionFn = typeof runStatusCompletion; type PrismaLike = Pick; +type ReminderPrismaLike = Pick; interface ExtractOpts { runCompletion?: RunCompletionFn; @@ -462,3 +464,94 @@ export async function buildCoachPlansBlock( if (plans.length === 0) return null; return { plans }; } + +// --------------------------------------------------------------------------- +// v1.22 (B2/B3) — episodic reminder recall sub-block +// --------------------------------------------------------------------------- + +/** + * How far ahead of `now` an active reminder enters the recall block. A + * reminder due within this horizon (or already overdue) is "near" enough that + * the Coach should reference it unprompted on the next message; a reminder due + * far in the future stays dormant until its window approaches. + */ +const REMINDER_RECALL_HORIZON_DAYS = 3; +const REMINDER_RECALL_HORIZON_MS = REMINDER_RECALL_HORIZON_DAYS * 86_400_000; + +/** One reminder as the snapshot recall block carries it. */ +export interface CoachReminderInjectEntry { + note: string; + metric?: string; + /** ISO due moment, when the reminder is date-triggered. */ + dueAt?: string; + /** Lifecycle status (active | due | surfaced) for the model's framing. */ + status: string; +} + +interface InjectReminderRow { + noteEncrypted: Uint8Array; + metric: string | null; + dueAt: Date | null; + status: string; + updatedAt: Date; +} + +/** + * Build the recall sub-block: the reminders the Coach should reference next + * time — anything already `due`/`surfaced` (the sweep flipped it and the user + * has not resolved it) plus `active` reminders whose `dueAt` is near or overdue. + * A recall-only `active` note (no `dueAt`) is also surfaced so the Coach honours + * the user's explicit "remember this" even without a fire moment. Decrypt is + * fault-isolated — an undecryptable row is skipped, never thrown. Returns `null` + * when the user has nothing to recall. + * + * This is the read half of the episodic memory type, mirroring + * `buildCoachPlansBlock`; the write half lives in `reminders.ts`. + */ +export async function buildCoachRemindersBlock( + userId: string, + now: Date, + opts?: { prisma?: ReminderPrismaLike }, +): Promise<{ reminders: CoachReminderInjectEntry[] } | null> { + const db = opts?.prisma ?? prisma; + const horizon = new Date(now.getTime() + REMINDER_RECALL_HORIZON_MS); + + const rows = (await db.coachReminder.findMany({ + where: { + userId, + deletedAt: null, + OR: [ + // Already surfaced by the sweep and not yet resolved. + { status: { in: ["due", "surfaced"] } }, + // Active + near/overdue date trigger. + { status: "active", dueAt: { lte: horizon } }, + // Active recall-only note (no fire moment) — honour the explicit ask. + { status: "active", dueAt: null }, + ], + }, + // Soonest-due first, then most-recently touched. + orderBy: [{ dueAt: "asc" }, { updatedAt: "desc" }], + take: REMINDERS_INJECT_TOP_N * 3, + select: { + noteEncrypted: true, + metric: true, + dueAt: true, + status: true, + updatedAt: true, + }, + })) as InjectReminderRow[]; + + const reminders: CoachReminderInjectEntry[] = []; + for (const r of rows) { + if (reminders.length >= REMINDERS_INJECT_TOP_N) break; + const note = decryptOrNull(r.noteEncrypted); + if (note === null) continue; + const entry: CoachReminderInjectEntry = { note, status: r.status }; + if (r.metric) entry.metric = r.metric; + if (r.dueAt) entry.dueAt = r.dueAt.toISOString(); + reminders.push(entry); + } + + if (reminders.length === 0) return null; + return { reminders }; +} diff --git a/src/lib/ai/coach/reminders.ts b/src/lib/ai/coach/reminders.ts new file mode 100644 index 000000000..e66d8c907 --- /dev/null +++ b/src/lib/ai/coach/reminders.ts @@ -0,0 +1,319 @@ +/** + * v1.22 (B2/B6) — Coach episodic reminder memory: capture + grammar. + * + * The missing memory type between `CoachFact` (durable traits) and `CoachPlan` + * (standing if-then habits): a time-anchored "bring this back later" intent the + * user explicitly asked the Coach to remember ("remind me about my sleep next + * week"). Captured inline on EVERY turn via the provider-neutral + * `---REMEMBER---` sentinel — so a casual "remember this" in a SHORT chat is no + * longer lost waiting for the >20-turn memory worker. + * + * Friction tier (matches the design): a plain note the user EXPLICITLY asked + * for is captured silently as `status: "active"` — confirming "should I remember + * what you just told me to remember?" is the annoying anti-pattern. The + * propose→confirm rule stays where it belongs: durable traits/plans the model + * INFERRED (those still flow through the extractor + a confirm card). + * + * Defence-in-depth mirrors `suggest-reminder.ts` / `keyvalues.ts`: a byte cap on + * the payload, a single block kept, the `when` validated against a CLOSED + * grammar (ISO date / relative `+Nd`-`+Nw` / a context-cue enum), the metric + * validated against a loose key pattern, and a malformed block dropped (the user + * never sees the raw marker). The note TEXT is encrypted via `bytes-codec.ts` + * (the same AES-256-GCM codec as `CoachPlan`); `metric`, `status`, the trigger + * fields and the dates stay plain so the sweep + picker filter without a decrypt. + * + * Server-only — the capture path reads `@/lib/db`. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; + +import { encryptToBytes } from "./bytes-codec"; + +/** App-side closed status enum (NOT a DB enum — matches the schema column). */ +export const COACH_REMINDER_STATUSES = [ + "proposed", + "active", + "due", + "surfaced", + "done", + "dismissed", +] as const; +export type CoachReminderStatus = (typeof COACH_REMINDER_STATUSES)[number]; + +/** App-side closed trigger-kind enum (matches the schema column). */ +export const COACH_REMINDER_TRIGGER_KINDS = ["date", "context"] as const; +export type CoachReminderTriggerKind = + (typeof COACH_REMINDER_TRIGGER_KINDS)[number]; + +/** + * Closed context-cue enum. A `context` reminder fires when the cue is satisfied + * (evaluated by the daily sweep against persisted rows). The set is deliberately + * small and deterministic — "next time you log X" / "next time you open the app". + */ +export const COACH_REMINDER_CONTEXT_CUES = [ + "NEXT_BP_LOGGED", + "NEXT_WEIGHT_LOGGED", + "NEXT_SLEEP_LOGGED", + "NEXT_APP_OPEN", +] as const; +export type CoachReminderContextCue = + (typeof COACH_REMINDER_CONTEXT_CUES)[number]; + +/** Provenance of a reminder row (matches the schema `source` column). */ +export const COACH_REMINDER_SOURCES = [ + "sentinel", + "extractor", + "manual", + "action", +] as const; +export type CoachReminderSource = (typeof COACH_REMINDER_SOURCES)[number]; + +/** Per-field caps (mirror the Zod gate + the prompt instruction). */ +export const REMINDER_NOTE_MAX_CHARS = 280; +export const REMINDER_METRIC_MAX_CHARS = 60; +/** Hard cap on non-terminal reminders per user (active + proposed + due/surfaced). */ +export const MAX_REMINDERS_PER_USER = 50; +/** How many reminders the recall sub-block carries into the snapshot. */ +export const REMINDERS_INJECT_TOP_N = 6; +/** Bounds on the relative-`+Nd` grammar (1 day .. 1 year out). */ +const MIN_RELATIVE_DAYS = 1; +const MAX_RELATIVE_DAYS = 366; + +const OPEN_SENTINEL = "---REMEMBER---"; +const CLOSE_SENTINEL = "---END---"; +/** Hard cap on the sentinel payload before parsing (prompt-injection guard). */ +const SENTINEL_BYTE_CAP = 1024; + +/** A loose metric key the model may name ("SLEEP", "BLOOD_PRESSURE"). */ +const METRIC_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*$/; +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const RELATIVE_RE = /^\+(\d{1,3})([dw])$/i; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** The resolved trigger a valid `when` grammar maps onto. */ +export interface ResolvedTrigger { + triggerKind: CoachReminderTriggerKind; + dueAt: Date | null; + contextCue: CoachReminderContextCue | null; +} + +function isContextCue(value: string): value is CoachReminderContextCue { + return (COACH_REMINDER_CONTEXT_CUES as readonly string[]).includes(value); +} + +/** + * Resolve the closed `when` grammar into a trigger, or `null` when the token is + * unparseable / out of bounds (the caller drops the reminder). Accepts: + * - an ISO calendar date `YYYY-MM-DD` → date trigger, dueAt 09:00 UTC that day + * - a relative `+Nd` / `+Nw` → date trigger, dueAt now + N days/weeks + * - a context-cue token from the enum → context trigger, dueAt null + */ +export function resolveWhenGrammar( + whenRaw: string, + now: Date, +): ResolvedTrigger | null { + const when = whenRaw.trim(); + if (!when) return null; + + if (ISO_DATE_RE.test(when)) { + // Anchor at 09:00 UTC on the named day — a deterministic, timezone-stable + // fire moment for the daily sweep (push delivery + per-tz receptivity land + // in a later wave; this is in-app surfacing only). + const dueAt = new Date(`${when}T09:00:00.000Z`); + if (Number.isNaN(dueAt.getTime())) return null; + return { triggerKind: "date", dueAt, contextCue: null }; + } + + const rel = RELATIVE_RE.exec(when); + if (rel) { + const n = Number.parseInt(rel[1], 10); + const unit = rel[2].toLowerCase(); + const days = unit === "w" ? n * 7 : n; + if (days < MIN_RELATIVE_DAYS || days > MAX_RELATIVE_DAYS) return null; + return { + triggerKind: "date", + dueAt: new Date(now.getTime() + days * MS_PER_DAY), + contextCue: null, + }; + } + + const cue = when.toUpperCase(); + if (isContextCue(cue)) { + return { triggerKind: "context", dueAt: null, contextCue: cue }; + } + + return null; +} + +/** The fields a valid `---REMEMBER---` block yields. */ +export interface ParsedReminderSentinel { + note: string; + /** Resolved trigger when a valid `when` was given; null = a recall-only note. */ + trigger: ResolvedTrigger | null; + metric: string | null; +} + +export interface RememberParseResult { + /** The reply with the block stripped (run AFTER the other sentinels). */ + prose: string; + reminder: ParsedReminderSentinel | null; + /** True when an opening sentinel was seen but yielded no valid reminder. */ + malformed: boolean; +} + +/** Pull one `key: value` line out of the block body (first match wins). */ +function fieldLine(body: string, key: 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; + if (line.slice(0, colon).trim().toLowerCase() !== key) continue; + const value = line + .slice(colon + 1) + .trim() + // Strip a trailing inline comment + surrounding quotes/backticks. + .replace(/\s+#.*$/, "") + .replace(/^["'`]|["'`]$/g, "") + .trim(); + if (value) return value; + } + return null; +} + +/** + * Strip the `---REMEMBER---` block out of a reply and resolve its fields. Run + * AFTER the KEYVALUES + SUGGEST-REMINDER strips so the prose it receives is + * already free of those blocks. A missing `note`, or a present-but-unparseable + * `when`, drops the reminder (`malformed: true`) while still removing the raw + * marker from the prose. + */ +export function parseRememberSentinel( + raw: string, + now: Date, +): RememberParseResult { + if (!raw) return { prose: "", reminder: null, malformed: false }; + + const openIdx = raw.indexOf(OPEN_SENTINEL); + if (openIdx === -1) return { prose: raw, reminder: null, malformed: false }; + + const before = raw.slice(0, openIdx); + const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length); + const closeRel = afterOpen.indexOf(CLOSE_SENTINEL); + const bodyRaw = closeRel === -1 ? afterOpen : afterOpen.slice(0, closeRel); + const body = + bodyRaw.length > SENTINEL_BYTE_CAP + ? bodyRaw.slice(0, SENTINEL_BYTE_CAP) + : bodyRaw; + const after = + closeRel === -1 ? "" : afterOpen.slice(closeRel + CLOSE_SENTINEL.length); + const prose = `${before}${after}`.replace(/^\s+|\s+$/gu, ""); + + const noteRaw = fieldLine(body, "note"); + if (!noteRaw) return { prose, reminder: null, malformed: true }; + const note = noteRaw.slice(0, REMINDER_NOTE_MAX_CHARS).trim(); + if (!note) return { prose, reminder: null, malformed: true }; + + // `when` is optional. Present-but-invalid is a malformed drop; absent is a + // recall-only note (active, no due moment). + const whenRaw = fieldLine(body, "when"); + let trigger: ResolvedTrigger | null = null; + if (whenRaw) { + trigger = resolveWhenGrammar(whenRaw, now); + if (!trigger) return { prose, reminder: null, malformed: true }; + } + + let metric: string | null = null; + const metricRaw = fieldLine(body, "metric"); + if (metricRaw && METRIC_KEY_RE.test(metricRaw)) { + metric = metricRaw.slice(0, REMINDER_METRIC_MAX_CHARS).toUpperCase(); + } + + return { prose, reminder: { note, trigger, metric }, malformed: false }; +} + +/** + * The one system-prompt clause that teaches the model to emit `---REMEMBER---`. + * Appended to the assembled Coach system prompt in the chat route (the canonical + * system-prompt module is owned elsewhere). Provider-neutral; EN + DE only (the + * Coach reasons in de/en). + */ +export function buildRememberAddendum(locale: string | undefined): string { + if (locale?.toLowerCase().startsWith("de")) { + return `WENN die Person dich bittet, dir etwas zu merken oder sie später daran zu erinnern, hänge GENAU EINEN Block ans Ende deiner Antwort: +---REMEMBER--- +note: +when: +metric: +---END--- +Erfinde NIEMALS eine Erinnerung, um die die Person nicht ausdrücklich gebeten hat. Bestätige kurz im Fließtext ("Notiert — ich komme darauf zurück."). Lass "when" weg, wenn keine Zeit genannt wurde.`; + } + return `WHEN the user asks you to remember something or remind them later, append EXACTLY ONE block at the end of your reply: +---REMEMBER--- +note: +when: +metric: +---END--- +NEVER invent a reminder the user did not explicitly ask for. Acknowledge briefly in prose ("Got it — I'll bring that up again."). Omit "when" if no time was stated.`; +} + +type PrismaLike = Pick; + +/** + * Persist a sentinel-captured reminder field-by-field (no mass assignment), + * always `status: "active"` (the user explicitly asked — no confirm gate). The + * per-user cap refuses a write once the non-terminal set is full. Best-effort: + * a failure never breaks the chat turn (the caller fires it fire-and-forget). + * + * Returns the created id, or `null` when the cap displaced it / a write failed. + */ +export async function captureReminderFromSentinel(args: { + userId: string; + conversationId: string; + parsed: ParsedReminderSentinel; + now?: Date; + db?: PrismaLike; +}): Promise { + const db = args.db ?? prisma; + const { userId, conversationId, parsed } = args; + + const nonTerminal = await db.coachReminder.count({ + where: { + userId, + deletedAt: null, + status: { in: ["proposed", "active", "due", "surfaced"] }, + }, + }); + if (nonTerminal >= MAX_REMINDERS_PER_USER) { + annotate({ + action: { name: "coach.reminder.capture_capped" }, + meta: { conversationId }, + }); + return null; + } + + const created = await db.coachReminder.create({ + data: { + userId, + noteEncrypted: encryptToBytes(parsed.note), + metric: parsed.metric, + triggerKind: parsed.trigger?.triggerKind ?? "date", + dueAt: parsed.trigger?.dueAt ?? null, + contextCue: parsed.trigger?.contextCue ?? null, + status: "active", + source: "sentinel", + sourceConversationId: conversationId, + }, + select: { id: true }, + }); + + annotate({ + action: { name: "coach.reminder.captured" }, + meta: { + conversationId, + triggerKind: parsed.trigger?.triggerKind ?? "none", + hasDue: parsed.trigger?.dueAt != null, + }, + }); + return created.id; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index e6abd0889..515e78b66 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -2558,11 +2558,21 @@ function degradeToBudget( block && Array.isArray(block.plans) && block.plans.length > 0 ? block.plans : null; + // v1.22 (B2/B3) — the episodic reminders survive the drop alongside + // facts/plans: they are the user's own "remember this" asks, tiny by + // construction (top-6, ≤280 chars each), and shedding them would make the + // Coach forget a reminder on exactly the data-heavy accounts that hit the + // char cap. + const reminders = + block && Array.isArray(block.reminders) && block.reminders.length > 0 + ? block.reminders + : null; const survivors: Record = { omitted: "trimmed for prompt budget", }; if (facts) survivors.facts = facts; if (plans) survivors.plans = plans; + if (reminders) survivors.reminders = reminders; snapshot[key] = survivors; return true; }; diff --git a/src/lib/ai/coach/suggest-action.ts b/src/lib/ai/coach/suggest-action.ts new file mode 100644 index 000000000..e466b55c0 --- /dev/null +++ b/src/lib/ai/coach/suggest-action.ts @@ -0,0 +1,252 @@ +/** + * v1.22 (F6) — generalised inline "confirm → apply" Coach action cards. + * + * The propose→confirm moat already ships for ONE action type — the cadence + * reminder (`suggest-reminder.ts` CADENCE_CATALOG → `suggestion` SSE frame → + * `reminder-suggestion-card.tsx` → `POST /api/coach/reminder-suggestions`). + * This module generalises it: the Coach may append a `---SUGGEST-ACTION---` + * block naming ONE action type from a CLOSED allowlist plus bounded, + * server-resolvable params. The route strips it, validates it, and emits an + * additive `suggestedAction` SSE frame the client renders as a one-tap confirm + * card. Confirm POSTs to `POST /api/coach/suggested-actions`, which builds the + * entity FIELD-BY-FIELD from a server-resolved spec. + * + * HARD SAFETY (matches the design §3.6): + * - NEVER auto-apply. The frame is additive; the prose stands alone if + * ignored. Only the user's confirm tap creates anything. + * - CLOSED allowlist only. The model names an action type + bounded params; + * it can never mint an arbitrary entity or widen a schedule (the interval + * is a closed preset id resolved on the server, never a free rrule). + * - NO clinical / medication changes via this path. The allowlist excludes + * anything that creates/edits a medication, a dose, or a clinical target. + * + * Defence-in-depth mirrors the other sentinels: a byte cap, a single block, the + * fields validated, a malformed block dropped (the user never sees the marker). + */ + +/** + * The system-prompt clause teaching the model to emit `---SUGGEST-ACTION---`. + * Appended to the assembled Coach system prompt in the chat route. Provider- + * neutral; EN + DE only. Names ONLY the closed allowlist — never medication or + * clinical changes. + */ +export function buildSuggestActionAddendum(locale: string | undefined): string { + if (locale?.toLowerCase().startsWith("de")) { + return `WENN eine deiner Empfehlungen mit einem Tipp umsetzbar ist, biete sie als bestätigbare Aktion an — hänge GENAU EINEN Block ans Ende deiner Antwort. NUR diese geschlossene Liste, NIEMALS Medikamente oder klinische Ziele: +---SUGGEST-ACTION--- +action: checkup.create # eine Vorsorge-/Check-up-Erinnerung +label: +interval: +---END--- +oder +---SUGGEST-ACTION--- +action: reminder.note # eine Erinnerung, auf etwas zurückzukommen +note: +when: +metric: +---END--- +Wende NIEMALS etwas selbst an — die Person bestätigt mit einem Tipp. Schlage höchstens eine Aktion pro Antwort vor und nur, wenn sie klar hilfreich ist.`; + } + return `WHEN one of your recommendations is actionable with a reminder, offer it as a confirmable action — append EXACTLY ONE block at the end of your reply. ONLY this closed allowlist, NEVER a medication or clinical target: +---SUGGEST-ACTION--- +action: checkup.create # a preventive-care / check-up reminder +label: +interval: +---END--- +or +---SUGGEST-ACTION--- +action: reminder.note # a reminder to revisit something +note: +when: +metric: +---END--- +NEVER apply anything yourself — the user confirms with a tap. Suggest at most one action per reply, and only when it is clearly helpful.`; +} + +const OPEN_SENTINEL = "---SUGGEST-ACTION---"; +const CLOSE_SENTINEL = "---END---"; +const SENTINEL_BYTE_CAP = 1024; + +/** The CLOSED action-type allowlist. NEVER add a clinical / medication type. */ +export const SUGGESTED_ACTION_TYPES = [ + "checkup.create", + "reminder.note", +] as const; +export type SuggestedActionType = (typeof SUGGESTED_ACTION_TYPES)[number]; + +export function isSuggestedActionType(v: string): v is SuggestedActionType { + return (SUGGESTED_ACTION_TYPES as readonly string[]).includes(v); +} + +/** Field caps shared with the route Zod gate. */ +export const CHECKUP_LABEL_MAX_CHARS = 120; +export const ACTION_NOTE_MAX_CHARS = 280; +export const ACTION_METRIC_MAX_CHARS = 60; + +/** + * Closed cadence catalog for `checkup.create`. The model names ONLY a preset + * id; the server owns the RRULE so the model cannot mint an arbitrary schedule. + * A preventive-care item is, by nature, infrequent — the set is deliberately + * coarse (monthly at the tightest). + */ +export const CHECKUP_INTERVAL_CATALOG: Readonly< + Record +> = Object.freeze({ + yearly: { + id: "yearly", + rrule: "FREQ=YEARLY;INTERVAL=1", + labelKey: "coach.suggestedAction.interval.yearly", + }, + halfYearly: { + id: "halfYearly", + rrule: "FREQ=MONTHLY;INTERVAL=6", + labelKey: "coach.suggestedAction.interval.halfYearly", + }, + quarterly: { + id: "quarterly", + rrule: "FREQ=MONTHLY;INTERVAL=3", + labelKey: "coach.suggestedAction.interval.quarterly", + }, + monthly: { + id: "monthly", + rrule: "FREQ=MONTHLY;INTERVAL=1", + labelKey: "coach.suggestedAction.interval.monthly", + }, +}); +export type CheckupIntervalId = keyof typeof CHECKUP_INTERVAL_CATALOG; + +export function isCheckupIntervalId(v: string): v is CheckupIntervalId { + return Object.prototype.hasOwnProperty.call(CHECKUP_INTERVAL_CATALOG, v); +} + +/** + * The validated params the SSE frame carries + the confirm route accepts. A + * discriminated union keyed on `actionType`, so the route switch is exhaustive. + */ +export type SuggestedActionParams = + | { actionType: "checkup.create"; label: string; interval: CheckupIntervalId } + | { + actionType: "reminder.note"; + note: string; + when?: string; + metric?: string; + }; + +/** The DTO the `suggestedAction` SSE frame carries (additive; older clients drop it). */ +export interface CoachSuggestedAction { + actionType: SuggestedActionType; + /** Plain-text preview of what will be created (the model's own content). */ + summary: string; + /** i18n key for the action-type heading on the card. */ + titleKey: string; + params: SuggestedActionParams; +} + +const METRIC_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*$/; + +export interface SuggestActionParseResult { + prose: string; + action: CoachSuggestedAction | null; + malformed: boolean; +} + +/** Pull one `key: value` line out of the block body (first match wins). */ +function fieldLine(body: string, key: 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; + if (line.slice(0, colon).trim().toLowerCase() !== key) continue; + const value = line + .slice(colon + 1) + .trim() + .replace(/\s+#.*$/, "") + .replace(/^["'`]|["'`]$/g, "") + .trim(); + if (value) return value; + } + return null; +} + +/** Resolve the per-action params from the block body; null = drop (malformed). */ +function resolveActionParams( + actionType: SuggestedActionType, + body: string, +): { params: SuggestedActionParams; summary: string; titleKey: string } | null { + if (actionType === "checkup.create") { + const labelRaw = fieldLine(body, "label"); + const intervalRaw = fieldLine(body, "interval"); + if (!labelRaw || !intervalRaw) return null; + const label = labelRaw.slice(0, CHECKUP_LABEL_MAX_CHARS).trim(); + if (!label) return null; + if (!isCheckupIntervalId(intervalRaw)) return null; + return { + params: { actionType, label, interval: intervalRaw }, + summary: label, + titleKey: "coach.suggestedAction.checkup.title", + }; + } + + // reminder.note + const noteRaw = fieldLine(body, "note"); + if (!noteRaw) return null; + const note = noteRaw.slice(0, ACTION_NOTE_MAX_CHARS).trim(); + if (!note) return null; + const params: SuggestedActionParams = { actionType: "reminder.note", note }; + const whenRaw = fieldLine(body, "when"); + if (whenRaw) params.when = whenRaw.slice(0, 32); + const metricRaw = fieldLine(body, "metric"); + if (metricRaw && METRIC_KEY_RE.test(metricRaw)) { + params.metric = metricRaw.slice(0, ACTION_METRIC_MAX_CHARS).toUpperCase(); + } + return { + params, + summary: note, + titleKey: "coach.suggestedAction.note.title", + }; +} + +/** + * Strip the `---SUGGEST-ACTION---` block and resolve it into a closed action. + * Run AFTER the other sentinel strips. An unknown action type, or a missing / + * invalid param, drops the action (`malformed: true`) while still removing the + * raw marker from the prose. + */ +export function parseSuggestAction(raw: string): SuggestActionParseResult { + if (!raw) return { prose: "", action: null, malformed: false }; + + const openIdx = raw.indexOf(OPEN_SENTINEL); + if (openIdx === -1) return { prose: raw, action: null, malformed: false }; + + const before = raw.slice(0, openIdx); + const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length); + const closeRel = afterOpen.indexOf(CLOSE_SENTINEL); + const bodyRaw = closeRel === -1 ? afterOpen : afterOpen.slice(0, closeRel); + const body = + bodyRaw.length > SENTINEL_BYTE_CAP + ? bodyRaw.slice(0, SENTINEL_BYTE_CAP) + : bodyRaw; + const after = + closeRel === -1 ? "" : afterOpen.slice(closeRel + CLOSE_SENTINEL.length); + const prose = `${before}${after}`.replace(/^\s+|\s+$/gu, ""); + + const actionRaw = fieldLine(body, "action"); + if (!actionRaw || !isSuggestedActionType(actionRaw)) { + return { prose, action: null, malformed: true }; + } + + const resolved = resolveActionParams(actionRaw, body); + if (!resolved) return { prose, action: null, malformed: true }; + + return { + prose, + action: { + actionType: actionRaw, + summary: resolved.summary, + titleKey: resolved.titleKey, + params: resolved.params, + }, + malformed: false, + }; +} diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 11862a186..1618d5ceb 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -8,6 +8,8 @@ */ import { z } from "zod/v4"; +import type { CoachSuggestedAction } from "./suggest-action"; + /** * Chat-message role. Stored as a free-form string column server-side * (`coach_messages.role`) but constrained at the application layer so a @@ -205,6 +207,7 @@ export type CoachStreamEvent = metricSource: CoachProvenance; } | { type: "suggestion"; suggestion: CoachSuggestion } + | { type: "suggestedAction"; suggestedAction: CoachSuggestedAction } | { type: "reasoning"; text: string } | { type: "done"; @@ -338,6 +341,13 @@ export interface CoachProvenance { * on a conversation reload. Absent on turns that carry no suggestion. */ suggestion?: CoachSuggestion; + /** + * v1.22 (F6) — a generalised confirm→apply action card attached to this turn + * (`checkup.create` / `reminder.note`, closed allowlist). Persisted alongside + * the message so the card re-renders on a conversation reload. Absent on turns + * that carry no action. + */ + suggestedAction?: CoachSuggestedAction; /** * v1.20.0 (F1) — the retrieval-tool trace for this turn: which tools the * Coach called and whether each found data (`present`). Metadata only — no diff --git a/src/lib/jobs/coach-reminder-sweep.ts b/src/lib/jobs/coach-reminder-sweep.ts new file mode 100644 index 000000000..5fde7451c --- /dev/null +++ b/src/lib/jobs/coach-reminder-sweep.ts @@ -0,0 +1,133 @@ +/** + * v1.22 (B2/M4) — daily Coach-reminder sweep. + * + * The active scheduler the "remind me" complaint needs: a single indexed query + * per tick that brings a reminder back at its chosen moment. Two sources: + * + * 1. `CoachReminder` rows that are date-triggered, `active`, and whose `dueAt` + * has passed → flipped to `due` (the in-app tile + indicator read `due` / + * `surfaced`). `lastSurfacedAt` is stamped + `surfaceCount` bumped. + * 2. `CoachPlan` rows whose dangling `reviewDate` (written nowhere, read + * nowhere until now — see the design §1c) has passed and that are still + * `active` → a one-off `CoachReminder` is minted from the plan's own + * cue→action text (relatedPlanId set, source `extractor`) and the plan's + * `reviewDate` is cleared so the review fires once. This finally activates + * the column the B1 schema anticipated. + * + * In-app surfacing ONLY in this wave: push delivery + the receptivity gate (B4) + * land later. The sweep never calls a provider and never pushes — it only flips + * status + mints the plan-review reminder, both idempotent across re-fires. + * + * Context-cue reminders (`triggerKind: "context"`) are NOT evaluated here — that + * is a follow-on (F4); the column exists and capture supports it, but the date + * sweep is the high-value slice. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; + +export const COACH_REMINDER_SWEEP_QUEUE = "coach-reminder-sweep"; +// Daily at 05:20 Europe/Berlin — just after the 05:15 nudge tick so both +// proactive passes run on settled overnight rows. +export const COACH_REMINDER_SWEEP_CRON = "20 5 * * *"; + +/** Bound the plan-review fan-out per tick so one sweep can't run unbounded. */ +const PLAN_REVIEW_BATCH = 200; + +export interface CoachReminderSweepSummary { + remindersDue: number; + planReviewsMinted: number; + errored: number; +} + +type SweepPrisma = Pick; + +/** + * Run one sweep tick. Idempotent: a reminder already `due` is not re-flipped, + * and a plan whose `reviewDate` was cleared on a prior tick is not re-minted. + */ +export async function runCoachReminderSweep( + prisma: SweepPrisma, + now: Date = new Date(), +): Promise { + const summary: CoachReminderSweepSummary = { + remindersDue: 0, + planReviewsMinted: 0, + errored: 0, + }; + + // ── 1. flip overdue active date-reminders → due ────────────── + const due = await prisma.coachReminder.updateMany({ + where: { + deletedAt: null, + status: "active", + triggerKind: "date", + dueAt: { not: null, lte: now }, + }, + data: { + status: "due", + lastSurfacedAt: now, + surfaceCount: { increment: 1 }, + }, + }); + summary.remindersDue = due.count; + + // ── 2. mint plan-review reminders for passed reviewDates ────── + const plans = await prisma.coachPlan.findMany({ + where: { + deletedAt: null, + status: "active", + reviewDate: { not: null, lte: now }, + }, + take: PLAN_REVIEW_BATCH, + select: { + id: true, + userId: true, + metric: true, + ifCueEncrypted: true, + thenActionEncrypted: true, + }, + }); + + for (const plan of plans) { + try { + // The note is the plan's OWN cue→action prose (the user's words), never + // fabricated. Decrypt fault-isolated — an undecryptable plan is skipped + // (its reviewDate stays so a future key fix can still surface it). + let note: string; + try { + const ifCue = decryptFromBytes(plan.ifCueEncrypted); + const thenAction = decryptFromBytes(plan.thenActionEncrypted); + note = `${ifCue} → ${thenAction}`.slice(0, 280); + } catch { + summary.errored += 1; + continue; + } + + await prisma.coachReminder.create({ + data: { + userId: plan.userId, + noteEncrypted: encryptToBytes(note), + metric: plan.metric, + relatedPlanId: plan.id, + triggerKind: "date", + dueAt: now, + status: "due", + source: "extractor", + lastSurfacedAt: now, + surfaceCount: 1, + }, + }); + // Clear the plan's reviewDate so the review fires exactly once. + await prisma.coachPlan.update({ + where: { id: plan.id }, + data: { reviewDate: null }, + }); + summary.planReviewsMinted += 1; + } catch { + summary.errored += 1; + } + } + + return summary; +} diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 0f0c2132f..d6d210138 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -34,6 +34,11 @@ import { COACH_NUDGE_CRON, runCoachNudgeTick, } from "@/lib/jobs/coach-nudge"; +import { + COACH_REMINDER_SWEEP_QUEUE, + COACH_REMINDER_SWEEP_CRON, + runCoachReminderSweep, +} from "@/lib/jobs/coach-reminder-sweep"; import { MEDICATION_LOW_STOCK_QUEUE, MEDICATION_LOW_STOCK_CRON, @@ -159,6 +164,9 @@ const allQueues = [ // v1.15.20 — proactive Coach nudge. Without this entry the daily 05:15 // schedule silently no-ops and no nudge ever fires. COACH_NUDGE_QUEUE, + // v1.22 (M4) — daily Coach-reminder sweep. Without this entry the 05:20 + // schedule silently no-ops and no "remind me" reminder ever surfaces. + COACH_REMINDER_SWEEP_QUEUE, // v1.16.11 — daily medication low-stock pass. Without this entry the 09:00 // schedule silently no-ops and no low-stock alert ever fires. MEDICATION_LOW_STOCK_QUEUE, @@ -204,6 +212,10 @@ const schedules: ScheduleEntry[] = [ // the 04:45–04:55 score crons so the recovery-score trigger reads // settled rows. Deterministic triggers only — no AI call on this path. [COACH_NUDGE_QUEUE, COACH_NUDGE_CRON], + // v1.22 (M4) — daily 05:20 Europe/Berlin Coach-reminder sweep, just after + // the nudge tick. Flips overdue reminders to `due` + mints plan-review + // reminders from passed CoachPlan.reviewDate. In-app surface only; no push. + [COACH_REMINDER_SWEEP_QUEUE, COACH_REMINDER_SWEEP_CRON], // v1.16.11 — medication low-stock pass at 09:00 Europe/Berlin: a // supply alert is an errand prompt, so it fires at a time the user // can act on it. Once daily; the per-medication stamp keeps it at @@ -447,5 +459,36 @@ export async function registerStatusQueues( }, ); + // v1.22 (M4) — daily Coach-reminder sweep. Single-flight; flipping an overdue + // reminder to `due` and minting plan-review reminders are both idempotent + // across re-fires (an already-due reminder is not re-flipped; a plan whose + // reviewDate was cleared is not re-minted). No provider call, no push. + await boss.work( + COACH_REMINDER_SWEEP_QUEUE, + { localConcurrency: 1 }, + async () => { + await withBackgroundEvent("job.coach_reminder_sweep", async (evt) => { + try { + const summary = await runCoachReminderSweep( + getWorkerPrisma(), + new Date(), + ); + evt.setBackground({ + task_name: "job.coach_reminder_sweep", + result: { + reminders_due: summary.remindersDue, + plan_reviews_minted: summary.planReviewsMinted, + errored: summary.errored, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); + }, + ); + return allQueues; }