Merge fix/v125-coach-nudge into release/v1.25.0

Proactive nudge rework + PUT /api/auth/me/locale.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 17:02:10 +02:00
commit 0989ed4f20
18 files changed
+908 -237

No files matched your search

+2
View File
@@ -9891,6 +9891,8 @@ components:
- biweekly
ambientSuggestions:
type: boolean
nudgeAiComposed:
type: boolean
measurementReminder:
type: object
properties:
+16 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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?",
+62
View File
@@ -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
+10
View File
@@ -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.
+28
View File
@@ -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;
+215 -50
View File
@@ -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();
});
});
+225
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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,
});
});
+23
View File
@@ -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,
};
}