From d49479bc644ea2881d87a4dbeab1a5fa4d15b392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 15:30:41 +0200 Subject: [PATCH 1/4] Pin where the Coach stops checking its own numbers Three characterisation tests for grounding gaps found while verifying that every figure the Coach states comes from the record. The numeric grounding guard rewrites any figure the turn's Grounding Ledger cannot account for, and it holds: removing the strip reddens the existing cross-turn ledger test. What is not covered is its activation. On the tool path the guard runs only when `loop.toolResults` is non-empty, and a tool that found nothing resolves to `{ present: false, reason: "none" }` with no `available` payload, which the loop drops. So the guard disarms itself exactly when the record is empty: ask about a metric with no readings, and an invented figure is persisted and streamed verbatim, with no withheld-figure marker to tell the reader it was never checked. The no-tools snapshot fallback is populated only in the non-tool-mode branch, so the tool path has nothing to fall back to, and every cloud provider runs the tool path. Separately, a rollup bucket outlives the rows it was folded from. The read-swap falls back to live SQL only on an empty band, `ensureUserRollupsFresh` repairs the DAY tier over the trailing 90 days only, and it keys off `Measurement.updatedAt`, which a hard delete does not bump. The integration test reproduces the hard `deleteMany` in the WHOOP body sync against a MONTH-band bucket: the deleted value keeps reaching the Coach's coarse tail while the all-time extremes beside it report only the surviving value, and the block is stamped current because `asOf` is derived from raw reading age and knows nothing about rollup age. All three assert current behaviour and carry a docblock saying so. Closing either gap will redden them, which is the point. --- .../__tests__/route-guard-activation.test.ts | 356 ++++++++++++++++++ .../tools-loop-miss-payloads.test.ts | 125 ++++++ .../coach-stale-rollup-tail.test.ts | 133 +++++++ 3 files changed, 614 insertions(+) create mode 100644 src/app/api/insights/chat/__tests__/route-guard-activation.test.ts create mode 100644 src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts create mode 100644 tests/integration/coach-stale-rollup-tail.test.ts diff --git a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts new file mode 100644 index 000000000..e3a7763c1 --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts @@ -0,0 +1,356 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * CHARACTERISATION — when the Coach's numeric grounding guard is armed, and + * when it is not. + * + * The guard rewrites any figure in the reply that the turn's Grounding Ledger + * cannot account for. It runs only when `activatingPayloads` is non-empty, and + * on the tool path that array comes solely from `loop.toolResults`. A tool that + * found nothing at all resolves to `{ present: false, reason: "none" }` with no + * `available` payload, and `loop.ts` drops that shape from `toolResults` + * (pinned in `tools-loop-miss-payloads.test.ts`). The no-tools snapshot + * fallback is populated only in the non-tool-mode branch, so on the tool path + * there is nothing to fall back to. + * + * The consequence, pinned below: on a tool-mode turn where every tool missed — + * i.e. exactly when the user has no data for what they asked about — a figure + * the model invented is streamed and persisted verbatim, and the reply carries + * no `unverifiedFigures` marker, so it reads to the user as fully checked. + * + * These tests assert the CURRENT behaviour, not the desired one. If the + * activation gate is widened so the guard covers the all-missed turn, they will + * fail — that failure is the fix landing, and the assertions should be flipped + * to the safe expectation at that point. + * + * The first test is the control: with one present tool result the guard is + * armed and the same invented figure is stripped. + */ + +const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}'; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + HttpError: class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }, +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + isModuleEnabled: vi.fn(async () => true), +})); +vi.mock("@/lib/feature-flags", () => ({ + requireAssistantSurface: vi.fn(async () => undefined), +})); +vi.mock("@/lib/api-response", () => ({ + apiError: (error: string, status: number) => ({ data: null, error, status }), + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(), +})); +vi.mock("@/lib/logging/redact", () => ({ + redactSecrets: (s: string) => s, + redactOptional: (s: unknown) => s, +})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) }, + coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) }, + }, +})); + +const { checkRateLimit } = vi.hoisted(() => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit })); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: vi.fn(async () => "en"), +})); + +const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({ + runStreamingRawCompletionWithFallback: vi.fn(), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runStreamingRawCompletionWithFallback, +})); + +const { resolveProviderChain } = vi.hoisted(() => ({ + resolveProviderChain: vi.fn(), +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain, + resolveProvider: vi.fn(), +})); + +const { assertConsentForChain } = vi.hoisted(() => ({ + assertConsentForChain: vi.fn(async () => undefined), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain })); +vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" })); +vi.mock("@/lib/ai/ai-budgets", () => ({ + AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } }, +})); + +vi.mock("@/lib/ai/coach/types", async () => { + const actual = await vi.importActual( + "@/lib/ai/coach/types", + ); + return actual; +}); + +const { fetchConversationWithMessages } = vi.hoisted(() => ({ + fetchConversationWithMessages: vi.fn(), +})); +vi.mock("@/lib/ai/coach/persistence", () => ({ + appendMessage: vi.fn(async () => ({ id: "m1" })), + createConversation: vi.fn(async () => ({ id: "c1" })), + fetchConversationWithMessages, + listConversations: vi.fn(), +})); +vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({ + enqueueCoachMemoryRefresh: vi.fn(), +})); +vi.mock("@/lib/ai/coach/facts", () => ({ + storeDeterministicFacts: vi.fn(async () => undefined), +})); + +// Guard II — the schedule read. Empty here; the schedule-gated dose rule is +// unit-tested in the outbound-screen suite. +vi.mock("@/lib/medications/scheduled-doses", () => ({ + getScheduledDoseValues: vi.fn(async () => []), +})); + +const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({ + reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 3000 })), + reconcileSpend: vi.fn(async () => undefined), +})); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(() => "2026-07-23"), + reserveBudget, + reconcileSpend, + resolveDailyCap: vi.fn(() => 2_000_000), +})); + +const { detectRefusal } = vi.hoisted(() => ({ + detectRefusal: vi.fn(() => ({ refuse: false })), +})); +vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal })); +vi.mock("@/lib/ai/coach/outbound-guard", () => ({ + screenCoachReply: vi.fn(() => ({ block: false })), + coachOutboundFallback: vi.fn(() => "fallback"), +})); +vi.mock("@/lib/ai/coach/system-prompt", () => ({ + getCoachSystemPrompt: vi.fn(() => "SYSTEM"), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/coach/snapshot", () => ({ + buildCoachSnapshot: vi.fn(async () => ({ + snapshotJson: SNAPSHOT_JSON, + sections: { bloodPressure: { aggregate: { mean: 128 } } }, + provenance: { windows: ["last30days"], metrics: ["bp"] }, + referenceGrounding: "REFERENCE RANGES", + })), +})); +vi.mock("@/lib/workouts/hr-series", () => ({ + buildWorkoutHrSeries: vi.fn(async () => null), +})); +vi.mock("@/lib/workouts/zones", () => ({ + computeZones: vi.fn(() => null), + hrMaxFromAge: vi.fn(() => 185), + parseWhoopZoneDurations: vi.fn(() => null), +})); +vi.mock("@/lib/workouts/sport-context", () => ({ + buildSportContext: vi.fn(async () => null), +})); + +const { + buildCoachDataInventory, + renderDataInventory, + renderFocusHint, + runCoachToolLoop, +} = vi.hoisted(() => ({ + buildCoachDataInventory: vi.fn(async () => ({ + entries: [], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: ["bp"], window: "last30days" }, + })), + renderDataInventory: vi.fn(() => "DATA INVENTORY\n- blood pressure: present"), + renderFocusHint: vi.fn(() => ""), + runCoachToolLoop: vi.fn(), +})); +vi.mock("@/lib/ai/coach/tools", () => ({ + COACH_TOOL_DEFS: [{ name: "get_metric_series" }], + MAX_ROUNDS: 3, + buildCoachDataInventory, + renderDataInventory, + renderFocusHint, + buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"), + runCoachToolLoop, +})); + +const { parseKeyValuesSentinel } = vi.hoisted(() => ({ + parseKeyValuesSentinel: vi.fn(), +})); +vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel })); +const { parseSuggestReminder } = vi.hoisted(() => ({ + parseSuggestReminder: vi.fn(), +})); +vi.mock("@/lib/ai/coach/suggest-reminder", () => ({ parseSuggestReminder })); +vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() })); +vi.mock("@/lib/validations/coach-prefs", () => ({ + parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })), + DEFAULT_REMINDER_SUGGESTION_PREFS: {}, +})); + +const { appendMessage } = await import("@/lib/ai/coach/persistence"); + +const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise })); +vi.mock("@/lib/sse/create-stream", () => ({ + createSseStream: ( + producer: (c: { + signal: { aborted: boolean }; + enqueue: () => void; + }) => void | Promise, + ) => { + sse.done = Promise.resolve( + producer({ signal: { aborted: false }, enqueue: () => {} }), + ); + return new ReadableStream(); + }, +})); + +import { POST } from "../route"; + +const post = POST as unknown as (req: Request) => Promise; + +function chatReq(body: Record): Request { + return new Request("http://localhost/api/insights/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** Force the model's assembled prose through the sentinel/suggest parsers. */ +function stubReply(prose: string, toolResults: unknown[]): void { + parseKeyValuesSentinel.mockReturnValue({ + prose, + keyValues: [], + malformed: false, + malformedEntries: [], + }); + parseSuggestReminder.mockReturnValue({ prose }); + runCoachToolLoop.mockImplementation(async () => ({ + result: { content: prose, tokensUsed: 80, model: "m" }, + workingProviderType: "anthropic", + totalTokens: 80, + rounds: 1, + toolTrace: [{ name: "get_metric_series", present: true }], + toolResults, + })); +} + +function assistantContent(): string { + const calls = (appendMessage as ReturnType).mock.calls; + const assistant = calls.find( + (c) => (c[0] as { role: string }).role === "assistant", + ); + return (assistant?.[0] as { content: string }).content; +} + +function assistantProvenance(): Record | null | undefined { + const calls = (appendMessage as ReturnType).mock.calls; + const assistant = calls.find( + (c) => (c[0] as { role: string }).role === "assistant", + ); + return ( + assistant?.[0] as { + metricSource?: Record | null; + } + ).metricSource; +} + +describe("coach chat — grounding-guard activation", () => { + beforeEach(() => { + vi.clearAllMocks(); + reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 }); + detectRefusal.mockReturnValue({ refuse: false }); + checkRateLimit.mockResolvedValue({ allowed: true }); + assertConsentForChain.mockResolvedValue(undefined); + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + ]); + fetchConversationWithMessages.mockResolvedValue({ + id: "c1", + attachmentCount: 0, + summary: null, + messages: [], + }); + }); + + it("CONTROL — one present tool result arms the guard: the invented figure is stripped", async () => { + stubReply("Your sleep averaged 432 minutes last week.", [ + { present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } }, + ]); + + await post(chatReq({ conversationId: "c1", message: "how did I sleep?" })); + await sse.done; + + expect(assistantContent()).not.toContain("432"); + expect(assistantContent()).toContain("[…]"); + expect(assistantProvenance()?.unverifiedFigures).toBe(1); + }); + + it("every tool missed: the guard stays dormant and the invented figure survives", async () => { + // A pure miss never reaches `toolResults` (see tools-loop-miss-payloads), + // so this is what the route sees when the record holds nothing. + stubReply("Your sleep averaged 432 minutes last week.", []); + + await post(chatReq({ conversationId: "c1", message: "how did I sleep?" })); + await sse.done; + + expect(assistantContent()).toBe( + "Your sleep averaged 432 minutes last week.", + ); + // No withheld-figure notice either, so the reply reads as verified. + expect(assistantProvenance()?.unverifiedFigures).toBeUndefined(); + }); + + it("the model called no tool at all: the guard stays dormant", async () => { + const prose = "Your resting heart rate has been averaging 58 bpm."; + parseKeyValuesSentinel.mockReturnValue({ + prose, + keyValues: [], + malformed: false, + malformedEntries: [], + }); + parseSuggestReminder.mockReturnValue({ prose }); + runCoachToolLoop.mockImplementation(async () => ({ + result: { content: prose, tokensUsed: 80, model: "m" }, + workingProviderType: "anthropic", + totalTokens: 80, + rounds: 1, + toolTrace: [], + toolResults: [], + })); + + await post(chatReq({ conversationId: "c1", message: "how is my RHR?" })); + await sse.done; + + expect(assistantContent()).toBe(prose); + expect(assistantProvenance()?.unverifiedFigures).toBeUndefined(); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts new file mode 100644 index 000000000..c19d6e9b6 --- /dev/null +++ b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts @@ -0,0 +1,125 @@ +/** + * CHARACTERISATION — which tool results reach `toolResults`, and why it matters. + * + * `toolResults` is not just a trace. The chat route derives + * `activatingPayloads` from it, and the numeric grounding guard runs ONLY when + * that array is non-empty (`insights/chat/route.ts`, the + * `activatingPayloads.length > 0` gate). So whatever the loop drops here, the + * guard never sees. + * + * Two shapes of empty read, from `availability.ts` `resolveEmptyRead`: + * - nothing recorded at all -> `{ present: false, reason: "none" }`, no `available` + * - rows exist out of window -> `{ present: false, reason: "unavailable_in_scope", available }` + * + * Only the second survives `loop.ts`'s push condition. The first is dropped, + * which disarms the grounding guard for that turn. This test pins the drop so + * the consequence is visible at the seam where it happens. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import type { CoachToolResult } from "@/lib/ai/coach/tools/executor"; + +const executeCoachTool = vi.fn<(args?: unknown) => Promise>(); +vi.mock("@/lib/ai/coach/tools/executor", () => ({ + executeCoachTool: (args: unknown) => executeCoachTool(args), +})); + +const runRawCompletionWithFallback = vi.fn(); +vi.mock("@/lib/ai/provider-runner", () => ({ + runRawCompletionWithFallback: (args: unknown) => + runRawCompletionWithFallback(args), +})); + +import { runCoachToolLoop } from "@/lib/ai/coach/tools/loop"; +import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions"; + +const baseArgs = { + userId: "u1", + providers: [], + system: "sys", + messages: [{ role: "user" as const, content: "how did I sleep?" }], + tools: COACH_TOOL_DEFS, +}; + +describe("coach tool loop — which results reach the grounding guard", () => { + beforeEach(() => { + executeCoachTool.mockReset(); + runRawCompletionWithFallback.mockReset(); + }); + + it("a pure miss (present:false, no available) is dropped from toolResults", async () => { + executeCoachTool.mockResolvedValue({ present: false, reason: "none" }); + runRawCompletionWithFallback + .mockResolvedValueOnce({ + result: { + content: "", + tokensUsed: 30, + model: "mock", + providerType: "anthropic" as const, + toolCalls: [ + { id: "t1", name: "get_sleep", arguments: JSON.stringify({}) }, + ], + finishReason: "tool_calls", + }, + workingProvider: { providerType: "anthropic" }, + fallbackHops: [], + }) + .mockResolvedValueOnce({ + result: { + content: "You averaged 7 h 12 min of sleep.", + tokensUsed: 20, + model: "mock", + providerType: "anthropic" as const, + finishReason: "stop", + }, + workingProvider: { providerType: "anthropic" }, + fallbackHops: [], + }); + + const loop = await runCoachToolLoop(baseArgs); + + // The trace records that the tool ran and found nothing… + expect(loop.toolTrace).toEqual([{ name: "get_sleep", present: false }]); + // …but the payload set the route grades against is empty, so the route's + // `activatingPayloads.length > 0` gate is false and the numeric grounding + // guard never runs for this turn. + expect(loop.toolResults).toEqual([]); + }); + + it("an out-of-window miss WITH `available` is kept, so the guard stays armed", async () => { + executeCoachTool.mockResolvedValue({ + present: false, + reason: "unavailable_in_scope", + available: { count: 12, reachableWithWindow: "lastYear" }, + } as unknown as CoachToolResult); + runRawCompletionWithFallback + .mockResolvedValueOnce({ + result: { + content: "", + tokensUsed: 30, + model: "mock", + providerType: "anthropic" as const, + toolCalls: [ + { id: "t1", name: "get_sleep", arguments: JSON.stringify({}) }, + ], + finishReason: "tool_calls", + }, + workingProvider: { providerType: "anthropic" }, + fallbackHops: [], + }) + .mockResolvedValueOnce({ + result: { + content: "Nothing in the last 30 days.", + tokensUsed: 20, + model: "mock", + providerType: "anthropic" as const, + finishReason: "stop", + }, + workingProvider: { providerType: "anthropic" }, + fallbackHops: [], + }); + + const loop = await runCoachToolLoop(baseArgs); + expect(loop.toolResults).toHaveLength(1); + }); +}); diff --git a/tests/integration/coach-stale-rollup-tail.test.ts b/tests/integration/coach-stale-rollup-tail.test.ts new file mode 100644 index 000000000..c49609ee1 --- /dev/null +++ b/tests/integration/coach-stale-rollup-tail.test.ts @@ -0,0 +1,133 @@ +/** + * CHARACTERISATION — a rollup bucket outlives the rows it was folded from, and + * the Coach is shown the result without any marker saying so. + * + * The read-swap in `tiered-context.ts` falls back to live SQL only when a band + * is EMPTY (`if (buckets.length > 0) return buckets;`), so a bucket that is + * merely wrong is served as though it were current. The one staleness repair, + * `ensureUserRollupsFresh`, recomputes the DAY tier over the trailing 90 days + * only, and keys off `Measurement.updatedAt` — which a HARD delete does not + * bump. `src/lib/whoop/sync-body.ts` performs exactly such a hard + * `measurement.deleteMany` on a WEIGHT row with no rollup invalidation, so this + * reproduces that shape against a MONTH-band bucket. + * + * What this pins, in the snapshot the Coach narrates from: + * - `weight.timeline.coarse.monthly` still carries the deleted value; + * - `weight.aggregate.allTimeMin/Max` correctly carry only the surviving + * value, so the snapshot contradicts itself and nothing flags which half + * is right; + * - `weight.asOf.currentForTodayClaims` is true, because `asOf` is derived + * from the freshest RAW reading and knows nothing about rollup age. + * + * This asserts CURRENT behaviour. Closing the gap — comparing bucket + * `computedAt` against the type's in-window `MAX(measurement.updatedAt)`, or + * invalidating on the hard delete — will redden it, and the assertions should + * then be flipped to the honest expectation. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; +import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; + +const prisma = getPrismaClient(); +const DAY = 24 * 60 * 60 * 1000; + +/** The value only ever present in the rows we delete. */ +const GHOST_KG = 95; +/** The value the record still holds afterwards. */ +const LIVE_KG = 80; + +interface WeightBlock { + aggregate: { allTimeMin: number; allTimeMax: number }; + timeline: { coarse?: { monthly: Array<[string, number, number, number]> } }; + asOf: { currentForTodayClaims: boolean }; +} + +describe("coach snapshot — a rollup bucket that outlived its rows", () => { + beforeEach(async () => { + await truncateAllTables(prisma); + }); + + it("serves the deleted value in the coarse tail while claiming the block is current", async () => { + const user = await prisma.user.create({ + data: { + email: "stale-rollup@example.test", + username: "stale-rollup", + passwordHash: "x", + }, + }); + const userId = user.id; + + // Rows in the MONTH band (90 days to a year), plus recent rows so the + // snapshot renders a weight block at all. + await prisma.measurement.createMany({ + data: [ + ...Array.from({ length: 12 }, (_, i) => ({ + userId, + type: "WEIGHT" as const, + value: GHOST_KG, + unit: "kg", + source: "WHOOP" as const, + externalId: `stale-${i}`, + measuredAt: new Date(Date.now() - (200 + i) * DAY), + })), + ...Array.from({ length: 6 }, (_, i) => ({ + userId, + type: "WEIGHT" as const, + value: LIVE_KG, + unit: "kg", + measuredAt: new Date(Date.now() - i * DAY), + })), + ], + }); + + await recomputeUserRollups(userId); + + // The hard delete `whoop/sync-body.ts` performs — no rollup invalidation, + // and no `updatedAt` bump for the freshness probe to notice. + const deleted = await prisma.measurement.deleteMany({ + where: { userId, type: "WEIGHT", source: "WHOOP" }, + }); + expect(deleted.count).toBe(12); + + const remaining = await prisma.measurement.findMany({ + where: { userId, type: "WEIGHT" }, + select: { value: true }, + }); + expect(remaining.every((r) => r.value === LIVE_KG)).toBe(true); + + // The MONTH buckets are untouched by the delete. + const buckets = await prisma.measurementRollup.findMany({ + where: { userId, type: "WEIGHT", granularity: "MONTH", source: "WHOOP" }, + select: { mean: true, count: true }, + }); + expect(buckets.length).toBeGreaterThan(0); + expect(buckets.every((b) => b.mean === GHOST_KG)).toBe(true); + + const snap = await buildCoachSnapshot(userId); + const weight = (snap.sections as Record) + .weight as WeightBlock; + + // The coarse tail the Coach narrates still reports the deleted value. + const monthly = weight.timeline.coarse?.monthly ?? []; + expect(monthly.length).toBeGreaterThan(0); + expect(monthly.every((row) => row[1] === GHOST_KG)).toBe(true); + expect(snap.snapshotJson).toContain(String(GHOST_KG)); + + // …while the all-time extremes, read live, know only the surviving value. + expect(weight.aggregate.allTimeMin).toBe(LIVE_KG); + expect(weight.aggregate.allTimeMax).toBe(LIVE_KG); + + // …and the block is stamped current, which licenses present-tense prose. + expect(weight.asOf.currentForTodayClaims).toBe(true); + }); +}); From 251a42bb0ab098172223171048f66408a5002cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 15:46:19 +0200 Subject: [PATCH 2/4] Grade the Coach turn that went looking and found nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The numeric grounding guard keyed its activation off `loop.toolResults` alone. A tool that finds nothing resolves to `{ present: false, reason: "none" }` with no `available` payload, `runCoachToolLoop` drops that shape, and the no-tools snapshot fallback is populated only in the non-tool branch — so the guard sat out every turn where the record was empty, which is precisely the turn where a fabricated figure is likeliest and does the most harm. Ask about a metric you have never logged and an invented number was persisted and streamed verbatim, with no withheld-figure marker to say it had never been checked. `toolTrace` is the discriminant, because it records the pure misses the results array drops. Tools ran and every one missed: grade the reply — an empty ledger there is not missing information, it is the positive statement that nothing can be cited, so `findUnverifiedCoachNumbersInLedger` takes an explicit opt-in to grade against it rather than no-opping. The model answered without calling a tool: still dormant, unchanged, because the tool-mode base prompt carries no pre-computed figures and flagging a legitimately recalled one was the v1.32.1 regression. The inventory counts ride along as a widener so a plain count restatement stays grounded. Eliding the digits is not enough on an empty record. "Your sleep averaged […] minutes, up from […]" still asserts a series, an average and a direction for a metric with no readings; only the precision goes, the false claim stays. So a turn that missed every tool and lost a figure to the guard is replaced with the honest answer, in all six locales. Scoped so a reply whose figure reconciles against a prior turn or an inventory count is left alone. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../__tests__/route-guard-activation.test.ts | 111 +++++++++++++----- src/app/api/insights/chat/route.ts | 78 +++++++++++- src/lib/ai/coach/coach-prose-grounding.ts | 18 ++- 9 files changed, 185 insertions(+), 40 deletions(-) diff --git a/messages/de.json b/messages/de.json index f47b95c2c..67e3f9651 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8967,7 +8967,8 @@ "outOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen — Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.", "promptInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.", "conversationPoisoned": "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung." - } + }, + "noRecordedData": "Zu deiner Frage sind bei dir keine Messwerte erfasst, also kann ich dir dazu keine Zahl nennen. Sobald du etwas eingetragen hast, frag mich noch einmal — dann rechne ich mit deinen eigenen Werten." }, "safety": { "floor": { diff --git a/messages/en.json b/messages/en.json index f335611ac..4d689f6bf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8967,7 +8967,8 @@ "outOfScope": "I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.", "promptInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.", "conversationPoisoned": "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation." - } + }, + "noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers." }, "safety": { "floor": { diff --git a/messages/es.json b/messages/es.json index c33bbad2d..da064100b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8967,7 +8967,8 @@ "outOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. No puedo responder preguntas fuera de ese ámbito.", "promptInjection": "He detectado instrucciones que intentan anular mis directrices. Soy el Coach de HealthLog y solo resumo tus propios datos de salud; reformula tu pregunta en esos términos, por favor.", "conversationPoisoned": "Un mensaje anterior de esta conversación contiene instrucciones que intentan anular mis directrices. Empieza una conversación nueva, por favor." - } + }, + "noRecordedData": "No tienes ninguna medición registrada sobre lo que preguntas, así que no puedo darte ninguna cifra. Cuando registres algo, vuelve a preguntarme y trabajaré con tus propios datos." }, "safety": { "floor": { diff --git a/messages/fr.json b/messages/fr.json index 9edb08991..19ff3c528 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8967,7 +8967,8 @@ "outOfScope": "Je peux uniquement t'aider avec les données de santé de ton journal : mesures, médicaments, humeur, tendances. Je ne réponds pas aux questions en dehors de ce cadre.", "promptInjection": "J'ai repéré des instructions qui cherchent à contourner mes consignes. Je suis le Coach HealthLog et je résume uniquement tes propres données de santé — reformule ta question dans ce cadre, s'il te plaît.", "conversationPoisoned": "Un message précédent de cette conversation contient des instructions qui cherchent à contourner mes consignes. Démarre une nouvelle conversation, s'il te plaît." - } + }, + "noRecordedData": "Aucune mesure n'est enregistrée pour ce que tu demandes, je ne peux donc te donner aucun chiffre. Dès que tu en auras saisi, repose-moi la question et je partirai de tes propres valeurs." }, "safety": { "floor": { diff --git a/messages/it.json b/messages/it.json index 5df1f3bab..eb36f3640 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8967,7 +8967,8 @@ "outOfScope": "Posso aiutarti solo con i dati di salute del tuo registro: misurazioni, farmaci, umore, tendenze. Non rispondo a domande al di fuori di questo ambito.", "promptInjection": "Ho notato istruzioni che cercano di aggirare le mie direttive. Sono il Coach di HealthLog e riassumo soltanto i tuoi dati di salute — riformula la domanda in questi termini, per favore.", "conversationPoisoned": "Un messaggio precedente di questa conversazione contiene istruzioni che cercano di aggirare le mie direttive. Inizia una nuova conversazione, per favore." - } + }, + "noRecordedData": "Non hai misurazioni registrate su quello che chiedi, quindi non posso darti alcun valore. Appena ne inserisci qualcuna, richiedimelo e userò i tuoi dati." }, "safety": { "floor": { diff --git a/messages/pl.json b/messages/pl.json index ef3622552..8976add7f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8967,7 +8967,8 @@ "outOfScope": "Mogę pomóc wyłącznie z danymi zdrowotnymi z Twojego dziennika: pomiarami, lekami, nastrojem, trendami. Na pytania spoza tego zakresu nie odpowiadam.", "promptInjection": "Zauważyłem polecenia, które próbują obejść moje wytyczne. Jestem Coachem HealthLog i podsumowuję wyłącznie Twoje własne dane zdrowotne — sformułuj pytanie w tych ramach.", "conversationPoisoned": "Wcześniejsza wiadomość w tej rozmowie zawiera polecenia, które próbują obejść moje wytyczne. Rozpocznij nową rozmowę." - } + }, + "noRecordedData": "Nie masz zapisanych żadnych pomiarów dla tego, o co pytasz, więc nie mogę podać Ci żadnej liczby. Gdy coś zapiszesz, zapytaj ponownie — wtedy oprę się na Twoich własnych wartościach." }, "safety": { "floor": { diff --git a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts index e3a7763c1..b394f43b9 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts @@ -1,30 +1,26 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; /** - * CHARACTERISATION — when the Coach's numeric grounding guard is armed, and - * when it is not. + * REGRESSION — when the Coach's numeric grounding guard is armed, and when it + * deliberately is not. * * The guard rewrites any figure in the reply that the turn's Grounding Ledger - * cannot account for. It runs only when `activatingPayloads` is non-empty, and - * on the tool path that array comes solely from `loop.toolResults`. A tool that - * found nothing at all resolves to `{ present: false, reason: "none" }` with no - * `available` payload, and `loop.ts` drops that shape from `toolResults` - * (pinned in `tools-loop-miss-payloads.test.ts`). The no-tools snapshot - * fallback is populated only in the non-tool-mode branch, so on the tool path - * there is nothing to fall back to. + * cannot account for. Its activation used to key solely off `loop.toolResults`, + * and a tool that found nothing resolves to `{ present: false, reason: "none" }` + * with no `available` payload, which `loop.ts` drops (pinned in + * `tools-loop-miss-payloads.test.ts`). So the guard disarmed itself exactly + * when the record was empty, and a fabricated figure shipped verbatim with no + * withheld-figure marker. * - * The consequence, pinned below: on a tool-mode turn where every tool missed — - * i.e. exactly when the user has no data for what they asked about — a figure - * the model invented is streamed and persisted verbatim, and the reply carries - * no `unverifiedFigures` marker, so it reads to the user as fully checked. + * `toolTrace` now discriminates the two cases the old condition conflated: * - * These tests assert the CURRENT behaviour, not the desired one. If the - * activation gate is widened so the guard covers the all-missed turn, they will - * fail — that failure is the fix landing, and the assertions should be flipped - * to the safe expectation at that point. - * - * The first test is the control: with one present tool result the guard is - * armed and the same invented figure is stripped. + * - tools RAN and every one missed -> grade. An empty ledger is the finding, + * not a reason to skip, so every magnitude is unreconciled. A reply that + * loses a figure this way is replaced with the honest "nothing recorded" + * answer rather than shipping elided prose that still asserts a series. + * - the model called NO tool -> stay dormant. This is the v1.32.1 + * contract: the tool-mode base prompt deliberately carries no pre-computed + * figures, and flagging a legitimately recalled one was a real regression. */ const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}'; @@ -301,6 +297,27 @@ describe("coach chat — grounding-guard activation", () => { }); }); + /** Drive the loop with an explicit trace so a MISS is modelled faithfully. */ + function stubMissedTurn(prose: string): void { + parseKeyValuesSentinel.mockReturnValue({ + prose, + keyValues: [], + malformed: false, + malformedEntries: [], + }); + parseSuggestReminder.mockReturnValue({ prose }); + runCoachToolLoop.mockImplementation(async () => ({ + result: { content: prose, tokensUsed: 80, model: "m" }, + workingProviderType: "anthropic", + totalTokens: 80, + rounds: 1, + // The tool ran and found nothing; `loop.ts` drops the pure miss from + // `toolResults` but the trace still records the call. + toolTrace: [{ name: "get_sleep", present: false }], + toolResults: [], + })); + } + it("CONTROL — one present tool result arms the guard: the invented figure is stripped", async () => { stubReply("Your sleep averaged 432 minutes last week.", [ { present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } }, @@ -314,22 +331,58 @@ describe("coach chat — grounding-guard activation", () => { expect(assistantProvenance()?.unverifiedFigures).toBe(1); }); - it("every tool missed: the guard stays dormant and the invented figure survives", async () => { - // A pure miss never reaches `toolResults` (see tools-loop-miss-payloads), - // so this is what the route sees when the record holds nothing. - stubReply("Your sleep averaged 432 minutes last week.", []); + it("every tool missed: the fabricated figure is caught and the turn is replaced", async () => { + stubMissedTurn("Your sleep averaged 432 minutes last week."); await post(chatReq({ conversationId: "c1", message: "how did I sleep?" })); await sse.done; - expect(assistantContent()).toBe( - "Your sleep averaged 432 minutes last week.", - ); - // No withheld-figure notice either, so the reply reads as verified. + const content = assistantContent(); + expect(content).not.toContain("432"); + // Not the elided prose either — the sentence asserted a series the record + // does not hold, so the whole turn is replaced with the honest answer. + expect(content).not.toContain("[…]"); + expect(content).toContain("recorded readings"); + // The replacement copy says it in words, so the count-based notice is off. expect(assistantProvenance()?.unverifiedFigures).toBeUndefined(); }); - it("the model called no tool at all: the guard stays dormant", async () => { + it("every tool missed but the figure is grounded by a prior turn: left alone", async () => { + // Turn 1 fetched systolic 128 and persisted it as a tool figure. This turn + // every tool misses, but recalling 128 still reconciles, so nothing is + // stripped and the reply is NOT replaced. + fetchConversationWithMessages.mockResolvedValue({ + id: "c1", + attachmentCount: 0, + summary: null, + messages: [ + { role: "user", content: "How is my BP?" }, + { + role: "assistant", + content: "Your systolic averaged 128.", + metricSource: { groundedFigures: [128] }, + }, + ], + }); + stubMissedTurn( + "No sleep readings yet. Your systolic 128 average is unrelated to that.", + ); + + await post(chatReq({ conversationId: "c1", message: "and my sleep?" })); + await sse.done; + + const content = assistantContent(); + expect(content).toContain("128"); + expect(content).not.toContain("[…]"); + expect(content).not.toContain("recorded readings"); + }); + + it("the model called no tool at all: the guard stays dormant (v1.32.1)", async () => { + // Deliberately unchanged behaviour. The tool-mode base prompt carries no + // pre-computed figures, so a number here came from the transcript or the + // model's own recall, and grading it off the counts-only inventory flagged + // legitimate figures as ungrounded. `toolTrace` is empty, so the widened + // activation above does not reach this case. const prose = "Your resting heart rate has been averaging 58 bpm."; parseKeyValuesSentinel.mockReturnValue({ prose, diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index d10447086..a5c0e3191 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -639,6 +639,13 @@ async function handleChatRequest(request: NextRequest): Promise { // delivered this turn (`includeFullSnapshot`); on a cheap follow-up the block // was not re-sent, so there is no fresh authoritative set to grade against. let noToolsSnapshotPayloads: unknown[] = []; + // The DATA INVENTORY manifest (per-domain sample counts) that rode this + // turn's tool-mode prompt. Hoisted out of the tool branch because the + // all-missed activation below needs it as a WIDENER: the counts were in + // front of the model, so a plain count restatement ("you've logged 42 BP + // readings") must stay grounded even on a turn whose every tool missed. + // Never an ACTIVATOR on its own — see the v1.32.1 note below. + let inventoryPayloads: unknown[] = []; let totalTokensSpent: number; // v1.21.0 (F3) — cached-input tokens to subtract at reconcile (prompt-cached // input the user did not re-pay for must not be billed to the daily meter). @@ -705,6 +712,7 @@ async function handleChatRequest(request: NextRequest): Promise { // out-of-window aggregate rule 3 lets the model cite. Ground it. ...(loop.toolResults ?? []).map((r) => r.data ?? r.available), ]; + inventoryPayloads = [inventory.entries]; toolResultPayloads = presentToolPayloads.length > 0 ? [...presentToolPayloads, inventory.entries] @@ -993,17 +1001,43 @@ async function handleChatRequest(request: NextRequest): Promise { // active grading — they never ACTIVATE it, so a snapshot figure the model // cited on a no-tool turn is still left alone (the v1.32.1 regression guard // holds). Assistant prose is never a ledger source (D3). + // + // The turn that CALLED tools and got nothing back is graded too. It used to + // be the one turn the verifier sat out: a pure miss carries no `data` and no + // `available`, `runCoachToolLoop` drops that shape from `toolResults`, and + // the no-tools snapshot fallback below is populated only in the non-tool + // branch — so `activatingPayloads` came out empty and every figure in the + // reply shipped unchecked. That is backwards. A turn whose every tool + // reported `{ present: false }` is not an absence of evidence about the + // reply, it is evidence that the record holds nothing to cite, which is + // exactly when a fabricated figure is both likeliest and most harmful. + // + // `toolTrace` is the discriminant, because it records every tool that ran + // INCLUDING the pure misses. It separates the two cases the old condition + // conflated: tools ran and found nothing (grade — the model was told the + // record is empty and answered with numbers anyway), versus the model + // answered without calling a tool at all (stay dormant — the v1.32.1 + // regression, where the base prompt deliberately carries no pre-computed + // figures and flagging a legitimately recalled one was a real defect). + const missedEveryTool = + toolMode && toolTrace.length > 0 && toolResultPayloads.length === 0; const activatingPayloads = toolResultPayloads.length > 0 ? toolResultPayloads - : noToolsSnapshotPayloads; + : missedEveryTool + ? inventoryPayloads + : 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) { + // True when the guard stripped a figure on a turn whose every tool missed — + // i.e. the model put a number on a record that holds none. Drives the + // honest-replacement below. + let fabricatedOnEmptyRecord = false; + if (activatingPayloads.length > 0 || missedEveryTool) { const ledger = buildGroundingLedger({ toolPayloads: activatingPayloads, priorToolFigures, @@ -1026,6 +1060,9 @@ async function handleChatRequest(request: NextRequest): Promise { replyText, ledger, locale, + // On an all-missed turn an EMPTY ledger is the finding, not a reason + // to skip: nothing was retrievable, so nothing can reconcile. + { gradeAgainstEmptyLedger: missedEveryTool }, ); if (unverified.length > 0) { const { prose: corrected, stripped } = stripUnverifiedNumbers( @@ -1034,6 +1071,7 @@ async function handleChatRequest(request: NextRequest): Promise { ); replyText = corrected; unverifiedStripped = stripped; + fabricatedOnEmptyRecord = missedEveryTool && stripped > 0; annotate({ action: { name: "coach.prose.number_unverified" }, meta: { @@ -1048,6 +1086,42 @@ async function handleChatRequest(request: NextRequest): Promise { } } + // A stripped reply on an empty record is not worth sending. Eliding the + // digits leaves the sentences that carried them — "your sleep averaged […] + // minutes, up from […]" still asserts a series, an average and a direction + // for a metric the tools just reported has no readings at all. The false + // claim survives the strip; only its precision goes. So replace the turn + // with the honest answer instead of shipping the elided mush. + // + // Scoped tightly: this fires only when every tool missed AND the guard + // actually stripped something. A reply that cited a legitimately grounded + // figure (a prior turn's tool result, an inventory count) reconciles + // against the ledger, strips nothing, and is left alone. + // + // REPLACE, not withhold — the same policy the outbound screen uses on this + // surface, for the same reason: the user is waiting on a synchronous answer + // and silence reads as a failure. + // + // Accepted trade-off: a reply mixing one grounded recall with one + // fabrication is replaced wholesale, losing the good half. That is the + // right way round — the alternative leaves an invented figure's sentence + // on screen. + if (fabricatedOnEmptyRecord) { + replyText = getServerTranslator(locale).t("coach.noRecordedData"); + // The reply now carries no figures at all, so the withheld-figure notice + // would be describing prose the reader can no longer see. The replacement + // copy states the same thing in plain words. + unverifiedStripped = 0; + annotate({ + action: { name: "coach.prose.empty_record_replaced" }, + meta: { promptVersion: PROMPT_VERSION }, + }); + await auditLog("insights.coach.empty_record_replaced", { + userId, + details: { conversationId: workingConversationId }, + }); + } + // v1.21.0 (NEW-C C-3) — Learn-link post-filter. The prompt instructs the // model to only link a published `/learn/`, but that is guidance, not // enforcement: a fabricated `/learn/` would otherwise ship as diff --git a/src/lib/ai/coach/coach-prose-grounding.ts b/src/lib/ai/coach/coach-prose-grounding.ts index 2c4971b77..3c65e2986 100644 --- a/src/lib/ai/coach/coach-prose-grounding.ts +++ b/src/lib/ai/coach/coach-prose-grounding.ts @@ -739,11 +739,23 @@ export function findUnverifiedCoachNumbersInLedger( prose: string, ledger: ReadonlyArray, locale: Locale = "en", + opts: { gradeAgainstEmptyLedger?: boolean } = {}, ): UnverifiedCoachNumber[] { if (!prose) return []; - // No authoritative figures (a qualitative turn / no-tools path) — nothing to - // grade against. The prompt-level grounding rule remains the backstop. - if (ledger.length === 0) return []; + // An empty ledger is ambiguous on its own, so the caller resolves it. + // + // Default (`false`): no authoritative figures were delivered — a qualitative + // turn, or the no-tools path on a cheap follow-up. Nothing to grade against, + // and the prompt-level grounding rule remains the backstop. + // + // `gradeAgainstEmptyLedger: true`: the caller KNOWS this turn went looking + // and came back with nothing — every retrieval tool it called reported + // `{ present: false }`. Then an empty ledger is not "no information", it is + // the positive statement that the record holds nothing to cite, so every + // magnitude in the reply is unreconciled by construction and must be graded + // as such. Only the chat route passes this; the fenced-chat and eval callers + // keep the default. + if (ledger.length === 0 && !opts.gradeAgainstEmptyLedger) return []; const spans = sentenceSpans(prose); const sentenceAt = (index: number): string => { From c1811bc85bd0b8753c17e67e302901820064d16a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 15:54:12 +0200 Subject: [PATCH 3/4] Stop narrating rollup buckets the record cannot account for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rollup band outlives the rows it was folded from. The read-swap falls back to live SQL only when a band is EMPTY, so a bucket that is merely wrong is served as current; `ensureUserRollupsFresh` repairs the DAY tier over the trailing 90 days only, and keys off `Measurement.updatedAt`, which a hard delete does not bump. The WHOOP body sync hard-deletes a WEIGHT row with no invalidation, so a MONTH bucket keeps reporting a value the record no longer holds. The block was handing the model two irreconcilable accounts of the same record and marking neither: the coarse tail said one thing, the all-time extremes read live beside it said another, and `asOf` stamped the whole thing current because it is derived from raw reading age and knows nothing about rollup age. Reconcile them where they are assembled. A bucket mean is an average of real rows, so it cannot fall outside those rows' own all-time extremes — when it does, the band is describing readings the live read cannot see. That is an invariant rather than a heuristic, so the disputed tail is dropped and `asOf.coarseHistoryWithheld` records that it went. Narrating a ghost is worse than narrating less. `currentForTodayClaims` stays keyed to raw recency, which is what it means. The surviving readings really are from today, and suppressing a true present-tense statement to punish a stale history band would trade one dishonesty for another. The bucket on disk is still wrong, and a wide event now says so. The deeper fix is to make the tier notice — compare a bucket's `computedAt` against the type's in-window MAX(`updatedAt`), or invalidate on the hard-delete path — which is a larger change than this repair. --- src/lib/ai/coach/snapshot-freshness.ts | 118 ++++++++++++++++-- src/lib/ai/coach/snapshot.ts | 15 ++- .../__tests__/measurement-freshness.test.ts | 63 +++++++++- .../coach-stale-rollup-tail.test.ts | 71 ++++++----- 4 files changed, 224 insertions(+), 43 deletions(-) diff --git a/src/lib/ai/coach/snapshot-freshness.ts b/src/lib/ai/coach/snapshot-freshness.ts index 670d365be..7f27fe8e7 100644 --- a/src/lib/ai/coach/snapshot-freshness.ts +++ b/src/lib/ai/coach/snapshot-freshness.ts @@ -16,6 +16,14 @@ * directly, so the answer arrives with the data instead of depending on the * reader working it out. * + * The same pass reconciles each block's coarse (MONTH / YEAR) timeline, served + * from the rollup tier, against the all-time extremes read live beside it. The + * read-swap falls back to live SQL only when a band is EMPTY, so a bucket whose + * rows were hard-deleted with no invalidation keeps being served as current — + * and the block then states two different things about the same record with + * nothing saying which is right. A bucket mean cannot lie outside its own rows' + * extremes, so when it does the band is dropped rather than narrated. + * * Pure and in-place — the snapshot is a plain record by the time it gets here. */ import { @@ -35,6 +43,12 @@ export interface SnapshotAsOf { * the day they were taken on, and must be stated with that day. */ currentForTodayClaims: boolean; + /** + * Set when the block's coarse (MONTH / YEAR) timeline was dropped because it + * could not be reconciled with the live record beside it. Present only in + * that case, so its absence is the normal state and costs no prompt budget. + */ + coarseHistoryWithheld?: true; } function readNewestDaysAgo(block: unknown): number | null { @@ -61,27 +75,117 @@ export function asOfFromDaysAgo(daysAgo: number): SnapshotAsOf { }; } +/** Coarse-band bucket rows are `[bucketStart, mean, min, max]`. */ +type CoarseBucket = [string, number, number, number]; + +/** Rounding headroom, so a float artefact is never read as a contradiction. */ +const RECONCILE_EPSILON = 1e-6; + +function readAllTimeExtremes( + block: unknown, +): { min: number; max: number } | null { + if (typeof block !== "object" || block === null) return null; + const aggregate = (block as { aggregate?: unknown }).aggregate; + if (typeof aggregate !== "object" || aggregate === null) return null; + const min = (aggregate as { allTimeMin?: unknown }).allTimeMin; + const max = (aggregate as { allTimeMax?: unknown }).allTimeMax; + if (typeof min !== "number" || !Number.isFinite(min)) return null; + if (typeof max !== "number" || !Number.isFinite(max)) return null; + return { min, max }; +} + +function readCoarse(block: unknown): Record | null { + if (typeof block !== "object" || block === null) return null; + const timeline = (block as { timeline?: unknown }).timeline; + if (typeof timeline !== "object" || timeline === null) return null; + const coarse = (timeline as { coarse?: unknown }).coarse; + if (typeof coarse !== "object" || coarse === null) return null; + return coarse as Record; +} + +function bucketsOf(coarse: Record, band: string): number[] { + const rows = coarse[band]; + if (!Array.isArray(rows)) return []; + return (rows as CoarseBucket[]) + .map((row) => (Array.isArray(row) ? row[1] : Number.NaN)) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); +} + +/** + * Decide whether a block's coarse timeline can be reconciled with the live + * record it sits beside. + * + * The coarse MONTH / YEAR bands are served from the rollup tier; the aggregate's + * all-time extremes are read live. A bucket mean is an average of real rows, so + * it MUST lie within the all-time min/max of those same rows. When it does not, + * the rollup is describing readings the live read cannot see — the bucket + * outlived the rows it was folded from. + * + * This is an invariant, not a heuristic: no arrangement of existing rows can + * average to a value outside their own extremes. + */ +function coarseContradictsRecord(block: unknown): boolean { + const extremes = readAllTimeExtremes(block); + if (extremes === null) return false; + const coarse = readCoarse(block); + if (coarse === null) return false; + const means = [ + ...bucketsOf(coarse, "monthly"), + ...bucketsOf(coarse, "yearly"), + ]; + if (means.length === 0) return false; + return means.some( + (mean) => + mean < extremes.min - RECONCILE_EPSILON || + mean > extremes.max + RECONCILE_EPSILON, + ); +} + /** * Attach `asOf` to every snapshot block whose aggregate reports a freshest * reading. Blocks with no coverage (narrative memory, plans, the reference * grounding table) are untouched — they carry no measurement to date. * - * Returns the metric keys that were stamped stale, so the caller can annotate - * them: a briefing narrated off a week-old series is worth seeing in the wide - * event, not just in the reader's confusion. + * Also drops a coarse timeline that contradicts the live record beside it. The + * rollup tier's read-swap falls back to live SQL only when a band is EMPTY, so + * a bucket that is merely WRONG — the rows behind it deleted, and no + * invalidation fired — is served as though it were current. The block then + * carried two irreconcilable accounts of the same record (a coarse tail saying + * one thing, all-time extremes saying another) with nothing marking which to + * believe, under an `asOf` stamped from raw reading age that knows nothing + * about rollup age. Narrating a ghost is worse than narrating less, so the + * disputed tail goes and `coarseHistoryWithheld` says it went. + * + * `currentForTodayClaims` stays keyed to raw recency, which is what it means: + * the freshest READING is still as fresh as it was, and suppressing it would + * silence a true present-tense statement to punish a stale history band. + * + * Returns the metric keys stamped stale and the keys whose coarse tail was + * withheld, so the caller can annotate both: a briefing narrated off a week-old + * series is worth seeing in the wide event, and so is a rollup that has drifted + * away from the rows underneath it. */ -export function annotateSnapshotFreshness( - snapshot: Record, -): string[] { +export function annotateSnapshotFreshness(snapshot: Record): { + stale: string[]; + coarseWithheld: string[]; +} { const stale: string[] = []; + const coarseWithheld: string[] = []; for (const [key, block] of Object.entries(snapshot)) { const daysAgo = readNewestDaysAgo(block); if (daysAgo === null) continue; const asOf = asOfFromDaysAgo(daysAgo); + if (coarseContradictsRecord(block)) { + const timeline = (block as { timeline?: Record }) + .timeline; + if (timeline) delete timeline.coarse; + asOf.coarseHistoryWithheld = true; + coarseWithheld.push(key); + } (block as Record).asOf = asOf; if (!asOf.currentForTodayClaims) stale.push(key); } - return stale; + return { stale, coarseWithheld }; } export { TODAY_CLAIM_MAX_AGE_DAYS }; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 7209f983b..3bbb92afc 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -1238,11 +1238,20 @@ async function buildCoachSnapshotImpl( // hero line said "today" about a metric last measured five days earlier. The // stamp sits on the block itself so the age travels with the numbers into // every surface the snapshot feeds — hero, briefing, Coach reply, tools. - const staleBlocks = annotateSnapshotFreshness(compactSnapshot); - if (staleBlocks.length > 0) { + const freshness = annotateSnapshotFreshness(compactSnapshot); + if (freshness.stale.length > 0) { annotate({ action: { name: "coach.snapshot.stale_blocks" }, - meta: { blocks: staleBlocks.sort() }, + meta: { blocks: freshness.stale.sort() }, + }); + } + // A rollup band that no longer reconciles with the rows underneath it is an + // operator-visible defect, not just a narration problem — the bucket is wrong + // on disk and stays wrong until something recomputes it. + if (freshness.coarseWithheld.length > 0) { + annotate({ + action: { name: "coach.snapshot.coarse_withheld" }, + meta: { blocks: freshness.coarseWithheld.sort() }, }); } diff --git a/src/lib/insights/__tests__/measurement-freshness.test.ts b/src/lib/insights/__tests__/measurement-freshness.test.ts index 4d77ad0d8..91e74c1ba 100644 --- a/src/lib/insights/__tests__/measurement-freshness.test.ts +++ b/src/lib/insights/__tests__/measurement-freshness.test.ts @@ -119,9 +119,11 @@ describe("the Coach snapshot stamp", () => { scope: { sources: ["pulse", "weight"] }, }; - const stale = annotateSnapshotFreshness(snapshot); + const { stale, coarseWithheld } = annotateSnapshotFreshness(snapshot); expect(stale).toEqual(["pulse"]); + // Neither block carries a coarse band, so nothing is disputed. + expect(coarseWithheld).toEqual([]); expect((snapshot.pulse as { asOf: unknown }).asOf).toEqual({ daysAgo: 5, isToday: false, @@ -135,4 +137,63 @@ describe("the Coach snapshot stamp", () => { expect(snapshot.memory).toEqual({ headline: "steady month" }); expect(snapshot.scope).toEqual({ sources: ["pulse", "weight"] }); }); + + /** + * A coarse bucket mean is an average of real rows, so it cannot sit outside + * those rows' own all-time extremes. When it does, the rollup band is + * describing readings the live read cannot see and must not be narrated. + */ + it("drops a coarse band whose bucket mean the live extremes cannot account for", () => { + const snapshot: Record = { + weight: { + aggregate: { + allTimeMin: 80, + allTimeMax: 80, + coverage: { count: 6, newestDaysAgo: 0 }, + }, + timeline: { + recent: [], + coarse: { monthly: [["2026-01-01", 95, 95, 95]], yearly: [] }, + }, + }, + }; + + const { coarseWithheld } = annotateSnapshotFreshness(snapshot); + + expect(coarseWithheld).toEqual(["weight"]); + const weight = snapshot.weight as { + timeline: { coarse?: unknown }; + asOf: { coarseHistoryWithheld?: true; currentForTodayClaims: boolean }; + }; + expect(weight.timeline.coarse).toBeUndefined(); + expect(weight.asOf.coarseHistoryWithheld).toBe(true); + // Raw recency is untouched — the surviving readings are still from today. + expect(weight.asOf.currentForTodayClaims).toBe(true); + }); + + it("keeps a coarse band that reconciles with the live extremes", () => { + const snapshot: Record = { + weight: { + aggregate: { + allTimeMin: 78, + allTimeMax: 96, + coverage: { count: 40, newestDaysAgo: 0 }, + }, + timeline: { + recent: [], + coarse: { monthly: [["2026-01-01", 95, 94, 96]], yearly: [] }, + }, + }, + }; + + const { coarseWithheld } = annotateSnapshotFreshness(snapshot); + + expect(coarseWithheld).toEqual([]); + const weight = snapshot.weight as { + timeline: { coarse?: unknown }; + asOf: { coarseHistoryWithheld?: true }; + }; + expect(weight.timeline.coarse).toBeDefined(); + expect(weight.asOf.coarseHistoryWithheld).toBeUndefined(); + }); }); diff --git a/tests/integration/coach-stale-rollup-tail.test.ts b/tests/integration/coach-stale-rollup-tail.test.ts index c49609ee1..60a579b4b 100644 --- a/tests/integration/coach-stale-rollup-tail.test.ts +++ b/tests/integration/coach-stale-rollup-tail.test.ts @@ -1,30 +1,36 @@ /** - * CHARACTERISATION — a rollup bucket outlives the rows it was folded from, and - * the Coach is shown the result without any marker saying so. + * REGRESSION — a rollup bucket must not narrate rows that no longer exist. * * The read-swap in `tiered-context.ts` falls back to live SQL only when a band - * is EMPTY (`if (buckets.length > 0) return buckets;`), so a bucket that is - * merely wrong is served as though it were current. The one staleness repair, - * `ensureUserRollupsFresh`, recomputes the DAY tier over the trailing 90 days - * only, and keys off `Measurement.updatedAt` — which a HARD delete does not - * bump. `src/lib/whoop/sync-body.ts` performs exactly such a hard - * `measurement.deleteMany` on a WEIGHT row with no rollup invalidation, so this - * reproduces that shape against a MONTH-band bucket. + * is EMPTY, so a bucket that is merely WRONG is served as though it were + * current. The one staleness repair, `ensureUserRollupsFresh`, recomputes the + * DAY tier over the trailing 90 days only, and keys off `Measurement.updatedAt` + * — which a HARD delete does not bump. `src/lib/whoop/sync-body.ts` performs + * exactly such a hard `measurement.deleteMany` on a WEIGHT row with no rollup + * invalidation, so this reproduces that shape against a MONTH-band bucket. * - * What this pins, in the snapshot the Coach narrates from: - * - `weight.timeline.coarse.monthly` still carries the deleted value; - * - `weight.aggregate.allTimeMin/Max` correctly carry only the surviving - * value, so the snapshot contradicts itself and nothing flags which half - * is right; - * - `weight.asOf.currentForTodayClaims` is true, because `asOf` is derived - * from the freshest RAW reading and knows nothing about rollup age. + * Before the fix the Coach snapshot carried two irreconcilable accounts of the + * same record with nothing marking which to believe: `coarse.monthly` reported + * the deleted value while `aggregate.allTimeMin/Max` beside it reported only + * the surviving one, under an `asOf` stamped current because it is derived from + * raw reading age and knows nothing about rollup age. * - * This asserts CURRENT behaviour. Closing the gap — comparing bucket - * `computedAt` against the type's in-window `MAX(measurement.updatedAt)`, or - * invalidating on the hard delete — will redden it, and the assertions should - * then be flipped to the honest expectation. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; + * `annotateSnapshotFreshness` now reconciles the two where they are assembled. + * A bucket mean is an average of real rows, so it must lie within those rows' + * all-time extremes; a mean outside them proves the bucket outlived its rows. + * The disputed coarse tail is dropped and `asOf.coarseHistoryWithheld` records + * that it was — narrating a ghost is worse than narrating less. + * + * `currentForTodayClaims` deliberately stays true here. It means "the freshest + * READING is recent enough for present tense", and it is: the surviving rows + * are from today. Suppressing it would silence a true statement to punish a + * stale history band. + * + * The row on disk is still wrong. The deeper fix is to make the tier notice — + * either compare a bucket's `computedAt` against the type's in-window + * MAX(`updatedAt`), or invalidate on the hard-delete path — and that is a + * larger change than this honesty repair. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; process.env.ENCRYPTION_KEY ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -49,7 +55,7 @@ const LIVE_KG = 80; interface WeightBlock { aggregate: { allTimeMin: number; allTimeMax: number }; timeline: { coarse?: { monthly: Array<[string, number, number, number]> } }; - asOf: { currentForTodayClaims: boolean }; + asOf: { currentForTodayClaims: boolean; coarseHistoryWithheld?: true }; } describe("coach snapshot — a rollup bucket that outlived its rows", () => { @@ -57,7 +63,7 @@ describe("coach snapshot — a rollup bucket that outlived its rows", () => { await truncateAllTables(prisma); }); - it("serves the deleted value in the coarse tail while claiming the block is current", async () => { + it("withholds the coarse tail whose buckets the live record cannot account for", async () => { const user = await prisma.user.create({ data: { email: "stale-rollup@example.test", @@ -117,17 +123,18 @@ describe("coach snapshot — a rollup bucket that outlived its rows", () => { const weight = (snap.sections as Record) .weight as WeightBlock; - // The coarse tail the Coach narrates still reports the deleted value. - const monthly = weight.timeline.coarse?.monthly ?? []; - expect(monthly.length).toBeGreaterThan(0); - expect(monthly.every((row) => row[1] === GHOST_KG)).toBe(true); - expect(snap.snapshotJson).toContain(String(GHOST_KG)); - - // …while the all-time extremes, read live, know only the surviving value. + // The all-time extremes, read live, know only the surviving value. expect(weight.aggregate.allTimeMin).toBe(LIVE_KG); expect(weight.aggregate.allTimeMax).toBe(LIVE_KG); - // …and the block is stamped current, which licenses present-tense prose. + // The coarse tail could not be reconciled with them, so it is gone… + expect(weight.timeline.coarse).toBeUndefined(); + expect(weight.asOf.coarseHistoryWithheld).toBe(true); + + // …and the deleted value reaches no part of what the model is shown. + expect(snap.snapshotJson).not.toContain(String(GHOST_KG)); + + // The freshest reading is still from today, so present tense is still fair. expect(weight.asOf.currentForTodayClaims).toBe(true); }); }); From 6f2f43bb490e23ea698723ff9aa397b8da01a17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 16:05:21 +0200 Subject: [PATCH 4/4] Say what the AI surfaces actually compute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three places where the copy promised more than the handler delivers. The hero's personal-baseline line rendered unconditionally while both its siblings in the same meta row gate on their own data. It claims the subtitle above it was drawn from the reader's own last 90 days, but with no briefing the subtitle is fixed fallback copy computed from nothing — so an account with no briefing at all was told its generic sentence rested on a personal baseline. Gate it on the briefing, like the rest of the row. The pulse sub-page promised a personalised Karvonen target band. Karvonen is not computed anywhere: the band comes from CDC/NCHS population percentiles by age and sex, and falls back to a fixed 60-100 for a reader with no age on file. The copy now says that in all six locales, and the fallback's own source label says it is a general adult range rather than a personal one, because that arm is the same band for everyone. Karvonen itself stays unimplemented; whether to build it is a product decision, not a copy fix. The Coach's `allTime` window returns 365 days, identical to `lastYear`, and it is the default. "year so far" was wrong too — a rolling 365 days is not year-to-date. Both labels now say what the reads do. The two options remain functionally identical and one of them should probably go, but removing a default is a product decision. --- messages/de.json | 6 ++--- messages/en.json | 6 ++--- messages/es.json | 6 ++--- messages/fr.json | 6 ++--- messages/it.json | 6 ++--- messages/pl.json | 6 ++--- .../insights/__tests__/hero-strip.test.tsx | 27 +++++++++++++++++-- src/components/insights/hero-strip.tsx | 22 ++++++++++----- .../insights/metric-target-summary.tsx | 4 +-- src/lib/analytics/pulse-targets.ts | 9 +++++-- 10 files changed, 68 insertions(+), 30 deletions(-) diff --git a/messages/de.json b/messages/de.json index 67e3f9651..a2d2e6d9e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2748,8 +2748,8 @@ "last7days": "letzte 7 Tage", "last30days": "letzte 30 Tage", "last90days": "letzte 90 Tage", - "lastYear": "Jahresrückblick", - "allTime": "gesamter Zeitraum" + "lastYear": "letzte 12 Monate", + "allTime": "gesamter Zeitraum (12 Monate im Detail)" }, "windowLabel": "Zeitraum", "settingsAriaLabel": "Coach-Einstellungen", @@ -3047,7 +3047,7 @@ "loadError": "Diese Messgröße konnte gerade nicht geladen werden.", "blutdruckDescription": "Dein systolisch-/diastolisch-Verlauf mit altersangepassten Zielbereichen und einer textbasierten Einschätzung.", "gewichtDescription": "Gewichtsverlauf gegen das aus deiner Größe abgeleitete gesunde Band, mit Einschätzung.", - "pulsDescription": "Pulsverlauf gegenüber dem persönlichen Karvonen-Zielband, mit Einschätzung.", + "pulsDescription": "Ruhepuls im Vergleich zu einem alters- und geschlechtsabhängigen Referenzband aus CDC/NCHS-Populationsperzentilen, mit Einschätzung.", "stimmungDescription": "Tägliche Stimmungswerte im Zeitverlauf mit einer textbasierten Einschätzung.", "medikamenteDescription": "Einnahme-Kalender pro Medikament plus eine textbasierte Einschätzung.", "bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung.", diff --git a/messages/en.json b/messages/en.json index 4d689f6bf..ba0a5ac87 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2748,8 +2748,8 @@ "last7days": "last 7 days", "last30days": "last 30 days", "last90days": "last 90 days", - "lastYear": "year so far", - "allTime": "all time" + "lastYear": "last 12 months", + "allTime": "all time (12 months of detail)" }, "windowLabel": "Window", "settingsAriaLabel": "Coach settings", @@ -3047,7 +3047,7 @@ "loadError": "Could not load this metric right now.", "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus a written assessment.", - "pulsDescription": "Pulse trend against the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Resting pulse against an age- and sex-based reference band from CDC/NCHS population percentiles, plus a written assessment.", "stimmungDescription": "Daily mood scores over time with a written assessment.", "medikamenteDescription": "Per-medication adherence calendars and a written assessment.", "bmiDescription": "BMI derived from your weight and height, with a written assessment.", diff --git a/messages/es.json b/messages/es.json index da064100b..632a9f9ed 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2748,8 +2748,8 @@ "last7days": "Últimos 7 días", "last30days": "Últimos 30 días", "last90days": "Últimos 90 días", - "lastYear": "lo que va de año", - "allTime": "Todo el tiempo" + "lastYear": "últimos 12 meses", + "allTime": "todo el tiempo (12 meses en detalle)" }, "windowLabel": "Periodo", "settingsAriaLabel": "Ajustes del coach", @@ -3047,7 +3047,7 @@ "loadError": "No se pudo cargar esta métrica ahora mismo.", "blutdruckDescription": "Tu evolución sistólica/diastólica con rangos objetivo ajustados a la edad y una valoración en texto.", "gewichtDescription": "Evolución del peso frente a la banda saludable derivada de tu altura, con valoración.", - "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Pulso en reposo frente a una banda de referencia por edad y sexo basada en percentiles poblacionales de CDC/NCHS, con una valoración escrita.", "stimmungDescription": "Valores diarios de estado de ánimo en el tiempo, con una valoración en texto.", "medikamenteDescription": "Calendario de tomas por medicamento, más una valoración en texto.", "bmiDescription": "IMC derivado de tu peso y altura, con valoración.", diff --git a/messages/fr.json b/messages/fr.json index 19ff3c528..5e03401f1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2748,8 +2748,8 @@ "last7days": "7 derniers jours", "last30days": "30 derniers jours", "last90days": "90 derniers jours", - "lastYear": "depuis le début de l'année", - "allTime": "Toute la période" + "lastYear": "12 derniers mois", + "allTime": "toute la période (12 mois en détail)" }, "windowLabel": "Période", "settingsAriaLabel": "Paramètres du coach", @@ -3047,7 +3047,7 @@ "loadError": "Impossible de charger cette mesure pour le moment.", "blutdruckDescription": "Ton évolution systolique/diastolique avec des plages cibles adaptées à l’âge et une évaluation textuelle.", "gewichtDescription": "Évolution du poids face à la bande saine dérivée de ta taille, avec évaluation.", - "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Pouls au repos comparé à une plage de référence selon l'âge et le sexe, issue des percentiles de population CDC/NCHS, avec une évaluation rédigée.", "stimmungDescription": "Valeurs d’humeur quotidiennes dans le temps, avec une évaluation textuelle.", "medikamenteDescription": "Calendrier des prises par médicament, ainsi qu’une évaluation textuelle.", "bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation.", diff --git a/messages/it.json b/messages/it.json index eb36f3640..eb02d4511 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2748,8 +2748,8 @@ "last7days": "Ultimi 7 giorni", "last30days": "Ultimi 30 giorni", "last90days": "Ultimi 90 giorni", - "lastYear": "anno in corso", - "allTime": "Tutto il tempo" + "lastYear": "ultimi 12 mesi", + "allTime": "tutto il tempo (12 mesi in dettaglio)" }, "windowLabel": "Periodo", "settingsAriaLabel": "Impostazioni del coach", @@ -3047,7 +3047,7 @@ "loadError": "Impossibile caricare questa metrica al momento.", "blutdruckDescription": "Il tuo andamento sistolico/diastolico con range obiettivo adattati all’età e una valutazione testuale.", "gewichtDescription": "Andamento del peso rispetto alla fascia sana derivata dalla tua altezza, con valutazione.", - "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Battito a riposo confrontato con una fascia di riferimento per età e sesso, basata sui percentili di popolazione CDC/NCHS, con una valutazione scritta.", "stimmungDescription": "Valori giornalieri di umore nel tempo, con una valutazione testuale.", "medikamenteDescription": "Calendario delle assunzioni per farmaco e una valutazione testuale.", "bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione.", diff --git a/messages/pl.json b/messages/pl.json index 8976add7f..18514d304 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2748,8 +2748,8 @@ "last7days": "Ostatnie 7 dni", "last30days": "Ostatnie 30 dni", "last90days": "Ostatnie 90 dni", - "lastYear": "od początku roku", - "allTime": "Cały okres" + "lastYear": "ostatnie 12 miesięcy", + "allTime": "cały okres (12 miesięcy szczegółowo)" }, "windowLabel": "Okres", "settingsAriaLabel": "Ustawienia coacha", @@ -3047,7 +3047,7 @@ "loadError": "Nie udało się teraz wczytać tej metryki.", "blutdruckDescription": "Twój przebieg skurczowego/rozkurczowego z zakresami docelowymi dopasowanymi do wieku i oceną tekstową.", "gewichtDescription": "Przebieg wagi na tle zdrowego pasma wyliczonego z twojego wzrostu, z oceną.", - "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Tętno spoczynkowe na tle zakresu referencyjnego według wieku i płci, opartego na percentylach populacyjnych CDC/NCHS, wraz z oceną opisową.", "stimmungDescription": "Dzienne wartości nastroju w czasie wraz z oceną tekstową.", "medikamenteDescription": "Kalendarz przyjęć dla każdego leku plus ocena tekstowa.", "bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną.", diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index 7f59ad7b3..c13e4e984 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -241,12 +241,35 @@ describe("", () => { ); }); - it("renders the personal-baseline meta line", () => { - const html = render(); + it("renders the personal-baseline meta line under a real briefing", () => { + const html = render( + , + ); expect(html).toMatch(/data-slot="insights-hero-strip-baseline"/); expect(html).toContain("Based on your last 90 days"); }); + /** + * Inverted from the previous assertion, which rendered with + * `briefing={null}` and still expected the baseline line. + * + * That pinned the defect. The line claims the subtitle above it was drawn + * from the reader's own last 90 days, but with no briefing the subtitle is + * `heroFallbackSubtitle` — fixed copy computed from nothing. Both siblings in + * the same meta row (the freshness caption, the no-provider hint) already + * gate on their own data; this one did not, so an account with no briefing at + * all was told its generic sentence rested on a personal baseline. + */ + it("withholds the personal-baseline meta line when there is no briefing", () => { + const html = render(); + expect(html).not.toMatch(/data-slot="insights-hero-strip-baseline"/); + expect(html).not.toContain("Based on your last 90 days"); + // The fallback subtitle still renders — only the provenance claim goes. + expect(html).toContain( + "A daily read of your trends, drawn straight from the numbers you've logged.", + ); + }); + it("renders the freshness caption when updatedAt is supplied", () => { // v1.22 — the hero freshness line now uses `formatUpdatedLabel` for parity // with the briefing + per-metric cards: a same-day timestamp reads diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 3ba16e578..ef92e9184 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -199,14 +199,24 @@ export function HeroStrip({
- - {t("insights.heroPersonalBaseline")} - + {/* The baseline line describes where the SUBTITLE came from, so it + may only show when the subtitle IS a briefing. Without one the + subtitle falls back to generic copy derived from nothing, and + this line went on claiming a personal baseline underneath it — + the one part of the row that was ungated while both its + siblings already checked for their own data first. */} + {briefing && ( + + {t("insights.heroPersonalBaseline")} + + )} {generatedLine && ( <> - + {briefing && ( + + )} {generatedLine} diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index 199711035..81fdc0277 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -28,8 +28,8 @@ import { apiGet } from "@/lib/api/api-fetch"; * nothing and a cold sub-page warms the cache its siblings reuse. We * never recompute the ranges here: the route is the single source of * truth for the age-based ESH blood-pressure band, the WHO weight / BMI - * band, the Karvonen pulse band, the AASM sleep band, the ADA / DDG - * glucose bands, and the medication / mood targets. + * band, the CDC/NCHS percentile pulse band, the AASM sleep band, the + * ADA / DDG glucose bands, and the medication / mood targets. * * The panel is a compact reference panel carrying the full target * context: diff --git a/src/lib/analytics/pulse-targets.ts b/src/lib/analytics/pulse-targets.ts index 4acc0ad56..fb3420941 100644 --- a/src/lib/analytics/pulse-targets.ts +++ b/src/lib/analytics/pulse-targets.ts @@ -68,14 +68,19 @@ export function getPersonalizedPulseTarget( age: number | null, gender: PulseGender, ): PersonalizedPulseTarget { - // AHA fallback for adults when profile context is missing. + // No age to personalise against (missing profile, or under 20 where the + // percentile table does not apply), so this arm returns a fixed band that is + // the same for everyone. The source label says so in as many words: the + // surrounding UI describes the pulse band as age- and sex-based, and a reader + // who falls into this arm needs to know theirs is not. if (age == null || age < 20) { return { greenMin: 60, greenMax: 100, orangeMin: 55, orangeMax: 105, - source: "AHA (adults, resting pulse 60-100 bpm)", + source: + "AHA general adult range (resting pulse 60-100 bpm) — not personalised", }; }