diff --git a/messages/de.json b/messages/de.json index f47b95c2c..a2d2e6d9e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2748,8 +2748,8 @@ "last7days": "letzte 7 Tage", "last30days": "letzte 30 Tage", "last90days": "letzte 90 Tage", - "lastYear": "Jahresrückblick", - "allTime": "gesamter Zeitraum" + "lastYear": "letzte 12 Monate", + "allTime": "gesamter Zeitraum (12 Monate im Detail)" }, "windowLabel": "Zeitraum", "settingsAriaLabel": "Coach-Einstellungen", @@ -3047,7 +3047,7 @@ "loadError": "Diese Messgröße konnte gerade nicht geladen werden.", "blutdruckDescription": "Dein systolisch-/diastolisch-Verlauf mit altersangepassten Zielbereichen und einer textbasierten Einschätzung.", "gewichtDescription": "Gewichtsverlauf gegen das aus deiner Größe abgeleitete gesunde Band, mit Einschätzung.", - "pulsDescription": "Pulsverlauf gegenüber dem persönlichen Karvonen-Zielband, mit Einschätzung.", + "pulsDescription": "Ruhepuls im Vergleich zu einem alters- und geschlechtsabhängigen Referenzband aus CDC/NCHS-Populationsperzentilen, mit Einschätzung.", "stimmungDescription": "Tägliche Stimmungswerte im Zeitverlauf mit einer textbasierten Einschätzung.", "medikamenteDescription": "Einnahme-Kalender pro Medikament plus eine textbasierte Einschätzung.", "bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung.", @@ -8967,7 +8967,8 @@ "outOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen — Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.", "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." }, "safety": { "floor": { diff --git a/messages/en.json b/messages/en.json index f335611ac..ba0a5ac87 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2748,8 +2748,8 @@ "last7days": "last 7 days", "last30days": "last 30 days", "last90days": "last 90 days", - "lastYear": "year so far", - "allTime": "all time" + "lastYear": "last 12 months", + "allTime": "all time (12 months of detail)" }, "windowLabel": "Window", "settingsAriaLabel": "Coach settings", @@ -3047,7 +3047,7 @@ "loadError": "Could not load this metric right now.", "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus a written assessment.", - "pulsDescription": "Pulse trend against the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Resting pulse against an age- and sex-based reference band from CDC/NCHS population percentiles, plus a written assessment.", "stimmungDescription": "Daily mood scores over time with a written assessment.", "medikamenteDescription": "Per-medication adherence calendars and a written assessment.", "bmiDescription": "BMI derived from your weight and height, with a written assessment.", @@ -8967,7 +8967,8 @@ "outOfScope": "I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.", "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." }, "safety": { "floor": { diff --git a/messages/es.json b/messages/es.json index c33bbad2d..632a9f9ed 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2748,8 +2748,8 @@ "last7days": "Últimos 7 días", "last30days": "Últimos 30 días", "last90days": "Últimos 90 días", - "lastYear": "lo que va de año", - "allTime": "Todo el tiempo" + "lastYear": "últimos 12 meses", + "allTime": "todo el tiempo (12 meses en detalle)" }, "windowLabel": "Periodo", "settingsAriaLabel": "Ajustes del coach", @@ -3047,7 +3047,7 @@ "loadError": "No se pudo cargar esta métrica ahora mismo.", "blutdruckDescription": "Tu evolución sistólica/diastólica con rangos objetivo ajustados a la edad y una valoración en texto.", "gewichtDescription": "Evolución del peso frente a la banda saludable derivada de tu altura, con valoración.", - "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Pulso en reposo frente a una banda de referencia por edad y sexo basada en percentiles poblacionales de CDC/NCHS, con una valoración escrita.", "stimmungDescription": "Valores diarios de estado de ánimo en el tiempo, con una valoración en texto.", "medikamenteDescription": "Calendario de tomas por medicamento, más una valoración en texto.", "bmiDescription": "IMC derivado de tu peso y altura, con valoración.", @@ -8967,7 +8967,8 @@ "outOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. No puedo responder preguntas fuera de ese ámbito.", "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." }, "safety": { "floor": { diff --git a/messages/fr.json b/messages/fr.json index 9edb08991..5e03401f1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2748,8 +2748,8 @@ "last7days": "7 derniers jours", "last30days": "30 derniers jours", "last90days": "90 derniers jours", - "lastYear": "depuis le début de l'année", - "allTime": "Toute la période" + "lastYear": "12 derniers mois", + "allTime": "toute la période (12 mois en détail)" }, "windowLabel": "Période", "settingsAriaLabel": "Paramètres du coach", @@ -3047,7 +3047,7 @@ "loadError": "Impossible de charger cette mesure pour le moment.", "blutdruckDescription": "Ton évolution systolique/diastolique avec des plages cibles adaptées à l’âge et une évaluation textuelle.", "gewichtDescription": "Évolution du poids face à la bande saine dérivée de ta taille, avec évaluation.", - "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Pouls au repos comparé à une plage de référence selon l'âge et le sexe, issue des percentiles de population CDC/NCHS, avec une évaluation rédigée.", "stimmungDescription": "Valeurs d’humeur quotidiennes dans le temps, avec une évaluation textuelle.", "medikamenteDescription": "Calendrier des prises par médicament, ainsi qu’une évaluation textuelle.", "bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation.", @@ -8967,7 +8967,8 @@ "outOfScope": "Je peux uniquement t'aider avec les données de santé de ton journal : mesures, médicaments, humeur, tendances. Je ne réponds pas aux questions en dehors de ce cadre.", "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." }, "safety": { "floor": { diff --git a/messages/it.json b/messages/it.json index 5df1f3bab..eb02d4511 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2748,8 +2748,8 @@ "last7days": "Ultimi 7 giorni", "last30days": "Ultimi 30 giorni", "last90days": "Ultimi 90 giorni", - "lastYear": "anno in corso", - "allTime": "Tutto il tempo" + "lastYear": "ultimi 12 mesi", + "allTime": "tutto il tempo (12 mesi in dettaglio)" }, "windowLabel": "Periodo", "settingsAriaLabel": "Impostazioni del coach", @@ -3047,7 +3047,7 @@ "loadError": "Impossibile caricare questa metrica al momento.", "blutdruckDescription": "Il tuo andamento sistolico/diastolico con range obiettivo adattati all’età e una valutazione testuale.", "gewichtDescription": "Andamento del peso rispetto alla fascia sana derivata dalla tua altezza, con valutazione.", - "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Battito a riposo confrontato con una fascia di riferimento per età e sesso, basata sui percentili di popolazione CDC/NCHS, con una valutazione scritta.", "stimmungDescription": "Valori giornalieri di umore nel tempo, con una valutazione testuale.", "medikamenteDescription": "Calendario delle assunzioni per farmaco e una valutazione testuale.", "bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione.", @@ -8967,7 +8967,8 @@ "outOfScope": "Posso aiutarti solo con i dati di salute del tuo registro: misurazioni, farmaci, umore, tendenze. Non rispondo a domande al di fuori di questo ambito.", "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." }, "safety": { "floor": { diff --git a/messages/pl.json b/messages/pl.json index ef3622552..18514d304 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2748,8 +2748,8 @@ "last7days": "Ostatnie 7 dni", "last30days": "Ostatnie 30 dni", "last90days": "Ostatnie 90 dni", - "lastYear": "od początku roku", - "allTime": "Cały okres" + "lastYear": "ostatnie 12 miesięcy", + "allTime": "cały okres (12 miesięcy szczegółowo)" }, "windowLabel": "Okres", "settingsAriaLabel": "Ustawienia coacha", @@ -3047,7 +3047,7 @@ "loadError": "Nie udało się teraz wczytać tej metryki.", "blutdruckDescription": "Twój przebieg skurczowego/rozkurczowego z zakresami docelowymi dopasowanymi do wieku i oceną tekstową.", "gewichtDescription": "Przebieg wagi na tle zdrowego pasma wyliczonego z twojego wzrostu, z oceną.", - "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Tętno spoczynkowe na tle zakresu referencyjnego według wieku i płci, opartego na percentylach populacyjnych CDC/NCHS, wraz z oceną opisową.", "stimmungDescription": "Dzienne wartości nastroju w czasie wraz z oceną tekstową.", "medikamenteDescription": "Kalendarz przyjęć dla każdego leku plus ocena tekstowa.", "bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną.", @@ -8967,7 +8967,8 @@ "outOfScope": "Mogę pomóc wyłącznie z danymi zdrowotnymi z Twojego dziennika: pomiarami, lekami, nastrojem, trendami. Na pytania spoza tego zakresu nie odpowiadam.", "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." }, "safety": { "floor": { diff --git a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts new file mode 100644 index 000000000..b394f43b9 --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts @@ -0,0 +1,409 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * REGRESSION — when the Coach's numeric grounding guard is armed, and when it + * deliberately is not. + * + * The guard rewrites any figure in the reply that the turn's Grounding Ledger + * cannot account for. Its activation used to key solely off `loop.toolResults`, + * and a tool that found nothing resolves to `{ present: false, reason: "none" }` + * with no `available` payload, which `loop.ts` drops (pinned in + * `tools-loop-miss-payloads.test.ts`). So the guard disarmed itself exactly + * when the record was empty, and a fabricated figure shipped verbatim with no + * withheld-figure marker. + * + * `toolTrace` now discriminates the two cases the old condition conflated: + * + * - tools RAN and every one missed -> grade. An empty ledger is the finding, + * not a reason to skip, so every magnitude is unreconciled. A reply that + * loses a figure this way is replaced with the honest "nothing recorded" + * answer rather than shipping elided prose that still asserts a series. + * - the model called NO tool -> stay dormant. This is the v1.32.1 + * contract: the tool-mode base prompt deliberately carries no pre-computed + * figures, and flagging a legitimately recalled one was a real regression. + */ + +const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}'; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + HttpError: class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }, +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + isModuleEnabled: vi.fn(async () => true), +})); +vi.mock("@/lib/feature-flags", () => ({ + requireAssistantSurface: vi.fn(async () => undefined), +})); +vi.mock("@/lib/api-response", () => ({ + apiError: (error: string, status: number) => ({ data: null, error, status }), + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(), +})); +vi.mock("@/lib/logging/redact", () => ({ + redactSecrets: (s: string) => s, + redactOptional: (s: unknown) => s, +})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) }, + coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) }, + }, +})); + +const { checkRateLimit } = vi.hoisted(() => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit })); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: vi.fn(async () => "en"), +})); + +const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({ + runStreamingRawCompletionWithFallback: vi.fn(), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runStreamingRawCompletionWithFallback, +})); + +const { resolveProviderChain } = vi.hoisted(() => ({ + resolveProviderChain: vi.fn(), +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain, + resolveProvider: vi.fn(), +})); + +const { assertConsentForChain } = vi.hoisted(() => ({ + assertConsentForChain: vi.fn(async () => undefined), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain })); +vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" })); +vi.mock("@/lib/ai/ai-budgets", () => ({ + AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } }, +})); + +vi.mock("@/lib/ai/coach/types", async () => { + const actual = await vi.importActual( + "@/lib/ai/coach/types", + ); + return actual; +}); + +const { fetchConversationWithMessages } = vi.hoisted(() => ({ + fetchConversationWithMessages: vi.fn(), +})); +vi.mock("@/lib/ai/coach/persistence", () => ({ + appendMessage: vi.fn(async () => ({ id: "m1" })), + createConversation: vi.fn(async () => ({ id: "c1" })), + fetchConversationWithMessages, + listConversations: vi.fn(), +})); +vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({ + enqueueCoachMemoryRefresh: vi.fn(), +})); +vi.mock("@/lib/ai/coach/facts", () => ({ + storeDeterministicFacts: vi.fn(async () => undefined), +})); + +// Guard II — the schedule read. Empty here; the schedule-gated dose rule is +// unit-tested in the outbound-screen suite. +vi.mock("@/lib/medications/scheduled-doses", () => ({ + getScheduledDoseValues: vi.fn(async () => []), +})); + +const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({ + reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 3000 })), + reconcileSpend: vi.fn(async () => undefined), +})); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(() => "2026-07-23"), + reserveBudget, + reconcileSpend, + resolveDailyCap: vi.fn(() => 2_000_000), +})); + +const { detectRefusal } = vi.hoisted(() => ({ + detectRefusal: vi.fn(() => ({ refuse: false })), +})); +vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal })); +vi.mock("@/lib/ai/coach/outbound-guard", () => ({ + screenCoachReply: vi.fn(() => ({ block: false })), + coachOutboundFallback: vi.fn(() => "fallback"), +})); +vi.mock("@/lib/ai/coach/system-prompt", () => ({ + getCoachSystemPrompt: vi.fn(() => "SYSTEM"), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/coach/snapshot", () => ({ + buildCoachSnapshot: vi.fn(async () => ({ + snapshotJson: SNAPSHOT_JSON, + sections: { bloodPressure: { aggregate: { mean: 128 } } }, + provenance: { windows: ["last30days"], metrics: ["bp"] }, + referenceGrounding: "REFERENCE RANGES", + })), +})); +vi.mock("@/lib/workouts/hr-series", () => ({ + buildWorkoutHrSeries: vi.fn(async () => null), +})); +vi.mock("@/lib/workouts/zones", () => ({ + computeZones: vi.fn(() => null), + hrMaxFromAge: vi.fn(() => 185), + parseWhoopZoneDurations: vi.fn(() => null), +})); +vi.mock("@/lib/workouts/sport-context", () => ({ + buildSportContext: vi.fn(async () => null), +})); + +const { + buildCoachDataInventory, + renderDataInventory, + renderFocusHint, + runCoachToolLoop, +} = vi.hoisted(() => ({ + buildCoachDataInventory: vi.fn(async () => ({ + entries: [], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: ["bp"], window: "last30days" }, + })), + renderDataInventory: vi.fn(() => "DATA INVENTORY\n- blood pressure: present"), + renderFocusHint: vi.fn(() => ""), + runCoachToolLoop: vi.fn(), +})); +vi.mock("@/lib/ai/coach/tools", () => ({ + COACH_TOOL_DEFS: [{ name: "get_metric_series" }], + MAX_ROUNDS: 3, + buildCoachDataInventory, + renderDataInventory, + renderFocusHint, + buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"), + runCoachToolLoop, +})); + +const { parseKeyValuesSentinel } = vi.hoisted(() => ({ + parseKeyValuesSentinel: vi.fn(), +})); +vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel })); +const { parseSuggestReminder } = vi.hoisted(() => ({ + parseSuggestReminder: vi.fn(), +})); +vi.mock("@/lib/ai/coach/suggest-reminder", () => ({ parseSuggestReminder })); +vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() })); +vi.mock("@/lib/validations/coach-prefs", () => ({ + parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })), + DEFAULT_REMINDER_SUGGESTION_PREFS: {}, +})); + +const { appendMessage } = await import("@/lib/ai/coach/persistence"); + +const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise })); +vi.mock("@/lib/sse/create-stream", () => ({ + createSseStream: ( + producer: (c: { + signal: { aborted: boolean }; + enqueue: () => void; + }) => void | Promise, + ) => { + sse.done = Promise.resolve( + producer({ signal: { aborted: false }, enqueue: () => {} }), + ); + return new ReadableStream(); + }, +})); + +import { POST } from "../route"; + +const post = POST as unknown as (req: Request) => Promise; + +function chatReq(body: Record): Request { + return new Request("http://localhost/api/insights/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** Force the model's assembled prose through the sentinel/suggest parsers. */ +function stubReply(prose: string, toolResults: unknown[]): void { + parseKeyValuesSentinel.mockReturnValue({ + prose, + keyValues: [], + malformed: false, + malformedEntries: [], + }); + parseSuggestReminder.mockReturnValue({ prose }); + runCoachToolLoop.mockImplementation(async () => ({ + result: { content: prose, tokensUsed: 80, model: "m" }, + workingProviderType: "anthropic", + totalTokens: 80, + rounds: 1, + toolTrace: [{ name: "get_metric_series", present: true }], + toolResults, + })); +} + +function assistantContent(): string { + const calls = (appendMessage as ReturnType).mock.calls; + const assistant = calls.find( + (c) => (c[0] as { role: string }).role === "assistant", + ); + return (assistant?.[0] as { content: string }).content; +} + +function assistantProvenance(): Record | null | undefined { + const calls = (appendMessage as ReturnType).mock.calls; + const assistant = calls.find( + (c) => (c[0] as { role: string }).role === "assistant", + ); + return ( + assistant?.[0] as { + metricSource?: Record | null; + } + ).metricSource; +} + +describe("coach chat — grounding-guard activation", () => { + beforeEach(() => { + vi.clearAllMocks(); + reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 }); + detectRefusal.mockReturnValue({ refuse: false }); + checkRateLimit.mockResolvedValue({ allowed: true }); + assertConsentForChain.mockResolvedValue(undefined); + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + ]); + fetchConversationWithMessages.mockResolvedValue({ + id: "c1", + attachmentCount: 0, + summary: null, + messages: [], + }); + }); + + /** Drive the loop with an explicit trace so a MISS is modelled faithfully. */ + function stubMissedTurn(prose: string): void { + parseKeyValuesSentinel.mockReturnValue({ + prose, + keyValues: [], + malformed: false, + malformedEntries: [], + }); + parseSuggestReminder.mockReturnValue({ prose }); + runCoachToolLoop.mockImplementation(async () => ({ + result: { content: prose, tokensUsed: 80, model: "m" }, + workingProviderType: "anthropic", + totalTokens: 80, + rounds: 1, + // The tool ran and found nothing; `loop.ts` drops the pure miss from + // `toolResults` but the trace still records the call. + toolTrace: [{ name: "get_sleep", present: false }], + toolResults: [], + })); + } + + it("CONTROL — one present tool result arms the guard: the invented figure is stripped", async () => { + stubReply("Your sleep averaged 432 minutes last week.", [ + { present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } }, + ]); + + await post(chatReq({ conversationId: "c1", message: "how did I sleep?" })); + await sse.done; + + expect(assistantContent()).not.toContain("432"); + expect(assistantContent()).toContain("[…]"); + expect(assistantProvenance()?.unverifiedFigures).toBe(1); + }); + + it("every tool missed: the fabricated figure is caught and the turn is replaced", async () => { + stubMissedTurn("Your sleep averaged 432 minutes last week."); + + await post(chatReq({ conversationId: "c1", message: "how did I sleep?" })); + await sse.done; + + const content = assistantContent(); + expect(content).not.toContain("432"); + // Not the elided prose either — the sentence asserted a series the record + // does not hold, so the whole turn is replaced with the honest answer. + expect(content).not.toContain("[…]"); + expect(content).toContain("recorded readings"); + // The replacement copy says it in words, so the count-based notice is off. + expect(assistantProvenance()?.unverifiedFigures).toBeUndefined(); + }); + + it("every tool missed but the figure is grounded by a prior turn: left alone", async () => { + // Turn 1 fetched systolic 128 and persisted it as a tool figure. This turn + // every tool misses, but recalling 128 still reconciles, so nothing is + // stripped and the reply is NOT replaced. + fetchConversationWithMessages.mockResolvedValue({ + id: "c1", + attachmentCount: 0, + summary: null, + messages: [ + { role: "user", content: "How is my BP?" }, + { + role: "assistant", + content: "Your systolic averaged 128.", + metricSource: { groundedFigures: [128] }, + }, + ], + }); + stubMissedTurn( + "No sleep readings yet. Your systolic 128 average is unrelated to that.", + ); + + await post(chatReq({ conversationId: "c1", message: "and my sleep?" })); + await sse.done; + + const content = assistantContent(); + expect(content).toContain("128"); + expect(content).not.toContain("[…]"); + expect(content).not.toContain("recorded readings"); + }); + + it("the model called no tool at all: the guard stays dormant (v1.32.1)", async () => { + // Deliberately unchanged behaviour. The tool-mode base prompt carries no + // pre-computed figures, so a number here came from the transcript or the + // model's own recall, and grading it off the counts-only inventory flagged + // legitimate figures as ungrounded. `toolTrace` is empty, so the widened + // activation above does not reach this case. + const prose = "Your resting heart rate has been averaging 58 bpm."; + parseKeyValuesSentinel.mockReturnValue({ + prose, + keyValues: [], + malformed: false, + malformedEntries: [], + }); + parseSuggestReminder.mockReturnValue({ prose }); + runCoachToolLoop.mockImplementation(async () => ({ + result: { content: prose, tokensUsed: 80, model: "m" }, + workingProviderType: "anthropic", + totalTokens: 80, + rounds: 1, + toolTrace: [], + toolResults: [], + })); + + await post(chatReq({ conversationId: "c1", message: "how is my RHR?" })); + await sse.done; + + expect(assistantContent()).toBe(prose); + expect(assistantProvenance()?.unverifiedFigures).toBeUndefined(); + }); +}); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index d10447086..a5c0e3191 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -639,6 +639,13 @@ async function handleChatRequest(request: NextRequest): Promise { // delivered this turn (`includeFullSnapshot`); on a cheap follow-up the block // was not re-sent, so there is no fresh authoritative set to grade against. let noToolsSnapshotPayloads: unknown[] = []; + // The DATA INVENTORY manifest (per-domain sample counts) that rode this + // turn's tool-mode prompt. Hoisted out of the tool branch because the + // all-missed activation below needs it as a WIDENER: the counts were in + // front of the model, so a plain count restatement ("you've logged 42 BP + // readings") must stay grounded even on a turn whose every tool missed. + // Never an ACTIVATOR on its own — see the v1.32.1 note below. + let inventoryPayloads: unknown[] = []; let totalTokensSpent: number; // v1.21.0 (F3) — cached-input tokens to subtract at reconcile (prompt-cached // input the user did not re-pay for must not be billed to the daily meter). @@ -705,6 +712,7 @@ async function handleChatRequest(request: NextRequest): Promise { // out-of-window aggregate rule 3 lets the model cite. Ground it. ...(loop.toolResults ?? []).map((r) => r.data ?? r.available), ]; + inventoryPayloads = [inventory.entries]; toolResultPayloads = presentToolPayloads.length > 0 ? [...presentToolPayloads, inventory.entries] @@ -993,17 +1001,43 @@ async function handleChatRequest(request: NextRequest): Promise { // active grading — they never ACTIVATE it, so a snapshot figure the model // cited on a no-tool turn is still left alone (the v1.32.1 regression guard // holds). Assistant prose is never a ledger source (D3). + // + // The turn that CALLED tools and got nothing back is graded too. It used to + // be the one turn the verifier sat out: a pure miss carries no `data` and no + // `available`, `runCoachToolLoop` drops that shape from `toolResults`, and + // the no-tools snapshot fallback below is populated only in the non-tool + // branch — so `activatingPayloads` came out empty and every figure in the + // reply shipped unchecked. That is backwards. A turn whose every tool + // reported `{ present: false }` is not an absence of evidence about the + // reply, it is evidence that the record holds nothing to cite, which is + // exactly when a fabricated figure is both likeliest and most harmful. + // + // `toolTrace` is the discriminant, because it records every tool that ran + // INCLUDING the pure misses. It separates the two cases the old condition + // conflated: tools ran and found nothing (grade — the model was told the + // record is empty and answered with numbers anyway), versus the model + // answered without calling a tool at all (stay dormant — the v1.32.1 + // regression, where the base prompt deliberately carries no pre-computed + // figures and flagging a legitimately recalled one was a real defect). + const missedEveryTool = + toolMode && toolTrace.length > 0 && toolResultPayloads.length === 0; const activatingPayloads = toolResultPayloads.length > 0 ? toolResultPayloads - : noToolsSnapshotPayloads; + : missedEveryTool + ? inventoryPayloads + : noToolsSnapshotPayloads; let groundedFigures: number[] = []; // v1.32.14 — count of figures withheld from THIS reply (each rewritten to the // elision mark). Hoisted out of the guard block so it can ride the provenance // envelope and drive the quiet per-message notice. Stays 0 on a blocked turn // (the guard is skipped, its fallback prose carries no figures). let unverifiedStripped = 0; - if (activatingPayloads.length > 0) { + // True when the guard stripped a figure on a turn whose every tool missed — + // i.e. the model put a number on a record that holds none. Drives the + // honest-replacement below. + let fabricatedOnEmptyRecord = false; + if (activatingPayloads.length > 0 || missedEveryTool) { const ledger = buildGroundingLedger({ toolPayloads: activatingPayloads, priorToolFigures, @@ -1026,6 +1060,9 @@ async function handleChatRequest(request: NextRequest): Promise { replyText, ledger, locale, + // On an all-missed turn an EMPTY ledger is the finding, not a reason + // to skip: nothing was retrievable, so nothing can reconcile. + { gradeAgainstEmptyLedger: missedEveryTool }, ); if (unverified.length > 0) { const { prose: corrected, stripped } = stripUnverifiedNumbers( @@ -1034,6 +1071,7 @@ async function handleChatRequest(request: NextRequest): Promise { ); replyText = corrected; unverifiedStripped = stripped; + fabricatedOnEmptyRecord = missedEveryTool && stripped > 0; annotate({ action: { name: "coach.prose.number_unverified" }, meta: { @@ -1048,6 +1086,42 @@ async function handleChatRequest(request: NextRequest): Promise { } } + // A stripped reply on an empty record is not worth sending. Eliding the + // digits leaves the sentences that carried them — "your sleep averaged […] + // minutes, up from […]" still asserts a series, an average and a direction + // for a metric the tools just reported has no readings at all. The false + // claim survives the strip; only its precision goes. So replace the turn + // with the honest answer instead of shipping the elided mush. + // + // Scoped tightly: this fires only when every tool missed AND the guard + // actually stripped something. A reply that cited a legitimately grounded + // figure (a prior turn's tool result, an inventory count) reconciles + // against the ledger, strips nothing, and is left alone. + // + // REPLACE, not withhold — the same policy the outbound screen uses on this + // surface, for the same reason: the user is waiting on a synchronous answer + // and silence reads as a failure. + // + // Accepted trade-off: a reply mixing one grounded recall with one + // fabrication is replaced wholesale, losing the good half. That is the + // right way round — the alternative leaves an invented figure's sentence + // on screen. + if (fabricatedOnEmptyRecord) { + replyText = getServerTranslator(locale).t("coach.noRecordedData"); + // The reply now carries no figures at all, so the withheld-figure notice + // would be describing prose the reader can no longer see. The replacement + // copy states the same thing in plain words. + unverifiedStripped = 0; + annotate({ + action: { name: "coach.prose.empty_record_replaced" }, + meta: { promptVersion: PROMPT_VERSION }, + }); + await auditLog("insights.coach.empty_record_replaced", { + userId, + details: { conversationId: workingConversationId }, + }); + } + // v1.21.0 (NEW-C C-3) — Learn-link post-filter. The prompt instructs the // model to only link a published `/learn/`, but that is guidance, not // enforcement: a fabricated `/learn/` would otherwise ship as diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index 7f59ad7b3..c13e4e984 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -241,12 +241,35 @@ describe("", () => { ); }); - it("renders the personal-baseline meta line", () => { - const html = render(); + it("renders the personal-baseline meta line under a real briefing", () => { + const html = render( + , + ); expect(html).toMatch(/data-slot="insights-hero-strip-baseline"/); expect(html).toContain("Based on your last 90 days"); }); + /** + * Inverted from the previous assertion, which rendered with + * `briefing={null}` and still expected the baseline line. + * + * That pinned the defect. The line claims the subtitle above it was drawn + * from the reader's own last 90 days, but with no briefing the subtitle is + * `heroFallbackSubtitle` — fixed copy computed from nothing. Both siblings in + * the same meta row (the freshness caption, the no-provider hint) already + * gate on their own data; this one did not, so an account with no briefing at + * all was told its generic sentence rested on a personal baseline. + */ + it("withholds the personal-baseline meta line when there is no briefing", () => { + const html = render(); + expect(html).not.toMatch(/data-slot="insights-hero-strip-baseline"/); + expect(html).not.toContain("Based on your last 90 days"); + // The fallback subtitle still renders — only the provenance claim goes. + expect(html).toContain( + "A daily read of your trends, drawn straight from the numbers you've logged.", + ); + }); + it("renders the freshness caption when updatedAt is supplied", () => { // v1.22 — the hero freshness line now uses `formatUpdatedLabel` for parity // with the briefing + per-metric cards: a same-day timestamp reads diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 3ba16e578..ef92e9184 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -199,14 +199,24 @@ export function HeroStrip({
- - {t("insights.heroPersonalBaseline")} - + {/* The baseline line describes where the SUBTITLE came from, so it + may only show when the subtitle IS a briefing. Without one the + subtitle falls back to generic copy derived from nothing, and + this line went on claiming a personal baseline underneath it — + the one part of the row that was ungated while both its + siblings already checked for their own data first. */} + {briefing && ( + + {t("insights.heroPersonalBaseline")} + + )} {generatedLine && ( <> - + {briefing && ( + + )} {generatedLine} diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index 199711035..81fdc0277 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -28,8 +28,8 @@ import { apiGet } from "@/lib/api/api-fetch"; * nothing and a cold sub-page warms the cache its siblings reuse. We * never recompute the ranges here: the route is the single source of * truth for the age-based ESH blood-pressure band, the WHO weight / BMI - * band, the Karvonen pulse band, the AASM sleep band, the ADA / DDG - * glucose bands, and the medication / mood targets. + * band, the CDC/NCHS percentile pulse band, the AASM sleep band, the + * ADA / DDG glucose bands, and the medication / mood targets. * * The panel is a compact reference panel carrying the full target * context: diff --git a/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts new file mode 100644 index 000000000..c19d6e9b6 --- /dev/null +++ b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts @@ -0,0 +1,125 @@ +/** + * CHARACTERISATION — which tool results reach `toolResults`, and why it matters. + * + * `toolResults` is not just a trace. The chat route derives + * `activatingPayloads` from it, and the numeric grounding guard runs ONLY when + * that array is non-empty (`insights/chat/route.ts`, the + * `activatingPayloads.length > 0` gate). So whatever the loop drops here, the + * guard never sees. + * + * Two shapes of empty read, from `availability.ts` `resolveEmptyRead`: + * - nothing recorded at all -> `{ present: false, reason: "none" }`, no `available` + * - rows exist out of window -> `{ present: false, reason: "unavailable_in_scope", available }` + * + * Only the second survives `loop.ts`'s push condition. The first is dropped, + * which disarms the grounding guard for that turn. This test pins the drop so + * the consequence is visible at the seam where it happens. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import type { CoachToolResult } from "@/lib/ai/coach/tools/executor"; + +const executeCoachTool = vi.fn<(args?: unknown) => Promise>(); +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 { runCoachToolLoop } from "@/lib/ai/coach/tools/loop"; +import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions"; + +const baseArgs = { + userId: "u1", + providers: [], + system: "sys", + messages: [{ role: "user" as const, content: "how did I sleep?" }], + tools: COACH_TOOL_DEFS, +}; + +describe("coach tool loop — which results reach the grounding guard", () => { + beforeEach(() => { + executeCoachTool.mockReset(); + runRawCompletionWithFallback.mockReset(); + }); + + it("a pure miss (present:false, no available) is dropped from toolResults", async () => { + executeCoachTool.mockResolvedValue({ present: false, reason: "none" }); + runRawCompletionWithFallback + .mockResolvedValueOnce({ + result: { + content: "", + tokensUsed: 30, + model: "mock", + providerType: "anthropic" as const, + toolCalls: [ + { id: "t1", name: "get_sleep", arguments: JSON.stringify({}) }, + ], + finishReason: "tool_calls", + }, + workingProvider: { providerType: "anthropic" }, + fallbackHops: [], + }) + .mockResolvedValueOnce({ + result: { + content: "You averaged 7 h 12 min of sleep.", + tokensUsed: 20, + model: "mock", + providerType: "anthropic" as const, + finishReason: "stop", + }, + workingProvider: { providerType: "anthropic" }, + fallbackHops: [], + }); + + const loop = await runCoachToolLoop(baseArgs); + + // The trace records that the tool ran and found nothing… + expect(loop.toolTrace).toEqual([{ name: "get_sleep", present: false }]); + // …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. + expect(loop.toolResults).toEqual([]); + }); + + it("an out-of-window miss WITH `available` is kept, so the guard stays armed", async () => { + executeCoachTool.mockResolvedValue({ + present: false, + reason: "unavailable_in_scope", + available: { count: 12, reachableWithWindow: "lastYear" }, + } as unknown as CoachToolResult); + runRawCompletionWithFallback + .mockResolvedValueOnce({ + result: { + content: "", + tokensUsed: 30, + model: "mock", + providerType: "anthropic" as const, + toolCalls: [ + { id: "t1", name: "get_sleep", arguments: JSON.stringify({}) }, + ], + finishReason: "tool_calls", + }, + workingProvider: { providerType: "anthropic" }, + fallbackHops: [], + }) + .mockResolvedValueOnce({ + result: { + content: "Nothing in the last 30 days.", + tokensUsed: 20, + model: "mock", + providerType: "anthropic" as const, + finishReason: "stop", + }, + workingProvider: { providerType: "anthropic" }, + fallbackHops: [], + }); + + const loop = await runCoachToolLoop(baseArgs); + expect(loop.toolResults).toHaveLength(1); + }); +}); diff --git a/src/lib/ai/coach/coach-prose-grounding.ts b/src/lib/ai/coach/coach-prose-grounding.ts index 2c4971b77..3c65e2986 100644 --- a/src/lib/ai/coach/coach-prose-grounding.ts +++ b/src/lib/ai/coach/coach-prose-grounding.ts @@ -739,11 +739,23 @@ export function findUnverifiedCoachNumbersInLedger( prose: string, ledger: ReadonlyArray, locale: Locale = "en", + opts: { gradeAgainstEmptyLedger?: boolean } = {}, ): UnverifiedCoachNumber[] { if (!prose) return []; - // No authoritative figures (a qualitative turn / no-tools path) — nothing to - // grade against. The prompt-level grounding rule remains the backstop. - if (ledger.length === 0) return []; + // An empty ledger is ambiguous on its own, so the caller resolves it. + // + // Default (`false`): no authoritative figures were delivered — a qualitative + // turn, or the no-tools path on a cheap follow-up. Nothing to grade against, + // and the prompt-level grounding rule remains the backstop. + // + // `gradeAgainstEmptyLedger: true`: the caller KNOWS this turn went looking + // and came back with nothing — every retrieval tool it called reported + // `{ present: false }`. Then an empty ledger is not "no information", it is + // the positive statement that the record holds nothing to cite, so every + // magnitude in the reply is unreconciled by construction and must be graded + // as such. Only the chat route passes this; the fenced-chat and eval callers + // keep the default. + if (ledger.length === 0 && !opts.gradeAgainstEmptyLedger) return []; const spans = sentenceSpans(prose); const sentenceAt = (index: number): string => { diff --git a/src/lib/ai/coach/snapshot-freshness.ts b/src/lib/ai/coach/snapshot-freshness.ts index 670d365be..7f27fe8e7 100644 --- a/src/lib/ai/coach/snapshot-freshness.ts +++ b/src/lib/ai/coach/snapshot-freshness.ts @@ -16,6 +16,14 @@ * directly, so the answer arrives with the data instead of depending on the * reader working it out. * + * The same pass reconciles each block's coarse (MONTH / YEAR) timeline, served + * from the rollup tier, against the all-time extremes read live beside it. The + * read-swap falls back to live SQL only when a band is EMPTY, so a bucket whose + * rows were hard-deleted with no invalidation keeps being served as current — + * and the block then states two different things about the same record with + * nothing saying which is right. A bucket mean cannot lie outside its own rows' + * extremes, so when it does the band is dropped rather than narrated. + * * Pure and in-place — the snapshot is a plain record by the time it gets here. */ import { @@ -35,6 +43,12 @@ export interface SnapshotAsOf { * the day they were taken on, and must be stated with that day. */ currentForTodayClaims: boolean; + /** + * Set when the block's coarse (MONTH / YEAR) timeline was dropped because it + * could not be reconciled with the live record beside it. Present only in + * that case, so its absence is the normal state and costs no prompt budget. + */ + coarseHistoryWithheld?: true; } function readNewestDaysAgo(block: unknown): number | null { @@ -61,27 +75,117 @@ export function asOfFromDaysAgo(daysAgo: number): SnapshotAsOf { }; } +/** Coarse-band bucket rows are `[bucketStart, mean, min, max]`. */ +type CoarseBucket = [string, number, number, number]; + +/** Rounding headroom, so a float artefact is never read as a contradiction. */ +const RECONCILE_EPSILON = 1e-6; + +function readAllTimeExtremes( + block: unknown, +): { min: number; max: number } | null { + if (typeof block !== "object" || block === null) return null; + const aggregate = (block as { aggregate?: unknown }).aggregate; + if (typeof aggregate !== "object" || aggregate === null) return null; + const min = (aggregate as { allTimeMin?: unknown }).allTimeMin; + const max = (aggregate as { allTimeMax?: unknown }).allTimeMax; + if (typeof min !== "number" || !Number.isFinite(min)) return null; + if (typeof max !== "number" || !Number.isFinite(max)) return null; + return { min, max }; +} + +function readCoarse(block: unknown): Record | null { + if (typeof block !== "object" || block === null) return null; + const timeline = (block as { timeline?: unknown }).timeline; + if (typeof timeline !== "object" || timeline === null) return null; + const coarse = (timeline as { coarse?: unknown }).coarse; + if (typeof coarse !== "object" || coarse === null) return null; + return coarse as Record; +} + +function bucketsOf(coarse: Record, band: string): number[] { + const rows = coarse[band]; + if (!Array.isArray(rows)) return []; + return (rows as CoarseBucket[]) + .map((row) => (Array.isArray(row) ? row[1] : Number.NaN)) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); +} + +/** + * Decide whether a block's coarse timeline can be reconciled with the live + * record it sits beside. + * + * The coarse MONTH / YEAR bands are served from the rollup tier; the aggregate's + * all-time extremes are read live. A bucket mean is an average of real rows, so + * it MUST lie within the all-time min/max of those same rows. When it does not, + * the rollup is describing readings the live read cannot see — the bucket + * outlived the rows it was folded from. + * + * This is an invariant, not a heuristic: no arrangement of existing rows can + * average to a value outside their own extremes. + */ +function coarseContradictsRecord(block: unknown): boolean { + const extremes = readAllTimeExtremes(block); + if (extremes === null) return false; + const coarse = readCoarse(block); + if (coarse === null) return false; + const means = [ + ...bucketsOf(coarse, "monthly"), + ...bucketsOf(coarse, "yearly"), + ]; + if (means.length === 0) return false; + return means.some( + (mean) => + mean < extremes.min - RECONCILE_EPSILON || + mean > extremes.max + RECONCILE_EPSILON, + ); +} + /** * Attach `asOf` to every snapshot block whose aggregate reports a freshest * reading. Blocks with no coverage (narrative memory, plans, the reference * grounding table) are untouched — they carry no measurement to date. * - * Returns the metric keys that were stamped stale, so the caller can annotate - * them: a briefing narrated off a week-old series is worth seeing in the wide - * event, not just in the reader's confusion. + * Also drops a coarse timeline that contradicts the live record beside it. The + * rollup tier's read-swap falls back to live SQL only when a band is EMPTY, so + * a bucket that is merely WRONG — the rows behind it deleted, and no + * invalidation fired — is served as though it were current. The block then + * carried two irreconcilable accounts of the same record (a coarse tail saying + * one thing, all-time extremes saying another) with nothing marking which to + * believe, under an `asOf` stamped from raw reading age that knows nothing + * about rollup age. Narrating a ghost is worse than narrating less, so the + * disputed tail goes and `coarseHistoryWithheld` says it went. + * + * `currentForTodayClaims` stays keyed to raw recency, which is what it means: + * the freshest READING is still as fresh as it was, and suppressing it would + * silence a true present-tense statement to punish a stale history band. + * + * Returns the metric keys stamped stale and the keys whose coarse tail was + * withheld, so the caller can annotate both: a briefing narrated off a week-old + * series is worth seeing in the wide event, and so is a rollup that has drifted + * away from the rows underneath it. */ -export function annotateSnapshotFreshness( - snapshot: Record, -): string[] { +export function annotateSnapshotFreshness(snapshot: Record): { + stale: string[]; + coarseWithheld: string[]; +} { const stale: string[] = []; + const coarseWithheld: string[] = []; for (const [key, block] of Object.entries(snapshot)) { const daysAgo = readNewestDaysAgo(block); if (daysAgo === null) continue; const asOf = asOfFromDaysAgo(daysAgo); + if (coarseContradictsRecord(block)) { + const timeline = (block as { timeline?: Record }) + .timeline; + if (timeline) delete timeline.coarse; + asOf.coarseHistoryWithheld = true; + coarseWithheld.push(key); + } (block as Record).asOf = asOf; if (!asOf.currentForTodayClaims) stale.push(key); } - return stale; + return { stale, coarseWithheld }; } export { TODAY_CLAIM_MAX_AGE_DAYS }; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 7209f983b..3bbb92afc 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -1238,11 +1238,20 @@ async function buildCoachSnapshotImpl( // hero line said "today" about a metric last measured five days earlier. The // stamp sits on the block itself so the age travels with the numbers into // every surface the snapshot feeds — hero, briefing, Coach reply, tools. - const staleBlocks = annotateSnapshotFreshness(compactSnapshot); - if (staleBlocks.length > 0) { + const freshness = annotateSnapshotFreshness(compactSnapshot); + if (freshness.stale.length > 0) { annotate({ action: { name: "coach.snapshot.stale_blocks" }, - meta: { blocks: staleBlocks.sort() }, + meta: { blocks: freshness.stale.sort() }, + }); + } + // A rollup band that no longer reconciles with the rows underneath it is an + // operator-visible defect, not just a narration problem — the bucket is wrong + // on disk and stays wrong until something recomputes it. + if (freshness.coarseWithheld.length > 0) { + annotate({ + action: { name: "coach.snapshot.coarse_withheld" }, + meta: { blocks: freshness.coarseWithheld.sort() }, }); } diff --git a/src/lib/analytics/pulse-targets.ts b/src/lib/analytics/pulse-targets.ts index 4acc0ad56..fb3420941 100644 --- a/src/lib/analytics/pulse-targets.ts +++ b/src/lib/analytics/pulse-targets.ts @@ -68,14 +68,19 @@ export function getPersonalizedPulseTarget( age: number | null, gender: PulseGender, ): PersonalizedPulseTarget { - // AHA fallback for adults when profile context is missing. + // No age to personalise against (missing profile, or under 20 where the + // percentile table does not apply), so this arm returns a fixed band that is + // the same for everyone. The source label says so in as many words: the + // surrounding UI describes the pulse band as age- and sex-based, and a reader + // who falls into this arm needs to know theirs is not. if (age == null || age < 20) { return { greenMin: 60, greenMax: 100, orangeMin: 55, orangeMax: 105, - source: "AHA (adults, resting pulse 60-100 bpm)", + source: + "AHA general adult range (resting pulse 60-100 bpm) — not personalised", }; } diff --git a/src/lib/insights/__tests__/measurement-freshness.test.ts b/src/lib/insights/__tests__/measurement-freshness.test.ts index 4d77ad0d8..91e74c1ba 100644 --- a/src/lib/insights/__tests__/measurement-freshness.test.ts +++ b/src/lib/insights/__tests__/measurement-freshness.test.ts @@ -119,9 +119,11 @@ describe("the Coach snapshot stamp", () => { scope: { sources: ["pulse", "weight"] }, }; - const stale = annotateSnapshotFreshness(snapshot); + const { stale, coarseWithheld } = annotateSnapshotFreshness(snapshot); expect(stale).toEqual(["pulse"]); + // Neither block carries a coarse band, so nothing is disputed. + expect(coarseWithheld).toEqual([]); expect((snapshot.pulse as { asOf: unknown }).asOf).toEqual({ daysAgo: 5, isToday: false, @@ -135,4 +137,63 @@ describe("the Coach snapshot stamp", () => { expect(snapshot.memory).toEqual({ headline: "steady month" }); expect(snapshot.scope).toEqual({ sources: ["pulse", "weight"] }); }); + + /** + * A coarse bucket mean is an average of real rows, so it cannot sit outside + * those rows' own all-time extremes. When it does, the rollup band is + * describing readings the live read cannot see and must not be narrated. + */ + it("drops a coarse band whose bucket mean the live extremes cannot account for", () => { + const snapshot: Record = { + weight: { + aggregate: { + allTimeMin: 80, + allTimeMax: 80, + coverage: { count: 6, newestDaysAgo: 0 }, + }, + timeline: { + recent: [], + coarse: { monthly: [["2026-01-01", 95, 95, 95]], yearly: [] }, + }, + }, + }; + + const { coarseWithheld } = annotateSnapshotFreshness(snapshot); + + expect(coarseWithheld).toEqual(["weight"]); + const weight = snapshot.weight as { + timeline: { coarse?: unknown }; + asOf: { coarseHistoryWithheld?: true; currentForTodayClaims: boolean }; + }; + expect(weight.timeline.coarse).toBeUndefined(); + expect(weight.asOf.coarseHistoryWithheld).toBe(true); + // Raw recency is untouched — the surviving readings are still from today. + expect(weight.asOf.currentForTodayClaims).toBe(true); + }); + + it("keeps a coarse band that reconciles with the live extremes", () => { + const snapshot: Record = { + weight: { + aggregate: { + allTimeMin: 78, + allTimeMax: 96, + coverage: { count: 40, newestDaysAgo: 0 }, + }, + timeline: { + recent: [], + coarse: { monthly: [["2026-01-01", 95, 94, 96]], yearly: [] }, + }, + }, + }; + + const { coarseWithheld } = annotateSnapshotFreshness(snapshot); + + expect(coarseWithheld).toEqual([]); + const weight = snapshot.weight as { + timeline: { coarse?: unknown }; + asOf: { coarseHistoryWithheld?: true }; + }; + expect(weight.timeline.coarse).toBeDefined(); + expect(weight.asOf.coarseHistoryWithheld).toBeUndefined(); + }); }); diff --git a/tests/integration/coach-stale-rollup-tail.test.ts b/tests/integration/coach-stale-rollup-tail.test.ts new file mode 100644 index 000000000..60a579b4b --- /dev/null +++ b/tests/integration/coach-stale-rollup-tail.test.ts @@ -0,0 +1,140 @@ +/** + * REGRESSION — a rollup bucket must not narrate rows that no longer exist. + * + * The read-swap in `tiered-context.ts` falls back to live SQL only when a band + * is EMPTY, so a bucket that is merely WRONG is served as though it were + * current. The one staleness repair, `ensureUserRollupsFresh`, recomputes the + * DAY tier over the trailing 90 days only, and keys off `Measurement.updatedAt` + * — which a HARD delete does not bump. `src/lib/whoop/sync-body.ts` performs + * exactly such a hard `measurement.deleteMany` on a WEIGHT row with no rollup + * invalidation, so this reproduces that shape against a MONTH-band bucket. + * + * Before the fix the Coach snapshot carried two irreconcilable accounts of the + * same record with nothing marking which to believe: `coarse.monthly` reported + * the deleted value while `aggregate.allTimeMin/Max` beside it reported only + * the surviving one, under an `asOf` stamped current because it is derived from + * raw reading age and knows nothing about rollup age. + * + * `annotateSnapshotFreshness` now reconciles the two where they are assembled. + * A bucket mean is an average of real rows, so it must lie within those rows' + * all-time extremes; a mean outside them proves the bucket outlived its rows. + * The disputed coarse tail is dropped and `asOf.coarseHistoryWithheld` records + * that it was — narrating a ghost is worse than narrating less. + * + * `currentForTodayClaims` deliberately stays true here. It means "the freshest + * READING is recent enough for present tense", and it is: the surviving rows + * are from today. Suppressing it would silence a true statement to punish a + * stale history band. + * + * The row on disk is still wrong. The deeper fix is to make the tier notice — + * either compare a bucket's `computedAt` against the type's in-window + * MAX(`updatedAt`), or invalidate on the hard-delete path — and that is a + * larger change than this honesty repair. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; +import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; + +const prisma = getPrismaClient(); +const DAY = 24 * 60 * 60 * 1000; + +/** The value only ever present in the rows we delete. */ +const GHOST_KG = 95; +/** The value the record still holds afterwards. */ +const LIVE_KG = 80; + +interface WeightBlock { + aggregate: { allTimeMin: number; allTimeMax: number }; + timeline: { coarse?: { monthly: Array<[string, number, number, number]> } }; + asOf: { currentForTodayClaims: boolean; coarseHistoryWithheld?: true }; +} + +describe("coach snapshot — a rollup bucket that outlived its rows", () => { + beforeEach(async () => { + await truncateAllTables(prisma); + }); + + it("withholds the coarse tail whose buckets the live record cannot account for", async () => { + const user = await prisma.user.create({ + data: { + email: "stale-rollup@example.test", + username: "stale-rollup", + passwordHash: "x", + }, + }); + const userId = user.id; + + // Rows in the MONTH band (90 days to a year), plus recent rows so the + // snapshot renders a weight block at all. + await prisma.measurement.createMany({ + data: [ + ...Array.from({ length: 12 }, (_, i) => ({ + userId, + type: "WEIGHT" as const, + value: GHOST_KG, + unit: "kg", + source: "WHOOP" as const, + externalId: `stale-${i}`, + measuredAt: new Date(Date.now() - (200 + i) * DAY), + })), + ...Array.from({ length: 6 }, (_, i) => ({ + userId, + type: "WEIGHT" as const, + value: LIVE_KG, + unit: "kg", + measuredAt: new Date(Date.now() - i * DAY), + })), + ], + }); + + await recomputeUserRollups(userId); + + // The hard delete `whoop/sync-body.ts` performs — no rollup invalidation, + // and no `updatedAt` bump for the freshness probe to notice. + const deleted = await prisma.measurement.deleteMany({ + where: { userId, type: "WEIGHT", source: "WHOOP" }, + }); + expect(deleted.count).toBe(12); + + const remaining = await prisma.measurement.findMany({ + where: { userId, type: "WEIGHT" }, + select: { value: true }, + }); + expect(remaining.every((r) => r.value === LIVE_KG)).toBe(true); + + // The MONTH buckets are untouched by the delete. + const buckets = await prisma.measurementRollup.findMany({ + where: { userId, type: "WEIGHT", granularity: "MONTH", source: "WHOOP" }, + select: { mean: true, count: true }, + }); + expect(buckets.length).toBeGreaterThan(0); + expect(buckets.every((b) => b.mean === GHOST_KG)).toBe(true); + + const snap = await buildCoachSnapshot(userId); + const weight = (snap.sections as Record) + .weight as WeightBlock; + + // The all-time extremes, read live, know only the surviving value. + expect(weight.aggregate.allTimeMin).toBe(LIVE_KG); + expect(weight.aggregate.allTimeMax).toBe(LIVE_KG); + + // The coarse tail could not be reconciled with them, so it is gone… + expect(weight.timeline.coarse).toBeUndefined(); + expect(weight.asOf.coarseHistoryWithheld).toBe(true); + + // …and the deleted value reaches no part of what the model is shown. + expect(snap.snapshotJson).not.toContain(String(GHOST_KG)); + + // The freshest reading is still from today, so present tense is still fair. + expect(weight.asOf.currentForTodayClaims).toBe(true); + }); +});