refactor(insights): move the briefing read and generation out of their route

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 15:44:04 +02:00
1 parent db623ef8bc
commit 1a5d79f24e
12 files changed
+1520 -1111

No files matched your search

@@ -41,7 +41,7 @@ const ADMITTED: Record<string, { jobs: string[]; why: string }> = {
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.",
},
@@ -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",
File diff suppressed because it is too large. Load diff
+133
View File
@@ -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<string, unknown> }
| { kind: "generate"; fallback: CachedFallback | null };
export async function briefingFromCache(
ctx: GenerationContext,
forceRefresh: boolean,
): Promise<CacheDecision> {
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<Record<string, unknown>> {
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,
};
}
+202
View File
@@ -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<string, unknown> }
| { 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<boolean> {
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<void> {
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<CommitOutcome> {
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,
},
};
}
@@ -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<RunRawWithFallbackResult> {
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<FirstCompletion> {
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),
};
}
}
@@ -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<GenerationContext> {
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,
};
}
@@ -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<ReturnType<typeof extractFeatures>>;
/**
* 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<string> = [
"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<BriefingFeatures | null> {
// 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<string, unknown>;
featuresJson: string;
units: ReturnType<typeof resolveUnitPreferences>;
}
export async function prepareBriefingFeatures(
ctx: GenerationContext,
): Promise<PreparedFeatures | null> {
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<string, unknown>,
);
// 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 };
}
@@ -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<ProviderChainResolved[] | null> {
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<string, unknown> }
| { ok: false; refusal: BriefingRefusal };
export async function generateBriefing(args: {
ctx: GenerationContext;
chain: ProviderChainResolved[];
ipAddress: string | null;
}): Promise<GenerateOutcome> {
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,
});
}
@@ -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<string, unknown>;
/** 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<string, unknown>;
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<GroundedReply, "briefingOmittedReason">,
): Promise<GroundedReply> {
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<string, unknown>).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 };
}
+129
View File
@@ -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<ReturnType<typeof buildBriefingPrompt>>;
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,
};
}
+224
View File
@@ -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,
};
}