diff --git a/knip.json b/knip.json index baae9fd35..f5d9ec71d 100644 --- a/knip.json +++ b/knip.json @@ -20,6 +20,7 @@ "src/proxy.ts", "src/instrumentation.ts", "src/lib/jobs/**/*.ts", + "src/lib/ai/coach/eval/judge.ts", "scripts/**/*.ts", "scripts/**/*.mjs", "e2e/**/*.spec.ts", diff --git a/messages/de.json b/messages/de.json index 2e4707abb..8a3a5fb4c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1946,7 +1946,6 @@ "confidenceMittel": "Mittel", "confidenceGering": "Gering", "loadError": "Auswertungen konnten nicht geladen werden", - "heroGenerated": "Erstellt {time}", "heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen", "heroRegenerate": "Neu generieren", "heroRegenerating": "Wird generiert", @@ -1958,7 +1957,6 @@ "narrativeMonth": "Dieser Monat", "narrativePreparing": "Deine Zusammenfassung wird erstellt…", "narrativeUpdating": "Wird aktualisiert…", - "narrativeUpdated": "Aktualisiert {time}", "narrativeProvenanceLabel": "Wie das zustande kam", "narrativeProvenanceMethod": "Eine Zusammenfassung des Zeitraums in einfacher Sprache, ausschließlich aus deinen erfassten Werten und den Zusammenhängen, die die statistische Mehrfachvergleichskorrektur überstanden haben. Beschreibend, nie eine Ursache.", "narrativeProvenanceMetrics": "Basierend auf: {metrics}", @@ -4738,6 +4736,8 @@ "changelog": "Changelog", "docs": "Dokumentation", "linksHeading": "Quellen & Dokumentation", + "identityDescription": "Version, Lizenz und Build-Details dieser Instanz.", + "linksDescription": "Quellcode, Änderungsprotokoll und Dokumentation.", "newerAvailable": "Neue Version: {tag}" }, "testConnection": { diff --git a/messages/en.json b/messages/en.json index 03e44452f..06b38c3e8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1946,7 +1946,6 @@ "confidenceMittel": "Medium", "confidenceGering": "Low", "loadError": "Failed to load insights", - "heroGenerated": "Generated {time}", "heroPersonalBaseline": "Based on your last 90 days", "heroRegenerate": "Regenerate", "heroRegenerating": "Regenerating", @@ -1958,7 +1957,6 @@ "narrativeMonth": "This month", "narrativePreparing": "Preparing your summary…", "narrativeUpdating": "Updating…", - "narrativeUpdated": "Updated {time}", "narrativeProvenanceLabel": "How this was put together", "narrativeProvenanceMethod": "A plain-language summary of the period, drawn only from your logged numbers and the associations that survived statistical multiple-comparison control. Descriptive, never a cause.", "narrativeProvenanceMetrics": "Based on: {metrics}", @@ -4738,6 +4736,8 @@ "changelog": "Changelog", "docs": "Documentation", "linksHeading": "Sources & docs", + "identityDescription": "Version, license, and build details for this instance.", + "linksDescription": "Source code, changelog, and documentation.", "newerAvailable": "Newer version: {tag}" }, "testConnection": { diff --git a/messages/es.json b/messages/es.json index f9641ab19..0b49cd985 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1946,7 +1946,6 @@ "confidenceMittel": "Media", "confidenceGering": "Baja", "loadError": "No se han podido cargar los análisis", - "heroGenerated": "Generado {time}", "heroPersonalBaseline": "Basado en tus últimos 90 días", "heroRegenerate": "Regenerar", "heroRegenerating": "Generando", @@ -1958,7 +1957,6 @@ "narrativeMonth": "Este mes", "narrativePreparing": "Preparando tu resumen…", "narrativeUpdating": "Actualizando…", - "narrativeUpdated": "Actualizado {time}", "narrativeProvenanceLabel": "Cómo se elaboró esto", "narrativeProvenanceMethod": "Un resumen del período en lenguaje sencillo, basado únicamente en tus valores registrados y en las asociaciones que superaron el control estadístico de comparaciones múltiples. Descriptivo, nunca una causa.", "narrativeProvenanceMetrics": "Basado en: {metrics}", @@ -4738,6 +4736,8 @@ "changelog": "Changelog", "docs": "Documentación", "linksHeading": "Fuentes y documentación", + "identityDescription": "Versión, licencia y detalles de compilación de esta instancia.", + "linksDescription": "Código fuente, registro de cambios y documentación.", "newerAvailable": "Nueva versión: {tag}" }, "testConnection": { diff --git a/messages/fr.json b/messages/fr.json index 890f9e6d4..971a1bbd8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1946,7 +1946,6 @@ "confidenceMittel": "Moyenne", "confidenceGering": "Faible", "loadError": "Impossible de charger les analyses", - "heroGenerated": "Généré {time}", "heroPersonalBaseline": "Basé sur tes 90 derniers jours", "heroRegenerate": "Régénérer", "heroRegenerating": "Génération en cours", @@ -1958,7 +1957,6 @@ "narrativeMonth": "Ce mois-ci", "narrativePreparing": "Préparation de votre résumé…", "narrativeUpdating": "Mise à jour…", - "narrativeUpdated": "Mis à jour {time}", "narrativeProvenanceLabel": "Comment cela a été établi", "narrativeProvenanceMethod": "Un résumé de la période en langage clair, établi uniquement à partir de vos valeurs enregistrées et des associations ayant survécu au contrôle statistique des comparaisons multiples. Descriptif, jamais une cause.", "narrativeProvenanceMetrics": "Basé sur : {metrics}", @@ -4738,6 +4736,8 @@ "changelog": "Changelog", "docs": "Documentation", "linksHeading": "Sources et documentation", + "identityDescription": "Version, licence et détails de build de cette instance.", + "linksDescription": "Code source, journal des modifications et documentation.", "newerAvailable": "Nouvelle version : {tag}" }, "testConnection": { diff --git a/messages/it.json b/messages/it.json index 2347e559a..4b762cd6b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1946,7 +1946,6 @@ "confidenceMittel": "Media", "confidenceGering": "Bassa", "loadError": "Impossibile caricare le analisi", - "heroGenerated": "Generato {time}", "heroPersonalBaseline": "Basato sugli ultimi 90 giorni", "heroRegenerate": "Rigenera", "heroRegenerating": "Generazione in corso", @@ -1958,7 +1957,6 @@ "narrativeMonth": "Questo mese", "narrativePreparing": "Preparazione del riepilogo…", "narrativeUpdating": "Aggiornamento…", - "narrativeUpdated": "Aggiornato {time}", "narrativeProvenanceLabel": "Come è stato realizzato", "narrativeProvenanceMethod": "Un riepilogo del periodo in linguaggio semplice, basato esclusivamente sui valori registrati e sulle associazioni che hanno superato il controllo statistico dei confronti multipli. Descrittivo, mai una causa.", "narrativeProvenanceMetrics": "Basato su: {metrics}", @@ -4738,6 +4736,8 @@ "changelog": "Changelog", "docs": "Documentazione", "linksHeading": "Fonti e documentazione", + "identityDescription": "Versione, licenza e dettagli di build di questa istanza.", + "linksDescription": "Codice sorgente, registro delle modifiche e documentazione.", "newerAvailable": "Nuova versione: {tag}" }, "testConnection": { diff --git a/messages/pl.json b/messages/pl.json index f0787958d..664af0d8d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1946,7 +1946,6 @@ "confidenceMittel": "Średnia", "confidenceGering": "Niska", "loadError": "Nie udało się załadować analiz", - "heroGenerated": "Wygenerowano {time}", "heroPersonalBaseline": "Na podstawie ostatnich 90 dni", "heroRegenerate": "Wygeneruj ponownie", "heroRegenerating": "Generowanie", @@ -1958,7 +1957,6 @@ "narrativeMonth": "Ten miesiąc", "narrativePreparing": "Przygotowywanie podsumowania…", "narrativeUpdating": "Aktualizowanie…", - "narrativeUpdated": "Zaktualizowano {time}", "narrativeProvenanceLabel": "Jak to powstało", "narrativeProvenanceMethod": "Podsumowanie okresu w prostym języku, oparte wyłącznie na zarejestrowanych wartościach i powiązaniach, które przeszły statystyczną korektę dla porównań wielokrotnych. Opisowe, nigdy nie przyczynowe.", "narrativeProvenanceMetrics": "Na podstawie: {metrics}", @@ -4738,6 +4736,8 @@ "changelog": "Changelog", "docs": "Dokumentacja", "linksHeading": "Źródła i dokumentacja", + "identityDescription": "Wersja, licencja i szczegóły kompilacji tej instancji.", + "linksDescription": "Kod źródłowy, dziennik zmian i dokumentacja.", "newerAvailable": "Nowa wersja: {tag}" }, "testConnection": { diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index d6a9b79b8..4efedf7ea 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -30,7 +30,8 @@ import { NextResponse, type NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { redactOptional, redactSecrets } from "@/lib/logging/redact"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -1169,10 +1170,23 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch // surfaces a graceful `coach.provider.*` frame; anything else degrades to // a generic unavailable frame. A genuine defect is still annotated. const providerError = classifyBubblingProviderError(err); + if (!providerError) { + // Not a tagged provider failure — a genuine server defect raised + // inside the open producer (e.g. a DB error in the post-stream + // `appendMessage` persistence). Record it on the wide event and + // forward it to GlitchTip so it stays visible instead of reading + // as a provider outage; the stream is open, so we still emit an + // error frame below rather than a 500. + getEvent()?.setError( + err instanceof Error ? err : new Error(String(err)), + ); + void reportCoachStreamDefect(err); + } annotate({ action: { name: "insights.coach.streamError" }, meta: { unwrapped: Boolean(providerError), + reported: !providerError, firstStatus: providerError?.httpStatus ?? null, }, }); @@ -1327,6 +1341,46 @@ function classifyBubblingProviderError(err: unknown): { return { code: "unavailable", httpStatus: status }; } +/** + * Fire-and-forget GlitchTip forward for a genuine server defect that + * surfaces INSIDE the open SSE producer — e.g. a Prisma failure in the + * post-stream `appendMessage` persistence, which runs after the provider + * call and therefore outside its try/catch. Once the stream is open the + * route can no longer return a 500, so without this such a defect would + * read as a generic provider outage in error tracking rather than the + * server bug it is. Mirrors the api-handler / worker forwarders: dynamic + * import (no cycle, no startup cost), redacted message + stack, and it + * NEVER throws so a sink failure cannot mask the original error. + */ +async function reportCoachStreamDefect(err: unknown): Promise { + const e = + err instanceof Error ? err : new Error("Unknown coach stream error"); + const message = redactSecrets(`[insights.coach.stream] ${e.message}`); + console.error("[coach-stream]", message, e); + try { + const [{ getGlitchtipSettings }, { sendGlitchtipEvent }] = + await Promise.all([ + import("@/lib/monitoring-settings"), + import("@/lib/monitoring/glitchtip"), + ]); + const settings = await getGlitchtipSettings(); + if (!settings.glitchtipEnabled || !settings.glitchtipDsn) return; + await sendGlitchtipEvent({ + dsn: settings.glitchtipDsn, + input: { + environment: settings.glitchtipEnvironment || "production", + message, + level: "error", + type: e.name || "Error", + stack: redactOptional(e.stack), + sourceTag: "healthlog-api-handler", + }, + }); + } catch { + /* the reporter must never throw */ + } +} + function streamProviderError(args: { code: string }): Response { const stream = createSseStream((controller) => { controller.enqueue( diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 0855a364b..3d68a8fbf 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -211,7 +211,7 @@ function CoachConversationsBody() { @@ -183,11 +183,11 @@ export function CoachDrawer({ diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index b03dd7600..b9cbd0be3 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -452,7 +452,10 @@ function ReadAloudButton({ content }: { content: string }) { aria-label={label} aria-pressed={speaking} title={label} - className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-8 items-center gap-1 rounded px-1.5 py-1 text-xs outline-none focus-visible:ring-2" + // Interactive bubble affordances share one tap convention: + // `min-h-11 sm:min-h-9` (44px mobile floor → 36px desktop), matching the + // feedback buttons below. Keep TTS / remember / feedback in lockstep. + className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-1.5 py-1 text-xs outline-none focus-visible:ring-2 sm:min-h-9" > {speaking ? (