diff --git a/messages/de.json b/messages/de.json index f47b95c2c..67e3f9651 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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..4d689f6bf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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..da064100b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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..19ff3c528 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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..eb36f3640 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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..8976add7f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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 index e3a7763c1..b394f43b9 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts @@ -1,30 +1,26 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; /** - * CHARACTERISATION — when the Coach's numeric grounding guard is armed, and - * when it is not. + * 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. It runs only when `activatingPayloads` is non-empty, and - * on the tool path that array comes solely from `loop.toolResults`. A tool that - * found nothing at all resolves to `{ present: false, reason: "none" }` with no - * `available` payload, and `loop.ts` drops that shape from `toolResults` - * (pinned in `tools-loop-miss-payloads.test.ts`). The no-tools snapshot - * fallback is populated only in the non-tool-mode branch, so on the tool path - * there is nothing to fall back to. + * 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. * - * The consequence, pinned below: on a tool-mode turn where every tool missed — - * i.e. exactly when the user has no data for what they asked about — a figure - * the model invented is streamed and persisted verbatim, and the reply carries - * no `unverifiedFigures` marker, so it reads to the user as fully checked. + * `toolTrace` now discriminates the two cases the old condition conflated: * - * These tests assert the CURRENT behaviour, not the desired one. If the - * activation gate is widened so the guard covers the all-missed turn, they will - * fail — that failure is the fix landing, and the assertions should be flipped - * to the safe expectation at that point. - * - * The first test is the control: with one present tool result the guard is - * armed and the same invented figure is stripped. + * - 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}}}'; @@ -301,6 +297,27 @@ describe("coach chat — grounding-guard activation", () => { }); }); + /** 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 } } }, @@ -314,22 +331,58 @@ describe("coach chat — grounding-guard activation", () => { expect(assistantProvenance()?.unverifiedFigures).toBe(1); }); - it("every tool missed: the guard stays dormant and the invented figure survives", async () => { - // A pure miss never reaches `toolResults` (see tools-loop-miss-payloads), - // so this is what the route sees when the record holds nothing. - stubReply("Your sleep averaged 432 minutes last week.", []); + 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; - expect(assistantContent()).toBe( - "Your sleep averaged 432 minutes last week.", - ); - // No withheld-figure notice either, so the reply reads as verified. + 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("the model called no tool at all: the guard stays dormant", async () => { + 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, 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/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 => {