fix(coach): mark withheld figures with an elision mark plus a per-message notice

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 09:29:20 +02:00
1 parent 0b828e2c5a
commit 5ad66af565
24 files changed
+611 -22

No files matched your search

+20
View File
@@ -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
+8 -1
View File
@@ -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
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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();
});
});
@@ -260,6 +260,18 @@ function assistantContent(): string {
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 — 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);
});
});
@@ -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");
});
});
@@ -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");
});
+16 -1
View File
@@ -937,7 +937,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// 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<Response> {
? 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<Response> {
unverified,
);
replyText = corrected;
unverifiedStripped = stripped;
annotate({
action: { name: "coach.prose.number_unverified" },
meta: {
@@ -1112,6 +1120,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
...(!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
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
// 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<CoachMessageDTO>,
): 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(
<MessageThread
conversation={conversationWith({ metricSource: provenance })}
/>,
);
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(
<MessageThread
conversation={conversationWith({
metricSource: { windows: [], metrics: [], unverifiedFigures: 3 },
})}
/>,
);
// 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(
<MessageThread
conversation={conversationWith({
metricSource: { windows: ["last7days"], metrics: ["bp"] },
})}
/>,
);
expect(html).not.toContain('data-slot="coach-unverified-notice"');
});
it("does NOT render when provenance is absent", () => {
const html = render(
<MessageThread conversation={conversationWith({ metricSource: null })} />,
);
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(
<MessageThread
conversation={conversationWith({
providerType: "refusal",
metricSource: { windows: [], metrics: [], unverifiedFigures: 1 },
})}
/>,
);
expect(html).not.toContain('data-slot="coach-unverified-notice"');
});
});
@@ -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 (
<div
data-slot="coach-bubble-assistant"
@@ -658,6 +671,19 @@ function ChatBubbleImpl({
{safeError && content && (
<p className="text-warning text-xs">{safeError}</p>
)}
{/* 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 && (
<p
data-slot="coach-unverified-notice"
className="text-muted-foreground flex items-start gap-1.5 text-xs leading-relaxed"
>
<Info aria-hidden="true" className="mt-0.5 size-3 shrink-0" />
{t("insights.coach.unverifiedFiguresNotice")}
</p>
)}
{/* v1.22 (W5) — accompanying chart(s). The token was already
stripped from the prose by <StreamedProse>; here it mounts the
real, self-fetching Recharts chart for the user's own series.
@@ -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);
});
});
+141 -2
View File
@@ -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();
});
});
+13 -2
View File
@@ -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 };
+130
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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;
+9
View File
@@ -379,6 +379,15 @@ export interface CoachProvenance {
* already persists cited values. Absent when the turn fetched no figures.
*/
groundedFigures?: ReadonlyArray<number>;
/**
* 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;
}
/**
+11
View File
@@ -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,
+8
View File
@@ -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",