mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #610 from MBombeck/release/v1.32.14
release/v1.32.14 — Coach withheld-figure elision mark and per-message notice, plus provenance reload fix
This commit is contained in:
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