From d7c71f3b320d09b86267cfc4df5072711e061572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 10:23:14 +0200 Subject: [PATCH] perf(insights): compute-once feature snapshot per warm scope The comprehensive briefing's bounded feature read (extractFeatures) was paid more than once where it should not be: the on-demand route and the shared generator each run a downgrade ladder that re-reads with identical arguments, and a nightly warm has no shared object across consumers in a single tick. Add a request/run-scoped feature cache: withFeatureCacheScope opens an AsyncLocalStorage store, getCachedFeatures memoises the first compute per (userId, day, includeRaw, sinceDays), gated on a cheap salient-input fingerprint so a mid-scope ingest recomputes and a probe failure fails safe. Outside a scope it is a pure passthrough. Wired into the comprehensive generator, the on-demand POST handler, and the nightly per-candidate loop + forceWarmUser. Coach keeps its own snapshot. --- src/app/api/insights/generate/route.ts | 1066 +++++++++-------- .../insights/__tests__/feature-cache.test.ts | 235 ++++ src/lib/insights/comprehensive-generate.ts | 28 +- src/lib/insights/feature-cache.ts | 177 +++ src/lib/jobs/insight-pregenerate.ts | 535 +++++---- 5 files changed, 1257 insertions(+), 784 deletions(-) create mode 100644 src/lib/insights/__tests__/feature-cache.test.ts create mode 100644 src/lib/insights/feature-cache.ts diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 17b8a9765..bddd9313a 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -12,6 +12,10 @@ import { BRIEFING_FEATURE_WINDOW_DAYS, } from "@/lib/insights/features"; import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; +import { + getCachedFeatures, + withFeatureCacheScope, +} from "@/lib/insights/feature-cache"; import { buildBriefingIllnessCycleContext, buildBriefingIllnessCyclePrompt, @@ -343,560 +347,586 @@ export const GET = apiHandler(async (request: NextRequest) => { }); }); -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - // v1.4.31 — the advisor + daily briefing surfaces ride this - // endpoint. Operator disables Coach, every advisor consumer - // (briefing card, recommendations grid, hero strip narration) - // empties out. - await requireAssistantSurface("coach"); - const userId = user.id; +export const POST = apiHandler((request: NextRequest) => + // v1.18.11 P3 — open a request-scoped feature cache so the bounded feature + // read is computed once and reused across the downgrade ladder (and any + // sibling consumer) for this request. + withFeatureCacheScope(async () => { + const { user } = await requireAuth(); + // v1.4.31 — the advisor + daily briefing surfaces ride this + // endpoint. Operator disables Coach, every advisor consumer + // (briefing card, recommendations grid, hero strip narration) + // empties out. + await requireAssistantSurface("coach"); + const userId = user.id; - const dbUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { - insightsPrivacyMode: true, - insightsCachedAt: true, - insightsCachedText: true, - // v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach - // settings. Filtered off `features` before serialisation so the - // LLM never sees the excluded blocks. - insightsExcludeMetrics: true, - locale: true, - // v1.10.0 — profile for the derived-signal briefing detector. - dateOfBirth: true, - gender: true, - heightCm: true, - }, - }); + const dbUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { + insightsPrivacyMode: true, + insightsCachedAt: true, + insightsCachedText: true, + // v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach + // settings. Filtered off `features` before serialisation so the + // LLM never sees the excluded blocks. + insightsExcludeMetrics: true, + locale: true, + // v1.10.0 — profile for the derived-signal briefing detector. + dateOfBirth: true, + gender: true, + heightCm: true, + }, + }); - const locale = await resolveServerLocale({ - request, - userLocale: dbUser?.locale ?? user.locale ?? null, - }); + const locale = await resolveServerLocale({ + request, + userLocale: dbUser?.locale ?? user.locale ?? null, + }); - const { data: body, error: jsonError } = await safeJson<{ - force?: unknown; - }>(request, { maxBytes: 16 * 1024 }); - if (jsonError) return jsonError; - const forceRefresh = body.force === true; + const { data: body, error: jsonError } = await safeJson<{ + force?: unknown; + }>(request, { maxBytes: 16 * 1024 }); + if (jsonError) return jsonError; + const forceRefresh = body.force === true; - if ( - !forceRefresh && - dbUser?.insightsCachedAt && - dbUser.insightsCachedText && - Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000 - ) { - try { - const cached = JSON.parse(dbUser.insightsCachedText); - // v1.4.16 B5c: detect legacy payloads (pre-rationale) so the UI - // can show a "regenerate for new explainability features" CTA. - // We don't auto-regenerate — that would burn rate-limit tokens - // on a cache-hit silently. User-initiated only. - const legacyPayload = isLegacyInsightPayload(cached); - annotate({ - action: { name: "insights.generate" }, - meta: { cached: true, legacyPayload }, - }); - return apiSuccess({ - insights: cached, - cached: true, - cachedAt: dbUser.insightsCachedAt, - legacyPayload, - }); - } catch { - // Invalid cache, regenerate - } - } - - // P13: rate-limit check moved BELOW the cache return so a hit on the - // 24h cache never burns one of the LLM-generation tokens. Otherwise a - // noisy reload loop on the dashboard would lock out real refreshes - // for an hour for no benefit. - // - // v1.4.16 A7: limit raised from 2 → 10 per hour (env-configurable via - // `INSIGHTS_RATE_LIMIT_PER_HOUR`). The 2/hour limit triggered too - // aggressively when a user iterated on settings or regenerated after - // adding a few measurements; 10/hour still cleanly bounds cost while - // staying out of the way for legitimate use. - const limit = resolveInsightsRateLimit(); - const rl = await checkRateLimit(`insights:${userId}`, limit, 60 * 60 * 1000); - if (!rl.allowed) { - return apiError(`Maximum ${limit} insight generations per hour.`, 429); - } - - // v1.4.16 phase B5b: resolve the user's full provider chain so a hard - // failure on the primary (401, 5xx, network) cascades to the next - // configured fallback rather than surfacing a 503/422 to the - // dashboard. Falls back to the single-provider resolution when the - // chain comes back empty AND the legacy `aiProvider` field still - // points at something usable — preserving v1.4.15 behaviour for - // accounts that haven't customised the chain. - const chain = await resolveProviderChain(userId); - if (chain.length === 0) { - // Try the legacy single-provider path one more time so users who - // configured AI before v1.4.16 don't suddenly see the 422. - const legacy = await resolveProvider(userId); - if (legacy.type === "none") { - return apiError( - "No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.", - 422, - ); - } - chain.push({ providerType: "admin-openai", instance: legacy }); - } - - // v1.12.1 — consent gate before any server-managed external egress. - // Required for the operator's global key (`admin-openai`); BYOK / local / - // ChatGPT-OAuth chains stay ungated. Throws ConsentRequiredError → - // apiHandler returns 403 + `consent.ai.required`. - await assertConsentForChain({ userId, chain, surface: "insights" }); - - const includeRaw = dbUser?.insightsPrivacyMode === "raw"; - // v1.4.36 W3 T1 — `extractFeatures` enforces a 5 MB ceiling on the - // serialised payload. If the raw (bucketed) shape ever blows past - // it (regression watch — the v1.4.35 rawMeasurements shape hit - // 25.9 MB on the largest tenant), the helper throws - // `FeaturesPayloadTooLargeError`. We downgrade to the aggregated - // shape rather than 500-ing so the user still gets an insight; the - // annotate event surfaces the regression to ops via the Logflare - // pipeline. - let features: Awaited>; - let payloadDowngraded = false; - let payloadOversizeBytes: number | null = null; - // v1.4.36 QA H1 — extra fallback when even the aggregated shape - // crosses the 5 MB ceiling (very large medication history + multi- - // year context). Strips anthropometrics + medications + every other - // exclude-token-mapped block and tries one more time. On a third - // failure we return 422 with an annotate event for ops visibility - // rather than 500-ing out of an uncaught throw. - const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ - "anthropometrics", - "medications", - "compliance", - "sleep", - "steps", - "hrv", - "resting_hr", - ]; - let payloadHardDowngraded = false; - // v1.18.11 P1 — bound the briefing's bulk feature read to a recent window - // (all-time extremes are sourced separately, see features.ts). The downgrade - // ladder below stays as the safety net for the rare oversize payload. - const featureWindow = { sinceDays: BRIEFING_FEATURE_WINDOW_DAYS }; - try { - features = await extractFeatures(userId, includeRaw, featureWindow); - } catch (err) { - if (err instanceof FeaturesPayloadTooLargeError) { - payloadDowngraded = true; - payloadOversizeBytes = err.sizeBytes; + if ( + !forceRefresh && + dbUser?.insightsCachedAt && + dbUser.insightsCachedText && + Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000 + ) { try { - features = await extractFeatures(userId, false, featureWindow); - } catch (retryErr) { - if (retryErr instanceof FeaturesPayloadTooLargeError) { - // Aggregated shape ALSO crossed the ceiling. Drop optional - // context blocks (anthropometrics, medications, sleep, - // steps, hrv, resting_hr) via the existing exclude filter - // and try once more. - payloadHardDowngraded = true; - payloadOversizeBytes = retryErr.sizeBytes; - try { - const aggregated = await extractFeatures( - userId, - false, - featureWindow, - ); - features = applyInsightsExcludeFilter( - aggregated, - MAX_DOWNGRADE_TOKENS, - ); - } catch (finalErr) { - // Even the aggregated read itself blew up. Annotate and - // return 422 rather than let the uncaught throw 500 out. - annotate({ - meta: { - insights_payload_too_large: true, - insights_payload_oversize_bytes: - finalErr instanceof FeaturesPayloadTooLargeError - ? finalErr.sizeBytes - : payloadOversizeBytes, - }, - }); - return apiError( - "Your data exceeds the AI payload size limit. Reduce the data window or exclude optional blocks in Settings > AI.", - 422, - ); - } - } else { - throw retryErr; - } + const cached = JSON.parse(dbUser.insightsCachedText); + // v1.4.16 B5c: detect legacy payloads (pre-rationale) so the UI + // can show a "regenerate for new explainability features" CTA. + // We don't auto-regenerate — that would burn rate-limit tokens + // on a cache-hit silently. User-initiated only. + const legacyPayload = isLegacyInsightPayload(cached); + annotate({ + action: { name: "insights.generate" }, + meta: { cached: true, legacyPayload }, + }); + return apiSuccess({ + insights: cached, + cached: true, + cachedAt: dbUser.insightsCachedAt, + legacyPayload, + }); + } catch { + // Invalid cache, regenerate } - } else { - throw err; } - } - // v1.4.36 W3 T3 — apply the user's exclude-metrics opt-out list - // BEFORE serialisation so the model never sees the dropped blocks. - const excludeList = dbUser?.insightsExcludeMetrics ?? []; - features = applyInsightsExcludeFilter(features, excludeList); - // v1.4.36 W3 T4 — drop zero-row blocks so the prompt never carries - // labelled-empty sections (`"sleep": []`, `"medications": []`, - // etc.). Prevents the model from narrating "there are no - // medications in your data" when the user explicitly excluded the - // block. - const compactFeatures = compactSections( - features as unknown as Record, - ); - const featuresJson = JSON.stringify(compactFeatures, null, 2); - if (payloadDowngraded) { - annotate({ - meta: { - insights_features_downgraded: true, - insights_features_oversize_bytes: payloadOversizeBytes, - insights_features_hard_downgraded: payloadHardDowngraded, - }, - }); - } - // v1.4.16 phase B8 — pick up the user's persisted comparison toggle - // and, when active, build a compact prior-period snapshot from the - // `features` summaries that already include `avg30LastMonth` / - // `avg30LastYear` (added in the analytics summarize() helper). The - // narrative is default-on whenever the toggle is on per research §7 - // Q4 — the pulldown is the single affordance. - const comparisonSnapshot = await buildComparisonSnapshotForUser(userId); - // v1.4.25 W4d — GLP-1 plateau detector. Appends a SYSTEM CONTEXT - // block to the user prompt when the active GLP-1 medication has been - // on a stable dose for ≥21 days with weight delta within ±0.5 kg. - // The block instructs the model to emit a glp1_plateau keyFinding - // framed observationally (no dose recommendation per GROUND RULE 13). - // Returns null when no GLP-1 is active OR no plateau condition; - // 99% of users land in the null branch and pay zero token cost. - const plateauContext = await detectGlp1Plateau(userId); - let userPrompt = buildUserPrompt( - featuresJson, - dbUser?.insightsPrivacyMode ?? "aggregated", - locale, - comparisonSnapshot ?? undefined, - ); - if (plateauContext) { - userPrompt += buildGlp1PlateauPrompt(plateauContext, locale); - } - // v1.15.20 — fold the user-authored "about me" self-description - // (Settings → AI) into the briefing as a delimited, user-provided - // SYSTEM CONTEXT block. Null (no text / undecryptable) costs nothing. - const aboutMe = await getSelfContextTextForUser(userId, locale); - if (aboutMe) { - userPrompt += buildAboutMeInsightBlock(aboutMe, locale); - } - // v1.10.0 — fold a notable derived wellness signal (readiness / recovery - // shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block. - // Reads the one derived contract; null (the common case) costs nothing. - const derivedSex = - dbUser?.gender === "MALE" || dbUser?.gender === "FEMALE" - ? (dbUser.gender as "MALE" | "FEMALE") - : null; - const derivedBriefing = await detectDerivedBriefingSignals(userId, { - ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null), - sex: derivedSex, - heightCm: dbUser?.heightCm ?? null, - }); - if (derivedBriefing) { - userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale); - } + // P13: rate-limit check moved BELOW the cache return so a hit on the + // 24h cache never burns one of the LLM-generation tokens. Otherwise a + // noisy reload loop on the dashboard would lock out real refreshes + // for an hour for no benefit. + // + // v1.4.16 A7: limit raised from 2 → 10 per hour (env-configurable via + // `INSIGHTS_RATE_LIMIT_PER_HOUR`). The 2/hour limit triggered too + // aggressively when a user iterated on settings or regenerated after + // adding a few measurements; 10/hour still cleanly bounds cost while + // staying out of the way for legitimate use. + const limit = resolveInsightsRateLimit(); + const rl = await checkRateLimit( + `insights:${userId}`, + limit, + 60 * 60 * 1000, + ); + if (!rl.allowed) { + return apiError(`Maximum ${limit} insight generations per hour.`, 429); + } - // v1.18.11 P5 — fold illness-episode + cycle state into the briefing so a - // mid-illness or mid-cycle user gets context-aware prose. Reuses the same - // server-authoritative builders the Coach assembles; both are module-gated - // and short-circuit to null (zero token cost) for users without them. - const illnessCycleCtx = await buildBriefingIllnessCycleContext( - userId, - dbUser?.gender ?? null, - await resolveUserTimezone(userId), - ); - if (illnessCycleCtx) { - userPrompt += buildBriefingIllnessCyclePrompt(illnessCycleCtx, locale); - } - - // v1.12.7 (B5) — inject the curated SOURCES block for the metric sections - // this briefing actually carries, so a normative claim can cite a real - // `referenceId` the schema + UI footnote already support. Returns the plain - // prompt unchanged when no applicable metric section is present. - const referenceMetrics = metricsFromPresentSections({ - bloodPressure: features.bloodPressure != null, - weight: features.weight != null, - pulse: features.pulse != null, - mood: features.mood != null, - medication: (features.medications?.length ?? 0) > 0, - }); - - let result; - let workingProviderType: string; - let fallbackHopCount = 0; - try { - const fallback = await runRawCompletionWithFallback({ - userId, - providers: chain, - params: { - // The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8 - // — emit a top-level `dailyBriefing` block when the snapshot has - // analysable signal — plus the trendAnnotations and - // storyboardAnnotations rules. The legacy `getInsightsSystemPrompt` - // returned the v1.4.5 `{changed, stable, drivers, …}` shape and - // never asked the model for dailyBriefing, so the hero strip's - // "Re-run analysis" button kept producing a payload with no - // briefing block (Issue 1, v1.4.20 post-deploy). The route already - // tolerates the strict shape via `insightResultSchema.safeParse`'s - // soft fallback to `parsed` and `passthrough()` on the strict - // schema, so switching the prompt does not break legacy callers. - systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics), - userPrompt, - temperature: 0.3, - maxTokens: 1500, - }, - }); - result = fallback.result; - workingProviderType = fallback.workingProvider.providerType; - fallbackHopCount = fallback.fallbackHops.length; - } catch (e) { - if (e instanceof AllProvidersFailedError) { - annotate({ - meta: { - insights_chain_outcome: "all-failed", - insights_chain_attempts: e.attempts.length, - insights_chain_first_provider: e.attempts[0]?.providerType ?? null, - insights_chain_first_status: e.attempts[0]?.httpStatus ?? null, - }, - }); - // Pick the most specific user-facing message we can. Auth-class - // failures across the entire chain mean every provider's - // credential is bad — keep the v1.4.5 "check your API key" - // wording so the user knows where to act. 429 across the chain - // is rate-limit; 5xx is upstream brown-out; transport-only - // (ECONNRESET, no httpStatus) maps to the generic connection - // hint so users on flaky networks don't see "rate-limited". - const allAuth = e.attempts.every( - (a) => a.httpStatus === 401 || a.httpStatus === 403, - ); - if (allAuth) { + // v1.4.16 phase B5b: resolve the user's full provider chain so a hard + // failure on the primary (401, 5xx, network) cascades to the next + // configured fallback rather than surfacing a 503/422 to the + // dashboard. Falls back to the single-provider resolution when the + // chain comes back empty AND the legacy `aiProvider` field still + // points at something usable — preserving v1.4.15 behaviour for + // accounts that haven't customised the chain. + const chain = await resolveProviderChain(userId); + if (chain.length === 0) { + // Try the legacy single-provider path one more time so users who + // configured AI before v1.4.16 don't suddenly see the 422. + const legacy = await resolveProvider(userId); + if (legacy.type === "none") { return apiError( - "AI provider rejected the request — check your API key in Settings > AI", + "No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.", 422, ); } - const all429 = e.attempts.every((a) => a.httpStatus === 429); - if (all429) { - return apiError("AI provider rate-limited the request", 429); + chain.push({ providerType: "admin-openai", instance: legacy }); + } + + // v1.12.1 — consent gate before any server-managed external egress. + // Required for the operator's global key (`admin-openai`); BYOK / local / + // ChatGPT-OAuth chains stay ungated. Throws ConsentRequiredError → + // apiHandler returns 403 + `consent.ai.required`. + await assertConsentForChain({ userId, chain, surface: "insights" }); + + const includeRaw = dbUser?.insightsPrivacyMode === "raw"; + // v1.4.36 W3 T1 — `extractFeatures` enforces a 5 MB ceiling on the + // serialised payload. If the raw (bucketed) shape ever blows past + // it (regression watch — the v1.4.35 rawMeasurements shape hit + // 25.9 MB on the largest tenant), the helper throws + // `FeaturesPayloadTooLargeError`. We downgrade to the aggregated + // shape rather than 500-ing so the user still gets an insight; the + // annotate event surfaces the regression to ops via the Logflare + // pipeline. + let features: Awaited>; + let payloadDowngraded = false; + let payloadOversizeBytes: number | null = null; + // v1.4.36 QA H1 — extra fallback when even the aggregated shape + // crosses the 5 MB ceiling (very large medication history + multi- + // year context). Strips anthropometrics + medications + every other + // exclude-token-mapped block and tries one more time. On a third + // failure we return 422 with an annotate event for ops visibility + // rather than 500-ing out of an uncaught throw. + const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ + "anthropometrics", + "medications", + "compliance", + "sleep", + "steps", + "hrv", + "resting_hr", + ]; + let payloadHardDowngraded = false; + // v1.18.11 P1 — bound the briefing's bulk feature read to a recent window + // (all-time extremes are sourced separately, see features.ts). The downgrade + // ladder below stays as the safety net for the rare oversize payload. + const featureWindow = { sinceDays: BRIEFING_FEATURE_WINDOW_DAYS }; + try { + // v1.18.11 P3 — compute-once-per-request: the scope opened around this + // handler shares the bounded feature read across the downgrade ladder. + features = await getCachedFeatures({ + userId, + includeRaw, + sinceDays: featureWindow.sinceDays, + compute: () => extractFeatures(userId, includeRaw, featureWindow), + }); + } catch (err) { + if (err instanceof FeaturesPayloadTooLargeError) { + payloadDowngraded = true; + payloadOversizeBytes = err.sizeBytes; + try { + features = await getCachedFeatures({ + userId, + includeRaw: false, + sinceDays: featureWindow.sinceDays, + compute: () => extractFeatures(userId, false, featureWindow), + }); + } catch (retryErr) { + if (retryErr instanceof FeaturesPayloadTooLargeError) { + // Aggregated shape ALSO crossed the ceiling. Drop optional + // context blocks (anthropometrics, medications, sleep, + // steps, hrv, resting_hr) via the existing exclude filter + // and try once more. + payloadHardDowngraded = true; + payloadOversizeBytes = retryErr.sizeBytes; + try { + const aggregated = await getCachedFeatures({ + userId, + includeRaw: false, + sinceDays: featureWindow.sinceDays, + compute: () => extractFeatures(userId, false, featureWindow), + }); + features = applyInsightsExcludeFilter( + aggregated, + MAX_DOWNGRADE_TOKENS, + ); + } catch (finalErr) { + // Even the aggregated read itself blew up. Annotate and + // return 422 rather than let the uncaught throw 500 out. + annotate({ + meta: { + insights_payload_too_large: true, + insights_payload_oversize_bytes: + finalErr instanceof FeaturesPayloadTooLargeError + ? finalErr.sizeBytes + : payloadOversizeBytes, + }, + }); + return apiError( + "Your data exceeds the AI payload size limit. Reduce the data window or exclude optional blocks in Settings > AI.", + 422, + ); + } + } else { + throw retryErr; + } + } + } else { + throw err; } - const has5xx = e.attempts.some( - (a) => a.httpStatus !== null && a.httpStatus >= 500, - ); - if (has5xx) { + } + // v1.4.36 W3 T3 — apply the user's exclude-metrics opt-out list + // BEFORE serialisation so the model never sees the dropped blocks. + const excludeList = dbUser?.insightsExcludeMetrics ?? []; + features = applyInsightsExcludeFilter(features, excludeList); + // v1.4.36 W3 T4 — drop zero-row blocks so the prompt never carries + // labelled-empty sections (`"sleep": []`, `"medications": []`, + // etc.). Prevents the model from narrating "there are no + // medications in your data" when the user explicitly excluded the + // block. + const compactFeatures = compactSections( + features as unknown as Record, + ); + const featuresJson = JSON.stringify(compactFeatures, null, 2); + if (payloadDowngraded) { + annotate({ + meta: { + insights_features_downgraded: true, + insights_features_oversize_bytes: payloadOversizeBytes, + insights_features_hard_downgraded: payloadHardDowngraded, + }, + }); + } + + // v1.4.16 phase B8 — pick up the user's persisted comparison toggle + // and, when active, build a compact prior-period snapshot from the + // `features` summaries that already include `avg30LastMonth` / + // `avg30LastYear` (added in the analytics summarize() helper). The + // narrative is default-on whenever the toggle is on per research §7 + // Q4 — the pulldown is the single affordance. + const comparisonSnapshot = await buildComparisonSnapshotForUser(userId); + // v1.4.25 W4d — GLP-1 plateau detector. Appends a SYSTEM CONTEXT + // block to the user prompt when the active GLP-1 medication has been + // on a stable dose for ≥21 days with weight delta within ±0.5 kg. + // The block instructs the model to emit a glp1_plateau keyFinding + // framed observationally (no dose recommendation per GROUND RULE 13). + // Returns null when no GLP-1 is active OR no plateau condition; + // 99% of users land in the null branch and pay zero token cost. + const plateauContext = await detectGlp1Plateau(userId); + let userPrompt = buildUserPrompt( + featuresJson, + dbUser?.insightsPrivacyMode ?? "aggregated", + locale, + comparisonSnapshot ?? undefined, + ); + if (plateauContext) { + userPrompt += buildGlp1PlateauPrompt(plateauContext, locale); + } + // v1.15.20 — fold the user-authored "about me" self-description + // (Settings → AI) into the briefing as a delimited, user-provided + // SYSTEM CONTEXT block. Null (no text / undecryptable) costs nothing. + const aboutMe = await getSelfContextTextForUser(userId, locale); + if (aboutMe) { + userPrompt += buildAboutMeInsightBlock(aboutMe, locale); + } + // v1.10.0 — fold a notable derived wellness signal (readiness / recovery + // shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block. + // Reads the one derived contract; null (the common case) costs nothing. + const derivedSex = + dbUser?.gender === "MALE" || dbUser?.gender === "FEMALE" + ? (dbUser.gender as "MALE" | "FEMALE") + : null; + const derivedBriefing = await detectDerivedBriefingSignals(userId, { + ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null), + sex: derivedSex, + heightCm: dbUser?.heightCm ?? null, + }); + if (derivedBriefing) { + userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale); + } + + // v1.18.11 P5 — fold illness-episode + cycle state into the briefing so a + // mid-illness or mid-cycle user gets context-aware prose. Reuses the same + // server-authoritative builders the Coach assembles; both are module-gated + // and short-circuit to null (zero token cost) for users without them. + const illnessCycleCtx = await buildBriefingIllnessCycleContext( + userId, + dbUser?.gender ?? null, + await resolveUserTimezone(userId), + ); + if (illnessCycleCtx) { + userPrompt += buildBriefingIllnessCyclePrompt(illnessCycleCtx, locale); + } + + // v1.12.7 (B5) — inject the curated SOURCES block for the metric sections + // this briefing actually carries, so a normative claim can cite a real + // `referenceId` the schema + UI footnote already support. Returns the plain + // prompt unchanged when no applicable metric section is present. + const referenceMetrics = metricsFromPresentSections({ + bloodPressure: features.bloodPressure != null, + weight: features.weight != null, + pulse: features.pulse != null, + mood: features.mood != null, + medication: (features.medications?.length ?? 0) > 0, + }); + + let result; + let workingProviderType: string; + let fallbackHopCount = 0; + try { + const fallback = await runRawCompletionWithFallback({ + userId, + providers: chain, + params: { + // The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8 + // — emit a top-level `dailyBriefing` block when the snapshot has + // analysable signal — plus the trendAnnotations and + // storyboardAnnotations rules. The legacy `getInsightsSystemPrompt` + // returned the v1.4.5 `{changed, stable, drivers, …}` shape and + // never asked the model for dailyBriefing, so the hero strip's + // "Re-run analysis" button kept producing a payload with no + // briefing block (Issue 1, v1.4.20 post-deploy). The route already + // tolerates the strict shape via `insightResultSchema.safeParse`'s + // soft fallback to `parsed` and `passthrough()` on the strict + // schema, so switching the prompt does not break legacy callers. + systemPrompt: buildSystemPromptWithReferences( + locale, + referenceMetrics, + ), + userPrompt, + temperature: 0.3, + maxTokens: 1500, + }, + }); + result = fallback.result; + workingProviderType = fallback.workingProvider.providerType; + fallbackHopCount = fallback.fallbackHops.length; + } catch (e) { + if (e instanceof AllProvidersFailedError) { + annotate({ + meta: { + insights_chain_outcome: "all-failed", + insights_chain_attempts: e.attempts.length, + insights_chain_first_provider: e.attempts[0]?.providerType ?? null, + insights_chain_first_status: e.attempts[0]?.httpStatus ?? null, + }, + }); + // Pick the most specific user-facing message we can. Auth-class + // failures across the entire chain mean every provider's + // credential is bad — keep the v1.4.5 "check your API key" + // wording so the user knows where to act. 429 across the chain + // is rate-limit; 5xx is upstream brown-out; transport-only + // (ECONNRESET, no httpStatus) maps to the generic connection + // hint so users on flaky networks don't see "rate-limited". + const allAuth = e.attempts.every( + (a) => a.httpStatus === 401 || a.httpStatus === 403, + ); + if (allAuth) { + return apiError( + "AI provider rejected the request — check your API key in Settings > AI", + 422, + ); + } + const all429 = e.attempts.every((a) => a.httpStatus === 429); + if (all429) { + return apiError("AI provider rate-limited the request", 429); + } + const has5xx = e.attempts.some( + (a) => a.httpStatus !== null && a.httpStatus >= 500, + ); + if (has5xx) { + return apiError( + "AI provider temporarily unavailable, try again in a moment", + 503, + ); + } + const allTransport = e.attempts.every( + (a) => a.httpStatus === null || a.httpStatus <= 0, + ); + if (allTransport) { + return apiError( + "AI provider connection failed — check your AI settings", + 422, + ); + } return apiError( "AI provider temporarily unavailable, try again in a moment", 503, ); } - const allTransport = e.attempts.every( - (a) => a.httpStatus === null || a.httpStatus <= 0, - ); - if (allTransport) { + // v1.4.6 T5 mapped only the parse-error branch from 502→422. Non- + // hard provider errors (e.g. a custom 4xx from a self-hosted local + // model) still propagate here. Cloudflare rewrites 5xx to its own + // HTML error page, which breaks `await res.json()` on the + // dashboard. Mirror the v1.4.5 ai/test categorisation. + const err = e as Error & { httpStatus?: number; bodyExcerpt?: string }; + annotate({ + meta: { + insights_provider_error: err.message.slice(0, 500), + insights_provider_status: err.httpStatus ?? null, + insights_provider_body_excerpt: + err.bodyExcerpt?.slice(0, 500) ?? null, + }, + }); + const status = err.httpStatus ?? 0; + if (status === 401 || status === 403) { return apiError( - "AI provider connection failed — check your AI settings", + "AI provider rejected the request — check your API key in Settings > AI", 422, ); } + if (status === 429) { + return apiError("AI provider rate-limited the request", 429); + } + if (status >= 500) { + return apiError( + "AI provider temporarily unavailable, try again in a moment", + 503, + ); + } return apiError( - "AI provider temporarily unavailable, try again in a moment", - 503, - ); - } - // v1.4.6 T5 mapped only the parse-error branch from 502→422. Non- - // hard provider errors (e.g. a custom 4xx from a self-hosted local - // model) still propagate here. Cloudflare rewrites 5xx to its own - // HTML error page, which breaks `await res.json()` on the - // dashboard. Mirror the v1.4.5 ai/test categorisation. - const err = e as Error & { httpStatus?: number; bodyExcerpt?: string }; - annotate({ - meta: { - insights_provider_error: err.message.slice(0, 500), - insights_provider_status: err.httpStatus ?? null, - insights_provider_body_excerpt: err.bodyExcerpt?.slice(0, 500) ?? null, - }, - }); - const status = err.httpStatus ?? 0; - if (status === 401 || status === 403) { - return apiError( - "AI provider rejected the request — check your API key in Settings > AI", + "AI provider connection failed — check your AI settings", 422, ); } - if (status === 429) { - return apiError("AI provider rate-limited the request", 429); + + let insights: InsightResult | Record; + try { + const parsed = JSON.parse(result.content); + // Try new schema first, fall back to raw parsed if validation fails + const validated = insightResultSchema.safeParse(parsed); + insights = validated.success ? validated.data : parsed; + } catch { + // Returning 502 here triggers Cloudflare's HTML error rewrite, which + // breaks `await res.json()` on the client side. 422 stays passthrough + // so the React Query mutation can read the JSON body and surface a + // readable message. Same fix pattern as v1.4.5 ai/test. + return apiError("AI response was not valid JSON", 422); } - if (status >= 500) { - return apiError( - "AI provider temporarily unavailable, try again in a moment", - 503, + + // v1.18.10 (HIGH-1) — daily-briefing number-grounding gate, mirroring the + // recommendations citation cross-check next door. The schema only checks the + // briefing's SHAPE; this asserts every number the briefing prose restates + // traces to a `features.signalsOfDay` figure. On a miss: one corrective + // retry, then drop the briefing block rather than ship a fabricated number. + { + const signals = features.signalsOfDay ?? null; + let ungrounded = findUngroundedBriefingNumbers( + readBriefingBlock(insights), + signals, ); - } - return apiError( - "AI provider connection failed — check your AI settings", - 422, - ); - } - - let insights: InsightResult | Record; - try { - const parsed = JSON.parse(result.content); - // Try new schema first, fall back to raw parsed if validation fails - const validated = insightResultSchema.safeParse(parsed); - insights = validated.success ? validated.data : parsed; - } catch { - // Returning 502 here triggers Cloudflare's HTML error rewrite, which - // breaks `await res.json()` on the client side. 422 stays passthrough - // so the React Query mutation can read the JSON body and surface a - // readable message. Same fix pattern as v1.4.5 ai/test. - return apiError("AI response was not valid JSON", 422); - } - - // v1.18.10 (HIGH-1) — daily-briefing number-grounding gate, mirroring the - // recommendations citation cross-check next door. The schema only checks the - // briefing's SHAPE; this asserts every number the briefing prose restates - // traces to a `features.signalsOfDay` figure. On a miss: one corrective - // retry, then drop the briefing block rather than ship a fabricated number. - { - const signals = features.signalsOfDay ?? null; - let ungrounded = findUngroundedBriefingNumbers( - readBriefingBlock(insights), - signals, - ); - if (ungrounded.length > 0) { - annotate({ - action: { name: "insights.generate.briefing_grounding_retry" }, - meta: { ungroundedCount: ungrounded.length }, - }); - try { - const retry = await runRawCompletionWithFallback({ - userId, - providers: chain, - params: { - systemPrompt: buildSystemPromptWithReferences( - locale, - referenceMetrics, - ), - userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, - temperature: 0.3, - maxTokens: 1500, - }, + if (ungrounded.length > 0) { + annotate({ + action: { name: "insights.generate.briefing_grounding_retry" }, + meta: { ungroundedCount: ungrounded.length }, }); - const parsedRetry = JSON.parse(retry.result.content); - const validatedRetry = insightResultSchema.safeParse(parsedRetry); - const retryInsights = validatedRetry.success - ? validatedRetry.data - : parsedRetry; - ungrounded = findUngroundedBriefingNumbers( - readBriefingBlock(retryInsights), - signals, - ); - if (ungrounded.length === 0) { - insights = retryInsights; - result = retry.result; - workingProviderType = retry.workingProvider.providerType; + try { + const retry = await runRawCompletionWithFallback({ + userId, + providers: chain, + params: { + systemPrompt: buildSystemPromptWithReferences( + locale, + referenceMetrics, + ), + userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, + temperature: 0.3, + maxTokens: 1500, + }, + }); + const parsedRetry = JSON.parse(retry.result.content); + const validatedRetry = insightResultSchema.safeParse(parsedRetry); + const retryInsights = validatedRetry.success + ? validatedRetry.data + : parsedRetry; + ungrounded = findUngroundedBriefingNumbers( + readBriefingBlock(retryInsights), + signals, + ); + if (ungrounded.length === 0) { + insights = retryInsights; + result = retry.result; + workingProviderType = retry.workingProvider.providerType; + } + } catch { + // Retry failed to produce parseable JSON — fall through to the strip. } - } catch { - // Retry failed to produce parseable JSON — fall through to the strip. + } + // Still ungrounded after the retry: strip the briefing rather than ship a + // fabricated figure. The structured recommendations/citations stay. + if (ungrounded.length > 0 && insights && typeof insights === "object") { + (insights as Record).dailyBriefing = null; + annotate({ + action: { name: "insights.generate.briefing_grounding_stripped" }, + meta: { ungroundedCount: ungrounded.length }, + }); } } - // Still ungrounded after the retry: strip the briefing rather than ship a - // fabricated figure. The structured recommendations/citations stay. - if (ungrounded.length > 0 && insights && typeof insights === "object") { - (insights as Record).dailyBriefing = null; - annotate({ - action: { name: "insights.generate.briefing_grounding_stripped" }, - meta: { ungroundedCount: ungrounded.length }, - }); - } - } - await prisma.user.update({ - where: { id: userId }, - data: { - insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(insights), - // v1.16.8 — store the snapshot fingerprint so the nightly / forced - // regeneration paths can detect "nothing changed" and skip the - // provider call. The user-initiated POST itself stays un-gated: an - // explicit regenerate request is honoured even on unchanged data - // (it is already bounded by the hourly rate limit above). The - // composite shape MUST match the gate in `comprehensive-generate.ts`, - // or every off-request warm after a manual regenerate re-pays a - // full generation on unchanged data. Beyond the features, three - // prompt inputs that can change with NO data change join in: - // - aboutMe (Coach remember / Settings → AI edit), - // - comparisonBaseline (the comparison toggle adds/removes the - // prior-period context block; `null` snapshot means "none"), - // - generationLocale (the language the cached text renders in — - // hashing it makes a locale switch regenerate exactly once, so - // the briefing follows the reader; named `generationLocale` - // because the canonicaliser strips `locale` keys as volatile). - insightsSnapshotHash: hashInsightSnapshot({ - features: compactFeatures, - aboutMe: aboutMe ?? null, - comparisonBaseline: comparisonSnapshot?.baseline ?? "none", - generationLocale: locale, - }), - }, - }); + await prisma.user.update({ + where: { id: userId }, + data: { + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify(insights), + // v1.16.8 — store the snapshot fingerprint so the nightly / forced + // regeneration paths can detect "nothing changed" and skip the + // provider call. The user-initiated POST itself stays un-gated: an + // explicit regenerate request is honoured even on unchanged data + // (it is already bounded by the hourly rate limit above). The + // composite shape MUST match the gate in `comprehensive-generate.ts`, + // or every off-request warm after a manual regenerate re-pays a + // full generation on unchanged data. Beyond the features, three + // prompt inputs that can change with NO data change join in: + // - aboutMe (Coach remember / Settings → AI edit), + // - comparisonBaseline (the comparison toggle adds/removes the + // prior-period context block; `null` snapshot means "none"), + // - generationLocale (the language the cached text renders in — + // hashing it makes a locale switch regenerate exactly once, so + // the briefing follows the reader; named `generationLocale` + // because the canonicaliser strips `locale` keys as volatile). + insightsSnapshotHash: hashInsightSnapshot({ + features: compactFeatures, + aboutMe: aboutMe ?? null, + comparisonBaseline: comparisonSnapshot?.baseline ?? "none", + generationLocale: locale, + }), + }, + }); - // v1.16.8 — no blanket per-status eviction here any more (nuking ~45 - // cache rows per manual regenerate deleted every hash baseline and was - // the source of the post-regenerate card-regeneration storm). Instead, - // the manual regenerate takes the cards along through the hash-gated - // queue: every scope the user has data for is enqueued, the worker - // forces each generator past its same-day cache read, and the content- - // hash gate regenerates exactly the cards whose data changed while - // refreshing the unchanged ones for free. This is the user's escape - // hatch for a card they can SEE is stale — without it the cards had no - // refresh path until the next nightly warm. - const refillScopes = await enqueueStatusRefillForUser( - userId, - locale === "de" ? "de" : "en", - ); - annotate({ - action: { name: "insights.generate.cards_refill" }, - meta: { scopes: refillScopes }, - }); + // v1.16.8 — no blanket per-status eviction here any more (nuking ~45 + // cache rows per manual regenerate deleted every hash baseline and was + // the source of the post-regenerate card-regeneration storm). Instead, + // the manual regenerate takes the cards along through the hash-gated + // queue: every scope the user has data for is enqueued, the worker + // forces each generator past its same-day cache read, and the content- + // hash gate regenerates exactly the cards whose data changed while + // refreshing the unchanged ones for free. This is the user's escape + // hatch for a card they can SEE is stale — without it the cards had no + // refresh path until the next nightly warm. + const refillScopes = await enqueueStatusRefillForUser( + userId, + locale === "de" ? "de" : "en", + ); + annotate({ + action: { name: "insights.generate.cards_refill" }, + meta: { scopes: refillScopes }, + }); - // v1.7.0 W6 — the dashboard snapshot embeds the pre-generated daily - // briefing read-only; drop it so the next snapshot carries the fresh - // briefing instead of the stale one. - invalidateUserInsights(userId); + // v1.7.0 W6 — the dashboard snapshot embeds the pre-generated daily + // briefing read-only; drop it so the next snapshot carries the fresh + // briefing instead of the stale one. + invalidateUserInsights(userId); - await auditLog("insights.generate", { - userId, - ipAddress: getClientIp(request), - details: { - privacyMode: dbUser?.insightsPrivacyMode, - tokensUsed: result.tokensUsed, - providerType: result.providerType, - chainProviderType: workingProviderType, - fallbackHopCount, - model: result.model, - }, - }); + await auditLog("insights.generate", { + userId, + ipAddress: getClientIp(request), + details: { + privacyMode: dbUser?.insightsPrivacyMode, + tokensUsed: result.tokensUsed, + providerType: result.providerType, + chainProviderType: workingProviderType, + fallbackHopCount, + model: result.model, + }, + }); - annotate({ - action: { name: "insights.generate" }, - meta: { - cached: false, - providerType: result.providerType, - chainProviderType: workingProviderType, - fallbackHopCount, - model: result.model, - tokensUsed: result.tokensUsed, - }, - }); + annotate({ + action: { name: "insights.generate" }, + meta: { + cached: false, + providerType: result.providerType, + chainProviderType: workingProviderType, + fallbackHopCount, + model: result.model, + tokensUsed: result.tokensUsed, + }, + }); - return apiSuccess({ insights, cached: false, legacyPayload: false }); -}); + return apiSuccess({ insights, cached: false, legacyPayload: false }); + }), +); diff --git a/src/lib/insights/__tests__/feature-cache.test.ts b/src/lib/insights/__tests__/feature-cache.test.ts new file mode 100644 index 000000000..447496a02 --- /dev/null +++ b/src/lib/insights/__tests__/feature-cache.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { groupBy: vi.fn() }, + }, +})); + +// Berlin day key is deterministic input to the cache key; pin it. +vi.mock("@/lib/tz/resolver", () => ({ + toBerlinDayKey: () => "2026-06-21", +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { + getCachedFeatures, + withFeatureCacheScope, +} from "../feature-cache"; + +const T0 = new Date("2026-06-20T08:00:00.000Z"); + +function fingerprintRows(weightCount: number) { + return [ + { type: "WEIGHT", _count: { _all: weightCount }, _max: { measuredAt: T0 } }, + ]; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue( + fingerprintRows(10) as never, + ); +}); + +describe("getCachedFeatures (P3)", () => { + it("computes once and reuses within a scope for identical key + inputs", async () => { + const compute = vi.fn().mockResolvedValue({ marker: "features-v1" }); + + const { first, second } = await withFeatureCacheScope(async () => { + const first = await getCachedFeatures({ + userId: "u1", + includeRaw: false, + sinceDays: 400, + compute, + }); + const second = await getCachedFeatures({ + userId: "u1", + includeRaw: false, + sinceDays: 400, + compute, + }); + return { first, second }; + }); + + expect(compute).toHaveBeenCalledTimes(1); + // The same object reference is handed back — a true reuse, not a re-run. + expect(second).toBe(first); + expect(first).toEqual({ marker: "features-v1" }); + }); + + it("recomputes when the salient input fingerprint changes mid-scope", async () => { + const compute = vi + .fn() + .mockResolvedValueOnce({ marker: "before" }) + .mockResolvedValueOnce({ marker: "after" }); + + const out = await withFeatureCacheScope(async () => { + const before = await getCachedFeatures({ + userId: "u1", + includeRaw: false, + sinceDays: 400, + compute, + }); + // A new reading lands mid-scope — the fingerprint probe now sees a + // higher count, so the gate misses and the read is recomputed. + vi.mocked(prisma.measurement.groupBy).mockResolvedValue( + fingerprintRows(11) as never, + ); + const after = await getCachedFeatures({ + userId: "u1", + includeRaw: false, + sinceDays: 400, + compute, + }); + return { before, after }; + }); + + expect(compute).toHaveBeenCalledTimes(2); + expect(out.before).toEqual({ marker: "before" }); + expect(out.after).toEqual({ marker: "after" }); + }); + + it("keys separately on includeRaw and sinceDays", async () => { + const compute = vi + .fn() + .mockResolvedValueOnce({ k: "agg-400" }) + .mockResolvedValueOnce({ k: "raw-400" }) + .mockResolvedValueOnce({ k: "agg-90" }); + + await withFeatureCacheScope(async () => { + await getCachedFeatures({ + userId: "u1", + includeRaw: false, + sinceDays: 400, + compute, + }); + await getCachedFeatures({ + userId: "u1", + includeRaw: true, + sinceDays: 400, + compute, + }); + await getCachedFeatures({ + userId: "u1", + includeRaw: false, + sinceDays: 90, + compute, + }); + }); + + // Three distinct keys → three computes (no cross-key collision). + expect(compute).toHaveBeenCalledTimes(3); + }); + + it("keys separately per user (no cross-user leak)", async () => { + const compute = vi + .fn() + .mockResolvedValueOnce({ u: "u1" }) + .mockResolvedValueOnce({ u: "u2" }); + + const out = await withFeatureCacheScope(async () => { + const a = await getCachedFeatures({ + userId: "u1", + includeRaw: false, + sinceDays: 400, + compute, + }); + const b = await getCachedFeatures({ + userId: "u2", + includeRaw: false, + sinceDays: 400, + compute, + }); + return { a, b }; + }); + + expect(compute).toHaveBeenCalledTimes(2); + expect(out.a).toEqual({ u: "u1" }); + expect(out.b).toEqual({ u: "u2" }); + }); + + it("passes straight through (no caching, no probe) outside any scope", async () => { + const compute = vi.fn().mockResolvedValue({ marker: "x" }); + + const a = await getCachedFeatures({ + userId: "u1", + includeRaw: false, + sinceDays: 400, + compute, + }); + const b = await getCachedFeatures({ + userId: "u1", + includeRaw: false, + sinceDays: 400, + compute, + }); + + // No scope → no memoisation → each call computes; and the fingerprint + // probe is never even issued (it only runs inside a scope). + expect(compute).toHaveBeenCalledTimes(2); + expect(prisma.measurement.groupBy).not.toHaveBeenCalled(); + expect(a).toEqual({ marker: "x" }); + expect(b).toEqual({ marker: "x" }); + }); + + it("does not share a cache across sibling scopes", async () => { + const compute = vi.fn().mockResolvedValue({ marker: "y" }); + + await withFeatureCacheScope(() => + getCachedFeatures({ + userId: "u1", + includeRaw: false, + sinceDays: 400, + compute, + }), + ); + await withFeatureCacheScope(() => + getCachedFeatures({ + userId: "u1", + includeRaw: false, + sinceDays: 400, + compute, + }), + ); + + // Each scope has its own store, so the second scope recomputes. + expect(compute).toHaveBeenCalledTimes(2); + }); + + it("recomputes on a fingerprint probe failure rather than serving a stale object", async () => { + const compute = vi + .fn() + .mockResolvedValueOnce({ marker: "1" }) + .mockResolvedValueOnce({ marker: "2" }); + vi.mocked(prisma.measurement.groupBy).mockRejectedValue( + new Error("db down"), + ); + + const out = await withFeatureCacheScope(async () => { + const first = await getCachedFeatures({ + userId: "u1", + includeRaw: false, + sinceDays: 400, + compute, + }); + const second = await getCachedFeatures({ + userId: "u1", + includeRaw: false, + sinceDays: 400, + compute, + }); + return { first, second }; + }); + + // A failed probe yields a unique sentinel hash each call → the key never + // matches → the read is recomputed (fail-safe, never a wrong reuse). + expect(compute).toHaveBeenCalledTimes(2); + expect(out.first).toEqual({ marker: "1" }); + expect(out.second).toEqual({ marker: "2" }); + }); +}); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index a83582f25..b302e7f9f 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -31,6 +31,7 @@ import { buildBriefingGroundingCorrection, } from "@/lib/ai/briefing-grounding"; import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; +import { getCachedFeatures } from "@/lib/insights/feature-cache"; import { buildBriefingIllnessCycleContext, buildBriefingIllnessCyclePrompt, @@ -734,19 +735,34 @@ export async function generateComprehensiveInsight( const featureWindow = { sinceDays: BRIEFING_FEATURE_WINDOW_DAYS }; let features: Awaited>; try { - features = await extractFeatures(userId, includeRaw, featureWindow); + // v1.18.11 P3 — compute-once-per-scope. Inside the nightly-tick scope (or + // the on-demand route scope) the bounded feature read is shared, so the + // downgrade-ladder re-reads below and any sibling consumer in the same + // scope reuse this object instead of re-querying. + features = await getCachedFeatures({ + userId, + includeRaw, + sinceDays: featureWindow.sinceDays, + compute: () => extractFeatures(userId, includeRaw, featureWindow), + }); } catch (err) { if (err instanceof FeaturesPayloadTooLargeError) { try { - features = await extractFeatures(userId, false, featureWindow); + features = await getCachedFeatures({ + userId, + includeRaw: false, + sinceDays: featureWindow.sinceDays, + compute: () => extractFeatures(userId, false, featureWindow), + }); } catch (retryErr) { if (retryErr instanceof FeaturesPayloadTooLargeError) { try { - const aggregated = await extractFeatures( + const aggregated = await getCachedFeatures({ userId, - false, - featureWindow, - ); + includeRaw: false, + sinceDays: featureWindow.sinceDays, + compute: () => extractFeatures(userId, false, featureWindow), + }); features = applyInsightsExcludeFilter( aggregated, MAX_DOWNGRADE_TOKENS, diff --git a/src/lib/insights/feature-cache.ts b/src/lib/insights/feature-cache.ts new file mode 100644 index 000000000..bc640e591 --- /dev/null +++ b/src/lib/insights/feature-cache.ts @@ -0,0 +1,177 @@ +/** + * v1.18.11 (P3) — request/run-scoped shared feature cache. + * + * The comprehensive briefing builds its feature snapshot via + * `extractFeatures(userId, includeRaw, { sinceDays })`. That call materialises a + * bounded measurement read + the per-type rollup folds + the all-time-extremes + * aggregation — the most expensive read in the insight pipeline. Two patterns + * paid for it more than once: + * + * 1. The on-demand route + `comprehensive-generate.ts` each run a downgrade + * ladder that, on an oversize payload, re-invokes + * `extractFeatures(userId, false, window)` with byte-identical arguments — + * a second (and third) full read for the same user in the same request. + * 2. A single nightly tick warms one user's briefing and may, in the same + * run-scope, touch the same feature read again (e.g. a follow-on reduce + * stage). Without a shared object each consumer re-queried the DB. + * + * This module turns `extractFeatures` into a compute-ONCE-per-scope read: a + * caller opens a scope with `withFeatureCacheScope(...)`, and every + * `getCachedFeatures(...)` inside that scope keyed on the SAME + * `(userId, calendar-day, includeRaw, sinceDays, inputHash)` reuses the first + * computed object. This is an INPUT gate (compute-once + reuse), not a + * post-build skip: the key folds a cheap input fingerprint so an unchanged + * input set is computed once and a genuinely changed one recomputes. + * + * Scope discipline: + * - The cache lives only for the duration of the `withFeatureCacheScope` + * callback (an `AsyncLocalStorage` store). Outside any scope + * `getCachedFeatures` falls straight through to the computer — no global + * state, no cross-user leak, no stale-across-requests hazard. + * - The key is `(userId, day, includeRaw, sinceDays, inputHash)`. `day` is + * the Berlin calendar day so a scope that straddles midnight recomputes + * rather than serving yesterday's window. `inputHash` is a cheap + * count/newest fingerprint of the salient measurement + mood inputs, so a + * mid-scope ingest (rare, but possible on a long-running warm) recomputes + * instead of reusing a pre-ingest snapshot. + * + * The Coach owns its own snapshot builder and is deliberately NOT routed + * through here (its snapshot layers many blocks beyond `extractFeatures`); this + * cache is exposed as a standalone helper either could adopt without coupling. + */ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { annotate } from "@/lib/logging/context"; + +/** One memoised feature read, with the input fingerprint it was keyed on. */ +interface CacheEntry { + /** The resolved feature object (typed loosely — the caller re-narrows). */ + value: unknown; + /** The salient-input fingerprint the value was computed for. */ + inputHash: string; +} + +/** The per-scope store: a map from the stable key prefix to its entry. */ +type FeatureCacheStore = Map; + +const store = new AsyncLocalStorage(); + +/** + * The measurement types whose count + newest stamp fingerprint a briefing + * feature read. This is the same salient set `extractFeatures` narrates over; + * the fingerprint is intentionally coarse (count + newest per type) so it is a + * single cheap grouped query, not a second full read. A new or removed reading + * for any of these flips the hash and forces a recompute within the scope. + */ +const FEATURE_INPUT_TYPES: readonly MeasurementType[] = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "BODY_FAT", + "SLEEP_DURATION", + "ACTIVITY_STEPS", + "BLOOD_GLUCOSE", + "TIME_IN_DAYLIGHT", +]; + +/** + * Run `fn` inside a fresh feature-cache scope. Every `getCachedFeatures` call + * made (synchronously or via awaited async work) within `fn` shares one cache. + * Nesting reuses the innermost store, so an inner scope never shadows an outer + * one's already-computed reads. + */ +export function withFeatureCacheScope(fn: () => Promise): Promise { + const existing = store.getStore(); + if (existing) return fn(); + return store.run(new Map(), fn); +} + +/** + * Cheap salient-input fingerprint for `userId` — one grouped query over the + * feature input types' count + newest stamp. Deterministic across group order + * (sorted before hashing). Best-effort: a read failure yields a sentinel hash + * so the cache degrades to "always recompute" rather than reusing a wrong + * object. + */ +async function computeFeatureInputHash(userId: string): Promise { + try { + const grouped = await prisma.measurement.groupBy({ + by: ["type"], + where: { + userId, + type: { in: [...FEATURE_INPUT_TYPES] }, + deletedAt: null, + }, + _count: { _all: true }, + _max: { measuredAt: true }, + }); + const fingerprint = grouped + .map((row) => ({ + type: row.type, + count: row._count._all, + newest: row._max.measuredAt ? row._max.measuredAt.toISOString() : null, + })) + .sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0)); + return hashInsightSnapshot({ featureInput: fingerprint }); + } catch { + // A probe failure must never serve a stale object: a unique sentinel makes + // the key miss so the caller computes fresh. + return `nofingerprint:${Date.now()}:${Math.random()}`; + } +} + +/** + * Compute-once-per-scope wrapper around a feature read. + * + * Inside a `withFeatureCacheScope`, the first call for a given + * `(userId, day, includeRaw, sinceDays, inputHash)` runs `compute()` and stores + * the result; subsequent calls with the same key reuse it. Outside any scope it + * simply runs `compute()` (no caching). The returned value is whatever + * `compute()` resolves to — callers re-narrow it to their feature type. + * + * The input fingerprint is probed once per call (a cheap grouped query); the + * cost is paid to GUARANTEE correctness (a mid-scope ingest recomputes). The + * net saving is the full feature read on every cache HIT, which dwarfs the + * fingerprint probe. + */ +export async function getCachedFeatures(args: { + userId: string; + includeRaw: boolean; + sinceDays: number; + compute: () => Promise; +}): Promise { + const current = store.getStore(); + if (!current) { + // No active scope — pass straight through. This keeps the helper a no-op + // for callers that never opened a scope (e.g. unit paths) so behaviour is + // identical to a bare `extractFeatures`. + return args.compute(); + } + + const inputHash = await computeFeatureInputHash(args.userId); + const dayKey = toBerlinDayKey(new Date()); + const key = `${args.userId}|${dayKey}|${args.includeRaw ? "raw" : "agg"}|${args.sinceDays}`; + + const hit = current.get(key); + if (hit && hit.inputHash === inputHash) { + annotate({ + action: { name: "insights.feature_cache.hit" }, + meta: { since_days: args.sinceDays, include_raw: args.includeRaw }, + }); + return hit.value as T; + } + + const value = await args.compute(); + current.set(key, { value, inputHash }); + annotate({ + action: { name: "insights.feature_cache.miss" }, + meta: { since_days: args.sinceDays, include_raw: args.includeRaw }, + }); + return value; +} diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index a8f08699b..bb878ef4d 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -59,6 +59,7 @@ import { type GenerateOutcome, } from "@/lib/insights/comprehensive-generate"; import { generateStatusBatchForUser } from "@/lib/insights/status-batch"; +import { withFeatureCacheScope } from "@/lib/insights/feature-cache"; import { generateMetricStatus } from "@/lib/insights/metric-status"; import { METRIC_STATUS_IDS, @@ -486,100 +487,106 @@ export async function runInsightPregenerate( result.total = candidates.length; for (const candidate of candidates) { - // Budget gate — one COMPREHENSIVE pre-generation per user per 20 h. - // The route's on-demand path uses a different bucket - // (`insights:${userId}`), so this never starves a user's manual - // regenerate quota. The gate bounds the comprehensive cost only: a - // blocked user still gets the refill-only status warm below, because - // the 02:xx status crons already skipped every pregenerate candidate - // on the assumption that THIS pass warms their cards — exiting early - // here left those cards cold until the first on-visit generation. - const budget = await checkRateLimit( - `insight-pregenerate:${candidate.id}`, - 1, - PREGENERATE_BUDGET_WINDOW_MS, - ); - - const locale = normalizeLocale(candidate.locale); - let outcome: GenerateOutcome | null = null; - if (!budget.allowed) { - result.budgetBlocked++; - } else { - // Force a fresh generation: the discovery window (20 h) is shorter - // than the generator's 24 h cache TTL, so without `force` a user - // whose cache is 20–24 h old would be discovered, consume the - // budget bucket, then short-circuit to `cached` with no actual - // pre-generation — defeating the "warm the cache before the user's - // morning visit" intent for the common case. The per-user budget - // bucket (above) bounds attempts, and the generator's content-hash - // gate (v1.16.8) turns a same-data force into a timestamp refresh - // (`unchanged`) with no provider call. - // - // Bounded budget + abort (v1.16.1): one stalled provider must not - // pin the whole batch behind a single candidate, and `withTimeout` - // alone cannot cancel the detached generation — without the abort a - // late resolve would still write a cache row + timestamp after the - // loop moved on (the same race the forced single-user warm closes). - const controller = new AbortController(); - const bounded = await withTimeout( - () => - generate(candidate.id, { - locale, - force: true, - signal: controller.signal, - }), - COMPREHENSIVE_WARM_TIMEOUT_MS, - null, - () => controller.abort(), + // v1.18.11 P3 — one feature-cache scope per candidate, so the + // comprehensive briefing's bounded feature read is computed once and any + // sibling consumer in this candidate's warm reuses it. Scoped per user so + // no cross-user object can leak. + await withFeatureCacheScope(async () => { + // Budget gate — one COMPREHENSIVE pre-generation per user per 20 h. + // The route's on-demand path uses a different bucket + // (`insights:${userId}`), so this never starves a user's manual + // regenerate quota. The gate bounds the comprehensive cost only: a + // blocked user still gets the refill-only status warm below, because + // the 02:xx status crons already skipped every pregenerate candidate + // on the assumption that THIS pass warms their cards — exiting early + // here left those cards cold until the first on-visit generation. + const budget = await checkRateLimit( + `insight-pregenerate:${candidate.id}`, + 1, + PREGENERATE_BUDGET_WINDOW_MS, ); - if (bounded.timedOut || bounded.errored || bounded.value === null) { - result.failed++; + + const locale = normalizeLocale(candidate.locale); + let outcome: GenerateOutcome | null = null; + if (!budget.allowed) { + result.budgetBlocked++; } else { - outcome = bounded.value; - switch (outcome.status) { - case "generated": - result.generated++; - break; - case "cached": - result.cached++; - break; - case "unchanged": - result.unchanged++; - break; - case "skipped": - result.skipped++; - break; - case "failed": - result.failed++; - break; + // Force a fresh generation: the discovery window (20 h) is shorter + // than the generator's 24 h cache TTL, so without `force` a user + // whose cache is 20–24 h old would be discovered, consume the + // budget bucket, then short-circuit to `cached` with no actual + // pre-generation — defeating the "warm the cache before the user's + // morning visit" intent for the common case. The per-user budget + // bucket (above) bounds attempts, and the generator's content-hash + // gate (v1.16.8) turns a same-data force into a timestamp refresh + // (`unchanged`) with no provider call. + // + // Bounded budget + abort (v1.16.1): one stalled provider must not + // pin the whole batch behind a single candidate, and `withTimeout` + // alone cannot cancel the detached generation — without the abort a + // late resolve would still write a cache row + timestamp after the + // loop moved on (the same race the forced single-user warm closes). + const controller = new AbortController(); + const bounded = await withTimeout( + () => + generate(candidate.id, { + locale, + force: true, + signal: controller.signal, + }), + COMPREHENSIVE_WARM_TIMEOUT_MS, + null, + () => controller.abort(), + ); + if (bounded.timedOut || bounded.errored || bounded.value === null) { + result.failed++; + } else { + outcome = bounded.value; + switch (outcome.status) { + case "generated": + result.generated++; + break; + case "cached": + result.cached++; + break; + case "unchanged": + result.unchanged++; + break; + case "skipped": + result.skipped++; + break; + case "failed": + result.failed++; + break; + } } } - } - // Warm the per-metric assessment caches — refill-only, single locale - // (v1.16.8). The comprehensive write no longer evicts the per-status - // rows, so there is nothing to force-refill: a card already generated - // today is a cheap cache read, a cold card runs its generator, and the - // generator's content-hash gate turns a same-data regeneration into a - // timestamp refresh with no provider call. The warm runs on every - // comprehensive outcome (budget-blocked, failed, timed-out, - // consent-skipped included) because the 02:xx status crons skip every - // pregenerate candidate on the assumption that THIS pass covers them. - // - // The only outcome with nothing to warm is a missing provider - // (`skipped`/`no-provider`): the per-card generators would no-op to - // their fallbacks anyway, so the pass is skipped to save the - // chain-resolves. - if (outcome?.status === "skipped" && outcome.reason === "no-provider") { - continue; - } - result.assessmentsWarmed += await warmStatus(candidate.id, [locale]); - // v1.8.7.1 — warm the generic per-HealthKit-metric caches too, for - // the user's data-bearing metrics only (the helper filters via one - // grouped count, so an empty metric never reaches the provider). - result.metricAssessmentsWarmed += await warmGenericMetrics(candidate.id, [ - locale, - ]); + // Warm the per-metric assessment caches — refill-only, single locale + // (v1.16.8). The comprehensive write no longer evicts the per-status + // rows, so there is nothing to force-refill: a card already generated + // today is a cheap cache read, a cold card runs its generator, and the + // generator's content-hash gate turns a same-data regeneration into a + // timestamp refresh with no provider call. The warm runs on every + // comprehensive outcome (budget-blocked, failed, timed-out, + // consent-skipped included) because the 02:xx status crons skip every + // pregenerate candidate on the assumption that THIS pass covers them. + // + // The only outcome with nothing to warm is a missing provider + // (`skipped`/`no-provider`): the per-card generators would no-op to + // their fallbacks anyway, so the pass is skipped to save the + // chain-resolves. + if (outcome?.status === "skipped" && outcome.reason === "no-provider") { + return; + } + result.assessmentsWarmed += await warmStatus(candidate.id, [locale]); + // v1.8.7.1 — warm the generic per-HealthKit-metric caches too, for + // the user's data-bearing metrics only (the helper filters via one + // grouped count, so an empty metric never reaches the provider). + result.metricAssessmentsWarmed += await warmGenericMetrics(candidate.id, [ + locale, + ]); + }); } // Wide-event tally so the nightly dashboard can track how many @@ -661,194 +668,202 @@ export async function forceWarmUser( now?: Date; } = {}, ): Promise { - const generate = options.generate ?? generateComprehensiveInsight; - // v1.18.7 (HIGH-1) — batch the seven status warms into ONE provider call - // in production; keep the per-card path when a test injects generators. - const injectedStatusGenerators = options.statusGenerators; - const warmStatus = (userId: string, locales: ReadonlyArray<"de" | "en">) => - injectedStatusGenerators - ? warmPerStatusCaches(userId, locales, injectedStatusGenerators) - : warmStatusBatch(userId, locales); - const warmGenericMetrics = - options.warmGenericMetrics ?? - ((id: string, locales: ReadonlyArray<"de" | "en">) => - warmGenericMetricCaches(prisma, id, locales)); - const now = options.now ?? new Date(); + // v1.18.11 P3 — one feature-cache scope for the whole forced warm of this + // user, so the comprehensive briefing's bounded feature read is computed + // once and reused by any sibling consumer in the same warm. + return withFeatureCacheScope(async () => { + const generate = options.generate ?? generateComprehensiveInsight; + // v1.18.7 (HIGH-1) — batch the seven status warms into ONE provider call + // in production; keep the per-card path when a test injects generators. + const injectedStatusGenerators = options.statusGenerators; + const warmStatus = (userId: string, locales: ReadonlyArray<"de" | "en">) => + injectedStatusGenerators + ? warmPerStatusCaches(userId, locales, injectedStatusGenerators) + : warmStatusBatch(userId, locales); + const warmGenericMetrics = + options.warmGenericMetrics ?? + ((id: string, locales: ReadonlyArray<"de" | "en">) => + warmGenericMetricCaches(prisma, id, locales)); + const now = options.now ?? new Date(); - const result: ForceWarmResult = { - comprehensive: "skipped", - assessmentsWarmed: 0, - metricAssessmentsWarmed: 0, - }; + const result: ForceWarmResult = { + comprehensive: "skipped", + assessmentsWarmed: 0, + metricAssessmentsWarmed: 0, + }; - // v1.9.0 — gate each section on the flag for the surface it actually - // warms, matching the route. The HTTP route admits this job on the per-user - // `insightStatus` surface, so warming the per-status + generic-metric cards - // must run whenever `insightStatus` is enabled. Only the comprehensive - // briefing belongs to the `briefing` surface — gating the whole warm on - // `briefing` (the previous behaviour) let an operator's global `briefing` - // kill-switch silently suppress the assessment cards the user has enabled. - // When the master assistant switch is off both flags resolve false, so the - // whole thing still no-ops. - const flags = await getAssistantFlags(); - if (!flags.briefing && !flags.insightStatus) return result; + // v1.9.0 — gate each section on the flag for the surface it actually + // warms, matching the route. The HTTP route admits this job on the per-user + // `insightStatus` surface, so warming the per-status + generic-metric cards + // must run whenever `insightStatus` is enabled. Only the comprehensive + // briefing belongs to the `briefing` surface — gating the whole warm on + // `briefing` (the previous behaviour) let an operator's global `briefing` + // kill-switch silently suppress the assessment cards the user has enabled. + // When the master assistant switch is off both flags resolve false, so the + // whole thing still no-ops. + const flags = await getAssistantFlags(); + if (!flags.briefing && !flags.insightStatus) return result; - // v1.16.8 — warm only the caller's resolved locale. The old dual-locale - // warm compensated for the comprehensive write's cross-locale per-status - // eviction; that eviction is gone, so the other locale family's rows - // survive untouched and a client actually reading the second locale - // warms it lazily through the read-path enqueue on its first miss. - const locales: ReadonlyArray<"de" | "en"> = [locale]; + // v1.16.8 — warm only the caller's resolved locale. The old dual-locale + // warm compensated for the comprehensive write's cross-locale per-status + // eviction; that eviction is gone, so the other locale family's rows + // survive untouched and a client actually reading the second locale + // warms it lazily through the read-path enqueue on its first miss. + const locales: ReadonlyArray<"de" | "en"> = [locale]; - // The comprehensive step gets its own bounded budget and its failure is - // non-fatal. A slow or stalled briefing generation must not short-circuit - // the per-status + generic-metric passes — those are the ~37 cards the - // user actually clicks first, and warming them is independent of the - // comprehensive insight (each generator resolves its own provider chain - // and writes its own cache row). A timeout / error / skipped comprehensive - // falls through to the warm passes instead of aborting the whole job. - // - // On a timeout `withTimeout` cannot cancel the detached generation; without - // a cut-off a late-resolving comprehensive would still write its cache row - // + timestamp after the job moved on. Thread an AbortController so the - // timeout aborts the generation before it can touch the cache. - // - // v1.16.8 — three pre-flight gates make the forced path idempotent and - // bounded (the enqueue-side singleton alone cannot, because the client's - // revalidation poll outlives any reasonable singleton window): - // 1. freshness — a comprehensive warmed within the last hour is - // current; re-forcing it would at best re-discover an unchanged - // hash and at worst burn a generation on intra-day noise. - // 2. failure backoff — a forced attempt that failed within the last - // hour stays failed for most causes (broken key, dead local - // endpoint); re-attempting on every page-open re-runs the full - // feature extraction + provider stall each time. - // 3. daily cap — at most FORCE_WARM_DAILY_LIMIT forced attempts per - // rolling 24 h, so even a client bug that defeats 1+2 cannot turn - // the forced path into an unmetered generation loop. - if (flags.briefing) { - let freshness: { - insightsCachedAt: Date | null; - insightsWarmFailedAt: Date | null; - } | null = null; - try { - freshness = await prisma.user.findUnique({ - where: { id: userId }, - select: { insightsCachedAt: true, insightsWarmFailedAt: true }, - }); - } catch { - // Best-effort pre-flight — on a read failure fall through to the - // budgeted generation attempt below. - } + // The comprehensive step gets its own bounded budget and its failure is + // non-fatal. A slow or stalled briefing generation must not short-circuit + // the per-status + generic-metric passes — those are the ~37 cards the + // user actually clicks first, and warming them is independent of the + // comprehensive insight (each generator resolves its own provider chain + // and writes its own cache row). A timeout / error / skipped comprehensive + // falls through to the warm passes instead of aborting the whole job. + // + // On a timeout `withTimeout` cannot cancel the detached generation; without + // a cut-off a late-resolving comprehensive would still write its cache row + // + timestamp after the job moved on. Thread an AbortController so the + // timeout aborts the generation before it can touch the cache. + // + // v1.16.8 — three pre-flight gates make the forced path idempotent and + // bounded (the enqueue-side singleton alone cannot, because the client's + // revalidation poll outlives any reasonable singleton window): + // 1. freshness — a comprehensive warmed within the last hour is + // current; re-forcing it would at best re-discover an unchanged + // hash and at worst burn a generation on intra-day noise. + // 2. failure backoff — a forced attempt that failed within the last + // hour stays failed for most causes (broken key, dead local + // endpoint); re-attempting on every page-open re-runs the full + // feature extraction + provider stall each time. + // 3. daily cap — at most FORCE_WARM_DAILY_LIMIT forced attempts per + // rolling 24 h, so even a client bug that defeats 1+2 cannot turn + // the forced path into an unmetered generation loop. + if (flags.briefing) { + let freshness: { + insightsCachedAt: Date | null; + insightsWarmFailedAt: Date | null; + } | null = null; + try { + freshness = await prisma.user.findUnique({ + where: { id: userId }, + select: { insightsCachedAt: true, insightsWarmFailedAt: true }, + }); + } catch { + // Best-effort pre-flight — on a read failure fall through to the + // budgeted generation attempt below. + } - const cachedAt = freshness?.insightsCachedAt ?? null; - const failedAt = freshness?.insightsWarmFailedAt ?? null; - const isFresh = - cachedAt !== null && - now.getTime() - cachedAt.getTime() < FORCE_WARM_FRESH_MS; - const inBackoff = - !isFresh && - failedAt !== null && - now.getTime() - failedAt.getTime() < FORCE_WARM_FAILURE_BACKOFF_MS; + const cachedAt = freshness?.insightsCachedAt ?? null; + const failedAt = freshness?.insightsWarmFailedAt ?? null; + const isFresh = + cachedAt !== null && + now.getTime() - cachedAt.getTime() < FORCE_WARM_FRESH_MS; + const inBackoff = + !isFresh && + failedAt !== null && + now.getTime() - failedAt.getTime() < FORCE_WARM_FAILURE_BACKOFF_MS; - if (isFresh) { - result.comprehensive = "fresh"; - annotate({ - action: { name: "insights.pregenerate.force.fresh" }, - meta: { cached_at: cachedAt?.toISOString() ?? null }, - }); - } else if (inBackoff) { - result.comprehensive = "backoff"; - annotate({ - action: { name: "insights.pregenerate.force.backoff" }, - meta: { failed_at: failedAt?.toISOString() ?? null }, - }); - } else { - const budget = await checkRateLimit( - `insight-pregenerate-daily:${userId}`, - FORCE_WARM_DAILY_LIMIT, - FORCE_WARM_DAILY_WINDOW_MS, - ); - if (!budget.allowed) { - result.comprehensive = "capped"; + if (isFresh) { + result.comprehensive = "fresh"; annotate({ - action: { name: "insights.pregenerate.force.capped" }, - meta: { daily_limit: FORCE_WARM_DAILY_LIMIT }, + action: { name: "insights.pregenerate.force.fresh" }, + meta: { cached_at: cachedAt?.toISOString() ?? null }, + }); + } else if (inBackoff) { + result.comprehensive = "backoff"; + annotate({ + action: { name: "insights.pregenerate.force.backoff" }, + meta: { failed_at: failedAt?.toISOString() ?? null }, }); } else { - const controller = new AbortController(); - const comprehensive = await withTimeout( - () => - generate(userId, { - locale, - force: true, - signal: controller.signal, - }), - COMPREHENSIVE_WARM_TIMEOUT_MS, - null, - () => controller.abort(), + const budget = await checkRateLimit( + `insight-pregenerate-daily:${userId}`, + FORCE_WARM_DAILY_LIMIT, + FORCE_WARM_DAILY_WINDOW_MS, ); - if (comprehensive.timedOut) { - result.comprehensive = "timeout"; - } else if (comprehensive.errored || comprehensive.value === null) { - result.comprehensive = "failed"; + if (!budget.allowed) { + result.comprehensive = "capped"; + annotate({ + action: { name: "insights.pregenerate.force.capped" }, + meta: { daily_limit: FORCE_WARM_DAILY_LIMIT }, + }); } else { - result.comprehensive = comprehensive.value.status; - } + const controller = new AbortController(); + const comprehensive = await withTimeout( + () => + generate(userId, { + locale, + force: true, + signal: controller.signal, + }), + COMPREHENSIVE_WARM_TIMEOUT_MS, + null, + () => controller.abort(), + ); + if (comprehensive.timedOut) { + result.comprehensive = "timeout"; + } else if (comprehensive.errored || comprehensive.value === null) { + result.comprehensive = "failed"; + } else { + result.comprehensive = comprehensive.value.status; + } - // Stamp / clear the failure marker for the backoff gate above. A - // `skipped` outcome (no provider / no consent) is not a failure — - // it costs nothing and should not delay a user who configures a - // provider a minute later. - const failed = - result.comprehensive === "failed" || - result.comprehensive === "timeout"; - try { - if (failed) { - await prisma.user.update({ - where: { id: userId }, - data: { insightsWarmFailedAt: now }, - }); - } else if (failedAt !== null) { - await prisma.user.update({ - where: { id: userId }, - data: { insightsWarmFailedAt: null }, - }); + // Stamp / clear the failure marker for the backoff gate above. A + // `skipped` outcome (no provider / no consent) is not a failure — + // it costs nothing and should not delay a user who configures a + // provider a minute later. + const failed = + result.comprehensive === "failed" || + result.comprehensive === "timeout"; + try { + if (failed) { + await prisma.user.update({ + where: { id: userId }, + data: { insightsWarmFailedAt: now }, + }); + } else if (failedAt !== null) { + await prisma.user.update({ + where: { id: userId }, + data: { insightsWarmFailedAt: null }, + }); + } + } catch { + // Marker maintenance is best-effort; never fail the warm on it. } - } catch { - // Marker maintenance is best-effort; never fail the warm on it. } } } - } - // Run the per-status + generic-metric warm passes whenever the - // `insightStatus` surface is enabled, regardless of the comprehensive - // outcome. Each generator independently no-ops to its no-key fallback when - // no provider is configured (no LLM call) and serves its fallback without - // persisting on a per-card timeout, so running them after a failed - // comprehensive is safe and bounded by their own per-card budgets. The only - // outcome that has nothing to warm is a missing provider, and the - // generators detect that themselves at near-zero cost. - if (flags.insightStatus) { - // Refill-only (v1.16.8): a card already generated today is a cheap - // cache read; a cold card runs its generator, whose content-hash gate - // skips the LLM when the underlying data did not change. There is no - // force mode left to mirror — the comprehensive write no longer evicts - // the per-status rows. - result.assessmentsWarmed = await warmStatus(userId, locales); - result.metricAssessmentsWarmed = await warmGenericMetrics(userId, locales); - } + // Run the per-status + generic-metric warm passes whenever the + // `insightStatus` surface is enabled, regardless of the comprehensive + // outcome. Each generator independently no-ops to its no-key fallback when + // no provider is configured (no LLM call) and serves its fallback without + // persisting on a per-card timeout, so running them after a failed + // comprehensive is safe and bounded by their own per-card budgets. The only + // outcome that has nothing to warm is a missing provider, and the + // generators detect that themselves at near-zero cost. + if (flags.insightStatus) { + // Refill-only (v1.16.8): a card already generated today is a cheap + // cache read; a cold card runs its generator, whose content-hash gate + // skips the LLM when the underlying data did not change. There is no + // force mode left to mirror — the comprehensive write no longer evicts + // the per-status rows. + result.assessmentsWarmed = await warmStatus(userId, locales); + result.metricAssessmentsWarmed = await warmGenericMetrics( + userId, + locales, + ); + } - annotate({ - action: { name: "insights.pregenerate.force" }, - meta: { - locale, - comprehensive: result.comprehensive, - assessments_warmed: result.assessmentsWarmed, - metric_assessments_warmed: result.metricAssessmentsWarmed, - }, + annotate({ + action: { name: "insights.pregenerate.force" }, + meta: { + locale, + comprehensive: result.comprehensive, + assessments_warmed: result.assessmentsWarmed, + metric_assessments_warmed: result.metricAssessmentsWarmed, + }, + }); + + return result; }); - - return result; }