diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 30b9cbc17..eed99e898 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9891,6 +9891,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/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/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/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; 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, }; }