diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts index 6dd0e92a9..df2aa21fd 100644 --- a/src/hooks/use-insight-status.ts +++ b/src/hooks/use-insight-status.ts @@ -78,5 +78,10 @@ export function useInsightStatus(metric: InsightStatusMetric) { }, enabled: isAuthenticated, staleTime: 60 * 1000, + // v1.4.28 FB-D2 — the status route now returns a deterministic + // cached-style envelope on provider timeout. Retrying that response + // re-fires the same expensive upstream call and the user perceives + // a longer hang. Drop retries to surface the fallback immediately. + retry: 0, }); } diff --git a/src/lib/insights/__tests__/pulse-status-timeout.test.ts b/src/lib/insights/__tests__/pulse-status-timeout.test.ts new file mode 100644 index 000000000..8936b8c6d --- /dev/null +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi, beforeEach, afterEach } 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. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + measurement: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/ai/provider", () => ({ + resolveProvider: vi.fn(), +})); + +vi.mock("@/lib/insights/memory", () => ({ + getPreviousInsightContext: vi.fn().mockResolvedValue(null), + formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), +})); + +import { prisma } from "@/lib/db"; +import { resolveProvider } from "@/lib/ai/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 () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 72, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] 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; + + expect(result.hasProvider).toBe(true); + expect(result.cached).toBe(true); + expect(typeof result.text).toBe("string"); + expect(result.text?.length ?? 0).toBeGreaterThan(0); + // The route must NOT persist a fallback into the audit cache — + // a transient provider stall should not poison tomorrow's cache + // hit. + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index ef702a85b..fde03d4e8 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -24,6 +24,10 @@ 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 { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -179,20 +183,27 @@ export async function generateBloodPressureStatusForUser( } } - const measurements = await prisma.measurement.findMany({ - where: { - userId, - type: { - in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + // 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 + // per reading day so 1095 = 365 d × 3 channels. + const measurements = await prisma.measurement + .findMany({ + where: { + userId, + type: { + in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + }, }, - }, - orderBy: { measuredAt: "asc" }, - select: { - type: true, - value: true, - measuredAt: true, - }, - }); + orderBy: { measuredAt: "desc" }, + take: 1095, + select: { + type: true, + value: true, + measuredAt: true, + }, + }) + .then((rows) => rows.reverse()); const now = new Date(); @@ -352,12 +363,17 @@ export async function generateBloodPressureStatusForUser( }; }); - // Fetch mood context (optional — for enrichment only) - const moodEntries = await prisma.moodEntry.findMany({ - where: { userId }, - orderBy: { moodLoggedAt: "asc" }, - select: { date: true, score: true, moodLoggedAt: true }, - }); + // Fetch mood context (optional — for enrichment only). v1.4.28 + // FB-D2 — cap at 90 entries (~3 months) so power users don't pull + // unbounded rows before the bucket-series budget runs. + const moodEntries = await prisma.moodEntry + .findMany({ + where: { userId }, + orderBy: { moodLoggedAt: "desc" }, + take: 90, + select: { date: true, score: true, moodLoggedAt: true }, + }) + .then((rows) => rows.reverse()); const moodSeries = applyPayloadBudget( moodEntries.map((entry) => ({ @@ -496,18 +512,35 @@ export async function generateBloodPressureStatusForUser( locale, ); - const result = await provider.generateCompletion({ - systemPrompt: getBloodPressureSystemPrompt(locale), - userPrompt: getBloodPressureUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }); + // 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) { + return { + hasProvider: true, + text: getNoKeyBloodPressureStatusText(locale), + cached: true, + 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 blood-pressure-status"); diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 2c034a5be..c669f932a 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -9,6 +9,10 @@ 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 { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -141,17 +145,23 @@ export async function generateBmiStatusForUser( }; } - const measurements = await prisma.measurement.findMany({ - where: { - userId, - type: "WEIGHT", - }, - orderBy: { measuredAt: "asc" }, - select: { - value: true, - measuredAt: true, - }, - }); + // v1.4.28 FB-D2 — cap the snapshot input (weight runs at most once + // per day for typical users; 365 covers a full year while the + // downstream payload budget trims further). + const measurements = await prisma.measurement + .findMany({ + where: { + userId, + type: "WEIGHT", + }, + orderBy: { measuredAt: "desc" }, + take: 365, + select: { + value: true, + measuredAt: true, + }, + }) + .then((rows) => rows.reverse()); const now = new Date(); const weightSeries = applyPayloadBudget( @@ -250,18 +260,35 @@ export async function generateBmiStatusForUser( locale, ); - const result = await provider.generateCompletion({ - systemPrompt: getBmiSystemPrompt(locale), - userPrompt: getBmiUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }); + // 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) { + return { + hasProvider: true, + text: getNoKeyBmiStatusText(locale), + cached: true, + 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 bmi-status"); diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 1c4ce1479..f89abdfaf 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -14,6 +14,10 @@ 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 { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -137,20 +141,26 @@ export async function generateGeneralStatusForUser( } } - const measurements = await prisma.measurement.findMany({ - where: { - userId, - type: { - in: [...MEASUREMENT_TYPES], + // 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. + const measurements = await prisma.measurement + .findMany({ + where: { + userId, + type: { + in: [...MEASUREMENT_TYPES], + }, }, - }, - orderBy: { measuredAt: "asc" }, - select: { - type: true, - value: true, - measuredAt: true, - }, - }); + orderBy: { measuredAt: "desc" }, + take: 5000, + select: { + type: true, + value: true, + measuredAt: true, + }, + }) + .then((rows) => rows.reverse()); const now = new Date(); @@ -219,12 +229,16 @@ export async function generateGeneralStatusForUser( })); const adherenceSeries = applyPayloadBudget(adherenceRecords, { now }); - // Fetch mood context (optional — for enrichment only) - const moodEntries = await prisma.moodEntry.findMany({ - where: { userId }, - orderBy: { moodLoggedAt: "asc" }, - select: { date: true, score: true, moodLoggedAt: true }, - }); + // Fetch mood context (optional — for enrichment only). v1.4.28 + // FB-D2 — cap at 90 entries. + const moodEntries = await prisma.moodEntry + .findMany({ + where: { userId }, + orderBy: { moodLoggedAt: "desc" }, + take: 90, + select: { date: true, score: true, moodLoggedAt: true }, + }) + .then((rows) => rows.reverse()); const moodRecords = moodEntries.map((entry) => ({ measuredAt: entry.moodLoggedAt, @@ -345,18 +359,35 @@ export async function generateGeneralStatusForUser( locale, ); - const result = await provider.generateCompletion({ - systemPrompt: getGeneralStatusSystemPrompt(locale), - userPrompt: getGeneralStatusUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }); + // 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) { + return { + hasProvider: true, + text: getNoKeyGeneralStatusText(locale), + cached: true, + 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 general-status"); diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 78812efd1..cc91e4fa6 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -14,6 +14,10 @@ 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 { annotate } from "@/lib/logging/context"; // 360 daily days + 24 monthly windows ≈ 1080 days of intake history. @@ -308,18 +312,36 @@ export async function generateMedicationComplianceStatusForUser( locale, ); - const result = await provider.generateCompletion({ - systemPrompt: getMedicationComplianceSystemPrompt(locale), - userPrompt: getMedicationComplianceUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }); + // 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) { + return { + hasProvider: true, + summary: getNoKeyMedicationComplianceStatusText(locale), + medications: [], + cached: true, + updatedAt: null, + }; + } + + const result = raced.value; const content = result.content; if (typeof content !== "string" || content.trim().length === 0) { throw new Error( diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index f6d5df26d..ad413d742 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -16,6 +16,10 @@ 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 { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -161,18 +165,25 @@ export async function generateMoodStatusForUser( } } - const entries = await prisma.moodEntry.findMany({ - where: { - userId, - }, - orderBy: { date: "asc" }, - select: { - date: true, - score: true, - tags: true, - moodLoggedAt: true, - }, - }); + // v1.4.28 FB-D2 — cap at 90 entries (~3 months) for the mood + // surface. The downstream `applyPayloadBudget` slims further. + // Order desc + reverse so the per-row consumers downstream (which + // expect oldest-first) see the same shape. + const entries = await prisma.moodEntry + .findMany({ + where: { + userId, + }, + orderBy: { date: "desc" }, + take: 90, + select: { + date: true, + score: true, + tags: true, + moodLoggedAt: true, + }, + }) + .then((rows) => rows.reverse()); const now = new Date(); @@ -226,15 +237,21 @@ export async function generateMoodStatusForUser( ? Math.round((Date.now() - newestEntry.getTime()) / (24 * 60 * 60 * 1000)) : null; - // Fetch cross-metric context for enrichment - const measurements = await prisma.measurement.findMany({ - where: { - userId, - type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"] }, - }, - orderBy: { measuredAt: "asc" }, - select: { type: true, value: true, measuredAt: true }, - }); + // Fetch cross-metric context for enrichment. v1.4.28 FB-D2 — cap at + // 1095 = 365 d × 3 channels so power users do not pull unbounded + // rows. Order desc + reverse so the downstream filters see oldest- + // first as before. + const measurements = await prisma.measurement + .findMany({ + where: { + userId, + type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"] }, + }, + orderBy: { measuredAt: "desc" }, + take: 1095, + select: { type: true, value: true, measuredAt: true }, + }) + .then((rows) => rows.reverse()); const weightSeries = applyPayloadBudget( measurements @@ -400,18 +417,35 @@ export async function generateMoodStatusForUser( locale, ); - const result = await provider.generateCompletion({ - systemPrompt: getMoodSystemPrompt(locale), - userPrompt: getMoodUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }); + // 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) { + return { + hasProvider: true, + text: getNoKeyMoodStatusText(locale), + cached: true, + 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 mood-status"); diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index eb05036ed..a206b4deb 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -15,6 +15,10 @@ 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 { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -136,17 +140,22 @@ export async function generatePulseStatusForUser( } } + // 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 + // before the budget call even started. const measurements = await prisma.measurement.findMany({ where: { userId, type: "PULSE", }, - orderBy: { measuredAt: "asc" }, + orderBy: { measuredAt: "desc" }, + take: 365, select: { value: true, measuredAt: true, }, - }); + }).then((rows) => rows.reverse()); const now = new Date(); @@ -161,12 +170,17 @@ export async function generatePulseStatusForUser( pulseSeries.daily.map((bucket) => ({ value: bucket.value })), ); - // Fetch mood context (optional — for enrichment only) - const moodEntries = await prisma.moodEntry.findMany({ - where: { userId }, - orderBy: { moodLoggedAt: "asc" }, - select: { date: true, score: true, moodLoggedAt: true }, - }); + // Fetch mood context (optional — for enrichment only). Cap at 90 + // entries (~3 months); the bucket-series budget then summarises + // further. v1.4.28 FB-D2 — prevent unbounded reads for power users. + const moodEntries = await prisma.moodEntry + .findMany({ + where: { userId }, + orderBy: { moodLoggedAt: "desc" }, + take: 90, + select: { date: true, score: true, moodLoggedAt: true }, + }) + .then((rows) => rows.reverse()); const moodSeries = applyPayloadBudget( moodEntries.map((entry) => ({ @@ -286,18 +300,39 @@ export async function generatePulseStatusForUser( locale, ); - const result = await provider.generateCompletion({ - systemPrompt: getPulseSystemPrompt(locale), - userPrompt: getPulseUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }); + // 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. + // The fallback is NOT persisted to the audit cache — a transient + // upstream stall would otherwise poison tomorrow's hit. + 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) { + return { + hasProvider: true, + text: getNoKeyPulseStatusText(locale), + cached: true, + 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 pulse-status"); diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index e61725df4..b8e1be4ec 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -19,6 +19,10 @@ 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 { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -164,20 +168,26 @@ export async function generateWeightStatusForUser( } } - const measurements = await prisma.measurement.findMany({ - where: { - userId, - type: { - in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + // v1.4.28 FB-D2 — cap the snapshot input. BP captures three types + // per day so 1095 = 365 d × 3 channels. Order desc + reverse so we + // keep the most recent year of rows. + const measurements = await prisma.measurement + .findMany({ + where: { + userId, + type: { + in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + }, }, - }, - orderBy: { measuredAt: "asc" }, - select: { - type: true, - value: true, - measuredAt: true, - }, - }); + take: 1095, + orderBy: { measuredAt: "desc" }, + select: { + type: true, + value: true, + measuredAt: true, + }, + }) + .then((rows) => rows.reverse()); const now = new Date(); @@ -211,12 +221,16 @@ export async function generateWeightStatusForUser( { now }, ); - // Fetch mood context (optional — for enrichment only) - const moodEntries = await prisma.moodEntry.findMany({ - where: { userId }, - orderBy: { moodLoggedAt: "asc" }, - select: { date: true, score: true, moodLoggedAt: true }, - }); + // Fetch mood context (optional — for enrichment only). v1.4.28 + // FB-D2 — cap at 90 entries. + const moodEntries = await prisma.moodEntry + .findMany({ + where: { userId }, + orderBy: { moodLoggedAt: "desc" }, + take: 90, + select: { date: true, score: true, moodLoggedAt: true }, + }) + .then((rows) => rows.reverse()); const moodSeries = applyPayloadBudget( moodEntries.map((entry) => ({ @@ -396,18 +410,35 @@ export async function generateWeightStatusForUser( locale, ); - const result = await provider.generateCompletion({ - systemPrompt: getWeightSystemPrompt(locale), - userPrompt: getWeightUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - ), - temperature: 0.3, - maxTokens: 1000, - }); + // 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) { + return { + hasProvider: true, + text: getNoKeyWeightStatusText(locale), + cached: true, + 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 weight-status"); diff --git a/src/lib/insights/with-timeout.ts b/src/lib/insights/with-timeout.ts new file mode 100644 index 000000000..142827b35 --- /dev/null +++ b/src/lib/insights/with-timeout.ts @@ -0,0 +1,71 @@ +/** + * v1.4.28 R3a FB-D2 — 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. + * + * `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 "timed + * out" from "the upstream returned this value" without re-encoding + * the fallback as a magic string. + */ +export interface TimeoutEnvelope { + timedOut: boolean; + value: T; +} + +export function withTimeout( + start: () => Promise, + ms: number, + fallback: T, +): Promise> { + let timer: ReturnType | null = null; + let settled = false; + + return new Promise>((resolve) => { + timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve({ timedOut: true, value: fallback }); + }, ms); + + start() + .then((value) => { + if (settled) return; + settled = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + resolve({ timedOut: false, value }); + }) + .catch(() => { + 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 }); + }); + }); +} + +/** + * 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. + */ +export const STATUS_PROVIDER_TIMEOUT_MS = 20_000;