mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Evaluate the context cues the reminder grammar has promised since v1.22
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.
This commit is contained in:
1 parent
0cec8b07bd
commit
cd127010fe
11 files changed
+764
-91
No files matched your search
@@ -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: <T extends (...args: unknown[]) => 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<unknown>)();
|
||||
|
||||
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<unknown>)();
|
||||
|
||||
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<unknown>)()) as {
|
||||
error: null;
|
||||
};
|
||||
expect(res.error).toBeNull();
|
||||
expect(readStatus).toHaveBeenCalledWith("u1");
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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<unknown>) =>
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<CoachReminderContextCue, readonly MeasurementType[]>
|
||||
> = {
|
||||
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<SurfaceOutcome> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<SurfaceOutcome> {
|
||||
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;
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||
"connect_tickets_deleted",
|
||||
"connections_deleted",
|
||||
"considered",
|
||||
"context_surfaced",
|
||||
"created",
|
||||
"daily_rows_retired",
|
||||
"days_consolidated",
|
||||
|
||||
@@ -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<ReminderSatisfySummary> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user