mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #825 from MBombeck/verify/coach-grounding
Grade the Coach turn that went looking and found nothing
This commit is contained in:
18 files changed
+1030
-52
No files matched your search
+5
-4
@@ -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": {
|
||||
|
||||
+5
-4
@@ -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": {
|
||||
|
||||
+5
-4
@@ -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": {
|
||||
|
||||
+5
-4
@@ -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": {
|
||||
|
||||
+5
-4
@@ -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": {
|
||||
|
||||
+5
-4
@@ -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": {
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => 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<typeof import("@/lib/ai/coach/types")>(
|
||||
"@/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<unknown> }));
|
||||
vi.mock("@/lib/sse/create-stream", () => ({
|
||||
createSseStream: (
|
||||
producer: (c: {
|
||||
signal: { aborted: boolean };
|
||||
enqueue: () => void;
|
||||
}) => void | Promise<void>,
|
||||
) => {
|
||||
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<Response>;
|
||||
|
||||
function chatReq(body: Record<string, unknown>): 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<typeof vi.fn>).mock.calls;
|
||||
const assistant = calls.find(
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
return (assistant?.[0] as { content: string }).content;
|
||||
}
|
||||
|
||||
function assistantProvenance(): Record<string, unknown> | null | undefined {
|
||||
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const assistant = calls.find(
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
return (
|
||||
assistant?.[0] as {
|
||||
metricSource?: Record<string, unknown> | 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();
|
||||
});
|
||||
});
|
||||
@@ -639,6 +639,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// 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<Response> {
|
||||
// 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<Response> {
|
||||
// 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<Response> {
|
||||
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<Response> {
|
||||
);
|
||||
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<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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/<slug>`, but that is guidance, not
|
||||
// enforcement: a fabricated `/learn/<invented-slug>` would otherwise ship as
|
||||
|
||||
@@ -241,12 +241,35 @@ describe("<HeroStrip>", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the personal-baseline meta line", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
it("renders the personal-baseline meta line under a real briefing", () => {
|
||||
const html = render(
|
||||
<HeroStrip briefing={sampleBriefing} now={morningLocal} />,
|
||||
);
|
||||
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(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
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
|
||||
|
||||
@@ -199,14 +199,24 @@ export function HeroStrip({
|
||||
<ProseBlocks text={subtitle} />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span data-slot="insights-hero-strip-baseline">
|
||||
{t("insights.heroPersonalBaseline")}
|
||||
</span>
|
||||
{/* 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 && (
|
||||
<span data-slot="insights-hero-strip-baseline">
|
||||
{t("insights.heroPersonalBaseline")}
|
||||
</span>
|
||||
)}
|
||||
{generatedLine && (
|
||||
<>
|
||||
<span aria-hidden="true" className="opacity-50">
|
||||
·
|
||||
</span>
|
||||
{briefing && (
|
||||
<span aria-hidden="true" className="opacity-50">
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
<span data-slot="insights-hero-strip-generated">
|
||||
{generatedLine}
|
||||
</span>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<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 { 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);
|
||||
});
|
||||
});
|
||||
@@ -739,11 +739,23 @@ export function findUnverifiedCoachNumbersInLedger(
|
||||
prose: string,
|
||||
ledger: ReadonlyArray<LedgerEntry>,
|
||||
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 => {
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
function bucketsOf(coarse: Record<string, unknown>, 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, unknown>,
|
||||
): string[] {
|
||||
export function annotateSnapshotFreshness(snapshot: Record<string, unknown>): {
|
||||
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<string, unknown> })
|
||||
.timeline;
|
||||
if (timeline) delete timeline.coarse;
|
||||
asOf.coarseHistoryWithheld = true;
|
||||
coarseWithheld.push(key);
|
||||
}
|
||||
(block as Record<string, unknown>).asOf = asOf;
|
||||
if (!asOf.currentForTodayClaims) stale.push(key);
|
||||
}
|
||||
return stale;
|
||||
return { stale, coarseWithheld };
|
||||
}
|
||||
|
||||
export { TODAY_CLAIM_MAX_AGE_DAYS };
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>)
|
||||
.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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user