From cd127010fea30c859401ea0590d33191e9886ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 01:45:47 +0200 Subject: [PATCH] Evaluate the context cues the reminder grammar has promised since v1.22 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Coach reminder captured with NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED or NEXT_APP_OPEN confirmed to the user and then never fired: the daily sweep skipped context triggers and no other evaluator existed. Build the evaluator. The three logging cues resolve on evidence — a live matching-type measurement logged after capture — and ride the same eventful reminder-satisfy hook every ingest path already fires, with the daily sweep re-running them as the idempotent backstop. NEXT_APP_OPEN resolves on the nudge-status read (the poll that mounts with the app chrome), owner-only so a delegate's visit never satisfies it, with a capture grace so the session that created the reminder does not count as the next open. Every trigger delivers through the surfacing transaction extracted from the sweep: conversation + assistant message + status flip, all-or-nothing. --- .../nudge-status/__tests__/route.test.ts | 94 +++++++++ .../api/insights/coach/nudge-status/route.ts | 19 +- .../coach/__tests__/context-reminders.test.ts | 195 ++++++++++++++++++ src/lib/ai/coach/context-reminders.ts | 149 +++++++++++++ src/lib/ai/coach/reminder-surface.ts | 136 ++++++++++++ .../__tests__/coach-reminder-sweep.test.ts | 45 ++++ .../jobs/__tests__/reminder-satisfy.test.ts | 48 +++++ src/lib/jobs/coach-reminder-sweep.ts | 140 +++++-------- src/lib/jobs/job-outcome.ts | 1 + src/lib/jobs/measurement-reminder.ts | 26 ++- src/lib/jobs/reminder/register-status.ts | 2 + 11 files changed, 764 insertions(+), 91 deletions(-) create mode 100644 src/app/api/insights/coach/nudge-status/__tests__/route.test.ts create mode 100644 src/lib/ai/coach/__tests__/context-reminders.test.ts create mode 100644 src/lib/ai/coach/context-reminders.ts create mode 100644 src/lib/ai/coach/reminder-surface.ts diff --git a/src/app/api/insights/coach/nudge-status/__tests__/route.test.ts b/src/app/api/insights/coach/nudge-status/__tests__/route.test.ts new file mode 100644 index 000000000..06b273b0d --- /dev/null +++ b/src/app/api/insights/coach/nudge-status/__tests__/route.test.ts @@ -0,0 +1,94 @@ +/** + * GET /api/insights/coach/nudge-status — the NEXT_APP_OPEN evaluation + * hook. This poll mounts with the app chrome, which makes it the + * app-open signal, so the route runs the context evaluation before it + * computes the unread flag (owner only — a delegate opening the record + * is not the owner opening their app). + * + * Watched red: with the `evaluateCoachContextReminders` call removed + * from the route the first test fails on the hook assertion — the + * pre-fix state where NEXT_APP_OPEN reminders could never fire. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { requireRecordAuth, evaluateContext, readStatus } = vi.hoisted(() => ({ + requireRecordAuth: vi.fn(), + evaluateContext: vi.fn(async () => ({ surfaced: 0, errored: 0 })), + readStatus: vi.fn(async () => ({ unread: false, nudgedAt: null })), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireRecordAuth, +})); +vi.mock("@/lib/feature-flags", () => ({ + requireAssistantSurface: vi.fn(async () => undefined), +})); +vi.mock("@/lib/ai/coach/nudge-status", () => ({ + readCoachNudgeStatus: readStatus, +})); +vi.mock("@/lib/ai/coach/context-reminders", () => ({ + evaluateCoachContextReminders: evaluateContext, +})); +vi.mock("@/lib/db", () => ({ prisma: { __tag: "prisma" } })); +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null }), +})); + +import { GET } from "../route"; + +beforeEach(() => { + vi.clearAllMocks(); + readStatus.mockResolvedValue({ unread: false, nudgedAt: null }); +}); + +describe("nudge-status — NEXT_APP_OPEN hook", () => { + it("runs the app-open evaluation for the owner BEFORE computing the unread flag", async () => { + requireRecordAuth.mockResolvedValue({ + user: { id: "u1" }, + actor: { id: "u1" }, + }); + + await (GET as unknown as () => Promise)(); + + expect(evaluateContext).toHaveBeenCalledTimes(1); + const [prismaArg, userId, trigger] = evaluateContext.mock.calls[0]!; + expect((prismaArg as { __tag: string }).__tag).toBe("prisma"); + expect(userId).toBe("u1"); + expect(trigger).toBe("app-open"); + // Ordering: the surfaced message must be visible to this very read. + expect(evaluateContext.mock.invocationCallOrder[0]).toBeLessThan( + readStatus.mock.invocationCallOrder[0]!, + ); + }); + + it("never satisfies the cue for a switched caller", async () => { + requireRecordAuth.mockResolvedValue({ + user: { id: "owner" }, + actor: { id: "delegate" }, + }); + + await (GET as unknown as () => Promise)(); + + expect(evaluateContext).not.toHaveBeenCalled(); + expect(readStatus).toHaveBeenCalledWith("owner"); + }); + + it("keeps the badge alive when the evaluator fails", async () => { + requireRecordAuth.mockResolvedValue({ + user: { id: "u1" }, + actor: { id: "u1" }, + }); + evaluateContext.mockRejectedValueOnce(new Error("evaluator down")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const res = (await (GET as unknown as () => Promise)()) as { + error: null; + }; + expect(res.error).toBeNull(); + expect(readStatus).toHaveBeenCalledWith("u1"); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/src/app/api/insights/coach/nudge-status/route.ts b/src/app/api/insights/coach/nudge-status/route.ts index 50aae7591..5c3d92fa7 100644 --- a/src/app/api/insights/coach/nudge-status/route.ts +++ b/src/app/api/insights/coach/nudge-status/route.ts @@ -26,6 +26,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status"; +import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders"; +import { prisma } from "@/lib/db"; export const GET = apiHandler(async () => { // The RECORD's unread signal, not the caller's. The tempting reading is that @@ -43,11 +45,26 @@ export const GET = apiHandler(async () => { // switch and that is not in tension with this: chat spends the owner's AI // budget and writes into their conversation, and reading whether the thread // has something unopened does neither. - const { user } = await requireRecordAuth("read", "record"); + const { user, actor } = await requireRecordAuth("read", "record"); // Operator-level flag — an `AppSettings` singleton, unaffected by whose // record is open. await requireAssistantSurface("coach"); + // NEXT_APP_OPEN Coach reminders resolve here: this poll mounts with the + // app chrome, which makes it the app-open signal itself. Owner-only — a + // delegate opening the record is not the owner opening their app, so a + // switched caller never satisfies the cue. Awaited so the reminder's + // message is already in the thread when this very response computes the + // unread flag; fault-isolated so an evaluator hiccup can never break the + // badge. Steady-state cost is one indexed query returning zero rows. + if (actor.id === user.id) { + try { + await evaluateCoachContextReminders(prisma, user.id, "app-open"); + } catch (err) { + console.warn("[coach] app-open context evaluation failed", err); + } + } + // Shared with the `/coach` RSC prefetch (`src/app/coach/page.tsx`) so both // readers compute the unread signal identically. return apiSuccess(await readCoachNudgeStatus(user.id)); diff --git a/src/lib/ai/coach/__tests__/context-reminders.test.ts b/src/lib/ai/coach/__tests__/context-reminders.test.ts new file mode 100644 index 000000000..8772b6a8d --- /dev/null +++ b/src/lib/ai/coach/__tests__/context-reminders.test.ts @@ -0,0 +1,195 @@ +/** + * Context-cue evaluation for Coach reminders — the evaluator the capture + * grammar promised since v1.22 and never had. + * + * Watched red: with `evaluateCoachContextReminders` gutted to return + * `{ surfaced: 0, errored: 0 }` unconditionally (the pre-fix reality — + * captured, confirmed, never fired), every surfacing test below fails + * naming the missing conversation write. Verified red against that stub. + */ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: (buf: Uint8Array) => Buffer.from(buf).toString("utf8"), + encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)), +})); + +import { + APP_OPEN_GRACE_MS, + evaluateCoachContextReminders, +} from "../context-reminders"; + +const NOW = new Date("2026-06-27T12:00:00.000Z"); + +function bytes(tag: string): Uint8Array { + return new Uint8Array(Buffer.from(tag, "utf8")); +} + +function minutesBefore(now: Date, minutes: number): Date { + return new Date(now.getTime() - minutes * 60 * 1000); +} + +function makePrisma(options: { + reminders?: { + id: string; + noteEncrypted: Uint8Array; + contextCue: string; + createdAt: Date; + }[]; + /** Measurement evidence per findFirst call: null = none. */ + evidence?: { id: string } | null; +}) { + const tx = { + coachConversation: { create: vi.fn(async () => ({ id: "c1" })) }, + coachMessage: { create: vi.fn(async () => ({ id: "m1" })) }, + coachReminder: { updateMany: vi.fn(async () => ({ count: 1 })) }, + }; + const prisma = { + coachReminder: { + findMany: vi.fn(async () => options.reminders ?? []), + }, + measurement: { + findFirst: vi.fn(async () => options.evidence ?? null), + }, + user: { findUnique: vi.fn(async () => ({ locale: "en" })) }, + $transaction: vi.fn(async (fn: (t: typeof tx) => Promise) => + fn(tx), + ), + tx, + }; + return prisma; +} + +describe("evaluateCoachContextReminders — measurement cues", () => { + it("surfaces a NEXT_WEIGHT_LOGGED reminder when a matching row was logged after capture", async () => { + const createdAt = minutesBefore(NOW, 60); + const prisma = makePrisma({ + reminders: [ + { + id: "r1", + noteEncrypted: bytes("check in on the diet"), + contextCue: "NEXT_WEIGHT_LOGGED", + createdAt, + }, + ], + evidence: { id: "m-weight" }, + }); + + const outcome = await evaluateCoachContextReminders( + prisma as never, + "u1", + "measurement", + NOW, + ); + + expect(outcome.surfaced).toBe(1); + // Evidence query: matching types, live rows only, logged AFTER capture + // (row createdAt, not measuredAt — back-dating still counts as logging). + expect(prisma.measurement.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: "u1", + type: { in: ["WEIGHT"] }, + deletedAt: null, + createdAt: { gt: createdAt }, + }), + }), + ); + // Delivered through the shared surfacing transaction: message + flip. + expect(prisma.tx.coachMessage.create).toHaveBeenCalledTimes(1); + expect(prisma.tx.coachReminder.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: { in: ["r1"] } }, + data: expect.objectContaining({ status: "surfaced" }), + }), + ); + }); + + it("leaves the reminder untouched when no matching measurement exists", async () => { + const prisma = makePrisma({ + reminders: [ + { + id: "r1", + noteEncrypted: bytes("check in on the diet"), + contextCue: "NEXT_BP_LOGGED", + createdAt: minutesBefore(NOW, 60), + }, + ], + evidence: null, + }); + + const outcome = await evaluateCoachContextReminders( + prisma as never, + "u1", + "measurement", + NOW, + ); + + expect(outcome.surfaced).toBe(0); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("never evaluates NEXT_APP_OPEN on the measurement trigger", async () => { + const prisma = makePrisma({ reminders: [] }); + await evaluateCoachContextReminders( + prisma as never, + "u1", + "measurement", + NOW, + ); + const where = prisma.coachReminder.findMany.mock.calls[0]![0]!.where as { + contextCue: { in: string[] }; + }; + expect(where.contextCue.in).not.toContain("NEXT_APP_OPEN"); + }); +}); + +describe("evaluateCoachContextReminders — app open", () => { + it("surfaces a NEXT_APP_OPEN reminder once the capture grace has passed", async () => { + const prisma = makePrisma({ + reminders: [ + { + id: "r2", + noteEncrypted: bytes("look at the sleep chart"), + contextCue: "NEXT_APP_OPEN", + createdAt: new Date(NOW.getTime() - APP_OPEN_GRACE_MS), + }, + ], + }); + + const outcome = await evaluateCoachContextReminders( + prisma as never, + "u1", + "app-open", + NOW, + ); + + expect(outcome.surfaced).toBe(1); + // The app-open leg needs no measurement evidence. + expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); + expect(prisma.tx.coachMessage.create).toHaveBeenCalledTimes(1); + }); + + it("stays quiet inside the capture grace — the session that created it is not the next open", async () => { + const prisma = makePrisma({ + reminders: [ + { + id: "r2", + noteEncrypted: bytes("look at the sleep chart"), + contextCue: "NEXT_APP_OPEN", + createdAt: minutesBefore(NOW, 1), + }, + ], + }); + + const outcome = await evaluateCoachContextReminders( + prisma as never, + "u1", + "app-open", + NOW, + ); + + expect(outcome.surfaced).toBe(0); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/ai/coach/context-reminders.ts b/src/lib/ai/coach/context-reminders.ts new file mode 100644 index 000000000..7a2fe1b9d --- /dev/null +++ b/src/lib/ai/coach/context-reminders.ts @@ -0,0 +1,149 @@ +/** + * Context-cue evaluation for Coach reminders (`triggerKind: "context"`). + * + * The capture grammar has advertised four cues since v1.22 — + * NEXT_BP_LOGGED / NEXT_WEIGHT_LOGGED / NEXT_SLEEP_LOGGED / + * NEXT_APP_OPEN — and the resolver persisted them, but no evaluator + * existed: the user got a confirmation for a reminder that could never + * fire. This module is that evaluator. + * + * Two triggers, both converging on the same surfacing transaction the + * date sweep uses (`reminder-surface.ts`): + * + * - `"measurement"` — the three logging cues. Called by the + * `reminder-satisfy` worker right after any measurement ingest (the + * same eventful hook that auto-resolves Vorsorge reminders), and once + * a day by the Coach-reminder sweep as the idempotent backstop. A cue + * is satisfied by EVIDENCE, not by the caller's claim: a live row of + * a matching type must exist that was LOGGED (row `createdAt`, not + * `measuredAt` — back-dating an old paper reading still counts as + * logging) after the reminder was captured. + * + * - `"app-open"` — NEXT_APP_OPEN. Evaluated on the nudge-status read, + * the cheapest request that fires exactly when the app chrome mounts. + * A capture-time grace keeps the reminder from firing into the very + * session that created it: "next time you open the app" must not + * mean "in the message list you are already looking at". After + * surfacing the row leaves the candidate set (`status: "surfaced"`), + * so the steady-state cost per read is one indexed query returning + * zero rows. + * + * Server-only — reads `@/lib/db`-shaped clients (injected). + */ +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; + +import type { CoachReminderContextCue } from "@/lib/ai/coach/reminders"; +import { + surfaceCoachReminders, + type SurfaceOutcome, +} from "@/lib/ai/coach/reminder-surface"; + +/** Which measurement types count as evidence for each logging cue. */ +export const CONTEXT_CUE_MEASUREMENT_TYPES: Partial< + Record +> = { + NEXT_BP_LOGGED: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + NEXT_WEIGHT_LOGGED: ["WEIGHT"], + NEXT_SLEEP_LOGGED: ["SLEEP_DURATION"], +}; + +/** + * How long after capture a NEXT_APP_OPEN reminder stays quiet. The + * capture happens inside a live Coach chat — the user is demonstrably in + * the app — so an ungated evaluation would surface the reminder on the + * very next status poll of the same session. + */ +export const APP_OPEN_GRACE_MS = 15 * 60 * 1000; + +export type ContextTrigger = "measurement" | "app-open"; + +type ContextPrisma = Pick< + PrismaClient, + | "coachReminder" + | "coachConversation" + | "coachMessage" + | "measurement" + | "user" + | "$transaction" +>; + +/** Bound one evaluation pass (a user rarely has >50 reminders — the capture cap). */ +const CANDIDATE_BATCH = 100; + +/** + * Evaluate one user's context reminders for one trigger and surface the + * satisfied ones through the shared delivery transaction. Fault-isolated + * end to end — a failed evaluation must never fail the ingest or the + * status read that hosts it. + */ +export async function evaluateCoachContextReminders( + prisma: ContextPrisma, + userId: string, + trigger: ContextTrigger, + now: Date = new Date(), +): Promise { + const cues: CoachReminderContextCue[] = + trigger === "app-open" + ? ["NEXT_APP_OPEN"] + : (Object.keys( + CONTEXT_CUE_MEASUREMENT_TYPES, + ) as CoachReminderContextCue[]); + + // `due` rides along with `active` for the same reason as the date sweep: + // a row a partial write left mid-flight must resolve, not stick. + const candidates = await prisma.coachReminder.findMany({ + where: { + userId, + deletedAt: null, + status: { in: ["active", "due"] }, + triggerKind: "context", + contextCue: { in: cues }, + }, + take: CANDIDATE_BATCH, + orderBy: { createdAt: "asc" }, + select: { + id: true, + noteEncrypted: true, + contextCue: true, + createdAt: true, + }, + }); + if (candidates.length === 0) return { surfaced: 0, errored: 0 }; + + const satisfied: { id: string; noteEncrypted: Uint8Array }[] = []; + let errored = 0; + + for (const reminder of candidates) { + try { + if (reminder.contextCue === "NEXT_APP_OPEN") { + if (now.getTime() - reminder.createdAt.getTime() >= APP_OPEN_GRACE_MS) { + satisfied.push(reminder); + } + continue; + } + const types = + CONTEXT_CUE_MEASUREMENT_TYPES[ + reminder.contextCue as CoachReminderContextCue + ]; + if (!types) continue; // unknown cue — never guess + // Evidence check: a live matching-type row logged after capture. + const evidence = await prisma.measurement.findFirst({ + where: { + userId, + type: { in: [...types] }, + deletedAt: null, + createdAt: { gt: reminder.createdAt }, + }, + select: { id: true }, + }); + if (evidence) satisfied.push(reminder); + } catch { + errored += 1; + } + } + + if (satisfied.length === 0) return { surfaced: 0, errored }; + + const outcome = await surfaceCoachReminders(prisma, userId, satisfied, now); + return { surfaced: outcome.surfaced, errored: errored + outcome.errored }; +} diff --git a/src/lib/ai/coach/reminder-surface.ts b/src/lib/ai/coach/reminder-surface.ts new file mode 100644 index 000000000..f10056678 --- /dev/null +++ b/src/lib/ai/coach/reminder-surface.ts @@ -0,0 +1,136 @@ +/** + * The ONE surfacing step every Coach-reminder trigger converges on. + * + * Extracted from the daily sweep so the context-cue evaluators + * (`context-reminders.ts`) deliver through the exact same transaction a + * date reminder does: conversation + assistant message + status flip, + * all-or-nothing. A partial write is the failure this shape exists to + * remove — a status without a message is a badge pointing at nothing, + * and a message without a status change re-surfaces the same note on + * every following trigger. + * + * Deterministic and provider-free: the message body is a localized + * template around the user's OWN note text — the words they asked to be + * reminded of, never anything generated. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { Locale } from "@/lib/i18n/config"; +import { defaultLocale, locales } from "@/lib/i18n/config"; + +/** How many notes one surfacing message carries before the rest wait. */ +export const MAX_NOTES_PER_MESSAGE = 6; + +export type ReminderSurfacePrisma = Pick< + PrismaClient, + | "coachReminder" + | "coachConversation" + | "coachMessage" + | "user" + | "$transaction" +>; + +function resolveLocale(locale: string | null | undefined): Locale { + return locales.includes(locale as Locale) + ? (locale as Locale) + : defaultLocale; +} + +/** + * The surfacing message for one user's due reminders. Deterministic, + * localized, and built only from the user's own note text. + */ +export function buildReminderSurfaceMessage( + notes: readonly string[], + locale: string | null | undefined, +): { title: string; body: string } { + const t = getServerTranslator(resolveLocale(locale)).t; + const title = t("coachNudges.reminderSurfacedTitle"); + const lead = + notes.length === 1 + ? t("coachNudges.reminderSurfacedLeadOne") + : t("coachNudges.reminderSurfacedLeadMany", { count: notes.length }); + const body = [lead, ...notes.map((n) => `• ${n}`)].join("\n"); + return { title, body }; +} + +export interface SurfaceOutcome { + /** Reminders that reached the conversation in this call. */ + surfaced: number; + /** Undecryptable notes / failed writes (fault-isolated). */ + errored: number; +} + +/** + * Surface a batch of one user's reminders into their Coach thread. + * + * Decrypt is fault-isolated per row — one undecryptable note must not + * cost the user the reminders beside it. At most `MAX_NOTES_PER_MESSAGE` + * notes ride one message; the remainder keep their status and surface on + * the next trigger. A write failure leaves every row untouched: no badge + * claims a message that was never written. + */ +export async function surfaceCoachReminders( + prisma: ReminderSurfacePrisma, + userId: string, + rows: readonly { id: string; noteEncrypted: Uint8Array }[], + now: Date, +): Promise { + const outcome: SurfaceOutcome = { surfaced: 0, errored: 0 }; + if (rows.length === 0) return outcome; + + const usable: { id: string; note: string }[] = []; + for (const row of rows) { + try { + usable.push({ id: row.id, note: decryptFromBytes(row.noteEncrypted) }); + } catch { + outcome.errored += 1; + } + } + if (usable.length === 0) return outcome; + + try { + const batch = usable.slice(0, MAX_NOTES_PER_MESSAGE); + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + const { title, body } = buildReminderSurfaceMessage( + batch.map((r) => r.note), + user?.locale, + ); + + await prisma.$transaction(async (tx) => { + const conversation = await tx.coachConversation.create({ + data: { userId, title: title.slice(0, 80) }, + }); + await tx.coachMessage.create({ + data: { + conversationId: conversation.id, + role: "assistant", + encryptedContent: encryptToBytes(body), + metricSourceJson: null, + // Same tag the proactive nudge uses — this IS a proactive message, + // and the nudge cron's frequency gate reads the tag back. + providerType: "nudge", + promptVersion: null, + }, + }); + await tx.coachReminder.updateMany({ + where: { id: { in: batch.map((r) => r.id) } }, + data: { + status: "surfaced", + lastSurfacedAt: now, + surfaceCount: { increment: 1 }, + }, + }); + }); + outcome.surfaced += batch.length; + } catch { + outcome.errored += 1; + } + + return outcome; +} diff --git a/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts index fee4c27f4..4927ce4b2 100644 --- a/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts +++ b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts @@ -19,6 +19,14 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)), })); +// The context-cue backstop delegates to the shared evaluator; its own +// behaviour is pinned in `ai/coach/__tests__/context-reminders.test.ts`. +const contextEvaluateMock = vi.fn(async () => ({ surfaced: 0, errored: 0 })); +vi.mock("@/lib/ai/coach/context-reminders", () => ({ + evaluateCoachContextReminders: (...args: unknown[]) => + contextEvaluateMock(...args), +})); + import { runCoachReminderSweep } from "../coach-reminder-sweep"; const NOW = new Date("2026-06-27T05:20:00.000Z"); @@ -188,6 +196,43 @@ describe("runCoachReminderSweep", () => { expect(flip[0].where.id.in).toEqual(["r-ok"]); }); + // Watched red: with the context-backstop pass removed from the sweep this + // fails on the evaluator assertion — the pre-fix sweep skipped context + // reminders entirely ("follow-on (F4)"). + it("re-evaluates the measurement context cues as the daily backstop", async () => { + const prisma = makePrisma({}); + contextEvaluateMock.mockClear(); + contextEvaluateMock.mockResolvedValue({ surfaced: 1, errored: 0 }); + prisma.coachReminder.findMany + .mockResolvedValueOnce([]) // step 2: overdue date reminders + .mockResolvedValueOnce([ + { userId: "u1" }, + { userId: "u1" }, + { userId: "u2" }, + ] as never); // step 3: pending context rows + + const summary = await runCoachReminderSweep(prisma as never, NOW); + + // One evaluation per distinct user, measurement trigger only. + expect(contextEvaluateMock).toHaveBeenCalledTimes(2); + expect(contextEvaluateMock).toHaveBeenCalledWith( + prisma, + "u1", + "measurement", + NOW, + ); + expect(summary.contextSurfaced).toBe(2); + + // NEXT_APP_OPEN is deliberately outside the backstop: only a real + // app-open signal may satisfy it. + const where = ( + prisma.coachReminder.findMany.mock.calls[1] as unknown as [ + { where: { contextCue: { not: string } } }, + ] + )[0].where; + expect(where.contextCue).toEqual({ not: "NEXT_APP_OPEN" }); + }); + it("skips an undecryptable plan without sinking the tick", async () => { const prisma = makePrisma({ plans: [ diff --git a/src/lib/jobs/__tests__/reminder-satisfy.test.ts b/src/lib/jobs/__tests__/reminder-satisfy.test.ts index ea5d9f841..403389196 100644 --- a/src/lib/jobs/__tests__/reminder-satisfy.test.ts +++ b/src/lib/jobs/__tests__/reminder-satisfy.test.ts @@ -16,6 +16,17 @@ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => bossInstance, })); +// The Coach context-cue evaluation rides this hook; its behaviour is pinned +// in `src/lib/ai/coach/__tests__/context-reminders.test.ts`. Stubbed here so +// the Vorsorge assertions stay isolated; one test below pins that the hook +// actually fires (watched red: removing the call site in +// `runReminderSatisfyForUser` fails it). +const evaluateContextMock = vi.fn(async () => ({ surfaced: 0, errored: 0 })); +vi.mock("@/lib/ai/coach/context-reminders", () => ({ + evaluateCoachContextReminders: (...args: unknown[]) => + evaluateContextMock(...args), +})); + import { enqueueReminderSatisfy, REMINDER_SATISFY_QUEUE, @@ -228,6 +239,43 @@ describe("runReminderSatisfyForUser", () => { expect(updates).toHaveLength(0); }); + it("evaluates the Coach context cues on the same ingest event", async () => { + const now = new Date("2026-06-14T20:00:00Z"); + const { prisma } = makePrisma({ reminders: [] }); + evaluateContextMock.mockClear(); + evaluateContextMock.mockResolvedValueOnce({ surfaced: 2, errored: 0 }); + + const summary = await runReminderSatisfyForUser(prisma as never, "u1", now); + + expect(evaluateContextMock).toHaveBeenCalledWith( + prisma, + "u1", + "measurement", + now, + ); + expect(summary.coachContextSurfaced).toBe(2); + }); + + it("never fails the Vorsorge resolution over a Coach context error", async () => { + const measuredAt = new Date("2026-06-14T18:00:00Z"); + const { prisma } = makePrisma({ + reminders: [reminderRow()], + measurement: { measuredAt }, + }); + evaluateContextMock.mockClear(); + evaluateContextMock.mockRejectedValueOnce(new Error("coach down")); + + const summary = await runReminderSatisfyForUser( + prisma as never, + "u1", + new Date("2026-06-14T20:00:00Z"), + { isModuleEnabled: vi.fn(async () => true) }, + ); + + expect(summary.satisfied).toBe(1); + expect(summary.failed).toBe(1); + }); + it("is forward-only: a stale event is a no-op (cron + hook converge)", async () => { const { prisma, updates } = makePrisma({ reminders: [ diff --git a/src/lib/jobs/coach-reminder-sweep.ts b/src/lib/jobs/coach-reminder-sweep.ts index 010bde5be..bd89611cf 100644 --- a/src/lib/jobs/coach-reminder-sweep.ts +++ b/src/lib/jobs/coach-reminder-sweep.ts @@ -38,16 +38,19 @@ * a deterministic localized template around the user's OWN note text — the * words they asked to be reminded of, never anything fabricated. * - * 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. + * Context-cue reminders (`triggerKind: "context"`) are evaluated eventfully: + * the measurement cues by the `reminder-satisfy` worker after every ingest, + * `NEXT_APP_OPEN` on the nudge-status read (`ai/coach/context-reminders.ts`). + * This sweep re-runs the measurement-cue evaluation once a day as the + * idempotent backstop for any ingest path that never enqueued — the same + * cron-behind-event pattern the Vorsorge engine uses. Every trigger + * converges on the ONE surfacing transaction in `ai/coach/reminder-surface.ts`. */ import type { PrismaClient } from "@/generated/prisma/client"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; -import { getServerTranslator } from "@/lib/i18n/server-translator"; -import type { Locale } from "@/lib/i18n/config"; -import { defaultLocale, locales } from "@/lib/i18n/config"; +import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders"; +import { surfaceCoachReminders } from "@/lib/ai/coach/reminder-surface"; export const COACH_REMINDER_SWEEP_QUEUE = "coach-reminder-sweep"; // Daily at 05:20 Europe/Berlin — just after the 05:15 nudge tick so both @@ -58,13 +61,15 @@ export const COACH_REMINDER_SWEEP_CRON = "20 5 * * *"; const PLAN_REVIEW_BATCH = 200; /** Bound the surfacing fan-out per tick for the same reason. */ const SURFACE_BATCH = 500; -/** How many notes one surfacing message carries before the rest wait a tick. */ -const MAX_NOTES_PER_MESSAGE = 6; +/** Bound the context-backstop fan-out per tick for the same reason. */ +const CONTEXT_BACKSTOP_BATCH = 500; export interface CoachReminderSweepSummary { /** Reminders whose moment arrived AND that reached the conversation. */ remindersDue: number; planReviewsMinted: number; + /** Context-cue reminders the daily backstop surfaced. */ + contextSurfaced: number; errored: number; } @@ -74,35 +79,11 @@ type SweepPrisma = Pick< | "coachPlan" | "coachConversation" | "coachMessage" + | "measurement" | "user" | "$transaction" >; -function resolveLocale(locale: string | null | undefined): Locale { - return locales.includes(locale as Locale) - ? (locale as Locale) - : defaultLocale; -} - -/** - * The surfacing message for one user's due reminders. Deterministic, localized, - * and built only from the user's own note text — the sweep composes, it never - * generates. - */ -export function buildReminderSurfaceMessage( - notes: readonly string[], - locale: string | null | undefined, -): { title: string; body: string } { - const t = getServerTranslator(resolveLocale(locale)).t; - const title = t("coachNudges.reminderSurfacedTitle"); - const lead = - notes.length === 1 - ? t("coachNudges.reminderSurfacedLeadOne") - : t("coachNudges.reminderSurfacedLeadMany", { count: notes.length }); - const body = [lead, ...notes.map((n) => `• ${n}`)].join("\n"); - return { title, body }; -} - /** * Run one sweep tick. Idempotent: a reminder already `surfaced` is not * re-surfaced, and a plan whose `reviewDate` was cleared on a prior tick is not @@ -115,6 +96,7 @@ export async function runCoachReminderSweep( const summary: CoachReminderSweepSummary = { remindersDue: 0, planReviewsMinted: 0, + contextSurfaced: 0, errored: 0, }; @@ -207,65 +189,45 @@ export async function runCoachReminderSweep( } for (const [userId, rows] of byUser) { + // The shared surfacing step: decrypt fault-isolated per row, one + // localized message per user, conversation + message + status flip in + // ONE transaction. On failure the rows stay where they were — the next + // tick retries, and until then no badge claims a message that was + // never written. + const outcome = await surfaceCoachReminders(prisma, userId, rows, now); + summary.remindersDue += outcome.surfaced; + summary.errored += outcome.errored; + } + + // ── 3. context-cue backstop ─────────────────────────────────── + // + // The measurement cues fire eventfully from the `reminder-satisfy` + // worker; this daily pass re-evaluates them so an ingest path that + // never enqueued (boss unavailable, direct DB write) still resolves + // within a day. NEXT_APP_OPEN is deliberately absent: only a real + // app-open signal may satisfy it. + const contextRows = await prisma.coachReminder.findMany({ + where: { + deletedAt: null, + status: { in: ["active", "due"] }, + triggerKind: "context", + contextCue: { not: "NEXT_APP_OPEN" }, + }, + take: CONTEXT_BACKSTOP_BATCH, + orderBy: { createdAt: "asc" }, + select: { userId: true }, + }); + for (const userId of new Set(contextRows.map((r) => r.userId))) { try { - // Decrypt fault-isolated per row: one undecryptable note must not cost - // the user the reminders that sit beside it. - const usable: { id: string; note: string }[] = []; - for (const row of rows) { - try { - usable.push({ - id: row.id, - note: decryptFromBytes(row.noteEncrypted), - }); - } catch { - summary.errored += 1; - } - } - if (usable.length === 0) continue; - - const batch = usable.slice(0, MAX_NOTES_PER_MESSAGE); - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { locale: true }, - }); - const { title, body } = buildReminderSurfaceMessage( - batch.map((r) => r.note), - user?.locale, + const outcome = await evaluateCoachContextReminders( + prisma, + userId, + "measurement", + now, ); - - // Conversation + message + status flip in ONE transaction. A partial - // write is the failure this whole change exists to remove: a status - // without a message is a badge pointing at nothing, and a message - // without a status change re-surfaces the same note every night. - await prisma.$transaction(async (tx) => { - const conversation = await tx.coachConversation.create({ - data: { userId, title: title.slice(0, 80) }, - }); - await tx.coachMessage.create({ - data: { - conversationId: conversation.id, - role: "assistant", - encryptedContent: encryptToBytes(body), - metricSourceJson: null, - // Same tag the proactive nudge uses — this IS a proactive message, - // and the nudge cron's frequency gate reads the tag back. - providerType: "nudge", - promptVersion: null, - }, - }); - await tx.coachReminder.updateMany({ - where: { id: { in: batch.map((r) => r.id) } }, - data: { - status: "surfaced", - lastSurfacedAt: now, - surfaceCount: { increment: 1 }, - }, - }); - }); - summary.remindersDue += batch.length; + summary.contextSurfaced += outcome.surfaced; + summary.errored += outcome.errored; } catch { - // Leave the rows where they were: the next tick retries, and until then - // no badge claims a message that was never written. summary.errored += 1; } } diff --git a/src/lib/jobs/job-outcome.ts b/src/lib/jobs/job-outcome.ts index 8120255e9..c7b7cf87b 100644 --- a/src/lib/jobs/job-outcome.ts +++ b/src/lib/jobs/job-outcome.ts @@ -54,6 +54,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "connect_tickets_deleted", "connections_deleted", "considered", + "context_surfaced", "created", "daily_rows_retired", "days_consolidated", diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index c6a467ffa..49a12d2dc 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -43,6 +43,7 @@ import { } from "@/lib/measurement-reminders/scheduling"; import { findSatisfyingEvent } from "@/lib/measurement-reminders/resolve"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; +import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders"; /** * v1.18.0 — map a reminder's `measurementType` to the toggleable module @@ -387,6 +388,8 @@ export interface ReminderSatisfySummary { satisfied: number; skippedModuleDisabled: number; skippedNoEvent: number; + /** Coach context-cue reminders the same ingest event surfaced. */ + coachContextSurfaced: number; failed: number; } @@ -413,7 +416,6 @@ export async function runReminderSatisfyForUser( now: Date, options: { isModuleEnabled?: typeof isModuleEnabled } = {}, ): Promise { - void now; const moduleGate = options.isModuleEnabled ?? isModuleEnabled; const summary: ReminderSatisfySummary = { @@ -421,6 +423,7 @@ export async function runReminderSatisfyForUser( satisfied: 0, skippedModuleDisabled: 0, skippedNoEvent: 0, + coachContextSurfaced: 0, failed: 0, }; @@ -478,6 +481,27 @@ export async function runReminderSatisfyForUser( } } + // Coach context-cue reminders ("next time you log X") ride the same + // eventful hook: the measurement that satisfies a Vorsorge cadence is + // the same evidence a NEXT_*_LOGGED cue waits for. Fault-isolated — a + // context failure never costs the Vorsorge resolution above. + try { + const outcome = await evaluateCoachContextReminders( + prisma, + userId, + "measurement", + now, + ); + summary.coachContextSurfaced = outcome.surfaced; + summary.failed += outcome.errored; + } catch (err: unknown) { + summary.failed += 1; + const message = err instanceof Error ? err.message : String(err); + getEvent()?.addWarning( + `reminder-satisfy coach context evaluation failed for ${userId}: ${message}`, + ); + } + return summary; } diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 3771f9ab2..22cff8e9b 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -844,12 +844,14 @@ export async function registerStatusQueues( result: { reminders_due: summary.remindersDue, plan_reviews_minted: summary.planReviewsMinted, + context_surfaced: summary.contextSurfaced, errored: summary.errored, }, }); return jobDone({ reminders_due: summary.remindersDue, plan_reviews_minted: summary.planReviewsMinted, + context_surfaced: summary.contextSurfaced, errored: summary.errored, }); } catch (err) {