mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
0b828e2c5a
commit
5ad66af565
24 files changed
+611
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user