From 54c9b4206f0af62d2a925391e77bdd446a602baa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:50:32 +0200 Subject: [PATCH 1/2] fix(i18n): persist User.locale so background messages match the UI language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The language switcher wrote the choice to localStorage, the `healthlog-locale` cookie and `` only — never to the user row. Server-side background work (the proactive Coach nudge, the Telegram test message) has no request cookie to read and falls back to `User.locale`; with the column left null those messages went out in English regardless of the language the user reads the app in. Add `PUT /api/auth/me/locale` (idempotent, validated against the supported locales) and have the i18n provider mirror the active locale onto the row — on every switch and once on mount, so the column is backfilled for users who set their language before this landed. Fire-and-forget: a 401 on a public page or an offline blip never blocks the UI flip; the cookie stays the first-paint source of truth. --- src/app/api/auth/me/locale/route.ts | 62 +++++++++++++++++++++++++++++ src/lib/i18n/context.tsx | 28 +++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/app/api/auth/me/locale/route.ts diff --git a/src/app/api/auth/me/locale/route.ts b/src/app/api/auth/me/locale/route.ts new file mode 100644 index 000000000..06681f44a --- /dev/null +++ b/src/app/api/auth/me/locale/route.ts @@ -0,0 +1,62 @@ +/** + * PUT /api/auth/me/locale + * + * v1.25.0 — persist the user's UI locale onto `User.locale`. + * + * The language switcher historically wrote the choice to localStorage, + * the `healthlog-locale` cookie and `` ONLY — never to the + * user row. Server-side background work (the proactive Coach nudge, the + * Telegram test message, …) has no request cookie to read, so it falls + * back to `User.locale`; with the column left null those messages went + * out in English regardless of the language the user reads the app in. + * + * This setter closes the gap: the client persists the active locale here + * (on every switch, plus a one-time backfill on mount) so the column + * reliably mirrors the UI language and the cron paths render in it. + * + * Auth: cookie session OR Bearer token. + * Validation: `{ "locale": "de" }` — must be one of the supported + * locales; anything else is rejected at the surface so the column never + * holds a value the resolver would later have to defend against. + */ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { locales, type Locale } from "@/lib/i18n/config"; + +export const dynamic = "force-dynamic"; + +function isLocale(value: unknown): value is Locale { + return ( + typeof value === "string" && (locales as readonly string[]).includes(value) + ); +} + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "user.locale.update" } }); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const locale = (body as { locale?: unknown } | null)?.locale; + if (!isLocale(locale)) { + return apiError("Not a supported locale.", 422); + } + + // Idempotent: skip the write (and the annotation noise) when the column + // already matches — the mount-time backfill fires on every page load. + if (user.locale !== locale) { + await prisma.user.update({ + where: { id: user.id }, + data: { locale }, + }); + annotate({ meta: { locale_next: locale } }); + } + + return apiSuccess({ locale }); +}); diff --git a/src/lib/i18n/context.tsx b/src/lib/i18n/context.tsx index 7c2dd1349..2add8a6d1 100644 --- a/src/lib/i18n/context.tsx +++ b/src/lib/i18n/context.tsx @@ -89,6 +89,26 @@ function persistLocale(newLocale: Locale) { document.documentElement.lang = newLocale; } +// v1.25.0 — mirror the active locale onto `User.locale` so server-side +// background work (the proactive Coach nudge, the Telegram test message) +// renders in the user's language. The cookie/localStorage choice is +// invisible to a cron that has no request context; without this write the +// column stays null and those messages fall back to English. Fire-and-forget +// and idempotent on the server: a 401 on a public page, an offline blip or a +// no-op equal value all fail silently and never block the UI flip. +function persistLocaleToServer(newLocale: Locale) { + void fetch("/api/auth/me/locale", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ locale: newLocale }), + credentials: "same-origin", + keepalive: true, + }).catch(() => { + // Best-effort: the cookie remains the first-paint source of truth, and + // the next mount retries the backfill. + }); +} + export function I18nProvider({ children, initialLocale, @@ -152,6 +172,14 @@ export function I18nProvider({ }; }, [locale]); + // v1.25.0 — keep `User.locale` in step with the committed UI locale. Runs on + // mount (backfilling the column for users who set their language before this + // landed) and on every switch, so the proactive Coach nudge and other + // cookie-blind background paths render in the language the user reads. + useEffect(() => { + persistLocaleToServer(locale); + }, [locale]); + const setLocale = useCallback((newLocale: Locale) => { if (!(locales as readonly string[]).includes(newLocale)) return; From e216a9d678d2f777bf9dbf48ac4d8d8637ced1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:50:49 +0200 Subject: [PATCH 2/2] feat(coach): warm, localized proactive nudge with opt-in AI enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 2 + messages/de.json | 32 +- messages/en.json | 32 +- messages/es.json | 32 +- messages/fr.json | 32 +- messages/it.json | 32 +- messages/pl.json | 32 +- .../__tests__/route.test.ts | 11 + src/components/settings/coach-nudge-card.tsx | 24 ++ src/lib/ai/ai-budgets.ts | 10 + src/lib/jobs/__tests__/coach-nudge.test.ts | 265 +++++++++++++--- src/lib/jobs/coach-nudge-ai.ts | 225 ++++++++++++++ src/lib/jobs/coach-nudge.ts | 293 ++++++++++++------ src/lib/jobs/reminder/register-status.ts | 1 + .../__tests__/notification-prefs.test.ts | 9 + src/lib/validations/notification-prefs.ts | 23 ++ 16 files changed, 818 insertions(+), 237 deletions(-) create mode 100644 src/lib/jobs/coach-nudge-ai.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 15d669cd8..5fc7820c1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9882,6 +9882,8 @@ components: - biweekly ambientSuggestions: type: boolean + nudgeAiComposed: + type: boolean measurementReminder: type: object properties: diff --git a/messages/de.json b/messages/de.json index d1b80c95b..6e0d70d1a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6123,7 +6123,10 @@ "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.", "suggestionsLabel": "Beispielvorschläge", "suggestionsDesc": "Zeigt auf dem Coach-Bildschirm gelegentlich eine fertige Beispielfrage.", - "suggestionsAria": "Beispiel-Coach-Vorschläge umschalten" + "suggestionsAria": "Beispiel-Coach-Vorschläge umschalten", + "aiComposedLabel": "Den Coach den Anstoß formulieren lassen", + "aiComposedDesc": "Wenn ein Anbieter verfügbar ist, formuliert das Modell den proaktiven Anstoß statt der festen Vorlage. Standardmäßig aus.", + "aiComposedAria": "KI-formulierten proaktiven Anstoß umschalten" }, "lowStock": { "title": "Vorratswarnung", @@ -6354,21 +6357,18 @@ "dailyBody": "Wie geht es dir heute?" }, "coachNudges": { - "complianceTitle": "Dein Coach hätte einen Gedanken dazu", - "complianceBody": "Deine Einnahmen waren diese Woche lückenhaft. Sprich kurz mit dem Coach — vielleicht lässt sich der Plan vereinfachen.", - "bpTitle": "Blutdruck im Wochenmittel erhöht", - "bpBody": "Dein Blutdruck lag diese Woche im Schnitt über deinem Ziel. Der Coach kann die Werte mit dir einordnen.", - "scoreTitle": "Dein Erholungswert fällt", - "scoreBody": "Dein Erholungswert ist diese Woche deutlich gesunken. Der Coach hilft dir, mögliche Ursachen einzugrenzen.", - "selfContextTitle": "Hilf deinem Coach, dich besser zu kennen", - "selfContextBody": "Deine Selbstauskunft ist unvollständig oder länger nicht aktualisiert. Zwei Minuten in den Einstellungen halten den Kontext des Coachs frisch.", - "weightTitle": "Dein Gewicht entfernt sich vom Zielbereich", - "weightBody": "Dein Wochenmittel hat sich weiter von deinem Zielbereich entfernt. Der Coach kann mit dir in Ruhe Bilanz ziehen.", - "sleepDebtTitle": "Mehrere kurze Nächte diese Woche", - "sleepDebtBody": "Die meisten deiner letzten Nächte lagen unter deinem Schlafziel. Der Coach hilft dir, nach Mustern zu suchen.", - "measurementGapTitle": "Dein Protokoll ist still geworden", - "measurementGapBody": "Seit einer Woche kommen keine neuen Werte an. Ein kurzer Austausch mit dem Coach hilft beim Wiedereinstieg — oder deckt eine hängende Synchronisierung auf.", - "focusSuffix": "Du wolltest auf Folgendes achten: {focus}.", + "greetingMorning": "Guten Morgen", + "greetingMorningNamed": "Guten Morgen, {name}", + "greetingHey": "Hey", + "greetingHeyNamed": "Hey {name}", + "complianceBody": "Deine Einnahmen sind diese Woche etwas durcheinandergeraten. Wenn du magst, schauen wir gemeinsam, was es dir leichter machen würde.", + "bpBody": "Dein Blutdruck lag diese Woche etwas über deinem gewohnten Bereich. Wann immer du möchtest, sehe ich mir das gern mit dir an.", + "scoreBody": "Deine Erholung lässt seit ein paar Tagen etwas nach. Wenn dich interessiert, was dahintersteckt, bin ich da.", + "selfContextBody": "Deine Selbstauskunft ist länger nicht aktualisiert. Wenn du einen Moment hast, hilft mir eine Auffrischung, dich besser zu verstehen.", + "weightBody": "Dein Gewicht hat sich zuletzt etwas von deinem Zielbereich entfernt. Wenn du magst, ziehen wir in Ruhe gemeinsam Bilanz.", + "sleepDebtBody": "Einige deiner Nächte waren diese Woche recht kurz. Wenn du willst, suchen wir gemeinsam nach einem Muster.", + "measurementGapBody": "Dein Protokoll ist seit etwa einer Woche still. Wann immer es dir passt, hilft ein kurzer Austausch beim Wiedereinstieg.", + "focusNote": "Das passt auch zu dem, was du selbst im Blick behalten möchtest.", "questionConditions": "Hast du chronische Erkrankungen, die der Coach kennen sollte?", "questionFocus": "Gibt es etwas Bestimmtes, worauf der Coach achten soll?", "questionAllergies": "Hast du Allergien oder Unverträglichkeiten?", diff --git a/messages/en.json b/messages/en.json index 6b637b7f9..2e6526ac0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6123,7 +6123,10 @@ "saveError": "Could not save. Please try again later.", "suggestionsLabel": "Example suggestions", "suggestionsDesc": "Show the occasional ready-made example question on the Coach screen.", - "suggestionsAria": "Toggle example Coach suggestions" + "suggestionsAria": "Toggle example Coach suggestions", + "aiComposedLabel": "Let the Coach phrase the nudge", + "aiComposedDesc": "When a provider is available, the proactive nudge is written by the model instead of the fixed template. Off by default.", + "aiComposedAria": "Toggle AI-composed proactive nudge" }, "lowStock": { "title": "Low-stock alert", @@ -6354,21 +6357,18 @@ "dailyBody": "How are you feeling today?" }, "coachNudges": { - "complianceTitle": "Your Coach has a thought on this", - "complianceBody": "Your intakes were patchy this week. Have a quick word with the Coach — maybe the plan can be simplified.", - "bpTitle": "Blood pressure elevated this week", - "bpBody": "Your blood pressure averaged above your target this week. The Coach can help you put the readings in context.", - "scoreTitle": "Your recovery score is dropping", - "scoreBody": "Your recovery score fell noticeably this week. The Coach can help you narrow down possible causes.", - "selfContextTitle": "Help your Coach know you better", - "selfContextBody": "Your self-description is incomplete or has not been updated in a while. Two minutes in the settings keep the Coach's context fresh.", - "weightTitle": "Your weight is drifting from your target", - "weightBody": "Your weekly average moved further away from your target range. The Coach can help you take stock calmly.", - "sleepDebtTitle": "Several short nights this week", - "sleepDebtBody": "Most of your recent nights came in under your sleep target. The Coach can help you look for patterns.", - "measurementGapTitle": "Your log went quiet", - "measurementGapBody": "No new readings arrived for a week. A quick word with the Coach helps you pick the routine back up — or spot a broken sync.", - "focusSuffix": "You wanted to keep an eye on: {focus}.", + "greetingMorning": "Good morning", + "greetingMorningNamed": "Good morning, {name}", + "greetingHey": "Hey there", + "greetingHeyNamed": "Hey {name}", + "complianceBody": "Your medication routine slipped a little this week. If you like, we can look at what would make it easier to keep up.", + "bpBody": "Your blood pressure has been running a touch above your usual range this week. Whenever you're ready, I'm happy to look at it with you.", + "scoreBody": "Your recovery has been easing off over the last few days. If you're curious what's behind it, I'm here to talk it through.", + "selfContextBody": "It's been a while since your self-description was updated. When you have a moment, refreshing it helps me understand you better.", + "weightBody": "Your weight has drifted a little from your target range lately. If you'd like, we can take stock together, calmly.", + "sleepDebtBody": "Several of your nights came up short this week. If you want, we can look for a pattern together.", + "measurementGapBody": "Your log has been quiet for about a week. Whenever it suits you, a quick check-in helps you pick things back up.", + "focusNote": "It also ties into what you said you wanted to keep an eye on.", "questionConditions": "Do you have any chronic conditions the Coach should know about?", "questionFocus": "Is there anything specific the Coach should pay attention to?", "questionAllergies": "Do you have any allergies or intolerances?", diff --git a/messages/es.json b/messages/es.json index 511245644..7bc071632 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6123,7 +6123,10 @@ "saveError": "No se pudo guardar. Inténtalo de nuevo más tarde.", "suggestionsLabel": "Sugerencias de ejemplo", "suggestionsDesc": "Muestra de vez en cuando una pregunta de ejemplo en la pantalla del Coach.", - "suggestionsAria": "Activar o desactivar las sugerencias de ejemplo del Coach" + "suggestionsAria": "Activar o desactivar las sugerencias de ejemplo del Coach", + "aiComposedLabel": "Deja que el Coach redacte el aviso", + "aiComposedDesc": "Cuando hay un proveedor disponible, el aviso proactivo lo redacta el modelo en lugar de la plantilla fija. Desactivado por defecto.", + "aiComposedAria": "Activar o desactivar el aviso proactivo redactado por IA" }, "lowStock": { "title": "Aviso de existencias bajas", @@ -6354,21 +6357,18 @@ "dailyBody": "¿Cómo te sientes hoy?" }, "coachNudges": { - "complianceTitle": "Tu Coach tiene algo que comentarte", - "complianceBody": "Tus tomas han sido irregulares esta semana. Habla un momento con el Coach: quizá se pueda simplificar el plan.", - "bpTitle": "Tensión elevada esta semana", - "bpBody": "Tu tensión media de esta semana superó tu objetivo. El Coach puede ayudarte a poner los valores en contexto.", - "scoreTitle": "Tu puntuación de recuperación baja", - "scoreBody": "Tu puntuación de recuperación ha caído notablemente esta semana. El Coach puede ayudarte a acotar las posibles causas.", - "selfContextTitle": "Ayuda a tu Coach a conocerte mejor", - "selfContextBody": "Tu autodescripción está incompleta o lleva tiempo sin actualizarse. Dos minutos en los ajustes mantienen fresco el contexto del Coach.", - "weightTitle": "Tu peso se aleja de tu rango objetivo", - "weightBody": "Tu media semanal se ha alejado más de tu rango objetivo. El Coach puede ayudarte a hacer balance con calma.", - "sleepDebtTitle": "Varias noches cortas esta semana", - "sleepDebtBody": "La mayoría de tus últimas noches quedaron por debajo de tu objetivo de sueño. El Coach puede ayudarte a buscar patrones.", - "measurementGapTitle": "Tu registro se ha quedado en silencio", - "measurementGapBody": "Hace una semana que no llegan valores nuevos. Una breve charla con el Coach ayuda a retomar la rutina — o a detectar una sincronización rota.", - "focusSuffix": "Querías prestar atención a: {focus}.", + "greetingMorning": "Buenos días", + "greetingMorningNamed": "Buenos días, {name}", + "greetingHey": "Hola", + "greetingHeyNamed": "Hola, {name}", + "complianceBody": "Esta semana tu rutina de tomas se descuadró un poco. Si quieres, vemos juntos qué te facilitaría mantenerla.", + "bpBody": "Tu tensión estuvo esta semana un poco por encima de tu rango habitual. Cuando te venga bien, la miro contigo encantado.", + "scoreBody": "Tu recuperación ha ido aflojando estos últimos días. Si tienes curiosidad por la causa, aquí estoy para hablarlo.", + "selfContextBody": "Hace tiempo que no actualizas tu autodescripción. Cuando tengas un momento, refrescarla me ayuda a entenderte mejor.", + "weightBody": "Tu peso se ha alejado un poco de tu rango objetivo últimamente. Si quieres, hacemos balance juntos, con calma.", + "sleepDebtBody": "Varias de tus noches se quedaron cortas esta semana. Si te apetece, buscamos un patrón juntos.", + "measurementGapBody": "Tu registro lleva una semana en silencio. Cuando te venga bien, una breve puesta al día ayuda a retomarlo.", + "focusNote": "También tiene que ver con eso que querías vigilar.", "questionConditions": "¿Tienes enfermedades crónicas que el Coach deba conocer?", "questionFocus": "¿Hay algo concreto en lo que el Coach deba fijarse?", "questionAllergies": "¿Tienes alergias o intolerancias?", diff --git a/messages/fr.json b/messages/fr.json index 722bb3c26..c5258abf5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6123,7 +6123,10 @@ "saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard.", "suggestionsLabel": "Suggestions d'exemples", "suggestionsDesc": "Affiche de temps en temps une question d'exemple prête à l'emploi sur l'écran du Coach.", - "suggestionsAria": "Activer ou désactiver les suggestions d'exemples du Coach" + "suggestionsAria": "Activer ou désactiver les suggestions d'exemples du Coach", + "aiComposedLabel": "Laisser le Coach rédiger le message", + "aiComposedDesc": "Quand un fournisseur est disponible, le message proactif est rédigé par le modèle plutôt que par le modèle de texte fixe. Désactivé par défaut.", + "aiComposedAria": "Activer ou désactiver le message proactif rédigé par l'IA" }, "lowStock": { "title": "Alerte de stock faible", @@ -6354,21 +6357,18 @@ "dailyBody": "Comment vous sentez-vous aujourd’hui ?" }, "coachNudges": { - "complianceTitle": "Votre Coach a une remarque à ce sujet", - "complianceBody": "Vos prises ont été irrégulières cette semaine. Échangez avec le Coach — le plan peut peut-être être simplifié.", - "bpTitle": "Tension élevée cette semaine", - "bpBody": "Votre tension moyenne de la semaine a dépassé votre objectif. Le Coach peut vous aider à mettre les valeurs en perspective.", - "scoreTitle": "Votre score de récupération baisse", - "scoreBody": "Votre score de récupération a nettement chuté cette semaine. Le Coach peut vous aider à cerner les causes possibles.", - "selfContextTitle": "Aidez votre Coach à mieux vous connaître", - "selfContextBody": "Votre auto-description est incomplète ou n'a pas été mise à jour depuis un moment. Deux minutes dans les réglages gardent le contexte du Coach à jour.", - "weightTitle": "Votre poids s'éloigne de votre zone cible", - "weightBody": "Votre moyenne hebdomadaire s'est encore éloignée de votre zone cible. Le Coach peut vous aider à faire le point sereinement.", - "sleepDebtTitle": "Plusieurs nuits courtes cette semaine", - "sleepDebtBody": "La plupart de vos dernières nuits sont restées sous votre objectif de sommeil. Le Coach peut vous aider à chercher des régularités.", - "measurementGapTitle": "Votre journal est devenu silencieux", - "measurementGapBody": "Aucune nouvelle valeur depuis une semaine. Un bref échange avec le Coach aide à reprendre la routine — ou à repérer une synchronisation en panne.", - "focusSuffix": "Vous vouliez surveiller : {focus}.", + "greetingMorning": "Bonjour", + "greetingMorningNamed": "Bonjour, {name}", + "greetingHey": "Salut", + "greetingHeyNamed": "Salut {name}", + "complianceBody": "Vos prises se sont un peu déréglées cette semaine. Si vous voulez, on regarde ensemble ce qui les rendrait plus faciles à suivre.", + "bpBody": "Votre tension est restée un peu au-dessus de votre plage habituelle cette semaine. Quand vous voulez, je la regarde volontiers avec vous.", + "scoreBody": "Votre récupération s'essouffle depuis quelques jours. Si vous êtes curieux d'en connaître la cause, je suis là pour en parler.", + "selfContextBody": "Votre auto-description n'a pas été mise à jour depuis un moment. Quand vous aurez un instant, la rafraîchir m'aide à mieux vous comprendre.", + "weightBody": "Votre poids s'est un peu éloigné de votre zone cible ces derniers temps. Si vous le souhaitez, on fait le point ensemble, tranquillement.", + "sleepDebtBody": "Plusieurs de vos nuits ont été un peu courtes cette semaine. Si vous voulez, on cherche une régularité ensemble.", + "measurementGapBody": "Votre journal est silencieux depuis environ une semaine. Quand cela vous convient, un petit point aide à reprendre.", + "focusNote": "Cela rejoint aussi ce que vous vouliez garder à l'œil.", "questionConditions": "Avez-vous des maladies chroniques que le Coach devrait connaître ?", "questionFocus": "Y a-t-il quelque chose de précis auquel le Coach devrait veiller ?", "questionAllergies": "Avez-vous des allergies ou des intolérances ?", diff --git a/messages/it.json b/messages/it.json index 8ae1aef9a..17aed052e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6123,7 +6123,10 @@ "saveError": "Impossibile salvare. Riprova più tardi.", "suggestionsLabel": "Suggerimenti di esempio", "suggestionsDesc": "Mostra di tanto in tanto una domanda di esempio pronta all'uso nella schermata del Coach.", - "suggestionsAria": "Attiva o disattiva i suggerimenti di esempio del Coach" + "suggestionsAria": "Attiva o disattiva i suggerimenti di esempio del Coach", + "aiComposedLabel": "Lascia che sia il Coach a formulare il promemoria", + "aiComposedDesc": "Quando è disponibile un provider, il promemoria proattivo viene scritto dal modello anziché dal modello di testo fisso. Disattivato per impostazione predefinita.", + "aiComposedAria": "Attiva o disattiva il promemoria proattivo scritto dall'IA" }, "lowStock": { "title": "Avviso scorte basse", @@ -6354,21 +6357,18 @@ "dailyBody": "Come ti senti oggi?" }, "coachNudges": { - "complianceTitle": "Il tuo Coach ha un'osservazione", - "complianceBody": "Le tue assunzioni sono state discontinue questa settimana. Parla un attimo con il Coach: forse il piano si può semplificare.", - "bpTitle": "Pressione elevata questa settimana", - "bpBody": "La tua pressione media di questa settimana ha superato il tuo obiettivo. Il Coach può aiutarti a contestualizzare i valori.", - "scoreTitle": "Il tuo punteggio di recupero sta calando", - "scoreBody": "Il tuo punteggio di recupero è calato sensibilmente questa settimana. Il Coach può aiutarti a circoscrivere le possibili cause.", - "selfContextTitle": "Aiuta il tuo Coach a conoscerti meglio", - "selfContextBody": "La tua autodescrizione è incompleta o non viene aggiornata da tempo. Due minuti nelle impostazioni mantengono fresco il contesto del Coach.", - "weightTitle": "Il tuo peso si sta allontanando dall'obiettivo", - "weightBody": "La tua media settimanale si è allontanata ulteriormente dal tuo intervallo obiettivo. Il Coach può aiutarti a fare il punto con calma.", - "sleepDebtTitle": "Diverse notti brevi questa settimana", - "sleepDebtBody": "La maggior parte delle tue ultime notti è rimasta sotto il tuo obiettivo di sonno. Il Coach può aiutarti a cercare schemi ricorrenti.", - "measurementGapTitle": "Il tuo diario è rimasto in silenzio", - "measurementGapBody": "Da una settimana non arrivano nuovi valori. Due parole con il Coach aiutano a riprendere la routine — o a scoprire una sincronizzazione interrotta.", - "focusSuffix": "Volevi tenere d'occhio: {focus}.", + "greetingMorning": "Buongiorno", + "greetingMorningNamed": "Buongiorno, {name}", + "greetingHey": "Ciao", + "greetingHeyNamed": "Ciao, {name}", + "complianceBody": "Questa settimana le tue assunzioni sono andate un po' a singhiozzo. Se vuoi, vediamo insieme cosa potrebbe semplificartele.", + "bpBody": "La tua pressione è rimasta questa settimana un po' sopra il tuo intervallo abituale. Quando ti va, la guardo volentieri con te.", + "scoreBody": "Il tuo recupero sta calando un po' in questi ultimi giorni. Se ti incuriosisce il motivo, sono qui per parlarne.", + "selfContextBody": "È da un po' che non aggiorni la tua autodescrizione. Quando hai un momento, rinfrescarla mi aiuta a capirti meglio.", + "weightBody": "Ultimamente il tuo peso si è un po' allontanato dal tuo intervallo obiettivo. Se vuoi, facciamo il punto insieme, con calma.", + "sleepDebtBody": "Diverse delle tue notti sono state un po' corte questa settimana. Se ti va, cerchiamo insieme uno schema.", + "measurementGapBody": "Il tuo diario è in silenzio da circa una settimana. Quando ti è comodo, due parole aiutano a riprendere.", + "focusNote": "Si lega anche a ciò che volevi tenere d'occhio.", "questionConditions": "Hai malattie croniche che il Coach dovrebbe conoscere?", "questionFocus": "C'è qualcosa di specifico a cui il Coach dovrebbe fare attenzione?", "questionAllergies": "Hai allergie o intolleranze?", diff --git a/messages/pl.json b/messages/pl.json index 382247918..84bd67359 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6123,7 +6123,10 @@ "saveError": "Nie udało się zapisać. Spróbuj ponownie później.", "suggestionsLabel": "Przykładowe sugestie", "suggestionsDesc": "Czasami pokazuje gotowe przykładowe pytanie na ekranie Coacha.", - "suggestionsAria": "Przełącz przykładowe sugestie Coacha" + "suggestionsAria": "Przełącz przykładowe sugestie Coacha", + "aiComposedLabel": "Pozwól Coachowi sformułować przypomnienie", + "aiComposedDesc": "Gdy dostawca jest dostępny, proaktywne przypomnienie tworzy model zamiast stałego szablonu. Domyślnie wyłączone.", + "aiComposedAria": "Przełącz proaktywne przypomnienie tworzone przez SI" }, "lowStock": { "title": "Alert niskiego zapasu", @@ -6354,21 +6357,18 @@ "dailyBody": "Jak się dzisiaj czujesz?" }, "coachNudges": { - "complianceTitle": "Twój Coach ma na ten temat przemyślenie", - "complianceBody": "W tym tygodniu Twoje dawki były przyjmowane nieregularnie. Porozmawiaj chwilę z Coachem — może plan da się uprościć.", - "bpTitle": "Podwyższone ciśnienie w tym tygodniu", - "bpBody": "Twoje średnie ciśnienie w tym tygodniu przekroczyło cel. Coach pomoże Ci umieścić wyniki w kontekście.", - "scoreTitle": "Twój wskaźnik regeneracji spada", - "scoreBody": "Twój wskaźnik regeneracji wyraźnie spadł w tym tygodniu. Coach pomoże Ci zawęzić możliwe przyczyny.", - "selfContextTitle": "Pomóż Coachowi lepiej Cię poznać", - "selfContextBody": "Twój opis o sobie jest niepełny lub dawno nie był aktualizowany. Dwie minuty w ustawieniach utrzymają kontekst Coacha w aktualnym stanie.", - "weightTitle": "Twoja waga oddala się od zakresu docelowego", - "weightBody": "Twoja tygodniowa średnia jeszcze bardziej oddaliła się od zakresu docelowego. Coach pomoże Ci spokojnie podsumować sytuację.", - "sleepDebtTitle": "Kilka krótkich nocy w tym tygodniu", - "sleepDebtBody": "Większość Twoich ostatnich nocy była poniżej celu snu. Coach pomoże Ci poszukać wzorców.", - "measurementGapTitle": "Twój dziennik ucichł", - "measurementGapBody": "Od tygodnia nie pojawiają się nowe wartości. Krótka rozmowa z Coachem pomoże wrócić do rutyny — albo wykryć zepsutą synchronizację.", - "focusSuffix": "Chciałeś/chciałaś zwracać uwagę na: {focus}.", + "greetingMorning": "Dzień dobry", + "greetingMorningNamed": "Dzień dobry, {name}", + "greetingHey": "Cześć", + "greetingHeyNamed": "Cześć, {name}", + "complianceBody": "W tym tygodniu Twoje przyjmowanie dawek trochę się rozjechało. Jeśli chcesz, zobaczmy razem, co by Ci to ułatwiło.", + "bpBody": "Twoje ciśnienie było w tym tygodniu nieco powyżej zwykłego zakresu. Kiedy zechcesz, chętnie przyjrzę się temu z Tobą.", + "scoreBody": "Twoja regeneracja od kilku dni nieco słabnie. Jeśli jesteś ciekaw przyczyny, jestem tu, żeby o tym porozmawiać.", + "selfContextBody": "Dawno nie aktualizowano Twojego opisu o sobie. Gdy znajdziesz chwilę, odświeżenie go pomaga mi lepiej Cię zrozumieć.", + "weightBody": "Twoja waga ostatnio nieco oddaliła się od zakresu docelowego. Jeśli chcesz, spokojnie podsumujemy to razem.", + "sleepDebtBody": "Kilka Twoich nocy było w tym tygodniu krótkich. Jeśli masz ochotę, poszukajmy razem wzorca.", + "measurementGapBody": "Twój dziennik milczy od około tygodnia. Kiedy Ci pasuje, krótka rozmowa pomoże wrócić do rutyny.", + "focusNote": "Wiąże się to też z tym, na co chciałeś/chciałaś zwracać uwagę.", "questionConditions": "Czy masz choroby przewlekłe, o których Coach powinien wiedzieć?", "questionFocus": "Czy jest coś konkretnego, na co Coach powinien zwracać uwagę?", "questionAllergies": "Czy masz alergie lub nietolerancje?", diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index 77824fcd8..c67287ba2 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -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, diff --git a/src/components/settings/coach-nudge-card.tsx b/src/components/settings/coach-nudge-card.tsx index 9cc6705d1..ce062a7d9 100644 --- a/src/components/settings/coach-nudge-card.tsx +++ b/src/components/settings/coach-nudge-card.tsx @@ -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({ + {/* 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. */} +
+
+

