From 5ad66af565288422c31464d66e2ebed6a6edf864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 09:29:20 +0200 Subject: [PATCH] fix(coach): mark withheld figures with an elision mark plus a per-message notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the internal "[unverified]" token the grounding guard left in a Coach reply with the editorial elision mark […], and add a quiet muted caption under the bubble explaining that some figures could not be checked against the user's data. The mark holds the slot grammar-safely mid-sentence, before a surviving unit, and inside a range; the notice carries the meaning. The withheld count rides the existing provenance envelope (unverifiedFigures) through the stream, persistence, and reload, and the fenced document-chat path carries a minimal envelope so its notice survives too. New i18n key across all six locales. Also fix an adjacent reload gap: provenanceFromJson restored only a subset of the serialised envelope, so a persisted suggestion card, confirm action card, and the tool trace were dropped on conversation reload despite the render layer expecting them to survive. Restore them defensively alongside the new field. --- CHANGELOG.md | 20 +++ docs/api/openapi.yaml | 9 +- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + package.json | 2 +- public/sw.js | 2 +- .../inbound/[id]/chat/__tests__/route.test.ts | 34 ++++- .../chat/__tests__/route-guard-ledger.test.ts | 21 ++- .../chat/__tests__/route-guard.test.ts | 6 +- .../chat/__tests__/route-tool-mode.test.ts | 6 +- src/app/api/insights/chat/route.ts | 17 ++- .../chat-bubble-unverified-notice.test.tsx | 125 +++++++++++++++ .../insights/coach-panel/chat-bubble.tsx | 26 ++++ .../__tests__/coach-prose-grounding.test.ts | 43 +++++- .../ai/coach/__tests__/persistence.test.ts | 143 +++++++++++++++++- src/lib/ai/coach/coach-prose-grounding.ts | 15 +- src/lib/ai/coach/persistence.ts | 130 ++++++++++++++++ src/lib/ai/coach/types.ts | 9 ++ src/lib/documents/fenced-chat.ts | 11 ++ src/lib/openapi/routes/coach.ts | 8 + 24 files changed, 611 insertions(+), 22 deletions(-) create mode 100644 src/components/insights/coach-panel/__tests__/chat-bubble-unverified-notice.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a29b5c5..4617fa8ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## [Unreleased] +## [1.32.14] — 2026-07-24 + +When the Coach removes a number it could not check against your data, the reply +now reads clearly and tells you what happened. + +- **A withheld figure reads as a quiet omission.** The Coach checks every number + in its answer against the figures it was actually shown, and holds back any it + cannot verify so a drifted value never reaches you as if it were real. That + held-back number used to show up as an internal `[unverified]` marker in the + middle of the sentence. It now appears as a short editorial mark, and a small + muted line under the message tells you that some figures could not be checked + and were left out. The line is worded for each of the six languages and stays + out of the way until there is something to say. +- **Coach action cards and the tool trace survive a reload.** A suggested + reminder, a confirm-to-create action card, and the "what I looked at" tool + trace were saved with the message but dropped when the conversation was + reopened, so they quietly vanished on reload. They are now restored with the + rest of the message, so reopening a thread shows the same cards and trace it + showed the first time. + ## [1.32.13] — 2026-07-24 An Insights assessment card no longer shows the raw model output when a response diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e676736b2..d3b114d83 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.13 + version: 1.32.14 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -24138,6 +24138,13 @@ components: - name - present additionalProperties: false + unverifiedFigures: + description: v1.32.14 — count of numeric tokens the grounding guard withheld from this reply (each rewritten to the + editorial elision mark). Drives the quiet per-message notice. Count only, never the withheld values. Absent + when the turn withheld nothing. + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 required: - windows - metrics diff --git a/messages/de.json b/messages/de.json index 8e1addd18..168a8cd97 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2350,6 +2350,7 @@ "send": "Senden", "stop": "Stopp", "thinking": "Denke nach…", + "unverifiedFiguresNotice": "Einige Zahlen ließen sich nicht mit deinen Daten abgleichen und wurden weggelassen.", "composerPlaceholder": "Frag mich etwas…", "nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen", "fabLabel": "Coach öffnen", diff --git a/messages/en.json b/messages/en.json index 8972c4541..257b7cfbe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2340,6 +2340,7 @@ "send": "Send", "stop": "Stop", "thinking": "Thinking…", + "unverifiedFiguresNotice": "Some figures couldn't be checked against your data and were left out.", "composerPlaceholder": "Ask me anything…", "nudgeBubbleLabel": "New message from the Coach — open chat", "fabLabel": "Open the Coach", diff --git a/messages/es.json b/messages/es.json index 03e2642b8..8ef65c75d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2352,6 +2352,7 @@ "send": "Enviar", "stop": "Detener", "thinking": "Pensando…", + "unverifiedFiguresNotice": "Algunas cifras no se pudieron contrastar con tus datos y se omitieron.", "composerPlaceholder": "Pregúntame algo …", "nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat", "fabLabel": "Abrir el coach", diff --git a/messages/fr.json b/messages/fr.json index 972b19cc6..a40154fb5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2354,6 +2354,7 @@ "send": "Envoyer", "stop": "Arrêter", "thinking": "Réflexion en cours…", + "unverifiedFiguresNotice": "Certains chiffres n'ont pas pu être vérifiés avec tes données et ont été omis.", "composerPlaceholder": "Pose ta question …", "nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat", "fabLabel": "Ouvrir le coach", diff --git a/messages/it.json b/messages/it.json index 016a154c6..dfeb62985 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2344,6 +2344,7 @@ "send": "Invia", "stop": "Interrompi", "thinking": "Sto pensando…", + "unverifiedFiguresNotice": "Alcuni numeri non è stato possibile verificarli con i tuoi dati e sono stati omessi.", "composerPlaceholder": "Chiedimi pure …", "nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat", "fabLabel": "Apri il coach", diff --git a/messages/pl.json b/messages/pl.json index 2126bc36f..33b15221a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2338,6 +2338,7 @@ "send": "Wyślij", "stop": "Zatrzymaj", "thinking": "Myślę…", + "unverifiedFiguresNotice": "Niektórych liczb nie udało się zweryfikować z Twoimi danymi, więc je pominięto.", "composerPlaceholder": "Zapytaj mnie …", "nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat", "fabLabel": "Otwórz trenera", diff --git a/package.json b/package.json index 19832faca..07988043f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.13", + "version": "1.32.14", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 9ac7db314..b612ef307 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.13"; + /* @sw-version-fallback */ "v1.32.14"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts index b177af231..1fa158e40 100644 --- a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts @@ -371,12 +371,42 @@ describe("POST — numeric grounding", () => { .join(""); // 999 is not in the document (which says 160) → soft-stripped. expect(reply).not.toContain("999"); - expect(reply).toContain("[unverified]"); + expect(reply).toContain("[…]"); // The persisted assistant turn carries the corrected prose. const assistantWrite = vi .mocked(appendMessage) .mock.calls.find((c) => c[0].role === "assistant"); - expect(assistantWrite?.[0].content).toContain("[unverified]"); + expect(assistantWrite?.[0].content).toContain("[…]"); + // v1.32.14 — the fenced path rides a MINIMAL provenance envelope so the + // per-message notice renders + survives reload: count only, no windows/metrics. + expect(assistantWrite?.[0].metricSource).toEqual({ + windows: [], + metrics: [], + unverifiedFigures: 1, + }); + }); + + it("persists no provenance when the reply is clean", async () => { + vi.mocked(runStreamingRawCompletionWithFallback).mockResolvedValue({ + result: { + content: "Your LDL reading looks steady overall.", + tokensUsed: 20, + model: "claude", + providerType: "anthropic", + }, + workingProvider: { providerType: "anthropic", instance: {} }, + fallbackHops: [], + } as never); + const res = await POST( + req("doc-1", { message: "how is my ldl?" }) as never, + ctx("doc-1") as never, + ); + await frames(res); + const assistantWrite = vi + .mocked(appendMessage) + .mock.calls.find((c) => c[0].role === "assistant"); + // v1.32.14 — clean reply → metricSourceJson stays null (no notice on reload). + expect(assistantWrite?.[0].metricSource ?? null).toBeNull(); }); }); diff --git a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts index b690a690a..913a32602 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts @@ -260,6 +260,18 @@ function assistantContent(): string { 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 — cross-turn Grounding Ledger (G2)", () => { beforeEach(() => { vi.clearAllMocks(); @@ -301,6 +313,9 @@ describe("coach chat — cross-turn Grounding Ledger (G2)", () => { expect(content).toContain("128"); expect(content).toContain("440"); expect(content).not.toContain("[unverified]"); + expect(content).not.toContain("[…]"); + // v1.32.14 — a fully grounded reply carries NO unverifiedFigures key. + expect(assistantProvenance()?.unverifiedFigures).toBeUndefined(); }); it("does NOT let a number from a prior ASSISTANT REPLY ground the next turn (D3 / H3)", async () => { @@ -331,6 +346,10 @@ describe("coach chat — cross-turn Grounding Ledger (G2)", () => { const content = assistantContent(); expect(content).not.toContain("158"); - expect(content).toContain("[unverified]"); + // v1.32.14 — the withheld figure reads as the editorial elision mark, never + // the old internal QA literal, and the count rides the provenance envelope. + expect(content).toContain("[…]"); + expect(content).not.toContain("[unverified]"); + expect(assistantProvenance()?.unverifiedFigures).toBe(1); }); }); diff --git a/src/app/api/insights/chat/__tests__/route-guard.test.ts b/src/app/api/insights/chat/__tests__/route-guard.test.ts index 8300f5645..abed117d0 100644 --- a/src/app/api/insights/chat/__tests__/route-guard.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard.test.ts @@ -299,7 +299,7 @@ describe("coach chat — Guard I at the route level (G1)", () => { { present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } }, ]); expect(out).toBe(content); - expect(out).not.toContain("[unverified]"); + expect(out).not.toContain("[…]"); }); it("streams dates, ranges and thousands unmangled when they are grounded (tool turn)", async () => { @@ -316,7 +316,7 @@ describe("coach chat — Guard I at the route level (G1)", () => { }, ]); expect(out).toBe(content); - expect(out).not.toContain("[unverified]"); + expect(out).not.toContain("[…]"); }); it("soft-strips a genuinely fabricated figure on a tool turn (the floor still holds)", async () => { @@ -327,7 +327,7 @@ describe("coach chat — Guard I at the route level (G1)", () => { data: { metric: "bp", section: { aggregate: { avgSys30: 128 } } }, }, ]); - expect(out).toContain("[unverified]"); + expect(out).toContain("[…]"); expect(out).not.toContain("138"); }); }); diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index 3e99f2cf6..827007729 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -405,7 +405,7 @@ describe("coach chat — tool-mode routing (F1)", () => { (c) => (c[0] as { role: string }).role === "assistant", ); const content = (assistantCall?.[0] as { content: string }).content; - expect(content).toContain("[unverified]"); + expect(content).toContain("[…]"); expect(content).not.toContain("138"); }); @@ -474,7 +474,7 @@ describe("coach chat — tool-mode routing (F1)", () => { (c) => (c[0] as { role: string }).role === "assistant", ); const content = (assistantCall?.[0] as { content: string }).content; - expect(content).not.toContain("[unverified]"); + expect(content).not.toContain("[…]"); expect(content).toContain("42"); expect(content).toContain("320"); }); @@ -534,7 +534,7 @@ describe("coach chat — tool-mode routing (F1)", () => { (c) => (c[0] as { role: string }).role === "assistant", ); const content = (assistantCall?.[0] as { content: string }).content; - expect(content).not.toContain("[unverified]"); + expect(content).not.toContain("[…]"); expect(content).toContain("130"); }); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index c9a68e4c5..b989f897a 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -937,7 +937,9 @@ async function handleChatRequest(request: NextRequest): Promise { // v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on the Coach prose. Cross- // check every number the model cited against this turn's authoritative figure // set; an unmatched number (transcription / paraphrase drift) is soft-stripped - // to "[unverified]" and annotated. Cheap, non-blocking, and a no-op when there + // to the editorial elision mark `[…]` and annotated (v1.32.14 — the withheld + // count also rides the provenance envelope to drive the per-message notice). + // Cheap, non-blocking, and a no-op when there // is no authoritative set — the prompt-level grounding rule remains the // backstop, exactly like the briefing's "no signals → skip". A blocked turn // already carries canned fallback prose, so skip it. @@ -961,6 +963,11 @@ async function handleChatRequest(request: NextRequest): Promise { ? toolResultPayloads : 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) { const ledger = buildGroundingLedger({ toolPayloads: activatingPayloads, @@ -991,6 +998,7 @@ async function handleChatRequest(request: NextRequest): Promise { unverified, ); replyText = corrected; + unverifiedStripped = stripped; annotate({ action: { name: "coach.prose.number_unverified" }, meta: { @@ -1112,6 +1120,13 @@ async function handleChatRequest(request: NextRequest): Promise { ...(!outbound.block && groundedFigures.length > 0 ? { groundedFigures } : {}), + // v1.32.14 — the count of figures the grounding guard withheld this turn, + // so the quiet "some figures couldn't be checked" notice renders under the + // bubble and survives a conversation reload. Count only, no values. Omitted + // when nothing was withheld. + ...(unverifiedStripped > 0 + ? { unverifiedFigures: unverifiedStripped } + : {}), }; if (sentinel.malformed) { // Graceful degrade: log so ops can spot a provider whose diff --git a/src/components/insights/coach-panel/__tests__/chat-bubble-unverified-notice.test.tsx b/src/components/insights/coach-panel/__tests__/chat-bubble-unverified-notice.test.tsx new file mode 100644 index 000000000..f695129aa --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/chat-bubble-unverified-notice.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +// v1.32.14 — the per-message "some figures couldn't be checked" notice renders +// only on a settled assistant turn whose provenance carries unverifiedFigures ≥ 1. +// Mirror the coach-charts harness: SSR render with the hooks the bubble reaches +// for stubbed out. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "tester", role: "USER", avatarUrl: null }, + isAuthenticated: true, + isLoading: false, + }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined }), + useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }), +})); + +import { MessageThread } from "../message-thread"; +import type { + CoachConversationDetailDTO, + CoachMessageDTO, + CoachProvenance, +} from "@/lib/ai/coach/types"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +// Distinctive substrings free of HTML-escaped characters, so the assertion does +// not couple to how the SSR renderer escapes the apostrophe in "couldn't". +const EN_COPY_HEAD = "be checked against your data and were left out."; + +function conversationWith( + overrides: Partial, +): CoachConversationDetailDTO { + const message: CoachMessageDTO = { + id: "m1", + role: "assistant", + content: "Your systolic was […] mmHg last week.", + createdAt: "2026-07-24T10:00:00.000Z", + metricSource: null, + providerType: "openai", + promptVersion: null, + tokensUsed: null, + model: null, + ...overrides, + }; + return { + id: "c1", + title: "Test", + createdAt: "2026-07-24T10:00:00.000Z", + updatedAt: "2026-07-24T10:00:00.000Z", + messageCount: 1, + fenced: false, + attachmentCount: 0, + messages: [message], + }; +} + +describe("Coach unverified-figures notice", () => { + it("renders under a settled assistant turn when unverifiedFigures ≥ 1", () => { + const provenance: CoachProvenance = { + windows: [], + metrics: [], + unverifiedFigures: 1, + }; + const html = render( + , + ); + expect(html).toContain('data-slot="coach-unverified-notice"'); + // Muted meta treatment (UI-STANDARDS §3) — never a warning colour. + expect(html).toContain("text-muted-foreground"); + }); + + it("carries the localized, count-free copy", () => { + const html = render( + , + ); + // Count-free sentence works for any n even though unverifiedFigures is 3. + expect(html).toContain(EN_COPY_HEAD); + expect(EN_COPY_HEAD).not.toMatch(/\d/); + }); + + it("does NOT render when no figure was withheld", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="coach-unverified-notice"'); + }); + + it("does NOT render when provenance is absent", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="coach-unverified-notice"'); + }); + + it("does NOT render on a refusal turn even with the field set", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="coach-unverified-notice"'); + }); +}); diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 9b58dde49..36b85c097 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -11,6 +11,7 @@ import { ChevronRight, Clock, Copy, + Info, Loader2, RotateCcw, Sparkles, @@ -604,6 +605,18 @@ function ChatBubbleImpl({ ? selectCoachChartTokens(content, metricSource?.metrics) : []; + // v1.32.14 — quiet per-message notice: the grounding guard withheld ≥1 figure + // from this reply (each rewritten to the `[…]` elision mark). Shown only on a + // SETTLED assistant turn — skipped while in-flight, on errored and refusal + // turns — mirroring the token-footer gating. Rides `metricSource` so it survives + // reload with no memo change (the comparator already ref-checks metricSource). + const showUnverifiedNotice = + !inProgress && + !errorCode && + providerType !== "refusal" && + !!content && + (metricSource?.unverifiedFigures ?? 0) > 0; + return (
{safeError}

)} + {/* v1.32.14 — quiet grounding caveat when the guard withheld a figure. + Muted meta (UI-STANDARDS §3), never a warning colour — it is a + grounding caveat, not an error. Static single line, decorative icon, + no interactivity. The `[…]` marks in the prose above point here. */} + {showUnverifiedNotice && ( +

+

+ )} {/* v1.22 (W5) — accompanying chart(s). The token was already stripped from the prose by ; here it mounts the real, self-fetching Recharts chart for the user's own series. diff --git a/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts index 1b1f19e6a..97ed6043a 100644 --- a/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts +++ b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts @@ -18,6 +18,7 @@ import { collectNumericLeaves, findUnverifiedCoachNumbers, stripUnverifiedNumbers, + UNVERIFIED_ELISION_MARK, } from "@/lib/ai/coach/coach-prose-grounding"; describe("collectNumericLeaves", () => { @@ -196,7 +197,7 @@ describe("findUnverifiedCoachNumbers", () => { "Your blood pressure spiked to 138 on July 21st, well above your typical range."; const findings = findUnverifiedCoachNumbers(prose, bpPayload); const { prose: out } = stripUnverifiedNumbers(prose, findings); - expect(out).not.toContain("[unverified]st"); + expect(out).not.toContain(`${UNVERIFIED_ELISION_MARK}st`); expect(out).toBe(prose); }); @@ -450,25 +451,57 @@ describe("stripUnverifiedNumbers", () => { const { prose: out, stripped } = stripUnverifiedNumbers(prose, findings); expect(stripped).toBe(1); expect(out).toContain("128"); - expect(out).toContain("[unverified]"); + expect(out).toContain(UNVERIFIED_ELISION_MARK); expect(out).not.toContain("138"); }); + it("rewrites to the editorial elision mark, not the old QA literal", () => { + // v1.32.14 — the user reads a deliberate omission, never an internal token. + const prose = "Your 10-year risk is 42%."; + const { prose: out } = stripUnverifiedNumbers(prose, [ + { value: 42, source: "42" }, + ]); + expect(UNVERIFIED_ELISION_MARK).toBe("[…]"); + expect(out).not.toContain("[unverified]"); + expect(out).toContain("[…]"); + // Digit-free by construction, so the tokenizer can never re-flag the mark. + expect(/\d/.test(UNVERIFIED_ELISION_MARK)).toBe(false); + }); + + it("reads cleanly before a surviving unit (the unit is not part of the token)", () => { + // The tokenizer flags the bare magnitude, so "128 mmHg" strips to "[…] mmHg". + const prose = "Your systolic was 128 mmHg."; + const { prose: out } = stripUnverifiedNumbers(prose, [ + { value: 128, source: "128" }, + ]); + expect(out).toBe("Your systolic was […] mmHg."); + expect(out).not.toContain("[unverified]"); + }); + + it("reads cleanly as a range endpoint", () => { + const prose = "between 118 and 130 mmHg"; + const { prose: out } = stripUnverifiedNumbers(prose, [ + { value: 118, source: "118" }, + ]); + expect(out).toBe("between […] and 130 mmHg"); + }); + it("is a no-op when nothing was flagged", () => { const { prose, stripped } = stripUnverifiedNumbers("all good", []); expect(prose).toBe("all good"); expect(stripped).toBe(0); + expect(prose).not.toContain(UNVERIFIED_ELISION_MARK); }); it("is boundary-safe: a flagged '23' never clips a grounded '2023' (v1.32.7)", () => { - // The old plain indexOf produced "20[unverified]" here. + // The old plain indexOf produced "20[…]" here. const prose = "Since 2023 your reading of 23 stood out."; const { prose: out, stripped } = stripUnverifiedNumbers(prose, [ { value: 23, source: "23" }, ]); expect(out).toContain("2023"); - expect(out).not.toContain("20[unverified]"); - expect(out).toContain("[unverified]"); + expect(out).not.toContain(`20${UNVERIFIED_ELISION_MARK}`); + expect(out).toContain(UNVERIFIED_ELISION_MARK); expect(stripped).toBe(1); }); }); diff --git a/src/lib/ai/coach/__tests__/persistence.test.ts b/src/lib/ai/coach/__tests__/persistence.test.ts index 6e10ee7ee..e57018032 100644 --- a/src/lib/ai/coach/__tests__/persistence.test.ts +++ b/src/lib/ai/coach/__tests__/persistence.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; // exercised without a real Postgres / encryption key. The transaction // runner simply invokes the callback with the stubbed `tx`. const txCreate = { - coachConversation: { create: vi.fn() }, + coachConversation: { create: vi.fn(), update: vi.fn() }, coachMessage: { create: vi.fn() }, }; vi.mock("@/lib/db", () => ({ @@ -21,8 +21,13 @@ vi.mock("../bytes-codec", () => ({ decryptFromBytes: vi.fn(), })); -import { recordProactiveNudge, summariseTitle } from "../persistence"; +import { + appendMessage, + recordProactiveNudge, + summariseTitle, +} from "../persistence"; import { encryptToBytes } from "../bytes-codec"; +import type { CoachProvenance } from "../types"; describe("summariseTitle", () => { it("returns the input unchanged when below 80 chars", () => { @@ -111,3 +116,137 @@ describe("recordProactiveNudge", () => { ); }); }); + +describe("appendMessage — provenance round-trip", () => { + // Echo the serialised metricSourceJson the write path produced, so the read + // path (`provenanceFromJson`) round-trips it exactly as a reload would. + function stubEchoCreate(overrideJson?: string | null): void { + txCreate.coachConversation.update.mockResolvedValue({}); + txCreate.coachMessage.create.mockImplementation( + async (arg: { + data: { + metricSourceJson: string | null; + providerType: string | null; + promptVersion: string | null; + tokensUsed: number | null; + model: string | null; + }; + }) => ({ + id: "m_rt", + createdAt: new Date("2026-07-24T00:00:00.000Z"), + metricSourceJson: + overrideJson !== undefined ? overrideJson : arg.data.metricSourceJson, + providerType: arg.data.providerType, + promptVersion: arg.data.promptVersion, + tokensUsed: arg.data.tokensUsed, + model: arg.data.model, + }), + ); + } + + it("restores the FULL envelope on reload — including suggestion, suggestedAction and toolCalls (v1.32.14 reload fix)", async () => { + stubEchoCreate(); + const envelope: CoachProvenance = { + windows: ["last30days"], + metrics: ["bp"], + counts: { bp: 12 }, + keyValues: [{ label: "Systolic", value: "128", unit: "mmHg" }], + groundedFigures: [128], + suggestion: { + cadenceId: "bp-weekly", + measurementType: "bp", + label: "coach.suggest.bp", + }, + suggestedAction: { + actionType: "reminder.note", + summary: "Recheck your evening reading", + titleKey: "coach.suggestedAction.reminder.note", + params: { + actionType: "reminder.note", + note: "Recheck BP in the evening", + when: "evening", + metric: "bp", + }, + }, + toolCalls: [{ name: "get_metric_series", present: true }], + unverifiedFigures: 2, + }; + + const out = await appendMessage({ + conversationId: "conv_1", + role: "assistant", + content: "Your systolic averaged 128.", + metricSource: envelope, + }); + + // Before the fix, suggestion/suggestedAction/toolCalls were dropped here. + expect(out.metricSource).toEqual(envelope); + }); + + it("round-trips a checkup.create action card", async () => { + stubEchoCreate(); + const envelope: CoachProvenance = { + windows: [], + metrics: [], + suggestedAction: { + actionType: "checkup.create", + summary: "Book a yearly check-up", + titleKey: "coach.suggestedAction.checkup.create", + params: { + actionType: "checkup.create", + label: "Annual physical", + interval: "yearly", + }, + }, + }; + const out = await appendMessage({ + conversationId: "conv_1", + role: "assistant", + content: "Consider a yearly check-up.", + metricSource: envelope, + }); + expect(out.metricSource).toEqual(envelope); + }); + + it("tolerates a legacy blob without the new fields (no throw, fields absent)", async () => { + // A pre-feature row: only the original windows/metrics envelope on disk. + stubEchoCreate( + JSON.stringify({ windows: ["last7days"], metrics: ["sleep"] }), + ); + const out = await appendMessage({ + conversationId: "conv_1", + role: "assistant", + content: "old row", + metricSource: { windows: [], metrics: [] }, + }); + expect(out.metricSource).toEqual({ + windows: ["last7days"], + metrics: ["sleep"], + }); + expect(out.metricSource?.unverifiedFigures).toBeUndefined(); + expect(out.metricSource?.suggestion).toBeUndefined(); + }); + + it("drops a malformed unverifiedFigures / suggestedAction rather than trusting it", async () => { + stubEchoCreate( + JSON.stringify({ + windows: [], + metrics: [], + unverifiedFigures: -3, // negative → dropped + suggestion: { cadenceId: 42 }, // wrong type → dropped + suggestedAction: { actionType: "medication.create" }, // off-allowlist → dropped + toolCalls: [{ name: "ok", present: "yes" }], // present not boolean → dropped + }), + ); + const out = await appendMessage({ + conversationId: "conv_1", + role: "assistant", + content: "malformed", + metricSource: { windows: [], metrics: [] }, + }); + expect(out.metricSource?.unverifiedFigures).toBeUndefined(); + expect(out.metricSource?.suggestion).toBeUndefined(); + expect(out.metricSource?.suggestedAction).toBeUndefined(); + expect(out.metricSource?.toolCalls).toBeUndefined(); + }); +}); diff --git a/src/lib/ai/coach/coach-prose-grounding.ts b/src/lib/ai/coach/coach-prose-grounding.ts index 6c50827c5..2c4971b77 100644 --- a/src/lib/ai/coach/coach-prose-grounding.ts +++ b/src/lib/ai/coach/coach-prose-grounding.ts @@ -878,6 +878,17 @@ export function hasThresholdVerdict(prose: string): boolean { return THRESHOLD_VERDICT_PATTERNS.some((p) => p.test(prose)); } +/** + * The editorial elision mark that replaces each withheld numeric token + * (bracketed U+2026 ellipsis). Chosen over a localised noun phrase because it is + * grammar-safe in every slot the tokenizer produces — mid-sentence, before a + * surviving unit ("[…] mmHg"), and as a range endpoint — and locale-invariant, so + * the server needs no i18n table. It carries no digit, so the tokenizer can never + * re-flag it. The per-message notice (`insights.coach.unverifiedFiguresNotice`) + * carries the meaning; this mark only holds the slot. + */ +export const UNVERIFIED_ELISION_MARK = "[…]"; + /** * Soft-correct the prose: replace each unverified numeric token with a neutral * placeholder so a drifted figure never reaches the user as if authoritative, @@ -886,7 +897,7 @@ export function hasThresholdVerdict(prose: string): boolean { * first occurrence of each. * * Boundary-safe (v1.32.7): the flagged token is matched only where it is NOT - * embedded in a larger number, so a flagged "23" can never clip "20[unverified]" + * embedded in a larger number, so a flagged "23" can never clip "20[…]" * out of a grounded "2023". The old plain `indexOf` had that exact bug. * * Returns the (possibly unchanged) prose plus the count of tokens stripped. @@ -911,7 +922,7 @@ export function stripUnverifiedNumbers( const match = bounded.exec(out); if (match === null) continue; const idx = match.index; - out = `${out.slice(0, idx)}[unverified]${out.slice(idx + token.length)}`; + out = `${out.slice(0, idx)}${UNVERIFIED_ELISION_MARK}${out.slice(idx + token.length)}`; stripped += 1; } return { prose: out, stripped }; diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 17c5f1253..bad27134f 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -13,6 +13,11 @@ import { prisma } from "@/lib/db"; import { decryptFromBytes, encryptToBytes } from "./bytes-codec"; import { COACH_CONVERSATION_TITLE_MAX } from "./types"; +import { + isCheckupIntervalId, + isSuggestedActionType, + type CoachSuggestedAction, +} from "./suggest-action"; import type { CoachConversationAttachmentDTO, @@ -21,6 +26,7 @@ import type { CoachMessageDTO, CoachMessageRole, CoachProvenance, + CoachSuggestion, } from "./types"; /** @@ -55,6 +61,107 @@ function provenanceToJson(provenance: CoachProvenance | null): string | null { return JSON.stringify(provenance); } +/** + * v1.32.14 — defensive restore of the cadence-suggestion card. The value was + * server-written (JSON.stringify of the envelope the server itself built), yet a + * persisted blob is parsed shape-first and never blindly trusted: every field + * must be the expected type or the whole card is dropped, mirroring the keyValues + * restore. Fixes the reload gap where a persisted card silently vanished. + */ +function restoreSuggestion(raw: unknown): CoachSuggestion | undefined { + if (!raw || typeof raw !== "object") return undefined; + const c = raw as Record; + if ( + typeof c.cadenceId === "string" && + typeof c.measurementType === "string" && + typeof c.label === "string" + ) { + return { + cadenceId: c.cadenceId, + measurementType: c.measurementType, + label: c.label, + }; + } + return undefined; +} + +/** + * v1.32.14 — defensive restore of the confirm→apply action card. Validates the + * discriminated `params` against the SAME closed allowlists the write path used + * (`isSuggestedActionType`, `isCheckupIntervalId`); anything that fails the shape + * check is dropped rather than trusted. Fixes the reload gap. + */ +function restoreSuggestedAction( + raw: unknown, +): CoachSuggestedAction | undefined { + if (!raw || typeof raw !== "object") return undefined; + const c = raw as Record; + if ( + typeof c.actionType !== "string" || + !isSuggestedActionType(c.actionType) || + typeof c.summary !== "string" || + typeof c.titleKey !== "string" || + !c.params || + typeof c.params !== "object" + ) { + return undefined; + } + const p = c.params as Record; + if (c.actionType === "checkup.create") { + if ( + p.actionType === "checkup.create" && + typeof p.label === "string" && + typeof p.interval === "string" && + isCheckupIntervalId(p.interval) + ) { + return { + actionType: "checkup.create", + summary: c.summary, + titleKey: c.titleKey, + params: { + actionType: "checkup.create", + label: p.label, + interval: p.interval, + }, + }; + } + return undefined; + } + // reminder.note — `note` required, `when` / `metric` optional strings. + if (p.actionType === "reminder.note" && typeof p.note === "string") { + return { + actionType: "reminder.note", + summary: c.summary, + titleKey: c.titleKey, + params: { + actionType: "reminder.note", + note: p.note, + ...(typeof p.when === "string" ? { when: p.when } : {}), + ...(typeof p.metric === "string" ? { metric: p.metric } : {}), + }, + }; + } + return undefined; +} + +/** + * v1.32.14 — defensive restore of the retrieval-tool trace ("what I looked at"). + * Keeps only well-formed `{ name, present }` entries; an empty or malformed array + * restores to undefined. Fixes the reload gap. + */ +function restoreToolCalls(raw: unknown): CoachProvenance["toolCalls"] { + if (!Array.isArray(raw)) return undefined; + const cleaned: Array<{ name: string; present: boolean }> = []; + for (const item of raw) { + if (!item || typeof item !== "object") continue; + const c = item as Record; + if (typeof c.name === "string" && typeof c.present === "boolean") { + cleaned.push({ name: c.name, present: c.present }); + } + } + return cleaned.length > 0 ? cleaned : undefined; +} + function provenanceFromJson(raw: string | null): CoachProvenance | null { if (!raw) return null; try { @@ -123,12 +230,35 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null { ); if (nums.length > 0) groundedFigures = nums; } + // v1.32.14 — count of figures the grounding guard withheld from this reply. + // Positive integer only; a legacy row without the field, a non-number, or a + // zero/negative value restores to undefined (no notice). + let unverifiedFigures: number | undefined; + if ( + typeof parsed.unverifiedFigures === "number" && + Number.isInteger(parsed.unverifiedFigures) && + parsed.unverifiedFigures > 0 + ) { + unverifiedFigures = parsed.unverifiedFigures; + } + // v1.32.14 — the action cards + tool trace were serialised on write but + // previously dropped here, so they silently vanished on every conversation + // reload despite the render layer expecting them to survive. Restore them + // (defensively parsed) so the suggestion card, action card, and "what I + // looked at" trace re-render. + const suggestion = restoreSuggestion(parsed.suggestion); + const suggestedAction = restoreSuggestedAction(parsed.suggestedAction); + const toolCalls = restoreToolCalls(parsed.toolCalls); return { windows, metrics, counts, ...(keyValues ? { keyValues } : {}), ...(groundedFigures ? { groundedFigures } : {}), + ...(suggestion ? { suggestion } : {}), + ...(suggestedAction ? { suggestedAction } : {}), + ...(toolCalls ? { toolCalls } : {}), + ...(unverifiedFigures !== undefined ? { unverifiedFigures } : {}), }; } catch { return null; diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index c826fa190..ef02d12c4 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -379,6 +379,15 @@ export interface CoachProvenance { * already persists cited values. Absent when the turn fetched no figures. */ groundedFigures?: ReadonlyArray; + /** + * v1.32.14 — the count of numeric tokens the post-hoc grounding guard withheld + * from this reply (each rewritten to the editorial elision mark `[…]`). Drives + * the quiet per-message "some figures couldn't be checked" notice under the + * bubble. COUNT ONLY, never the withheld values — same privacy posture as the + * rest of the plaintext `metricSourceJson` envelope. Absent (omitted) when the + * turn withheld nothing. + */ + unverifiedFigures?: number; } /** diff --git a/src/lib/documents/fenced-chat.ts b/src/lib/documents/fenced-chat.ts index 6e08b38c9..a09dd161d 100644 --- a/src/lib/documents/fenced-chat.ts +++ b/src/lib/documents/fenced-chat.ts @@ -445,6 +445,9 @@ Reply now as the assistant, grounded ONLY in the documents above, in ${ } // ── Numeric grounding — authoritative set = the LIVE attachments' numbers ── + // v1.32.14 — count of figures withheld from this reply (each rewritten to the + // elision mark), hoisted so the minimal provenance envelope below carries it. + let unverifiedStripped = 0; if (!outbound.block) { const unverified = findUnverifiedCoachNumbers( replyText, @@ -457,6 +460,7 @@ Reply now as the assistant, grounded ONLY in the documents above, in ${ unverified, ); replyText = prose; + unverifiedStripped = stripped; annotate({ action: { name: "documents.chat.number_unverified" }, meta: { @@ -472,6 +476,13 @@ Reply now as the assistant, grounded ONLY in the documents above, in ${ conversationId, role: "assistant", content: replyText, + // v1.32.14 — the fenced path otherwise persists no provenance; when the + // guard withheld a figure, ride a MINIMAL envelope so the per-message notice + // renders and survives reload. Count only, no values, no windows/metrics. + metricSource: + unverifiedStripped > 0 + ? { windows: [], metrics: [], unverifiedFigures: unverifiedStripped } + : null, providerType: pick!.providerType, tokensUsed: totalTokens || null, model: result.model ?? null, diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 68dd521c5..34988b5ea 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -453,6 +453,14 @@ const coachProvenanceSchema = z .describe( "v1.20.0 — the retrieval-tool trace for this turn: which tools the Coach called and whether each found data. Metadata only (no values). Absent on the legacy snapshot path and on turns that called no tools.", ), + unverifiedFigures: z + .number() + .int() + .positive() + .optional() + .describe( + "v1.32.14 — count of numeric tokens the grounding guard withheld from this reply (each rewritten to the editorial elision mark). Drives the quiet per-message notice. Count only, never the withheld values. Absent when the turn withheld nothing.", + ), }) .meta({ id: "CoachProvenance",