diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 2167be54f..4cc3fd4e1 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -6,6 +6,7 @@ import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import { buildDailyDigest, COACH_CHECKIN_RESURFACE_DAYS, + DOSE_DUE_LOOKAHEAD_MS, MAX_WORTH_A_LOOK, type DailyDigestCoachPlan, type DailyDigestInput, @@ -211,8 +212,8 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { expect(dose).toBeDefined(); expect(dose?.status).toBe("warning"); expect(dose?.moduleKey).toBe("medications"); - expect(dose?.title).toBe("Medication due"); - expect(dose?.body).toBe("Ramipril is due today."); + expect(dose?.title).toBe("Medication overdue"); + expect(dose?.body).toBe("Ramipril is past due."); expect(dose?.actions).toHaveLength(1); expect(dose?.actions[0].intent).toBe("dose.log"); // Deep-links straight to the overdue medication's card, not the bare @@ -249,7 +250,7 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); }); - it("does NOT emit a dose-window item when nothing is overdue", () => { + it("does NOT emit a dose-window item when nothing is due at all", () => { const d = buildDailyDigest( input({ medsToday: meds({ nextDueOverdue: false }) }), t, @@ -257,6 +258,114 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); }); + it("emits a calm due item for a takeable dose that is not yet overdue", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ + activeCount: 1, + scheduledToday: 1, + nextDueAt: new Date(NOW.getTime() + 30 * 60_000).toISOString(), + nextDueOverdue: false, + nextDueMedicationName: "Ramipril", + nextDueMedicationId: "med-1", + }), + }), + t, + ); + const dose = d.worthALook.find((i) => i.kind === "dose_window"); + expect(dose).toBeDefined(); + // Reads as informational, NOT as the overdue warning. + expect(dose?.status).toBe("info"); + expect(dose?.title).toBe(t("daily.item.doseWindow.title")); + expect(dose?.body).toBe( + t("daily.item.doseWindow.bodyNamed", { name: "Ramipril" }), + ); + expect(dose?.actions[0]?.href).toBe("/medications?highlight=med-1"); + }); + + it("reads an overdue dose differently from a merely due one", () => { + const due = buildDailyDigest( + input({ + medsToday: meds({ + nextDueAt: new Date(NOW.getTime() + 30 * 60_000).toISOString(), + nextDueOverdue: false, + nextDueMedicationName: "Ramipril", + }), + }), + t, + ).worthALook.find((i) => i.kind === "dose_window"); + const overdue = buildDailyDigest( + input({ + medsToday: meds({ + nextDueAt: new Date(NOW.getTime() - 30 * 60_000).toISOString(), + nextDueOverdue: true, + nextDueMedicationName: "Ramipril", + }), + }), + t, + ).worthALook.find((i) => i.kind === "dose_window"); + + expect(overdue?.status).toBe("warning"); + expect(overdue?.title).toBe(t("daily.item.doseWindow.overdueTitle")); + expect(overdue?.body).toBe( + t("daily.item.doseWindow.overdueBodyNamed", { name: "Ramipril" }), + ); + // The two faces must not collapse into the same copy. + expect(overdue?.title).not.toBe(due?.title); + expect(overdue?.body).not.toBe(due?.body); + expect(overdue?.status).not.toBe(due?.status); + }); + + it("does NOT manufacture a dose item on a day whose doses are all taken", () => { + // Every scheduled dose resolved; the next display-due slot is tomorrow. + const d = buildDailyDigest( + input({ + medsToday: meds({ + activeCount: 2, + scheduledToday: 2, + takenToday: 2, + nextDueAt: new Date(NOW.getTime() + 22 * 60 * 60_000).toISOString(), + nextDueOverdue: false, + nextDueMedicationName: "Ramipril", + }), + }), + t, + ); + expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); + }); + + it("keeps a dose beyond the takeable lead off the rail", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ + nextDueAt: new Date( + NOW.getTime() + DOSE_DUE_LOOKAHEAD_MS + 60_000, + ).toISOString(), + nextDueOverdue: false, + nextDueMedicationName: "Ramipril", + }), + }), + t, + ); + expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); + }); + + it("renders neither face for a cached block whose anchor has since passed", () => { + // `MedsTodayBlock` contract: a past `nextDueAt` with `nextDueOverdue: + // false` means the anchor passed AFTER the block was built. + const d = buildDailyDigest( + input({ + medsToday: meds({ + nextDueAt: new Date(NOW.getTime() - 5 * 60_000).toISOString(), + nextDueOverdue: false, + nextDueMedicationName: "Ramipril", + }), + }), + t, + ); + expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); + }); + it("maps one sync-issue item per broken integration", () => { const d = buildDailyDigest( input({ diff --git a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts new file mode 100644 index 000000000..5159ef7e3 --- /dev/null +++ b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts @@ -0,0 +1,201 @@ +/** + * Daily-briefing grounding gate — what stands in place of a rejected briefing. + * + * The gate itself is absolute: a briefing carrying a number the server never + * computed is discarded, always. These tests pin the DISPOSAL, which used to + * fork on why the corrective retry failed — a transport failure fell back to + * the previous cached briefing, a content failure left a hole and the reader + * lost the paragraph, signals, findings and recommendations over one figure. + * Both paths now fall back, and the hard strip survives only when there is + * genuinely no previous briefing to show. + * + * They also pin the widened allow-set: the prompt hands the model the WHOLE + * features object, so a figure from any block in it (here a medication + * compliance rate) is grounded — while an invented number still is not. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findUnique = vi.fn(); +const userUpdate = vi.fn(); +const resolveProviderChain = vi.fn(); +const resolveProvider = vi.fn(); +const runRawCompletionWithFallback = vi.fn(); +const extractFeatures = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: vi.fn(async () => [{ total_tokens: 0 }]), + $executeRaw: vi.fn(async () => 0), + user: { + findUnique: (...a: unknown[]) => findUnique(...a), + update: (...a: unknown[]) => userUpdate(...a), + }, + auditLog: { deleteMany: vi.fn() }, + }, +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), + resolveProvider: (...a: unknown[]) => resolveProvider(...a), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback: (...a: unknown[]) => + runRawCompletionWithFallback(...a), +})); +vi.mock("@/lib/insights/features", () => ({ + FeaturesPayloadTooLargeError: class extends Error { + sizeBytes = 0; + }, + extractFeatures: (...a: unknown[]) => extractFeatures(...a), + BRIEFING_FEATURE_WINDOW_DAYS: 400, +})); +vi.mock("@/lib/insights/illness-cycle-briefing", () => ({ + buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null), + buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""), +})); +vi.mock("@/lib/insights/glp1-plateau", () => ({ + detectGlp1Plateau: vi.fn(async () => null), + buildGlp1PlateauPrompt: vi.fn(() => ""), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(async () => null), + buildAboutMeInsightBlock: vi.fn(() => ""), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserInsights: vi.fn(), +})); + +import { generateComprehensiveInsight } from "../comprehensive-generate"; + +/** + * A signals block (so the gate engages at all) plus a medications block. The + * medication figures are server-computed and reach the prompt, but the old + * hand-maintained allow-set never admitted them. + */ +const FEATURES = { + signalsOfDay: [ + { + sourceMetric: "weight", + latest: 81.4, + avg7: 82, + avg30: 82.1, + deltaVs7: -0.6, + deltaVs30: -0.7, + spread30: 1.2, + recentAnomaly: null, + }, + ], + medications: [ + { + name: "Ramipril", + dose: "5 mg", + category: "cardio", + compliance7: 100, + compliance30: 86, + compliance90: 88, + streak: 12, + missedLast7: 0, + }, + ], +}; + +/** A number that appears nowhere in the features payload. */ +const FABRICATED = JSON.stringify({ + dailyBriefing: { paragraph: "Your weight is down 47.9 kg this week." }, +}); + +/** Cites the medication compliance rate the server itself computed. */ +const GROUNDED_VIA_MEDS = JSON.stringify({ + dailyBriefing: { paragraph: "Adherence sits at 86 % over the last 30 days." }, +}); + +const PREVIOUS_PAYLOAD = JSON.stringify({ + dailyBriefing: { paragraph: "Yesterday's grounded briefing." }, + recommendations: [], +}); + +function cachedUser(over: Record = {}) { + return { + insightsPrivacyMode: "aggregated", + insightsCachedAt: null, + insightsCachedText: null, + insightsExcludeMetrics: [], + // Never equal to the freshly computed hash, so a full generation runs. + insightsSnapshotHash: null, + insightsBriefingRerollDate: null, + ...over, + }; +} + +function completion(content: string) { + return { + result: { content, tokensUsed: 10, model: "m" }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }; +} + +/** The `dailyBriefing` the generation persisted. */ +function persistedBriefing(): unknown { + const call = userUpdate.mock.calls.at(-1); + const text = call?.[0]?.data?.insightsCachedText as string | undefined; + if (!text) return undefined; + return (JSON.parse(text) as Record).dailyBriefing; +} + +beforeEach(() => { + vi.clearAllMocks(); + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: {} }, + ]); + resolveProvider.mockResolvedValue({ type: "none" }); + extractFeatures.mockResolvedValue(FEATURES); + userUpdate.mockResolvedValue({}); + findUnique.mockResolvedValue(cachedUser()); +}); + +describe("briefing grounding — disposal", () => { + it("keeps the previous briefing when the corrective retry fails on CONTENT", async () => { + findUnique.mockResolvedValue( + cachedUser({ + insightsCachedAt: new Date("2026-07-15T03:00:00.000Z"), + insightsCachedText: PREVIOUS_PAYLOAD, + }), + ); + // Both passes carry the same fabricated figure: a content failure. + runRawCompletionWithFallback.mockResolvedValue(completion(FABRICATED)); + + await generateComprehensiveInsight("u1", { locale: "en" }); + + // The fabricated text never persists... + expect(JSON.stringify(persistedBriefing())).not.toContain("47.9"); + // ...and the reader keeps yesterday's grounded briefing rather than a hole. + expect(persistedBriefing()).toEqual({ + paragraph: "Yesterday's grounded briefing.", + }); + }); + + it("still strips hard when there is no previous briefing to stand in", async () => { + runRawCompletionWithFallback.mockResolvedValue(completion(FABRICATED)); + + await generateComprehensiveInsight("u1", { locale: "en" }); + + expect(persistedBriefing()).toBeNull(); + }); + + it("admits a figure from a features block the old allow-set never listed", async () => { + runRawCompletionWithFallback.mockResolvedValue( + completion(GROUNDED_VIA_MEDS), + ); + + const outcome = await generateComprehensiveInsight("u1", { locale: "en" }); + + expect(outcome).toEqual({ status: "generated", providerType: "openai" }); + // No corrective retry: the compliance rate is grounded, so the gate is + // satisfied on the first pass and the briefing survives intact. + expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); + expect(persistedBriefing()).toEqual({ + paragraph: "Adherence sits at 86 % over the last 30 days.", + }); + }); +});