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