diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index fb76ceb66..f5186c345 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -11,8 +11,8 @@ vi.mock("@/lib/db", () => ({ }, })); -vi.mock("@/lib/ai/provider", () => ({ - resolveProvider: vi.fn(), +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), })); vi.mock("@/lib/insights/memory", () => ({ @@ -25,12 +25,30 @@ vi.mock("@/lib/medication-category", () => ({ })); import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; import { getMedicationCategories } from "@/lib/medication-category"; import { generateBloodPressureStatusForUser } from "../blood-pressure-status"; const dayMs = 24 * 60 * 60 * 1000; +function stubCompletion( + content: string, + capture?: { userPrompt: string | null }, +) { + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { userPrompt: string }) => { + if (capture) capture.userPrompt = args.userPrompt; + return { + kind: "ok", + content, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never; + }, + ); +} + beforeEach(() => { vi.resetAllMocks(); vi.mocked(getMedicationCategories).mockResolvedValue({}); @@ -40,7 +58,6 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () => it("emits {daily, monthly} per metric series with correct field shape", async () => { const now = new Date(); - // 3 years of SYS data — covers daily window + monthly window. const records: Array<{ type: string; value: number; @@ -74,17 +91,7 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () => } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async (args: { userPrompt: string }) => { - captured.userPrompt = args.userPrompt; - return { - content: '{"summary":"OK"}', - model: "x", - tokensUsed: 1, - }; - }), - } as never); + stubCompletion('{"summary":"OK"}', captured); await generateBloodPressureStatusForUser("user-1", { locale: "en" }); @@ -109,8 +116,8 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () => }); }); -describe("generateBloodPressureStatusForUser — v1.4.41 timeout-stub persistence", () => { - it("persists a sentinel row keyed to today when the provider times out", async () => { +describe("generateBloodPressureStatusForUser — timeout/error never persists", () => { + it("serves the fallback without writing a cache row on timeout", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); @@ -128,87 +135,65 @@ describe("generateBloodPressureStatusForUser — v1.4.41 timeout-stub persistenc createdAt: new Date(), } as never); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn( - () => - new Promise(() => { - /* never resolves */ - }), - ), - } as never); - - vi.useFakeTimers(); - const promise = generateBloodPressureStatusForUser("user-1", { - locale: "en", - }); - await vi.advanceTimersByTimeAsync(25_000); - const result = await promise; - vi.useRealTimers(); - - expect(result.text).toBeTruthy(); - expect(result.cached).toBe(true); - expect(result.updatedAt).toBeTruthy(); - - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBe(1); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { - dateKey?: string; - text?: string; - timeout?: boolean; - model?: string; - }; - expect(parsed.text).toBeTruthy(); - expect(parsed.timeout).toBe(true); - expect(parsed.model).toBe("timeout-stub"); - expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); - }); - - it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: "Europe/Berlin", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date()); - const y = parts.find((p) => p.type === "year")!.value; - const m = parts.find((p) => p.type === "month")!.value; - const d = parts.find((p) => p.type === "day")!.value; - const todayKey = `${y}-${m}-${d}`; - const stubRow = { - createdAt: new Date(), - details: JSON.stringify({ - dateKey: todayKey, - locale: "en", - text: "Blood pressure assessment fallback…", - timeout: true, - }), - }; - - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - dateOfBirth: null, - } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never); - const providerCall = vi.fn(); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: providerCall, + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "timeout", } as never); const result = await generateBloodPressureStatusForUser("user-1", { locale: "en", }); - expect(providerCall).not.toHaveBeenCalled(); - expect(result.text).toBe("Blood pressure assessment fallback…"); + expect(result.text).toBeTruthy(); expect(result.cached).toBe(true); - expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(result.updatedAt).toBeNull(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); }); +describe("generateBloodPressureStatusForUser — cache-read skips a stub", () => { + it("regenerates when the only cached row is a timeout stub", async () => { + const now = new Date(); + const todayKey = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + }).format(now); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: now, + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Blood pressure assessment fallback…", + model: "timeout-stub", + timeout: true, + }), + } as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now }, + { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now }, + ] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + + stubCompletion('{"summary":"Fresh BP assessment."}'); + + const result = await generateBloodPressureStatusForUser("user-1", { + locale: "en", + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("Fresh BP assessment."); + expect(result.cached).toBe(false); + }); +}); + describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ @@ -228,15 +213,9 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F createdAt: new Date(), } as never); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async () => ({ - content: - '{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}', - model: "x", - tokensUsed: 1, - })), - } as never); + stubCompletion( + '{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}', + ); const result = await generateBloodPressureStatusForUser("user-1", { locale: "en", diff --git a/src/lib/insights/__tests__/bmi-status.test.ts b/src/lib/insights/__tests__/bmi-status.test.ts index 8c21284b6..8cece73ec 100644 --- a/src/lib/insights/__tests__/bmi-status.test.ts +++ b/src/lib/insights/__tests__/bmi-status.test.ts @@ -8,8 +8,8 @@ vi.mock("@/lib/db", () => ({ }, })); -vi.mock("@/lib/ai/provider", () => ({ - resolveProvider: vi.fn(), +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), })); vi.mock("@/lib/insights/memory", () => ({ @@ -18,11 +18,30 @@ vi.mock("@/lib/insights/memory", () => ({ })); import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateBmiStatusForUser } from "../bmi-status"; const dayMs = 24 * 60 * 60 * 1000; +/** A `runStatusCompletion` stub returning fixed content, capturing the prompt. */ +function stubCompletion( + content: string, + capture?: { userPrompt: string | null }, +) { + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { userPrompt: string }) => { + if (capture) capture.userPrompt = args.userPrompt; + return { + kind: "ok", + content, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never; + }, + ); +} + beforeEach(() => { vi.resetAllMocks(); }); @@ -48,17 +67,7 @@ describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => { } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async (args: { userPrompt: string }) => { - captured.userPrompt = args.userPrompt; - return { - content: '{"summary":"OK"}', - model: "x", - tokensUsed: 1, - }; - }), - } as never); + stubCompletion('{"summary":"OK"}', captured); await generateBmiStatusForUser("user-1", { locale: "en" }); @@ -79,8 +88,8 @@ describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => { }); }); -describe("generateBmiStatusForUser — v1.4.37 timeout-stub persistence", () => { - it("persists a sentinel row keyed to today when the provider times out", async () => { +describe("generateBmiStatusForUser — timeout/error never persists", () => { + it("serves the fallback without writing a cache row on timeout", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: 175, } as never); @@ -92,111 +101,77 @@ describe("generateBmiStatusForUser — v1.4.37 timeout-stub persistence", () => createdAt: new Date(), } as never); - // Simulate a provider that never resolves before the timeout. The - // helper races the call against `STATUS_PROVIDER_TIMEOUT_MS`; a - // promise that hangs forever cuts the race short. - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn( - () => new Promise(() => { - /* never resolves */ - }), - ), - } as never); - - // Shrink the timeout window so this test does not hang the suite - // for the full 20 s. The withTimeout helper polls in ~1 s - // increments; this still exercises the same race shape. - vi.useFakeTimers(); - const promise = generateBmiStatusForUser("user-1", { locale: "en" }); - await vi.advanceTimersByTimeAsync(25_000); - const result = await promise; - vi.useRealTimers(); - - // The user-facing payload still reads the deterministic no-key - // fallback, but the cached/updatedAt fields signal "this is a - // stub" so the next mount short-circuits at the cache lookup. - expect(result.text).toBeTruthy(); - expect(result.cached).toBe(true); - expect(result.updatedAt).toBeTruthy(); - - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBe(1); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { - dateKey?: string; - text?: string; - timeout?: boolean; - model?: string; - }; - expect(parsed.text).toBeTruthy(); - expect(parsed.timeout).toBe(true); - expect(parsed.model).toBe("timeout-stub"); - // The dateKey must read as a valid YYYY-MM-DD so the cache hit - // logic on the next mount matches against today's berlin key. - expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); - }); - - it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { - // First mount: cache miss, provider times out, stub persisted. - // Simulate that prior state by handing back the stub row from - // findFirst on the second invocation. - const stubRow = { - createdAt: new Date(), - details: JSON.stringify({ - dateKey: new Intl.DateTimeFormat("en-US", { - timeZone: "Europe/Berlin", - year: "numeric", - month: "2-digit", - day: "2-digit", - }) - .formatToParts(new Date()) - .reduce>((acc, p) => { - if (p.type !== "literal") acc[p.type] = p.value; - return acc; - }, {}), - locale: "en", - text: "BMI is a directional metric…", - timeout: true, - }), - }; - // Build a real YYYY-MM-DD string the same way the source helper - // does so the cache-key match is exact. - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: "Europe/Berlin", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date()); - const y = parts.find((p) => p.type === "year")!.value; - const m = parts.find((p) => p.type === "month")!.value; - const d = parts.find((p) => p.type === "day")!.value; - const todayKey = `${y}-${m}-${d}`; - stubRow.details = JSON.stringify({ - dateKey: todayKey, - locale: "en", - text: "BMI is a directional metric…", - timeout: true, - }); - - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never); - const providerCall = vi.fn(); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: providerCall, + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "timeout", } as never); const result = await generateBmiStatusForUser("user-1", { locale: "en" }); - // Cache short-circuit fires before the provider call — no race, - // no second persist. - expect(providerCall).not.toHaveBeenCalled(); - expect(result.text).toBe("BMI is a directional metric…"); + // The user-facing payload reads the deterministic fallback, but + // NOTHING is persisted — so the next mount re-attempts a real + // generation instead of sticking the fallback for the day. + expect(result.text).toBeTruthy(); expect(result.cached).toBe(true); - expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(result.updatedAt).toBeNull(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); + + it("serves the fallback without writing a cache row on provider error", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + heightCm: 175, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 80, measuredAt: new Date() }, + ] as never); + + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "error", + } as never); + + const result = await generateBmiStatusForUser("user-1", { locale: "en" }); + expect(result.text).toBeTruthy(); + expect(result.updatedAt).toBeNull(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + +describe("generateBmiStatusForUser — cache-read skips a stub", () => { + it("regenerates when the only cached row is a timeout stub", async () => { + const now = new Date(); + const todayKey = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + }).format(now); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + heightCm: 175, + } as never); + // The most recent cached row is a stub keyed to today. + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: now, + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Generic stub fallback.", + model: "timeout-stub", + timeout: true, + }), + } as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 80, measuredAt: now }, + ] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + + stubCompletion('{"summary":"Fresh real assessment."}'); + + const result = await generateBmiStatusForUser("user-1", { locale: "en" }); + + // The stub must NOT be served — a fresh generation runs instead. + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("Fresh real assessment."); + expect(result.cached).toBe(false); + }); }); describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () => { @@ -212,15 +187,9 @@ describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () = createdAt: new Date(), } as never); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async () => ({ - content: - '{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}', - model: "x", - tokensUsed: 1, - })), - } as never); + stubCompletion( + '{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}', + ); const result = await generateBmiStatusForUser("user-1", { locale: "en" }); diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts index 1f7cbc6dc..f8c3fc5af 100644 --- a/src/lib/insights/__tests__/general-status.test.ts +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -10,8 +10,8 @@ vi.mock("@/lib/db", () => ({ }, })); -vi.mock("@/lib/ai/provider", () => ({ - resolveProvider: vi.fn(), +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), })); vi.mock("@/lib/insights/memory", () => ({ @@ -20,11 +20,29 @@ vi.mock("@/lib/insights/memory", () => ({ })); import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateGeneralStatusForUser } from "../general-status"; const dayMs = 24 * 60 * 60 * 1000; +function stubCompletion( + content: string, + capture?: { userPrompt: string | null }, +) { + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { userPrompt: string }) => { + if (capture) capture.userPrompt = args.userPrompt; + return { + kind: "ok", + content, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never; + }, + ); +} + beforeEach(() => { vi.resetAllMocks(); }); @@ -33,8 +51,6 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => { it("emits {daily, monthly} per metric series with dayOffset/monthOffset/value/n", async () => { const now = new Date(); - // 3 years of weight data — daily across the whole window so the - // bucketed output covers months 1-12 (daily) and 13-36 (monthly). const weightRecords: Array<{ type: string; value: number; @@ -64,22 +80,11 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => { } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async (args: { userPrompt: string }) => { - captured.userPrompt = args.userPrompt; - return { - content: '{"summary":"OK"}', - model: "x", - tokensUsed: 1, - }; - }), - } as never); + stubCompletion('{"summary":"OK"}', captured); await generateGeneralStatusForUser("user-1", { locale: "en" }); expect(captured.userPrompt).not.toBeNull(); - // The snapshot JSON is embedded in the user prompt as a fenced block. const match = captured.userPrompt!.match(/\{[\s\S]*\}/); expect(match).not.toBeNull(); const snapshot = JSON.parse(match![0]); @@ -100,8 +105,8 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => { }); }); -describe("generateGeneralStatusForUser — v1.4.41 timeout-stub persistence", () => { - it("persists a sentinel row keyed to today when the provider times out", async () => { +describe("generateGeneralStatusForUser — timeout/error never persists", () => { + it("serves the fallback without writing a cache row on timeout", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); @@ -117,85 +122,63 @@ describe("generateGeneralStatusForUser — v1.4.41 timeout-stub persistence", () createdAt: new Date(), } as never); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn( - () => - new Promise(() => { - /* never resolves */ - }), - ), - } as never); - - vi.useFakeTimers(); - const promise = generateGeneralStatusForUser("user-1", { locale: "en" }); - await vi.advanceTimersByTimeAsync(25_000); - const result = await promise; - vi.useRealTimers(); - - expect(result.text).toBeTruthy(); - expect(result.cached).toBe(true); - expect(result.updatedAt).toBeTruthy(); - - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBe(1); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { - dateKey?: string; - text?: string; - timeout?: boolean; - model?: string; - }; - expect(parsed.text).toBeTruthy(); - expect(parsed.timeout).toBe(true); - expect(parsed.model).toBe("timeout-stub"); - expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); - }); - - it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: "Europe/Berlin", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date()); - const y = parts.find((p) => p.type === "year")!.value; - const m = parts.find((p) => p.type === "month")!.value; - const d = parts.find((p) => p.type === "day")!.value; - const todayKey = `${y}-${m}-${d}`; - const stubRow = { - createdAt: new Date(), - details: JSON.stringify({ - dateKey: todayKey, - locale: "en", - text: "General status fallback…", - timeout: true, - }), - }; - - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - dateOfBirth: null, - } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never); - const providerCall = vi.fn(); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: providerCall, + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "timeout", } as never); const result = await generateGeneralStatusForUser("user-1", { locale: "en", }); - expect(providerCall).not.toHaveBeenCalled(); - expect(result.text).toBe("General status fallback…"); + expect(result.text).toBeTruthy(); expect(result.cached).toBe(true); - expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(result.updatedAt).toBeNull(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); }); +describe("generateGeneralStatusForUser — cache-read skips a stub", () => { + it("regenerates when the only cached row is a timeout stub", async () => { + const now = new Date(); + const todayKey = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + }).format(now); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: now, + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "General status fallback…", + model: "timeout-stub", + timeout: true, + }), + } as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", value: 82, measuredAt: now }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + + stubCompletion('{"summary":"Fresh general assessment."}'); + + const result = await generateGeneralStatusForUser("user-1", { + locale: "en", + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("Fresh general assessment."); + expect(result.cached).toBe(false); + }); +}); + describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ @@ -213,15 +196,9 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", createdAt: new Date(), } as never); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async () => ({ - content: - '{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}', - model: "x", - tokensUsed: 1, - })), - } as never); + stubCompletion( + '{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}', + ); const result = await generateGeneralStatusForUser("user-1", { locale: "en", diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index ab2c0f2ea..6ed8366c6 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -8,8 +8,8 @@ vi.mock("@/lib/db", () => ({ }, })); -vi.mock("@/lib/ai/provider", () => ({ - resolveProvider: vi.fn(), +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), })); vi.mock("@/lib/insights/memory", () => ({ @@ -22,12 +22,43 @@ vi.mock("@/lib/medication-category", () => ({ })); import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; import { getMedicationCategories } from "@/lib/medication-category"; import { generateMedicationComplianceStatusForUser } from "../medication-compliance-status"; const dayMs = 24 * 60 * 60 * 1000; +function stubCompletion( + content: string, + capture?: { userPrompt: string | null }, +) { + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { userPrompt: string }) => { + if (capture) capture.userPrompt = args.userPrompt; + return { + kind: "ok", + content, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never; + }, + ); +} + +function medFixture(now: Date) { + return { + id: "med-1", + name: "Ramipril", + dose: "5mg", + active: true, + createdAt: new Date(now.getTime() - 60 * dayMs), + schedules: [ + { id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }, + ], + }; +} + beforeEach(() => { vi.resetAllMocks(); vi.mocked(getMedicationCategories).mockResolvedValue({}); @@ -42,7 +73,9 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload" dose: "5mg", active: true, createdAt: new Date(now.getTime() - 1100 * dayMs), - schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }], + schedules: [ + { id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }, + ], }; const events: Array<{ @@ -73,17 +106,7 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload" } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async (args: { userPrompt: string }) => { - captured.userPrompt = args.userPrompt; - return { - content: '{"summary":"OK","medications":[]}', - model: "x", - tokensUsed: 1, - }; - }), - } as never); + stubCompletion('{"summary":"OK","medications":[]}', captured); await generateMedicationComplianceStatusForUser("user-1", { locale: "en", @@ -108,129 +131,81 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload" }); }); -describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub persistence", () => { - it("persists a sentinel row keyed to today when the provider times out", async () => { +describe("generateMedicationComplianceStatusForUser — timeout never persists", () => { + it("serves the fallback summary without writing a cache row on timeout", async () => { const now = new Date(); - const medication = { - id: "med-1", - name: "Ramipril", - dose: "5mg", - active: true, - createdAt: new Date(now.getTime() - 60 * dayMs), - schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }], - }; - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ - medication, + medFixture(now), ] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), + createdAt: now, } as never); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn( - () => - new Promise(() => { - /* never resolves */ - }), - ), - } as never); - - vi.useFakeTimers(); - const promise = generateMedicationComplianceStatusForUser("user-1", { - locale: "en", - }); - await vi.advanceTimersByTimeAsync(25_000); - const result = await promise; - vi.useRealTimers(); - - // Route returns the richer `{summary, medications}` shape — the - // helper writes the persist row and the route maps `summary` ← - // `text` on the way out. - expect(result.summary).toBeTruthy(); - expect(result.medications).toEqual([]); - expect(result.cached).toBe(true); - expect(result.updatedAt).toBeTruthy(); - - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBe(1); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { - dateKey?: string; - text?: string; - timeout?: boolean; - model?: string; - }; - expect(parsed.text).toBeTruthy(); - expect(parsed.timeout).toBe(true); - expect(parsed.model).toBe("timeout-stub"); - expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); - }); -}); - -describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub short-circuit", () => { - it("subsequent mounts short-circuit when a stub row with text+timeout:true exists", async () => { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: "Europe/Berlin", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date()); - const y = parts.find((p) => p.type === "year")!.value; - const m = parts.find((p) => p.type === "month")!.value; - const d = parts.find((p) => p.type === "day")!.value; - const todayKey = `${y}-${m}-${d}`; - const stubRow = { - createdAt: new Date(), - details: JSON.stringify({ - dateKey: todayKey, - locale: "en", - text: "Medication compliance fallback…", - timeout: true, - }), - }; - - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never); - const providerCall = vi.fn(); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: providerCall, + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "timeout", } as never); const result = await generateMedicationComplianceStatusForUser("user-1", { locale: "en", }); - expect(providerCall).not.toHaveBeenCalled(); - expect(result.summary).toBe("Medication compliance fallback…"); + expect(result.summary).toBeTruthy(); expect(result.medications).toEqual([]); expect(result.cached).toBe(true); - expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(result.updatedAt).toBeNull(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); }); +describe("generateMedicationComplianceStatusForUser — cache-read skips a stub", () => { + it("regenerates when the only cached row is a timeout stub", async () => { + const now = new Date(); + const todayKey = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + }).format(now); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: now, + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Medication compliance fallback…", + model: "timeout-stub", + timeout: true, + }), + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medFixture(now), + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + + stubCompletion('{"summary":"Fresh compliance assessment.","medications":[]}'); + + const result = await generateMedicationComplianceStatusForUser("user-1", { + locale: "en", + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.summary).toBe("Fresh compliance assessment."); + expect(result.cached).toBe(false); + }); +}); + describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached summary + per-medication text", async () => { const now = new Date(); - const medication = { - id: "med-1", - name: "Ramipril", - dose: "5mg", - active: true, - createdAt: new Date(now.getTime() - 60 * dayMs), - schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }], - }; vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ - medication, + medFixture(now), ] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, @@ -239,24 +214,19 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1 createdAt: new Date(), } as never); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async () => ({ - content: JSON.stringify({ - summary: - "Compliance held steady at 96%. metric:BLOOD_PRESSURE_SYS over the last 30 days.", - medications: [ - { - medicationId: "med-1", - summary: - "Ramipril taken on schedule. metric:PULSE no missed doses in 30 days.", - }, - ], - }), - model: "x", - tokensUsed: 1, - })), - } as never); + stubCompletion( + JSON.stringify({ + summary: + "Compliance held steady at 96%. metric:BLOOD_PRESSURE_SYS over the last 30 days.", + medications: [ + { + medicationId: "med-1", + summary: + "Ramipril taken on schedule. metric:PULSE no missed doses in 30 days.", + }, + ], + }), + ); const result = await generateMedicationComplianceStatusForUser("user-1", { locale: "en", diff --git a/src/lib/insights/__tests__/mood-status.test.ts b/src/lib/insights/__tests__/mood-status.test.ts index bac7611d0..73a6bcc4d 100644 --- a/src/lib/insights/__tests__/mood-status.test.ts +++ b/src/lib/insights/__tests__/mood-status.test.ts @@ -8,8 +8,8 @@ vi.mock("@/lib/db", () => ({ }, })); -vi.mock("@/lib/ai/provider", () => ({ - resolveProvider: vi.fn(), +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), })); vi.mock("@/lib/insights/memory", () => ({ @@ -18,11 +18,29 @@ vi.mock("@/lib/insights/memory", () => ({ })); import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateMoodStatusForUser } from "../mood-status"; const dayMs = 24 * 60 * 60 * 1000; +function stubCompletion( + content: string, + capture?: { userPrompt: string | null }, +) { + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { userPrompt: string }) => { + if (capture) capture.userPrompt = args.userPrompt; + return { + kind: "ok", + content, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never; + }, + ); +} + beforeEach(() => { vi.resetAllMocks(); }); @@ -39,9 +57,6 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => { for (let day = 0; day < 1000; day++) { const t = new Date(now.getTime() - day * dayMs); entries.push({ - // production code keys mood buckets by `moodLoggedAt`, so the - // `date` string here is irrelevant for bucketing — keep it - // ISO-shaped so other downstream consumers don't choke. date: t.toISOString().slice(0, 10), score: 3 + (day % 3) * 0.5, tags: [], @@ -57,17 +72,7 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => { } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async (args: { userPrompt: string }) => { - captured.userPrompt = args.userPrompt; - return { - content: '{"summary":"OK"}', - model: "x", - tokensUsed: 1, - }; - }), - } as never); + stubCompletion('{"summary":"OK"}', captured); await generateMoodStatusForUser("user-1", { locale: "en" }); @@ -88,97 +93,65 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => { }); }); -describe("generateMoodStatusForUser — v1.4.41 timeout-stub persistence", () => { - it("persists a sentinel row keyed to today when the provider times out", async () => { +describe("generateMoodStatusForUser — timeout/error never persists", () => { + it("serves the fallback without writing a cache row on timeout", async () => { const t = new Date(); vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ - { - date: t.toISOString().slice(0, 10), - score: 4, - tags: [], - moodLoggedAt: t, - }, + { date: t.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: t }, ] as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({ createdAt: new Date(), } as never); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn( - () => - new Promise(() => { - /* never resolves */ - }), - ), - } as never); - - vi.useFakeTimers(); - const promise = generateMoodStatusForUser("user-1", { locale: "en" }); - await vi.advanceTimersByTimeAsync(25_000); - const result = await promise; - vi.useRealTimers(); - - expect(result.text).toBeTruthy(); - expect(result.cached).toBe(true); - expect(result.updatedAt).toBeTruthy(); - - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBe(1); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { - dateKey?: string; - text?: string; - timeout?: boolean; - model?: string; - }; - expect(parsed.text).toBeTruthy(); - expect(parsed.timeout).toBe(true); - expect(parsed.model).toBe("timeout-stub"); - expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); - }); - - it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: "Europe/Berlin", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date()); - const y = parts.find((p) => p.type === "year")!.value; - const m = parts.find((p) => p.type === "month")!.value; - const d = parts.find((p) => p.type === "day")!.value; - const todayKey = `${y}-${m}-${d}`; - const stubRow = { - createdAt: new Date(), - details: JSON.stringify({ - dateKey: todayKey, - locale: "en", - text: "Mood fallback text…", - timeout: true, - }), - }; - - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never); - const providerCall = vi.fn(); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: providerCall, + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "timeout", } as never); const result = await generateMoodStatusForUser("user-1", { locale: "en" }); - expect(providerCall).not.toHaveBeenCalled(); - expect(result.text).toBe("Mood fallback text…"); + expect(result.text).toBeTruthy(); expect(result.cached).toBe(true); - expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(result.updatedAt).toBeNull(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); }); +describe("generateMoodStatusForUser — cache-read skips a stub", () => { + it("regenerates when the only cached row is a timeout stub", async () => { + const now = new Date(); + const todayKey = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + }).format(now); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: now, + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Mood fallback text…", + model: "timeout-stub", + timeout: true, + }), + } as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + { date: now.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: now }, + ] as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + + stubCompletion('{"summary":"Fresh mood assessment."}'); + + const result = await generateMoodStatusForUser("user-1", { locale: "en" }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("Fresh mood assessment."); + expect(result.cached).toBe(false); + }); +}); + describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { const t = new Date(); @@ -191,14 +164,7 @@ describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () createdAt: new Date(), } as never); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async () => ({ - content: '{"summary":"Mood stayed positive. metric:MOOD"}', - model: "x", - tokensUsed: 1, - })), - } as never); + stubCompletion('{"summary":"Mood stayed positive. metric:MOOD"}'); const result = await generateMoodStatusForUser("user-1", { locale: "en" }); diff --git a/src/lib/insights/__tests__/pulse-status-timeout.test.ts b/src/lib/insights/__tests__/pulse-status-timeout.test.ts index 088ea2d8b..127f7e623 100644 --- a/src/lib/insights/__tests__/pulse-status-timeout.test.ts +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -1,19 +1,15 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; /** - * v1.4.28 R3a FB-D2 — provider timeout fallback for the pulse-status - * route. When the upstream `generateCompletion` call does not resolve - * inside the 20-second budget the route returns a deterministic - * cached-style envelope so the InsightStatusCard renders the - * fallback text instead of spinning behind React-Query's default - * retries. - * - * v1.4.41 — the timeout branch now persists a sentinel keyed to - * today (see `persistTimeoutStubAndReturn`) so subsequent mounts - * short-circuit at the cache lookup. The persisted row carries - * `timeout: true` + `model: "timeout-stub"` so the daily pre-warm - * job can recognise and overwrite the stub instead of treating it - * as a real assessment. + * Provider-timeout fallback for the pulse-status route. When the + * provider chain does not resolve inside `STATUS_PROVIDER_TIMEOUT_MS` + * the route returns the deterministic no-key fallback so the + * InsightStatusCard renders instead of spinning — but it must NOT + * persist the fallback. The earlier behaviour cached a `timeout-stub` + * row keyed to today; that stub stuck until midnight and hid the real + * data-driven assessment. The fix treats a timeout as a transient miss: + * serve the fallback for this render, write nothing, re-attempt next + * mount. */ vi.mock("@/lib/db", () => ({ @@ -25,8 +21,8 @@ vi.mock("@/lib/db", () => ({ }, })); -vi.mock("@/lib/ai/provider", () => ({ - resolveProvider: vi.fn(), +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), })); vi.mock("@/lib/insights/memory", () => ({ @@ -35,20 +31,15 @@ vi.mock("@/lib/insights/memory", () => ({ })); import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generatePulseStatusForUser } from "../pulse-status"; beforeEach(() => { vi.resetAllMocks(); - vi.useFakeTimers(); -}); - -afterEach(() => { - vi.useRealTimers(); }); describe("generatePulseStatusForUser — provider timeout fallback", () => { - it("returns a cached-style envelope when the provider exceeds 20 s", async () => { + it("returns the fallback without persisting a cache row on timeout", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, gender: null, @@ -58,40 +49,21 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => { { value: 72, measuredAt: new Date() }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), + + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "timeout", } as never); - // Provider stalls indefinitely. - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn( - () => new Promise<{ content: string }>(() => {}), - ), - } as never); - - const promise = generatePulseStatusForUser("user-1", { locale: "en" }); - // Advance past the 20-second budget so the timeout race wins. - await vi.advanceTimersByTimeAsync(21_000); - const result = await promise; + const result = await generatePulseStatusForUser("user-1", { + locale: "en", + }); expect(result.hasProvider).toBe(true); expect(result.cached).toBe(true); expect(typeof result.text).toBe("string"); expect(result.text?.length ?? 0).toBeGreaterThan(0); - // v1.4.41 — the timeout branch now persists a sentinel row so - // subsequent mounts short-circuit at the cache lookup. The - // daily pre-warm worker overwrites the stub by detecting - // `timeout: true` + `model: "timeout-stub"`. - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const createArg = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { - data: { details: string }; - }; - const parsed = JSON.parse(createArg.data.details) as { - timeout?: boolean; - model?: string; - }; - expect(parsed.timeout).toBe(true); - expect(parsed.model).toBe("timeout-stub"); + // No persisted row — the sticky-stub bug is gone. + expect(result.updatedAt).toBeNull(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index 7288196b2..ce1624c8a 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -9,8 +9,8 @@ vi.mock("@/lib/db", () => ({ }, })); -vi.mock("@/lib/ai/provider", () => ({ - resolveProvider: vi.fn(), +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), })); vi.mock("@/lib/insights/memory", () => ({ @@ -19,11 +19,29 @@ vi.mock("@/lib/insights/memory", () => ({ })); import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generatePulseStatusForUser } from "../pulse-status"; const dayMs = 24 * 60 * 60 * 1000; +function stubCompletion( + content: string, + capture?: { userPrompt: string | null }, +) { + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { userPrompt: string }) => { + if (capture) capture.userPrompt = args.userPrompt; + return { + kind: "ok", + content, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never; + }, + ); +} + beforeEach(() => { vi.resetAllMocks(); }); @@ -51,17 +69,7 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => { } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async (args: { userPrompt: string }) => { - captured.userPrompt = args.userPrompt; - return { - content: '{"summary":"OK"}', - model: "x", - tokensUsed: 1, - }; - }), - } as never); + stubCompletion('{"summary":"OK"}', captured); await generatePulseStatusForUser("user-1", { locale: "en" }); @@ -82,50 +90,41 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => { }); }); -describe("generatePulseStatusForUser — v1.4.41 timeout-stub short-circuit", () => { - // The persist-on-timeout path is covered by - // `pulse-status-timeout.test.ts`. This block pins the second-mount - // behaviour: when a sentinel row already exists for today the cache - // lookup short-circuits and the provider is never invoked. - it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { - const parts = new Intl.DateTimeFormat("en-US", { +describe("generatePulseStatusForUser — cache-read skips a stub", () => { + it("regenerates when the only cached row is a timeout stub", async () => { + const now = new Date(); + const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date()); - const y = parts.find((p) => p.type === "year")!.value; - const m = parts.find((p) => p.type === "month")!.value; - const d = parts.find((p) => p.type === "day")!.value; - const todayKey = `${y}-${m}-${d}`; - const stubRow = { - createdAt: new Date(), - details: JSON.stringify({ - dateKey: todayKey, - locale: "en", - text: "Pulse fallback text…", - timeout: true, - }), - }; - + }).format(now); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never); - const providerCall = vi.fn(); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: providerCall, + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: now, + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Pulse fallback text…", + model: "timeout-stub", + timeout: true, + }), } as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 72, measuredAt: now }, + ] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + + stubCompletion('{"summary":"Fresh pulse assessment."}'); const result = await generatePulseStatusForUser("user-1", { locale: "en" }); - expect(providerCall).not.toHaveBeenCalled(); - expect(result.text).toBe("Pulse fallback text…"); - expect(result.cached).toBe(true); - expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("Fresh pulse assessment."); + expect(result.cached).toBe(false); }); }); @@ -144,15 +143,9 @@ describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", () createdAt: new Date(), } as never); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async () => ({ - content: - '{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}', - model: "x", - tokensUsed: 1, - })), - } as never); + stubCompletion( + '{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}', + ); const result = await generatePulseStatusForUser("user-1", { locale: "en" }); diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts new file mode 100644 index 000000000..33f783997 --- /dev/null +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { findFirst: vi.fn() }, + }, +})); + +import { prisma } from "@/lib/db"; +import { isTimeoutStub, readFreshStatusText } from "../status-cache"; + +const TODAY = "2026-05-31"; + +function cacheRow(details: Record, createdAt = new Date()) { + return { createdAt, details: JSON.stringify(details) }; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("isTimeoutStub", () => { + it("flags the timeout-stub model marker", () => { + expect(isTimeoutStub({ model: "timeout-stub" })).toBe(true); + }); + + it("flags the timeout:true marker", () => { + expect(isTimeoutStub({ timeout: true })).toBe(true); + }); + + it("does not flag a real assessment", () => { + expect(isTimeoutStub({ model: "gpt-4o-mini", timeout: false })).toBe(false); + expect(isTimeoutStub({})).toBe(false); + }); +}); + +describe("readFreshStatusText", () => { + it("returns today's real assessment text", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: TODAY, + text: "Your weight trend is stable.", + model: "gpt-4o-mini", + }) as never, + ); + const hit = await readFreshStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + force: false, + }); + expect(hit?.text).toBe("Your weight trend is stable."); + }); + + it("skips a timeout-stub row keyed to today", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: TODAY, + text: "Generic fallback advice.", + model: "timeout-stub", + timeout: true, + }) as never, + ); + const hit = await readFreshStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + force: false, + }); + expect(hit).toBeNull(); + }); + + it("skips a stale-day row", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ dateKey: "2026-05-30", text: "Yesterday." }) as never, + ); + const hit = await readFreshStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + force: false, + }); + expect(hit).toBeNull(); + }); + + it("skips an empty-text row", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ dateKey: TODAY, text: " " }) as never, + ); + const hit = await readFreshStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + force: false, + }); + expect(hit).toBeNull(); + }); + + it("does not read the cache under force", async () => { + const hit = await readFreshStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + force: true, + }); + expect(hit).toBeNull(); + expect(prisma.auditLog.findFirst).not.toHaveBeenCalled(); + }); + + it("treats a malformed payload as a miss", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: new Date(), + details: "{not json", + } as never); + const hit = await readFreshStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + force: false, + }); + expect(hit).toBeNull(); + }); +}); diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index 909dd392f..012bafb3f 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -8,8 +8,8 @@ vi.mock("@/lib/db", () => ({ }, })); -vi.mock("@/lib/ai/provider", () => ({ - resolveProvider: vi.fn(), +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), })); vi.mock("@/lib/insights/memory", () => ({ @@ -18,11 +18,29 @@ vi.mock("@/lib/insights/memory", () => ({ })); import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateWeightStatusForUser } from "../weight-status"; const dayMs = 24 * 60 * 60 * 1000; +function stubCompletion( + content: string, + capture?: { userPrompt: string | null }, +) { + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { userPrompt: string }) => { + if (capture) capture.userPrompt = args.userPrompt; + return { + kind: "ok", + content, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never; + }, + ); +} + beforeEach(() => { vi.resetAllMocks(); }); @@ -51,17 +69,7 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => { } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async (args: { userPrompt: string }) => { - captured.userPrompt = args.userPrompt; - return { - content: '{"summary":"OK"}', - model: "x", - tokensUsed: 1, - }; - }), - } as never); + stubCompletion('{"summary":"OK"}', captured); await generateWeightStatusForUser("user-1", { locale: "en" }); @@ -82,8 +90,8 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => { }); }); -describe("generateWeightStatusForUser — v1.4.41 timeout-stub persistence", () => { - it("persists a sentinel row keyed to today when the provider times out", async () => { +describe("generateWeightStatusForUser — timeout/error never persists", () => { + it("serves the fallback without writing a cache row on timeout", async () => { vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 82, measuredAt: new Date() }, @@ -93,83 +101,57 @@ describe("generateWeightStatusForUser — v1.4.41 timeout-stub persistence", () createdAt: new Date(), } as never); - // Provider stalls indefinitely so the timeout race wins. - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn( - () => - new Promise(() => { - /* never resolves */ - }), - ), - } as never); - - vi.useFakeTimers(); - const promise = generateWeightStatusForUser("user-1", { locale: "en" }); - await vi.advanceTimersByTimeAsync(25_000); - const result = await promise; - vi.useRealTimers(); - - expect(result.text).toBeTruthy(); - expect(result.cached).toBe(true); - expect(result.updatedAt).toBeTruthy(); - - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBe(1); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { - dateKey?: string; - text?: string; - timeout?: boolean; - model?: string; - }; - expect(parsed.text).toBeTruthy(); - expect(parsed.timeout).toBe(true); - expect(parsed.model).toBe("timeout-stub"); - expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); - }); - - it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: "Europe/Berlin", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date()); - const y = parts.find((p) => p.type === "year")!.value; - const m = parts.find((p) => p.type === "month")!.value; - const d = parts.find((p) => p.type === "day")!.value; - const todayKey = `${y}-${m}-${d}`; - const stubRow = { - createdAt: new Date(), - details: JSON.stringify({ - dateKey: todayKey, - locale: "en", - text: "Weight is a directional metric…", - timeout: true, - }), - }; - - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never); - const providerCall = vi.fn(); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: providerCall, + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "timeout", } as never); const result = await generateWeightStatusForUser("user-1", { locale: "en", }); - expect(providerCall).not.toHaveBeenCalled(); - expect(result.text).toBe("Weight is a directional metric…"); + expect(result.text).toBeTruthy(); expect(result.cached).toBe(true); - expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(result.updatedAt).toBeNull(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); }); +describe("generateWeightStatusForUser — cache-read skips a stub", () => { + it("regenerates when the only cached row is a timeout stub", async () => { + const now = new Date(); + const todayKey = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + }).format(now); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: now, + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Generic stub fallback.", + model: "timeout-stub", + timeout: true, + }), + } as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", value: 82, measuredAt: now }, + ] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + + stubCompletion('{"summary":"Fresh real assessment."}'); + + const result = await generateWeightStatusForUser("user-1", { + locale: "en", + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("Fresh real assessment."); + expect(result.cached).toBe(false); + }); +}); + describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); @@ -181,15 +163,9 @@ describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", ( createdAt: new Date(), } as never); - vi.mocked(resolveProvider).mockResolvedValue({ - type: "anthropic", - generateCompletion: vi.fn(async () => ({ - content: - '{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}', - model: "x", - tokensUsed: 1, - })), - } as never); + stubCompletion( + '{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}', + ); const result = await generateWeightStatusForUser("user-1", { locale: "en", diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index f68c68291..53e4afc40 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -1,5 +1,4 @@ import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; import { getBloodPressureSystemPrompt, getBloodPressureUserPrompt, @@ -29,11 +28,9 @@ import { type DailyBucket, } from "@/lib/insights/bucket-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; -import { - withTimeout, - STATUS_PROVIDER_TIMEOUT_MS, -} from "@/lib/insights/with-timeout"; -import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { readFreshStatusText } from "@/lib/insights/status-cache"; +import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -134,13 +131,18 @@ export async function generateBloodPressureStatusForUser( const cacheAction = `insights.blood-pressure-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); - const provider = await resolveProvider(userId); - if (provider.type === "none") { + const cached = await readFreshStatusText({ + userId, + cacheAction, + todayKey, + force, + }); + if (cached) { return { - hasProvider: false, - text: getNoKeyBloodPressureStatusText(locale), + hasProvider: true, + text: cached.text, cached: true, - updatedAt: null, + updatedAt: cached.updatedAt, }; } @@ -151,35 +153,6 @@ export async function generateBloodPressureStatusForUser( }, }); - const latestCache = await prisma.auditLog.findFirst({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, details: true }, - }); - - if (!force && latestCache?.details) { - try { - const parsed = JSON.parse(latestCache.details) as { - dateKey?: string; - text?: string; - }; - if ( - parsed.dateKey === todayKey && - typeof parsed.text === "string" && - parsed.text.trim().length > 0 - ) { - return { - hasProvider: true, - text: parsed.text, - cached: true, - updatedAt: latestCache.createdAt.toISOString(), - }; - } - } catch { - // ignore invalid cache payload - } - } - // v1.4.28 FB-D2 — cap the snapshot input. The downstream // `applyPayloadBudget` trims further; this `take` keeps the read // bounded for Apple-Health-rich accounts. BP captures three types @@ -522,56 +495,46 @@ export async function generateBloodPressureStatusForUser( locale, ); - // v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text - // on stall so the InsightStatusCard renders deterministically. - const raced = await withTimeout( - () => - provider.generateCompletion({ - systemPrompt: getBloodPressureSystemPrompt(locale), - userPrompt: getBloodPressureUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }), - STATUS_PROVIDER_TIMEOUT_MS, - null, - ); - - if (raced.timedOut || raced.value === null) { - // v1.4.37 — persist a sentinel row keyed to today so the next - // mount short-circuits at the cache lookup above instead of - // re-racing the same 20 s provider call on every cold visit. - // See `persistTimeoutStubAndReturn` for the full rationale. - return persistTimeoutStubAndReturn({ - userId, - cacheAction, + const outcome = await runStatusCompletion({ + userId, + cacheAction, + systemPrompt: getBloodPressureSystemPrompt(locale), + userPrompt: getBloodPressureUserPrompt( + snapshotJson, todayKey, locale, - providerType: provider.type, + previousContextBlock, + ), + temperature: 0.3, + maxTokens: 1000, + }); + + if (outcome.kind === "none") { + return { + hasProvider: false, + text: getNoKeyBloodPressureStatusText(locale), + cached: true, + updatedAt: null, + }; + } + if (outcome.kind === "timeout" || outcome.kind === "error") { + return returnTimeoutFallback({ + cacheAction, + reason: outcome.kind, stubText: getNoKeyBloodPressureStatusText(locale), }); } - const result = raced.value; - const content = result.content; - if (typeof content !== "string" || content.trim().length === 0) { - throw new Error("AI returned empty content for blood-pressure-status"); - } - let summary = ""; try { - const parsed = JSON.parse(content) as { summary?: string }; + const parsed = JSON.parse(outcome.content) as { summary?: string }; if (typeof parsed.summary === "string") { summary = parsed.summary; } else { - summary = content; + summary = outcome.content; } } catch { - summary = content; + summary = outcome.content; } summary = normalizeSummaryText(summary); @@ -589,9 +552,9 @@ export async function generateBloodPressureStatusForUser( dateKey: todayKey, locale, text: summary, - providerType: provider.type, - model: result.model ?? "unknown", - tokensUsed: result.tokensUsed ?? null, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, }), }, select: { createdAt: true }, diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 68313d8e6..2b3c11bb2 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -1,5 +1,4 @@ import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; import { getBmiSystemPrompt, getBmiUserPrompt } from "@/lib/ai/prompts/bmi"; import { classifyBMI } from "@/lib/analytics/classifications"; import { getNoKeyBmiStatusText } from "@/lib/insights/no-key-fallbacks"; @@ -9,11 +8,9 @@ import { } from "@/lib/insights/memory"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; -import { - withTimeout, - STATUS_PROVIDER_TIMEOUT_MS, -} from "@/lib/insights/with-timeout"; -import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { readFreshStatusText } from "@/lib/insights/status-cache"; +import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -80,13 +77,18 @@ export async function generateBmiStatusForUser( const cacheAction = `insights.bmi-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); - const provider = await resolveProvider(userId); - if (provider.type === "none") { + const cached = await readFreshStatusText({ + userId, + cacheAction, + todayKey, + force, + }); + if (cached) { return { - hasProvider: false, - text: getNoKeyBmiStatusText(locale), + hasProvider: true, + text: cached.text, cached: true, - updatedAt: null, + updatedAt: cached.updatedAt, }; } @@ -97,35 +99,6 @@ export async function generateBmiStatusForUser( }, }); - const latestCache = await prisma.auditLog.findFirst({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, details: true }, - }); - - if (!force && latestCache?.details) { - try { - const parsed = JSON.parse(latestCache.details) as { - dateKey?: string; - text?: string; - }; - if ( - parsed.dateKey === todayKey && - typeof parsed.text === "string" && - parsed.text.trim().length > 0 - ) { - return { - hasProvider: true, - text: parsed.text, - cached: true, - updatedAt: latestCache.createdAt.toISOString(), - }; - } - } catch { - // ignore invalid cache payload - } - } - if (!user?.heightCm || user.heightCm <= 0) { return { hasProvider: true, @@ -134,7 +107,7 @@ export async function generateBmiStatusForUser( ? "Für die BMI-Einschätzung fehlen aktuell Größenangaben im Profil." : "BMI assessment currently requires height data in the profile.", cached: true, - updatedAt: latestCache?.createdAt.toISOString() ?? null, + updatedAt: null, }; } @@ -253,56 +226,46 @@ export async function generateBmiStatusForUser( locale, ); - // v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text - // on stall so the InsightStatusCard renders deterministically. - const raced = await withTimeout( - () => - provider.generateCompletion({ - systemPrompt: getBmiSystemPrompt(locale), - userPrompt: getBmiUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }), - STATUS_PROVIDER_TIMEOUT_MS, - null, - ); - - if (raced.timedOut || raced.value === null) { - // v1.4.37 — persist a sentinel row keyed to today so the next - // mount short-circuits at the cache lookup above instead of - // re-racing the same 20 s provider call on every cold visit. - // See `persistTimeoutStubAndReturn` for the full rationale. - return persistTimeoutStubAndReturn({ - userId, - cacheAction, + const outcome = await runStatusCompletion({ + userId, + cacheAction, + systemPrompt: getBmiSystemPrompt(locale), + userPrompt: getBmiUserPrompt( + snapshotJson, todayKey, locale, - providerType: provider.type, + previousContextBlock, + ), + temperature: 0.3, + maxTokens: 1000, + }); + + if (outcome.kind === "none") { + return { + hasProvider: false, + text: getNoKeyBmiStatusText(locale), + cached: true, + updatedAt: null, + }; + } + if (outcome.kind === "timeout" || outcome.kind === "error") { + return returnTimeoutFallback({ + cacheAction, + reason: outcome.kind, stubText: getNoKeyBmiStatusText(locale), }); } - const result = raced.value; - const content = result.content; - if (typeof content !== "string" || content.trim().length === 0) { - throw new Error("AI returned empty content for bmi-status"); - } - let summary = ""; try { - const parsed = JSON.parse(content) as { summary?: string }; + const parsed = JSON.parse(outcome.content) as { summary?: string }; if (typeof parsed.summary === "string") { summary = parsed.summary; } else { - summary = content; + summary = outcome.content; } } catch { - summary = content; + summary = outcome.content; } summary = normalizeSummaryText(summary); @@ -318,9 +281,9 @@ export async function generateBmiStatusForUser( dateKey: todayKey, locale, text: summary, - providerType: provider.type, - model: result.model ?? "unknown", - tokensUsed: result.tokensUsed ?? null, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, }), }, select: { createdAt: true }, diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 1a2e1a828..385b6d1ca 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -1,5 +1,4 @@ import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; import { getGeneralStatusSystemPrompt, getGeneralStatusUserPrompt, @@ -14,11 +13,9 @@ import { } from "@/lib/insights/memory"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; -import { - withTimeout, - STATUS_PROVIDER_TIMEOUT_MS, -} from "@/lib/insights/with-timeout"; -import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { readFreshStatusText } from "@/lib/insights/status-cache"; +import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -88,13 +85,18 @@ export async function generateGeneralStatusForUser( const cacheAction = `insights.general-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); - const provider = await resolveProvider(userId); - if (provider.type === "none") { + const cached = await readFreshStatusText({ + userId, + cacheAction, + todayKey, + force, + }); + if (cached) { return { - hasProvider: false, - text: getNoKeyGeneralStatusText(locale), + hasProvider: true, + text: cached.text, cached: true, - updatedAt: null, + updatedAt: cached.updatedAt, }; } @@ -105,35 +107,6 @@ export async function generateGeneralStatusForUser( }, }); - const latestCache = await prisma.auditLog.findFirst({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, details: true }, - }); - - if (!force && latestCache?.details) { - try { - const parsed = JSON.parse(latestCache.details) as { - dateKey?: string; - text?: string; - }; - if ( - parsed.dateKey === todayKey && - typeof parsed.text === "string" && - parsed.text.trim().length > 0 - ) { - return { - hasProvider: true, - text: parsed.text, - cached: true, - updatedAt: latestCache.createdAt.toISOString(), - }; - } - } catch { - // ignore invalid cache payload - } - } - // v1.4.28 FB-D2 — cap the snapshot input. General-status pulls // every supported measurement type; without a cap the read scales // linearly with account density. @@ -353,56 +326,46 @@ export async function generateGeneralStatusForUser( locale, ); - // v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text - // on stall so the InsightStatusCard renders deterministically. - const raced = await withTimeout( - () => - provider.generateCompletion({ - systemPrompt: getGeneralStatusSystemPrompt(locale), - userPrompt: getGeneralStatusUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }), - STATUS_PROVIDER_TIMEOUT_MS, - null, - ); - - if (raced.timedOut || raced.value === null) { - // v1.4.37 — persist a sentinel row keyed to today so the next - // mount short-circuits at the cache lookup above instead of - // re-racing the same 20 s provider call on every cold visit. - // See `persistTimeoutStubAndReturn` for the full rationale. - return persistTimeoutStubAndReturn({ - userId, - cacheAction, + const outcome = await runStatusCompletion({ + userId, + cacheAction, + systemPrompt: getGeneralStatusSystemPrompt(locale), + userPrompt: getGeneralStatusUserPrompt( + snapshotJson, todayKey, locale, - providerType: provider.type, + previousContextBlock, + ), + temperature: 0.3, + maxTokens: 1000, + }); + + if (outcome.kind === "none") { + return { + hasProvider: false, + text: getNoKeyGeneralStatusText(locale), + cached: true, + updatedAt: null, + }; + } + if (outcome.kind === "timeout" || outcome.kind === "error") { + return returnTimeoutFallback({ + cacheAction, + reason: outcome.kind, stubText: getNoKeyGeneralStatusText(locale), }); } - const result = raced.value; - const content = result.content; - if (typeof content !== "string" || content.trim().length === 0) { - throw new Error("AI returned empty content for general-status"); - } - let summary = ""; try { - const parsed = JSON.parse(content) as { summary?: string }; + const parsed = JSON.parse(outcome.content) as { summary?: string }; if (typeof parsed.summary === "string") { summary = parsed.summary; } else { - summary = content; + summary = outcome.content; } } catch { - summary = content; + summary = outcome.content; } summary = normalizeSummaryText(summary); @@ -418,9 +381,9 @@ export async function generateGeneralStatusForUser( dateKey: todayKey, locale, text: summary, - providerType: provider.type, - model: result.model ?? "unknown", - tokensUsed: result.tokensUsed ?? null, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, }), }, select: { createdAt: true }, diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 47c0858ef..eebc855ba 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -1,5 +1,4 @@ import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; import { getMedicationComplianceSystemPrompt, getMedicationComplianceUserPrompt, @@ -19,11 +18,9 @@ import { } from "@/lib/insights/memory"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; -import { - withTimeout, - STATUS_PROVIDER_TIMEOUT_MS, -} from "@/lib/insights/with-timeout"; -import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { isTimeoutStub } from "@/lib/insights/status-cache"; +import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -69,73 +66,50 @@ export async function generateMedicationComplianceStatusForUser( const cacheAction = `insights.medication-compliance-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); - const provider = await resolveProvider(userId); - if (provider.type === "none") { - return { - hasProvider: false, - summary: getNoKeyMedicationComplianceStatusText(locale), - medications: [], - cached: true, - updatedAt: null, - }; - } - - const latestCache = await prisma.auditLog.findFirst({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, details: true }, - }); - - if (!force && latestCache?.details) { - try { - const parsed = JSON.parse(latestCache.details) as { - dateKey?: string; - summary?: string; - text?: string; - timeout?: boolean; - medications?: MedicationSummaryItem[]; - }; - - if ( - parsed.dateKey === todayKey && - typeof parsed.summary === "string" && - parsed.summary.trim().length > 0 && - Array.isArray(parsed.medications) - ) { - return { - hasProvider: true, - summary: parsed.summary, - medications: parsed.medications.filter( - (entry): entry is MedicationSummaryItem => - typeof entry?.medicationId === "string" && - typeof entry?.text === "string" && - entry.text.trim().length > 0, - ), - cached: true, - updatedAt: latestCache.createdAt.toISOString(), + // This route carries a richer cached envelope (`summary` + + // `medications`) than the standard `text`-only generators, so it + // keeps its own cache-read — but it shares the stub-rejection + // predicate so a timeout stub never sticks for the day. + if (!force) { + const latestCache = await prisma.auditLog.findFirst({ + where: { userId, action: cacheAction }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true, details: true }, + }); + if (latestCache?.details) { + try { + const parsed = JSON.parse(latestCache.details) as { + dateKey?: string; + summary?: string; + text?: string; + model?: string; + timeout?: boolean; + medications?: MedicationSummaryItem[]; }; - } - // v1.4.41 — timeout-stub rows persist `text` + `timeout: true` - // instead of the standard `summary` + `medications` envelope. - // Recognise the stub so the second mount short-circuits at the - // cache lookup instead of re-racing the provider call. - if ( - parsed.dateKey === todayKey && - parsed.timeout === true && - typeof parsed.text === "string" && - parsed.text.trim().length > 0 - ) { - return { - hasProvider: true, - summary: parsed.text, - medications: [], - cached: true, - updatedAt: latestCache.createdAt.toISOString(), - }; + if ( + parsed.dateKey === todayKey && + !isTimeoutStub(parsed) && + typeof parsed.summary === "string" && + parsed.summary.trim().length > 0 && + Array.isArray(parsed.medications) + ) { + return { + hasProvider: true, + summary: parsed.summary, + medications: parsed.medications.filter( + (entry): entry is MedicationSummaryItem => + typeof entry?.medicationId === "string" && + typeof entry?.text === "string" && + entry.text.trim().length > 0, + ), + cached: true, + updatedAt: latestCache.createdAt.toISOString(), + }; + } + } catch { + // ignore invalid cache payload } - } catch { - // ignore invalid cache payload } } @@ -154,7 +128,7 @@ export async function generateMedicationComplianceStatusForUser( : "There are currently no active medications configured.", medications: [], cached: true, - updatedAt: latestCache?.createdAt.toISOString() ?? null, + updatedAt: null, }; } @@ -333,58 +307,47 @@ export async function generateMedicationComplianceStatusForUser( locale, ); - // v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text - // on stall so the InsightStatusCard renders deterministically. - const raced = await withTimeout( - () => - provider.generateCompletion({ - systemPrompt: getMedicationComplianceSystemPrompt(locale), - userPrompt: getMedicationComplianceUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }), - STATUS_PROVIDER_TIMEOUT_MS, - null, - ); - - if (raced.timedOut || raced.value === null) { - // v1.4.37 — persist a sentinel row keyed to today so the next - // mount short-circuits at the cache lookup above instead of - // re-racing the same 20 s provider call on every cold visit. - // See `persistTimeoutStubAndReturn` for the full rationale. - // This route returns a richer shape (`summary`, `medications`) - // than the standard `text` envelope, so the helper is called - // for its persist side-effect and the route maps `summary` ← - // `text` on the way out. - const stubReturn = await persistTimeoutStubAndReturn({ - userId, - cacheAction, + const outcome = await runStatusCompletion({ + userId, + cacheAction, + systemPrompt: getMedicationComplianceSystemPrompt(locale), + userPrompt: getMedicationComplianceUserPrompt( + snapshotJson, todayKey, locale, - providerType: provider.type, + previousContextBlock, + ), + temperature: 0.3, + maxTokens: 1000, + }); + + if (outcome.kind === "none") { + return { + hasProvider: false, + summary: getNoKeyMedicationComplianceStatusText(locale), + medications: [], + cached: true, + updatedAt: null, + }; + } + if (outcome.kind === "timeout" || outcome.kind === "error") { + // Transient miss — serve the fallback for this render without + // persisting it, so the next mount re-attempts a real generation. + returnTimeoutFallback({ + cacheAction, + reason: outcome.kind, stubText: getNoKeyMedicationComplianceStatusText(locale), }); return { hasProvider: true, - summary: stubReturn.text, + summary: getNoKeyMedicationComplianceStatusText(locale), medications: [], cached: true, - updatedAt: stubReturn.updatedAt, + updatedAt: null, }; } - const result = raced.value; - const content = result.content; - if (typeof content !== "string" || content.trim().length === 0) { - throw new Error( - "AI returned empty content for medication-compliance-status", - ); - } + const content = outcome.content; let summary = ""; let medicationSummaries: MedicationSummaryItem[] = []; @@ -442,9 +405,9 @@ export async function generateMedicationComplianceStatusForUser( locale, summary, medications: medicationSummaries, - providerType: provider.type, - model: result.model ?? "unknown", - tokensUsed: result.tokensUsed ?? null, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, }), }, select: { createdAt: true }, diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index a13d5c820..2b8d44eed 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -1,5 +1,4 @@ import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; import { getMoodSystemPrompt, getMoodUserPrompt } from "@/lib/ai/prompts/mood"; import { getNoKeyMoodStatusText } from "@/lib/insights/no-key-fallbacks"; import { @@ -16,11 +15,9 @@ import { type DailyBucket, } from "@/lib/insights/bucket-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; -import { - withTimeout, - STATUS_PROVIDER_TIMEOUT_MS, -} from "@/lib/insights/with-timeout"; -import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { readFreshStatusText } from "@/lib/insights/status-cache"; +import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -118,43 +115,19 @@ export async function generateMoodStatusForUser( const cacheAction = `insights.mood-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); - const provider = await resolveProvider(userId); - if (provider.type === "none") { - return { - hasProvider: false, - text: getNoKeyMoodStatusText(locale), - cached: true, - updatedAt: null, - }; - } - - const latestCache = await prisma.auditLog.findFirst({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, details: true }, + const cached = await readFreshStatusText({ + userId, + cacheAction, + todayKey, + force, }); - - if (!force && latestCache?.details) { - try { - const parsed = JSON.parse(latestCache.details) as { - dateKey?: string; - text?: string; - }; - if ( - parsed.dateKey === todayKey && - typeof parsed.text === "string" && - parsed.text.trim().length > 0 - ) { - return { - hasProvider: true, - text: parsed.text, - cached: true, - updatedAt: latestCache.createdAt.toISOString(), - }; - } - } catch { - // ignore invalid cache payload - } + if (cached) { + return { + hasProvider: true, + text: cached.text, + cached: true, + updatedAt: cached.updatedAt, + }; } // v1.4.28 FB-D2 — cap at 90 entries (~3 months) for the mood @@ -409,56 +382,46 @@ export async function generateMoodStatusForUser( locale, ); - // v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text - // on stall so the InsightStatusCard renders deterministically. - const raced = await withTimeout( - () => - provider.generateCompletion({ - systemPrompt: getMoodSystemPrompt(locale), - userPrompt: getMoodUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }), - STATUS_PROVIDER_TIMEOUT_MS, - null, - ); - - if (raced.timedOut || raced.value === null) { - // v1.4.37 — persist a sentinel row keyed to today so the next - // mount short-circuits at the cache lookup above instead of - // re-racing the same 20 s provider call on every cold visit. - // See `persistTimeoutStubAndReturn` for the full rationale. - return persistTimeoutStubAndReturn({ - userId, - cacheAction, + const outcome = await runStatusCompletion({ + userId, + cacheAction, + systemPrompt: getMoodSystemPrompt(locale), + userPrompt: getMoodUserPrompt( + snapshotJson, todayKey, locale, - providerType: provider.type, + previousContextBlock, + ), + temperature: 0.3, + maxTokens: 1000, + }); + + if (outcome.kind === "none") { + return { + hasProvider: false, + text: getNoKeyMoodStatusText(locale), + cached: true, + updatedAt: null, + }; + } + if (outcome.kind === "timeout" || outcome.kind === "error") { + return returnTimeoutFallback({ + cacheAction, + reason: outcome.kind, stubText: getNoKeyMoodStatusText(locale), }); } - const result = raced.value; - const content = result.content; - if (typeof content !== "string" || content.trim().length === 0) { - throw new Error("AI returned empty content for mood-status"); - } - let summary = ""; try { - const parsed = JSON.parse(content) as { summary?: string }; + const parsed = JSON.parse(outcome.content) as { summary?: string }; if (typeof parsed.summary === "string") { summary = parsed.summary; } else { - summary = content; + summary = outcome.content; } } catch { - summary = content; + summary = outcome.content; } summary = normalizeSummaryText(summary); @@ -474,9 +437,9 @@ export async function generateMoodStatusForUser( dateKey: todayKey, locale, text: summary, - providerType: provider.type, - model: result.model ?? "unknown", - tokensUsed: result.tokensUsed ?? null, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, }), }, select: { createdAt: true }, diff --git a/src/lib/insights/persist-timeout-stub.ts b/src/lib/insights/persist-timeout-stub.ts deleted file mode 100644 index cfbb4a3e9..000000000 --- a/src/lib/insights/persist-timeout-stub.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { prisma } from "@/lib/db"; - -/** - * v1.4.37 / v1.4.41 — shared timeout-stub persist for InsightStatus routes. - * - * When the upstream provider call exceeds `STATUS_PROVIDER_TIMEOUT_MS` - * the caller persists a sentinel `auditLog` row keyed to today's Berlin - * day so the next mount short-circuits at the cache lookup instead of - * re-racing the same 20 s provider call on every cold visit. Without - * this, a single stall (provider hiccup, network blip, model warm-up) - * leaves the user staring at the loading state on every reload until - * the daily 02:20 pre-warm job runs. - * - * The persisted body is the deterministic no-key fallback the caller - * would have returned in the bare-fallback shape, so the user-facing - * UI is identical; only the re-fire frequency drops to zero for the - * rest of the day. - * - * `meta.timeout` + `meta.model = "timeout-stub"` are set so the daily - * pg-boss pre-warm worker (the same job that originally seeded the - * cache) can recognise and overwrite the stub rather than respect it - * as a real assessment. - * - * The write is best-effort — if it fails the caller still sees the - * deterministic fallback text and the next mount falls back to the - * (still expensive) race. We do not surface the error: the user does - * not care that the cache write missed, only that the page rendered. - */ -export async function persistTimeoutStubAndReturn(input: { - userId: string; - cacheAction: string; - todayKey: string; - locale: string; - providerType: string; - stubText: string; -}): Promise<{ - hasProvider: true; - text: string; - cached: true; - updatedAt: string | null; -}> { - const { userId, cacheAction, todayKey, locale, providerType, stubText } = - input; - let stubUpdatedAt: string | null = null; - try { - const stub = await prisma.auditLog.create({ - data: { - userId, - action: cacheAction, - details: JSON.stringify({ - dateKey: todayKey, - locale, - text: stubText, - providerType, - model: "timeout-stub", - tokensUsed: null, - timeout: true, - }), - }, - select: { createdAt: true }, - }); - stubUpdatedAt = stub.createdAt.toISOString(); - } catch { - // The persist is best-effort — silently swallow so the user-facing - // payload still renders even when the row write missed. - } - return { - hasProvider: true, - text: stubText, - cached: true, - updatedAt: stubUpdatedAt, - }; -} diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index bf78c9e24..7a5bf1caa 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -1,5 +1,4 @@ import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; import { getPulseSystemPrompt, getPulseUserPrompt, @@ -15,11 +14,9 @@ import { import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; -import { - withTimeout, - STATUS_PROVIDER_TIMEOUT_MS, -} from "@/lib/insights/with-timeout"; -import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { readFreshStatusText } from "@/lib/insights/status-cache"; +import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -85,13 +82,18 @@ export async function generatePulseStatusForUser( const cacheAction = `insights.pulse-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); - const provider = await resolveProvider(userId); - if (provider.type === "none") { + const cached = await readFreshStatusText({ + userId, + cacheAction, + todayKey, + force, + }); + if (cached) { return { - hasProvider: false, - text: getNoKeyPulseStatusText(locale), + hasProvider: true, + text: cached.text, cached: true, - updatedAt: null, + updatedAt: cached.updatedAt, }; } @@ -103,35 +105,6 @@ export async function generatePulseStatusForUser( }, }); - const latestCache = await prisma.auditLog.findFirst({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, details: true }, - }); - - if (!force && latestCache?.details) { - try { - const parsed = JSON.parse(latestCache.details) as { - dateKey?: string; - text?: string; - }; - if ( - parsed.dateKey === todayKey && - typeof parsed.text === "string" && - parsed.text.trim().length > 0 - ) { - return { - hasProvider: true, - text: parsed.text, - cached: true, - updatedAt: latestCache.createdAt.toISOString(), - }; - } - } catch { - // ignore invalid cache payload - } - } - // v1.4.28 FB-D2 — cap the snapshot input. The downstream // `applyPayloadBudget` trims further but the unbounded findMany was // pulling tens of thousands of rows for Apple-Health-rich accounts @@ -293,62 +266,46 @@ export async function generatePulseStatusForUser( locale, ); - // v1.4.28 FB-D2 — cap the provider round-trip at 20 s. On timeout - // (or upstream failure) return the no-key fallback text in a - // cached-style envelope so the status card renders deterministically - // instead of spinning behind React-Query's default retry ladder. - // v1.4.41 — the timeout branch now persists a sentinel keyed to - // today (see `persistTimeoutStubAndReturn`) so subsequent mounts - // short-circuit at the cache lookup instead of re-racing the - // upstream call. The daily pre-warm job overwrites the stub. - const raced = await withTimeout( - () => - provider.generateCompletion({ - systemPrompt: getPulseSystemPrompt(locale), - userPrompt: getPulseUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }), - STATUS_PROVIDER_TIMEOUT_MS, - null, - ); - - if (raced.timedOut || raced.value === null) { - // v1.4.37 — persist a sentinel row keyed to today so the next - // mount short-circuits at the cache lookup above instead of - // re-racing the same 20 s provider call on every cold visit. - // See `persistTimeoutStubAndReturn` for the full rationale. - return persistTimeoutStubAndReturn({ - userId, - cacheAction, + const outcome = await runStatusCompletion({ + userId, + cacheAction, + systemPrompt: getPulseSystemPrompt(locale), + userPrompt: getPulseUserPrompt( + snapshotJson, todayKey, locale, - providerType: provider.type, + previousContextBlock, + ), + temperature: 0.3, + maxTokens: 1000, + }); + + if (outcome.kind === "none") { + return { + hasProvider: false, + text: getNoKeyPulseStatusText(locale), + cached: true, + updatedAt: null, + }; + } + if (outcome.kind === "timeout" || outcome.kind === "error") { + return returnTimeoutFallback({ + cacheAction, + reason: outcome.kind, stubText: getNoKeyPulseStatusText(locale), }); } - const result = raced.value; - const content = result.content; - if (typeof content !== "string" || content.trim().length === 0) { - throw new Error("AI returned empty content for pulse-status"); - } - let summary = ""; try { - const parsed = JSON.parse(content) as { summary?: string }; + const parsed = JSON.parse(outcome.content) as { summary?: string }; if (typeof parsed.summary === "string") { summary = parsed.summary; } else { - summary = content; + summary = outcome.content; } } catch { - summary = content; + summary = outcome.content; } summary = normalizeSummaryText(summary); @@ -364,9 +321,9 @@ export async function generatePulseStatusForUser( dateKey: todayKey, locale, text: summary, - providerType: provider.type, - model: result.model ?? "unknown", - tokensUsed: result.tokensUsed ?? null, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, }), }, select: { createdAt: true }, diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts new file mode 100644 index 000000000..f7e9edae2 --- /dev/null +++ b/src/lib/insights/status-cache.ts @@ -0,0 +1,94 @@ +import { prisma } from "@/lib/db"; + +/** + * Shared cache-read for the seven `*-status.ts` insight generators. + * + * Every generator persists its assessment as an `auditLog` row keyed + * `insights.-status.` whose `details` JSON carries + * `{ dateKey, locale, text, providerType, model, tokensUsed }`. On the + * next mount the generator reads the most recent such row and, if it is + * still for today, serves it without re-hitting the provider. + * + * The timeout fallback used to poison this read. When a provider call + * exceeded the status budget the route persisted a `model:"timeout-stub"` + * / `timeout:true` row carrying the generic no-key text under the SAME + * `text` field a real assessment uses. The cache-read only checked + * `dateKey === today && text` — so the stub looked like a valid + * assessment and stuck until midnight, hiding the real data-driven text + * for the rest of the day. + * + * `readFreshStatusText` is the one cache-read every standard generator + * shares. It rejects stubs explicitly so a single stall no longer pins + * the fallback for the day, and a fresh generation is attempted instead. + */ + +interface ParsedStatusCache { + dateKey?: string; + locale?: string; + text?: string; + summary?: string; + providerType?: string; + model?: string; + tokensUsed?: number | null; + timeout?: boolean; +} + +/** + * A cached row is a timeout stub when it carries the sentinel marker the + * timeout path writes. Either flag is sufficient — older stub rows may + * predate one of the two markers, so both are honoured. + */ +export function isTimeoutStub(parsed: { + model?: string; + timeout?: boolean; +}): boolean { + return parsed.model === "timeout-stub" || parsed.timeout === true; +} + +export interface FreshStatusCacheHit { + text: string; + updatedAt: string; +} + +/** + * Read the latest cached assessment for `(userId, cacheAction)` and + * return its text only when it is (a) for today and (b) NOT a timeout + * stub. Returns `null` on a miss, a stale day, a stub, or a malformed + * payload — every one of those means the caller should regenerate. + * + * `force` short-circuits to `null` so a forced regeneration never reads + * the cache. The DB read is still skipped entirely under `force` to + * keep the forced path cheap. + */ +export async function readFreshStatusText(args: { + userId: string; + cacheAction: string; + todayKey: string; + force: boolean; +}): Promise { + const { userId, cacheAction, todayKey, force } = args; + if (force) return null; + + const latestCache = await prisma.auditLog.findFirst({ + where: { userId, action: cacheAction }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true, details: true }, + }); + if (!latestCache?.details) return null; + + try { + const parsed = JSON.parse(latestCache.details) as ParsedStatusCache; + if (parsed.dateKey !== todayKey) return null; + if (isTimeoutStub(parsed)) return null; + if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { + return null; + } + return { + text: parsed.text, + updatedAt: latestCache.createdAt.toISOString(), + }; + } catch { + // Malformed cache payload — treat as a miss and regenerate. + return null; + } +} diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts new file mode 100644 index 000000000..85b84b847 --- /dev/null +++ b/src/lib/insights/status-provider.ts @@ -0,0 +1,126 @@ +import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; +import { + AllProvidersFailedError, + runRawCompletionWithFallback, + type ProviderChainResolved, +} from "@/lib/ai/provider-runner"; +import { annotate } from "@/lib/logging/context"; +import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout"; + +/** + * Shared provider plumbing for the seven `*-status.ts` generators. + * + * Before this, each generator resolved a single provider via + * `resolveProvider()` and raced one `generateCompletion()` call against + * a 20 s cap. A degraded primary provider could not cascade, and the + * cap fired below the providers' own 60 s floor — converting healthy- + * but-slow generations into the generic fallback. + * + * This helper mirrors the Coach (`chat/route.ts`): resolve the user's + * provider chain, fall back to the legacy single provider when the chain + * is empty, and run `runRawCompletionWithFallback` so a degraded provider + * cascades to the next. The whole thing is still wrapped in + * `withTimeout` at the aligned 60 s budget so a total stall can't pin the + * card — but a timeout / error is reported as a transient miss (the + * caller serves the fallback for this render without persisting it), + * never as the day's cached assessment. + */ + +export type StatusProviderResult = + | { kind: "none" } + | { kind: "timeout" } + | { kind: "error" } + | { + kind: "ok"; + content: string; + providerType: string; + model: string; + tokensUsed: number | null; + }; + +interface RunStatusCompletionArgs { + userId: string; + cacheAction: string; + systemPrompt: string; + userPrompt: string; + temperature?: number; + maxTokens?: number; +} + +/** + * Resolve the provider chain for `userId`, falling back to the legacy + * single provider. Returns `null` when the user has no usable provider + * anywhere (the caller surfaces the no-key fallback with + * `hasProvider:false`). + */ +async function resolveStatusChain( + userId: string, +): Promise { + const chain = await resolveProviderChain(userId); + if (chain.length > 0) return chain; + + const legacy = await resolveProvider(userId); + if (legacy.type === "none") return null; + return [{ providerType: "admin-openai", instance: legacy }]; +} + +/** + * Run a status generation across the user's provider chain, bounded by + * `STATUS_PROVIDER_TIMEOUT_MS`. The result discriminates between + * no-provider / timeout / provider-error / success so the caller can + * decide what to persist — only `ok` is ever cached as the day's + * assessment. + */ +export async function runStatusCompletion( + args: RunStatusCompletionArgs, +): Promise { + const { userId, cacheAction, systemPrompt, userPrompt } = args; + + const chain = await resolveStatusChain(userId); + if (chain === null) { + return { kind: "none" }; + } + + const raced = await withTimeout( + () => + runRawCompletionWithFallback({ + userId, + providers: chain, + params: { + systemPrompt, + userPrompt, + temperature: args.temperature ?? 0.3, + maxTokens: args.maxTokens ?? 1000, + }, + }), + STATUS_PROVIDER_TIMEOUT_MS, + null, + ); + + if (raced.timedOut) { + return { kind: "timeout" }; + } + if (raced.errored || raced.value === null) { + return { kind: "error" }; + } + + const { result, workingProvider } = raced.value; + const content = result.content; + if (typeof content !== "string" || content.trim().length === 0) { + annotate({ + action: { name: "insights.status.empty_content" }, + meta: { cacheAction, providerType: workingProvider.providerType }, + }); + return { kind: "error" }; + } + + return { + kind: "ok", + content, + providerType: workingProvider.providerType, + model: result.model ?? "unknown", + tokensUsed: result.tokensUsed ?? null, + }; +} + +export { AllProvidersFailedError }; diff --git a/src/lib/insights/timeout-fallback.ts b/src/lib/insights/timeout-fallback.ts new file mode 100644 index 000000000..9b94b273e --- /dev/null +++ b/src/lib/insights/timeout-fallback.ts @@ -0,0 +1,46 @@ +import { annotate } from "@/lib/logging/context"; + +/** + * Shared timeout / provider-error fallback for the InsightStatus routes. + * + * When the upstream provider call exceeds `STATUS_PROVIDER_TIMEOUT_MS` + * (or rejects outright) the route still has to return a deterministic + * envelope so the InsightStatusCard renders the no-key fallback text + * instead of spinning. It used to ALSO persist a sentinel `auditLog` + * row keyed to today's Berlin day. That stub carried the generic + * fallback under the same `text` field a real assessment uses, the + * cache-read served it as valid, and it stuck until midnight — hiding + * the real, data-driven assessment for the rest of the day on any + * account that hit a single transient stall. + * + * The fix removes the persist entirely: a timeout / error is treated as + * a transient miss. The current render still gets the fallback text, but + * nothing is written, so the next mount re-attempts a real generation. + * The shared cache-read (`status-cache.ts`) additionally rejects any + * legacy stub rows still in the table by their `model:"timeout-stub"` / + * `timeout:true` markers. + * + * `cached:true` is kept so the UI does not mislabel the fallback as a + * fresh assessment, and `updatedAt:null` signals "no persisted row". + */ +export function returnTimeoutFallback(input: { + cacheAction: string; + reason: "timeout" | "error"; + stubText: string; +}): { + hasProvider: true; + text: string; + cached: true; + updatedAt: null; +} { + annotate({ + action: { name: "insights.status.fallback_served" }, + meta: { cacheAction: input.cacheAction, reason: input.reason }, + }); + return { + hasProvider: true, + text: input.stubText, + cached: true, + updatedAt: null, + }; +} diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index e1cab0b63..c4f718452 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -1,5 +1,4 @@ import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; import { getWeightSystemPrompt, getWeightUserPrompt, @@ -19,11 +18,9 @@ import { type DailyBucket, } from "@/lib/insights/bucket-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; -import { - withTimeout, - STATUS_PROVIDER_TIMEOUT_MS, -} from "@/lib/insights/with-timeout"; -import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { readFreshStatusText } from "@/lib/insights/status-cache"; +import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -121,43 +118,22 @@ export async function generateWeightStatusForUser( const cacheAction = `insights.weight-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); - const provider = await resolveProvider(userId); - if (provider.type === "none") { - return { - hasProvider: false, - text: getNoKeyWeightStatusText(locale), - cached: true, - updatedAt: null, - }; - } - - const latestCache = await prisma.auditLog.findFirst({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, details: true }, + // Serve today's real assessment when present. The shared reader + // rejects timeout stubs, so a single stall no longer pins the + // fallback for the day. + const cached = await readFreshStatusText({ + userId, + cacheAction, + todayKey, + force, }); - - if (!force && latestCache?.details) { - try { - const parsed = JSON.parse(latestCache.details) as { - dateKey?: string; - text?: string; - }; - if ( - parsed.dateKey === todayKey && - typeof parsed.text === "string" && - parsed.text.trim().length > 0 - ) { - return { - hasProvider: true, - text: parsed.text, - cached: true, - updatedAt: latestCache.createdAt.toISOString(), - }; - } - } catch { - // ignore invalid cache payload - } + if (cached) { + return { + hasProvider: true, + text: cached.text, + cached: true, + updatedAt: cached.updatedAt, + }; } // v1.4.28 FB-D2 — cap the snapshot input. BP captures three types @@ -402,56 +378,50 @@ export async function generateWeightStatusForUser( locale, ); - // v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text - // on stall so the InsightStatusCard renders deterministically. - const raced = await withTimeout( - () => - provider.generateCompletion({ - systemPrompt: getWeightSystemPrompt(locale), - userPrompt: getWeightUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }), - STATUS_PROVIDER_TIMEOUT_MS, - null, - ); - - if (raced.timedOut || raced.value === null) { - // v1.4.37 — persist a sentinel row keyed to today so the next - // mount short-circuits at the cache lookup above instead of - // re-racing the same 20 s provider call on every cold visit. - // See `persistTimeoutStubAndReturn` for the full rationale. - return persistTimeoutStubAndReturn({ - userId, - cacheAction, + // Run the provider chain bounded by the aligned 60 s budget. A + // timeout / error / empty content is a transient miss — serve the + // fallback for this render without persisting it, so the next mount + // re-attempts a real generation rather than sticking the fallback. + const outcome = await runStatusCompletion({ + userId, + cacheAction, + systemPrompt: getWeightSystemPrompt(locale), + userPrompt: getWeightUserPrompt( + snapshotJson, todayKey, locale, - providerType: provider.type, + previousContextBlock, + ), + temperature: 0.3, + maxTokens: 1000, + }); + + if (outcome.kind === "none") { + return { + hasProvider: false, + text: getNoKeyWeightStatusText(locale), + cached: true, + updatedAt: null, + }; + } + if (outcome.kind === "timeout" || outcome.kind === "error") { + return returnTimeoutFallback({ + cacheAction, + reason: outcome.kind, stubText: getNoKeyWeightStatusText(locale), }); } - const result = raced.value; - const content = result.content; - if (typeof content !== "string" || content.trim().length === 0) { - throw new Error("AI returned empty content for weight-status"); - } - let summary = ""; try { - const parsed = JSON.parse(content) as { summary?: string }; + const parsed = JSON.parse(outcome.content) as { summary?: string }; if (typeof parsed.summary === "string") { summary = parsed.summary; } else { - summary = content; + summary = outcome.content; } } catch { - summary = content; + summary = outcome.content; } summary = normalizeSummaryText(summary); @@ -467,9 +437,9 @@ export async function generateWeightStatusForUser( dateKey: todayKey, locale, text: summary, - providerType: provider.type, - model: result.model ?? "unknown", - tokensUsed: result.tokensUsed ?? null, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, }), }, select: { createdAt: true }, diff --git a/src/lib/insights/with-timeout.ts b/src/lib/insights/with-timeout.ts index 142827b35..609019aee 100644 --- a/src/lib/insights/with-timeout.ts +++ b/src/lib/insights/with-timeout.ts @@ -1,25 +1,30 @@ +import { annotate } from "@/lib/logging/context"; + /** - * v1.4.28 R3a FB-D2 — race an awaited promise against a hard timeout. + * Race an awaited promise against a hard timeout. * * The seven `*-status.ts` routes await `provider.generateCompletion()` - * with no client-side timeout. When the upstream provider stalls (cold - * cache, transient brown-out) React-Query's three default retries plus - * default exponential backoff stacks on top of the upstream latency - * and the InsightStatusCard spins for up to 90 s before the user sees - * either the cached fallback text or a generic failure. + * and need an upper bound so a stalled provider does not pin the + * InsightStatusCard behind React-Query's retries. `withTimeout` resolves + * with the wrapped promise's value when it lands inside the budget, or + * the `fallback` value otherwise. * - * `withTimeout(promise, ms, fallback)` resolves with the wrapped - * promise's value when it lands inside the budget, or the `fallback` - * value otherwise. Use the `signal` payload to abort upstream work so - * the original promise does not keep consuming a provider slot after - * the caller has moved on. + * Returns a typed envelope so the call site can distinguish a real + * timeout from an upstream error from a normal value, and react + * differently to each — a timeout/error must NOT be persisted as a + * day-long cache entry the way a real assessment is. * - * Returns a typed envelope so the call site can distinguish "timed - * out" from "the upstream returned this value" without re-encoding - * the fallback as a magic string. + * The budget matches the provider clients' own 60 s ceiling. The + * earlier 20 s cap fired below the providers' floor on cold starts and + * model warm-ups, converting healthy-but-slow generations into the + * generic fallback; aligning the two lets a slow-but-valid round-trip + * land instead of being discarded. */ export interface TimeoutEnvelope { + /** True when the budget expired before the upstream settled. */ timedOut: boolean; + /** True when the upstream rejected before the budget expired. */ + errored: boolean; value: T; } @@ -35,7 +40,11 @@ export function withTimeout( timer = setTimeout(() => { if (settled) return; settled = true; - resolve({ timedOut: true, value: fallback }); + annotate({ + action: { name: "insights.status.provider_timeout" }, + meta: { timeout_ms: ms }, + }); + resolve({ timedOut: true, errored: false, value: fallback }); }, ms); start() @@ -46,26 +55,35 @@ export function withTimeout( clearTimeout(timer); timer = null; } - resolve({ timedOut: false, value }); + resolve({ timedOut: false, errored: false, value }); }) - .catch(() => { + .catch((error: unknown) => { if (settled) return; settled = true; if (timer) { clearTimeout(timer); timer = null; } - // Upstream errored before the timeout fired — surface the - // fallback so the route still returns a deterministic shape - // and the caller does not have to learn another failure mode. - resolve({ timedOut: true, value: fallback }); + // Upstream errored before the timeout fired. Surface it as a + // distinct `errored` envelope and annotate the reason so a real + // provider failure is observable in the wide events instead of + // being silently swallowed into the timeout path. + const reason = + error instanceof Error ? error.message : String(error ?? "unknown"); + annotate({ + action: { name: "insights.status.provider_error" }, + meta: { reason: reason.slice(0, 240) }, + }); + resolve({ timedOut: false, errored: true, value: fallback }); }); }); } /** - * The 20-second cap shipped in v1.4.28. Provider round-trips that - * exceed this budget are converted to a cached-fallback render with a - * "couldn't reach provider" caption on the InsightStatusCard. + * Status-path provider budget, aligned with the provider clients' own + * 60 s ceiling. A round-trip that exceeds this is treated as a transient + * miss — the caller returns the deterministic fallback for this render + * WITHOUT persisting it as the day's assessment, so the next mount + * re-attempts a real generation. */ -export const STATUS_PROVIDER_TIMEOUT_MS = 20_000; +export const STATUS_PROVIDER_TIMEOUT_MS = 60_000;