+ {t("notifications.coachNudge.aiComposedLabel")} +

+

+ {t("notifications.coachNudge.aiComposedDesc")} +

+
+ + patchCoach({ nudgeAiComposed: next }, null) + } + disabled={!isAuthenticated || saving} + aria-label={t("notifications.coachNudge.aiComposedAria")} + /> +
)} {/* v1.25.0 — proactive ambient SUGGESTIONS opt-out. Independent of the diff --git a/src/lib/ai/ai-budgets.ts b/src/lib/ai/ai-budgets.ts index 6716ac5ee..d97544066 100644 --- a/src/lib/ai/ai-budgets.ts +++ b/src/lib/ai/ai-budgets.ts @@ -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. diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts index 3fc8a9592..40180b790 100644 --- a/src/lib/jobs/__tests__/coach-nudge.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge.test.ts @@ -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(); + }); }); diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts new file mode 100644 index 000000000..49944c59b --- /dev/null +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -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 = { + 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; + } +}; diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 55c907164..efed94e02 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -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 = { + 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 { 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 diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 832e8d46f..bba28d96a 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -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, diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts index ee37ed340..99a4ccab7 100644 --- a/src/lib/validations/__tests__/notification-prefs.test.ts +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -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, }); }); diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index 26abf53ab..bd277cc94 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -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, }; }