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

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