diff --git a/src/__tests__/background-reasoning-inventory.test.ts b/src/__tests__/background-reasoning-inventory.test.ts index ccb773f6c..0354a6a87 100644 --- a/src/__tests__/background-reasoning-inventory.test.ts +++ b/src/__tests__/background-reasoning-inventory.test.ts @@ -41,7 +41,7 @@ const ADMITTED: Record = { jobs: ["daily_briefing"], why: "The daily briefing's generation and its phrasing re-roll; the JSON and grounding repairs do not reason.", }, - "app/api/insights/generate/route.ts": { + "lib/insights/briefing-request/completion.ts": { jobs: ["daily_briefing"], why: "A briefing asked for by hand reasons like the scheduled one; its JSON and grounding repairs do not.", }, diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index b84376146..0f9126d56 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -506,7 +506,7 @@ const READER_UNIT_PRODUCERS = [ "lib/ai/prompts/weight.ts", "lib/insights/features-units.ts", "lib/insights/comprehensive-generate.ts", - "app/api/insights/generate/route.ts", + "lib/insights/briefing-request/features.ts", "lib/insights/weight-status.ts", "lib/insights/metric-status.ts", "lib/insights/narrative/period-narrative.ts", diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 394be24bb..3884229d2 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -1,4 +1,8 @@ /** + * GET /api/insights/generate — the read-only advisor briefing; POST — an + * on-demand generation. Both live in `src/lib/insights/briefing-request/`; + * this file keeps the gates, the body, the hourly quota and the statuses. + * * Refs #786 — async-truth contract, server end (the client end lives in * `src/components/insights/use-insights-advisor.ts`, `AdvisorSettleState`): * the force POST generates INLINE and, on success, writes the fresh briefing @@ -9,131 +13,44 @@ * GET until `cachedAt` advances past its baseline (success) or a NEWER * failure marker appears (honest error). Renaming or short-circuiting either * write breaks that convergence — the settling seam tests pin the client - * half, this comment is the server half of the contract. + * half, this comment and `briefing-request/commit.ts` the server half. */ -import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; -import { - generatedOnLocalToday, - insightsPayloadForToday, -} from "@/lib/daily/briefing-today-read"; -import { withBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; -import { prisma } from "@/lib/db"; -import { auditLog } from "@/lib/auth/audit"; +import { NextRequest } from "next/server"; + import { apiSuccess, apiError, getClientIp, safeJson, } from "@/lib/api-response"; -import { - extractFeatures, - FeaturesPayloadTooLargeError, - 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, -} from "@/lib/insights/illness-cycle-briefing"; -import { compactSections } from "@/lib/ai/prompts/compact-sections"; -import { - detectGlp1Plateau, - buildGlp1PlateauPrompt, -} from "@/lib/insights/glp1-plateau"; -import { - detectDerivedBriefingSignals, - buildDerivedBriefingPrompt, -} from "@/lib/insights/derived-briefing"; -import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; -import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt"; -import { - buildSystemPromptWithReferences, - buildBriefingPersonalisationBlock, -} from "@/lib/ai/prompts/insight-generator"; -import { - buildAboutMeInsightBlock, - getSelfContextTextForUser, -} from "@/lib/ai/coach/about-me"; -import { metricsFromPresentSections } from "@/lib/ai/medical-references"; -import { resolveUserTimezone } from "@/lib/tz/resolver"; -import { insightResultSchema, type InsightResult } from "@/lib/ai/types"; -import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets"; -import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; -import { providerFailureKind } from "@/lib/ai/failure-kind"; -import { - recordBriefingFailure, - readBriefingFailure, -} from "@/lib/insights/briefing-failure-marker"; -import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; -import { AllProvidersFailedError } from "@/lib/ai/provider-runner"; -import { - BriefingBudgetExceededError, - runBriefingCompletion, -} from "@/lib/insights/briefing-provider"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; -import { screenInsightPayloadProse } from "@/lib/ai/safety/insight-payload-screen"; -import { - findUngroundedBriefingNumbers, - readBriefingBlock, - buildBriefingGroundingCorrection, -} from "@/lib/ai/briefing-grounding"; -import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { AI_PROVIDER_NONE_ERROR_CODE } from "@/lib/ai/capabilities/types"; -import { invalidateUserInsights } from "@/lib/cache/invalidate"; -import { annotate } from "@/lib/logging/context"; -import { toProfileSex } from "@/lib/profile/sex"; -import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { normalizeLocale } from "@/lib/insights/status-shared"; -import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; -import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { withFeatureCacheScope } from "@/lib/insights/feature-cache"; import { - applyBriefingShortForm, - briefingFormForRecord, - buildBriefingShortFormBlock, -} from "@/lib/insights/briefing-short-form"; -import { featuresInReaderUnits } from "@/lib/insights/features-units"; -import { resolveUnitPreferences } from "@/lib/measurements/display-transform"; -import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; -// v1.28.25 — the comparison-snapshot builder was a private near-copy of -// the lib export (drifted only in comments); the route now shares the -// one implementation the nightly warm pass uses. + briefingFromCache, + serveCachedFallback, +} from "@/lib/insights/briefing-request/cache"; import { - buildComparisonSnapshotForUser, - enqueueStatusRefillForUser, -} from "@/lib/insights/comprehensive-generate"; -import { cachedPayloadCarriesBriefing } from "@/lib/insights/briefing-payload"; + loadGenerationContext, + type BriefingRefusal, +} from "@/lib/insights/briefing-request/context"; import { - INSIGHTS_CACHE_SELECT, - insightsCacheWrite, - openInsightsCachedText, -} from "@/lib/insights/cached-text-cipher"; + generateBriefing, + resolveConsentedBriefingChain, +} from "@/lib/insights/briefing-request/generate"; +import { readAdvisorBriefing } from "@/lib/insights/briefing-request/read"; +import { checkRateLimit } from "@/lib/rate-limit"; export const dynamic = "force-dynamic"; -/** - * Briefing freshness window for the read-only GET. A cached briefing read - * this recently is fresh and triggers no warm; older / missing enqueues an - * out-of-band regeneration. Mirrors the 24 h cache-hit window the POST path - * honours so the two stay consistent. - */ -const BRIEFING_FRESH_MS = 24 * 60 * 60 * 1000; - const DEFAULT_INSIGHTS_RATE_LIMIT_PER_HOUR = 10; /** * Reads `INSIGHTS_RATE_LIMIT_PER_HOUR` from the environment with a - * sensible 10/hour default. The previous hard-coded 2/hour limit (from - * v1.4 P13) was too aggressive for users iterating on settings or - * regenerating after adding measurements. Operators who run on a + * sensible 10/hour default. A hard-coded 2/hour limit was too aggressive + * for users iterating on settings or regenerating after adding + * measurements. Operators who run on a * stricter LLM budget can dial it down via env without redeploying * code; values <1 fall back to the default. */ @@ -147,18 +64,81 @@ export function resolveInsightsRateLimit(): number { return parsed; } +const PROVIDER_REJECTED = + "AI provider rejected the request. Check your API key in Settings > AI"; +const PROVIDER_UNAVAILABLE = + "AI provider temporarily unavailable, try again in a moment"; + /** - * GET /api/insights/generate — read-only advisor read. - * - * The advisor consumers (hero strip, daily-briefing card, trends row) used - * to mount against the POST handler, which generates inline on a cache miss - * and blocks the page-load path on the full provider chain (up to the - * client's 8 s abort). This read-only GET serves the cached payload from - * `User.insightsCachedTextEncrypted` immediately — NEVER calling the provider — and, - * when the cache is stale / missing AND a provider is configured, enqueues - * an out-of-band warm (the same `insight-pregenerate` queue the nightly - * cron and the "prepare assessments" button use). The next read reflects - * the fresh briefing. User-initiated regeneration stays on the POST path. + * The answer for each way a generation ends without a briefing. A 502 from + * a provider would be rewritten by Cloudflare into an HTML page the client + * cannot parse, so provider failures answer 422 / 429 / 503 instead. + */ +const REFUSALS: Record< + BriefingRefusal, + { message: string; status: number; errorCode?: string } +> = { + "payload-too-large": { + message: + "Your data exceeds the AI payload size limit. Reduce the data window or exclude optional blocks in Settings > AI.", + status: 422, + }, + // The day's token ceiling: no provider was contacted. The hourly rate + // limit bounds request frequency; this bounds cost, with its own code so + // the client can say so. + "budget-exceeded": { + message: + "Daily AI token budget reached. Insights will be available again tomorrow.", + status: 429, + errorCode: "insights.generate.budgetExceeded", + }, + "provider-rejected": { message: PROVIDER_REJECTED, status: 422 }, + "provider-rate-limited": { + message: "AI provider rate-limited the request", + status: 429, + }, + "provider-unavailable": { message: PROVIDER_UNAVAILABLE, status: 503 }, + "provider-connection": { + message: "AI provider connection failed. Check your AI settings", + status: 422, + }, + "response-truncated": { + message: + "AI response was cut off before the JSON completed. Raise the token limit (INSIGHTS_MAX_TOKENS)", + status: 422, + errorCode: "ai_response_truncated", + }, + "response-invalid-json": { + message: "AI response was not valid JSON", + status: 422, + }, + "outbound-screened": { + message: + "The generated insight did not pass the safety check and was discarded. Try regenerating.", + status: 502, + errorCode: "insights.generate.outboundScreened", + }, + "profile-scope-changed": { + message: "Profile scope changed during insight generation", + status: 409, + errorCode: "insights.generate.profileScopeChanged", + }, + "scope-changed": { + message: "Insight scope changed during generation", + status: 409, + errorCode: "insights.generate.scopeChanged", + }, +}; + +function refuse(refusal: BriefingRefusal) { + const { message, status, errorCode } = REFUSALS[refusal]; + return errorCode + ? apiError(message, status, { errorCode }) + : apiError(message, status); +} + +/** + * GET — read-only advisor read: the cached payload, never a provider call. * * A pure AI read: the payload is the model-written briefing and nothing * else, so it keeps a typed refusal rather than answering 200 with nothing. @@ -172,183 +152,23 @@ export function resolveInsightsRateLimit(): number { */ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - // The briefing is its own capability now, not the Coach's: a user with the + // The briefing is its own capability, not the Coach's: a user with the // Coach hidden still reads their briefing, and one who opted out of AI // analysis does not. await requireAiCapability("briefing"); - const userId = user.id; - - const dbUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { - insightsCachedAt: true, - ...INSIGHTS_CACHE_SELECT, - insightsCachedLocale: true, - locale: true, - timezone: true, - }, - }); - const cachedText = openInsightsCachedText(dbUser); - - // Resolve the locale the caller is actually reading (cookie / - // Accept-Language fall-back when `User.locale` is unset) and validate it - // against the shipped list — the same convention the nightly's - // `normalizeLocale` and the status routes follow. The previous - // `(locale) === "en" ? "en" : "de"` collapsed a NULL `User.locale` - // to German even for a client reading the app in English, so the - // visit-triggered warm filled the wrong cache family. - const readerLocale = normalizeLocale( - await resolveServerLocale({ + return apiSuccess( + await readAdvisorBriefing({ request, - userLocale: dbUser?.locale ?? user.locale ?? null, + userId: user.id, + userLocale: user.locale ?? null, }), ); - - // The cache is one slot per user. Text written in another language is - // never the reader's briefing, however fresh: treat the slot as empty for - // prose, and re-warm in the reader's language below. An untagged row - // predates the tag and is served as before. - const languageMismatch = - dbUser?.insightsCachedLocale != null && - dbUser.insightsCachedLocale !== readerLocale; - - const cachedAt = dbUser?.insightsCachedAt ?? null; - const timezone = dbUser?.timezone ?? DEFAULT_TIMEZONE; - // Fresh means written today on the reader's calendar, by the moment the - // text was generated: `insightsCachedAt` also moves when an unchanged - // warm only re-stamps yesterday's text. - const generatedToday = generatedOnLocalToday( - readBriefingGeneratedAt(cachedText), - timezone, - new Date(), - ); - const isFresh = - !languageMismatch && - cachedAt !== null && - generatedToday && - Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS; - - // v1.18.9 (#4) — resolve provider availability once, up front. The - // read path never blocks on or generates through the provider, but it - // must REPORT whether one exists: a provider-less account's cached - // briefing freezes at the last successful generation (the pregenerate - // cron can't refill it), and the UI presented that stale text as - // current. Surfacing `hasProvider: false` lets the insights surfaces - // pair the honest "Stand: vor X Tagen" age with a discreet - // connect-an-AI-provider hint instead of implying the read is live. - const hasProvider = await hasUsableStatusProvider(userId); - - // v1.25 — surface whether the most recent generation attempt failed (a - // marker newer than the last successful generation). The briefing keeps its - // last good text on failure, so this is the only honest signal that a shown - // briefing is held rather than freshly refreshed — and, when there is no - // last good text, that the empty state should read "couldn't generate" with - // a retry rather than the generic "no briefing yet". - const briefingFailure = await readBriefingFailure({ - userId, - since: cachedAt, - }); - // v1.28.30 — a `briefing-ungrounded` marker is NOT a failure: the - // generation succeeded but the grounding gate withheld the briefing - // (marker written after the cache write, so it survives the `since` - // filter). Surface it as the persistent omission signal — the card's - // "briefing withheld" state used to exist only on the transient POST - // response, so a nightly strip read as a silent generic empty card. - const briefingOmittedReason: "ungrounded" | null = - briefingFailure?.reason === "briefing-ungrounded" ? "ungrounded" : null; - const generationFailed = - briefingFailure !== null && briefingOmittedReason === null; - // v1.25.3 — the failure class lets the empty state point its hint at the - // right lever (raise the response timeout vs re-check the provider). - const generationFailureClass = generationFailed - ? (briefingFailure?.failureClass ?? null) - : null; - - // Read-only: never block on the provider. Warm out of band only when the - // cached briefing is stale / missing AND a provider is configured (a - // provider-less account costs one cheap chain-resolve and shows the - // empty / connect-AI state instead of a wasted enqueue). - let revalidating = false; - if (!isFresh && hasProvider) { - void enqueueForceWarm({ userId, locale: readerLocale }); - revalidating = true; - } - - if (cachedText && !languageMismatch) { - try { - const cached = JSON.parse(cachedText); - const legacyPayload = isLegacyInsightPayload(cached); - annotate({ - action: { name: "insights.generate.read" }, - meta: { cached: true, legacyPayload, revalidating, hasProvider }, - }); - return apiSuccess({ - // The briefing as every surface serves it: today's text only, its - // signals measured today, its deltas at display precision. - insights: await insightsPayloadForToday(cached, { - userId, - timezone, - language: readerLocale, - }), - cached: true, - cachedAt, - legacyPayload, - // Honest stale-serve marker: true while the out-of-band warm is - // in flight, so the client can poll (bounded) until the fresh - // briefing lands instead of sitting on the stale payload for the - // rest of the session. - revalidating, - // v1.18.9 (#4) — false when no AI provider is configured anywhere: - // the served briefing can never refresh, so the UI pairs its age - // with a connect-provider affordance. - hasProvider, - // v1.25 — true when the last generation attempt failed (held text). - generationFailed, - // v1.25.3 — coarse failure class for the empty-state hint (null when - // the last attempt succeeded). - generationFailureClass, - // v1.28.30 — persistent grounding-omission signal (see above), so a - // nightly-stripped briefing renders "withheld", not "no briefing yet". - briefingOmittedReason, - }); - } catch { - // Invalid cache row — fall through to the empty payload below. The - // warm enqueue above (when a provider exists) repairs it for the - // next read. - } - } - - annotate({ - action: { name: "insights.generate.read" }, - meta: { - cached: false, - revalidating, - hasProvider, - languageMismatch, - readerLocale, - }, - }); - return apiSuccess({ - insights: null, - cached: false, - legacyPayload: false, - revalidating, - // v1.18.9 (#4) — see the cached branch above. - hasProvider, - // v1.25 — no last-good text AND the last attempt failed: the UI shows a - // "couldn't generate" empty state with a retry instead of the generic one. - generationFailed, - // v1.25.3 — coarse failure class for the empty-state hint. - generationFailureClass, - // v1.28.30 — see the cached branch above. - briefingOmittedReason, - }); }); export const POST = apiHandler((request: NextRequest) => - // v1.18.11 — 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. + // 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(); // The `briefing` capability, before any read or provider work. Refused @@ -357,63 +177,11 @@ export const POST = apiHandler((request: NextRequest) => await requireAiCapability("briefing"); const userId = user.id; - const dbUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { - insightsPrivacyMode: true, - insightsCachedAt: true, - ...INSIGHTS_CACHE_SELECT, - insightsCachedLocale: true, - timezone: true, - // v1.4.36 — 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, - // v1.22 — first name for the sparse, hash-gated briefing opener. - displayName: true, - // v1.25 — per-user response-timeout (seconds), threaded onto the - // generation calls so a slow self-hosted backend honours the setting. - aiResponseTimeoutSeconds: true, - // The reader's units; the feature set is converted into them before - // it is serialised, hashed and checked for grounding. - unitPreference: true, - glucoseUnit: true, - }, - }); - const cachedText = openInsightsCachedText(dbUser); - const modeAtRead = dbUser?.insightsPrivacyMode ?? "aggregated"; - // v1.34 — the commit guard below (line ~966) matches on these exact - // columns instead of the broad `updatedAt` timestamp. Every User write - // bumps `updatedAt`, including `resolveProviderChain()`'s OAuth - // token-refresh update later in this same request — keying the - // optimistic-concurrency check on it produced a false 409 whenever a - // provider credential happened to refresh mid-generation. These are - // exactly the columns selected above that feed the prompt built below; - // capturing them here and requiring an exact match at commit still - // catches a genuine privacy-mode / exclude-list / locale / profile - // change without colliding on unrelated column writes. - const excludeMetricsAtRead = dbUser?.insightsExcludeMetrics ?? []; - const localeAtRead = dbUser?.locale ?? null; - const dateOfBirthAtRead = dbUser?.dateOfBirth ?? null; - const genderAtRead = dbUser?.gender ?? null; - const heightCmAtRead = dbUser?.heightCm ?? null; - const displayNameAtRead = dbUser?.displayName ?? null; - - const effectiveTimeoutMs = resolveEffectiveTimeoutMs( - dbUser?.aiResponseTimeoutSeconds, - AI_BUDGETS.comprehensive.timeoutMs, - ); - - const locale = await resolveServerLocale({ + const ctx = await loadGenerationContext({ request, - userLocale: dbUser?.locale ?? user.locale ?? null, + userId, + userLocale: user.locale ?? null, }); - const postTimezone = dbUser?.timezone ?? DEFAULT_TIMEZONE; const { data: body, error: jsonError } = await safeJson<{ force?: unknown; @@ -421,97 +189,12 @@ export const POST = apiHandler((request: NextRequest) => if (jsonError) return jsonError; const forceRefresh = body.force === true; - // v1.28.30 — a fresh cache WITHOUT a briefing must not satisfy a - // briefing-expecting caller: after a failed nightly warm the cached - // payload can sit inside the 24 h window all day, and the old - // unconditional short-circuit served it to every POST (three - // regenerate attempts, three `cached: true` responses, zero - // generations). The short-circuit now holds only when the cached - // payload actually carries a briefing OR the account has no usable - // provider (regenerating would be futile — serve what exists). The - // read-only GET is untouched: it never generates and its out-of-band - // revalidation behaviour stays as-is. When the briefingless fall- - // through is rate-limited below, the cached payload is served after - // all so a POST-as-read client degrades to the old behaviour instead - // of a 429. - let briefinglessCacheFallback: { - cached: unknown; - legacyPayload: boolean; - cachedAt: Date; - } | null = null; - // A cache tagged with another language is not this reader's cache and - // never short-circuits; the generation below writes the reader's own. - const cachedLocaleMatches = - dbUser?.insightsCachedLocale == null || - dbUser.insightsCachedLocale === locale; - if ( - !forceRefresh && - dbUser?.insightsCachedAt && - cachedText && - cachedLocaleMatches && - Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000 - ) { - try { - const cached = JSON.parse(cachedText); - // v1.4.16: 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); - // A briefing generated on an earlier day is not today's read: like - // a briefingless cache, it does not short-circuit a provider-backed - // account, and degrades to the cached payload on a 429 below. - const carriesBriefing = - cachedPayloadCarriesBriefing(cachedText) && - generatedOnLocalToday( - readBriefingGeneratedAt(cachedText), - postTimezone, - new Date(), - ); - if (carriesBriefing || !(await hasUsableStatusProvider(userId))) { - annotate({ - action: { name: "insights.generate" }, - meta: { - cached: true, - legacyPayload, - briefingless: !carriesBriefing, - }, - }); - return apiSuccess({ - insights: await insightsPayloadForToday(cached, { - userId, - timezone: postTimezone, - language: locale, - }), - cached: true, - cachedAt: dbUser.insightsCachedAt, - legacyPayload, - }); - } - briefinglessCacheFallback = { - cached, - legacyPayload, - cachedAt: dbUser.insightsCachedAt, - }; - annotate({ - action: { name: "insights.generate.briefingless_cache_bypassed" }, - meta: { locale }, - }); - } catch { - // Invalid cache, regenerate - } - } + const decision = await briefingFromCache(ctx, forceRefresh); + if (decision.kind === "serve") return apiSuccess(decision.data); - // 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: 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. + // The rate limit sits BELOW the cache return so a hit on the 24 h cache + // never burns one of the generation tokens; otherwise a noisy reload + // loop on the dashboard would lock out real refreshes for an hour. const limit = resolveInsightsRateLimit(); const rl = await checkRateLimit( `insights:${userId}`, @@ -519,696 +202,32 @@ export const POST = apiHandler((request: NextRequest) => 60 * 60 * 1000, ); if (!rl.allowed) { - // v1.28.30 — a POST-as-read caller whose fresh-but-briefingless cache - // fell through to regeneration degrades to the cached payload when - // the hourly quota is exhausted, instead of surfacing a 429 it never - // used to see. Explicit `force` keeps the honest 429. - if (briefinglessCacheFallback !== null) { - annotate({ - action: { name: "insights.generate" }, - meta: { cached: true, rate_limited_fallback: true }, - }); - return apiSuccess({ - insights: await insightsPayloadForToday( - briefinglessCacheFallback.cached, - { userId, timezone: postTimezone, language: locale }, - ), - cached: true, - cachedAt: briefinglessCacheFallback.cachedAt, - legacyPayload: briefinglessCacheFallback.legacyPayload, - }); + // A POST-as-read caller whose fresh-but-briefingless cache fell + // through to regeneration degrades to the cached payload when the + // hourly quota is exhausted, instead of surfacing a 429 it never used + // to see. Explicit `force` keeps the honest 429. + if (decision.fallback !== null) { + return apiSuccess(await serveCachedFallback(ctx, decision.fallback)); } return apiError(`Maximum ${limit} insight generations per hour.`, 429); } - // v1.4.16: 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, - { errorCode: AI_PROVIDER_NONE_ERROR_CODE }, - ); - } - 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 — `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 — 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 — 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 — 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; - } - } - // v1.4.36 — 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); - // The reader's units, after all canonical arithmetic: the prompt, the - // stored hash and the grounding check read the same converted figures. - const units = resolveUnitPreferences({ - unitPreference: dbUser?.unitPreference, - glucoseUnit: dbUser?.glucoseUnit, - }); - features = featuresInReaderUnits(features, units); - // v1.4.36 — 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, - ); - // Compact on the wire: indentation is about 40% of a pretty-printed - // payload, and the model reads the structure without it (#1217). - const featuresJson = JSON.stringify(compactFeatures); - if (payloadDowngraded) { - annotate({ - meta: { - insights_features_downgraded: true, - insights_features_oversize_bytes: payloadOversizeBytes, - insights_features_hard_downgraded: payloadHardDowngraded, - }, - }); - } - - // v1.4.16 — 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 — 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, - units.system, - ); - } - // 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 = toProfileSex(dbUser?.gender); - 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 — 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.22 — opener-archetype rotation + sparse first-name personalization - // (deterministic per user+day; omitted for unnamed accounts). - userPrompt += buildBriefingPersonalisationBlock( - userId, - dbUser?.displayName ?? null, - locale, - await resolveUserTimezone(userId), - ); - // v1.43 (#1217) — the short form on the `frugal` usage level; the full - // contract on every other level. - const briefingForm = await briefingFormForRecord(userId); - userPrompt += buildBriefingShortFormBlock(locale, briefingForm); - - // v1.12.7 — 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 runBriefingCompletion({ - userId, - chain, - // 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, - // v1.28.28 (#470) — env-tunable output ceiling (INSIGHTS_MAX_TOKENS, - // default 2500). The old fixed 1500 truncated verbose models' - // briefing JSON mid-string → generic invalid-JSON 422. - maxTokens: resolveInsightsMaxTokens(), - // v1.21.5 — wider upstream budget so the reasoning-heavy briefing - // generation is not aborted at the client's 60 s default on large - // accounts (which returned an empty briefing + trend narrative). - // v1.25 — honours the per-user response-timeout setting. - timeoutMs: effectiveTimeoutMs, - stage: "generate", - // v1.41 — a briefing asked for by hand reasons exactly like the - // scheduled one; the retries below repair a reply and do not. - reasoningJob: "daily_briefing", - }); - result = fallback.result; - workingProviderType = fallback.workingProvider.providerType; - fallbackHopCount = fallback.fallbackHops.length; - } catch (e) { - // The day's token ceiling refused the reservation — no provider was - // contacted, so this is NOT a provider failure and must not record a - // briefing-failure marker (which would make the read path claim the - // refresh broke). The hourly rate limit bounds request frequency; this - // bounds cost. 429 with a distinct code so the client can say so. - if (e instanceof BriefingBudgetExceededError) { - return apiError( - "Daily AI token budget reached. Insights will be available again tomorrow.", - 429, - { errorCode: "insights.generate.budgetExceeded" }, - ); - } - // v1.25 — record a dated failure marker (no cache row is written on this - // path, so the last good briefing stays intact). The read path pairs the - // preserved text with a discreet "couldn't refresh" hint, or shows a - // retry empty state when there is no last good text. - // v1.25.3 — carry the upstream status so the read-path hint can tell an - // auth / rate-limit failure apart from a plain timeout. For an - // all-providers-failed error the first hop's status is the most - // representative; a single provider error exposes its own `httpStatus`. - const failureHttpStatus = - e instanceof AllProvidersFailedError - ? (e.attempts[0]?.httpStatus ?? null) - : typeof (e as { httpStatus?: number }).httpStatus === "number" - ? (e as { httpStatus?: number }).httpStatus - : null; - void recordBriefingFailure({ - userId, - reason: - e instanceof AllProvidersFailedError - ? "all-providers-failed" - : "provider-error", - locale, - httpStatus: failureHttpStatus, - failureKind: - e instanceof AllProvidersFailedError - ? (e.attempts[0]?.failureKind ?? null) - : providerFailureKind(e).failure, - }); - 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, - ); - } - // 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", - 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, - ); - } + // The chain, with the consent gate on it (403 `consent.ai.required` + // before any server-managed external egress). + const chain = await resolveConsentedBriefingChain(userId); + if (chain === null) { return apiError( - "AI provider connection failed. Check your AI settings", + "No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.", 422, + { errorCode: AI_PROVIDER_NONE_ERROR_CODE }, ); } - 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. - void recordBriefingFailure({ userId, reason: "invalid-json", locale }); - // v1.28.28 (#470) — truncation-aware: `finishReason === "length"` means - // the model hit the output-token ceiling and the JSON was cut off - // mid-string, which is a budget problem, not a model-quality one. Say - // so with a distinct errorCode instead of the generic invalid-JSON line. - if (result.finishReason === "length") { - annotate({ meta: { insights_response_truncated: true } }); - return apiError( - "AI response was cut off before the JSON completed. Raise the token limit (INSIGHTS_MAX_TOKENS)", - 422, - { errorCode: "ai_response_truncated" }, - ); - } - return apiError("AI response was not valid JSON", 422); - } - - // v1.18.10 — 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. - // - // v1.28.28 (#470) — when the gate strips the briefing, SAY SO: the 200 - // used to carry a silently-null briefing, the card showed "no briefing - // yet", and the regenerate button read as doing nothing. The additive - // `briefingOmittedReason` field lets the card render an honest "a figure - // couldn't be verified, so the briefing wasn't shown" state instead. - let briefingOmittedReason: "ungrounded" | null = null; - { - const signals = features.signalsOfDay ?? null; - let ungrounded = findUngroundedBriefingNumbers( - readBriefingBlock(insights), - signals, - features, - comparisonSnapshot, - ); - if (ungrounded.length > 0) { - annotate({ - action: { name: "insights.generate.briefing_grounding_retry" }, - meta: { ungroundedCount: ungrounded.length }, - }); - try { - const retry = await runBriefingCompletion({ - userId, - chain, - systemPrompt: buildSystemPromptWithReferences( - locale, - referenceMetrics, - ), - userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, - temperature: 0.3, - // v1.28.28 (#470) — same env-tunable ceiling as the first call. - maxTokens: resolveInsightsMaxTokens(), - // v1.21.5 — wider upstream budget; see the first generation call. - // v1.25 — honours the per-user response-timeout setting. - timeoutMs: effectiveTimeoutMs, - stage: "grounding-retry", - }); - const parsedRetry = JSON.parse(retry.result.content); - const validatedRetry = insightResultSchema.safeParse(parsedRetry); - const retryInsights = validatedRetry.success - ? validatedRetry.data - : parsedRetry; - ungrounded = findUngroundedBriefingNumbers( - readBriefingBlock(retryInsights), - signals, - features, - comparisonSnapshot, - ); - if (ungrounded.length === 0) { - insights = retryInsights; - result = retry.result; - workingProviderType = retry.workingProvider.providerType; - } - } catch { - // Retry failed to produce parseable JSON, or the day's ceiling - // refused the correction pass — either way fall through to the - // strip. An ungrounded figure is never persisted. - } - } - // Still ungrounded after the retry: the freshly generated briefing is - // discarded rather than shipped — the anti-fabrication guarantee is - // absolute here too. But rather than leave a hole, stand the PREVIOUS - // cached briefing in its place: it passed this identical gate when it - // was written, so it carries no fabricated figure, and a day-old - // grounded briefing beats a vanished one. This mirrors the background - // generator, which is the path that writes the same cached payload; - // the two must not disagree on disposal. - // - // `briefingOmittedReason` is set ONLY when no previous briefing exists - // to stand in, so the card's honest "a figure couldn't be verified" - // state still fires exactly when the user really has no briefing. - if (ungrounded.length > 0 && insights && typeof insights === "object") { - let fallbackBriefing: unknown = null; - if (cachedText) { - try { - const prev = JSON.parse(cachedText) as Record; - fallbackBriefing = prev.dailyBriefing ?? null; - } catch { - // Unparseable previous payload — nothing safe to stand in. - } - } - (insights as Record).dailyBriefing = fallbackBriefing; - briefingOmittedReason = fallbackBriefing == null ? "ungrounded" : null; - annotate({ - action: { name: "insights.generate.briefing_grounding_stripped" }, - meta: { - ungroundedCount: ungrounded.length, - briefing_fallback: - fallbackBriefing != null ? "previous-cached" : "stripped", - }, - }); - } - } - - // Outbound safety screen over the whole prose surface, before the persist. - // - // This route is user-initiated but writes the SAME cached payload the - // background generator writes, so a violation must not be persisted here - // either. The user is waiting, so unlike the background path they get an - // explicit error rather than silence -- and because nothing was written, - // the previous cached insight is still what the app renders. - const screened = screenInsightPayloadProse(insights, locale); - if (screened) { - annotate({ - action: { name: "insights.generate.outbound_blocked" }, - meta: { locale, reason: screened }, - }); - return apiError( - "The generated insight did not pass the safety check and was discarded. Try regenerating.", - 502, - { errorCode: "insights.generate.outboundScreened" }, - ); - } - - const currentSelfContext = await getSelfContextTextForUser(userId, locale); - if (currentSelfContext !== aboutMe) { - return apiError("Profile scope changed during insight generation", 409, { - errorCode: "insights.generate.profileScopeChanged", - }); - } - if (briefingForm === "short") { - insights = applyBriefingShortForm(insights); - } - const stored = withBriefingGeneratedAt(insights, new Date()); - const committed = await prisma.user.updateMany({ - where: { - id: userId, - insightsPrivacyMode: modeAtRead, - insightsExcludeMetrics: { equals: excludeMetricsAtRead }, - locale: localeAtRead, - dateOfBirth: dateOfBirthAtRead, - gender: genderAtRead, - heightCm: heightCmAtRead, - displayName: displayNameAtRead, - }, - data: { - insightsCachedAt: new Date(), - ...insightsCacheWrite(JSON.stringify(stored)), - insightsCachedLocale: locale, - insightsSnapshotHash: hashInsightSnapshot({ - features: compactFeatures, - aboutMe: aboutMe ?? null, - comparisonBaseline: comparisonSnapshot?.baseline ?? "none", - generationLocale: locale, - ...(briefingForm === "short" ? { briefingForm } : {}), - }), - }, - }); - if (committed.count === 0) { - return apiError("Insight scope changed during generation", 409, { - errorCode: "insights.generate.scopeChanged", - }); - } - - // v1.28.30 — persist the grounding omission (marker AFTER the cache - // write so it is newer than `insightsCachedAt`). The transient - // `briefingOmittedReason` on this response only reached the caller that - // forced the regenerate; every later read showed a generic "no briefing - // yet". The dated marker lets the GET surface the same honest - // "briefing withheld" state until the next successful generation. - if (briefingOmittedReason === "ungrounded") { - void recordBriefingFailure({ - userId, - reason: "briefing-ungrounded", - 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, - normalizeLocale(locale), - ); - annotate({ - action: { name: "insights.generate.cards_refill" }, - meta: { scopes: refillScopes }, - }); - - // v1.7.0 — 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, + const outcome = await generateBriefing({ + ctx, + chain, 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, - }, - }); - - return apiSuccess({ - insights: await insightsPayloadForToday(stored, { - userId, - timezone: postTimezone, - language: locale, - }), - cached: false, - legacyPayload: false, - // v1.28.28 (#470) — additive: non-null when the grounding gate stripped - // the briefing from THIS generation, so the card can explain the hole. - briefingOmittedReason, }); + return outcome.ok ? apiSuccess(outcome.data) : refuse(outcome.refusal); }), ); diff --git a/src/lib/insights/briefing-request/cache.ts b/src/lib/insights/briefing-request/cache.ts new file mode 100644 index 000000000..2673bf402 --- /dev/null +++ b/src/lib/insights/briefing-request/cache.ts @@ -0,0 +1,133 @@ +/** + * The generating POST's cache short-circuit, and the cached payload it + * degrades to when the hour's generation quota is spent. + * + * A fresh cache WITHOUT a briefing must not satisfy a briefing-expecting + * caller: after a failed nightly warm the cached payload can sit inside the + * 24 h window all day, and an unconditional short-circuit served it to every + * POST (three regenerate attempts, three `cached: true` responses, zero + * generations). The short-circuit holds only when the cached payload carries + * today's briefing OR the account has no usable provider (regenerating would + * be futile — serve what exists). When the briefingless fall-through is + * rate-limited, the cached payload is served after all, so a POST-as-read + * client degrades to the old behaviour instead of a 429. + */ +import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload"; +import { + generatedOnLocalToday, + insightsPayloadForToday, +} from "@/lib/daily/briefing-today-read"; +import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; +import { cachedPayloadCarriesBriefing } from "@/lib/insights/briefing-payload"; +import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; +import { annotate } from "@/lib/logging/context"; + +import type { GenerationContext } from "./context"; + +/** A fresh cached payload kept aside for the quota fallback. */ +export interface CachedFallback { + cached: unknown; + legacyPayload: boolean; + cachedAt: Date; +} + +export type CacheDecision = + | { kind: "serve"; data: Record } + | { kind: "generate"; fallback: CachedFallback | null }; + +export async function briefingFromCache( + ctx: GenerationContext, + forceRefresh: boolean, +): Promise { + const { dbUser, cachedText, userId, locale } = ctx; + // A cache tagged with another language is not this reader's cache and + // never short-circuits; the generation writes the reader's own. + const cachedLocaleMatches = + dbUser?.insightsCachedLocale == null || + dbUser.insightsCachedLocale === locale; + if ( + !forceRefresh && + dbUser?.insightsCachedAt && + cachedText && + cachedLocaleMatches && + Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000 + ) { + try { + const cached = JSON.parse(cachedText); + // Legacy payloads (pre-rationale) are flagged so the UI can show a + // "regenerate for new explainability features" CTA. No automatic + // regeneration: that would burn rate-limit tokens on a cache hit + // silently. User-initiated only. + const legacyPayload = isLegacyInsightPayload(cached); + // A briefing generated on an earlier day is not today's read: like a + // briefingless cache, it does not short-circuit a provider-backed + // account, and degrades to the cached payload on a 429. + const carriesBriefing = + cachedPayloadCarriesBriefing(cachedText) && + generatedOnLocalToday( + readBriefingGeneratedAt(cachedText), + ctx.timezone, + new Date(), + ); + if (carriesBriefing || !(await hasUsableStatusProvider(userId))) { + annotate({ + action: { name: "insights.generate" }, + meta: { + cached: true, + legacyPayload, + briefingless: !carriesBriefing, + }, + }); + return { + kind: "serve", + data: { + insights: await insightsPayloadForToday(cached, { + userId, + timezone: ctx.timezone, + language: locale, + }), + cached: true, + cachedAt: dbUser.insightsCachedAt, + legacyPayload, + }, + }; + } + annotate({ + action: { name: "insights.generate.briefingless_cache_bypassed" }, + meta: { locale }, + }); + return { + kind: "generate", + fallback: { + cached, + legacyPayload, + cachedAt: dbUser.insightsCachedAt, + }, + }; + } catch { + // Invalid cache, regenerate + } + } + return { kind: "generate", fallback: null }; +} + +/** The cached payload, served when the hour's quota refused a generation. */ +export async function serveCachedFallback( + ctx: GenerationContext, + fallback: CachedFallback, +): Promise> { + annotate({ + action: { name: "insights.generate" }, + meta: { cached: true, rate_limited_fallback: true }, + }); + return { + insights: await insightsPayloadForToday(fallback.cached, { + userId: ctx.userId, + timezone: ctx.timezone, + language: ctx.locale, + }), + cached: true, + cachedAt: fallback.cachedAt, + legacyPayload: fallback.legacyPayload, + }; +} diff --git a/src/lib/insights/briefing-request/commit.ts b/src/lib/insights/briefing-request/commit.ts new file mode 100644 index 000000000..44141b255 --- /dev/null +++ b/src/lib/insights/briefing-request/commit.ts @@ -0,0 +1,202 @@ +/** + * The on-demand briefing's commit: the outbound safety screen, the two scope + * guards, the cache write, and everything that follows a written briefing. + * + * Contract with the client (`AdvisorSettleState` in + * `src/components/insights/use-insights-advisor.ts`, refs #786): on success + * this writes the fresh briefing to the user cache (`insightsCachedAt` + * advances) — even when the client has long since aborted its 45 s request — + * and a failed inline generation leaves a failure marker the read reports as + * `generationFailed`. The client settles a timed-out regenerate on exactly + * those two signals, so renaming or short-circuiting either write breaks + * that convergence. + */ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me"; +import { screenInsightPayloadProse } from "@/lib/ai/safety/insight-payload-screen"; +import { invalidateUserInsights } from "@/lib/cache/invalidate"; +import { insightsPayloadForToday } from "@/lib/daily/briefing-today-read"; +import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker"; +import { withBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; +import { applyBriefingShortForm } from "@/lib/insights/briefing-short-form"; +import { insightsCacheWrite } from "@/lib/insights/cached-text-cipher"; +import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { normalizeLocale } from "@/lib/insights/status-shared"; +import { annotate } from "@/lib/logging/context"; + +import type { BriefingCall } from "./completion"; +import type { BriefingRefusal } from "./context"; +import type { PreparedFeatures } from "./features"; +import type { GroundedReply } from "./grounding"; + +export type CommitOutcome = + | { ok: true; data: Record } + | { ok: false; refusal: BriefingRefusal }; + +/** Write the briefing, guarded on the scope the prompt was built from. */ +async function writeBriefing( + call: BriefingCall, + prepared: PreparedFeatures, + stored: object, +): Promise { + const { ctx, prompt } = call; + const { scope, locale } = ctx; + const committed = await prisma.user.updateMany({ + where: { + id: ctx.userId, + insightsPrivacyMode: scope.mode, + insightsExcludeMetrics: { equals: scope.excludeMetrics }, + locale: scope.locale, + dateOfBirth: scope.dateOfBirth, + gender: scope.gender, + heightCm: scope.heightCm, + displayName: scope.displayName, + }, + data: { + insightsCachedAt: new Date(), + ...insightsCacheWrite(JSON.stringify(stored)), + insightsCachedLocale: locale, + insightsSnapshotHash: hashInsightSnapshot({ + features: prepared.compactFeatures, + aboutMe: prompt.aboutMe ?? null, + comparisonBaseline: prompt.comparisonSnapshot?.baseline ?? "none", + generationLocale: locale, + ...(prompt.briefingForm === "short" + ? { briefingForm: prompt.briefingForm } + : {}), + }), + }, + }); + return committed.count > 0; +} + +/** What follows a written briefing: the marker, the cards, the audit row. */ +async function afterWrite( + call: BriefingCall, + reply: GroundedReply, + ipAddress: string | null, + fallbackHopCount: number, +): Promise { + const { ctx } = call; + const { userId, locale } = ctx; + const { result, workingProviderType } = reply; + // The grounding omission persists (marker AFTER the cache write so it is + // newer than `insightsCachedAt`). The transient `briefingOmittedReason` on + // the response only reached the caller that forced the regenerate; every + // later read showed a generic "no briefing yet". The dated marker lets the + // read surface the same honest "briefing withheld" state until the next + // successful generation. + if (reply.briefingOmittedReason === "ungrounded") { + void recordBriefingFailure({ + userId, + reason: "briefing-ungrounded", + locale, + }); + } + + // No blanket per-status eviction (nuking ~45 cache rows per manual + // regenerate deleted every hash baseline and caused a card-regeneration + // storm). 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, + normalizeLocale(locale), + ); + annotate({ + action: { name: "insights.generate.cards_refill" }, + meta: { scopes: refillScopes }, + }); + + // 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, + details: { + privacyMode: ctx.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, + }, + }); +} + +export async function commitBriefing( + call: BriefingCall, + prepared: PreparedFeatures, + reply: GroundedReply, + options: { ipAddress: string | null; fallbackHopCount: number }, +): Promise { + const { ctx, prompt } = call; + const { userId, locale } = ctx; + let { insights } = reply; + + // Outbound safety screen over the whole prose surface, before the + // persist. This path is user-initiated but writes the SAME cached payload + // the background generator writes, so a violation must not be persisted + // here either. The user is waiting, so unlike the background path they get + // an explicit error rather than silence — and because nothing was written, + // the previous cached insight is still what the app renders. + const screened = screenInsightPayloadProse(insights, locale); + if (screened) { + annotate({ + action: { name: "insights.generate.outbound_blocked" }, + meta: { locale, reason: screened }, + }); + return { ok: false, refusal: "outbound-screened" }; + } + + const currentSelfContext = await getSelfContextTextForUser(userId, locale); + if (currentSelfContext !== prompt.aboutMe) { + return { ok: false, refusal: "profile-scope-changed" }; + } + if (prompt.briefingForm === "short") { + insights = applyBriefingShortForm(insights); + } + const stored = withBriefingGeneratedAt(insights, new Date()); + if (!(await writeBriefing(call, prepared, stored))) { + return { ok: false, refusal: "scope-changed" }; + } + + await afterWrite(call, reply, options.ipAddress, options.fallbackHopCount); + + return { + ok: true, + data: { + insights: await insightsPayloadForToday(stored, { + userId, + timezone: ctx.timezone, + language: locale, + }), + cached: false, + legacyPayload: false, + // Additive: non-null when the grounding gate stripped the briefing + // from THIS generation, so the card can explain the hole (#470). + briefingOmittedReason: reply.briefingOmittedReason, + }, + }; +} diff --git a/src/lib/insights/briefing-request/completion.ts b/src/lib/insights/briefing-request/completion.ts new file mode 100644 index 000000000..a63f86804 --- /dev/null +++ b/src/lib/insights/briefing-request/completion.ts @@ -0,0 +1,192 @@ +/** + * The on-demand briefing's provider calls, and what a failed call means for + * the person waiting on it. + */ +import { resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets"; +import { providerFailureKind } from "@/lib/ai/failure-kind"; +import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator"; +import { + AllProvidersFailedError, + type ProviderChainResolved, + type RunRawWithFallbackResult, +} from "@/lib/ai/provider-runner"; +import { + BriefingBudgetExceededError, + runBriefingCompletion, +} from "@/lib/insights/briefing-provider"; +import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker"; +import { annotate } from "@/lib/logging/context"; + +import type { BriefingRefusal, GenerationContext } from "./context"; +import type { BriefingPrompt } from "./prompt"; + +export interface BriefingCall { + ctx: GenerationContext; + chain: ProviderChainResolved[]; + prompt: BriefingPrompt; +} + +/** One briefing completion with the route's fixed parameters. */ +export function runBriefingCall( + call: BriefingCall, + options: { + userPrompt: string; + stage: "generate" | "grounding-retry"; + reasoning: boolean; + }, +): Promise { + const { ctx, chain, prompt } = call; + return runBriefingCompletion({ + userId: ctx.userId, + chain, + // The strict prompt 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 old `{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. The reply tolerates the strict shape via + // `insightResultSchema.safeParse`'s soft fallback to the parsed object. + systemPrompt: buildSystemPromptWithReferences( + ctx.locale, + prompt.referenceMetrics, + ), + userPrompt: options.userPrompt, + temperature: 0.3, + // Env-tunable output ceiling (INSIGHTS_MAX_TOKENS, default 2500, #470). + // A fixed 1500 truncated verbose models' briefing JSON mid-string → + // generic invalid-JSON 422. + maxTokens: resolveInsightsMaxTokens(), + // A wide upstream budget, honouring the per-user response-timeout + // setting, so the reasoning-heavy briefing generation is not aborted at + // the client's 60 s default on large accounts. + timeoutMs: ctx.effectiveTimeoutMs, + stage: options.stage, + // A briefing asked for by hand reasons exactly like the scheduled one; + // the retries repair a reply and do not. + ...(options.reasoning ? { reasoningJob: "daily_briefing" as const } : {}), + }); +} + +export type FirstCompletion = + | { + ok: true; + result: RunRawWithFallbackResult["result"]; + workingProviderType: string; + fallbackHopCount: number; + } + | { ok: false; refusal: BriefingRefusal }; + +/** The refusal for a chain on which every provider failed. */ +function chainRefusal(e: AllProvidersFailedError): BriefingRefusal { + 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, + }, + }); + // The most specific user-facing message available. Auth-class failures + // across the entire chain mean every provider's credential is bad — the + // "check your API key" wording tells the user 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". + if (e.attempts.every((a) => a.httpStatus === 401 || a.httpStatus === 403)) { + return "provider-rejected"; + } + if (e.attempts.every((a) => a.httpStatus === 429)) { + return "provider-rate-limited"; + } + if (e.attempts.some((a) => a.httpStatus !== null && a.httpStatus >= 500)) { + return "provider-unavailable"; + } + if (e.attempts.every((a) => a.httpStatus === null || a.httpStatus <= 0)) { + return "provider-connection"; + } + return "provider-unavailable"; +} + +/** + * The refusal for a single provider error that did not cascade (e.g. a + * custom 4xx from a self-hosted local model). Cloudflare rewrites 5xx to its + * own HTML error page, which breaks `await res.json()` on the dashboard, so + * the statuses map onto the ai/test categorisation. + */ +function providerRefusal(e: unknown): BriefingRefusal { + 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 "provider-rejected"; + if (status === 429) return "provider-rate-limited"; + if (status >= 500) return "provider-unavailable"; + return "provider-connection"; +} + +export async function runFirstCompletion( + call: BriefingCall, +): Promise { + try { + const fallback = await runBriefingCall(call, { + userPrompt: call.prompt.userPrompt, + stage: "generate", + reasoning: true, + }); + return { + ok: true, + result: fallback.result, + workingProviderType: fallback.workingProvider.providerType, + fallbackHopCount: fallback.fallbackHops.length, + }; + } catch (e) { + // The day's token ceiling refused the reservation — no provider was + // contacted, so this is NOT a provider failure and must not record a + // briefing-failure marker (which would make the read path claim the + // refresh broke). The hourly rate limit bounds request frequency; this + // bounds cost. + if (e instanceof BriefingBudgetExceededError) { + return { ok: false, refusal: "budget-exceeded" }; + } + // A dated failure marker (no cache row is written on this path, so the + // last good briefing stays intact). The read path pairs the preserved + // text with a discreet "couldn't refresh" hint, or shows a retry empty + // state when there is no last good text. The upstream status rides + // along so the hint can tell an auth / rate-limit failure apart from a + // plain timeout: for an all-providers-failed error the first hop's + // status is the most representative; a single provider error exposes its + // own `httpStatus`. + const failureHttpStatus = + e instanceof AllProvidersFailedError + ? (e.attempts[0]?.httpStatus ?? null) + : typeof (e as { httpStatus?: number }).httpStatus === "number" + ? (e as { httpStatus?: number }).httpStatus + : null; + void recordBriefingFailure({ + userId: call.ctx.userId, + reason: + e instanceof AllProvidersFailedError + ? "all-providers-failed" + : "provider-error", + locale: call.ctx.locale, + httpStatus: failureHttpStatus, + failureKind: + e instanceof AllProvidersFailedError + ? (e.attempts[0]?.failureKind ?? null) + : providerFailureKind(e).failure, + }); + return { + ok: false, + refusal: + e instanceof AllProvidersFailedError + ? chainRefusal(e) + : providerRefusal(e), + }; + } +} diff --git a/src/lib/insights/briefing-request/context.ts b/src/lib/insights/briefing-request/context.ts new file mode 100644 index 000000000..7c488fa3c --- /dev/null +++ b/src/lib/insights/briefing-request/context.ts @@ -0,0 +1,137 @@ +/** + * What an on-demand briefing generation reads before it decides anything: + * the account row, the cached payload, the scope columns the commit guard + * matches on, the response timeout, the reader's language and zone. + */ +import type { NextRequest } from "next/server"; + +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; +import type { Locale } from "@/lib/i18n/config"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { + INSIGHTS_CACHE_SELECT, + openInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +export const GENERATION_USER_SELECT = { + insightsPrivacyMode: true, + insightsCachedAt: true, + ...INSIGHTS_CACHE_SELECT, + insightsCachedLocale: true, + timezone: true, + // The per-user opt-out list mirroring the Coach settings. Filtered off + // `features` before serialisation so the model never sees the excluded + // blocks. + insightsExcludeMetrics: true, + locale: true, + // The profile for the derived-signal briefing detector. + dateOfBirth: true, + gender: true, + heightCm: true, + // The first name for the sparse, hash-gated briefing opener. + displayName: true, + // The per-user response timeout (seconds), threaded onto the generation + // calls so a slow self-hosted backend honours the setting. + aiResponseTimeoutSeconds: true, + // The reader's units; the feature set is converted into them before it is + // serialised, hashed and checked for grounding. + unitPreference: true, + glucoseUnit: true, +} as const; + +export type GenerationUserRow = Prisma.UserGetPayload<{ + select: typeof GENERATION_USER_SELECT; +}>; + +/** + * The columns the commit guard matches on, captured at read time. + * + * Every User write bumps `updatedAt`, including `resolveProviderChain()`'s + * OAuth token-refresh update later in the same request, so keying the + * optimistic-concurrency check on it produced a false 409 whenever a + * provider credential happened to refresh mid-generation. These are exactly + * the selected columns that feed the prompt; requiring an exact match at + * commit still catches a genuine privacy-mode / exclude-list / locale / + * profile change without colliding on unrelated column writes. + */ +export interface GenerationScope { + mode: GenerationUserRow["insightsPrivacyMode"]; + excludeMetrics: GenerationUserRow["insightsExcludeMetrics"]; + locale: GenerationUserRow["locale"]; + dateOfBirth: GenerationUserRow["dateOfBirth"]; + gender: GenerationUserRow["gender"]; + heightCm: GenerationUserRow["heightCm"]; + displayName: GenerationUserRow["displayName"]; +} + +export interface GenerationContext { + userId: string; + dbUser: GenerationUserRow | null; + cachedText: string | null; + scope: GenerationScope; + effectiveTimeoutMs: number; + locale: Locale; + timezone: string; +} + +/** + * Why a generation ended without a briefing. The route owns the status and + * the wording for each. + */ +export type BriefingRefusal = + | "payload-too-large" + | "budget-exceeded" + | "provider-rejected" + | "provider-rate-limited" + | "provider-unavailable" + | "provider-connection" + | "response-truncated" + | "response-invalid-json" + | "outbound-screened" + | "profile-scope-changed" + | "scope-changed"; + +export async function loadGenerationContext(args: { + request: NextRequest; + userId: string; + userLocale: string | null; +}): Promise { + const { request, userId } = args; + const dbUser = await prisma.user.findUnique({ + where: { id: userId }, + select: GENERATION_USER_SELECT, + }); + const cachedText = openInsightsCachedText(dbUser); + const scope: GenerationScope = { + mode: dbUser?.insightsPrivacyMode ?? "aggregated", + excludeMetrics: dbUser?.insightsExcludeMetrics ?? [], + locale: dbUser?.locale ?? null, + dateOfBirth: dbUser?.dateOfBirth ?? null, + gender: dbUser?.gender ?? null, + heightCm: dbUser?.heightCm ?? null, + displayName: dbUser?.displayName ?? null, + }; + + const effectiveTimeoutMs = resolveEffectiveTimeoutMs( + dbUser?.aiResponseTimeoutSeconds, + AI_BUDGETS.comprehensive.timeoutMs, + ); + + const locale = await resolveServerLocale({ + request, + userLocale: dbUser?.locale ?? args.userLocale ?? null, + }); + return { + userId, + dbUser, + cachedText, + scope, + effectiveTimeoutMs, + locale, + timezone: dbUser?.timezone ?? DEFAULT_TIMEZONE, + }; +} diff --git a/src/lib/insights/briefing-request/features.ts b/src/lib/insights/briefing-request/features.ts new file mode 100644 index 000000000..d8e5be35f --- /dev/null +++ b/src/lib/insights/briefing-request/features.ts @@ -0,0 +1,150 @@ +/** + * The feature set an on-demand briefing is written from: the bounded read, + * its downgrade ladder for an oversized payload, the user's exclusions, the + * reader's units, and the compact wire form. + */ +import { compactSections } from "@/lib/ai/prompts/compact-sections"; +import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; +import { getCachedFeatures } from "@/lib/insights/feature-cache"; +import { + BRIEFING_FEATURE_WINDOW_DAYS, + extractFeatures, + FeaturesPayloadTooLargeError, +} from "@/lib/insights/features"; +import { featuresInReaderUnits } from "@/lib/insights/features-units"; +import { annotate } from "@/lib/logging/context"; +import { resolveUnitPreferences } from "@/lib/measurements/display-transform"; + +import type { GenerationContext } from "./context"; + +export type BriefingFeatures = Awaited>; + +/** + * The blocks dropped when even the aggregated shape crosses the ceiling + * (very large medication history + multi-year context), before one last try. + */ +const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ + "anthropometrics", + "medications", + "compliance", + "sleep", + "steps", + "hrv", + "resting_hr", +]; + +interface Downgrade { + payloadDowngraded: boolean; + payloadHardDowngraded: boolean; + payloadOversizeBytes: number | null; +} + +/** + * The bounded feature read, computed once per request through the scope the + * route opened. `extractFeatures` enforces a 5 MB ceiling on the serialised + * payload and throws `FeaturesPayloadTooLargeError` past it; the raw + * (bucketed) shape downgrades to the aggregated one rather than 500-ing, so + * the user still gets an insight, and the annotation surfaces the + * regression to ops. Null when every rung is still too large. + */ +async function readWithDowngrade( + userId: string, + includeRaw: boolean, + downgrade: Downgrade, +): Promise { + // The bulk read is bounded to a recent window (all-time extremes are + // sourced separately, see features.ts); the ladder is the safety net for + // the rare oversize payload. + const featureWindow = { sinceDays: BRIEFING_FEATURE_WINDOW_DAYS }; + const read = (raw: boolean) => + getCachedFeatures({ + userId, + includeRaw: raw, + sinceDays: featureWindow.sinceDays, + compute: () => extractFeatures(userId, raw, featureWindow), + }); + try { + return await read(includeRaw); + } catch (err) { + if (!(err instanceof FeaturesPayloadTooLargeError)) throw err; + downgrade.payloadDowngraded = true; + downgrade.payloadOversizeBytes = err.sizeBytes; + } + try { + return await read(false); + } catch (retryErr) { + if (!(retryErr instanceof FeaturesPayloadTooLargeError)) throw retryErr; + // The aggregated shape ALSO crossed the ceiling. Drop the optional + // context blocks via the exclude filter and try once more. + downgrade.payloadHardDowngraded = true; + downgrade.payloadOversizeBytes = retryErr.sizeBytes; + } + try { + return applyInsightsExcludeFilter(await read(false), MAX_DOWNGRADE_TOKENS); + } catch (finalErr) { + // Even the last read blew up. Annotate and refuse rather than let the + // uncaught throw 500 out. + annotate({ + meta: { + insights_payload_too_large: true, + insights_payload_oversize_bytes: + finalErr instanceof FeaturesPayloadTooLargeError + ? finalErr.sizeBytes + : downgrade.payloadOversizeBytes, + }, + }); + return null; + } +} + +export interface PreparedFeatures { + features: BriefingFeatures; + compactFeatures: Record; + featuresJson: string; + units: ReturnType; +} + +export async function prepareBriefingFeatures( + ctx: GenerationContext, +): Promise { + const { dbUser, userId } = ctx; + const includeRaw = dbUser?.insightsPrivacyMode === "raw"; + const downgrade: Downgrade = { + payloadDowngraded: false, + payloadHardDowngraded: false, + payloadOversizeBytes: null, + }; + let features = await readWithDowngrade(userId, includeRaw, downgrade); + if (features === null) return null; + // The user's exclude-metrics opt-out list, applied BEFORE serialisation so + // the model never sees the dropped blocks. + const excludeList = dbUser?.insightsExcludeMetrics ?? []; + features = applyInsightsExcludeFilter(features, excludeList); + // The reader's units, after all canonical arithmetic: the prompt, the + // stored hash and the grounding check read the same converted figures. + const units = resolveUnitPreferences({ + unitPreference: dbUser?.unitPreference, + glucoseUnit: dbUser?.glucoseUnit, + }); + features = featuresInReaderUnits(features, units); + // Zero-row blocks are dropped so the prompt never carries labelled-empty + // sections (`"sleep": []`, `"medications": []`, etc.), which made the + // model narrate "there are no medications in your data" when the user + // explicitly excluded the block. + const compactFeatures = compactSections( + features as unknown as Record, + ); + // Compact on the wire: indentation is about 40% of a pretty-printed + // payload, and the model reads the structure without it (#1217). + const featuresJson = JSON.stringify(compactFeatures); + if (downgrade.payloadDowngraded) { + annotate({ + meta: { + insights_features_downgraded: true, + insights_features_oversize_bytes: downgrade.payloadOversizeBytes, + insights_features_hard_downgraded: downgrade.payloadHardDowngraded, + }, + }); + } + return { features, compactFeatures, featuresJson, units }; +} diff --git a/src/lib/insights/briefing-request/generate.ts b/src/lib/insights/briefing-request/generate.ts new file mode 100644 index 000000000..4ab6029be --- /dev/null +++ b/src/lib/insights/briefing-request/generate.ts @@ -0,0 +1,96 @@ +/** + * An on-demand briefing generation, from the resolved provider chain to the + * committed payload. The route owns authentication, the capability gate, + * the body, the hourly quota and the status of every refusal. + */ +import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; +import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; +import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker"; +import { annotate } from "@/lib/logging/context"; + +import { commitBriefing } from "./commit"; +import { runFirstCompletion, type BriefingCall } from "./completion"; +import type { BriefingRefusal, GenerationContext } from "./context"; +import { prepareBriefingFeatures } from "./features"; +import { groundBriefing, parseBriefingReply } from "./grounding"; +import { buildBriefingPrompt } from "./prompt"; + +/** + * 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 to the dashboard. An empty chain falls back to the + * single-provider resolution when the legacy `aiProvider` field still points + * at something usable, so accounts that never customised the chain keep + * working. Null when there is no provider at all. + * + * The consent gate runs on the resolved chain before anything leaves for a + * model. Required for the operator's global key (`admin-openai`); BYOK / + * local / ChatGPT-OAuth chains stay ungated. It throws ConsentRequiredError, + * which `apiHandler` answers with 403 + `consent.ai.required`. + */ +export async function resolveConsentedBriefingChain( + userId: string, +): Promise { + const chain = await resolveProviderChain(userId); + if (chain.length === 0) { + const legacy = await resolveProvider(userId); + if (legacy.type === "none") return null; + chain.push({ providerType: "admin-openai", instance: legacy }); + } + await assertConsentForChain({ userId, chain, surface: "insights" }); + return chain; +} + +export type GenerateOutcome = + | { ok: true; data: Record } + | { ok: false; refusal: BriefingRefusal }; + +export async function generateBriefing(args: { + ctx: GenerationContext; + chain: ProviderChainResolved[]; + ipAddress: string | null; +}): Promise { + const { ctx, chain } = args; + const prepared = await prepareBriefingFeatures(ctx); + if (prepared === null) return { ok: false, refusal: "payload-too-large" }; + + const prompt = await buildBriefingPrompt(ctx, prepared); + const call: BriefingCall = { ctx, chain, prompt }; + + const first = await runFirstCompletion(call); + if (!first.ok) return first; + + let insights; + try { + insights = parseBriefingReply(first.result.content); + } catch { + // A 502 here triggers Cloudflare's HTML error rewrite, which breaks + // `await res.json()` on the client; the refusal stays a JSON 422 so the + // React Query mutation can read the body and surface a readable message. + void recordBriefingFailure({ + userId: ctx.userId, + reason: "invalid-json", + locale: ctx.locale, + }); + // Truncation-aware (#470): `finishReason === "length"` means the model + // hit the output-token ceiling and the JSON was cut off mid-string, which + // is a budget problem, not a model-quality one. + if (first.result.finishReason === "length") { + annotate({ meta: { insights_response_truncated: true } }); + return { ok: false, refusal: "response-truncated" }; + } + return { ok: false, refusal: "response-invalid-json" }; + } + + const reply = await groundBriefing(call, prepared, { + insights, + result: first.result, + workingProviderType: first.workingProviderType, + }); + + return commitBriefing(call, prepared, reply, { + ipAddress: args.ipAddress, + fallbackHopCount: first.fallbackHopCount, + }); +} diff --git a/src/lib/insights/briefing-request/grounding.ts b/src/lib/insights/briefing-request/grounding.ts new file mode 100644 index 000000000..d78653519 --- /dev/null +++ b/src/lib/insights/briefing-request/grounding.ts @@ -0,0 +1,127 @@ +/** + * The reply's parse and the daily-briefing number-grounding gate. + * + * The schema only checks the briefing's SHAPE; the gate asserts every + * number the briefing prose restates traces to a `features.signalsOfDay` + * figure, mirroring the recommendations citation cross-check. On a miss: one + * corrective retry, then the briefing block is dropped rather than shipping + * a fabricated number. + */ +import { + buildBriefingGroundingCorrection, + findUngroundedBriefingNumbers, + readBriefingBlock, +} from "@/lib/ai/briefing-grounding"; +import type { RunRawWithFallbackResult } from "@/lib/ai/provider-runner"; +import { insightResultSchema, type InsightResult } from "@/lib/ai/types"; +import { annotate } from "@/lib/logging/context"; + +import { runBriefingCall, type BriefingCall } from "./completion"; +import type { PreparedFeatures } from "./features"; + +export type BriefingInsights = InsightResult | Record; + +/** The parsed reply: the strict shape when it validates, the raw object if not. */ +export function parseBriefingReply(content: string): BriefingInsights { + const parsed = JSON.parse(content); + // Try the strict schema first, fall back to the raw parsed object. + const validated = insightResultSchema.safeParse(parsed); + return validated.success ? validated.data : parsed; +} + +export interface GroundedReply { + insights: BriefingInsights; + result: RunRawWithFallbackResult["result"]; + workingProviderType: string; + /** + * Non-null when the gate stripped the briefing from THIS generation and no + * previous one could stand in, so the card can explain the hole instead + * of reading "no briefing yet" (#470). + */ + briefingOmittedReason: "ungrounded" | null; +} + +/** The previous cached briefing, when the payload parses and carries one. */ +function previousBriefing(cachedText: string | null): unknown { + if (!cachedText) return null; + try { + const prev = JSON.parse(cachedText) as Record; + return prev.dailyBriefing ?? null; + } catch { + // Unparseable previous payload — nothing safe to stand in. + return null; + } +} + +export async function groundBriefing( + call: BriefingCall, + prepared: PreparedFeatures, + reply: Omit, +): Promise { + const { features } = prepared; + const { comparisonSnapshot } = call.prompt; + let { insights, result, workingProviderType } = reply; + let briefingOmittedReason: "ungrounded" | null = null; + const signals = features.signalsOfDay ?? null; + let ungrounded = findUngroundedBriefingNumbers( + readBriefingBlock(insights), + signals, + features, + comparisonSnapshot, + ); + if (ungrounded.length > 0) { + annotate({ + action: { name: "insights.generate.briefing_grounding_retry" }, + meta: { ungroundedCount: ungrounded.length }, + }); + try { + const retry = await runBriefingCall(call, { + userPrompt: `${call.prompt.userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, + stage: "grounding-retry", + reasoning: false, + }); + const retryInsights = parseBriefingReply(retry.result.content); + ungrounded = findUngroundedBriefingNumbers( + readBriefingBlock(retryInsights), + signals, + features, + comparisonSnapshot, + ); + if (ungrounded.length === 0) { + insights = retryInsights; + result = retry.result; + workingProviderType = retry.workingProvider.providerType; + } + } catch { + // Retry failed to produce parseable JSON, or the day's ceiling refused + // the correction pass — either way fall through to the strip. An + // ungrounded figure is never persisted. + } + } + // Still ungrounded after the retry: the freshly generated briefing is + // discarded rather than shipped — the anti-fabrication guarantee is + // absolute here too. But rather than leave a hole, the PREVIOUS cached + // briefing stands in its place: it passed this identical gate when it was + // written, so it carries no fabricated figure, and a day-old grounded + // briefing beats a vanished one. This mirrors the background generator, + // which is the path that writes the same cached payload; the two must not + // disagree on disposal. + // + // `briefingOmittedReason` is set ONLY when no previous briefing exists to + // stand in, so the card's honest "a figure couldn't be verified" state + // still fires exactly when the user really has no briefing. + if (ungrounded.length > 0 && insights && typeof insights === "object") { + const fallbackBriefing = previousBriefing(call.ctx.cachedText); + (insights as Record).dailyBriefing = fallbackBriefing; + briefingOmittedReason = fallbackBriefing == null ? "ungrounded" : null; + annotate({ + action: { name: "insights.generate.briefing_grounding_stripped" }, + meta: { + ungroundedCount: ungrounded.length, + briefing_fallback: + fallbackBriefing != null ? "previous-cached" : "stripped", + }, + }); + } + return { insights, result, workingProviderType, briefingOmittedReason }; +} diff --git a/src/lib/insights/briefing-request/prompt.ts b/src/lib/insights/briefing-request/prompt.ts new file mode 100644 index 000000000..cfebd7e88 --- /dev/null +++ b/src/lib/insights/briefing-request/prompt.ts @@ -0,0 +1,129 @@ +/** + * The on-demand briefing's prompt: the feature snapshot, and every + * SYSTEM CONTEXT block that rides on it. + */ +import { metricsFromPresentSections } from "@/lib/ai/medical-references"; +import { + buildAboutMeInsightBlock, + getSelfContextTextForUser, +} from "@/lib/ai/coach/about-me"; +import { buildBriefingPersonalisationBlock } from "@/lib/ai/prompts/insight-generator"; +import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { + briefingFormForRecord, + buildBriefingShortFormBlock, +} from "@/lib/insights/briefing-short-form"; +import { buildComparisonSnapshotForUser } from "@/lib/insights/comprehensive-generate"; +import { + buildDerivedBriefingPrompt, + detectDerivedBriefingSignals, +} from "@/lib/insights/derived-briefing"; +import { + buildGlp1PlateauPrompt, + detectGlp1Plateau, +} from "@/lib/insights/glp1-plateau"; +import { + buildBriefingIllnessCycleContext, + buildBriefingIllnessCyclePrompt, +} from "@/lib/insights/illness-cycle-briefing"; +import { toProfileSex } from "@/lib/profile/sex"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +import type { GenerationContext } from "./context"; +import type { PreparedFeatures } from "./features"; + +export type BriefingPrompt = Awaited>; + +export async function buildBriefingPrompt( + ctx: GenerationContext, + prepared: PreparedFeatures, +) { + const { userId, dbUser, locale } = ctx; + const { features, featuresJson, units } = prepared; + // The persisted comparison toggle: when active, a compact prior-period + // snapshot built from the `features` summaries that already include + // `avg30LastMonth` / `avg30LastYear`. The narrative is default-on whenever + // the toggle is on — the pulldown is the single affordance. + const comparisonSnapshot = await buildComparisonSnapshotForUser(userId); + // GLP-1 plateau detector: a SYSTEM CONTEXT block when the active GLP-1 + // medication has been on a stable dose for ≥21 days with the weight delta + // within ±0.5 kg, asking for an observational glp1_plateau keyFinding (no + // dose recommendation per GROUND RULE 13). Null — the common case — costs + // no tokens. + const plateauContext = await detectGlp1Plateau(userId); + let userPrompt = buildUserPrompt( + featuresJson, + dbUser?.insightsPrivacyMode ?? "aggregated", + locale, + comparisonSnapshot ?? undefined, + ); + if (plateauContext) { + userPrompt += buildGlp1PlateauPrompt(plateauContext, locale, units.system); + } + // The user-authored "about me" self-description (Settings → AI), 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); + } + // A notable derived wellness signal (readiness / recovery shift, + // confidence-gated), read from the one derived contract; null (the common + // case) costs nothing. + const derivedSex = toProfileSex(dbUser?.gender); + const derivedBriefing = await detectDerivedBriefingSignals(userId, { + ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null), + sex: derivedSex, + heightCm: dbUser?.heightCm ?? null, + }); + if (derivedBriefing) { + userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale); + } + + // Illness-episode + cycle state, so a mid-illness or mid-cycle user gets + // context-aware prose. 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); + } + + // Opener-archetype rotation + sparse first-name personalization + // (deterministic per user+day; omitted for unnamed accounts). + userPrompt += buildBriefingPersonalisationBlock( + userId, + dbUser?.displayName ?? null, + locale, + await resolveUserTimezone(userId), + ); + // The short form on the `frugal` usage level; the full contract on every + // other level (#1217). + const briefingForm = await briefingFormForRecord(userId); + userPrompt += buildBriefingShortFormBlock(locale, briefingForm); + + // 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. The plain prompt stays 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, + }); + + return { + userPrompt, + comparisonSnapshot, + aboutMe, + briefingForm, + referenceMetrics, + }; +} diff --git a/src/lib/insights/briefing-request/read.ts b/src/lib/insights/briefing-request/read.ts new file mode 100644 index 000000000..23a8b7e91 --- /dev/null +++ b/src/lib/insights/briefing-request/read.ts @@ -0,0 +1,224 @@ +/** + * The read-only advisor briefing: the cached payload, served immediately and + * never generated here. + * + * The advisor consumers (hero strip, daily-briefing card, trends row) used + * to mount against the generating POST, which blocked the page-load path on + * the full provider chain. This read serves `User.insightsCachedTextEncrypted` + * as it is and, when the cache is stale or missing AND a provider is + * configured, enqueues an out-of-band warm (the same `insight-pregenerate` + * queue the nightly cron and the "prepare assessments" button use). The next + * read reflects the fresh briefing. + */ +import type { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + generatedOnLocalToday, + insightsPayloadForToday, +} from "@/lib/daily/briefing-today-read"; +import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { readBriefingFailure } from "@/lib/insights/briefing-failure-marker"; +import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; +import { + INSIGHTS_CACHE_SELECT, + openInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; +import { normalizeLocale } from "@/lib/insights/status-shared"; +import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; +import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; +import { annotate } from "@/lib/logging/context"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +/** + * Briefing freshness window for the read. A cached briefing read this + * recently is fresh and triggers no warm; older / missing enqueues an + * out-of-band regeneration. Mirrors the 24 h cache-hit window the POST path + * honours so the two stay consistent. + */ +const BRIEFING_FRESH_MS = 24 * 60 * 60 * 1000; + +/** + * The honesty signals beside the payload: whether a provider exists, and + * what the last generation attempt left behind. + */ +async function readGenerationSignals(userId: string, cachedAt: Date | null) { + // Provider availability, resolved once. The read never blocks on or + // generates through the provider, but it must REPORT whether one exists: + // a provider-less account's cached briefing freezes at the last successful + // generation (the pregenerate cron can't refill it), and the UI presented + // that stale text as current. `hasProvider: false` lets the insights + // surfaces pair the honest "Stand: vor X Tagen" age with a discreet + // connect-an-AI-provider hint instead of implying the read is live. + const hasProvider = await hasUsableStatusProvider(userId); + + // Whether the most recent generation attempt failed (a marker newer than + // the last successful generation). The briefing keeps its last good text + // on failure, so this is the only honest signal that a shown briefing is + // held rather than freshly refreshed — and, when there is no last good + // text, that the empty state should read "couldn't generate" with a retry + // rather than the generic "no briefing yet". + const briefingFailure = await readBriefingFailure({ + userId, + since: cachedAt, + }); + // A `briefing-ungrounded` marker is NOT a failure: the generation + // succeeded but the grounding gate withheld the briefing (marker written + // after the cache write, so it survives the `since` filter). It is the + // persistent omission signal — the card's "briefing withheld" state used + // to exist only on the transient POST response, so a nightly strip read as + // a silent generic empty card. + const briefingOmittedReason: "ungrounded" | null = + briefingFailure?.reason === "briefing-ungrounded" ? "ungrounded" : null; + const generationFailed = + briefingFailure !== null && briefingOmittedReason === null; + // The failure class lets the empty state point its hint at the right + // lever (raise the response timeout vs re-check the provider). + const generationFailureClass = generationFailed + ? (briefingFailure?.failureClass ?? null) + : null; + return { + hasProvider, + generationFailed, + generationFailureClass, + briefingOmittedReason, + }; +} + +export async function readAdvisorBriefing(args: { + request: NextRequest; + userId: string; + userLocale: string | null; +}) { + const { request, userId } = args; + const dbUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { + insightsCachedAt: true, + ...INSIGHTS_CACHE_SELECT, + insightsCachedLocale: true, + locale: true, + timezone: true, + }, + }); + const cachedText = openInsightsCachedText(dbUser); + + // The locale the caller is actually reading (cookie / Accept-Language + // fall-back when `User.locale` is unset), validated against the shipped + // list — the same convention the nightly's `normalizeLocale` and the status + // routes follow. Collapsing a NULL `User.locale` to German filled the wrong + // cache family for a client reading the app in English. + const readerLocale = normalizeLocale( + await resolveServerLocale({ + request, + userLocale: dbUser?.locale ?? args.userLocale ?? null, + }), + ); + + // The cache is one slot per user. Text written in another language is + // never the reader's briefing, however fresh: treat the slot as empty for + // prose, and re-warm in the reader's language below. An untagged row + // predates the tag and is served as before. + const languageMismatch = + dbUser?.insightsCachedLocale != null && + dbUser.insightsCachedLocale !== readerLocale; + + const cachedAt = dbUser?.insightsCachedAt ?? null; + const timezone = dbUser?.timezone ?? DEFAULT_TIMEZONE; + // Fresh means written today on the reader's calendar, by the moment the + // text was generated: `insightsCachedAt` also moves when an unchanged + // warm only re-stamps yesterday's text. + const generatedToday = generatedOnLocalToday( + readBriefingGeneratedAt(cachedText), + timezone, + new Date(), + ); + const isFresh = + !languageMismatch && + cachedAt !== null && + generatedToday && + Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS; + + const signals = await readGenerationSignals(userId, cachedAt); + const { hasProvider } = signals; + + // Never block on the provider. Warm out of band only when the cached + // briefing is stale / missing AND a provider is configured (a + // provider-less account costs one cheap chain-resolve and shows the + // empty / connect-AI state instead of a wasted enqueue). + let revalidating = false; + if (!isFresh && hasProvider) { + void enqueueForceWarm({ userId, locale: readerLocale }); + revalidating = true; + } + + if (cachedText && !languageMismatch) { + try { + const cached = JSON.parse(cachedText); + const legacyPayload = isLegacyInsightPayload(cached); + annotate({ + action: { name: "insights.generate.read" }, + meta: { cached: true, legacyPayload, revalidating, hasProvider }, + }); + return { + // The briefing as every surface serves it: today's text only, its + // signals measured today, its deltas at display precision. + insights: await insightsPayloadForToday(cached, { + userId, + timezone, + language: readerLocale, + }), + cached: true, + cachedAt, + legacyPayload, + // Honest stale-serve marker: true while the out-of-band warm is in + // flight, so the client can poll (bounded) until the fresh briefing + // lands instead of sitting on the stale payload for the rest of the + // session. + revalidating, + // False when no AI provider is configured anywhere: the served + // briefing can never refresh, so the UI pairs its age with a + // connect-provider affordance. + hasProvider, + // True when the last generation attempt failed (held text). + generationFailed: signals.generationFailed, + // Coarse failure class for the empty-state hint (null when the last + // attempt succeeded). + generationFailureClass: signals.generationFailureClass, + // Persistent grounding-omission signal (see above), so a + // nightly-stripped briefing renders "withheld", not "no briefing + // yet". + briefingOmittedReason: signals.briefingOmittedReason, + }; + } catch { + // Invalid cache row — fall through to the empty payload below. The + // warm enqueue above (when a provider exists) repairs it for the next + // read. + } + } + + annotate({ + action: { name: "insights.generate.read" }, + meta: { + cached: false, + revalidating, + hasProvider, + languageMismatch, + readerLocale, + }, + }); + return { + insights: null, + cached: false, + legacyPayload: false, + revalidating, + hasProvider, + // No last-good text AND the last attempt failed: the UI shows a + // "couldn't generate" empty state with a retry instead of the generic + // one. + generationFailed: signals.generationFailed, + generationFailureClass: signals.generationFailureClass, + briefingOmittedReason: signals.briefingOmittedReason, + }; +}