Merge branch 'coach/wpF-contracts' into release/v1.39.4

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 11:10:37 +02:00
commit 78fc22d0f8
75 files changed
+6618 -233

No files matched your search

+1286 -95
View File
File diff suppressed because it is too large. Load diff
+174 -1
View File
@@ -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": {
+174 -1
View File
@@ -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": {
+174 -1
View File
@@ -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": {
+174 -1
View File
@@ -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": {
+174 -1
View File
@@ -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": {
+174 -1
View File
@@ -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": {
+174 -1
View File
@@ -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": {
@@ -0,0 +1,14 @@
-- The tables of values a Coach turn read, stored with the assistant message
-- that showed them: a JSON array of result tables, AES-256-GCM encrypted in
-- the same Bytes codec as the message body. The values are health data, so
-- they never sit in the plaintext `metric_source_json`, which carries only the
-- tables' metadata (ref, source, shape, row count).
--
-- A stored table keeps the values the answer was given with; a later edit or
-- sync does not rewrite what an earlier answer showed. The column goes with
-- its row, so deleting a conversation or wiping the account removes it by
-- the existing cascade.
--
-- Additive only: a nullable column, no table rewrite, no data change.
ALTER TABLE "coach_messages" ADD COLUMN "results_encrypted" BYTEA;
+6
View File
@@ -6237,6 +6237,12 @@ model CoachMessage {
// nullable: user turns, refusals, and pre-feature rows carry NULL.
tokensUsed Int? @map("tokens_used")
model String?
// v1.39.4 — the tables of values this turn read (JSON `CoachResultTable[]`,
// at most 128 KB), AES-256-GCM encrypted like `encryptedContent`. Health
// values, so they never ride the plaintext `metricSourceJson`, which holds
// only the tables' metadata. Read through `readMessageResults` by the
// owner-narrowed results endpoint; NULL on every turn without a table.
resultsEncrypted Bytes? @map("results_encrypted")
createdAt DateTime @default(now()) @map("created_at")
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
+11 -1
View File
@@ -418,7 +418,8 @@ async function main() {
}
// ───── Coach (Bytes columns) ─────
// "encryptedContent" "summaryEncrypted" "titleEncrypted" "factEncrypted"
// "encryptedContent" "resultsEncrypted" "summaryEncrypted" "titleEncrypted"
// "factEncrypted"
results.push(
await rotateBytesColumn(
"CoachMessage",
@@ -426,6 +427,15 @@ async function main() {
prisma.coachMessage,
),
);
// v1.39.4 — the tables of values a turn read. NULL on every turn without a
// table, which `rotateBytesColumn` skips.
results.push(
await rotateBytesColumn(
"CoachMessage",
"resultsEncrypted",
prisma.coachMessage,
),
);
results.push(
await rotateBytesColumn(
"CoachConversation",
@@ -0,0 +1,106 @@
/**
* No Coach stream frame added after the native client shipped may carry a
* top-level key that client already decodes.
*
* The native client decodes every frame into one flat struct —
* `{ type, token, conversationId, messageId, code, message, suggestion,
* metricSource, usage }` — and dispatches on `type`, ignoring a type it does
* not know. That makes an unknown frame harmless only while its keys are
* unknown too: a new frame that reused `message` or `usage` with a different
* shape would make the whole frame fail to decode, and a new frame that
* reused one with the same shape would leak a value into whatever the client
* does with that key. So a new frame names its payload with a key of its own
* (`step`, `result`, `followUps`, `clarification`).
*
* Reads the frame list from the Zod mirror (`stream-events.ts`), which a
* type test holds equal to `CoachStreamEvent`, so a frame added to the
* TypeScript union is seen here too.
*
* Checked by breaking it: adding `message: z.string()` to the `step` frame in
* `stream-events.ts` turns the second test red and names the frame and key.
*/
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import { coachStreamEventSchema } from "@/lib/ai/coach/stream-events";
/** The keys the shipped native client decodes, `type` aside. */
const NATIVE_CLIENT_KEYS = [
"token",
"conversationId",
"messageId",
"code",
"message",
"suggestion",
"metricSource",
"usage",
] as const;
/** The frames the shipped client was built against; they own those keys. */
const FRAMES_THE_CLIENT_KNOWS = new Set([
"token",
"provenance",
"suggestion",
"suggestedAction",
"reasoning",
"done",
"error",
]);
type FrameSchema = z.ZodObject<{ type: z.ZodLiteral<string> } & z.ZodRawShape>;
function frames(): Array<{ type: string; keys: string[] }> {
return (coachStreamEventSchema.options as readonly FrameSchema[]).map(
(option) => ({
type: String(option.shape.type.value),
keys: Object.keys(option.shape),
}),
);
}
/** Every `frame.key` pair where a newer frame reuses a native-client key. */
function collisions(list: Array<{ type: string; keys: string[] }>): string[] {
const reserved: ReadonlySet<string> = new Set(NATIVE_CLIENT_KEYS);
return list
.filter((frame) => !FRAMES_THE_CLIENT_KNOWS.has(frame.type))
.flatMap((frame) =>
frame.keys
.filter((key) => reserved.has(key))
.map((key) => `${frame.type}.${key}`),
);
}
describe("Coach stream frames stay decodable by the shipped native client", () => {
it("reads every frame, including the ones added after the client shipped", () => {
const types = frames().map((frame) => frame.type);
// An empty or partial list would agree with anything.
expect(types).toEqual(
expect.arrayContaining([
...FRAMES_THE_CLIENT_KNOWS,
"step",
"result",
"followUps",
"clarification",
]),
);
expect(
types.filter((type) => !FRAMES_THE_CLIENT_KNOWS.has(type)).length,
).toBeGreaterThan(0);
});
it("no newer frame carries a key the native client decodes", () => {
expect(
collisions(frames()),
"rename the payload key: the native client decodes these keys on every frame",
).toEqual([]);
});
it("the check flags a colliding frame", () => {
expect(
collisions([
{ type: "step", keys: ["type", "step", "message"] },
{ type: "done", keys: ["type", "conversationId", "messageId"] },
]),
).toEqual(["step.message"]);
});
});
@@ -217,6 +217,8 @@ export const DATA_ROUTES: Readonly<Record<string, string>> = {
"One stored conversation: read, rename, delete.",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts":
"A rating on a message that already exists.",
"src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts":
"The stored tables of one message: the person's record, withheld per module, never per AI capability.",
"src/app/api/insights/breathing-screening/route.ts":
"Device breathing data, owned by the sleep module.",
"src/app/api/insights/labs-changes/route.ts":
@@ -337,6 +337,11 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// entry here would fail the "does not hide a gate" check below.
"src/app/api/insights/chat/[id]/route.ts",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts",
// One message's stored tables. No whole-route gate for the same reason,
// but each table is withheld when the module owning its domain is off
// (`isCoachDomainWithheld` over the resolved module map), so a switched-off
// domain's values never come back through a stored answer.
"src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts",
// The Today digest and its dismiss: data, with the AI parts masked by the
// loader through the capability resolver.
"src/app/api/daily/digest/route.ts",
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
const { requireAuth, readMessageResults, resolveModuleMap } = vi.hoisted(
() => ({
requireAuth: vi.fn(),
readMessageResults: vi.fn(),
resolveModuleMap: vi.fn(),
}),
);
vi.mock("@/lib/api-handler", async (importOriginal) => {
const actual = await importOriginal<typeof ApiHandlerModule>();
return {
...actual,
apiHandler: <T extends (...args: never[]) => unknown>(handler: T) =>
handler,
requireAuth,
};
});
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap }));
vi.mock("@/lib/ai/coach/persistence", () => ({ readMessageResults }));
import { GET } from "../route";
const USER_ID = "user-1";
function call(id = "c1", messageId = "m1") {
return GET(
new NextRequest(
`http://localhost/api/insights/chat/${id}/messages/${messageId}/results`,
),
{ params: Promise.resolve({ id, messageId }) },
) as Promise<Response>;
}
beforeEach(() => {
vi.clearAllMocks();
requireAuth.mockResolvedValue({ user: { id: USER_ID } });
resolveModuleMap.mockResolvedValue({ sleep: false, mood: true });
});
describe("GET /api/insights/chat/[id]/messages/[messageId]/results", () => {
it("reads under the caller's own id and the path ids only", async () => {
readMessageResults.mockResolvedValue([]);
const response = await call("c1", "m1");
expect(response.status).toBe(200);
expect(readMessageResults).toHaveBeenCalledWith(
USER_ID,
"c1",
"m1",
expect.any(Function),
);
expect(await response.json()).toEqual({
data: { results: [] },
error: null,
});
});
it("answers 404 for a message the caller does not own", async () => {
readMessageResults.mockResolvedValue(null);
const response = await call();
expect(response.status).toBe(404);
});
it("withholds a domain whose module is switched off, and only that one", async () => {
readMessageResults.mockResolvedValue([]);
await call();
const withhold = readMessageResults.mock.calls[0][3] as (
domain: string,
) => boolean;
expect(withhold("sleep")).toBe(true);
expect(withhold("mood")).toBe(false);
// No module owns blood pressure or labs: never withheld.
expect(withhold("bp")).toBe(false);
expect(withhold("labs")).toBe(false);
});
it("refuses an unauthenticated caller before any read", async () => {
requireAuth.mockRejectedValue(new Error("Not authenticated"));
await expect(call()).rejects.toThrow("Not authenticated");
expect(readMessageResults).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,53 @@
/**
* GET /api/insights/chat/[id]/messages/[messageId]/results — the tables of
* values one assistant message read, decrypted for their owner.
*
* A data read, never AI-gated: the tables are the person's stored record,
* readable while the Coach is unavailable for any reason, like the
* conversation they belong to. Narrowed exactly like
* `GET /api/insights/chat/[id]`: the message must sit in a conversation the
* caller owns, and a foreign or unknown id is 404, never 403.
*
* Each table is served whole or withheld with a reason: `module_disabled`
* when its domain's module is switched off for the record now (the table is
* not even decrypted), `unavailable` when the stored tables cannot be read.
* The list follows the order of `metricSource.results` on the message.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { resolveModuleMap } from "@/lib/modules/gate";
import { readMessageResults } from "@/lib/ai/coach/persistence";
import { isCoachDomainWithheld } from "@/lib/ai/coach/results/domain-module";
interface RouteCtx {
params: Promise<{ id: string; messageId: string }>;
}
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
const auth = await requireAuth();
const userId = auth.user.id;
const { id, messageId } = await ctx.params;
if (!id || !messageId) return apiError("coach.message.notFound", 404);
const modules = await resolveModuleMap(userId);
const results = await readMessageResults(userId, id, messageId, (domain) =>
isCoachDomainWithheld(domain, modules),
);
if (!results) return apiError("coach.message.notFound", 404);
annotate({
action: { name: "insights.coach.results.read" },
meta: {
conversationId: id,
tables: results.length,
withheld: results.filter((entry) => "withheld" in entry).length,
},
});
return apiSuccess({ results });
});
export const dynamic = "force-dynamic";
+4
View File
@@ -109,6 +109,8 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
scope,
guidedQuestion,
workoutId,
followUp,
clarification,
} = parsed.data;
// Per-user request-rate ceiling layered in front of the daily budget
@@ -171,6 +173,8 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
scope,
guidedQuestion,
workoutId,
followUp,
clarification,
recheckCapability: coachCapabilityRefusal,
});
}
@@ -77,6 +77,10 @@ function streaming(
metricSource: null,
suggestion: null,
suggestedAction: null,
steps: [],
results: [],
followUps: [],
clarification: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -0,0 +1,180 @@
/**
* v1.39.4 — the client end of the dialog frames: step upserts, the request
* fields a chip or an answered question adds, and which message the chips
* and the clarification belong to.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (k: string) => k }),
}));
import type {
CoachMessageDTO,
CoachStep,
CoachStreamEvent,
} from "@/lib/ai/coach/types";
import {
parseSseChunk,
resolveCoachSendTarget,
upsertStep,
type CoachStreamingMessage,
} from "../use-coach";
import { latestClarification, latestFollowUps } from "../message-thread";
const STEP: CoachStep = {
id: "s1",
tool: "get_sleep",
labelKey: "coach.step.read",
label: "Reading Sleep",
domain: "sleep",
status: "running",
};
const CHIP = {
id: "f1",
kind: "as_table" as const,
labelKey: "coach.followUp.asTable",
label: "Show as a table",
reuse: true,
origin: "server" as const,
};
const CLARIFY = { kind: "window" as const, choices: [], freeText: true };
function streaming(
partial: Partial<CoachStreamingMessage>,
): CoachStreamingMessage {
return {
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
steps: [],
results: [],
followUps: [],
clarification: null,
inProgress: false,
messageId: null,
errorCode: null,
usage: null,
...partial,
};
}
function message(partial: Partial<CoachMessageDTO>): CoachMessageDTO {
return {
id: "m1",
role: "assistant",
content: "reply",
createdAt: "2026-09-26T00:00:00.000Z",
metricSource: null,
providerType: "anthropic",
promptVersion: null,
tokensUsed: null,
model: null,
...partial,
};
}
describe("dialog frames on the client", () => {
it("parses the new frames like any other", () => {
const frames: CoachStreamEvent[] = [
{ type: "step", step: STEP },
{ type: "followUps", followUps: [CHIP] },
{ type: "clarification", clarification: CLARIFY },
];
const { events } = parseSseChunk(
"",
frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join(""),
);
expect(events).toEqual(frames);
});
it("upserts a step by id, keeping first-seen order", () => {
const second = { ...STEP, id: "s2" };
let steps = upsertStep([], STEP);
steps = upsertStep(steps, second);
steps = upsertStep(steps, { ...STEP, status: "done", count: 12 });
expect(steps.map((s) => [s.id, s.status])).toEqual([
["s1", "done"],
["s2", "running"],
]);
});
});
describe("resolveCoachSendTarget — chips and answers", () => {
it("carries followUp and clarification on the tool route", () => {
const target = resolveCoachSendTarget({
conversationId: "c1",
message: "Show as a table",
followUp: { messageId: "m1", id: "f1" },
clarification: { messageId: "m0", choiceId: "c2" },
});
expect(target.url).toBe("/api/insights/chat");
expect(JSON.parse(target.body)).toMatchObject({
followUp: { messageId: "m1", id: "f1" },
clarification: { messageId: "m0", choiceId: "c2" },
});
});
it("never carries them to the fenced route", () => {
const target = resolveCoachSendTarget({
conversationId: "c1",
message: "x",
fenced: true,
followUp: { messageId: "m1", id: "f1" },
clarification: { messageId: "m0" },
});
expect(target.url).toBe("/api/insights/chat/fenced");
const body = JSON.parse(target.body);
expect(body).not.toHaveProperty("followUp");
expect(body).not.toHaveProperty("clarification");
});
});
describe("which message the chips and the question belong to", () => {
const withChips = message({
metricSource: {
windows: [],
metrics: [],
followUps: [CHIP],
clarification: CLARIFY,
},
});
it("offers the latest persisted assistant message's chips", () => {
expect(latestFollowUps([withChips], undefined)).toEqual({
messageId: "m1",
followUps: [CHIP],
});
expect(latestClarification([withChips], undefined)).toEqual({
messageId: "m1",
clarification: CLARIFY,
});
});
it("offers nothing once the person replied, and nothing mid-turn", () => {
const answered = [withChips, message({ id: "m2", role: "user" })];
expect(latestFollowUps(answered, undefined)).toBeNull();
expect(latestClarification(answered, undefined)).toBeNull();
expect(
latestFollowUps([withChips], streaming({ inProgress: true })),
).toBeNull();
});
it("prefers the just-settled streamed turn over an older message", () => {
const live = streaming({
messageId: "m9",
followUps: [{ ...CHIP, id: "f2" }],
clarification: CLARIFY,
});
expect(latestFollowUps([withChips], live)?.messageId).toBe("m9");
expect(latestClarification([withChips], live)?.messageId).toBe("m9");
// A settled streamed turn without chips hides an older message's.
expect(
latestFollowUps([withChips], streaming({ messageId: "m9" })),
).toBeNull();
});
});
@@ -41,6 +41,9 @@ import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { SuggestedActionCard } from "./suggested-action-card";
import { StreamedProse } from "./streamed-prose";
import { MessageTokenFooter } from "./message-token-footer";
import { CoachTurnSteps } from "./turn-steps";
import { CoachResults } from "./coach-results";
import { CoachMethodLine } from "./method-line";
import {
COACH_ICON_BUTTON,
ReadAloudButton,
@@ -387,6 +390,18 @@ interface ChatBubbleProps {
*/
suggestedAction?:
import("@/lib/ai/coach/suggest-action").CoachSuggestedAction | null;
/**
* v1.39.4 — live steps from the streaming hook. Persisted messages carry
* them on `metricSource.steps`; the bubble falls back to that.
*/
steps?: import("@/lib/ai/coach/types").CoachStep[];
/**
* v1.39.4 — live tables from the streaming hook. A persisted message
* lists their metadata on `metricSource.results` and the values are
* fetched lazily, which needs the conversation id.
*/
results?: import("@/lib/ai/coach/types").CoachResultTable[];
conversationId?: string | null;
providerType?: string | null;
inProgress?: boolean;
errorCode?: string | null;
@@ -464,6 +479,9 @@ export function areChatBubblePropsEqual(
prev.metricSource === next.metricSource &&
prev.suggestion === next.suggestion &&
prev.suggestedAction === next.suggestedAction &&
prev.steps === next.steps &&
prev.results === next.results &&
prev.conversationId === next.conversationId &&
prev.usage === next.usage &&
// onRegenerate is a per-render closure — compare only whether it is present.
(prev.onRegenerate === undefined) === (next.onRegenerate === undefined)
@@ -476,6 +494,9 @@ function ChatBubbleImpl({
metricSource,
suggestion,
suggestedAction,
steps,
results,
conversationId,
providerType,
inProgress,
errorCode,
@@ -654,7 +675,8 @@ function ChatBubbleImpl({
!!metricSource &&
((metricSource.metrics?.length ?? 0) > 0 ||
(metricSource.windows?.length ?? 0) > 0);
const hasProvenance = hasChips || keyValues.length > 0;
const method = metricSource?.method ?? null;
const hasProvenance = hasChips || keyValues.length > 0 || method !== null;
// v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance-
// grounded `metric:<TYPE>` chart under a SETTLED assistant turn. Skipped
@@ -693,6 +715,12 @@ function ChatBubbleImpl({
)}
</div>
<div className="flex max-w-[calc(80%-2.625rem)] flex-col gap-2">
{/* v1.39.4 — what the Coach read on this turn: live from the step
frames, restored from the provenance on reload. */}
<CoachTurnSteps
steps={steps ?? metricSource?.steps ?? []}
active={!!inProgress}
/>
{/* v1.19.1 (C3) — the live turn shows the classic typing animation
(three pulsing dots) while it is still thinking with no prose
yet, restoring the writing/typing indicator the maintainer
@@ -768,6 +796,17 @@ function ChatBubbleImpl({
})}
</div>
)}
{/* v1.39.4 — the tables the turn read, on a settled turn only: live
from the result frames, or fetched lazily for a persisted
message whose provenance lists them. */}
{!inProgress && !errorCode && (
<CoachResults
conversationId={conversationId ?? null}
messageId={messageId ?? null}
metas={metricSource?.results ?? []}
live={results}
/>
)}
{/* v1.18.6 — a "no provider configured anywhere" turn is a
setup gap, not a transient failure: surface a direct link to
Settings → AI so the Coach guides the user into BYOK / local
@@ -874,6 +913,9 @@ function ChatBubbleImpl({
))}
</ul>
)}
{/* v1.39.4 — how the answer was worked out, beside the key
values it explains. */}
<CoachMethodLine method={method} />
</div>
</details>
)}
@@ -0,0 +1,27 @@
"use client";
/**
* v1.39.4 — the choices for a clarifying question, above the composer.
* The question itself is the assistant reply; a tap on a choice sends its
* label with `clarification: { messageId, choiceId }`, and a typed answer
* goes out with `clarification: { messageId }`.
*
* Not built yet: renders nothing.
*/
import type {
CoachClarification,
CoachClarificationChoice,
} from "@/lib/ai/coach/types";
export interface CoachClarificationCardProps {
clarification: CoachClarification;
/** The assistant message that asked. */
messageId: string;
/** True while a turn is in flight. */
disabled: boolean;
onChoose: (choice: CoachClarificationChoice) => void;
}
export function CoachClarificationCard(_props: CoachClarificationCardProps) {
return null;
}
@@ -18,7 +18,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { randomId } from "@/lib/random-id";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import type { CoachScope } from "@/lib/ai/coach/types";
import type {
CoachClarificationChoice,
CoachFollowUp,
CoachScope,
} from "@/lib/ai/coach/types";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
@@ -45,7 +49,12 @@ import {
guidedReducer,
} from "./guided-questions-machine";
import { HistoryRail } from "./history-rail";
import { MessageThread, type InterleavedThreadItem } from "./message-thread";
import {
MessageThread,
latestClarification,
type InterleavedThreadItem,
} from "./message-thread";
import { CoachClarificationCard } from "./clarification-card";
import { MobileRailTray } from "./mobile-rail-tray";
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
import { SourcesRail } from "./sources-rail";
@@ -606,6 +615,37 @@ export function CoachConversation({
registerConversationIdGetter(() => conversationIdRef.current);
}, [registerConversationIdGetter]);
// v1.39.4 — the open clarifying question, if the latest reply asked one.
// A typed message answers it; a chip on the card answers it with a choice.
const openClarification = fenced
? null
: latestClarification(conversation?.messages ?? [], send.streaming);
// v1.39.4 — a follow-up chip: its label is the message, and the server
// resolves what it asks for from the message that offered it.
function handleFollowUp(followUp: CoachFollowUp, messageId: string) {
if (send.isStreaming || fenced || currentConversationId === null) return;
void send.send({
conversationId: currentConversationId,
message: followUp.label,
followUp: { messageId, id: followUp.id },
});
}
// v1.39.4 — a choice on the clarification card.
function handleClarificationChoice(choice: CoachClarificationChoice) {
if (send.isStreaming || !openClarification) return;
if (currentConversationId === null) return;
void send.send({
conversationId: currentConversationId,
message: choice.label,
clarification: {
messageId: openClarification.messageId,
choiceId: choice.id,
},
});
}
async function handleSubmit(value: string) {
const trimmed = value.trim();
if (!trimmed || send.isStreaming) return;
@@ -654,6 +694,10 @@ export function CoachConversation({
guidedQuestion: fenced ? undefined : (guidedQuestion ?? undefined),
scope: fenced ? undefined : scope,
workoutId: fenced ? undefined : workoutId,
// v1.39.4 — a typed message answers an open clarifying question.
clarification: openClarification
? { messageId: openClarification.messageId }
: undefined,
fenced,
pendingAttachmentIds:
currentConversationId === null ? pendingAttachmentIds : undefined,
@@ -817,6 +861,14 @@ export function CoachConversation({
{t("insights.coach.attach.indexingHint")}
</p>
) : null}
{openClarification ? (
<CoachClarificationCard
clarification={openClarification.clarification}
messageId={openClarification.messageId}
disabled={send.isStreaming}
onChoose={handleClarificationChoice}
/>
) : null}
<CoachInput
value={inputValue}
onChange={setInputValue}
@@ -1106,6 +1158,7 @@ export function CoachConversation({
optimisticUser={send.optimisticUser}
interleaved={interleaved}
onRegenerate={handleRegenerate}
onFollowUp={handleFollowUp}
/>
</div>
{/* Docked composer — the SAME centred, capped column as the
@@ -1220,6 +1273,7 @@ export function CoachConversation({
optimisticUser={send.optimisticUser}
interleaved={interleaved}
onRegenerate={handleRegenerate}
onFollowUp={handleFollowUp}
/>
}
composer={composerStack}
@@ -0,0 +1,53 @@
"use client";
/**
* v1.39.4 — the tables under an assistant reply. A live turn hands its
* tables in from the `result` frames; a persisted message lists only their
* metadata, and the values are fetched when the message scrolls into view
* (`useCoachMessageResults`). A withheld table is skipped here; the notice
* for it belongs to the table view.
*/
import { useCoachMessageResults } from "@/hooks/use-coach-message-results";
import type {
CoachResultMeta,
CoachResultTable as CoachResultTableData,
} from "@/lib/ai/coach/types";
import { CoachResultTable } from "./result-table";
export interface CoachResultsProps {
conversationId: string | null;
/** The persisted message, once it has an id. */
messageId: string | null;
/** `metricSource.results` of the message. */
metas: CoachResultMeta[];
/** The tables a live turn streamed; absent on a persisted message. */
live?: CoachResultTableData[];
}
export function CoachResults({
conversationId,
messageId,
metas,
live,
}: CoachResultsProps) {
const hasLive = (live?.length ?? 0) > 0;
const { ref, results } = useCoachMessageResults({
conversationId,
messageId,
enabled: !hasLive && metas.length > 0,
});
if (!hasLive && metas.length === 0) return null;
const tables: CoachResultTableData[] = hasLive
? (live ?? [])
: (results ?? []).filter(
(entry): entry is CoachResultTableData => !("withheld" in entry),
);
return (
<div ref={ref} data-slot="coach-results" className="flex flex-col gap-3">
{tables.map((table) => (
<CoachResultTable key={table.ref} result={table} />
))}
</div>
);
}
@@ -0,0 +1,23 @@
"use client";
/**
* v1.39.4 — up to three follow-up chips under the latest assistant reply.
* A tap sends the chip's label as the message with
* `followUp: { messageId, id }`; the server resolves what the chip asks for.
*
* Not built yet: renders nothing.
*/
import type { CoachFollowUp } from "@/lib/ai/coach/types";
export interface CoachFollowUpChipsProps {
followUps: CoachFollowUp[];
/** The assistant message that offered the chips. */
messageId: string;
/** True while a turn is in flight. */
disabled: boolean;
onSelect: (followUp: CoachFollowUp, messageId: string) => void;
}
export function CoachFollowUpChips(_props: CoachFollowUpChipsProps) {
return null;
}
@@ -9,12 +9,17 @@ import { useTranslations } from "@/lib/i18n/context";
import { PlanProposalCards } from "./plan-proposal-card";
import { ChatBubble } from "./chat-bubble";
import { CoachFollowUpChips } from "./follow-up-chips";
import type {
CoachConversationDetailDTO,
CoachOptimisticUserMessage,
CoachStreamingMessage,
} from "./use-coach";
import type { CoachMessageDTO } from "@/lib/ai/coach/types";
import type {
CoachClarification,
CoachFollowUp,
CoachMessageDTO,
} from "@/lib/ai/coach/types";
// v1.28.26 file-size split (pure code motion): the bubble renderer +
// per-message actions live in `chat-bubble.tsx`, the read-aloud stack in
@@ -72,6 +77,54 @@ export interface MessageThreadProps {
* resubmits it as a fresh turn. Omitted → the regenerate action is hidden.
*/
onRegenerate?: (userText: string) => void;
/**
* v1.39.4 — a follow-up chip was tapped. Omitted → no chips are shown.
*/
onFollowUp?: (followUp: CoachFollowUp, messageId: string) => void;
}
/**
* v1.39.4 — the open clarifying question: the latest assistant turn's
* choices, whether it is the just-settled streamed turn or the last
* persisted message. None while a turn is in flight, and none once the
* person has answered (their message is then the last one).
*/
export function latestClarification(
messages: CoachMessageDTO[],
streaming: CoachStreamingMessage | undefined,
): { messageId: string; clarification: CoachClarification } | null {
if (streaming?.inProgress) return null;
if (streaming?.messageId && streaming.clarification) {
return {
messageId: streaming.messageId,
clarification: streaming.clarification,
};
}
const last = messages[messages.length - 1];
if (streaming?.messageId && streaming.messageId !== last?.id) return null;
if (last?.role !== "assistant") return null;
const clarification = last.metricSource?.clarification;
return clarification ? { messageId: last.id, clarification } : null;
}
/**
* v1.39.4 — the chips to offer, and the message that offered them: the
* latest assistant turn only, whether it is the just-settled streamed turn
* or the last persisted message. None while a turn is in flight.
*/
export function latestFollowUps(
messages: CoachMessageDTO[],
streaming: CoachStreamingMessage | undefined,
): { messageId: string; followUps: CoachFollowUp[] } | null {
if (streaming?.inProgress) return null;
if (streaming?.messageId && streaming.followUps.length > 0) {
return { messageId: streaming.messageId, followUps: streaming.followUps };
}
const last = messages[messages.length - 1];
if (streaming?.messageId && streaming.messageId !== last?.id) return null;
if (last?.role !== "assistant") return null;
const followUps = last.metricSource?.followUps ?? [];
return followUps.length > 0 ? { messageId: last.id, followUps } : null;
}
/**
@@ -167,6 +220,7 @@ export function MessageThread({
emptyHint,
interleaved,
onRegenerate,
onFollowUp,
}: MessageThreadProps) {
const { t } = useTranslations();
const scrollerRef = useRef<HTMLDivElement | null>(null);
@@ -253,6 +307,8 @@ export function MessageThread({
optimisticActive && optimisticUser ? optimisticUser.content : null,
);
const chips = latestFollowUps(messages, streaming);
// Track scroll position so we don't yank the viewport when the user
// is browsing earlier turns.
useEffect(() => {
@@ -385,6 +441,7 @@ export function MessageThread({
role={m.role}
content={m.content}
metricSource={m.metricSource}
conversationId={conversation?.id ?? null}
providerType={m.providerType}
messageId={m.id}
tokensUsed={m.tokensUsed}
@@ -437,6 +494,8 @@ export function MessageThread({
metricSource={streaming.metricSource}
suggestion={streaming.suggestion}
suggestedAction={streaming.suggestedAction}
steps={streaming.steps}
results={streaming.results}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
@@ -446,6 +505,15 @@ export function MessageThread({
/>
</div>
)}
{/* v1.39.4 — follow-up chips under the latest assistant turn only. */}
{onFollowUp && chips ? (
<CoachFollowUpChips
followUps={chips.followUps}
messageId={chips.messageId}
disabled={!!streaming?.inProgress}
onSelect={onFollowUp}
/>
) : null}
{/* v1.16.5 — thread tail: the current guided question and/or the
closing summary follow the last completed turn. */}
{placement.tail.map((i) => (
@@ -0,0 +1,18 @@
"use client";
/**
* v1.39.4 — how an answer was worked out, as one muted line inside the
* evidence disclosure: sources, windows, counts and aggregation, rendered
* on the server. Never a health value.
*
* Not built yet: renders nothing.
*/
import type { CoachMethod } from "@/lib/ai/coach/types";
export interface CoachMethodLineProps {
method: CoachMethod | null;
}
export function CoachMethodLine(_props: CoachMethodLineProps) {
return null;
}
@@ -0,0 +1,18 @@
"use client";
/**
* v1.39.4 — one table of values a Coach turn read: a captioned table with
* the first rows shown, the rest behind "Show all", and copy for a
* spreadsheet or as text.
*
* Not built yet: renders nothing.
*/
import type { CoachResultTable as CoachResultTableData } from "@/lib/ai/coach/types";
export interface CoachResultTableProps {
result: CoachResultTableData;
}
export function CoachResultTable(_props: CoachResultTableProps) {
return null;
}
@@ -0,0 +1,20 @@
"use client";
/**
* v1.39.4 — what the Coach read on a turn: a compact list of steps under
* the assistant avatar, live while the turn runs (from the `step` frames)
* and restored from `metricSource.steps` on reload.
*
* Not built yet: renders nothing.
*/
import type { CoachStep } from "@/lib/ai/coach/types";
export interface CoachTurnStepsProps {
steps: CoachStep[];
/** True while the turn is still running. */
active: boolean;
}
export function CoachTurnSteps(_props: CoachTurnStepsProps) {
return null;
}
+101 -35
View File
@@ -11,11 +11,15 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { toast } from "sonner";
import type {
CoachClarification,
CoachConversationAttachmentDTO,
CoachConversationDetailDTO,
CoachConversationsPage,
CoachFollowUp,
CoachProvenance,
CoachResultTable,
CoachScope,
CoachStep,
CoachStreamEvent,
CoachSuggestion,
CoachUsage,
@@ -539,6 +543,21 @@ export interface CoachStreamingMessage {
* on `metricSource.suggestedAction` instead. Null otherwise.
*/
suggestedAction: CoachSuggestedAction | null;
/**
* v1.39.4 — the live steps from the additive `step` frames, upserted by
* id in first-seen order. Persisted messages carry them on
* `metricSource.steps`.
*/
steps: CoachStep[];
/**
* v1.39.4 — the tables from the additive `result` frames. Persisted
* messages carry only their metadata; the values are fetched lazily.
*/
results: CoachResultTable[];
/** v1.39.4 — the chips from the additive `followUps` frame. */
followUps: CoachFollowUp[];
/** v1.39.4 — the choices from the additive `clarification` frame. */
clarification: CoachClarification | null;
/** True until the `done` frame closes the stream. */
inProgress: boolean;
/** Final messageId once `done` lands; null otherwise. */
@@ -617,12 +636,25 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
metricSource: null,
suggestion: null,
suggestedAction: null,
steps: [],
results: [],
followUps: [],
clarification: null,
inProgress: false,
messageId: null,
errorCode: null,
usage: null,
};
/** v1.39.4 — insert a step, or replace the one with the same id in place. */
export function upsertStep(steps: CoachStep[], step: CoachStep): CoachStep[] {
const at = steps.findIndex((s) => s.id === step.id);
if (at === -1) return [...steps, step];
const next = [...steps];
next[at] = step;
return next;
}
export interface SendCoachMessageParams {
conversationId?: string;
message: string;
@@ -665,6 +697,18 @@ export interface SendCoachMessageParams {
* client re-sent it.
*/
workoutId?: string;
/**
* v1.39.4 — the follow-up chip this message came from: the assistant
* message that offered it and the chip's id. The server resolves the chip
* from what it persisted; the client never sends what the chip asks for.
* Tool route only.
*/
followUp?: { messageId: string; id: string };
/**
* v1.39.4 — the clarifying question this message answers, and the chip
* tapped (absent when the person typed their own answer). Tool route only.
*/
clarification?: { messageId: string; choiceId?: string };
}
/**
@@ -712,6 +756,8 @@ export function resolveCoachSendTarget(params: SendCoachMessageParams): {
scope: params.scope,
guidedQuestion: params.guidedQuestion,
workoutId: params.workoutId,
followUp: params.followUp,
clarification: params.clarification,
}),
};
}
@@ -857,14 +903,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
conversationId: params.conversationId ?? null,
});
setStreaming({
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
...EMPTY_STREAMING,
inProgress: true,
messageId: null,
errorCode: null,
usage: null,
});
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
@@ -872,14 +912,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
// waiting for the fetch to fail with a generic network error.
if (typeof navigator !== "undefined" && navigator.onLine === false) {
setStreaming({
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
inProgress: false,
messageId: null,
...EMPTY_STREAMING,
errorCode: "coach.network",
usage: null,
});
return null;
}
@@ -905,28 +939,16 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
} catch (err) {
if ((err as Error).name === "AbortError") return null;
setStreaming({
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
inProgress: false,
messageId: null,
...EMPTY_STREAMING,
errorCode: "coach.network",
usage: null,
});
return null;
}
if (!response.body) {
setStreaming({
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
inProgress: false,
messageId: null,
...EMPTY_STREAMING,
errorCode: `coach.http.${response.status}`,
usage: null,
});
return null;
}
@@ -963,14 +985,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
// body was not JSON; fall through to the http-status fallback
}
setStreaming({
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
inProgress: false,
messageId: null,
...EMPTY_STREAMING,
errorCode: structured ?? `coach.http.${response.status}`,
usage: null,
});
// v1.16.4 — KEEP the optimistic user bubble: a rejected turn
// (budget gate, 4xx) has no persisted twin coming, and dropping
@@ -988,6 +1004,10 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
let collectedProvenance: CoachProvenance | null = null;
let collectedSuggestion: CoachSuggestion | null = null;
let collectedSuggestedAction: CoachSuggestedAction | null = null;
let collectedSteps: CoachStep[] = [];
const collectedResults: CoachResultTable[] = [];
let collectedFollowUps: CoachFollowUp[] = [];
let collectedClarification: CoachClarification | null = null;
let lastError: string | null = null;
let messageId: string | null = null;
let collectedUsage: CoachUsage | null = null;
@@ -1029,6 +1049,39 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
suggestedAction: evt.suggestedAction,
}));
break;
case "step": {
// v1.39.4 — live progress, upserted by step id.
const step = evt.step;
collectedSteps = upsertStep(collectedSteps, step);
setStreaming((prev) => ({
...prev,
steps: upsertStep(prev.steps, step),
}));
break;
}
case "result": {
const result = evt.result;
collectedResults.push(result);
setStreaming((prev) => ({
...prev,
results: [...prev.results, result],
}));
break;
}
case "followUps":
collectedFollowUps = evt.followUps;
setStreaming((prev) => ({
...prev,
followUps: evt.followUps,
}));
break;
case "clarification":
collectedClarification = evt.clarification;
setStreaming((prev) => ({
...prev,
clarification: evt.clarification,
}));
break;
case "reasoning":
// v1.19.1 — the thinking disclosure was removed, so the
// additive `reasoning` frame is accepted off the wire and
@@ -1069,6 +1122,10 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
metricSource: collectedProvenance,
suggestion: collectedSuggestion,
suggestedAction: collectedSuggestedAction,
steps: collectedSteps,
results: collectedResults,
followUps: collectedFollowUps,
clarification: collectedClarification,
inProgress: false,
messageId,
errorCode: lastError,
@@ -1083,6 +1140,15 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
queryKey: QUERY_KEYS.one(resolvedConversationId),
});
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() });
// v1.39.4 — the tables this turn streamed are exactly what the
// persisted message stores, so seed its lazy read rather than
// decrypting them again on the server when the twin lands.
if (messageId && collectedResults.length > 0) {
queryClient.setQueryData(
queryKeys.coachMessageResults(resolvedConversationId, messageId),
collectedResults,
);
}
optsRef.current.onDone?.(resolvedConversationId);
}
// v1.4.25 W5 — drop the optimistic user bubble; the persisted
+74
View File
@@ -0,0 +1,74 @@
"use client";
/**
* v1.39.4 — the stored tables of one Coach message, fetched lazily.
*
* A thread can hold many messages with tables, and each table set is
* decrypted on the server per request, so nothing is fetched until the
* message scrolls near the viewport: mount the returned `ref` on the
* element that shows the tables. `enabled` gates the whole read (a message
* whose provenance lists no tables never asks). Once fetched, the tables are
* the stored snapshot and do not change, so they stay fresh indefinitely.
*/
import { useEffect, useState, type RefCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import type { CoachResultEntry } from "@/lib/ai/coach/types";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
export function useCoachMessageResults(args: {
conversationId: string | null;
messageId: string | null;
enabled: boolean;
}): {
ref: RefCallback<HTMLElement>;
results: CoachResultEntry[] | undefined;
isLoading: boolean;
isError: boolean;
} {
const { conversationId, messageId, enabled } = args;
const [node, setNode] = useState<HTMLElement | null>(null);
const [visible, setVisible] = useState(false);
// Without an observer (an old engine) there is nothing to wait for.
const canObserve = typeof IntersectionObserver !== "undefined";
useEffect(() => {
if (!enabled || visible || !node || !canObserve) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) setVisible(true);
},
{ rootMargin: "200px" },
);
observer.observe(node);
return () => observer.disconnect();
}, [enabled, visible, node, canObserve]);
const query = useQuery({
queryKey: queryKeys.coachMessageResults(
conversationId ?? "",
messageId ?? "",
),
queryFn: async (): Promise<CoachResultEntry[]> => {
const data = await apiGet<{ results: CoachResultEntry[] }>(
`/api/insights/chat/${encodeURIComponent(conversationId ?? "")}/messages/${encodeURIComponent(messageId ?? "")}/results`,
);
return data.results;
},
enabled:
enabled &&
(visible || !canObserve) &&
conversationId !== null &&
messageId !== null,
staleTime: Infinity,
});
return {
ref: setNode,
results: query.data,
isLoading: query.isLoading,
isError: query.isError,
};
}
@@ -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<string, unknown> {
return JSON.parse(
readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"),
) as Record<string, unknown>;
}
function leaves(node: unknown, prefix: string, out: Map<string, unknown>) {
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<string> {
const keys = new Set<string>([
...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([]);
});
});
@@ -0,0 +1,339 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* v1.39.4 — the dialog fields of the provenance survive a reload, malformed
* entries are dropped one by one, the tables are sealed into their own
* column, and the owner-narrowed read serves each table whole or names why
* it does not.
*/
const txCreate = {
coachConversation: { update: vi.fn() },
coachMessage: { create: vi.fn() },
};
const findFirst = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
$transaction: vi.fn(async (cb: (tx: typeof txCreate) => Promise<unknown>) =>
cb(txCreate),
),
coachMessage: { findFirst: (...args: unknown[]) => findFirst(...args) },
},
}));
vi.mock("../bytes-codec", () => ({
encryptToBytes: vi.fn((text: string) =>
new TextEncoder().encode(`enc:${text}`),
),
decryptFromBytes: vi.fn((bytes: Uint8Array) => {
const text = new TextDecoder().decode(bytes);
if (!text.startsWith("enc:")) throw new Error("bad ciphertext");
return text.slice(4);
}),
}));
import {
RESULTS_MAX_BYTES,
appendMessage,
readMessageResults,
} from "../persistence";
import type {
CoachProvenance,
CoachResultMeta,
CoachResultTable,
} from "../types";
const META: CoachResultMeta = {
ref: "r1",
source: {
tool: "get_metric_series",
domain: "bp",
window: "last30days",
period: "current",
granularity: "day",
},
shape: "timeSeries",
titleKey: "coach.result.title.metricByPeriod",
title: "Blood pressure by day",
rowCount: 2,
chartKind: "line",
displayed: true,
};
const TABLE: CoachResultTable = {
...META,
columns: [
{
key: "period",
kind: "period",
labelKey: "coach.result.column.day",
label: "Day",
},
{
key: "sys",
kind: "number",
labelKey: "coach.result.column.systolic",
label: "Systolic",
unit: "mmHg",
decimals: 0,
},
],
rows: [
["2026-09-01", 128],
["2026-09-02", null],
],
truncated: false,
chart: { kind: "line", x: "period", series: ["sys"] },
};
const SLEEP_META: CoachResultMeta = {
...META,
ref: "r2",
source: { ...META.source, domain: "sleep", tool: "get_sleep" },
title: "Sleep by night",
};
const FULL: CoachProvenance = {
windows: ["last30days"],
metrics: ["bp"],
steps: [
{
id: "s1",
tool: "get_metric_series",
labelKey: "coach.step.read",
label: "Blood pressure, last 30 days",
domain: "bp",
window: "last30days",
status: "done",
count: 42,
resultRef: "r1",
},
],
method: {
entries: [
{ domain: "bp", window: "last30days", count: 42, aggregation: "mean" },
],
text: "Blood pressure, last 30 days: 42 readings",
},
results: [META],
followUps: [
{
id: "f1",
kind: "previous_period",
labelKey: "coach.followUp.previousPeriod",
label: "Compare with the period before",
anchor: { ref: "r1", domain: "bp", window: "last30days" },
reuse: false,
origin: "server",
},
],
clarification: {
kind: "window",
choices: [
{
id: "c1",
labelKey: "coach.clarify.window.last30days",
label: "Last 30 days",
value: { window: "last30days" },
},
],
freeText: true,
},
forcedFinal: true,
};
function stubEchoCreate(overrideJson?: string): void {
txCreate.coachConversation.update.mockResolvedValue({});
txCreate.coachMessage.create.mockImplementation(
async (arg: { data: { metricSourceJson: string | null } }) => ({
id: "m1",
role: "assistant",
createdAt: new Date("2026-09-26T00:00:00.000Z"),
metricSourceJson: overrideJson ?? arg.data.metricSourceJson,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
}),
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("appendMessage — the dialog fields", () => {
it("restores steps, method, table metadata, chips, clarification and the forced marker", async () => {
stubEchoCreate();
const out = await appendMessage({
conversationId: "c1",
role: "assistant",
content: "reply",
metricSource: FULL,
});
expect(out.metricSource).toEqual(FULL);
});
it("drops a malformed entry and keeps its well-formed siblings", async () => {
stubEchoCreate(
JSON.stringify({
windows: [],
metrics: [],
steps: [FULL.steps![0], { id: "s2", status: "exploded" }],
results: [{ ref: "r9" }, META],
followUps: [{ ...FULL.followUps![0], kind: "free_text" }],
method: { entries: "nope", text: "x" },
clarification: { kind: "metric", choices: [], freeText: "yes" },
forcedFinal: "true",
}),
);
const out = await appendMessage({
conversationId: "c1",
role: "assistant",
content: "reply",
metricSource: { windows: [], metrics: [] },
});
expect(out.metricSource).toEqual({
windows: [],
metrics: [],
steps: FULL.steps,
results: [META],
});
});
it("strips a key the stored blob carries that the contract does not know", async () => {
stubEchoCreate(
JSON.stringify({
windows: [],
metrics: [],
results: [{ ...META, rows: [["2026-09-01", 128]] }],
}),
);
const out = await appendMessage({
conversationId: "c1",
role: "assistant",
content: "reply",
metricSource: { windows: [], metrics: [] },
});
// Values never ride the plaintext provenance, even when a blob has them.
expect(out.metricSource?.results?.[0]).not.toHaveProperty("rows");
});
});
describe("appendMessage — the tables", () => {
it("seals the tables into their own column and leaves them out of the provenance", async () => {
stubEchoCreate();
await appendMessage({
conversationId: "c1",
role: "assistant",
content: "reply",
metricSource: { windows: [], metrics: [], results: [META] },
results: [TABLE],
});
const data = txCreate.coachMessage.create.mock.calls[0][0].data;
expect(new TextDecoder().decode(data.resultsEncrypted)).toBe(
`enc:${JSON.stringify([TABLE])}`,
);
expect(data.metricSourceJson).not.toContain("2026-09-01");
});
it("writes NULL on a turn without tables", async () => {
stubEchoCreate();
await appendMessage({
conversationId: "c1",
role: "assistant",
content: "reply",
results: [],
});
const data = txCreate.coachMessage.create.mock.calls[0][0].data;
expect(data.resultsEncrypted).toBeNull();
});
it("drops whole tables from the end until the message fits the ceiling", async () => {
stubEchoCreate();
const big: CoachResultTable = {
...TABLE,
ref: "r2",
rows: Array.from({ length: 400 }, () => ["x".repeat(400), 1]),
};
await appendMessage({
conversationId: "c1",
role: "assistant",
content: "reply",
results: [TABLE, big],
});
const data = txCreate.coachMessage.create.mock.calls[0][0].data;
const json = new TextDecoder().decode(data.resultsEncrypted).slice(4);
expect(Buffer.byteLength(json)).toBeLessThanOrEqual(RESULTS_MAX_BYTES);
expect(JSON.parse(json)).toEqual([TABLE]);
});
});
describe("readMessageResults", () => {
function row(results: CoachResultMeta[], bytes: Uint8Array | null) {
return {
metricSourceJson: JSON.stringify({ windows: [], metrics: [], results }),
resultsEncrypted: bytes,
};
}
const sealed = (tables: unknown) =>
new TextEncoder().encode(`enc:${JSON.stringify(tables)}`);
it("narrows to the owner through the conversation", async () => {
findFirst.mockResolvedValue(null);
await expect(readMessageResults("u1", "c1", "m1")).resolves.toBeNull();
expect(findFirst.mock.calls[0][0].where).toEqual({
id: "m1",
conversationId: "c1",
conversation: { userId: "u1" },
});
});
it("serves every table the provenance lists, in its order", async () => {
findFirst.mockResolvedValue(row([META], sealed([TABLE])));
await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([
TABLE,
]);
});
it("withholds a table whose domain is switched off, without decrypting it", async () => {
findFirst.mockResolvedValue(
row([META, SLEEP_META], sealed([TABLE, { ...TABLE, ...SLEEP_META }])),
);
const out = await readMessageResults(
"u1",
"c1",
"m1",
(domain) => domain === "sleep",
);
expect(out).toEqual([TABLE, { ref: "r2", withheld: "module_disabled" }]);
findFirst.mockResolvedValue(row([SLEEP_META], sealed([TABLE])));
const { decryptFromBytes } = await import("../bytes-codec");
vi.mocked(decryptFromBytes).mockClear();
await readMessageResults("u1", "c1", "m1", () => true);
expect(decryptFromBytes).not.toHaveBeenCalled();
});
it("names a table it cannot read rather than serving nothing", async () => {
findFirst.mockResolvedValue(
row([META], new TextEncoder().encode("garbage")),
);
await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([
{ ref: "r1", withheld: "unavailable" },
]);
// A ref the ciphertext does not carry (dropped at the size ceiling).
findFirst.mockResolvedValue(row([META, SLEEP_META], sealed([TABLE])));
await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([
TABLE,
{ ref: "r2", withheld: "unavailable" },
]);
});
it("answers an empty list for a message without tables", async () => {
findFirst.mockResolvedValue({
metricSourceJson: null,
resultsEncrypted: null,
});
await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([]);
});
});
@@ -0,0 +1,71 @@
/**
* The Zod mirror of the Coach wire (`stream-events.ts`) and the TypeScript
* contract (`types.ts`) describe the same shapes.
*
* The checks are compile-time: `expectTypeOf(...).toEqualTypeOf<...>()`
* fails `pnpm typecheck` when a field is added, dropped, renamed or retyped
* on one side only. Equality is exact in both directions, so a field that
* is optional on one side and required on the other fails as well.
*
* Checked by breaking it: removing `resultRef` from `coachStepSchema`, and
* making `CoachFollowUp.reuse` optional in `types.ts`, each fail the
* typecheck on the matching line below.
*/
import { describe, expectTypeOf, it } from "vitest";
import type { z } from "zod/v4";
import type {
CoachClarification,
CoachFollowUp,
CoachMethod,
CoachProvenance,
CoachResultEntry,
CoachResultMeta,
CoachResultTable,
CoachStep,
CoachStreamEvent,
} from "@/lib/ai/coach/types";
import {
coachClarificationSchema,
coachFollowUpSchema,
coachMethodSchema,
coachProvenanceSchema,
coachResultEntrySchema,
coachResultMetaSchema,
coachResultTableSchema,
coachStepSchema,
coachStreamEventSchema,
} from "@/lib/ai/coach/stream-events";
describe("Coach wire: Zod mirror equals the TypeScript contract", () => {
it("every frame", () => {
expectTypeOf<
z.infer<typeof coachStreamEventSchema>
>().toEqualTypeOf<CoachStreamEvent>();
});
it("each component the OpenAPI document names", () => {
expectTypeOf<z.infer<typeof coachStepSchema>>().toEqualTypeOf<CoachStep>();
expectTypeOf<
z.infer<typeof coachResultMetaSchema>
>().toEqualTypeOf<CoachResultMeta>();
expectTypeOf<
z.infer<typeof coachResultTableSchema>
>().toEqualTypeOf<CoachResultTable>();
expectTypeOf<
z.infer<typeof coachResultEntrySchema>
>().toEqualTypeOf<CoachResultEntry>();
expectTypeOf<
z.infer<typeof coachMethodSchema>
>().toEqualTypeOf<CoachMethod>();
expectTypeOf<
z.infer<typeof coachFollowUpSchema>
>().toEqualTypeOf<CoachFollowUp>();
expectTypeOf<
z.infer<typeof coachClarificationSchema>
>().toEqualTypeOf<CoachClarification>();
expectTypeOf<
z.infer<typeof coachProvenanceSchema>
>().toEqualTypeOf<CoachProvenance>();
});
});
@@ -0,0 +1,152 @@
/**
* v1.39.4 — the loop's progress seam: every call that starts settles, the
* trace keeps only schema-validated arguments, and a forced answer at the
* round cap is reported as such.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
vi.mock("@/lib/ai/coach/tools/executor", () => ({
executeCoachTool: (args: unknown) => executeCoachTool(args),
}));
const runRawCompletionWithFallback = vi.fn();
vi.mock("@/lib/ai/provider-runner", () => ({
runRawCompletionWithFallback: (args: unknown) =>
runRawCompletionWithFallback(args),
}));
import { HARD_CAP, runCoachToolLoop } from "@/lib/ai/coach/tools/loop";
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
type Call = { id: string; name: string; arguments: string };
function round(content: string, toolCalls?: Call[]) {
return {
result: {
content,
tokensUsed: 5,
model: "mock",
providerType: "anthropic" as const,
...(toolCalls
? { toolCalls, finishReason: "tool_calls" as const }
: { finishReason: "stop" as const }),
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
};
}
const baseArgs = {
userId: "u1",
providers: [],
system: "sys",
messages: [{ role: "user" as const, content: "q" }],
tools: COACH_TOOL_DEFS,
};
beforeEach(() => {
executeCoachTool.mockReset();
runRawCompletionWithFallback.mockReset();
});
describe("runCoachToolLoop — progress callbacks", () => {
it("settles every call that started, misses and invalid calls included, with turn-wide indexes", async () => {
executeCoachTool
.mockResolvedValueOnce({ present: true, data: {} })
.mockResolvedValueOnce({ present: false, reason: "invalid_arguments" })
.mockResolvedValueOnce({ present: false, reason: "unknown_tool" });
runRawCompletionWithFallback
.mockResolvedValueOnce(
round("", [
{ id: "a", name: "get_metric_series", arguments: '{"metric":"bp"}' },
{ id: "b", name: "get_sleep", arguments: "{not json" },
]),
)
.mockResolvedValueOnce(
round("", [{ id: "c", name: "get_everything", arguments: "{}" }]),
)
.mockResolvedValueOnce(round("answer"));
const started: Array<[string, number]> = [];
const settled: Array<[string, boolean, number]> = [];
const out = await runCoachToolLoop({
...baseArgs,
onCallStart: (call, index) => started.push([call.id, index]),
onCallSettled: (call, result, index) =>
settled.push([call.id, result.present, index]),
});
expect(started).toEqual([
["a", 0],
["b", 1],
["c", 2],
]);
expect([...settled].sort((x, y) => x[2] - y[2])).toEqual([
["a", true, 0],
["b", false, 1],
["c", false, 2],
]);
expect(out.forcedFinal).toBe(false);
});
it("keeps only schema-validated arguments on the trace", async () => {
executeCoachTool.mockResolvedValue({ present: false });
runRawCompletionWithFallback
.mockResolvedValueOnce(
round("", [
{ id: "a", name: "get_metric_series", arguments: '{"metric":"bp"}' },
// Unknown key: the strict schema refuses it.
{
id: "b",
name: "get_metric_series",
arguments: '{"metric":"bp","userId":"someone-else"}',
},
{ id: "c", name: "get_sleep", arguments: "{not json" },
]),
)
.mockResolvedValueOnce(round("answer"));
const out = await runCoachToolLoop(baseArgs);
const byArgs = out.toolTrace.map((t) => t.args ?? null);
expect(byArgs).toContainEqual({ metric: "bp" });
expect(byArgs.filter((a) => a === null)).toHaveLength(2);
expect(JSON.stringify(out.toolTrace)).not.toContain("someone-else");
});
it("a throwing callback never breaks the turn", async () => {
executeCoachTool.mockResolvedValue({ present: true, data: {} });
runRawCompletionWithFallback
.mockResolvedValueOnce(
round("", [{ id: "a", name: "get_sleep", arguments: "{}" }]),
)
.mockResolvedValueOnce(round("answer"));
const out = await runCoachToolLoop({
...baseArgs,
onCallStart: () => {
throw new Error("closed stream");
},
onCallSettled: () => {
throw new Error("closed stream");
},
});
expect(out.result.content).toBe("answer");
expect(out.toolTrace).toHaveLength(1);
});
it("reports a forced answer at the round cap", async () => {
executeCoachTool.mockResolvedValue({ present: false });
runRawCompletionWithFallback.mockImplementation(
(args: { params: { toolChoice?: string } }) =>
Promise.resolve(
args.params.toolChoice === "none"
? round("forced")
: round("", [{ id: "x", name: "get_sleep", arguments: "{}" }]),
),
);
const out = await runCoachToolLoop(baseArgs);
expect(out.rounds).toBe(HARD_CAP);
expect(out.forcedFinal).toBe(true);
});
});
@@ -79,7 +79,9 @@ describe("coach tool loop — which results reach the grounding guard", () => {
const loop = await runCoachToolLoop(baseArgs);
// The trace records that the tool ran and found nothing…
expect(loop.toolTrace).toEqual([{ name: "get_sleep", present: false }]);
expect(loop.toolTrace).toEqual([
{ name: "get_sleep", present: false, args: {} },
]);
// …but the payload set the route grades against is empty, so the route's
// `activatingPayloads.length > 0` gate is false and the numeric grounding
// guard never runs for this turn.
@@ -88,7 +88,7 @@ describe("runCoachToolLoop", () => {
expect(out.result.content).toBe("Your BP looks steady.");
expect(out.totalTokens).toBe(50); // summed across both rounds
expect(out.toolTrace).toEqual([
{ name: "get_metric_series", present: true },
{ name: "get_metric_series", present: true, args: { metric: "bp" } },
]);
// v1.21.0 (P6) — the present result's payload is retained for the prose
// number-verifier.
@@ -278,7 +278,7 @@ describe("shared snapshot scope threading (D5-1)", () => {
expect(out.toolResults).toHaveLength(1);
expect(out.toolResults[0].available).toMatchObject({ count: 1597 });
expect(out.toolTrace).toEqual([
{ name: "get_glucose_panel", present: false },
{ name: "get_glucose_panel", present: false, args: {} },
]);
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Clarifying questions. The model may end a reply with a `---CLARIFY---`
* block when the metric or window stays genuinely ambiguous; the server
* validates the choices against what the record holds, and the question text
* is the reply itself. The person answers with
* `clarification: { messageId, choiceId? }` on the next request.
*
* Not built yet: the prose passes through unchanged, no clarification is
* offered, and an answer adds nothing to the turn.
*/
import type { Locale } from "@/lib/i18n/config";
import type { CoachClarification } from "@/lib/ai/coach/types";
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
export function parseClarifySentinel(args: {
prose: string;
/** What the record holds; null on the no-tools path. */
inventory: InventoryEntry[] | null;
locale: Locale;
}): { prose: string; clarification: CoachClarification | null } {
return { prose: args.prose, clarification: null };
}
/**
* The turn-context line for an answered clarification, or null when the
* request carries none or its question is no longer current.
*/
export async function resolveClarificationAnswer(_args: {
userId: string;
conversationId: string | undefined;
clarification: { messageId: string; choiceId?: string } | undefined;
}): Promise<string | null> {
return null;
}
+255
View File
@@ -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<Record<CoachStepReason, string>> =
{
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<string> = 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<NonNullable<CoachMethodEntry["aggregation"]>, 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<NonNullable<CoachMethodEntry["absent"]>, 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<Record<CoachFollowUpKind, string>> =
{
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";
+15
View File
@@ -0,0 +1,15 @@
/**
* "Keep looking": when the loop forced an answer at its round cap, offer a
* chip that continues the question with what was already fetched.
*
* Not built yet: no chip.
*/
import type { Locale } from "@/lib/i18n/config";
import type { CoachFollowUp } from "@/lib/ai/coach/types";
export function buildContinueFollowUp(_args: {
forcedFinal: boolean;
locale: Locale;
}): CoachFollowUp | null {
return null;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* The follow-up chips under the latest assistant reply, derived on the
* server from what the turn read: as a chart, as a table, the period before,
* a year ago, a wider window, a related metric. At most three, deduplicated,
* labels from the catalog only.
*
* Pure. Not built yet: no chips.
*/
import type { Locale } from "@/lib/i18n/config";
import type {
CoachFollowUp,
CoachFollowUpKind,
CoachResultMeta,
CoachStep,
CoachStepDomain,
} from "@/lib/ai/coach/types";
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
export function deriveFollowUps(_args: {
results: CoachResultMeta[];
steps: CoachStep[];
/** What the record holds; null on the no-tools path. */
inventory: InventoryEntry[] | null;
/** Kind and domain pairs the model proposed, already catalog-checked. */
proposals: Array<{ kind: CoachFollowUpKind; domain: CoachStepDomain }>;
forcedFinal: boolean;
prefs: CoachPrefs;
locale: Locale;
}): CoachFollowUp[] {
return [];
}
@@ -0,0 +1,15 @@
/**
* The optional `---FOLLOWUPS---` block in the reply: the model may propose
* a chip's kind and domain from the catalog, nothing else. The block is
* stripped from the prose; a malformed block is dropped silently.
*
* Not built yet: the prose passes through unchanged, with no proposals.
*/
import type { CoachFollowUpKind, CoachStepDomain } from "@/lib/ai/coach/types";
export function parseFollowUpsSentinel(prose: string): {
prose: string;
proposals: Array<{ kind: CoachFollowUpKind; domain: CoachStepDomain }>;
} {
return { prose, proposals: [] };
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Resolve a tapped follow-up chip (`followUp: { messageId, id }` on the
* request) against what the server persisted: the chip must sit on the
* conversation's latest assistant message. A reuse chip is answered from the
* stored table without a model call; any other becomes a turn-context hint.
* A chip that is no longer current degrades to a plain message.
*
* Not built yet: every request is a plain message.
*/
import type { CoachFollowUp } from "@/lib/ai/coach/types";
/** A chip that resolved: what it asks for, and how it is answered. */
export interface ResolvedFollowUp {
sourceMessageId: string;
followUp: CoachFollowUp;
/** A line for the turn context, on a chip answered by the model. */
contextHint: string | null;
}
export async function resolveFollowUp(_args: {
userId: string;
conversationId: string | undefined;
followUp: { messageId: string; id: string } | undefined;
}): Promise<ResolvedFollowUp | null> {
return null;
}
+22
View File
@@ -0,0 +1,22 @@
/**
* The method line under an answer: which sources, windows, counts and
* aggregation it rests on, rendered on the server in the request locale.
* "Blood pressure, last 90 days: 142 readings, weekly means · Sleep: no
* readings in this window". Never a health value.
*
* Not built yet: no method line.
*/
import type { Locale } from "@/lib/i18n/config";
import type {
CoachMethod,
CoachResultMeta,
CoachStep,
} from "@/lib/ai/coach/types";
export function buildMethod(_args: {
steps: CoachStep[];
results: CoachResultMeta[];
locale: Locale;
}): CoachMethod | null {
return null;
}
+185
View File
@@ -10,8 +10,18 @@
* provenance (window names, metric tags, sample counts) and never raw
* values, so it can be queried without decryption for analytics.
*/
import type { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { decryptFromBytes, encryptToBytes } from "./bytes-codec";
import {
coachClarificationSchema,
coachFollowUpSchema,
coachMethodSchema,
coachResultMetaSchema,
coachResultTableSchema,
coachStepSchema,
} from "./stream-events";
import { COACH_CONVERSATION_TITLE_MAX } from "./types";
import {
isCheckupIntervalId,
@@ -20,12 +30,20 @@ import {
} from "./suggest-action";
import type {
CoachClarification,
CoachConversationAttachmentDTO,
CoachConversationDTO,
CoachConversationDetailDTO,
CoachFollowUp,
CoachMessageDTO,
CoachMessageRole,
CoachMethod,
CoachProvenance,
CoachResultEntry,
CoachResultMeta,
CoachResultTable,
CoachStep,
CoachStepDomain,
CoachSuggestion,
} from "./types";
@@ -56,6 +74,9 @@ export function summariseTitle(input: string): string {
return `${cut.trimEnd()}…`;
}
/** v1.39.4 — at most this many tables per message (`r1`..`r6`). */
const MAX_RESULTS_PER_MESSAGE = 6;
function provenanceToJson(provenance: CoachProvenance | null): string | null {
if (!provenance) return null;
return JSON.stringify(provenance);
@@ -162,6 +183,53 @@ function restoreToolCalls(raw: unknown): CoachProvenance["toolCalls"] {
return cleaned.length > 0 ? cleaned : undefined;
}
/**
* v1.39.4 — restore an array field of the provenance entry by entry against
* the wire schema. A malformed entry is dropped, not the whole field; an
* empty or absent array restores to undefined. The schemas strip keys they do
* not know, so a stored blob can never widen what a reader sees.
*/
function restoreEach<T>(
raw: unknown,
schema: z.ZodType<T>,
cap: number,
): T[] | undefined {
if (!Array.isArray(raw)) return undefined;
const cleaned: T[] = [];
for (const item of raw.slice(0, cap)) {
const parsed = schema.safeParse(item);
if (parsed.success) cleaned.push(parsed.data);
}
return cleaned.length > 0 ? cleaned : undefined;
}
/** The live steps a turn persisted (at most 12 per turn). */
function restoreSteps(raw: unknown): CoachStep[] | undefined {
return restoreEach(raw, coachStepSchema, 12);
}
/** The method line; dropped whole when it does not parse. */
function restoreMethod(raw: unknown): CoachMethod | undefined {
const parsed = coachMethodSchema.safeParse(raw);
return parsed.success ? parsed.data : undefined;
}
/** The tables' metadata (at most 6 per message). No values live here. */
function restoreResultMetas(raw: unknown): CoachResultMeta[] | undefined {
return restoreEach(raw, coachResultMetaSchema, MAX_RESULTS_PER_MESSAGE);
}
/** The chips offered under the reply (at most 3). */
function restoreFollowUps(raw: unknown): CoachFollowUp[] | undefined {
return restoreEach(raw, coachFollowUpSchema, 3);
}
/** The clarification choices; dropped whole when they do not parse. */
function restoreClarification(raw: unknown): CoachClarification | undefined {
const parsed = coachClarificationSchema.safeParse(raw);
return parsed.success ? parsed.data : undefined;
}
function provenanceFromJson(raw: string | null): CoachProvenance | null {
if (!raw) return null;
try {
@@ -249,6 +317,14 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
const suggestion = restoreSuggestion(parsed.suggestion);
const suggestedAction = restoreSuggestedAction(parsed.suggestedAction);
const toolCalls = restoreToolCalls(parsed.toolCalls);
// v1.39.4 — the dialog fields: steps, method, table metadata, chips, the
// clarification choices, and the forced-answer marker.
const steps = restoreSteps(parsed.steps);
const method = restoreMethod(parsed.method);
const results = restoreResultMetas(parsed.results);
const followUps = restoreFollowUps(parsed.followUps);
const clarification = restoreClarification(parsed.clarification);
const forcedFinal = parsed.forcedFinal === true;
return {
windows,
metrics,
@@ -259,6 +335,12 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
...(suggestedAction ? { suggestedAction } : {}),
...(toolCalls ? { toolCalls } : {}),
...(unverifiedFigures !== undefined ? { unverifiedFigures } : {}),
...(steps ? { steps } : {}),
...(method ? { method } : {}),
...(results ? { results } : {}),
...(followUps ? { followUps } : {}),
...(clarification ? { clarification } : {}),
...(forcedFinal ? { forcedFinal: true as const } : {}),
};
} catch {
return null;
@@ -295,6 +377,39 @@ export interface AppendMessageParams {
*/
tokensUsed?: number | null;
model?: string | null;
/**
* v1.39.4 — the tables of values this turn read. Encrypted into
* `resultsEncrypted`; their metadata rides `metricSource.results`. Omitted
* or empty on every turn without a table.
*/
results?: CoachResultTable[];
}
/**
* v1.39.4 — the at-rest ceiling for one message's tables, as JSON before
* encryption. Tables arrive trimmed to 400 rows; a message whose tables still
* exceed this keeps the leading tables that fit, and a reader is told the
* rest are unavailable rather than handed a partial table.
*/
export const RESULTS_MAX_BYTES = 128 * 1024;
/**
* Serialise a turn's tables for the ciphertext column, or null when there are
* none. Drops whole tables from the end until the JSON fits the ceiling.
*/
function resultsToBytes(
results: CoachResultTable[] | undefined,
): Uint8Array<ArrayBuffer> | null {
if (!results || results.length === 0) return null;
const kept = results.slice(0, MAX_RESULTS_PER_MESSAGE);
while (kept.length > 0) {
const json = JSON.stringify(kept);
if (Buffer.byteLength(json, "utf8") <= RESULTS_MAX_BYTES) {
return encryptToBytes(json);
}
kept.pop();
}
return null;
}
/**
@@ -417,6 +532,7 @@ export async function appendMessage(
promptVersion: params.promptVersion ?? null,
tokensUsed: params.tokensUsed ?? null,
model: params.model ?? null,
resultsEncrypted: resultsToBytes(params.results),
},
});
await tx.coachConversation.update({
@@ -547,6 +663,9 @@ export async function fetchConversationWithMessages(
// changing the oldest->newest contract the client renders.
orderBy: { createdAt: "desc" },
take: CONVERSATION_MESSAGE_DETAIL_CAP,
// v1.39.4 — the tables are read lazily, one message at a time, through
// `readMessageResults`; the detail read never loads their ciphertext.
omit: { resultsEncrypted: true },
},
// v1.29.x (S7) — the LIVE attachment set (join → document label columns
// only; the encrypted body is untouched), ordered by attach time. Always
@@ -601,6 +720,72 @@ export async function fetchConversationWithMessages(
};
}
/**
* v1.39.4 — the stored tables of one assistant message, for its owner.
*
* Narrowed like the conversation read: the message must sit in a
* conversation `userId` owns, and a foreign or missing id is null (the route
* answers 404 for both, so existence never leaks across accounts).
*
* One entry per table the message's provenance lists, in that order. A
* table is served whole or withheld with a reason, never partially:
* - `module_disabled` — `withhold(domain)` said the table's domain is
* switched off for the record now. Such a table is not even decrypted.
* - `unavailable` — the ciphertext did not decrypt, did not parse, or did
* not carry that ref (a table dropped at the size ceiling). Fail closed
* and say so, rather than serve nothing silently.
*/
export async function readMessageResults(
userId: string,
conversationId: string,
messageId: string,
withhold: (domain: CoachStepDomain) => boolean = () => false,
): Promise<CoachResultEntry[] | null> {
const row = await prisma.coachMessage.findFirst({
where: {
id: messageId,
conversationId,
conversation: { userId },
},
select: { metricSourceJson: true, resultsEncrypted: true },
});
if (!row) return null;
const metas = provenanceFromJson(row.metricSourceJson)?.results ?? [];
if (metas.length === 0) return [];
const served = metas.filter((meta) => !withhold(meta.source.domain));
const tables =
served.length > 0 ? decryptResultTables(row.resultsEncrypted) : null;
return metas.map((meta): CoachResultEntry => {
if (withhold(meta.source.domain)) {
return { ref: meta.ref, withheld: "module_disabled" };
}
const table = tables?.get(meta.ref);
return table ?? { ref: meta.ref, withheld: "unavailable" };
});
}
/** Decrypt and parse a message's tables by ref; null when unreadable. */
function decryptResultTables(
bytes: Uint8Array | null,
): Map<string, CoachResultTable> | null {
if (!bytes || bytes.byteLength === 0) return null;
let parsed: unknown;
try {
parsed = JSON.parse(decryptFromBytes(bytes));
} catch {
return null;
}
const tables = restoreEach(
parsed,
coachResultTableSchema,
MAX_RESULTS_PER_MESSAGE,
);
return new Map((tables ?? []).map((table) => [table.ref, table]));
}
export interface ListConversationsParams {
userId: string;
cursor?: string | null;
+15
View File
@@ -0,0 +1,15 @@
/**
* The chart a result table gets, chosen on the server from the table's shape
* and the metric: a line for level metrics over time, a bar for totals and
* category counts, a histogram for a distribution, or none (table only)
* when the table is too short, too long or has no numeric column.
*
* Pure. Not built yet: every table stays table-only.
*/
import type { CoachChartSpec, CoachResultTable } from "@/lib/ai/coach/types";
export function deriveChartSpec(
_table: Omit<CoachResultTable, "chart" | "chartKind">,
): CoachChartSpec | null {
return null;
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Which module owns the domain a stored Coach table is about, so a table
* read after the module was switched off is withheld rather than served.
*
* The same ownership the Coach applies when it reads: the measurement-level
* table in `MODULE_SCOPED_SOURCES`, plus the cycle and illness domains,
* which the Coach gates on their own modules. Labs, medication compliance,
* correlations and the full snapshot answer to no module there, so none
* here either — gating them on this read would make a stored answer
* stricter than the Coach that wrote it.
*/
import { MODULE_SCOPED_SOURCES } from "@/lib/modules/measurement-scope";
import type { ModuleKey } from "@/lib/modules/registry";
import type { CoachStepDomain } from "@/lib/ai/coach/types";
const OWNER_BY_DOMAIN: ReadonlyMap<string, ModuleKey> = (() => {
const out = new Map<string, ModuleKey>([
["cycle", "cycle"],
["illness", "illness"],
]);
for (const [key, sources] of Object.entries(MODULE_SCOPED_SOURCES)) {
for (const source of sources ?? []) out.set(source, key as ModuleKey);
}
return out;
})();
export function moduleForCoachDomain(
domain: CoachStepDomain,
): ModuleKey | null {
return OWNER_BY_DOMAIN.get(domain) ?? null;
}
/** True when the domain's module is switched off in `modules`. */
export function isCoachDomainWithheld(
domain: CoachStepDomain,
modules: Readonly<Record<ModuleKey, boolean>>,
): boolean {
const owner = moduleForCoachDomain(domain);
return owner !== null && modules[owner] === false;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Project this turn's tool results into result tables: per-sport counts for
* workouts, the latest reading per analyte for labs, per-night sleep, the
* compliance timeline, and the metric table tool's own rows.
*
* Input is only what the loop settled on THIS turn, so a table can never
* carry another turn's, or another account's, values.
*
* Not built yet: answers no tables.
*/
import type { Locale } from "@/lib/i18n/config";
import type { CoachResultTable } from "@/lib/ai/coach/types";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
/** One tool call of this turn, as the loop settled it. */
export interface SettledToolCall {
name: string;
/** Schema-validated arguments; absent when they did not validate. */
args?: Record<string, unknown>;
result: CoachToolResult;
}
/**
* Tables for this turn, refs `r1`.. in call order. `chart` and `chartKind`
* are left for `deriveChartSpec` to fill.
*/
export function projectResults(_args: {
calls: SettledToolCall[];
locale: Locale;
}): CoachResultTable[] {
return [];
}
+14
View File
@@ -0,0 +1,14 @@
/**
* `result:rN` references in the reply prose. The model marks the tables its
* answer relies on; the marks are stripped before the prose reaches the
* client, and a referenced table is shown expanded under the reply.
*
* Not built yet: the prose passes through unchanged and nothing counts as
* referenced.
*/
export function stripResultRefs(prose: string): {
prose: string;
referenced: string[];
} {
return { prose, referenced: [] };
}
+484
View File
@@ -0,0 +1,484 @@
/**
* The Coach chat wire as Zod schemas.
*
* `types.ts` holds the TypeScript contract the server and the web client
* compile against; this file is its mirror for the published OpenAPI
* document (`docs/api/openapi.yaml`), which the native client reads. The two
* are held equal by `__tests__/stream-events-type-parity.test.ts`: a field
* added on one side and not the other fails the typecheck.
*
* Nothing here parses a live frame. The server builds frames from typed
* values and the client dispatches on `type`; these schemas describe the
* shapes, they do not police them.
*/
import { z } from "zod/v4";
import { AI_UNAVAILABLE_REASONS } from "@/lib/ai/capabilities/types";
import {
SUGGESTED_ACTION_TYPES,
type CheckupIntervalId,
} from "@/lib/ai/coach/suggest-action";
import { COACH_TOOL_NAMES } from "@/lib/ai/coach/tools/definitions";
import {
coachKeyValueSchema,
coachScopeSourceSchema,
coachScopeWindowSchema,
} from "@/lib/ai/coach/types";
const coachWindowEnum = coachScopeWindowSchema;
const coachToolNameEnum = z.enum(COACH_TOOL_NAMES);
export const coachProvenanceMetricSchema = z.enum([
...coachScopeSourceSchema.options,
"general",
]);
export const coachStepDomainSchema = z
.enum([
...coachScopeSourceSchema.options,
"labs",
"illness",
"cycle",
"correlations",
"snapshot",
])
.meta({
id: "CoachStepDomain",
description:
"The data domain a step, table or chip is about: a measurement-backed scope source, or a domain read as a whole (labs, illness, cycle, correlations, the full snapshot).",
});
const coachResultGranularitySchema = z.enum(["day", "week", "month"]);
const coachResultPeriodSchema = z.enum(["current", "previous", "yearAgo"]);
// ── Steps ─────────────────────────────────────────────────────────────────
export const coachStepSchema = z
.object({
id: z.string().describe("`s1`..`s12`, unique within a turn."),
tool: z.enum([...COACH_TOOL_NAMES, "show_result", "snapshot"]),
labelKey: z
.string()
.describe("Closed catalog key (`coach.step.*`) the client localises."),
label: z
.string()
.describe(
"The label rendered on the server in the request locale. Render it when the client has no string for `labelKey`.",
),
domain: coachStepDomainSchema.optional(),
window: coachWindowEnum.optional(),
period: coachResultPeriodSchema.optional(),
granularity: coachResultGranularitySchema.optional(),
status: z.enum(["running", "done", "empty", "failed"]),
count: z
.number()
.int()
.optional()
.describe("Readings or rows the server counted. Never a health value."),
reason: z
.enum([
"no_data",
"outside_window",
"module_disabled",
"retrieval_failed",
"invalid_arguments",
])
.optional(),
resultRef: z
.string()
.optional()
.describe("The table this step produced (`r1`..), when it produced one."),
})
.meta({
id: "CoachStep",
description:
"One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done` / `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only: never free text, an analyte name or a value.",
});
// ── Results ───────────────────────────────────────────────────────────────
const coachResultColumnSchema = z.object({
key: z.string(),
kind: z.enum(["period", "category", "number", "count"]),
labelKey: z.string(),
label: z.string(),
unit: z
.string()
.optional()
.describe("Display unit token such as `mmHg`, never free text."),
decimals: z.number().int().optional(),
});
const coachChartSpecSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("line"),
x: z.string(),
series: z.array(z.string()),
}),
z.object({
kind: z.literal("bar"),
x: z.string(),
series: z.array(z.string()),
orientation: z.enum(["vertical", "horizontal"]),
}),
z.object({
kind: z.literal("histogram"),
column: z.string(),
unit: z.string().optional(),
bins: z.array(
z.object({ from: z.number(), to: z.number(), count: z.number().int() }),
),
}),
]);
const coachResultSourceSchema = z.object({
tool: coachToolNameEnum,
domain: coachStepDomainSchema,
window: coachWindowEnum,
period: coachResultPeriodSchema,
granularity: coachResultGranularitySchema.optional(),
});
const coachResultMetaShape = {
ref: z.string().describe("`r1`..`r6`, unique within a message."),
source: coachResultSourceSchema,
shape: z.enum(["timeSeries", "categoryCounts", "distribution", "single"]),
titleKey: z.string(),
title: z.string(),
rowCount: z.number().int().describe("The full row count, before any trim."),
chartKind: z.enum(["line", "bar", "histogram"]).nullable(),
displayed: z
.boolean()
.describe(
"True when the answer referenced the table (shown expanded); false when it sits under the data-used disclosure.",
),
reusedFrom: z
.object({ messageId: z.string(), ref: z.string() })
.optional()
.describe("Set when the table was copied from an earlier message."),
};
export const coachResultMetaSchema = z.object(coachResultMetaShape).meta({
id: "CoachResultMeta",
description:
"A table's metadata, persisted in the plaintext provenance (`metricSource.results`). The values are not here: fetch them from `GET /api/insights/chat/{id}/messages/{messageId}/results`.",
});
export const coachResultTableSchema = z
.object({
...coachResultMetaShape,
columns: z.array(coachResultColumnSchema),
rows: z
.array(z.array(z.union([z.string(), z.number(), z.null()])))
.describe(
"Row-major cells, at most 400 rows. `null` is a period with no reading.",
),
truncated: z.boolean().describe("True when `rowCount` exceeds the rows."),
chart: coachChartSpecSchema
.nullable()
.describe("The chart the server chose for the table, or null."),
})
.meta({
id: "CoachResultTable",
description:
"A table of the values a turn read. Health data: encrypted at rest and sent only to the account that owns the conversation.",
});
const coachResultWithheldSchema = z.object({
ref: z.string(),
withheld: z
.enum(["module_disabled", "unavailable"])
.describe(
"`module_disabled`: the table's domain is switched off for this record. `unavailable`: the stored tables could not be read.",
),
});
export const coachResultEntrySchema = z
.union([coachResultTableSchema, coachResultWithheldSchema])
.meta({
id: "CoachResultEntry",
description:
"One stored table, or the reason it is not served. Absence is explicit: a withheld table names why.",
});
// ── Method ────────────────────────────────────────────────────────────────
export const coachMethodSchema = z
.object({
entries: z.array(
z.object({
domain: coachStepDomainSchema,
window: coachWindowEnum.optional(),
period: coachResultPeriodSchema.optional(),
granularity: coachResultGranularitySchema.optional(),
count: z.number().int().optional(),
aggregation: z
.enum(["mean", "median", "latest", "sum", "count", "rate"])
.optional(),
absent: z
.enum(["no_data", "outside_window", "module_disabled"])
.optional(),
}),
),
text: z.string().describe("Rendered on the server in the request locale."),
})
.meta({
id: "CoachMethod",
description:
"How an answer was reached: which sources, windows, counts and aggregation. Never a health value.",
});
// ── Follow-ups ────────────────────────────────────────────────────────────
export const coachFollowUpSchema = z
.object({
id: z.string().describe("`f1`..`f3`."),
kind: z.enum([
"widen_window",
"previous_period",
"year_ago",
"as_chart",
"as_table",
"related_metric",
"continue",
]),
labelKey: z.string(),
label: z
.string()
.describe("Rendered on the server from a catalog; never model text."),
anchor: z
.object({
ref: z.string(),
domain: coachStepDomainSchema,
window: coachWindowEnum.optional(),
granularity: coachResultGranularitySchema.optional(),
period: coachResultPeriodSchema.optional(),
})
.optional(),
reuse: z
.boolean()
.describe(
"True when the chip is answered from a stored table without a model call.",
),
origin: z.enum(["server", "model"]),
})
.meta({
id: "CoachFollowUp",
description:
"A chip offered under the latest assistant reply. Send it back as `followUp: { messageId, id }` with the chip's label as `message`.",
});
// ── Clarification ─────────────────────────────────────────────────────────
export const coachClarificationSchema = z
.object({
kind: z.enum(["metric", "window", "context"]),
choices: z
.array(
z.object({
id: z.string(),
labelKey: z.string(),
label: z.string(),
value: z.object({
metric: coachScopeSourceSchema.optional(),
window: coachWindowEnum.optional(),
}),
}),
)
.describe("At most 4. Metric choices are always ones the record holds."),
freeText: z.boolean().describe("Whether a typed answer is welcome."),
})
.meta({
id: "CoachClarification",
description:
"The choices for a clarifying question. The question itself is the assistant message's text. Answer with `clarification: { messageId, choiceId? }`.",
});
// ── The cards and the usage envelope ──────────────────────────────────────
const coachSuggestionSchema = z
.object({
cadenceId: z.string(),
measurementType: z.string(),
label: z.string(),
})
.meta({
id: "CoachSuggestion",
description:
"A cadence-suggestion card. Accepting it posts `cadenceId` to `POST /api/measurement-reminders`.",
});
const checkupIntervalSchema = z.string() as z.ZodType<CheckupIntervalId>;
const coachSuggestedActionSchema = z
.object({
actionType: z.enum(SUGGESTED_ACTION_TYPES),
summary: z.string(),
titleKey: z.string(),
params: z.discriminatedUnion("actionType", [
z.object({
actionType: z.literal("checkup.create"),
label: z.string(),
interval: checkupIntervalSchema,
}),
z.object({
actionType: z.literal("reminder.note"),
note: z.string(),
when: z.string().optional(),
metric: z.string().optional(),
}),
]),
})
.meta({
id: "CoachSuggestedActionCard",
description:
"A confirm-to-apply action card from a closed allowlist. Nothing is created until the person confirms it through `POST /api/coach/suggested-actions`.",
});
const coachUsageSchema = z
.object({
totalTokens: z.number().int().nullable(),
promptTokens: z.number().int().nullable().optional(),
completionTokens: z.number().int().nullable().optional(),
model: z.string().nullable().optional(),
})
.meta({
id: "CoachUsage",
description:
"Per-turn token usage. Server-authoritative: clients display it, never recompute it.",
});
// ── Provenance ────────────────────────────────────────────────────────────
export const coachProvenanceSchema = z
.object({
windows: z
.array(coachWindowEnum)
.readonly()
.describe("Analysis windows the assistant drew on this turn."),
metrics: z
.array(coachProvenanceMetricSchema)
.readonly()
.describe(
"Stable metric-topic keys referenced (e.g. bp, weight, sleep, glucose). `general` is the empty-snapshot sentinel. The client translates these labels; the server never localises them.",
),
counts: z
.partialRecord(coachProvenanceMetricSchema, z.number().int())
.optional()
.describe(
"Per-metric sample-count summary; absent on an empty snapshot.",
),
keyValues: z
.array(coachKeyValueSchema)
.readonly()
.optional()
.describe(
"Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 entries.",
),
suggestion: coachSuggestionSchema
.optional()
.describe("The cadence-suggestion card this turn carried, if any."),
suggestedAction: coachSuggestedActionSchema
.optional()
.describe("The confirm-to-apply action card this turn carried, if any."),
toolCalls: z
.array(z.object({ name: z.string(), present: z.boolean() }))
.readonly()
.optional()
.describe(
"v1.20.0 — the retrieval-tool trace for this turn: which tools the Coach called and whether each found data. Metadata only (no values). Absent on the legacy snapshot path and on turns that called no tools.",
),
groundedFigures: z
.array(z.number())
.readonly()
.optional()
.describe(
"Bare magnitudes the turn's tools returned, recalled by a later turn's number check. Server-computed, label-less.",
),
unverifiedFigures: z
.number()
.int()
.positive()
.optional()
.describe(
"v1.32.14 — count of numeric tokens the grounding guard withheld from this reply (each rewritten to the editorial elision mark). Drives the quiet per-message notice. Count only, never the withheld values. Absent when the turn withheld nothing.",
),
steps: z
.array(coachStepSchema)
.optional()
.describe("v1.39.4 — what the Coach read on this turn."),
method: coachMethodSchema
.optional()
.describe("v1.39.4 — how the answer was reached."),
results: z
.array(coachResultMetaSchema)
.optional()
.describe(
"v1.39.4 — the tables this turn produced, metadata only; the values are behind the results endpoint.",
),
followUps: z
.array(coachFollowUpSchema)
.optional()
.describe("v1.39.4 — the chips offered under this reply."),
clarification: coachClarificationSchema
.optional()
.describe("v1.39.4 — the choices, when this reply is a question."),
forcedFinal: z
.literal(true)
.optional()
.describe(
"v1.39.4 — the answer was forced at the round cap while the Coach still wanted to read. Absent otherwise.",
),
})
.meta({
id: "CoachProvenance",
description:
"Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence key-values and cards. No raw timestamps and no table values.",
});
// ── Frames ────────────────────────────────────────────────────────────────
export const coachStreamEventSchema = z
.discriminatedUnion("type", [
z.object({ type: z.literal("token"), token: z.string() }),
z.object({
type: z.literal("provenance"),
metricSource: coachProvenanceSchema,
}),
z.object({
type: z.literal("suggestion"),
suggestion: coachSuggestionSchema,
}),
z.object({
type: z.literal("suggestedAction"),
suggestedAction: coachSuggestedActionSchema,
}),
z.object({ type: z.literal("reasoning"), text: z.string() }),
z.object({
type: z.literal("done"),
conversationId: z.string(),
messageId: z.string(),
usage: coachUsageSchema.optional(),
}),
z.object({
type: z.literal("error"),
code: z.string(),
message: z.string(),
reason: z.enum(AI_UNAVAILABLE_REASONS).optional(),
}),
z.object({ type: z.literal("step"), step: coachStepSchema }),
z.object({ type: z.literal("result"), result: coachResultTableSchema }),
z.object({
type: z.literal("followUps"),
followUps: z.array(coachFollowUpSchema),
}),
z.object({
type: z.literal("clarification"),
clarification: coachClarificationSchema,
}),
])
.meta({
id: "CoachStreamEvent",
description:
"One Server-Sent Events frame of a Coach turn, sent as `data: <json>\\n\\n` and dispatched on `type`. Order: `step*` → `token*` → `provenance` → `result*` → `suggestion?` → `suggestedAction?` → `clarification?` → `followUps?` → `done`, or a single `error`. Clients ignore a `type` they do not know.",
});
+38
View File
@@ -98,6 +98,44 @@ export const getCycleArgsSchema = z.object({}).strict();
export const getCorrelationsArgsSchema = z.object({}).strict();
/**
* v1.39.4 — each tool's argument schema, keyed by name. The record type makes
* a new tool name fail to compile until its schema is listed here.
*/
const COACH_TOOL_ARG_SCHEMAS: Record<CoachToolName, z.ZodType> = {
get_metric_series: getMetricSeriesArgsSchema,
get_glucose_panel: getGlucosePanelArgsSchema,
get_sleep: getSleepArgsSchema,
get_medication_compliance: getMedicationComplianceArgsSchema,
get_labs: getLabsArgsSchema,
get_illness_recovery: getIllnessRecoveryArgsSchema,
get_workouts: getWorkoutsArgsSchema,
get_cycle: getCycleArgsSchema,
get_correlations: getCorrelationsArgsSchema,
};
/**
* v1.39.4 — a call's arguments as its schema validates them, or undefined
* when the name is unknown, the JSON does not parse, or the schema refuses
* it. Only this validated form ever rides the tool trace; the model's raw
* argument string never does.
*/
export function parseCoachToolArgs(
name: string,
rawArguments: string,
): Record<string, unknown> | undefined {
if (!isCoachToolName(name)) return undefined;
let raw: unknown;
try {
raw = rawArguments.trim() === "" ? {} : JSON.parse(rawArguments);
} catch {
return undefined;
}
const parsed = COACH_TOOL_ARG_SCHEMAS[name].safeParse(raw);
if (!parsed.success) return undefined;
return parsed.data as Record<string, unknown>;
}
/**
* JSON-Schema parameter shapes handed to the provider. Kept hand-written
* (rather than generated from Zod) so the wire description the model reads is
+6
View File
@@ -99,6 +99,12 @@ export interface CoachToolResult {
export interface CoachToolTrace {
name: string;
present: boolean;
/**
* v1.39.4 — the call's arguments as their schema validated them; absent
* when they did not validate. Turn-internal (steps, method, chips): never
* persisted, the provenance keeps `{ name, present }` only.
*/
args?: Record<string, unknown>;
}
function pickSection(
+1 -1
View File
@@ -6,7 +6,7 @@
* figures, and a bounded loop that executes them. The legacy snapshot path
* stays alive as the no-tools fallback (per-provider `supportsTools`).
*/
export { COACH_TOOL_DEFS } from "./definitions";
export { COACH_TOOL_DEFS, parseCoachToolArgs } from "./definitions";
export type { CoachToolTrace } from "./executor";
export {
buildCoachDataInventory,
+62 -2
View File
@@ -26,9 +26,15 @@
import { annotate } from "@/lib/logging/context";
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import type { AiMessage, AiToolDef, CompletionResult } from "@/lib/ai/types";
import type {
AiMessage,
AiToolCall,
AiToolDef,
CompletionResult,
} from "@/lib/ai/types";
import type { ProviderHealthLedger } from "@/lib/ai/provider-health-ledger";
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
import { parseCoachToolArgs, type CoachToolName } from "./definitions";
import {
executeCoachTool,
type CoachToolResult,
@@ -81,6 +87,18 @@ export interface CoachToolLoopResult {
* one honest sentence the fix exists to make sayable.
*/
toolResults: CoachToolResult[];
/**
* v1.39.4 — true when the loop reached `HARD_CAP`: the model asked for
* tools in every round it was offered them, so the final answer was forced
* with `toolChoice: "none"` rather than given freely.
*/
forcedFinal: boolean;
}
/** v1.39.4 — a tool call to run before the first model round. */
interface CoachSeedCall {
name: CoachToolName;
args: Record<string, unknown>;
}
export async function runCoachToolLoop(args: {
@@ -108,6 +126,26 @@ export async function runCoachToolLoop(args: {
* shared default holds.
*/
timeoutMs?: number;
/**
* v1.39.4 — fired as each tool call starts. `index` counts calls across
* the whole turn from 0, so it is stable for a step id. A throwing callback
* is swallowed: progress reporting never breaks the loop.
*/
onCallStart?: (call: AiToolCall, index: number) => void;
/**
* v1.39.4 — fired once for every call that started, when its result is in:
* found, missed, invalid or failed alike (`executeCoachTool` never throws).
*/
onCallSettled?: (
call: AiToolCall,
result: CoachToolResult,
index: number,
) => void;
/**
* v1.39.4 — calls to run before round one, reusing the loop's own
* assistant-call and tool-result serialisation. Accepted, not read yet.
*/
seedCalls?: ReadonlyArray<CoachSeedCall>;
}): Promise<CoachToolLoopResult> {
const {
userId,
@@ -121,6 +159,8 @@ export async function runCoachToolLoop(args: {
ledger,
signal,
timeoutMs,
onCallStart,
onCallSettled,
} = args;
const messages: AiMessage[] = [...args.messages];
@@ -130,6 +170,7 @@ export async function runCoachToolLoop(args: {
let workingProviderType = "";
const toolTrace: CoachToolTrace[] = [];
const toolResults: CoachToolResult[] = [];
let callCount = 0;
// Round budget: rounds 1..HARD_CAP. On the last allowed round we forbid tool
// calls so the model must answer.
@@ -184,6 +225,7 @@ export async function runCoachToolLoop(args: {
rounds,
toolTrace,
toolResults,
forcedFinal: isForcedFinal,
};
}
@@ -197,6 +239,9 @@ export async function runCoachToolLoop(args: {
const results = await Promise.all(
calls.map(async (call) => {
const index = callCount;
callCount += 1;
notify(() => onCallStart?.(call, index));
const toolResult = await executeCoachTool({
userId,
name: call.name,
@@ -204,7 +249,13 @@ export async function runCoachToolLoop(args: {
fallbackWindow,
sharedScope,
});
toolTrace.push({ name: call.name, present: toolResult.present });
notify(() => onCallSettled?.(call, toolResult, index));
const validArgs = parseCoachToolArgs(call.name, call.arguments);
toolTrace.push({
name: call.name,
present: toolResult.present,
...(validArgs ? { args: validArgs } : {}),
});
// v1.21.0 (P6) — retain the payloads that carried figures for the
// post-hoc prose number-verifier (the union of numeric leaves grounds
// the figures the model may cite): a present result's `data`, or the
@@ -230,3 +281,12 @@ export async function runCoachToolLoop(args: {
// defensive guard.
throw new Error("coach tool loop exceeded hard cap without a final answer");
}
/** Run a progress callback; a throw from it never reaches the loop. */
function notify(callback: () => void): void {
try {
callback();
} catch {
// Progress is best-effort; the answer is not.
}
}
@@ -0,0 +1,187 @@
/**
* v1.39.4 — the dialog frames go out in the documented order, after the
* provenance, and a blocked turn carries none of the tables, chips or
* clarification.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(),
createConversation: vi.fn(),
}));
import type {
CoachClarification,
CoachFollowUp,
CoachResultTable,
CoachStreamEvent,
} from "@/lib/ai/coach/types";
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
import { emitReply } from "../sse";
import { assembleTurnDialog, buildTurnProvenance } from "../provenance";
import type { ModelOutcome } from "../model";
import type { GuardedReply } from "../reply-guards";
const TABLE: CoachResultTable = {
ref: "r1",
source: {
tool: "get_metric_series",
domain: "bp",
window: "last30days",
period: "current",
},
shape: "timeSeries",
titleKey: "coach.result.title.metricByPeriod",
title: "Blood pressure by day",
rowCount: 1,
chartKind: null,
displayed: false,
columns: [],
rows: [["2026-09-01", 128]],
truncated: false,
chart: null,
};
const CHIP: CoachFollowUp = {
id: "f1",
kind: "as_table",
labelKey: "coach.followUp.asTable",
label: "Show as a table",
reuse: true,
origin: "server",
};
const CLARIFY: CoachClarification = {
kind: "window",
choices: [],
freeText: true,
};
function collect() {
const frames: CoachStreamEvent[] = [];
return {
frames,
emitter: {
emit: (f: CoachStreamEvent) => frames.push(f),
aborted: () => false,
},
};
}
describe("emitReply — dialog frames", () => {
it("orders token, provenance, result, cards, clarification, followUps, done", async () => {
const { frames, emitter } = collect();
await emitReply(
emitter,
{
ok: true,
replyText: "Steady.",
provenance: { windows: [], metrics: [] },
suggestion: { cadenceId: "c", measurementType: "BP", label: "l" },
action: null,
results: [TABLE],
followUps: [CHIP],
clarification: CLARIFY,
messageId: "m1",
totalTokens: 10,
model: "x",
},
"c1",
);
expect(frames.map((f) => f.type)).toEqual([
"token",
"provenance",
"result",
"suggestion",
"clarification",
"followUps",
"done",
]);
});
it("emits no dialog frame when the turn has none", async () => {
const { frames, emitter } = collect();
await emitReply(
emitter,
{
ok: true,
replyText: "Steady.",
provenance: { windows: [], metrics: [] },
suggestion: null,
action: null,
results: [],
followUps: [],
clarification: null,
messageId: "m1",
totalTokens: 0,
model: null,
},
"c1",
);
expect(frames.map((f) => f.type)).toEqual(["token", "provenance", "done"]);
});
});
describe("assembleTurnDialog", () => {
const model = {
ok: true,
steps: [],
results: [TABLE],
forcedFinal: false,
inventory: null,
} as unknown as Extract<ModelOutcome, { ok: true }>;
const reply = (blocked: boolean): GuardedReply =>
({
outboundBlocked: blocked,
referencedResults: ["r1"],
followUpProposals: [],
clarification: blocked ? null : CLARIFY,
keyValuesSentinel: {
keyValues: [],
malformed: false,
malformedEntries: [],
},
groundedFigures: [],
unverifiedStripped: 0,
}) as unknown as GuardedReply;
it("marks a referenced table displayed and keeps its values out of the provenance", () => {
const dialog = assembleTurnDialog({
model,
reply: reply(false),
prefs: DEFAULT_COACH_PREFS,
locale: "en",
});
expect(dialog.results).toEqual([{ ...TABLE, displayed: true }]);
const provenance = buildTurnProvenance({
snapshotProvenance: { windows: [], metrics: [] },
reply: reply(false),
suggestion: null,
action: null,
toolTrace: [],
steps: [],
dialog,
forcedFinal: true,
});
expect(provenance.results?.[0]).not.toHaveProperty("rows");
expect(provenance.results?.[0]?.displayed).toBe(true);
expect(provenance.clarification).toEqual(CLARIFY);
expect(provenance.forcedFinal).toBe(true);
expect(JSON.stringify(provenance)).not.toContain("128");
});
it("drops tables, chips and clarification on a blocked turn", () => {
const dialog = assembleTurnDialog({
model,
reply: reply(true),
prefs: DEFAULT_COACH_PREFS,
locale: "en",
});
expect(dialog).toEqual({
results: [],
method: null,
followUps: [],
clarification: null,
});
});
});
+12
View File
@@ -0,0 +1,12 @@
/**
* Prompt rules for clarifying questions: ask only when the metric or window
* stays genuinely ambiguous, at most one question, never about doses or
* diagnoses.
*
* Not written yet: empty.
*/
import type { Locale } from "@/lib/i18n/config";
export function clarifyAddendum(_locale: Locale): string {
return "";
}
@@ -0,0 +1,10 @@
/**
* Prompt rules for proposing follow-up chips from the catalog.
*
* Not written yet: empty.
*/
import type { Locale } from "@/lib/i18n/config";
export function followUpsAddendum(_locale: Locale): string {
return "";
}
+23
View File
@@ -0,0 +1,23 @@
/**
* The dialog addenda appended to the tool-mode system prompt: result
* tables, clarification, the method line, follow-up chips. Each lives in its
* own file; an empty one adds nothing, so a turn with every addendum empty
* sends the prompt byte for byte as before.
*/
import type { Locale } from "@/lib/i18n/config";
import { clarifyAddendum } from "./clarify";
import { followUpsAddendum } from "./followups";
import { methodAddendum } from "./method";
import { resultsAddendum } from "./results";
export function buildDialogAddenda(locale: Locale): string {
return [
resultsAddendum(locale),
clarifyAddendum(locale),
methodAddendum(locale),
followUpsAddendum(locale),
]
.filter((block) => block.length > 0)
.join("\n\n");
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Prompt rules for the method line and for rechecking a figure the person
* questions.
*
* Not written yet: empty.
*/
import type { Locale } from "@/lib/i18n/config";
export function methodAddendum(_locale: Locale): string {
return "";
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Prompt rules for result tables: when to fetch a table, how to reference
* one, and when a stored table answers without a new fetch.
*
* Not written yet: empty.
*/
import type { Locale } from "@/lib/i18n/config";
export function resultsAddendum(_locale: Locale): string {
return "";
}
+111 -6
View File
@@ -23,14 +23,24 @@ import {
renderDataInventory,
renderFocusHint,
buildToolModeAddendum,
parseCoachToolArgs,
runCoachToolLoop,
type CoachToolTrace,
} from "@/lib/ai/coach/tools";
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
import type { CoachResultTable, CoachStep } from "@/lib/ai/coach/types";
import {
projectResults,
type SettledToolCall,
} from "@/lib/ai/coach/results/project";
import { deriveChartSpec } from "@/lib/ai/coach/results/chart-spec";
import { buildDialogAddenda } from "./addenda";
import { refundReservation, type TurnReservation } from "./budget";
import type { TurnChain } from "./chain";
import type { TurnContext } from "./context";
import { classifyBubblingProviderError } from "./errors";
import { snapshotStep, toStep } from "./steps";
import type { TurnEmitter } from "./types";
export type ModelOutcome =
@@ -77,9 +87,56 @@ export type ModelOutcome =
* to the daily meter).
*/
cachedTokens: number;
/**
* v1.39.4 — the steps this turn read, in first-seen order: the tool
* calls on the tool path, one snapshot step on the no-tools path.
* Already streamed as `step` frames; persisted on the provenance.
*/
steps: CoachStep[];
/**
* v1.39.4 — the tables this turn's tool results project to, with their
* charts. Not streamed here: they go out after the reply guards, and
* not at all on a blocked turn.
*/
results: CoachResultTable[];
/** v1.39.4 — the loop forced the answer at its round cap. */
forcedFinal: boolean;
/** v1.39.4 — the DATA INVENTORY entries; null on the no-tools path. */
inventory: InventoryEntry[] | null;
}
| { ok: false; code: string };
/**
* v1.39.4 — the turn's steps, upserted by id and streamed as they change.
* A frame is never written to a stream the client already closed.
*/
function stepRecorder(emitter: TurnEmitter): {
record(step: CoachStep | null): void;
list(): CoachStep[];
} {
const byId = new Map<string, CoachStep>();
return {
record(step) {
if (!step) return;
byId.set(step.id, step);
if (!emitter.aborted()) emitter.emit({ type: "step", step });
},
list: () => [...byId.values()],
};
}
/** v1.39.4 — a projected table with the chart the server chose for it. */
function withChart(table: CoachResultTable): CoachResultTable {
const chart = deriveChartSpec(table);
return { ...table, chart, chartKind: chart?.kind ?? null };
}
/** v1.39.4 — server-authored lines appended to the system prompt. */
function appendBlocks(base: string, blocks: string[]): string {
const extra = blocks.filter((block) => block.length > 0);
return extra.length > 0 ? [base, ...extra].join("\n\n") : base;
}
export async function runTurnModel(args: {
userId: string;
locale: Locale;
@@ -89,14 +146,18 @@ export async function runTurnModel(args: {
chain: TurnChain;
toolMode: boolean;
reservation: TurnReservation;
/**
* The turn's frame channel. Not written to yet: the model step is where
* live progress frames will originate.
*/
/** The turn's frame channel; the live `step` frames go out on it. */
emitter: TurnEmitter;
/**
* v1.39.4 — server-authored context lines for this turn (a resolved
* follow-up chip, an answered clarification). Empty on a plain turn,
* which then sends its prompt unchanged.
*/
turnHints: string[];
}): Promise<ModelOutcome> {
const { userId, locale, signal, conversationId, ctx, chain, toolMode } = args;
const { effectiveScope, workoutEvidence, turnContext, snapshot } = ctx;
const steps = stepRecorder(args.emitter);
try {
if (toolMode) {
// v1.20.0 (F1) — base context: the full system prompt + a tool-mode
@@ -108,7 +169,10 @@ export async function runTurnModel(args: {
const inventory = await buildCoachDataInventory(userId, effectiveScope);
const toolRequest = buildCoachToolRequest({
systemPrompt: ctx.systemPrompt,
toolModeAddendum: buildToolModeAddendum(locale),
toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [
buildDialogAddenda(locale),
...args.turnHints,
]),
focusHint: renderFocusHint(effectiveScope?.sources),
workoutEvidence,
dataInventory: renderDataInventory(inventory),
@@ -116,6 +180,9 @@ export async function runTurnModel(args: {
transcript: turnContext.transcript,
languageName: LANGUAGE_NAMES[locale],
});
// v1.39.4 — every call this turn settled, by its turn-wide index, for
// the result tables. Only this turn's own calls ever reach them.
const settled: SettledToolCall[] = [];
const loop = await runCoachToolLoop({
userId,
providers: chain,
@@ -135,6 +202,26 @@ export async function runTurnModel(args: {
signal,
// v1.22 (#89) — per-user response timeout for each tool-round call.
timeoutMs: ctx.aiResponseTimeoutMs,
// v1.39.4 — live steps: a `running` step as each call starts, its
// final status as it settles.
onCallStart: (call, index) =>
steps.record(
toStep({
call,
index,
parsedArgs: parseCoachToolArgs(call.name, call.arguments),
locale,
}),
),
onCallSettled: (call, result, index) => {
const parsedArgs = parseCoachToolArgs(call.name, call.arguments);
settled[index] = {
name: call.name,
...(parsedArgs ? { args: parsedArgs } : {}),
result,
};
steps.record(toStep({ call, index, parsedArgs, result, locale }));
},
});
// v1.32.1 — the numeric verifier ACTIVATES only when this turn actually
// delivered figures the model was told to ground against: a pinned
@@ -171,6 +258,13 @@ export async function runTurnModel(args: {
inventoryPayloads: [inventory.entries],
totalTokens: loop.totalTokens,
cachedTokens: loop.cachedTokens,
steps: steps.list(),
results: projectResults({
calls: settled.filter((call) => call !== undefined),
locale,
}).map(withChart),
forcedFinal: loop.forcedFinal === true,
inventory: inventory.entries,
};
}
// v1.22 (#89) — the no-tools path (local / Ollama / exo, and any chain
@@ -180,6 +274,13 @@ export async function runTurnModel(args: {
// heartbeat keeps the proxy connection warm and the assembled reply is
// returned in full so every guard still runs on the complete text.
let streamedDeltas = 0;
// v1.39.4 — the no-tools path reads the whole snapshot: one step.
steps.record(
snapshotStep({
metricCount: snapshot.provenance.metrics.length,
locale,
}),
);
const fallback = await runStreamingRawCompletionWithFallback({
surface: "coach",
userId,
@@ -192,7 +293,7 @@ export async function runTurnModel(args: {
// on every stateless no-tools request), so it ships as a single user
// message. The stable persona rides `system` and is cache-eligible.
params: singleUserTurn({
system: ctx.systemPrompt,
system: appendBlocks(ctx.systemPrompt, args.turnHints),
user: ctx.userPrompt,
temperature: AI_BUDGETS.coach.temperature,
maxTokens: AI_BUDGETS.coach.maxTokens,
@@ -228,6 +329,10 @@ export async function runTurnModel(args: {
inventoryPayloads: [],
totalTokens: result.tokensUsed ?? 0,
cachedTokens: result.cachedInputTokens ?? 0,
steps: steps.list(),
results: [],
forcedFinal: false,
inventory: null,
};
} catch (err) {
// The provider chain failed outright — no tokens were billed, so refund
+5 -1
View File
@@ -4,7 +4,7 @@
import { annotate } from "@/lib/logging/context";
import type { ProviderChainType } from "@/lib/ai/provider-chain";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
import type { CoachProvenance } from "@/lib/ai/coach/types";
import type { CoachProvenance, CoachResultTable } from "@/lib/ai/coach/types";
import { appendMessage } from "@/lib/ai/coach/persistence";
import type { TurnContext } from "./context";
@@ -17,6 +17,8 @@ export async function persistAssistantReply(args: {
model: Extract<ModelOutcome, { ok: true }>;
ctx: TurnContext;
toolMode: boolean;
/** v1.39.4 — the tables to seal beside the message. */
results: CoachResultTable[];
}): Promise<{ messageId: string }> {
const { conversationId, replyText, provenance, model, ctx, toolMode } = args;
const workingProviderType: ProviderChainType = model.workingProviderType;
@@ -38,6 +40,8 @@ export async function persistAssistantReply(args: {
// reflects the true turn cost on the tool path too.
tokensUsed: totalTokensSpent || null,
model: model.result.model ?? null,
// v1.39.4 — the tables, encrypted into their own column.
...(args.results.length > 0 ? { results: args.results } : {}),
});
// v1.18.7 — the day's spend was already reconciled against the
+45 -1
View File
@@ -19,10 +19,14 @@ import { resolveTurnChain } from "./chain";
import { assembleTurnContext } from "./context";
import { persistUserTurn, resolveTurnConversation } from "./conversation";
import { handleProducerFailure } from "./errors";
import { resolveClarificationAnswer } from "@/lib/ai/coach/clarify";
import { resolveFollowUp } from "@/lib/ai/coach/follow-ups/resolve";
import { runTurnModel } from "./model";
import { persistAssistantReply } from "./persist";
import { buildTurnProvenance } from "./provenance";
import { assembleTurnDialog, buildTurnProvenance } from "./provenance";
import { guardReply } from "./reply-guards";
import { runReuseTurn } from "./reuse-turn";
import {
SSE_HEADERS,
createTurnEmitter,
@@ -45,6 +49,32 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
const conversation = resolved.conversation;
const workingConversationId = conversation.conversationId;
// v1.39.4 — a tapped chip or an answered question. Resolved against what
// the server persisted, never against what the client says the chip was.
// A reuse chip is answered from a stored table without a model call.
const resolvedFollowUp = await resolveFollowUp({
userId,
conversationId: input.conversationId,
followUp: input.followUp,
});
if (resolvedFollowUp?.followUp.reuse) {
const reused = await runReuseTurn({
input,
conversation,
resolved: resolvedFollowUp,
});
if (reused) return reused;
}
const clarifiedLine = await resolveClarificationAnswer({
userId,
conversationId: input.conversationId,
clarification: input.clarification,
});
const turnHints = [
...(resolvedFollowUp?.contextHint ? [resolvedFollowUp.contextHint] : []),
...(clarifiedLine ? [clarifiedLine] : []),
];
await persistUserTurn(workingConversationId, message);
const ctx = await assembleTurnContext({
@@ -95,6 +125,7 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
toolMode,
reservation,
emitter,
turnHints,
});
if (!model.ok) return model;
@@ -120,12 +151,21 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
coachPrefs: ctx.coachPrefs,
reply,
});
const dialog = assembleTurnDialog({
model,
reply,
prefs: ctx.coachPrefs,
locale,
});
const provenance = buildTurnProvenance({
snapshotProvenance: ctx.snapshot.provenance,
reply,
suggestion: cards.suggestion,
action: cards.action,
toolTrace: model.toolTrace,
steps: model.steps,
dialog,
forcedFinal: model.forcedFinal,
});
const { messageId } = await persistAssistantReply({
conversationId: workingConversationId,
@@ -134,6 +174,7 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
model,
ctx,
toolMode,
results: dialog.results,
});
return {
@@ -142,6 +183,9 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
provenance,
suggestion: cards.suggestion,
action: cards.action,
results: dialog.results,
followUps: dialog.followUps,
clarification: dialog.clarification,
messageId,
totalTokens: model.totalTokens,
model: model.result.model ?? null,
+108 -3
View File
@@ -1,24 +1,119 @@
/**
* The provenance envelope a Coach reply is persisted and streamed with:
* what the snapshot covered, plus what this turn added (key values, cards,
* the tool trace, the grounded figures, the withheld-figure count).
* the tool trace, the grounded figures, the withheld-figure count, and the
* dialog: steps, tables, method, chips, clarification).
*/
import { annotate } from "@/lib/logging/context";
import type { Locale } from "@/lib/i18n/config";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
import type { CoachProvenance, CoachSuggestion } from "@/lib/ai/coach/types";
import type {
CoachClarification,
CoachFollowUp,
CoachMethod,
CoachProvenance,
CoachResultMeta,
CoachResultTable,
CoachStep,
CoachSuggestion,
} from "@/lib/ai/coach/types";
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
import type { CoachToolTrace } from "@/lib/ai/coach/tools";
import { buildMethod } from "@/lib/ai/coach/method";
import { deriveFollowUps } from "@/lib/ai/coach/follow-ups/derive";
import { buildContinueFollowUp } from "@/lib/ai/coach/follow-ups/continue";
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
import type { ModelOutcome } from "./model";
import type { GuardedReply } from "./reply-guards";
/** v1.39.4 — at most this many chips under a reply. */
const MAX_FOLLOW_UPS = 3;
/** What the dialog adds to a guarded reply. */
export interface TurnDialog {
/** The tables to stream and persist; empty on a blocked turn. */
results: CoachResultTable[];
method: CoachMethod | null;
/** Empty on a blocked turn. */
followUps: CoachFollowUp[];
/** Null on a blocked turn. */
clarification: CoachClarification | null;
}
/** A table's metadata: everything but the values, for the plaintext blob. */
function toResultMeta(table: CoachResultTable): CoachResultMeta {
return {
ref: table.ref,
source: table.source,
shape: table.shape,
titleKey: table.titleKey,
title: table.title,
rowCount: table.rowCount,
chartKind: table.chartKind,
displayed: table.displayed,
...(table.reusedFrom ? { reusedFrom: table.reusedFrom } : {}),
};
}
/**
* v1.39.4 — the dialog of a guarded reply. On an outbound block the tables,
* chips and clarification are dropped, the way key values already are: the
* reply they belonged to was replaced.
*/
export function assembleTurnDialog(args: {
model: Extract<ModelOutcome, { ok: true }>;
reply: GuardedReply;
prefs: CoachPrefs;
locale: Locale;
}): TurnDialog {
const { model, reply, prefs, locale } = args;
const blocked = reply.outboundBlocked;
const referenced = new Set(reply.referencedResults);
const results = blocked
? []
: model.results.map((table) =>
referenced.has(table.ref) ? { ...table, displayed: true } : table,
);
const metas = results.map(toResultMeta);
const method = buildMethod({ steps: model.steps, results: metas, locale });
const continueChip = blocked
? null
: buildContinueFollowUp({ forcedFinal: model.forcedFinal, locale });
const followUps = blocked
? []
: [
...(continueChip ? [continueChip] : []),
...deriveFollowUps({
results: metas,
steps: model.steps,
inventory: model.inventory,
proposals: reply.followUpProposals,
forcedFinal: model.forcedFinal,
prefs,
locale,
}),
].slice(0, MAX_FOLLOW_UPS);
return {
results,
method,
followUps,
clarification: reply.clarification,
};
}
export function buildTurnProvenance(args: {
snapshotProvenance: CoachProvenance;
reply: GuardedReply;
suggestion: CoachSuggestion | null;
action: CoachSuggestedAction | null;
toolTrace: CoachToolTrace[];
/** v1.39.4 — the steps the turn read; kept on a blocked turn too. */
steps: CoachStep[];
dialog: TurnDialog;
forcedFinal: boolean;
}): CoachProvenance {
const { snapshotProvenance, reply, toolTrace } = args;
const { snapshotProvenance, reply, toolTrace, steps, dialog } = args;
const surfacedSuggestion = args.suggestion;
const surfacedAction = args.action;
const sentinel = reply.keyValuesSentinel;
@@ -58,6 +153,16 @@ export function buildTurnProvenance(args: {
...(unverifiedStripped > 0
? { unverifiedFigures: unverifiedStripped }
: {}),
// v1.39.4 — the dialog. Metadata only: a table's values are in the
// encrypted column, never in this plaintext envelope.
...(steps.length > 0 ? { steps } : {}),
...(dialog.method ? { method: dialog.method } : {}),
...(dialog.results.length > 0
? { results: dialog.results.map(toResultMeta) }
: {}),
...(dialog.followUps.length > 0 ? { followUps: dialog.followUps } : {}),
...(dialog.clarification ? { clarification: dialog.clarification } : {}),
...(args.forcedFinal ? { forcedFinal: true as const } : {}),
};
if (sentinel.malformed) {
// Graceful degrade: log so ops can spot a provider whose
+33
View File
@@ -29,6 +29,10 @@ import {
captureReminderFromSentinel,
} from "@/lib/ai/coach/reminders";
import { parseSuggestAction } from "@/lib/ai/coach/suggest-action";
import { parseClarifySentinel } from "@/lib/ai/coach/clarify";
import { parseFollowUpsSentinel } from "@/lib/ai/coach/follow-ups/parse-sentinel";
import { stripResultRefs } from "@/lib/ai/coach/results/refs";
import type { CoachClarification } from "@/lib/ai/coach/types";
import type { TurnContext } from "./context";
import type { ModelOutcome } from "./model";
@@ -43,6 +47,15 @@ export interface GuardedReply {
outboundBlocked: boolean;
groundedFigures: number[];
unverifiedStripped: number;
/** v1.39.4 — the refs (`r1`..) the prose marked as the tables it uses. */
referencedResults: string[];
/** v1.39.4 — the chip kinds and domains the model proposed. */
followUpProposals: ReturnType<typeof parseFollowUpsSentinel>["proposals"];
/**
* v1.39.4 — the validated clarification, when this reply asks one. Null
* when it does not, and on a blocked turn (the question was replaced).
*/
clarification: CoachClarification | null;
}
export async function guardReply(args: {
@@ -131,6 +144,21 @@ export async function guardReply(args: {
const actionParse = parseSuggestAction(replyText);
replyText = actionParse.prose.trim() || replyText;
// v1.39.4 — the dialog sentinels and marks, stripped before the safety
// screen and the number check read the prose: the optional
// `---FOLLOWUPS---` proposal, the optional `---CLARIFY---` block, and the
// `result:rN` marks (whose digits must never reach the number check).
const followUpsParse = parseFollowUpsSentinel(replyText);
replyText = followUpsParse.prose.trim() || replyText;
const clarifyParse = parseClarifySentinel({
prose: replyText,
inventory: model.inventory,
locale,
});
replyText = clarifyParse.prose.trim() || replyText;
const resultRefs = stripResultRefs(replyText);
replyText = resultRefs.prose.trim() || replyText;
// v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant
// reply, before persistence and streaming. The inbound `detectRefusal`
// guards the user's message; this guards the model's reply for a
@@ -333,6 +361,11 @@ export async function guardReply(args: {
outboundBlocked: outbound.block,
groundedFigures,
unverifiedStripped,
referencedResults: resultRefs.referenced,
followUpProposals: followUpsParse.proposals,
// A blocked turn carries the fallback prose, so a question it asked is
// gone and its choices must not ride along.
clarification: outbound.block ? null : clarifyParse.clarification,
},
};
}
+20
View File
@@ -0,0 +1,20 @@
/**
* A model-free turn: a reuse chip ("as a chart", "as a table") answered from
* a table already stored on the conversation. No budget reservation and no
* provider call; the capability and the rate limit still apply at the route.
* Streams `step → token (caption) → provenance → result → followUps → done`
* and persists an assistant message with `providerType: "reuse"`.
*
* Not built yet: answers null, and the turn runs as a plain model turn.
*/
import type { ResolvedFollowUp } from "@/lib/ai/coach/follow-ups/resolve";
import type { TurnConversation, TurnInput } from "./types";
export async function runReuseTurn(_args: {
input: TurnInput;
conversation: TurnConversation;
resolved: ResolvedFollowUp;
}): Promise<Response | null> {
return null;
}
+19 -3
View File
@@ -87,9 +87,10 @@ function flushTick(): Promise<void> {
/**
* Emit a finished turn. A failed outcome is one `error` frame. A guarded
* reply goes out as `token* → provenance → suggestion? → suggestedAction? →
* done`; the token frames carry the FULLY-GUARDED text, and every guard ran
* before the first one leaves.
* reply goes out as `token* → provenance → result* → suggestion? →
* suggestedAction? → clarification? → followUps? → done`; the token frames
* carry the FULLY-GUARDED text, and every guard ran before the first one
* leaves. The live `step` frames went out earlier, while the model ran.
*/
export async function emitReply(
emitter: TurnEmitter,
@@ -115,6 +116,11 @@ export async function emitReply(
}
if (emitter.aborted()) return;
emitter.emit({ type: "provenance", metricSource: outcome.provenance });
// v1.39.4 — additive `result` frames: the tables this turn read, after
// every guard and only on the owner's own stream.
for (const result of outcome.results) {
emitter.emit({ type: "result", result });
}
// v1.18.1 (Workstream C) — additive `suggestion` frame.
if (outcome.suggestion) {
emitter.emit({ type: "suggestion", suggestion: outcome.suggestion });
@@ -126,6 +132,16 @@ export async function emitReply(
suggestedAction: outcome.action,
});
}
// v1.39.4 — additive `clarification` and `followUps` frames.
if (outcome.clarification) {
emitter.emit({
type: "clarification",
clarification: outcome.clarification,
});
}
if (outcome.followUps.length > 0) {
emitter.emit({ type: "followUps", followUps: outcome.followUps });
}
// v1.18.9 — additive `usage` envelope on the `done` frame.
emitter.emit({
type: "done",
+41
View File
@@ -0,0 +1,41 @@
/**
* Live steps: one `CoachStep` per tool call, emitted as `step` frames while
* the turn runs and persisted on `metricSource.steps`.
*
* A step carries a catalog label key, the server-rendered label, the domain,
* the window and a server-counted number. Never free text, an analyte name
* or a health value.
*
* Not built yet: both functions answer null, so a turn emits no step frame.
*/
import type { Locale } from "@/lib/i18n/config";
import type { AiToolCall } from "@/lib/ai/types";
import type { CoachStep } from "@/lib/ai/coach/types";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
/**
* The step for one tool call: `running` when `result` is absent (the call
* just started), its final status once the result is in. `index` counts
* calls across the turn from 0; `parsedArgs` are the schema-validated
* arguments, absent when they did not validate.
*/
export function toStep(_args: {
call: AiToolCall;
index: number;
parsedArgs: Record<string, unknown> | undefined;
result?: CoachToolResult;
locale: Locale;
}): CoachStep | null {
return null;
}
/**
* The single step a no-tools turn shows: the full snapshot, with the number
* of metrics it covered.
*/
export function snapshotStep(_args: {
metricCount: number;
locale: Locale;
}): CoachStep | null {
return null;
}
+13
View File
@@ -15,7 +15,10 @@ import type { Locale } from "@/lib/i18n/config";
import type { CoachTurn } from "@/lib/ai/coach/chat-request-builder";
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
import type {
CoachClarification,
CoachFollowUp,
CoachProvenance,
CoachResultTable,
CoachScope,
CoachStreamEvent,
CoachSuggestion,
@@ -32,6 +35,10 @@ export interface TurnInput {
scope: CoachScope | undefined;
guidedQuestion: string | undefined;
workoutId: string | undefined;
/** v1.39.4 — the follow-up chip this message came from, if any. */
followUp: { messageId: string; id: string } | undefined;
/** v1.39.4 — the clarifying question this message answers, if any. */
clarification: { messageId: string; choiceId?: string } | undefined;
/**
* The `coach` capability gate, re-run at the egress site. Owned by the
* route (the capability inventory reads it there); returns the refusal
@@ -80,6 +87,12 @@ export type ReplyOutcome =
provenance: CoachProvenance;
suggestion: CoachSuggestion | null;
action: CoachSuggestedAction | null;
/** v1.39.4 — tables streamed after the provenance, owner only. */
results: CoachResultTable[];
/** v1.39.4 — the chips under the reply. */
followUps: CoachFollowUp[];
/** v1.39.4 — the choices, when the reply is a question. */
clarification: CoachClarification | null;
messageId: string;
totalTokens: number;
model: string | null;
+261
View File
@@ -10,6 +10,7 @@ import { z } from "zod/v4";
import type { AiUnavailableReason } from "@/lib/ai/capabilities/types";
import type { CoachSuggestedAction } from "./suggest-action";
import type { CoachToolName } from "./tools/definitions";
/**
* Chat-message role. Stored as a free-form string column server-side
@@ -157,6 +158,31 @@ export const coachChatRequestSchema = z.object({
* turn, so per-turn work does not grow with conversation length.
*/
workoutId: z.string().max(64).optional(),
/**
* v1.39.4 — the person tapped a follow-up chip. `messageId` is the
* assistant message that offered it and `id` the chip on that message
* (`f1`..`f3`). The server resolves the chip from what it persisted on
* the conversation's latest assistant message; the client never sends
* what the chip asks for, only which chip it was. A chip that is no
* longer current degrades to a plain message.
*/
followUp: z
.object({
messageId: z.string().min(1).max(64),
id: z.string().min(1).max(8),
})
.optional(),
/**
* v1.39.4 — the person answered a clarifying question. `messageId` is
* the assistant message that asked it; `choiceId` the chip they tapped,
* absent when they typed their own answer instead.
*/
clarification: z
.object({
messageId: z.string().min(1).max(64),
choiceId: z.string().min(1).max(8).optional(),
})
.optional(),
});
/**
@@ -212,6 +238,21 @@ export interface CoachUsage {
* inside the thinking disclosure when a reasoning-capable provider
* emits a cheap reasoning summary. Providers without reasoning emit
* none, and the disclosure falls back to its elapsed-time label.
*
* v1.39.4 — four additive frames: `step` (live progress while the Coach
* reads the record), `result` (a table of the values it read, owner only),
* `followUps` (chips under the latest reply) and `clarification` (a
* question with choices). Frame order on a turn:
*
* step* → token* → provenance → result* → suggestion? →
* suggestedAction? → clarification? → followUps? → done
*
* No new frame carries a top-level key the older native client decodes
* (`token`, `conversationId`, `messageId`, `code`, `message`, `suggestion`,
* `metricSource`, `usage`), so an older client drops them whole.
* `src/__tests__/coach-stream-ios-compat.test.ts` holds that rule, and the
* Zod mirror in `stream-events.ts` is held equal to this union by a type
* test.
*/
export type CoachStreamEvent =
| { type: "token"; token: string }
@@ -238,8 +279,205 @@ export type CoachStreamEvent =
* ignore it.
*/
reason?: AiUnavailableReason;
}
| { type: "step"; step: CoachStep }
| { type: "result"; result: CoachResultTable }
| { type: "followUps"; followUps: CoachFollowUp[] }
| { type: "clarification"; clarification: CoachClarification };
// ── Steps (v1.39.4) ─────────────────────────────────────────────────────
// What the Coach is reading while a turn runs. Labels come from a closed
// catalog; a step never carries free text, an analyte name, or a value.
export type CoachStepStatus = "running" | "done" | "empty" | "failed";
/**
* The data domain a step, result or chip is about. The measurement-backed
* scope sources plus the domains that are read as a whole.
*/
export type CoachStepDomain =
CoachScopeSource | "labs" | "illness" | "cycle" | "correlations" | "snapshot";
/** Why a step or a method entry found nothing. */
export type CoachStepReason =
| "no_data"
| "outside_window"
| "module_disabled"
| "retrieval_failed"
| "invalid_arguments";
export interface CoachStep {
/** `s1`..`s12`, unique within a turn; frames upsert by id. */
id: string;
tool: CoachToolName | "show_result" | "snapshot";
/** Closed catalog key, e.g. `coach.step.read`. */
labelKey: string;
/** The label rendered on the server in the request locale; the fallback. */
label: string;
domain?: CoachStepDomain;
window?: CoachScopeWindow;
period?: CoachResultPeriod;
granularity?: CoachResultGranularity;
status: CoachStepStatus;
/** Readings or rows the server counted. Never a health value. */
count?: number;
reason?: CoachStepReason;
/** `r1` when the step produced a table. */
resultRef?: string;
}
// ── Results (v1.39.4) ───────────────────────────────────────────────────
// A table of the values a turn read. The values are health data: encrypted
// at rest (`coach_messages.results_encrypted`) and sent only to the owner.
// The metadata alone rides the plaintext provenance.
export type CoachResultGranularity = "day" | "week" | "month";
export type CoachResultPeriod = "current" | "previous" | "yearAgo";
export type CoachResultShape =
"timeSeries" | "categoryCounts" | "distribution" | "single";
export interface CoachResultColumn {
key: string;
kind: "period" | "category" | "number" | "count";
labelKey: string;
label: string;
/** Display unit token (e.g. `mmHg`), never free text. */
unit?: string;
decimals?: number;
}
/** `null` is a period with no reading: absence stays explicit. */
export type CoachResultCell = string | number | null;
export type CoachChartSpec =
| { kind: "line"; x: string; series: string[] }
| {
kind: "bar";
x: string;
series: string[];
orientation: "vertical" | "horizontal";
}
| {
kind: "histogram";
column: string;
unit?: string;
bins: Array<{ from: number; to: number; count: number }>;
};
export interface CoachResultSource {
tool: CoachToolName;
domain: CoachStepDomain;
window: CoachScopeWindow;
period: CoachResultPeriod;
granularity?: CoachResultGranularity;
}
/** Plaintext; rides `metricSource.results`. */
export interface CoachResultMeta {
/** `r1`..`r6`, per message. */
ref: string;
source: CoachResultSource;
shape: CoachResultShape;
titleKey: string;
title: string;
/** The full row count, before any trim. */
rowCount: number;
chartKind: CoachChartSpec["kind"] | null;
/** The answer referenced it (shown expanded) or it sits under "Data used". */
displayed: boolean;
/** Set when the table was copied from an earlier message of the thread. */
reusedFrom?: { messageId: string; ref: string };
}
/** Encrypted at rest; on the wire only to the owner. */
export interface CoachResultTable extends CoachResultMeta {
columns: CoachResultColumn[];
/** Row-major, at most 400 rows. */
rows: CoachResultCell[][];
/** True when `rowCount` exceeds `rows.length`. */
truncated: boolean;
chart: CoachChartSpec | null;
}
/**
* One entry of `GET …/messages/{messageId}/results`: the table, or the
* reason it is not served. `module_disabled` — the domain's module is now
* off for the record; `unavailable` — the stored tables could not be read.
*/
export type CoachResultEntry =
| CoachResultTable
| { ref: string; withheld: "module_disabled" | "unavailable" };
// ── Method (v1.39.4) ────────────────────────────────────────────────────
// How the answer was reached: sources, windows, counts, aggregation. Never
// a health value.
export interface CoachMethodEntry {
domain: CoachStepDomain;
window?: CoachScopeWindow;
period?: CoachResultPeriod;
granularity?: CoachResultGranularity;
count?: number;
aggregation?: "mean" | "median" | "latest" | "sum" | "count" | "rate";
absent?: "no_data" | "outside_window" | "module_disabled";
}
export interface CoachMethod {
entries: CoachMethodEntry[];
/** Rendered on the server in the request locale. */
text: string;
}
// ── Follow-ups (v1.39.4) ────────────────────────────────────────────────
// Chips under the latest assistant reply. The label is always rendered by
// the server from a catalog; the model may at most pick a kind and domain.
export type CoachFollowUpKind =
| "widen_window"
| "previous_period"
| "year_ago"
| "as_chart"
| "as_table"
| "related_metric"
| "continue";
export interface CoachFollowUp {
/** `f1`..`f3`. */
id: string;
kind: CoachFollowUpKind;
labelKey: string;
label: string;
anchor?: {
ref: string;
domain: CoachStepDomain;
window?: CoachScopeWindow;
granularity?: CoachResultGranularity;
period?: CoachResultPeriod;
};
/** True when the chip is answered from a stored table, without a model. */
reuse: boolean;
origin: "server" | "model";
}
// ── Clarification (v1.39.4) ─────────────────────────────────────────────
// A question with choices. The question text is the assistant message
// itself, encrypted like any reply; only the choices ride here.
export interface CoachClarificationChoice {
id: string;
labelKey: string;
label: string;
value: { metric?: CoachScopeSource; window?: CoachScopeWindow };
}
export interface CoachClarification {
kind: "metric" | "window" | "context";
/** At most 4; metric choices are always ones the record holds. */
choices: CoachClarificationChoice[];
/** Whether a typed answer is welcome beside the choices. */
freeText: boolean;
}
/**
* v1.4.22 — Zod schema for one entry inside the Coach's evidence
* block. The model emits these as lines between the
@@ -399,6 +637,29 @@ export interface CoachProvenance {
* turn withheld nothing.
*/
unverifiedFigures?: number;
/**
* v1.39.4 — what the Coach read on this turn, one entry per tool call (or
* one `snapshot` step on the no-tools path). Catalog labels, domains,
* windows and counts only.
*/
steps?: CoachStep[];
/** v1.39.4 — how the answer was reached, as a server-rendered line. */
method?: CoachMethod;
/**
* v1.39.4 — the tables this turn produced, metadata only. The values are
* in `results_encrypted` and served by
* `GET /api/insights/chat/{id}/messages/{messageId}/results`.
*/
results?: CoachResultMeta[];
/** v1.39.4 — the chips offered under this reply. */
followUps?: CoachFollowUp[];
/** v1.39.4 — the choices, when this reply is a clarifying question. */
clarification?: CoachClarification;
/**
* v1.39.4 — the tool loop hit its round cap while the model still wanted
* to read, so the answer was forced. Absent otherwise.
*/
forcedFinal?: true;
}
/**
+2
View File
@@ -222,6 +222,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// ───── Coach (Bytes columns) ─────
{ model: "CoachMessage", field: "encryptedContent", kind: "bytes" },
// v1.39.4 — the tables of values a turn read, JSON under the same codec.
{ model: "CoachMessage", field: "resultsEncrypted", kind: "bytes" },
{ model: "CoachConversation", field: "summaryEncrypted", kind: "bytes" },
// v1.39.3 — the title: the opening words of the first message, or a rename.
{ model: "CoachConversation", field: "titleEncrypted", kind: "bytes" },
+40
View File
@@ -79,6 +79,17 @@ export interface CoachMessageBackupEntry {
promptVersion: string | null;
tokensUsed: number | null;
model: string | null;
/**
* v1.39.4 — the tables of values the turn read, as ciphertext in base64.
* Present on a disaster-recovery payload only; null on a turn without one.
*/
resultsEncrypted?: string | null;
/**
* v1.39.4 — the same tables as readable JSON (a `CoachResultTable[]`
* array), on a portable payload only; null on a turn without one. The
* unreadable-row marker stands in for tables that did not decrypt.
*/
resultsJson?: string | null;
createdAt: string;
}
@@ -155,6 +166,7 @@ const COACH_CONVERSATION_BACKUP_SELECT = {
promptVersion: true,
tokensUsed: true,
model: true,
resultsEncrypted: true,
createdAt: true,
},
},
@@ -248,6 +260,14 @@ export async function buildCoachBackupSection(
promptVersion: message.promptVersion,
tokensUsed: message.tokensUsed,
model: message.model,
// v1.39.4 — the tables follow the same contract as the prose.
...(disasterRecovery
? {
resultsEncrypted: message.resultsEncrypted
? Buffer.from(message.resultsEncrypted).toString("base64")
: null,
}
: { resultsJson: decryptTurnSoft(message.resultsEncrypted) }),
createdAt: message.createdAt.toISOString(),
})),
attachments: row.attachments.map((attachment) => ({
@@ -318,6 +338,8 @@ export type RestoredCoachMessage = Pick<
| "promptVersion"
| "tokensUsed"
| "model"
| "resultsEncrypted"
| "resultsJson"
>
>;
@@ -401,6 +423,7 @@ export async function restoreCoachData(
promptVersion: message.promptVersion ?? null,
tokensUsed: message.tokensUsed ?? null,
model: message.model ?? null,
resultsEncrypted: resolveResultsBytes(message),
createdAt: new Date(message.createdAt),
})),
},
@@ -457,6 +480,23 @@ function resolveTurnBytes(
return encryptToBytes(message.content ?? "");
}
/**
* v1.39.4 — a turn's stored tables, from either end of the contract. A
* disaster-recovery file carries the ciphertext; a portable file carries the
* JSON, sealed here under the target instance's key. Neither end writes a
* zero-byte ciphertext for a turn without tables.
*/
function resolveResultsBytes(
message: RestoredCoachMessage,
): Uint8Array<ArrayBuffer> | null {
if (message.resultsEncrypted !== undefined) {
return message.resultsEncrypted === null
? null
: decodeBase64(message.resultsEncrypted);
}
return message.resultsJson ? encryptToBytes(message.resultsJson) : null;
}
function resolveTitleBytes(
conversation: RestoredCoachConversation,
): Uint8Array<ArrayBuffer> {
+55 -71
View File
@@ -35,6 +35,11 @@ import {
coachPlanPatchSchema,
} from "@/lib/validations/coach-plan";
import { coachChatRequestSchema } from "@/lib/ai/coach/types";
import {
coachProvenanceSchema,
coachResultEntrySchema,
coachStreamEventSchema,
} from "@/lib/ai/coach/stream-events";
import {
coachAttachmentCreateSchema,
fencedChatRequestSchema,
@@ -468,68 +473,8 @@ const coachSuggestedActionResultResponse = z
// endpoint decrypts every message server-side, so the client never
// handles a key. The list endpoint stays metadata-only (no decryption).
const coachProvenanceSchema = z
.object({
windows: z
.array(
z.enum([
"last7days",
"last30days",
"last90days",
"lastYear",
"allTime",
]),
)
.describe("Analysis windows the assistant drew on this turn."),
metrics: z
.array(z.string())
.describe(
"Stable metric-topic keys referenced (e.g. bp, weight, sleep, glucose). `general` is the empty-snapshot sentinel. The client translates these labels; the server never localises them.",
),
counts: z
.record(z.string(), z.number().int())
.optional()
.describe(
"Per-metric sample-count summary; absent on an empty snapshot.",
),
keyValues: z
.array(
z.object({
label: z.string(),
value: z.string(),
unit: z.string().optional(),
window: z.string().optional(),
}),
)
.optional()
.describe(
"Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 entries.",
),
toolCalls: z
.array(
z.object({
name: z.string(),
present: z.boolean(),
}),
)
.optional()
.describe(
"v1.20.0 — the retrieval-tool trace for this turn: which tools the Coach called and whether each found data. Metadata only (no values). Absent on the legacy snapshot path and on turns that called no tools.",
),
unverifiedFigures: z
.number()
.int()
.positive()
.optional()
.describe(
"v1.32.14 — count of numeric tokens the grounding guard withheld from this reply (each rewritten to the editorial elision mark). Drives the quiet per-message notice. Count only, never the withheld values. Absent when the turn withheld nothing.",
),
})
.meta({
id: "CoachProvenance",
description:
"Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence key-values. No raw timestamps.",
});
// The provenance envelope and the stream frames come from the Coach's own
// wire mirror, which a type test holds equal to the TypeScript contract.
const coachMessageSchema = z
.object({
@@ -546,7 +491,7 @@ const coachMessageSchema = z
.string()
.nullable()
.describe(
"Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks an empty assistant row closing a turn the client aborted mid-generation (navigation away); clients should render it as an interrupted turn with a retry affordance. Null for user turns.",
'Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks an empty assistant row closing a turn the client aborted mid-generation (navigation away); clients should render it as an interrupted turn with a retry affordance. v1.39.4 — the sentinel `reuse` marks a turn answered from a table already stored in the conversation (a follow-up chip such as "as a chart"), with no model call: render it as a normal assistant turn without a token footer. Null for user turns.',
),
promptVersion: z
.string()
@@ -720,7 +665,7 @@ const coachAttachmentsResponseSchema = z
const coachChatRequest = coachChatRequestSchema.meta({
id: "CoachChatRequest",
description:
"Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a new conversation (the server mints a title from the first message) and supplied to continue one. `scope` narrows which metrics the snapshot ships and which window the timeline covers; omitted fields fall back to server defaults. `locale` picks the reply language. `guidedQuestion` carries the clarifying question a message answers (client-side bubble, never persisted). No `userId` field — the owner is narrowed from the session / Bearer.",
"Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a new conversation (the server mints a title from the first message) and supplied to continue one. `scope` narrows which metrics the snapshot ships and which window the timeline covers; omitted fields fall back to server defaults. `locale` picks the reply language. `guidedQuestion` carries the clarifying question a message answers (client-side bubble, never persisted). v1.39.4 — `followUp: { messageId, id }` names a follow-up chip the person tapped (send the chip's `label` as `message`); the server resolves the chip from the conversation's latest assistant message, and a chip that is no longer current is treated as a plain message. `clarification: { messageId, choiceId? }` answers a clarifying question, `choiceId` absent when the person typed their own answer. No `userId` field — the owner is narrowed from the session / Bearer.",
});
const anamnesisFactCurrentExistsResponse = {
@@ -790,7 +735,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Send a Coach turn (streaming reply)",
description:
'v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message, reason? }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Requires the `coach` AI capability, checked right after auth: an unavailable Coach is refused with the capability envelope before the stream opens, except a missing provider, which keeps its `coach.provider.none` error frame and adds `reason: "no_provider"`. Budget- and rate-limited. Auth via cookie or Bearer.',
'v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event, each a `CoachStreamEvent` dispatched on `type`. Frames, in the order they arrive: `step` (v1.39.4, live progress while the Coach reads the record: `{ type, step }`, upserted by `step.id` from `running` to `done` / `empty` / `failed`; catalog labels, domains, windows and counts only), `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `result` (v1.39.4, a table of the values the turn read: `{ type, result }`, zero or more, sent only to the account that owns the conversation), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `suggestedAction` (v1.22, a confirm-to-apply action card: `{ type, suggestedAction }`; nothing is created until the person confirms it through `POST /api/coach/suggested-actions`), `clarification` (v1.39.4, the choices when the reply is a clarifying question: `{ type, clarification }`; answer with `clarification` on the next request), `followUps` (v1.39.4, up to three chips under the reply: `{ type, followUps }`; send one back with `followUp` on the next request), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), and `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or a single `error` (`{ type, code, message, reason? }`). No frame added after v1.18.9 carries a top-level key an older frame uses, so a client decoding every frame into one flat struct is safe. The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Requires the `coach` AI capability, checked right after auth: an unavailable Coach is refused with the capability envelope before the stream opens, except a missing provider, which keeps its `coach.provider.none` error frame and adds `reason: "no_provider"`. Budget- and rate-limited. Auth via cookie or Bearer.',
requestBody: {
required: true,
content: {
@@ -800,14 +745,10 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
responses: {
"200": {
description:
"Server-Sent Events stream of `token` / `provenance` / `done` / `error` frames.",
"Server-Sent Events stream: each `data: <json>\\n\\n` frame is one `CoachStreamEvent`. Keepalive comment lines (`: ka`) carry no data and are ignored.",
content: {
"text/event-stream": {
schema: {
type: "string",
description:
"SSE frames: `data: <json>\\n\\n`. See the operation description for the per-`type` frame shapes.",
},
schema: coachStreamEventSchema,
},
},
},
@@ -824,6 +765,49 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/insights/chat/{id}/messages/{messageId}/results": {
get: {
tags: ["Insights"],
summary: "Read the tables one Coach message read",
description:
"v1.39.4 — the tables of values one assistant message read, decrypted server-side for the account that owns the conversation. One entry per table the message's `metricSource.results` lists, in that order: the full `CoachResultTable`, or `{ ref, withheld }` when it is not served — `module_disabled` when the table's domain is switched off for the record now, `unavailable` when the stored tables cannot be read. A message without tables answers an empty list. Fetch lazily, per message, when a table scrolls into view. Never AI-gated: the tables are stored data and stay readable while the Coach is unavailable. A foreign or unknown conversation or message id maps to 404 (never 403). Auth via cookie or Bearer.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Conversation id.",
},
{
name: "messageId",
in: "path",
required: true,
schema: { type: "string" },
description: "Assistant message id within the conversation.",
},
],
responses: {
"200": {
description: "The message's tables, each served whole or withheld.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ results: z.array(coachResultEntrySchema) }),
"CoachMessageResultsResponse",
),
},
},
},
"404": {
description:
"Conversation or message not found, or not owned by the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/insights/chat/{id}": {
get: {
tags: ["Insights"],
+14
View File
@@ -48,6 +48,20 @@ export const coachKeys = {
*/
coachAttachmentMutation: (conversationId: string) =>
["coachConversation", conversationId, "attachments"] as const,
/**
* v1.39.4 — the stored tables of one assistant message
* (`GET /api/insights/chat/{id}/messages/{messageId}/results`). Nested under
* the conversation key so deleting or invalidating the conversation reaches
* every message's tables with the existing prefix match.
*/
coachMessageResults: (conversationId: string, messageId: string) =>
[
"coachConversation",
conversationId,
"messages",
messageId,
"results",
] as const,
/**
* v1.21.2 (A3) — today's most notable derived signal, resolved into the
* Coach hero's pre-seeded relevance opener
+4
View File
@@ -1357,6 +1357,10 @@ const coachMessageBackupSchema = z
promptVersion: z.string().nullable().optional(),
tokensUsed: z.number().int().nullable().optional(),
model: z.string().nullable().optional(),
// v1.39.4 — the turn's tables: ciphertext on a disaster-recovery file,
// readable JSON on a portable one, absent on every file written before.
resultsEncrypted: base64BytesSchema.nullable().optional(),
resultsJson: z.string().nullable().optional(),
createdAt: isoDateTime,
})
.passthrough();
+116
View File
@@ -117,6 +117,46 @@ const METRIC_SEALED_NOTE = "right hand, rested";
const COACH_USER_TURN = "my readings look higher this week, is that real?";
const COACH_ASSISTANT_TURN =
"the last seven mornings average 4 mmHg above the fortnight before";
/** The table of values the assistant turn read: ciphertext at rest. */
const COACH_RESULTS_JSON = JSON.stringify([
{
ref: "r1",
source: {
tool: "get_metric_series",
domain: "bp",
window: "last7days",
period: "current",
granularity: "day",
},
shape: "timeSeries",
titleKey: "coach.result.title.metricByPeriod",
title: "Blood pressure by day",
rowCount: 2,
chartKind: "line",
displayed: true,
columns: [
{
key: "period",
kind: "period",
labelKey: "coach.result.column.day",
label: "Day",
},
{
key: "sys",
kind: "number",
labelKey: "coach.result.column.systolic",
label: "Systolic",
unit: "mmHg",
},
],
rows: [
["2026-07-19", 131],
["2026-07-20", null],
],
truncated: false,
chart: { kind: "line", x: "period", series: ["sys"] },
},
]);
const DOSE_CHANGE_NOTE = "titration note, encrypted at rest";
const EXTRACTED_FACT_SPAN = "Ferritin 91 ng/mL (30 - 400)";
const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose";
@@ -926,6 +966,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
{
role: "assistant",
encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN),
resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON),
providerType: "anthropic",
model: "claude-opus-5",
tokensUsed: 812,
@@ -1580,6 +1621,9 @@ describe("every model the plan claims two-ended survives a real restore", () =>
content: decryptFromBytes(message.encryptedContent),
model: message.model,
tokensUsed: message.tokensUsed,
results: message.resultsEncrypted
? decryptFromBytes(message.resultsEncrypted)
: null,
})),
attachments: thread.attachments.map((a) => a.documentId),
})),
@@ -1597,12 +1641,14 @@ describe("every model the plan claims two-ended survives a real restore", () =>
content: COACH_USER_TURN,
model: null,
tokensUsed: null,
results: null,
},
{
role: "assistant",
content: COACH_ASSISTANT_TURN,
model: "claude-opus-5",
tokensUsed: 812,
results: COACH_RESULTS_JSON,
},
],
attachments: [],
@@ -1619,6 +1665,7 @@ describe("every model the plan claims two-ended survives a real restore", () =>
content: "What does the ferritin mean?",
model: null,
tokensUsed: null,
results: null,
},
],
attachments: [vaultDocument.id],
@@ -2699,6 +2746,75 @@ describe("every model the plan claims two-ended survives a real restore", () =>
* side by side: a dropped row and a nulled pointer, each named in the report,
* and a restore that still answers 200.
*/
/**
* The Coach's stored tables through a portable file.
*
* A disaster-recovery file carries them as ciphertext (the round trip at
* the top of this file). A portable file has to carry them readable, like
* the prose they sit beside, and the restore has to seal them again under
* the target's key. A restore that dropped the readable JSON would bring
* the thread back with every answer's table missing and nothing to say so.
*/
it("carries a turn's tables readable in a portable file and seals them again on restore", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
await prisma.coachConversation.create({
data: {
userId: OWNER_ID,
titleEncrypted: encryptToBytes("How is my blood pressure trending?"),
documentScoped: false,
messages: {
create: [
{
role: "assistant",
encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN),
resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON),
createdAt: AT("2026-07-20T09:59:00.000Z"),
},
],
},
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "portable-export",
});
const { coachConversations } = payload as {
coachConversations: Array<{ messages: Array<Record<string, unknown>> }>;
};
const [turn] = coachConversations[0].messages;
expect(turn).toMatchObject({ resultsJson: COACH_RESULTS_JSON });
expect(turn).not.toHaveProperty("resultsEncrypted");
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status, JSON.stringify(await response.json())).toBe(200);
const restored = await prisma.coachMessage.findFirstOrThrow({
where: { conversation: { userId: OWNER_ID } },
});
expect(restored.resultsEncrypted).not.toBeNull();
expect(decryptFromBytes(restored.resultsEncrypted!)).toBe(
COACH_RESULTS_JSON,
);
});
it("drops and names a filing and a commitment a truncated file cannot resolve", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);