mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge pull request #825 from MBombeck/verify/coach-grounding
Grade the Coach turn that went looking and found nothing
This commit is contained in:
18 files changed
+1030
-52
No files matched your search
@@ -0,0 +1,409 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* 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. 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.
|
||||
*
|
||||
* `toolTrace` now discriminates the two cases the old condition conflated:
|
||||
*
|
||||
* - 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}}}';
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => 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<typeof import("@/lib/ai/coach/types")>(
|
||||
"@/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<unknown> }));
|
||||
vi.mock("@/lib/sse/create-stream", () => ({
|
||||
createSseStream: (
|
||||
producer: (c: {
|
||||
signal: { aborted: boolean };
|
||||
enqueue: () => void;
|
||||
}) => void | Promise<void>,
|
||||
) => {
|
||||
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<Response>;
|
||||
|
||||
function chatReq(body: Record<string, unknown>): 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<typeof vi.fn>).mock.calls;
|
||||
const assistant = calls.find(
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
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 — 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: [],
|
||||
});
|
||||
});
|
||||
|
||||
/** 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 } } },
|
||||
]);
|
||||
|
||||
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 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;
|
||||
|
||||
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("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,
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -639,6 +639,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// 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<Response> {
|
||||
// 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<Response> {
|
||||
// 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<Response> {
|
||||
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<Response> {
|
||||
);
|
||||
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<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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/<slug>`, but that is guidance, not
|
||||
// enforcement: a fabricated `/learn/<invented-slug>` would otherwise ship as
|
||||
|
||||
@@ -241,12 +241,35 @@ describe("<HeroStrip>", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the personal-baseline meta line", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
it("renders the personal-baseline meta line under a real briefing", () => {
|
||||
const html = render(
|
||||
<HeroStrip briefing={sampleBriefing} now={morningLocal} />,
|
||||
);
|
||||
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(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
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
|
||||
|
||||
@@ -199,14 +199,24 @@ export function HeroStrip({
|
||||
<ProseBlocks text={subtitle} />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span data-slot="insights-hero-strip-baseline">
|
||||
{t("insights.heroPersonalBaseline")}
|
||||
</span>
|
||||
{/* 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 && (
|
||||
<span data-slot="insights-hero-strip-baseline">
|
||||
{t("insights.heroPersonalBaseline")}
|
||||
</span>
|
||||
)}
|
||||
{generatedLine && (
|
||||
<>
|
||||
<span aria-hidden="true" className="opacity-50">
|
||||
·
|
||||
</span>
|
||||
{briefing && (
|
||||
<span aria-hidden="true" className="opacity-50">
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
<span data-slot="insights-hero-strip-generated">
|
||||
{generatedLine}
|
||||
</span>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<CoachToolResult>>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -739,11 +739,23 @@ export function findUnverifiedCoachNumbersInLedger(
|
||||
prose: string,
|
||||
ledger: ReadonlyArray<LedgerEntry>,
|
||||
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 => {
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
function bucketsOf(coarse: Record<string, unknown>, 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, unknown>,
|
||||
): string[] {
|
||||
export function annotateSnapshotFreshness(snapshot: Record<string, unknown>): {
|
||||
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<string, unknown> })
|
||||
.timeline;
|
||||
if (timeline) delete timeline.coarse;
|
||||
asOf.coarseHistoryWithheld = true;
|
||||
coarseWithheld.push(key);
|
||||
}
|
||||
(block as Record<string, unknown>).asOf = asOf;
|
||||
if (!asOf.currentForTodayClaims) stale.push(key);
|
||||
}
|
||||
return stale;
|
||||
return { stale, coarseWithheld };
|
||||
}
|
||||
|
||||
export { TODAY_CLAIM_MAX_AGE_DAYS };
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user