mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
refactor(insights): move the briefing read and generation out of their route
This commit is contained in:
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",
|
||||
|
||||
+128
-1109
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user