mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(coach): capture and resurface "remind me" reminders
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.
This commit is contained in:
1 parent
9b61769a32
commit
27a8a59d67
9 files changed
+961
-3
No files matched your search
@@ -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<Response> {
|
||||
// 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<typeof prisma, "coachPlan" | "coachConversation">;
|
||||
type ReminderPrismaLike = Pick<typeof prisma, "coachReminder">;
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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: <kurz, in der Formulierung der Person, worum es geht>
|
||||
when: <optional: ISO-Datum JJJJ-MM-TT | relativ +Nd / +Nw | ein Kontext-Cue: NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN>
|
||||
metric: <optional, z. B. SLEEP, BLOOD_PRESSURE>
|
||||
---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: <short, in the user's own framing, what to bring back>
|
||||
when: <optional: ISO date YYYY-MM-DD | relative +Nd / +Nw | a context cue: NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN>
|
||||
metric: <optional, e.g. SLEEP, BLOOD_PRESSURE>
|
||||
---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<typeof prisma, "coachReminder">;
|
||||
|
||||
/**
|
||||
* 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<string | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown> = {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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: <kurzer Titel, z. B. Jährliches Blutbild>
|
||||
interval: <yearly | halfYearly | quarterly | monthly>
|
||||
---END---
|
||||
oder
|
||||
---SUGGEST-ACTION---
|
||||
action: reminder.note # eine Erinnerung, auf etwas zurückzukommen
|
||||
note: <kurz, worum es geht>
|
||||
when: <optional: ISO-Datum | +Nd / +Nw | NEXT_BP_LOGGED …>
|
||||
metric: <optional>
|
||||
---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: <short title, e.g. Annual blood panel>
|
||||
interval: <yearly | halfYearly | quarterly | monthly>
|
||||
---END---
|
||||
or
|
||||
---SUGGEST-ACTION---
|
||||
action: reminder.note # a reminder to revisit something
|
||||
note: <short, what to bring back>
|
||||
when: <optional: ISO date | +Nd / +Nw | NEXT_BP_LOGGED …>
|
||||
metric: <optional>
|
||||
---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<string, { id: string; rrule: string; labelKey: string }>
|
||||
> = 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<PrismaClient, "coachReminder" | "coachPlan">;
|
||||
|
||||
/**
|
||||
* 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<CoachReminderSweepSummary> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user