mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge branch 'coach/wpF-contracts' into release/v1.39.4
This commit is contained in:
commit
78fc22d0f8
75 files changed
+6618
-233
No files matched your search
+1286
-95
File diff suppressed because it is too large.
Load diff
+174
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: {} },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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.",
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user