From fe0a03761e57e07cc1f7d7a12776b0439fe53c36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 10:50:49 +0200 Subject: [PATCH] feat(coach): message catalog for the dialog in all seven languages Adds every string the dialog needs up front: step labels, table titles, columns and controls, the method line, the follow-up chips and their setting, the clarification card, and the caption of an answer reused from a stored table. A key catalog shared by server and client names each one; a test holds the catalog and the bundles equal in both directions. --- messages/de.json | 175 +++++++++++- messages/en.json | 175 +++++++++++- messages/es.json | 175 +++++++++++- messages/fr.json | 175 +++++++++++- messages/it.json | 175 +++++++++++- messages/ko.json | 175 +++++++++++- messages/pl.json | 175 +++++++++++- .../ai/coach/__tests__/dialog-keys.test.ts | 157 +++++++++++ src/lib/ai/coach/dialog-keys.ts | 255 ++++++++++++++++++ 9 files changed, 1630 insertions(+), 7 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/dialog-keys.test.ts create mode 100644 src/lib/ai/coach/dialog-keys.ts diff --git a/messages/de.json b/messages/de.json index 5fea285f0..f37372452 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9480,7 +9480,180 @@ "promptInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.", "conversationPoisoned": "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung." }, - "noRecordedData": "Zu deiner Frage sind bei dir keine Messwerte erfasst, also kann ich dir dazu keine Zahl nennen. Sobald du etwas eingetragen hast, frag mich noch einmal — dann rechne ich mit deinen eigenen Werten." + "noRecordedData": "Zu deiner Frage sind bei dir keine Messwerte erfasst, also kann ich dir dazu keine Zahl nennen. Sobald du etwas eingetragen hast, frag mich noch einmal — dann rechne ich mit deinen eigenen Werten.", + "step": { + "read": "Lese {domain}", + "readWindow": "Lese {domain}, {window}", + "snapshot": "Lese die Übersicht deiner Daten", + "showResult": "Öffne eine frühere Tabelle", + "reuse": "Nutze die Tabelle von vorhin", + "headerActive": "{label}…", + "headerDoneOne": "1 Quelle angesehen", + "headerDoneFew": "{count} Quellen angesehen", + "headerDoneOther": "{count} Quellen angesehen", + "listLabel": "Was der Coach angesehen hat", + "toggleShow": "Anzeigen, was der Coach angesehen hat", + "toggleHide": "Ausblenden, was der Coach angesehen hat", + "announceDone": "Fertig: {label}", + "readingsOne": "1 Messwert", + "readingsFew": "{count} Messwerte", + "readingsOther": "{count} Messwerte", + "rowsOne": "1 Zeile", + "rowsFew": "{count} Zeilen", + "rowsOther": "{count} Zeilen", + "metricsOne": "1 Messgröße", + "metricsFew": "{count} Messgrößen", + "metricsOther": "{count} Messgrößen", + "reason": { + "noData": "keine Messwerte", + "outsideWindow": "keine Messwerte in diesem Zeitraum", + "moduleDisabled": "ausgeschaltet", + "retrievalFailed": "konnte nicht gelesen werden", + "invalidArguments": "Anfrage nicht verstanden" + }, + "domain": { + "labs": "Laborwerte", + "illness": "Krankheit und Erholung", + "cycle": "Zyklus", + "correlations": "Zusammenhänge zwischen Messgrößen", + "snapshot": "Datenübersicht" + }, + "window": { + "last7days": "letzte 7 Tage", + "last30days": "letzte 30 Tage", + "last90days": "letzte 90 Tage", + "lastYear": "letzte 12 Monate", + "allTime": "gesamter Zeitraum" + }, + "period": { + "current": "dieser Zeitraum", + "previous": "der Zeitraum davor", + "yearAgo": "ein Jahr zuvor" + }, + "granularity": { + "day": "nach Tag", + "week": "nach Woche", + "month": "nach Monat" + } + }, + "result": { + "title": { + "byDay": "{metric} nach Tag", + "byWeek": "{metric} nach Woche", + "byMonth": "{metric} nach Monat", + "previousPeriod": "{metric}, Zeitraum davor", + "yearAgo": "{metric}, ein Jahr zuvor", + "workoutsBySport": "Trainings nach Sportart", + "labsLatest": "Neueste Laborwerte", + "sleepByNight": "Schlaf nach Nacht", + "complianceByDay": "Medikamenten-Einnahmetreue nach Tag", + "distribution": "{metric}: wie oft welcher Bereich vorkam" + }, + "column": { + "day": "Tag", + "week": "Woche", + "month": "Monat", + "night": "Nacht", + "sport": "Sportart", + "sessions": "Einheiten", + "analyte": "Laborwert", + "value": "Wert", + "unit": "Einheit", + "date": "Datum", + "referenceRange": "Referenzbereich", + "systolic": "Systolisch", + "diastolic": "Diastolisch", + "mean": "Durchschnitt", + "min": "Niedrigster", + "max": "Höchster", + "readings": "Messwerte", + "rate": "Einnahmetreue", + "duration": "Dauer", + "range": "Bereich", + "count": "Anzahl" + }, + "other": "Sonstige", + "noReading": "kein Messwert", + "showAll": "Alle anzeigen ({count})", + "showFewer": "Weniger anzeigen", + "copyMenu": "Tabelle kopieren", + "copyForSpreadsheet": "Für Tabellenkalkulation kopieren", + "copyAsText": "Als Text kopieren", + "copied": "Kopiert", + "copyFailed": "Die Tabelle konnte nicht kopiert werden", + "viewLabel": "Ansicht", + "viewChart": "Diagramm", + "viewTable": "Tabelle", + "chartSummary": "{title} als Diagramm. Die Tabellenansicht listet jeden Wert auf.", + "dataUsed": "Verwendete Daten ({count})", + "truncated": "Die ersten {shown} von {total} Zeilen", + "withheld": { + "moduleDisabled": "Diese Tabelle ist ausgeblendet, weil ihr Modul ausgeschaltet ist.", + "unavailable": "Diese Tabelle konnte nicht geladen werden." + }, + "loading": "Tabelle wird geladen…", + "loadFailed": "Die Tabellen konnten nicht geladen werden.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "Wie das ermittelt wurde", + "entry": "{domain}, {window}: {detail}", + "entryNoWindow": "{domain}: {detail}", + "readingsOne": "1 Messwert", + "readingsFew": "{count} Messwerte", + "readingsOther": "{count} Messwerte", + "aggregation": { + "mean": "Durchschnitt", + "median": "Median", + "latest": "letzter Messwert", + "sum": "Summe", + "count": "Anzahl", + "rate": "Quote" + }, + "averages": { + "day": "Tagesdurchschnitte", + "week": "Wochendurchschnitte", + "month": "Monatsdurchschnitte" + }, + "totals": { + "day": "Tagessummen", + "week": "Wochensummen", + "month": "Monatssummen" + }, + "absent": { + "noData": "keine Messwerte", + "outsideWindow": "keine Messwerte in diesem Zeitraum", + "moduleDisabled": "ausgeschaltet" + } + }, + "followUp": { + "groupLabel": "Vorgeschlagene Anschlussfragen", + "widenWindow": "Weiter zurückschauen", + "previousPeriod": "Mit dem Zeitraum davor vergleichen", + "yearAgo": "Mit dem Vorjahr vergleichen", + "asChart": "Als Diagramm zeigen", + "asTable": "Als Tabelle zeigen", + "relatedMetric": "Auch {metric} ansehen", + "continue": "Weitersuchen", + "settingLabel": "Anschlussfragen vorschlagen", + "settingHint": "Zeigt unter der letzten Antwort des Coaches bis zu drei Vorschläge für die nächste Frage." + }, + "clarify": { + "cardLabel": "Der Coach hat eine Rückfrage", + "choicesLabel": "Mögliche Antworten", + "freeTextHint": "Du kannst auch selbst eine Antwort schreiben.", + "contextHint": "Schreib deine Antwort unten.", + "window": { + "last7days": "Letzte 7 Tage", + "last30days": "Letzte 30 Tage", + "last90days": "Letzte 90 Tage", + "lastYear": "Letzte 12 Monate", + "allTime": "Gesamter Zeitraum" + } + }, + "reuse": { + "caption": "Hier sind dieselben Werte aus meiner letzten Antwort, unverändert." + } }, "safety": { "floor": { diff --git a/messages/en.json b/messages/en.json index ff67a71e9..2a914438f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9480,7 +9480,180 @@ "promptInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.", "conversationPoisoned": "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation." }, - "noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers." + "noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers.", + "step": { + "read": "Reading {domain}", + "readWindow": "Reading {domain}, {window}", + "snapshot": "Reading your record summary", + "showResult": "Opening an earlier table", + "reuse": "Using the table from before", + "headerActive": "{label}…", + "headerDoneOne": "Looked at 1 source", + "headerDoneFew": "Looked at {count} sources", + "headerDoneOther": "Looked at {count} sources", + "listLabel": "What the Coach looked at", + "toggleShow": "Show what the Coach looked at", + "toggleHide": "Hide what the Coach looked at", + "announceDone": "Done: {label}", + "readingsOne": "1 reading", + "readingsFew": "{count} readings", + "readingsOther": "{count} readings", + "rowsOne": "1 row", + "rowsFew": "{count} rows", + "rowsOther": "{count} rows", + "metricsOne": "1 metric", + "metricsFew": "{count} metrics", + "metricsOther": "{count} metrics", + "reason": { + "noData": "no readings", + "outsideWindow": "no readings in this window", + "moduleDisabled": "switched off", + "retrievalFailed": "couldn't be read", + "invalidArguments": "request not understood" + }, + "domain": { + "labs": "Lab results", + "illness": "Illness and recovery", + "cycle": "Cycle", + "correlations": "Connections between metrics", + "snapshot": "Record summary" + }, + "window": { + "last7days": "last 7 days", + "last30days": "last 30 days", + "last90days": "last 90 days", + "lastYear": "last 12 months", + "allTime": "all time" + }, + "period": { + "current": "this period", + "previous": "the period before", + "yearAgo": "a year earlier" + }, + "granularity": { + "day": "by day", + "week": "by week", + "month": "by month" + } + }, + "result": { + "title": { + "byDay": "{metric} by day", + "byWeek": "{metric} by week", + "byMonth": "{metric} by month", + "previousPeriod": "{metric}, the period before", + "yearAgo": "{metric}, a year earlier", + "workoutsBySport": "Workouts by sport", + "labsLatest": "Latest lab results", + "sleepByNight": "Sleep by night", + "complianceByDay": "Medication adherence by day", + "distribution": "{metric}: how often each range came up" + }, + "column": { + "day": "Day", + "week": "Week", + "month": "Month", + "night": "Night", + "sport": "Sport", + "sessions": "Sessions", + "analyte": "Test", + "value": "Value", + "unit": "Unit", + "date": "Date", + "referenceRange": "Reference range", + "systolic": "Systolic", + "diastolic": "Diastolic", + "mean": "Average", + "min": "Lowest", + "max": "Highest", + "readings": "Readings", + "rate": "Adherence", + "duration": "Duration", + "range": "Range", + "count": "Times" + }, + "other": "Other", + "noReading": "no reading", + "showAll": "Show all ({count})", + "showFewer": "Show fewer", + "copyMenu": "Copy table", + "copyForSpreadsheet": "Copy for spreadsheet", + "copyAsText": "Copy as text", + "copied": "Copied", + "copyFailed": "Couldn't copy the table", + "viewLabel": "View", + "viewChart": "Chart", + "viewTable": "Table", + "chartSummary": "{title}, shown as a chart. The table view lists every value.", + "dataUsed": "Data used ({count})", + "truncated": "Showing the first {shown} of {total} rows", + "withheld": { + "moduleDisabled": "This table is hidden because its module is switched off.", + "unavailable": "This table couldn't be loaded." + }, + "loading": "Loading table…", + "loadFailed": "Couldn't load the tables.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "How this was worked out", + "entry": "{domain}, {window}: {detail}", + "entryNoWindow": "{domain}: {detail}", + "readingsOne": "1 reading", + "readingsFew": "{count} readings", + "readingsOther": "{count} readings", + "aggregation": { + "mean": "average", + "median": "median", + "latest": "latest reading", + "sum": "total", + "count": "count", + "rate": "rate" + }, + "averages": { + "day": "daily averages", + "week": "weekly averages", + "month": "monthly averages" + }, + "totals": { + "day": "daily totals", + "week": "weekly totals", + "month": "monthly totals" + }, + "absent": { + "noData": "no readings", + "outsideWindow": "no readings in this window", + "moduleDisabled": "switched off" + } + }, + "followUp": { + "groupLabel": "Suggested follow-ups", + "widenWindow": "Look further back", + "previousPeriod": "Compare with the period before", + "yearAgo": "Compare with a year earlier", + "asChart": "Show as a chart", + "asTable": "Show as a table", + "relatedMetric": "Look at {metric} too", + "continue": "Keep looking", + "settingLabel": "Suggest follow-up questions", + "settingHint": "Show up to three follow-up suggestions under the Coach's latest answer." + }, + "clarify": { + "cardLabel": "The Coach has a question", + "choicesLabel": "Possible answers", + "freeTextHint": "You can also type your own answer.", + "contextHint": "Type your answer below.", + "window": { + "last7days": "Last 7 days", + "last30days": "Last 30 days", + "last90days": "Last 90 days", + "lastYear": "Last 12 months", + "allTime": "All time" + } + }, + "reuse": { + "caption": "Here are the same figures from my last answer, unchanged." + } }, "safety": { "floor": { diff --git a/messages/es.json b/messages/es.json index 241ebd154..8c66d2e1d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9480,7 +9480,180 @@ "promptInjection": "He detectado instrucciones que intentan anular mis directrices. Soy el Coach de HealthLog y solo resumo tus propios datos de salud; reformula tu pregunta en esos términos, por favor.", "conversationPoisoned": "Un mensaje anterior de esta conversación contiene instrucciones que intentan anular mis directrices. Empieza una conversación nueva, por favor." }, - "noRecordedData": "No tienes ninguna medición registrada sobre lo que preguntas, así que no puedo darte ninguna cifra. Cuando registres algo, vuelve a preguntarme y trabajaré con tus propios datos." + "noRecordedData": "No tienes ninguna medición registrada sobre lo que preguntas, así que no puedo darte ninguna cifra. Cuando registres algo, vuelve a preguntarme y trabajaré con tus propios datos.", + "step": { + "read": "Leyendo {domain}", + "readWindow": "Leyendo {domain}, {window}", + "snapshot": "Leyendo el resumen de tus datos", + "showResult": "Abriendo una tabla anterior", + "reuse": "Usando la tabla de antes", + "headerActive": "{label}…", + "headerDoneOne": "Consultó 1 fuente", + "headerDoneFew": "Consultó {count} fuentes", + "headerDoneOther": "Consultó {count} fuentes", + "listLabel": "Lo que consultó el Coach", + "toggleShow": "Mostrar lo que consultó el Coach", + "toggleHide": "Ocultar lo que consultó el Coach", + "announceDone": "Listo: {label}", + "readingsOne": "1 medición", + "readingsFew": "{count} mediciones", + "readingsOther": "{count} mediciones", + "rowsOne": "1 fila", + "rowsFew": "{count} filas", + "rowsOther": "{count} filas", + "metricsOne": "1 métrica", + "metricsFew": "{count} métricas", + "metricsOther": "{count} métricas", + "reason": { + "noData": "sin mediciones", + "outsideWindow": "sin mediciones en este periodo", + "moduleDisabled": "desactivado", + "retrievalFailed": "no se pudo leer", + "invalidArguments": "solicitud no entendida" + }, + "domain": { + "labs": "Resultados de laboratorio", + "illness": "Enfermedad y recuperación", + "cycle": "Ciclo", + "correlations": "Relaciones entre métricas", + "snapshot": "Resumen de datos" + }, + "window": { + "last7days": "últimos 7 días", + "last30days": "últimos 30 días", + "last90days": "últimos 90 días", + "lastYear": "últimos 12 meses", + "allTime": "todo el periodo" + }, + "period": { + "current": "este periodo", + "previous": "el periodo anterior", + "yearAgo": "un año antes" + }, + "granularity": { + "day": "por día", + "week": "por semana", + "month": "por mes" + } + }, + "result": { + "title": { + "byDay": "{metric} por día", + "byWeek": "{metric} por semana", + "byMonth": "{metric} por mes", + "previousPeriod": "{metric}, periodo anterior", + "yearAgo": "{metric}, un año antes", + "workoutsBySport": "Entrenamientos por deporte", + "labsLatest": "Resultados de laboratorio más recientes", + "sleepByNight": "Sueño por noche", + "complianceByDay": "Adherencia a la medicación por día", + "distribution": "{metric}: con qué frecuencia apareció cada rango" + }, + "column": { + "day": "Día", + "week": "Semana", + "month": "Mes", + "night": "Noche", + "sport": "Deporte", + "sessions": "Sesiones", + "analyte": "Prueba", + "value": "Valor", + "unit": "Unidad", + "date": "Fecha", + "referenceRange": "Rango de referencia", + "systolic": "Sistólica", + "diastolic": "Diastólica", + "mean": "Promedio", + "min": "Mínimo", + "max": "Máximo", + "readings": "Mediciones", + "rate": "Adherencia", + "duration": "Duración", + "range": "Rango", + "count": "Veces" + }, + "other": "Otros", + "noReading": "sin medición", + "showAll": "Mostrar todo ({count})", + "showFewer": "Mostrar menos", + "copyMenu": "Copiar tabla", + "copyForSpreadsheet": "Copiar para hoja de cálculo", + "copyAsText": "Copiar como texto", + "copied": "Copiado", + "copyFailed": "No se pudo copiar la tabla", + "viewLabel": "Vista", + "viewChart": "Gráfico", + "viewTable": "Tabla", + "chartSummary": "{title}, mostrado como gráfico. La vista de tabla recoge cada valor.", + "dataUsed": "Datos usados ({count})", + "truncated": "Se muestran las primeras {shown} de {total} filas", + "withheld": { + "moduleDisabled": "Esta tabla está oculta porque su módulo está desactivado.", + "unavailable": "No se pudo cargar esta tabla." + }, + "loading": "Cargando tabla…", + "loadFailed": "No se pudieron cargar las tablas.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "Cómo se obtuvo", + "entry": "{domain}, {window}: {detail}", + "entryNoWindow": "{domain}: {detail}", + "readingsOne": "1 medición", + "readingsFew": "{count} mediciones", + "readingsOther": "{count} mediciones", + "aggregation": { + "mean": "promedio", + "median": "mediana", + "latest": "última medición", + "sum": "suma", + "count": "recuento", + "rate": "tasa" + }, + "averages": { + "day": "promedios diarios", + "week": "promedios semanales", + "month": "promedios mensuales" + }, + "totals": { + "day": "totales diarios", + "week": "totales semanales", + "month": "totales mensuales" + }, + "absent": { + "noData": "sin mediciones", + "outsideWindow": "sin mediciones en este periodo", + "moduleDisabled": "desactivado" + } + }, + "followUp": { + "groupLabel": "Preguntas sugeridas", + "widenWindow": "Mirar más atrás", + "previousPeriod": "Comparar con el periodo anterior", + "yearAgo": "Comparar con un año antes", + "asChart": "Mostrar como gráfico", + "asTable": "Mostrar como tabla", + "relatedMetric": "Ver también {metric}", + "continue": "Seguir buscando", + "settingLabel": "Sugerir preguntas de seguimiento", + "settingHint": "Muestra hasta tres sugerencias bajo la última respuesta del Coach." + }, + "clarify": { + "cardLabel": "El Coach tiene una pregunta", + "choicesLabel": "Respuestas posibles", + "freeTextHint": "También puedes escribir tu propia respuesta.", + "contextHint": "Escribe tu respuesta abajo.", + "window": { + "last7days": "Últimos 7 días", + "last30days": "Últimos 30 días", + "last90days": "Últimos 90 días", + "lastYear": "Últimos 12 meses", + "allTime": "Todo el periodo" + } + }, + "reuse": { + "caption": "Aquí están las mismas cifras de mi última respuesta, sin cambios." + } }, "safety": { "floor": { diff --git a/messages/fr.json b/messages/fr.json index 64cbe69f1..6d333200b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9480,7 +9480,180 @@ "promptInjection": "J'ai repéré des instructions qui cherchent à contourner mes consignes. Je suis le Coach HealthLog et je résume uniquement tes propres données de santé — reformule ta question dans ce cadre, s'il te plaît.", "conversationPoisoned": "Un message précédent de cette conversation contient des instructions qui cherchent à contourner mes consignes. Démarre une nouvelle conversation, s'il te plaît." }, - "noRecordedData": "Aucune mesure n'est enregistrée pour ce que tu demandes, je ne peux donc te donner aucun chiffre. Dès que tu en auras saisi, repose-moi la question et je partirai de tes propres valeurs." + "noRecordedData": "Aucune mesure n'est enregistrée pour ce que tu demandes, je ne peux donc te donner aucun chiffre. Dès que tu en auras saisi, repose-moi la question et je partirai de tes propres valeurs.", + "step": { + "read": "Lecture : {domain}", + "readWindow": "Lecture : {domain}, {window}", + "snapshot": "Lecture du résumé de tes données", + "showResult": "Ouverture d'un tableau précédent", + "reuse": "Reprise du tableau précédent", + "headerActive": "{label}…", + "headerDoneOne": "1 source consultée", + "headerDoneFew": "{count} sources consultées", + "headerDoneOther": "{count} sources consultées", + "listLabel": "Ce que le Coach a consulté", + "toggleShow": "Afficher ce que le Coach a consulté", + "toggleHide": "Masquer ce que le Coach a consulté", + "announceDone": "Terminé : {label}", + "readingsOne": "1 mesure", + "readingsFew": "{count} mesures", + "readingsOther": "{count} mesures", + "rowsOne": "1 ligne", + "rowsFew": "{count} lignes", + "rowsOther": "{count} lignes", + "metricsOne": "1 indicateur", + "metricsFew": "{count} indicateurs", + "metricsOther": "{count} indicateurs", + "reason": { + "noData": "aucune mesure", + "outsideWindow": "aucune mesure sur cette période", + "moduleDisabled": "désactivé", + "retrievalFailed": "lecture impossible", + "invalidArguments": "demande non comprise" + }, + "domain": { + "labs": "Résultats d'analyses", + "illness": "Maladie et récupération", + "cycle": "Cycle", + "correlations": "Liens entre indicateurs", + "snapshot": "Résumé des données" + }, + "window": { + "last7days": "7 derniers jours", + "last30days": "30 derniers jours", + "last90days": "90 derniers jours", + "lastYear": "12 derniers mois", + "allTime": "toute la période" + }, + "period": { + "current": "cette période", + "previous": "la période précédente", + "yearAgo": "un an plus tôt" + }, + "granularity": { + "day": "par jour", + "week": "par semaine", + "month": "par mois" + } + }, + "result": { + "title": { + "byDay": "{metric} par jour", + "byWeek": "{metric} par semaine", + "byMonth": "{metric} par mois", + "previousPeriod": "{metric}, période précédente", + "yearAgo": "{metric}, un an plus tôt", + "workoutsBySport": "Entraînements par sport", + "labsLatest": "Derniers résultats d'analyses", + "sleepByNight": "Sommeil par nuit", + "complianceByDay": "Observance des médicaments par jour", + "distribution": "{metric} : fréquence de chaque plage" + }, + "column": { + "day": "Jour", + "week": "Semaine", + "month": "Mois", + "night": "Nuit", + "sport": "Sport", + "sessions": "Séances", + "analyte": "Analyse", + "value": "Valeur", + "unit": "Unité", + "date": "Date", + "referenceRange": "Plage de référence", + "systolic": "Systolique", + "diastolic": "Diastolique", + "mean": "Moyenne", + "min": "Minimum", + "max": "Maximum", + "readings": "Mesures", + "rate": "Observance", + "duration": "Durée", + "range": "Plage", + "count": "Occurrences" + }, + "other": "Autres", + "noReading": "aucune mesure", + "showAll": "Tout afficher ({count})", + "showFewer": "Afficher moins", + "copyMenu": "Copier le tableau", + "copyForSpreadsheet": "Copier pour un tableur", + "copyAsText": "Copier en texte", + "copied": "Copié", + "copyFailed": "Impossible de copier le tableau", + "viewLabel": "Affichage", + "viewChart": "Graphique", + "viewTable": "Tableau", + "chartSummary": "{title}, sous forme de graphique. La vue tableau liste chaque valeur.", + "dataUsed": "Données utilisées ({count})", + "truncated": "Affichage des {shown} premières lignes sur {total}", + "withheld": { + "moduleDisabled": "Ce tableau est masqué car son module est désactivé.", + "unavailable": "Impossible de charger ce tableau." + }, + "loading": "Chargement du tableau…", + "loadFailed": "Impossible de charger les tableaux.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "Comment cela a été calculé", + "entry": "{domain}, {window} : {detail}", + "entryNoWindow": "{domain} : {detail}", + "readingsOne": "1 mesure", + "readingsFew": "{count} mesures", + "readingsOther": "{count} mesures", + "aggregation": { + "mean": "moyenne", + "median": "médiane", + "latest": "dernière mesure", + "sum": "somme", + "count": "nombre", + "rate": "taux" + }, + "averages": { + "day": "moyennes journalières", + "week": "moyennes hebdomadaires", + "month": "moyennes mensuelles" + }, + "totals": { + "day": "totaux journaliers", + "week": "totaux hebdomadaires", + "month": "totaux mensuels" + }, + "absent": { + "noData": "aucune mesure", + "outsideWindow": "aucune mesure sur cette période", + "moduleDisabled": "désactivé" + } + }, + "followUp": { + "groupLabel": "Questions suggérées", + "widenWindow": "Remonter plus loin", + "previousPeriod": "Comparer avec la période précédente", + "yearAgo": "Comparer avec l'an dernier", + "asChart": "Afficher en graphique", + "asTable": "Afficher en tableau", + "relatedMetric": "Regarder aussi {metric}", + "continue": "Continuer à chercher", + "settingLabel": "Suggérer des questions de suivi", + "settingHint": "Affiche jusqu'à trois suggestions sous la dernière réponse du Coach." + }, + "clarify": { + "cardLabel": "Le Coach a une question", + "choicesLabel": "Réponses possibles", + "freeTextHint": "Tu peux aussi écrire ta propre réponse.", + "contextHint": "Écris ta réponse ci-dessous.", + "window": { + "last7days": "7 derniers jours", + "last30days": "30 derniers jours", + "last90days": "90 derniers jours", + "lastYear": "12 derniers mois", + "allTime": "Toute la période" + } + }, + "reuse": { + "caption": "Voici les mêmes chiffres que dans ma dernière réponse, sans changement." + } }, "safety": { "floor": { diff --git a/messages/it.json b/messages/it.json index 404c0ba2d..cbabb0406 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9480,7 +9480,180 @@ "promptInjection": "Ho notato istruzioni che cercano di aggirare le mie direttive. Sono il Coach di HealthLog e riassumo soltanto i tuoi dati di salute — riformula la domanda in questi termini, per favore.", "conversationPoisoned": "Un messaggio precedente di questa conversazione contiene istruzioni che cercano di aggirare le mie direttive. Inizia una nuova conversazione, per favore." }, - "noRecordedData": "Non hai misurazioni registrate su quello che chiedi, quindi non posso darti alcun valore. Appena ne inserisci qualcuna, richiedimelo e userò i tuoi dati." + "noRecordedData": "Non hai misurazioni registrate su quello che chiedi, quindi non posso darti alcun valore. Appena ne inserisci qualcuna, richiedimelo e userò i tuoi dati.", + "step": { + "read": "Lettura di {domain}", + "readWindow": "Lettura di {domain}, {window}", + "snapshot": "Lettura del riepilogo dei tuoi dati", + "showResult": "Apertura di una tabella precedente", + "reuse": "Uso della tabella di prima", + "headerActive": "{label}…", + "headerDoneOne": "1 fonte consultata", + "headerDoneFew": "{count} fonti consultate", + "headerDoneOther": "{count} fonti consultate", + "listLabel": "Cosa ha consultato il Coach", + "toggleShow": "Mostra cosa ha consultato il Coach", + "toggleHide": "Nascondi cosa ha consultato il Coach", + "announceDone": "Fatto: {label}", + "readingsOne": "1 misurazione", + "readingsFew": "{count} misurazioni", + "readingsOther": "{count} misurazioni", + "rowsOne": "1 riga", + "rowsFew": "{count} righe", + "rowsOther": "{count} righe", + "metricsOne": "1 parametro", + "metricsFew": "{count} parametri", + "metricsOther": "{count} parametri", + "reason": { + "noData": "nessuna misurazione", + "outsideWindow": "nessuna misurazione in questo periodo", + "moduleDisabled": "disattivato", + "retrievalFailed": "lettura non riuscita", + "invalidArguments": "richiesta non compresa" + }, + "domain": { + "labs": "Esami di laboratorio", + "illness": "Malattia e recupero", + "cycle": "Ciclo", + "correlations": "Relazioni tra parametri", + "snapshot": "Riepilogo dei dati" + }, + "window": { + "last7days": "ultimi 7 giorni", + "last30days": "ultimi 30 giorni", + "last90days": "ultimi 90 giorni", + "lastYear": "ultimi 12 mesi", + "allTime": "intero periodo" + }, + "period": { + "current": "questo periodo", + "previous": "il periodo precedente", + "yearAgo": "un anno prima" + }, + "granularity": { + "day": "per giorno", + "week": "per settimana", + "month": "per mese" + } + }, + "result": { + "title": { + "byDay": "{metric} per giorno", + "byWeek": "{metric} per settimana", + "byMonth": "{metric} per mese", + "previousPeriod": "{metric}, periodo precedente", + "yearAgo": "{metric}, un anno prima", + "workoutsBySport": "Allenamenti per sport", + "labsLatest": "Esami di laboratorio più recenti", + "sleepByNight": "Sonno per notte", + "complianceByDay": "Aderenza ai farmaci per giorno", + "distribution": "{metric}: quanto spesso è comparso ogni intervallo" + }, + "column": { + "day": "Giorno", + "week": "Settimana", + "month": "Mese", + "night": "Notte", + "sport": "Sport", + "sessions": "Sessioni", + "analyte": "Esame", + "value": "Valore", + "unit": "Unità", + "date": "Data", + "referenceRange": "Intervallo di riferimento", + "systolic": "Sistolica", + "diastolic": "Diastolica", + "mean": "Media", + "min": "Minimo", + "max": "Massimo", + "readings": "Misurazioni", + "rate": "Aderenza", + "duration": "Durata", + "range": "Intervallo", + "count": "Volte" + }, + "other": "Altro", + "noReading": "nessuna misurazione", + "showAll": "Mostra tutto ({count})", + "showFewer": "Mostra meno", + "copyMenu": "Copia tabella", + "copyForSpreadsheet": "Copia per foglio di calcolo", + "copyAsText": "Copia come testo", + "copied": "Copiato", + "copyFailed": "Impossibile copiare la tabella", + "viewLabel": "Vista", + "viewChart": "Grafico", + "viewTable": "Tabella", + "chartSummary": "{title}, mostrato come grafico. La vista tabella elenca ogni valore.", + "dataUsed": "Dati usati ({count})", + "truncated": "Prime {shown} righe su {total}", + "withheld": { + "moduleDisabled": "Questa tabella è nascosta perché il suo modulo è disattivato.", + "unavailable": "Impossibile caricare questa tabella." + }, + "loading": "Caricamento della tabella…", + "loadFailed": "Impossibile caricare le tabelle.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "Come è stato ricavato", + "entry": "{domain}, {window}: {detail}", + "entryNoWindow": "{domain}: {detail}", + "readingsOne": "1 misurazione", + "readingsFew": "{count} misurazioni", + "readingsOther": "{count} misurazioni", + "aggregation": { + "mean": "media", + "median": "mediana", + "latest": "ultima misurazione", + "sum": "somma", + "count": "conteggio", + "rate": "tasso" + }, + "averages": { + "day": "medie giornaliere", + "week": "medie settimanali", + "month": "medie mensili" + }, + "totals": { + "day": "totali giornalieri", + "week": "totali settimanali", + "month": "totali mensili" + }, + "absent": { + "noData": "nessuna misurazione", + "outsideWindow": "nessuna misurazione in questo periodo", + "moduleDisabled": "disattivato" + } + }, + "followUp": { + "groupLabel": "Domande suggerite", + "widenWindow": "Guarda più indietro", + "previousPeriod": "Confronta con il periodo precedente", + "yearAgo": "Confronta con un anno prima", + "asChart": "Mostra come grafico", + "asTable": "Mostra come tabella", + "relatedMetric": "Guarda anche {metric}", + "continue": "Continua a cercare", + "settingLabel": "Suggerisci domande di approfondimento", + "settingHint": "Mostra fino a tre suggerimenti sotto l'ultima risposta del Coach." + }, + "clarify": { + "cardLabel": "Il Coach ha una domanda", + "choicesLabel": "Risposte possibili", + "freeTextHint": "Puoi anche scrivere una tua risposta.", + "contextHint": "Scrivi la tua risposta qui sotto.", + "window": { + "last7days": "Ultimi 7 giorni", + "last30days": "Ultimi 30 giorni", + "last90days": "Ultimi 90 giorni", + "lastYear": "Ultimi 12 mesi", + "allTime": "Intero periodo" + } + }, + "reuse": { + "caption": "Ecco gli stessi valori della mia ultima risposta, invariati." + } }, "safety": { "floor": { diff --git a/messages/ko.json b/messages/ko.json index 8ce36f817..2f031e7b3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9480,7 +9480,180 @@ "promptInjection": "제 지침을 무시하게 만들려는 표현이 보였어요. 저는 HealthLog 코치이고 본인의 건강 데이터만 정리해 드려요 — 그 범위에 맞게 다시 질문해 주세요.", "conversationPoisoned": "이 대화의 앞선 메시지에 제 지침을 무시하게 만드는 표현이 있어요. 새 대화를 시작해 주세요." }, - "noRecordedData": "물어보신 내용에 대해 기록된 측정값이 없어서 수치를 알려드릴 수 없어요. 기록을 남기신 뒤에 다시 물어보시면 본인 수치로 답해 드릴게요." + "noRecordedData": "물어보신 내용에 대해 기록된 측정값이 없어서 수치를 알려드릴 수 없어요. 기록을 남기신 뒤에 다시 물어보시면 본인 수치로 답해 드릴게요.", + "step": { + "read": "{domain} 확인 중", + "readWindow": "{domain}, {window} 확인 중", + "snapshot": "기록 요약 확인 중", + "showResult": "이전 표 여는 중", + "reuse": "앞서 만든 표 사용 중", + "headerActive": "{label}…", + "headerDoneOne": "출처 1개 확인함", + "headerDoneFew": "출처 {count}개 확인함", + "headerDoneOther": "출처 {count}개 확인함", + "listLabel": "코치가 확인한 내용", + "toggleShow": "코치가 확인한 내용 보기", + "toggleHide": "코치가 확인한 내용 숨기기", + "announceDone": "완료: {label}", + "readingsOne": "측정값 1개", + "readingsFew": "측정값 {count}개", + "readingsOther": "측정값 {count}개", + "rowsOne": "1행", + "rowsFew": "{count}행", + "rowsOther": "{count}행", + "metricsOne": "지표 1개", + "metricsFew": "지표 {count}개", + "metricsOther": "지표 {count}개", + "reason": { + "noData": "측정값 없음", + "outsideWindow": "이 기간에는 측정값 없음", + "moduleDisabled": "꺼져 있음", + "retrievalFailed": "읽지 못함", + "invalidArguments": "요청을 이해하지 못함" + }, + "domain": { + "labs": "검사 결과", + "illness": "질병과 회복", + "cycle": "생리 주기", + "correlations": "지표 간 연관성", + "snapshot": "기록 요약" + }, + "window": { + "last7days": "최근 7일", + "last30days": "최근 30일", + "last90days": "최근 90일", + "lastYear": "최근 12개월", + "allTime": "전체 기간" + }, + "period": { + "current": "이번 기간", + "previous": "이전 기간", + "yearAgo": "1년 전" + }, + "granularity": { + "day": "일별", + "week": "주별", + "month": "월별" + } + }, + "result": { + "title": { + "byDay": "일별 {metric}", + "byWeek": "주별 {metric}", + "byMonth": "월별 {metric}", + "previousPeriod": "{metric}, 이전 기간", + "yearAgo": "{metric}, 1년 전", + "workoutsBySport": "종목별 운동", + "labsLatest": "최근 검사 결과", + "sleepByNight": "밤별 수면", + "complianceByDay": "일별 복약 순응도", + "distribution": "{metric}: 구간별 빈도" + }, + "column": { + "day": "날짜", + "week": "주", + "month": "월", + "night": "밤", + "sport": "종목", + "sessions": "횟수", + "analyte": "검사 항목", + "value": "값", + "unit": "단위", + "date": "일자", + "referenceRange": "참고 범위", + "systolic": "수축기", + "diastolic": "이완기", + "mean": "평균", + "min": "최저", + "max": "최고", + "readings": "측정 횟수", + "rate": "순응도", + "duration": "시간", + "range": "구간", + "count": "빈도" + }, + "other": "기타", + "noReading": "측정값 없음", + "showAll": "모두 보기 ({count})", + "showFewer": "간단히 보기", + "copyMenu": "표 복사", + "copyForSpreadsheet": "스프레드시트용으로 복사", + "copyAsText": "텍스트로 복사", + "copied": "복사됨", + "copyFailed": "표를 복사하지 못했어요", + "viewLabel": "보기 방식", + "viewChart": "차트", + "viewTable": "표", + "chartSummary": "{title}을(를) 차트로 표시해요. 표 보기에서 모든 값을 확인할 수 있어요.", + "dataUsed": "사용한 데이터 ({count})", + "truncated": "전체 {total}행 중 처음 {shown}행 표시", + "withheld": { + "moduleDisabled": "해당 모듈이 꺼져 있어 이 표를 숨겼어요.", + "unavailable": "이 표를 불러오지 못했어요." + }, + "loading": "표 불러오는 중…", + "loadFailed": "표를 불러오지 못했어요.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "산출 방법", + "entry": "{domain}, {window}: {detail}", + "entryNoWindow": "{domain}: {detail}", + "readingsOne": "측정값 1개", + "readingsFew": "측정값 {count}개", + "readingsOther": "측정값 {count}개", + "aggregation": { + "mean": "평균", + "median": "중앙값", + "latest": "최근 측정값", + "sum": "합계", + "count": "개수", + "rate": "비율" + }, + "averages": { + "day": "일 평균", + "week": "주 평균", + "month": "월 평균" + }, + "totals": { + "day": "일 합계", + "week": "주 합계", + "month": "월 합계" + }, + "absent": { + "noData": "측정값 없음", + "outsideWindow": "이 기간에는 측정값 없음", + "moduleDisabled": "꺼져 있음" + } + }, + "followUp": { + "groupLabel": "추천 후속 질문", + "widenWindow": "더 이전까지 보기", + "previousPeriod": "이전 기간과 비교", + "yearAgo": "1년 전과 비교", + "asChart": "차트로 보기", + "asTable": "표로 보기", + "relatedMetric": "{metric}도 보기", + "continue": "계속 찾아보기", + "settingLabel": "후속 질문 추천", + "settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요." + }, + "clarify": { + "cardLabel": "코치가 질문이 있어요", + "choicesLabel": "선택할 수 있는 답변", + "freeTextHint": "직접 답을 입력해도 돼요.", + "contextHint": "아래에 답을 입력해 주세요.", + "window": { + "last7days": "최근 7일", + "last30days": "최근 30일", + "last90days": "최근 90일", + "lastYear": "최근 12개월", + "allTime": "전체 기간" + } + }, + "reuse": { + "caption": "지난 답변과 같은 수치를 그대로 보여드릴게요." + } }, "safety": { "floor": { diff --git a/messages/pl.json b/messages/pl.json index fccc01aca..775c3ed9d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9480,7 +9480,180 @@ "promptInjection": "Zauważyłem polecenia, które próbują obejść moje wytyczne. Jestem Coachem HealthLog i podsumowuję wyłącznie Twoje własne dane zdrowotne — sformułuj pytanie w tych ramach.", "conversationPoisoned": "Wcześniejsza wiadomość w tej rozmowie zawiera polecenia, które próbują obejść moje wytyczne. Rozpocznij nową rozmowę." }, - "noRecordedData": "Nie masz zapisanych żadnych pomiarów dla tego, o co pytasz, więc nie mogę podać Ci żadnej liczby. Gdy coś zapiszesz, zapytaj ponownie — wtedy oprę się na Twoich własnych wartościach." + "noRecordedData": "Nie masz zapisanych żadnych pomiarów dla tego, o co pytasz, więc nie mogę podać Ci żadnej liczby. Gdy coś zapiszesz, zapytaj ponownie — wtedy oprę się na Twoich własnych wartościach.", + "step": { + "read": "Czytam: {domain}", + "readWindow": "Czytam: {domain}, {window}", + "snapshot": "Czytam podsumowanie Twoich danych", + "showResult": "Otwieram wcześniejszą tabelę", + "reuse": "Korzystam z tabeli z poprzedniej odpowiedzi", + "headerActive": "{label}…", + "headerDoneOne": "Sprawdzono 1 źródło", + "headerDoneFew": "Sprawdzono {count} źródła", + "headerDoneOther": "Sprawdzono {count} źródeł", + "listLabel": "Co sprawdził Coach", + "toggleShow": "Pokaż, co sprawdził Coach", + "toggleHide": "Ukryj, co sprawdził Coach", + "announceDone": "Gotowe: {label}", + "readingsOne": "1 pomiar", + "readingsFew": "{count} pomiary", + "readingsOther": "{count} pomiarów", + "rowsOne": "1 wiersz", + "rowsFew": "{count} wiersze", + "rowsOther": "{count} wierszy", + "metricsOne": "1 wskaźnik", + "metricsFew": "{count} wskaźniki", + "metricsOther": "{count} wskaźników", + "reason": { + "noData": "brak pomiarów", + "outsideWindow": "brak pomiarów w tym okresie", + "moduleDisabled": "wyłączone", + "retrievalFailed": "nie udało się odczytać", + "invalidArguments": "nie zrozumiano zapytania" + }, + "domain": { + "labs": "Wyniki badań", + "illness": "Choroba i powrót do zdrowia", + "cycle": "Cykl", + "correlations": "Związki między wskaźnikami", + "snapshot": "Podsumowanie danych" + }, + "window": { + "last7days": "ostatnie 7 dni", + "last30days": "ostatnie 30 dni", + "last90days": "ostatnie 90 dni", + "lastYear": "ostatnie 12 miesięcy", + "allTime": "cały okres" + }, + "period": { + "current": "ten okres", + "previous": "poprzedni okres", + "yearAgo": "rok wcześniej" + }, + "granularity": { + "day": "według dni", + "week": "według tygodni", + "month": "według miesięcy" + } + }, + "result": { + "title": { + "byDay": "{metric} według dni", + "byWeek": "{metric} według tygodni", + "byMonth": "{metric} według miesięcy", + "previousPeriod": "{metric}, poprzedni okres", + "yearAgo": "{metric}, rok wcześniej", + "workoutsBySport": "Treningi według dyscypliny", + "labsLatest": "Najnowsze wyniki badań", + "sleepByNight": "Sen według nocy", + "complianceByDay": "Przyjmowanie leków według dni", + "distribution": "{metric}: jak często występował każdy zakres" + }, + "column": { + "day": "Dzień", + "week": "Tydzień", + "month": "Miesiąc", + "night": "Noc", + "sport": "Dyscyplina", + "sessions": "Sesje", + "analyte": "Badanie", + "value": "Wartość", + "unit": "Jednostka", + "date": "Data", + "referenceRange": "Zakres referencyjny", + "systolic": "Skurczowe", + "diastolic": "Rozkurczowe", + "mean": "Średnia", + "min": "Najniższa", + "max": "Najwyższa", + "readings": "Pomiary", + "rate": "Regularność", + "duration": "Czas trwania", + "range": "Zakres", + "count": "Liczba razy" + }, + "other": "Inne", + "noReading": "brak pomiaru", + "showAll": "Pokaż wszystko ({count})", + "showFewer": "Pokaż mniej", + "copyMenu": "Kopiuj tabelę", + "copyForSpreadsheet": "Kopiuj do arkusza kalkulacyjnego", + "copyAsText": "Kopiuj jako tekst", + "copied": "Skopiowano", + "copyFailed": "Nie udało się skopiować tabeli", + "viewLabel": "Widok", + "viewChart": "Wykres", + "viewTable": "Tabela", + "chartSummary": "{title} w formie wykresu. Widok tabeli pokazuje każdą wartość.", + "dataUsed": "Użyte dane ({count})", + "truncated": "Pierwsze {shown} z {total} wierszy", + "withheld": { + "moduleDisabled": "Ta tabela jest ukryta, bo jej moduł jest wyłączony.", + "unavailable": "Nie udało się wczytać tej tabeli." + }, + "loading": "Wczytywanie tabeli…", + "loadFailed": "Nie udało się wczytać tabel.", + "histogramBin": "{from}–{to} {unit}" + }, + "method": { + "label": "Jak to ustalono", + "entry": "{domain}, {window}: {detail}", + "entryNoWindow": "{domain}: {detail}", + "readingsOne": "1 pomiar", + "readingsFew": "{count} pomiary", + "readingsOther": "{count} pomiarów", + "aggregation": { + "mean": "średnia", + "median": "mediana", + "latest": "ostatni pomiar", + "sum": "suma", + "count": "liczba", + "rate": "odsetek" + }, + "averages": { + "day": "średnie dzienne", + "week": "średnie tygodniowe", + "month": "średnie miesięczne" + }, + "totals": { + "day": "sumy dzienne", + "week": "sumy tygodniowe", + "month": "sumy miesięczne" + }, + "absent": { + "noData": "brak pomiarów", + "outsideWindow": "brak pomiarów w tym okresie", + "moduleDisabled": "wyłączone" + } + }, + "followUp": { + "groupLabel": "Proponowane pytania", + "widenWindow": "Sięgnij dalej wstecz", + "previousPeriod": "Porównaj z poprzednim okresem", + "yearAgo": "Porównaj z tym samym okresem rok wcześniej", + "asChart": "Pokaż jako wykres", + "asTable": "Pokaż jako tabelę", + "relatedMetric": "Sprawdź też: {metric}", + "continue": "Szukaj dalej", + "settingLabel": "Proponuj kolejne pytania", + "settingHint": "Pokazuje do trzech propozycji pod ostatnią odpowiedzią Coacha." + }, + "clarify": { + "cardLabel": "Coach ma pytanie", + "choicesLabel": "Możliwe odpowiedzi", + "freeTextHint": "Możesz też wpisać własną odpowiedź.", + "contextHint": "Wpisz odpowiedź poniżej.", + "window": { + "last7days": "Ostatnie 7 dni", + "last30days": "Ostatnie 30 dni", + "last90days": "Ostatnie 90 dni", + "lastYear": "Ostatnie 12 miesięcy", + "allTime": "Cały okres" + } + }, + "reuse": { + "caption": "Oto te same wartości z mojej poprzedniej odpowiedzi, bez zmian." + } }, "safety": { "floor": { diff --git a/src/lib/ai/coach/__tests__/dialog-keys.test.ts b/src/lib/ai/coach/__tests__/dialog-keys.test.ts new file mode 100644 index 000000000..c445d0bd8 --- /dev/null +++ b/src/lib/ai/coach/__tests__/dialog-keys.test.ts @@ -0,0 +1,157 @@ +/** + * The dialog's key catalog and the seven bundles agree, both ways. + * + * Forward: every key the catalog can produce — every label map entry, every + * domain, window, period and granularity, every plural tier — resolves in + * every locale. Reverse: every leaf under the namespaces the catalog owns + * (`coach.step`, `coach.result`, `coach.method`, `coach.followUp`, + * `coach.clarify`, `coach.reuse`) is one the catalog can produce, so a + * string cannot sit in the bundles with nothing able to reach it. + * + * Checked by breaking it: deleting `coach.result.copied` from `ko.json` + * fails the forward test by locale and key; adding an unused leaf under + * `coach.step` fails the reverse test by name. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { locales, type Locale } from "@/lib/i18n/config"; +import { + coachScopeWindowSchema, + type CoachResultGranularity, + type CoachResultPeriod, +} from "@/lib/ai/coach/types"; +import { coachStepDomainSchema } from "@/lib/ai/coach/stream-events"; +import { + COACH_CLARIFY_UI_KEYS, + COACH_FOLLOW_UP_KEYS, + COACH_FOLLOW_UP_UI_KEYS, + COACH_METHOD_ABSENT_KEYS, + COACH_METHOD_AGGREGATION_KEYS, + COACH_METHOD_KEYS, + COACH_RESULT_COLUMN_KEYS, + COACH_RESULT_TITLE_KEYS, + COACH_RESULT_UI_KEYS, + COACH_RESULT_WITHHELD_KEYS, + COACH_REUSE_CAPTION_KEY, + COACH_STEP_LABEL_KEYS, + COACH_STEP_REASON_KEYS, + COACH_STEP_UI_KEYS, + clarifyWindowLabelKey, + coachDomainLabelKey, + coachGranularityLabelKey, + coachPeriodLabelKey, + coachWindowLabelKey, + methodAveragesKey, + methodReadingsKey, + methodTotalsKey, + stepHeaderDoneKey, + stepMetricsKey, + stepReadingsKey, + stepRowsKey, +} from "@/lib/ai/coach/dialog-keys"; + +const ROOT = join(__dirname, "../../../../.."); + +function bundle(locale: Locale): Record { + return JSON.parse( + readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"), + ) as Record; +} + +function leaves(node: unknown, prefix: string, out: Map) { + if (node && typeof node === "object") { + for (const [key, value] of Object.entries(node)) { + leaves(value, prefix ? `${prefix}.${key}` : key, out); + } + } else { + out.set(prefix, node); + } + return out; +} + +const PERIODS: CoachResultPeriod[] = ["current", "previous", "yearAgo"]; +const GRANULARITIES: CoachResultGranularity[] = ["day", "week", "month"]; + +/** Counts that land on every plural tier some locale uses. */ +const COUNTS = [0, 1, 2, 3, 5, 12, 22]; + +function catalogKeys(): Set { + const keys = new Set([ + ...Object.values(COACH_STEP_LABEL_KEYS), + ...Object.values(COACH_STEP_UI_KEYS), + ...Object.values(COACH_STEP_REASON_KEYS), + ...Object.values(COACH_RESULT_TITLE_KEYS), + ...Object.values(COACH_RESULT_COLUMN_KEYS), + ...Object.values(COACH_RESULT_UI_KEYS), + ...Object.values(COACH_RESULT_WITHHELD_KEYS), + ...Object.values(COACH_METHOD_KEYS), + ...Object.values(COACH_METHOD_AGGREGATION_KEYS), + ...Object.values(COACH_METHOD_ABSENT_KEYS), + ...Object.values(COACH_FOLLOW_UP_KEYS), + ...Object.values(COACH_FOLLOW_UP_UI_KEYS), + ...Object.values(COACH_CLARIFY_UI_KEYS), + COACH_REUSE_CAPTION_KEY, + ]); + for (const domain of coachStepDomainSchema.options) { + keys.add(coachDomainLabelKey(domain)); + } + for (const window of coachScopeWindowSchema.options) { + keys.add(coachWindowLabelKey(window)); + keys.add(clarifyWindowLabelKey(window)); + } + for (const period of PERIODS) { + keys.add(coachPeriodLabelKey(period)); + } + for (const granularity of GRANULARITIES) { + keys.add(coachGranularityLabelKey(granularity)); + keys.add(methodAveragesKey(granularity)); + keys.add(methodTotalsKey(granularity)); + } + for (const locale of locales) { + for (const count of COUNTS) { + keys.add(stepHeaderDoneKey(count, locale)); + keys.add(stepReadingsKey(count, locale)); + keys.add(stepRowsKey(count, locale)); + keys.add(stepMetricsKey(count, locale)); + keys.add(methodReadingsKey(count, locale)); + } + } + return keys; +} + +const OWNED = [ + "coach.step", + "coach.result", + "coach.method", + "coach.followUp", + "coach.clarify", + "coach.reuse", +]; + +describe("Coach dialog keys", () => { + const keys = catalogKeys(); + + it("produces a non-trivial catalog", () => { + expect(keys.size).toBeGreaterThan(130); + }); + + it.each(locales)("every catalog key resolves in %s", (locale) => { + const strings = leaves(bundle(locale), "", new Map()); + const missing = [...keys].filter( + (key) => typeof strings.get(key) !== "string" || strings.get(key) === "", + ); + expect(missing).toEqual([]); + }); + + it("every leaf under the owned namespaces is reachable from the catalog", () => { + const strings = leaves(bundle("en"), "", new Map()); + const owned = [...strings.keys()].filter((key) => + OWNED.some((ns) => key.startsWith(`${ns}.`)), + ); + expect(owned.length).toBeGreaterThan(0); + expect(owned.filter((key) => !keys.has(key))).toEqual([]); + }); +}); diff --git a/src/lib/ai/coach/dialog-keys.ts b/src/lib/ai/coach/dialog-keys.ts new file mode 100644 index 000000000..a04dec646 --- /dev/null +++ b/src/lib/ai/coach/dialog-keys.ts @@ -0,0 +1,255 @@ +/** + * The message keys of the Coach dialog: live steps, result tables, the + * method line, follow-up chips, clarifying questions, the reuse caption. + * + * One closed catalog, shared by the server (which renders every `label` in + * the request locale) and the web client (which renders `labelKey` when it + * has the string, and the server's `label` when it does not). A label never + * carries model text: it is one of these keys plus server-chosen values + * (a domain, a window, a count). + * + * Every key here exists in all seven bundles; `__tests__/dialog-keys.test.ts` + * holds that, in both directions for the namespaces this file owns. + * + * Client-safe: no server import. + */ +import type { Locale } from "@/lib/i18n/config"; +import { pluralKey } from "@/lib/i18n/plural"; +import { + coachScopeSourceSchema, + type CoachFollowUpKind, + type CoachMethodEntry, + type CoachResultGranularity, + type CoachResultPeriod, + type CoachScopeWindow, + type CoachStepDomain, + type CoachStepReason, +} from "@/lib/ai/coach/types"; + +// ── Steps ───────────────────────────────────────────────────────────────── + +/** The step label templates (`{domain}`, `{window}`). */ +export const COACH_STEP_LABEL_KEYS = { + read: "coach.step.read", + readWindow: "coach.step.readWindow", + snapshot: "coach.step.snapshot", + showResult: "coach.step.showResult", + reuse: "coach.step.reuse", +} as const; + +/** The step list's own chrome. */ +export const COACH_STEP_UI_KEYS = { + headerActive: "coach.step.headerActive", + listLabel: "coach.step.listLabel", + toggleShow: "coach.step.toggleShow", + toggleHide: "coach.step.toggleHide", + announceDone: "coach.step.announceDone", +} as const; + +/** "Looked at N sources", by plural tier. */ +export function stepHeaderDoneKey(count: number, locale: Locale): string { + return pluralKey("coach.step.headerDone", count, locale); +} + +/** "N readings" on a step row, by plural tier. */ +export function stepReadingsKey(count: number, locale: Locale): string { + return pluralKey("coach.step.readings", count, locale); +} + +/** "N rows" on a step row, by plural tier. */ +export function stepRowsKey(count: number, locale: Locale): string { + return pluralKey("coach.step.rows", count, locale); +} + +/** "N metrics" on the snapshot step, by plural tier. */ +export function stepMetricsKey(count: number, locale: Locale): string { + return pluralKey("coach.step.metrics", count, locale); +} + +export const COACH_STEP_REASON_KEYS: Readonly> = + { + no_data: "coach.step.reason.noData", + outside_window: "coach.step.reason.outsideWindow", + module_disabled: "coach.step.reason.moduleDisabled", + retrieval_failed: "coach.step.reason.retrievalFailed", + invalid_arguments: "coach.step.reason.invalidArguments", + }; + +const SCOPE_SOURCES: ReadonlySet = new Set( + coachScopeSourceSchema.options, +); + +/** + * A domain's display name. A measurement-backed scope source reuses the + * metric names the sources rail already shows; the domains read as a whole + * have their own. + */ +export function coachDomainLabelKey(domain: CoachStepDomain): string { + return SCOPE_SOURCES.has(domain) + ? `insights.coach.metric.${domain}` + : `coach.step.domain.${domain}`; +} + +/** A window in running text ("last 30 days"). */ +export function coachWindowLabelKey(window: CoachScopeWindow): string { + return `coach.step.window.${window}`; +} + +export function coachPeriodLabelKey(period: CoachResultPeriod): string { + return `coach.step.period.${period}`; +} + +export function coachGranularityLabelKey( + granularity: CoachResultGranularity, +): string { + return `coach.step.granularity.${granularity}`; +} + +// ── Results ─────────────────────────────────────────────────────────────── + +/** Table titles; `{metric}` where the title names one. */ +export const COACH_RESULT_TITLE_KEYS = { + byDay: "coach.result.title.byDay", + byWeek: "coach.result.title.byWeek", + byMonth: "coach.result.title.byMonth", + previousPeriod: "coach.result.title.previousPeriod", + yearAgo: "coach.result.title.yearAgo", + workoutsBySport: "coach.result.title.workoutsBySport", + labsLatest: "coach.result.title.labsLatest", + sleepByNight: "coach.result.title.sleepByNight", + complianceByDay: "coach.result.title.complianceByDay", + distribution: "coach.result.title.distribution", +} as const; + +/** Column headings. */ +export const COACH_RESULT_COLUMN_KEYS = { + day: "coach.result.column.day", + week: "coach.result.column.week", + month: "coach.result.column.month", + night: "coach.result.column.night", + sport: "coach.result.column.sport", + sessions: "coach.result.column.sessions", + analyte: "coach.result.column.analyte", + value: "coach.result.column.value", + unit: "coach.result.column.unit", + date: "coach.result.column.date", + referenceRange: "coach.result.column.referenceRange", + systolic: "coach.result.column.systolic", + diastolic: "coach.result.column.diastolic", + mean: "coach.result.column.mean", + min: "coach.result.column.min", + max: "coach.result.column.max", + readings: "coach.result.column.readings", + rate: "coach.result.column.rate", + duration: "coach.result.column.duration", + range: "coach.result.column.range", + count: "coach.result.column.count", +} as const; + +/** The table's own chrome: paging, copy, the chart/table toggle, notices. */ +export const COACH_RESULT_UI_KEYS = { + other: "coach.result.other", + noReading: "coach.result.noReading", + showAll: "coach.result.showAll", + showFewer: "coach.result.showFewer", + copyMenu: "coach.result.copyMenu", + copyForSpreadsheet: "coach.result.copyForSpreadsheet", + copyAsText: "coach.result.copyAsText", + copied: "coach.result.copied", + copyFailed: "coach.result.copyFailed", + viewLabel: "coach.result.viewLabel", + viewChart: "coach.result.viewChart", + viewTable: "coach.result.viewTable", + chartSummary: "coach.result.chartSummary", + dataUsed: "coach.result.dataUsed", + truncated: "coach.result.truncated", + loading: "coach.result.loading", + loadFailed: "coach.result.loadFailed", + histogramBin: "coach.result.histogramBin", +} as const; + +/** Why a stored table is not shown. */ +export const COACH_RESULT_WITHHELD_KEYS = { + module_disabled: "coach.result.withheld.moduleDisabled", + unavailable: "coach.result.withheld.unavailable", +} as const; + +// ── Method ──────────────────────────────────────────────────────────────── + +export const COACH_METHOD_KEYS = { + label: "coach.method.label", + entry: "coach.method.entry", + entryNoWindow: "coach.method.entryNoWindow", +} as const; + +/** "N readings" in the method line, by plural tier. */ +export function methodReadingsKey(count: number, locale: Locale): string { + return pluralKey("coach.method.readings", count, locale); +} + +export const COACH_METHOD_AGGREGATION_KEYS: Readonly< + Record, string> +> = { + mean: "coach.method.aggregation.mean", + median: "coach.method.aggregation.median", + latest: "coach.method.aggregation.latest", + sum: "coach.method.aggregation.sum", + count: "coach.method.aggregation.count", + rate: "coach.method.aggregation.rate", +}; + +/** "weekly averages" and "weekly totals", by granularity. */ +export function methodAveragesKey(granularity: CoachResultGranularity): string { + return `coach.method.averages.${granularity}`; +} + +export function methodTotalsKey(granularity: CoachResultGranularity): string { + return `coach.method.totals.${granularity}`; +} + +export const COACH_METHOD_ABSENT_KEYS: Readonly< + Record, string> +> = { + no_data: "coach.method.absent.noData", + outside_window: "coach.method.absent.outsideWindow", + module_disabled: "coach.method.absent.moduleDisabled", +}; + +// ── Follow-ups ──────────────────────────────────────────────────────────── + +/** One label per chip kind; `{metric}` on a related-metric chip. */ +export const COACH_FOLLOW_UP_KEYS: Readonly> = + { + widen_window: "coach.followUp.widenWindow", + previous_period: "coach.followUp.previousPeriod", + year_ago: "coach.followUp.yearAgo", + as_chart: "coach.followUp.asChart", + as_table: "coach.followUp.asTable", + related_metric: "coach.followUp.relatedMetric", + continue: "coach.followUp.continue", + }; + +export const COACH_FOLLOW_UP_UI_KEYS = { + groupLabel: "coach.followUp.groupLabel", + settingLabel: "coach.followUp.settingLabel", + settingHint: "coach.followUp.settingHint", +} as const; + +// ── Clarification ───────────────────────────────────────────────────────── + +export const COACH_CLARIFY_UI_KEYS = { + cardLabel: "coach.clarify.cardLabel", + choicesLabel: "coach.clarify.choicesLabel", + freeTextHint: "coach.clarify.freeTextHint", + contextHint: "coach.clarify.contextHint", +} as const; + +/** A window choice on a clarification card ("Last 30 days"). */ +export function clarifyWindowLabelKey(window: CoachScopeWindow): string { + return `coach.clarify.window.${window}`; +} + +// ── Reuse ───────────────────────────────────────────────────────────────── + +/** The prose of a turn answered from a stored table without a model call. */ +export const COACH_REUSE_CAPTION_KEY = "coach.reuse.caption";