mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(coach): warm, localized proactive nudge with opt-in AI enrichment
Rework the proactive Coach nudge so it reads like one human checking in on
another instead of a terse English alert.
Copy: the title is a name-led greeting that rotates day to day ("Guten
Morgen, …" / "Hey …"; the 05:15 frame makes a morning opener safe) and the
body is one calm observation plus a gentle invite — never an imperative.
Drop the verbatim quoting of the user's decrypted self-context sentence:
the focus is referenced only by presence, abstractly, and is no longer
decrypted at all. Remove the 80-char mid-word clamp; each channel composes
to a length budget instead.
Cadence: suppress the nudge when the user already talked to the Coach in
the last ~24 h. With the existing rolling frequency cap (never two in a
window) and the no-trigger-no-nudge rule, the surface stays anti-nag.
AI enrichment (opt-in via the coach prefs blob, default off): when a
provider is healthy the body is composed through the model under hard
guards — a per-user budget gate, a tight per-call timeout, a per-tick
ceiling so the sequential tick can't stall across the user base, and the
outbound safety screen on the output. Any error, timeout, budget refusal
or unsafe reply falls silently back to the deterministic template, which is
always the default and the fail-closed fallback.
Localized greetings, per-trigger bodies and the focus note across all six
locales; the prefs opt-in is surfaced in the notification settings.
This commit is contained in:
1 parent
54c9b4206f
commit
e216a9d678
16 files changed
+818
-237
No files matched your search
@@ -103,6 +103,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -139,6 +140,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -177,6 +179,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -226,6 +229,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -251,6 +255,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -280,6 +285,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -301,6 +307,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -375,6 +382,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -414,6 +422,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -439,6 +448,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -483,6 +493,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
|
||||
@@ -34,6 +34,7 @@ interface CoachPrefsShape {
|
||||
nudgeRoutine: boolean;
|
||||
nudgeFrequency: "weekly" | "biweekly";
|
||||
ambientSuggestions: boolean;
|
||||
nudgeAiComposed: boolean;
|
||||
}
|
||||
|
||||
interface NotificationPrefsShape {
|
||||
@@ -47,6 +48,7 @@ const COACH_PREF_DEFAULTS: CoachPrefsShape = {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
};
|
||||
|
||||
const GROUP_FIELDS = [
|
||||
@@ -254,6 +256,28 @@ export function CoachNudgeCard({
|
||||
</option>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
{/* v1.25.0 — opt-in for AI-composed nudge copy. Off by default: the
|
||||
deterministic warm template is the baseline and the fail-closed
|
||||
fallback; this only lets the model phrase the nudge when a
|
||||
provider is healthy. */}
|
||||
<div className="flex min-h-11 items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">
|
||||
{t("notifications.coachNudge.aiComposedLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("notifications.coachNudge.aiComposedDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={resolved.nudgeAiComposed}
|
||||
onCheckedChange={(next) =>
|
||||
patchCoach({ nudgeAiComposed: next }, null)
|
||||
}
|
||||
disabled={!isAuthenticated || saving}
|
||||
aria-label={t("notifications.coachNudge.aiComposedAria")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* v1.25.0 — proactive ambient SUGGESTIONS opt-out. Independent of the
|
||||
|
||||
@@ -97,6 +97,16 @@ export const AI_BUDGETS = {
|
||||
*/
|
||||
coach: { temperature: 0.4, maxTokens: 600 },
|
||||
|
||||
/**
|
||||
* v1.25.0 — proactive Coach nudge enrichment (opt-in). One short, warm,
|
||||
* localized body of 1-2 sentences — no figures, no greeting (the app adds
|
||||
* the name-led greeting deterministically). 160 tokens comfortably covers
|
||||
* it; the tight `timeoutMs` keeps the sequential 05:15 tick from stalling
|
||||
* on a slow provider (any timeout falls back to the template). Temperature
|
||||
* a touch higher for warmth without drift.
|
||||
*/
|
||||
coachNudge: { temperature: 0.6, maxTokens: 160, timeoutMs: 9000 },
|
||||
|
||||
/**
|
||||
* Coach rolling-conversation summary worker — a compact summary of the
|
||||
* elided older turns. 200 tokens.
|
||||
|
||||
@@ -17,7 +17,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import {
|
||||
COACH_NUDGE_COMPLIANCE_MIN_DOSES,
|
||||
COACH_NUDGE_FOCUS_MAX_CHARS,
|
||||
COACH_NUDGE_SCORE_DROP,
|
||||
COACH_NUDGE_SLEEP_MIN_NIGHTS,
|
||||
COACH_NUDGE_TRIGGER_GROUPS,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
evaluateSleepDebtTrigger,
|
||||
evaluateWeightTrigger,
|
||||
findTriggerForUser,
|
||||
resolveGreetingName,
|
||||
runCoachNudgeTick,
|
||||
} from "../coach-nudge";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
@@ -259,66 +259,142 @@ describe("findTriggerForUser RECOVERY_SCORE canonical resolution", () => {
|
||||
});
|
||||
|
||||
describe("buildCoachNudgePayload", () => {
|
||||
it("resolves the German payload", () => {
|
||||
const { title, body } = buildCoachNudgePayload("bp", "de");
|
||||
expect(title).toBe("Blutdruck im Wochenmittel erhöht");
|
||||
const ALL_TRIGGERS = [
|
||||
"compliance",
|
||||
"bp",
|
||||
"score",
|
||||
"selfContext",
|
||||
"weight",
|
||||
"sleepDebt",
|
||||
"measurementGap",
|
||||
] as const;
|
||||
|
||||
it("opens the title with a localized morning greeting and the name", () => {
|
||||
const { title, body } = buildCoachNudgePayload("bp", "de", {
|
||||
name: "Marc",
|
||||
openerSeed: 0,
|
||||
});
|
||||
expect(title).toBe("Guten Morgen, Marc");
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resolves the English payload", () => {
|
||||
const { title } = buildCoachNudgePayload("compliance", "en");
|
||||
expect(title).toBe("Your Coach has a thought on this");
|
||||
it("rotates to the casual opener on an odd seed", () => {
|
||||
const { title } = buildCoachNudgePayload("bp", "de", {
|
||||
name: "Marc",
|
||||
openerSeed: 1,
|
||||
});
|
||||
expect(title).toBe("Hey Marc");
|
||||
});
|
||||
|
||||
it("drops the name from the greeting when none is given", () => {
|
||||
expect(buildCoachNudgePayload("score", "en", { openerSeed: 0 }).title).toBe(
|
||||
"Good morning",
|
||||
);
|
||||
expect(buildCoachNudgePayload("score", "en", { openerSeed: 1 }).title).toBe(
|
||||
"Hey there",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the default locale for unknown locales", () => {
|
||||
const fallback = buildCoachNudgePayload("score", null);
|
||||
const unknown = buildCoachNudgePayload("score", "xx");
|
||||
const fallback = buildCoachNudgePayload("score", null, {
|
||||
name: "Sam",
|
||||
openerSeed: 0,
|
||||
});
|
||||
const unknown = buildCoachNudgePayload("score", "xx", {
|
||||
name: "Sam",
|
||||
openerSeed: 0,
|
||||
});
|
||||
expect(unknown).toEqual(fallback);
|
||||
});
|
||||
|
||||
it("produces a distinct payload per trigger", () => {
|
||||
const titles = new Set(
|
||||
(
|
||||
[
|
||||
"compliance",
|
||||
"bp",
|
||||
"score",
|
||||
"selfContext",
|
||||
"weight",
|
||||
"sleepDebt",
|
||||
"measurementGap",
|
||||
] as const
|
||||
).map((trigger) => buildCoachNudgePayload(trigger, "en").title),
|
||||
it("produces a distinct body per trigger", () => {
|
||||
const bodies = new Set(
|
||||
ALL_TRIGGERS.map((trigger) => buildCoachNudgePayload(trigger, "en").body),
|
||||
);
|
||||
expect(titles.size).toBe(7);
|
||||
expect(bodies.size).toBe(7);
|
||||
});
|
||||
|
||||
it("appends the personal focus suffix when a Coach focus exists", () => {
|
||||
const { body } = buildCoachNudgePayload("bp", "en", "morning readings");
|
||||
expect(body).toContain("You wanted to keep an eye on: morning readings.");
|
||||
it("references a Coach focus abstractly — never quoting raw text", () => {
|
||||
const { body } = buildCoachNudgePayload("bp", "en", {
|
||||
hasCoachFocus: true,
|
||||
});
|
||||
expect(body).toContain("you said you wanted to keep an eye on");
|
||||
// No interpolation token survives; no raw user sentence is read back.
|
||||
expect(body).not.toContain("{focus}");
|
||||
expect(body).not.toContain("{");
|
||||
});
|
||||
|
||||
it("clamps an over-long focus before quoting it", () => {
|
||||
const focus = "x".repeat(COACH_NUDGE_FOCUS_MAX_CHARS + 40);
|
||||
const { body } = buildCoachNudgePayload("weight", "en", focus);
|
||||
expect(body).toContain("…");
|
||||
expect(body).not.toContain(focus);
|
||||
});
|
||||
|
||||
it("skips the suffix for the self-context check-up nudge", () => {
|
||||
const plain = buildCoachNudgePayload("selfContext", "en");
|
||||
const withFocus = buildCoachNudgePayload(
|
||||
"selfContext",
|
||||
"en",
|
||||
"morning readings",
|
||||
);
|
||||
it("skips the focus note for the self-context check-up", () => {
|
||||
const plain = buildCoachNudgePayload("selfContext", "en", {
|
||||
hasCoachFocus: false,
|
||||
});
|
||||
const withFocus = buildCoachNudgePayload("selfContext", "en", {
|
||||
hasCoachFocus: true,
|
||||
});
|
||||
expect(withFocus).toEqual(plain);
|
||||
});
|
||||
|
||||
it("skips the suffix for a null / blank focus", () => {
|
||||
const plain = buildCoachNudgePayload("bp", "en");
|
||||
expect(buildCoachNudgePayload("bp", "en", null)).toEqual(plain);
|
||||
expect(buildCoachNudgePayload("bp", "en", " ")).toEqual(plain);
|
||||
it("omits the focus note when the user has no focus", () => {
|
||||
const plain = buildCoachNudgePayload("bp", "en", { hasCoachFocus: false });
|
||||
expect(buildCoachNudgePayload("bp", "en")).toEqual(plain);
|
||||
});
|
||||
|
||||
it("never embeds health figures in the body", () => {
|
||||
for (const trigger of ALL_TRIGGERS) {
|
||||
const { body } = buildCoachNudgePayload(trigger, "en", {
|
||||
hasCoachFocus: true,
|
||||
});
|
||||
expect(body).not.toMatch(/\d/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGreetingName", () => {
|
||||
it("prefers the display name's first token", () => {
|
||||
expect(
|
||||
resolveGreetingName({
|
||||
displayName: "Marc B.",
|
||||
fullName: "Marc Bombeck",
|
||||
username: "marc",
|
||||
}),
|
||||
).toBe("Marc");
|
||||
});
|
||||
|
||||
it("falls back to the full name's given token", () => {
|
||||
expect(
|
||||
resolveGreetingName({
|
||||
displayName: null,
|
||||
fullName: "Alex Doe",
|
||||
username: "alex",
|
||||
}),
|
||||
).toBe("Alex");
|
||||
});
|
||||
|
||||
it("uses a plain username but never an email-shaped login", () => {
|
||||
expect(
|
||||
resolveGreetingName({
|
||||
displayName: null,
|
||||
fullName: null,
|
||||
username: "alex",
|
||||
}),
|
||||
).toBe("alex");
|
||||
expect(
|
||||
resolveGreetingName({
|
||||
displayName: null,
|
||||
fullName: null,
|
||||
username: "a@b.com",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when nothing usable exists", () => {
|
||||
expect(
|
||||
resolveGreetingName({
|
||||
displayName: " ",
|
||||
fullName: null,
|
||||
username: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -514,6 +590,7 @@ describe("runCoachNudgeTick — gates and prefs", () => {
|
||||
intakeRows?: unknown[];
|
||||
recentNudge?: unknown;
|
||||
recentPersistedNudge?: unknown;
|
||||
recentEngagement?: unknown;
|
||||
coachFocusEncrypted?: Uint8Array | null;
|
||||
}) {
|
||||
return {
|
||||
@@ -542,7 +619,9 @@ describe("runCoachNudgeTick — gates and prefs", () => {
|
||||
findMany: vi.fn(async () => []),
|
||||
count: vi.fn(async () => 1),
|
||||
},
|
||||
coachUsage: { findFirst: vi.fn(async () => null) },
|
||||
coachUsage: {
|
||||
findFirst: vi.fn(async () => overrides.recentEngagement ?? null),
|
||||
},
|
||||
userHealthProfile: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
coachFocusEncrypted: overrides.coachFocusEncrypted ?? null,
|
||||
@@ -725,7 +804,7 @@ describe("runCoachNudgeTick — gates and prefs", () => {
|
||||
expect(summary.dispatched).toBe(0);
|
||||
});
|
||||
|
||||
it("personalises the dispatched body with the decrypted Coach focus", async () => {
|
||||
it("references the Coach focus abstractly — never quoting the raw text", async () => {
|
||||
const dispatch = vi.fn(async (input: unknown) => {
|
||||
void input;
|
||||
return { dispatched: true };
|
||||
@@ -733,6 +812,8 @@ describe("runCoachNudgeTick — gates and prefs", () => {
|
||||
const prisma = prismaMock({
|
||||
users: [userRow(null)],
|
||||
intakeRows: failingIntakes,
|
||||
// Presence of the encrypted focus drives the abstract note; the bytes
|
||||
// are never decrypted by the nudge path now.
|
||||
coachFocusEncrypted: new Uint8Array([1, 2, 3]),
|
||||
});
|
||||
const summary = await runCoachNudgeTick(
|
||||
@@ -742,13 +823,16 @@ describe("runCoachNudgeTick — gates and prefs", () => {
|
||||
);
|
||||
expect(summary.dispatched).toBe(1);
|
||||
const payload = dispatch.mock.calls[0]?.[0] as unknown as {
|
||||
title: string;
|
||||
message: string;
|
||||
metadata: { trigger: string };
|
||||
};
|
||||
expect(payload.metadata.trigger).toBe("compliance");
|
||||
expect(payload.message).toContain(
|
||||
"You wanted to keep an eye on: morning blood pressure.",
|
||||
);
|
||||
// Title is a warm greeting; body carries the abstract focus note.
|
||||
expect(payload.title.length).toBeGreaterThan(0);
|
||||
expect(payload.message).toContain("you said you wanted to keep an eye on");
|
||||
// The decrypted self-context sentence is NEVER read back verbatim.
|
||||
expect(payload.message).not.toContain("morning blood pressure");
|
||||
});
|
||||
|
||||
it("persists the nudge as a conversation before dispatching (CCH-02)", async () => {
|
||||
@@ -790,7 +874,7 @@ describe("runCoachNudgeTick — gates and prefs", () => {
|
||||
expect(persistArg.userId).toBe("user-1");
|
||||
// The persisted body is the same compliance nudge copy the
|
||||
// notification carries — it lands as the initial assistant message.
|
||||
expect(persistArg.body).toContain("Your intakes were patchy this week");
|
||||
expect(persistArg.body).toContain("Your medication routine slipped");
|
||||
expect(persistArg.title.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -813,4 +897,85 @@ describe("runCoachNudgeTick — gates and prefs", () => {
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(summary.persisted).toBe(0);
|
||||
});
|
||||
|
||||
it("suppresses the nudge when the user engaged the Coach in the last ~24 h", async () => {
|
||||
const dispatch = vi.fn();
|
||||
const prisma = prismaMock({
|
||||
users: [userRow(null)],
|
||||
intakeRows: failingIntakes,
|
||||
recentEngagement: { userId: "user-1" },
|
||||
});
|
||||
const summary = await runCoachNudgeTick(
|
||||
prisma as unknown as PrismaClient,
|
||||
now,
|
||||
{ dispatch },
|
||||
);
|
||||
expect(summary.skippedRecentEngagement).toBe(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
// The engagement gate sits ahead of the trigger search.
|
||||
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("composes the body through the model when AI enrichment is opted in", async () => {
|
||||
const dispatch = vi.fn(async (input: unknown) => {
|
||||
void input;
|
||||
return { dispatched: true };
|
||||
});
|
||||
const composeAi = vi.fn(async () => ({
|
||||
title: "Guten Morgen",
|
||||
body: "AI-composed warm nudge body.",
|
||||
}));
|
||||
const prisma = prismaMock({
|
||||
users: [userRow({ coach: { nudgeAiComposed: true } })],
|
||||
intakeRows: failingIntakes,
|
||||
});
|
||||
const summary = await runCoachNudgeTick(
|
||||
prisma as unknown as PrismaClient,
|
||||
now,
|
||||
{ dispatch: dispatch as never, composeAi: composeAi as never },
|
||||
);
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(composeAi).toHaveBeenCalledTimes(1);
|
||||
const payload = dispatch.mock.calls[0]?.[0] as unknown as {
|
||||
message: string;
|
||||
};
|
||||
expect(payload.message).toBe("AI-composed warm nudge body.");
|
||||
});
|
||||
|
||||
it("falls back to the template when AI enrichment returns null", async () => {
|
||||
const dispatch = vi.fn(async (input: unknown) => {
|
||||
void input;
|
||||
return { dispatched: true };
|
||||
});
|
||||
const composeAi = vi.fn(async () => null);
|
||||
const prisma = prismaMock({
|
||||
users: [userRow({ coach: { nudgeAiComposed: true } })],
|
||||
intakeRows: failingIntakes,
|
||||
});
|
||||
const summary = await runCoachNudgeTick(
|
||||
prisma as unknown as PrismaClient,
|
||||
now,
|
||||
{ dispatch: dispatch as never, composeAi: composeAi as never },
|
||||
);
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(composeAi).toHaveBeenCalledTimes(1);
|
||||
const payload = dispatch.mock.calls[0]?.[0] as unknown as {
|
||||
message: string;
|
||||
};
|
||||
expect(payload.message).toContain("Your medication routine slipped");
|
||||
});
|
||||
|
||||
it("never calls the AI composer when enrichment is not opted in", async () => {
|
||||
const dispatch = vi.fn(async () => ({ dispatched: true }));
|
||||
const composeAi = vi.fn(async () => ({ title: "x", body: "y" }));
|
||||
const prisma = prismaMock({
|
||||
users: [userRow(null)],
|
||||
intakeRows: failingIntakes,
|
||||
});
|
||||
await runCoachNudgeTick(prisma as unknown as PrismaClient, now, {
|
||||
dispatch: dispatch as never,
|
||||
composeAi: composeAi as never,
|
||||
});
|
||||
expect(composeAi).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* v1.25.0 — AI enrichment for the proactive Coach nudge (opt-in).
|
||||
*
|
||||
* The deterministic warm template (`buildCoachNudgePayload`) is always the
|
||||
* DEFAULT and the fail-closed fallback. When the user opted in
|
||||
* (`coach.nudgeAiComposed`) AND a provider is healthy, the 05:15 tick composes
|
||||
* the nudge BODY through the model instead — the greeting title stays
|
||||
* deterministic so the warm, name-led opener is guaranteed regardless of the
|
||||
* model's output.
|
||||
*
|
||||
* Hard guards (non-negotiable):
|
||||
* - per-user daily BUDGET gate (the same reserve/reconcile ledger the Coach
|
||||
* chat uses, with the chain-aware daily cap);
|
||||
* - a tight per-call TIMEOUT (≤ ~9 s) on both the provider `timeoutMs` and a
|
||||
* belt-and-suspenders `AbortSignal`;
|
||||
* - a per-tick CEILING (a call count + a wall-clock deadline) so the
|
||||
* SEQUENTIAL tick cannot stall across the user base on provider latency;
|
||||
* - ANY error / timeout / budget refusal / unsafe output → return null and
|
||||
* the caller keeps the template.
|
||||
*
|
||||
* The same content rules as the template apply to the model's output, enforced
|
||||
* server-side: the outbound safety screen (`screenCoachReply`) rejects a
|
||||
* dose-prescription or a fabricated risk score, the prompt forbids quoting the
|
||||
* user's words / inventing figures / imperatives, and the body is length- and
|
||||
* shape-bounded before it can ship. The user's raw self-context focus is NEVER
|
||||
* passed to the model — only its presence, abstractly.
|
||||
*/
|
||||
import type { CoachNudgeTrigger } from "@/lib/jobs/coach-nudge";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { resolveProviderChain } from "@/lib/ai/provider";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { screenCoachReply } from "@/lib/ai/coach/outbound-guard";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/** Per-call upstream timeout — kept inside the spec's ≤8–10 s window. */
|
||||
export const COACH_NUDGE_AI_CALL_TIMEOUT_MS = 9_000;
|
||||
/** Per-tick ceiling: max AI compositions before the rest fall to template. */
|
||||
export const COACH_NUDGE_AI_MAX_PER_TICK = 25;
|
||||
/** Per-tick ceiling: wall-clock budget for ALL AI compositions this tick. */
|
||||
export const COACH_NUDGE_AI_TICK_BUDGET_MS = 90_000;
|
||||
/** Hard cap on a composed body before it ships (else fall back). */
|
||||
export const COACH_NUDGE_AI_MAX_BODY_CHARS = 320;
|
||||
|
||||
/**
|
||||
* Shared, mutable per-tick budget. One instance is created per
|
||||
* `runCoachNudgeTick` and threaded into every composition so the sequential
|
||||
* pass can't blow past the count or the wall-clock deadline.
|
||||
*/
|
||||
export interface NudgeAiTickBudget {
|
||||
remainingCount: number;
|
||||
/** Epoch ms (real wall clock) after which no further AI call is started. */
|
||||
deadline: number;
|
||||
}
|
||||
|
||||
export function createNudgeAiTickBudget(): NudgeAiTickBudget {
|
||||
return {
|
||||
remainingCount: COACH_NUDGE_AI_MAX_PER_TICK,
|
||||
deadline: Date.now() + COACH_NUDGE_AI_TICK_BUDGET_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComposeNudgeParams {
|
||||
userId: string;
|
||||
trigger: CoachNudgeTrigger;
|
||||
locale: Locale;
|
||||
name: string | null;
|
||||
hasCoachFocus: boolean;
|
||||
/** The deterministic template — handed to the model as a style reference. */
|
||||
template: { title: string; body: string };
|
||||
tickBudget: NudgeAiTickBudget;
|
||||
}
|
||||
|
||||
export type ComposeNudgeWithAI = (
|
||||
params: ComposeNudgeParams,
|
||||
) => Promise<{ title: string; body: string } | null>;
|
||||
|
||||
/**
|
||||
* Abstract, vendor-/figure-free topic phrase per trigger. The model writes the
|
||||
* actual copy in the user's locale; this only tells it WHAT the nudge is
|
||||
* about, never quoting any of the user's own words or readings.
|
||||
*/
|
||||
const TRIGGER_TOPIC: Record<CoachNudgeTrigger, string> = {
|
||||
compliance: "their medication intake has been a bit irregular this week",
|
||||
bp: "their blood pressure has run a touch above their usual target this week",
|
||||
score: "their recovery has been easing off over the last few days",
|
||||
weight: "their weight has been drifting away from their target range",
|
||||
sleepDebt: "they have had several short nights this week",
|
||||
measurementGap: "they have not logged any readings for about a week",
|
||||
selfContext:
|
||||
"a quick refresh of their self-description would help the coach know them better",
|
||||
};
|
||||
|
||||
function buildNudgeSystemPrompt(locale: Locale): string {
|
||||
const language = locale === "de" ? "German" : "English";
|
||||
return `You write a single, very short proactive message from a calm, caring personal-health coach. It will appear as a phone notification body.
|
||||
|
||||
Hard rules:
|
||||
- Write ONLY in ${language}.
|
||||
- One or two short sentences, warm and observational, ending in a GENTLE INVITE — never an imperative, never pushy.
|
||||
- Do NOT open with a greeting or the person's name; the app prepends the greeting.
|
||||
- Mention exactly ONE idea. No lists.
|
||||
- No numbers, no measurements, no figures of any kind.
|
||||
- No medical advice, no diagnosis, no medication or dose suggestions, no risk scores.
|
||||
- Never quote the person's own words and never invent details about them.
|
||||
- Plain text only — no markdown, no quotation marks around the message, no emoji.`;
|
||||
}
|
||||
|
||||
function buildNudgeUserPrompt(params: ComposeNudgeParams): string {
|
||||
const focusLine = params.hasCoachFocus
|
||||
? "They have set a personal focus they want to keep an eye on; you may gently acknowledge that they care about it, WITHOUT repeating or guessing what it is.\n"
|
||||
: "";
|
||||
return `Context: ${TRIGGER_TOPIC[params.trigger]}.
|
||||
${focusLine}For reference, a deterministic version of this message reads: "${params.template.body}"
|
||||
|
||||
Rephrase it warmly and naturally in your own words, following every rule. Return only the message text.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise the model's output into a shippable body, or null when it is
|
||||
* unusable (empty / over-length / unsafe). Strips wrapping quotes and collapses
|
||||
* whitespace; rejects rather than mid-word-clamping an over-long reply.
|
||||
*/
|
||||
function sanitiseAiBody(raw: string): string | null {
|
||||
let text = (raw ?? "").trim();
|
||||
if (!text) return null;
|
||||
// Strip a single layer of wrapping quotes the model sometimes adds.
|
||||
text = text
|
||||
.replace(/^["“”'`]+/, "")
|
||||
.replace(/["“”'`]+$/, "")
|
||||
.trim();
|
||||
text = text.replace(/\s+/g, " ");
|
||||
if (!text) return null;
|
||||
if (text.length > COACH_NUDGE_AI_MAX_BODY_CHARS) return null;
|
||||
// Same outbound content fence the Coach reply path runs: a dose-prescription
|
||||
// or a fabricated risk score is rejected, falling back to the template.
|
||||
if (screenCoachReply(text).block) return null;
|
||||
return text;
|
||||
}
|
||||
|
||||
export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => {
|
||||
const { tickBudget } = params;
|
||||
|
||||
// Per-tick ceiling — count and wall-clock. Exhausted → template.
|
||||
if (tickBudget.remainingCount <= 0 || Date.now() >= tickBudget.deadline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const chain = await resolveProviderChain(params.userId);
|
||||
if (chain.length === 0) return null;
|
||||
|
||||
const budget = AI_BUDGETS.coachNudge;
|
||||
const maxTokens = budget.maxTokens ?? 160;
|
||||
const dailyCap = resolveDailyCap(chain);
|
||||
const dateKey = buildDateKey();
|
||||
|
||||
// Per-user budget gate (atomic reserve; refunds itself on refusal).
|
||||
const reservation = await reserveBudget(
|
||||
params.userId,
|
||||
maxTokens,
|
||||
dateKey,
|
||||
dailyCap,
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
annotate({ action: { name: "coach.nudge.ai.budget_exceeded" } });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Committed to a real call — spend a per-tick slot.
|
||||
tickBudget.remainingCount -= 1;
|
||||
|
||||
let result;
|
||||
try {
|
||||
const provider = chain[0].instance;
|
||||
result = await provider.generateCompletion(
|
||||
singleUserTurn({
|
||||
system: buildNudgeSystemPrompt(params.locale),
|
||||
user: buildNudgeUserPrompt(params),
|
||||
temperature: budget.temperature,
|
||||
maxTokens,
|
||||
timeoutMs: COACH_NUDGE_AI_CALL_TIMEOUT_MS,
|
||||
signal: AbortSignal.timeout(COACH_NUDGE_AI_CALL_TIMEOUT_MS + 1_000),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Timeout / network / provider error → refund what wasn't spent and
|
||||
// fall back to the template.
|
||||
await reconcileSpend(
|
||||
params.userId,
|
||||
reservation.reserved,
|
||||
0,
|
||||
dateKey,
|
||||
).catch(() => {});
|
||||
annotate({ action: { name: "coach.nudge.ai.fallback" } });
|
||||
return null;
|
||||
}
|
||||
|
||||
await reconcileSpend(
|
||||
params.userId,
|
||||
reservation.reserved,
|
||||
result.tokensUsed ?? 0,
|
||||
dateKey,
|
||||
result.cachedInputTokens ?? 0,
|
||||
).catch(() => {});
|
||||
|
||||
const body = sanitiseAiBody(result.content);
|
||||
if (!body) {
|
||||
annotate({ action: { name: "coach.nudge.ai.fallback" } });
|
||||
return null;
|
||||
}
|
||||
|
||||
annotate({ action: { name: "coach.nudge.ai.composed" } });
|
||||
return { title: params.template.title, body };
|
||||
} catch {
|
||||
// Defensive: any unexpected failure keeps the template.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
+202
-91
@@ -57,11 +57,24 @@
|
||||
* medication → compliance; vitals → bp / score / weight / sleepDebt;
|
||||
* routine → measurementGap / selfContext.
|
||||
*
|
||||
* Personalisation (v1.16.5): when the user wrote a Coach focus in the
|
||||
* self-context questionnaire, the nudge body appends a deterministic
|
||||
* reference to it ("you wanted to keep an eye on …"). The focus is
|
||||
* decrypted only AFTER a trigger fired and a nudge is actually about
|
||||
* to dispatch — fail-closed, a decrypt error just drops the suffix.
|
||||
* Copy (v1.25.0): a warm, localized, deterministic TEMPLATE. The title
|
||||
* carries a greeting with the user's name ("Guten Morgen, …" / "Hey …",
|
||||
* rotating; the 05:15 cron makes a morning frame safe); the body carries
|
||||
* one calm observation and a gentle invite, never an imperative. When the
|
||||
* user set a Coach focus the body adds a NON-QUOTING acknowledgment — the
|
||||
* raw self-context sentence is never read back, only its presence
|
||||
* referenced. Nothing is decrypted here; the focus is a presence flag.
|
||||
*
|
||||
* AI enrichment (v1.25.0, opt-in, default OFF): when the user opted in AND
|
||||
* a provider is healthy, the body is composed through the model instead of
|
||||
* the template — under a per-user budget gate, a tight per-call timeout and
|
||||
* a per-tick ceiling. ANY error/timeout/budget falls silently back to the
|
||||
* deterministic template, which is always the safe default.
|
||||
*
|
||||
* Anti-nag (NORTH-STAR): the rolling frequency cap already prevents two
|
||||
* nudges in a window (so never two days running), nothing-to-say is silence
|
||||
* by construction (no trigger → no nudge), and a user who engaged the Coach
|
||||
* in the last ~24 h is skipped.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
@@ -72,11 +85,16 @@ import {
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
import { resolveCoachNudgePrefs } from "@/lib/validations/notification-prefs";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { recordProactiveNudge } from "@/lib/ai/coach/persistence";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { defaultLocale, locales } from "@/lib/i18n/config";
|
||||
import {
|
||||
composeNudgeWithAI,
|
||||
createNudgeAiTickBudget,
|
||||
type ComposeNudgeWithAI,
|
||||
type NudgeAiTickBudget,
|
||||
} from "@/lib/jobs/coach-nudge-ai";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
@@ -137,8 +155,12 @@ export const COACH_NUDGE_GAP_MIN_ACTIVE_DAYS = 10;
|
||||
export const COACH_NUDGE_GAP_SILENT_DAYS = 7;
|
||||
/** Gap trigger: activity lookback BEFORE the silent window (days). */
|
||||
export const COACH_NUDGE_GAP_LOOKBACK_DAYS = 21;
|
||||
/** Personalisation: max focus characters quoted in the nudge body. */
|
||||
export const COACH_NUDGE_FOCUS_MAX_CHARS = 80;
|
||||
/**
|
||||
* Anti-nag suppression: skip the nudge when the user already talked to the
|
||||
* Coach within this window. A proactive outreach to someone who just engaged
|
||||
* is noise, not care.
|
||||
*/
|
||||
export const COACH_NUDGE_RECENT_ENGAGEMENT_HOURS = 24;
|
||||
|
||||
export type CoachNudgeTrigger =
|
||||
| "compliance"
|
||||
@@ -178,6 +200,12 @@ export interface CoachNudgeSummary {
|
||||
skippedOptedOut: number;
|
||||
skippedNoProvider: number;
|
||||
skippedRecentNudge: number;
|
||||
/**
|
||||
* v1.25.0 — anti-nag: the user talked to the Coach within the last ~24 h,
|
||||
* so the proactive nudge is suppressed (engaging someone who just engaged
|
||||
* is noise, not care).
|
||||
*/
|
||||
skippedRecentEngagement: number;
|
||||
skippedNoTrigger: number;
|
||||
skippedNoChannel: number;
|
||||
/**
|
||||
@@ -368,76 +396,103 @@ export function evaluateMeasurementGapTrigger(
|
||||
);
|
||||
}
|
||||
|
||||
/** The per-trigger observation key. The title is a greeting (see below). */
|
||||
const NUDGE_BODY_KEY: Record<CoachNudgeTrigger, string> = {
|
||||
compliance: "coachNudges.complianceBody",
|
||||
bp: "coachNudges.bpBody",
|
||||
score: "coachNudges.scoreBody",
|
||||
selfContext: "coachNudges.selfContextBody",
|
||||
weight: "coachNudges.weightBody",
|
||||
sleepDebt: "coachNudges.sleepDebtBody",
|
||||
measurementGap: "coachNudges.measurementGapBody",
|
||||
};
|
||||
|
||||
/** Two rotating morning-safe openers; index picked from `openerSeed`. */
|
||||
const GREETING_KEYS = [
|
||||
{
|
||||
named: "coachNudges.greetingMorningNamed",
|
||||
plain: "coachNudges.greetingMorning",
|
||||
},
|
||||
{ named: "coachNudges.greetingHeyNamed", plain: "coachNudges.greetingHey" },
|
||||
] as const;
|
||||
|
||||
export interface NudgePayloadOptions {
|
||||
/**
|
||||
* Display name for the greeting. Null / empty → a name-less greeting
|
||||
* ("Guten Morgen") rather than an awkward "Guten Morgen, ".
|
||||
*/
|
||||
name?: string | null;
|
||||
/**
|
||||
* Whether the user set a Coach focus. Drives a NON-QUOTING acknowledgment
|
||||
* line — the raw focus sentence is never read back, only its presence
|
||||
* referenced. Ignored for the self-context check-up (it asks the user to
|
||||
* refresh that very focus).
|
||||
*/
|
||||
hasCoachFocus?: boolean;
|
||||
/**
|
||||
* Deterministic rotation seed for the greeting opener (e.g. the day of the
|
||||
* month) so the opener varies day to day but stays stable within a tick.
|
||||
*/
|
||||
openerSeed?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the localised push payload for a trigger. Bodies stay
|
||||
* deliberately vague on numbers — a lock screen is not the place for
|
||||
* health figures; the Coach conversation carries the detail.
|
||||
* v1.25.0 — derive a warm greeting name from the user's profile. Prefers the
|
||||
* explicit display name, then the given (first) token of the full name; falls
|
||||
* back to a sanitised username only when it is not an email-shaped login.
|
||||
* Returns null when nothing safe is available, so the greeting drops the name
|
||||
* rather than addressing the user by an email or an empty string.
|
||||
*/
|
||||
export function resolveGreetingName(user: {
|
||||
displayName?: string | null;
|
||||
fullName?: string | null;
|
||||
username?: string | null;
|
||||
}): string | null {
|
||||
const firstToken = (raw: string | null | undefined): string | null => {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!trimmed) return null;
|
||||
const token = trimmed.split(/\s+/)[0];
|
||||
return token && token.length > 0 ? token.slice(0, 40) : null;
|
||||
};
|
||||
const fromDisplay = firstToken(user.displayName);
|
||||
if (fromDisplay) return fromDisplay;
|
||||
const fromFull = firstToken(user.fullName);
|
||||
if (fromFull) return fromFull;
|
||||
const username = (user.username ?? "").trim();
|
||||
if (username && !username.includes("@") && !/\s/.test(username)) {
|
||||
return username.slice(0, 40);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the localised push payload for a trigger.
|
||||
*
|
||||
* v1.16.5 — `coachFocus` (the user's own "watch this" line from the
|
||||
* self-context questionnaire, already decrypted by the caller) appends
|
||||
* a deterministic personal suffix to the body. The focus is the user's
|
||||
* own words about what to pay attention to — not a reading, not a
|
||||
* figure — so quoting it on the lock screen stays within the
|
||||
* no-health-figures rule. Clamped to `COACH_NUDGE_FOCUS_MAX_CHARS`.
|
||||
* The self-context check-up nudge skips the suffix: quoting the focus
|
||||
* while asking the user to refresh it would contradict itself.
|
||||
* v1.25.0 — the TITLE is a warm greeting (with the user's name when known,
|
||||
* rotating openers; 05:15 makes a morning frame safe). The BODY is one calm
|
||||
* observation plus a gentle invite, deliberately vague on numbers — a lock
|
||||
* screen is not the place for health figures; the Coach conversation carries
|
||||
* the detail. When the user set a Coach focus the body adds a non-quoting
|
||||
* acknowledgment (the raw focus sentence is never read back). The
|
||||
* self-context check-up skips that acknowledgment: it is itself the prompt to
|
||||
* refresh the focus.
|
||||
*/
|
||||
export function buildCoachNudgePayload(
|
||||
trigger: CoachNudgeTrigger,
|
||||
locale: string | null | undefined,
|
||||
coachFocus?: string | null,
|
||||
options: NudgePayloadOptions = {},
|
||||
): { title: string; body: string } {
|
||||
const t = getServerTranslator(resolveLocale(locale)).t;
|
||||
const base = ((): { title: string; body: string } => {
|
||||
switch (trigger) {
|
||||
case "compliance":
|
||||
return {
|
||||
title: t("coachNudges.complianceTitle"),
|
||||
body: t("coachNudges.complianceBody"),
|
||||
};
|
||||
case "bp":
|
||||
return {
|
||||
title: t("coachNudges.bpTitle"),
|
||||
body: t("coachNudges.bpBody"),
|
||||
};
|
||||
case "score":
|
||||
return {
|
||||
title: t("coachNudges.scoreTitle"),
|
||||
body: t("coachNudges.scoreBody"),
|
||||
};
|
||||
case "selfContext":
|
||||
return {
|
||||
title: t("coachNudges.selfContextTitle"),
|
||||
body: t("coachNudges.selfContextBody"),
|
||||
};
|
||||
case "weight":
|
||||
return {
|
||||
title: t("coachNudges.weightTitle"),
|
||||
body: t("coachNudges.weightBody"),
|
||||
};
|
||||
case "sleepDebt":
|
||||
return {
|
||||
title: t("coachNudges.sleepDebtTitle"),
|
||||
body: t("coachNudges.sleepDebtBody"),
|
||||
};
|
||||
case "measurementGap":
|
||||
return {
|
||||
title: t("coachNudges.measurementGapTitle"),
|
||||
body: t("coachNudges.measurementGapBody"),
|
||||
};
|
||||
}
|
||||
})();
|
||||
const name = options.name?.trim() || null;
|
||||
const opener =
|
||||
GREETING_KEYS[(options.openerSeed ?? 0) % GREETING_KEYS.length];
|
||||
const title = name ? t(opener.named, { name }) : t(opener.plain);
|
||||
|
||||
const focus = coachFocus?.trim();
|
||||
if (!focus || trigger === "selfContext") return base;
|
||||
const clamped =
|
||||
focus.length > COACH_NUDGE_FOCUS_MAX_CHARS
|
||||
? `${focus.slice(0, COACH_NUDGE_FOCUS_MAX_CHARS - 1).trimEnd()}…`
|
||||
: focus;
|
||||
return {
|
||||
title: base.title,
|
||||
body: `${base.body} ${t("coachNudges.focusSuffix", { focus: clamped })}`,
|
||||
};
|
||||
let body = t(NUDGE_BODY_KEY[trigger]);
|
||||
if (options.hasCoachFocus && trigger !== "selfContext") {
|
||||
body = `${body} ${t("coachNudges.focusNote")}`;
|
||||
}
|
||||
return { title, body };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -704,10 +759,20 @@ export async function runCoachNudgeTick(
|
||||
options: {
|
||||
dispatch?: typeof dispatchNotification;
|
||||
recordNudge?: typeof recordProactiveNudge;
|
||||
/**
|
||||
* v1.25.0 — injectable AI composer (tests stub it). Defaults to the real
|
||||
* `composeNudgeWithAI`; the shared per-tick budget is created once below.
|
||||
*/
|
||||
composeAi?: ComposeNudgeWithAI;
|
||||
} = {},
|
||||
): Promise<CoachNudgeSummary> {
|
||||
const dispatchImpl = options.dispatch ?? dispatchNotification;
|
||||
const recordNudgeImpl = options.recordNudge ?? recordProactiveNudge;
|
||||
const composeAiImpl = options.composeAi ?? composeNudgeWithAI;
|
||||
// v1.25.0 — one budget for the whole sequential tick: a cap on AI
|
||||
// compositions and a wall-clock ceiling so a slow provider can't stall the
|
||||
// 05:15 pass across the user base. Exhaustion → template for the rest.
|
||||
const aiTickBudget: NudgeAiTickBudget = createNudgeAiTickBudget();
|
||||
|
||||
const summary: CoachNudgeSummary = {
|
||||
candidatesScanned: 0,
|
||||
@@ -716,6 +781,7 @@ export async function runCoachNudgeTick(
|
||||
skippedOptedOut: 0,
|
||||
skippedNoProvider: 0,
|
||||
skippedRecentNudge: 0,
|
||||
skippedRecentEngagement: 0,
|
||||
skippedNoTrigger: 0,
|
||||
skippedNoChannel: 0,
|
||||
skippedDuringIllness: 0,
|
||||
@@ -733,6 +799,9 @@ export async function runCoachNudgeTick(
|
||||
select: {
|
||||
id: true,
|
||||
locale: true,
|
||||
displayName: true,
|
||||
fullName: true,
|
||||
username: true,
|
||||
notificationPrefs: true,
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
@@ -821,7 +890,24 @@ export async function runCoachNudgeTick(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gate 6 — Rest Mode pause (v1.18.1 P4). A user with an active
|
||||
// Gate 6 — anti-nag engagement suppression (v1.25.0). A user who
|
||||
// talked to the Coach within the last ~24 h does not need to be poked
|
||||
// toward it; the proactive outreach is for someone who has drifted, not
|
||||
// someone already in the conversation. Cheaper than the trigger search,
|
||||
// so it sits ahead of it.
|
||||
const engagementCutoff = new Date(
|
||||
now.getTime() - COACH_NUDGE_RECENT_ENGAGEMENT_HOURS * 60 * 60 * 1000,
|
||||
);
|
||||
const recentEngagement = await prisma.coachUsage.findFirst({
|
||||
where: { userId: user.id, updatedAt: { gte: engagementCutoff } },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (recentEngagement) {
|
||||
summary.skippedRecentEngagement += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gate 7 — Rest Mode pause (v1.18.1 P4). A user with an active
|
||||
// illness/condition episode should not be told to measure more often;
|
||||
// the cadence-nudge pauses for the duration of the episode. Module-gated
|
||||
// (a non-illness account is never in Rest Mode) and only reached past
|
||||
@@ -844,30 +930,55 @@ export async function runCoachNudgeTick(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Personalisation — decrypt the user's Coach focus only now that
|
||||
// a nudge is actually about to dispatch. Fail-closed: a missing
|
||||
// row or an undecryptable payload just drops the suffix.
|
||||
let coachFocus: string | null = null;
|
||||
// Personalisation — the user's Coach focus is referenced ONLY by
|
||||
// PRESENCE now (v1.25.0): a non-quoting acknowledgment, never the raw
|
||||
// decrypted sentence read back. So a cheap presence read replaces the
|
||||
// former decrypt; nothing sensitive is decrypted during the nudge.
|
||||
let hasCoachFocus = false;
|
||||
if (trigger !== "selfContext") {
|
||||
try {
|
||||
const profileRow = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { coachFocusEncrypted: true },
|
||||
});
|
||||
if (profileRow?.coachFocusEncrypted) {
|
||||
coachFocus =
|
||||
decryptFromBytes(profileRow.coachFocusEncrypted).trim() || null;
|
||||
}
|
||||
} catch {
|
||||
coachFocus = null;
|
||||
}
|
||||
const profileRow = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { coachFocusEncrypted: true },
|
||||
});
|
||||
hasCoachFocus = profileRow?.coachFocusEncrypted != null;
|
||||
}
|
||||
|
||||
const { title, body } = buildCoachNudgePayload(
|
||||
trigger,
|
||||
user.locale,
|
||||
coachFocus,
|
||||
);
|
||||
const locale = resolveLocale(user.locale);
|
||||
const name = resolveGreetingName(user);
|
||||
// Rotate the opener day to day, stable within a tick.
|
||||
const openerSeed = now.getUTCDate();
|
||||
|
||||
// The deterministic template is the DEFAULT and the fail-closed
|
||||
// fallback. It is built unconditionally so the AI path always has a
|
||||
// ready-made replacement to fall back to on any error/timeout/budget.
|
||||
const template = buildCoachNudgePayload(trigger, locale, {
|
||||
name,
|
||||
hasCoachFocus,
|
||||
openerSeed,
|
||||
});
|
||||
|
||||
let title = template.title;
|
||||
let body = template.body;
|
||||
|
||||
// AI enrichment (opt-in, default OFF). Composes the body through the
|
||||
// model under hard guards; ANY failure returns null and we keep the
|
||||
// template. The greeting title stays deterministic so the warm,
|
||||
// name-led opener is guaranteed regardless of the model's output.
|
||||
if (nudgePrefs.aiComposed) {
|
||||
const composed = await composeAiImpl({
|
||||
userId: user.id,
|
||||
trigger,
|
||||
locale,
|
||||
name,
|
||||
hasCoachFocus,
|
||||
template,
|
||||
tickBudget: aiTickBudget,
|
||||
});
|
||||
if (composed) {
|
||||
title = composed.title;
|
||||
body = composed.body;
|
||||
}
|
||||
}
|
||||
|
||||
// v1.18.6 (CCH-02) — persist the nudge as a real conversation
|
||||
// BEFORE dispatching the notification. The proactive nudge used to
|
||||
|
||||
@@ -338,6 +338,7 @@ export async function registerStatusQueues(
|
||||
skipped_opted_out: summary.skippedOptedOut,
|
||||
skipped_no_provider: summary.skippedNoProvider,
|
||||
skipped_recent_nudge: summary.skippedRecentNudge,
|
||||
skipped_recent_engagement: summary.skippedRecentEngagement,
|
||||
skipped_no_trigger: summary.skippedNoTrigger,
|
||||
skipped_no_channel: summary.skippedNoChannel,
|
||||
failed: summary.failed,
|
||||
|
||||
@@ -110,6 +110,7 @@ describe("parseNotificationPrefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -134,6 +135,7 @@ describe("parseNotificationPrefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -160,6 +162,7 @@ describe("parseNotificationPrefs", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -202,6 +205,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -232,6 +236,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -259,6 +264,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -286,6 +292,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -314,6 +321,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -504,6 +512,7 @@ describe("resolveCoachNudgePrefs — v1.16.5 nudge cron gate", () => {
|
||||
enabled: true,
|
||||
groups: { medication: true, vitals: true, routine: true },
|
||||
minIntervalDays: 7,
|
||||
aiComposed: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -133,6 +133,17 @@ const coachPrefsSchema = z
|
||||
* prompts, or vice versa.
|
||||
*/
|
||||
ambientSuggestions: z.boolean(),
|
||||
/**
|
||||
* v1.25.0 — opt-IN for AI-composed nudge copy. Default OFF: the proactive
|
||||
* nudge ships a deterministic warm localized template, and that template is
|
||||
* always the fail-closed fallback. When this is on AND a provider is
|
||||
* healthy the 05:15 tick composes the nudge through the model instead —
|
||||
* under a per-user budget gate, a tight per-call timeout and a per-tick
|
||||
* ceiling, with any error/timeout/budget falling silently back to the
|
||||
* template. Off by default so provider saturation is opt-in, never the
|
||||
* baseline.
|
||||
*/
|
||||
nudgeAiComposed: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
@@ -213,6 +224,11 @@ export interface NotificationPrefs {
|
||||
* opener + suggested-prompt chips). Default `true`.
|
||||
*/
|
||||
ambientSuggestions: boolean;
|
||||
/**
|
||||
* v1.25.0 — opt-in for AI-composed nudge copy. Default `false`; the
|
||||
* deterministic template stays the default + fail-closed fallback.
|
||||
*/
|
||||
nudgeAiComposed: boolean;
|
||||
};
|
||||
measurementReminder: {
|
||||
/**
|
||||
@@ -270,6 +286,7 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
@@ -459,6 +476,11 @@ export interface CoachNudgePrefs {
|
||||
};
|
||||
/** 7 for "weekly", 14 for "biweekly". */
|
||||
minIntervalDays: number;
|
||||
/**
|
||||
* v1.25.0 — when true the nudge tick composes the copy through the model
|
||||
* (under hard guards) instead of the deterministic template. Default false.
|
||||
*/
|
||||
aiComposed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -481,6 +503,7 @@ export function resolveCoachNudgePrefs(raw: unknown): CoachNudgePrefs {
|
||||
routine: coach.nudgeRoutine,
|
||||
},
|
||||
minIntervalDays: coach.nudgeFrequency === "biweekly" ? 14 : 7,
|
||||
aiComposed: coach.nudgeAiComposed,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user