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

@@ -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.