mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user