diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f2ed8234b..7082d4e0f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1927,6 +1927,33 @@ paths: "401": *a1 "422": *a2 "429": *a3 + /api/insights/pregenerate: + post: + tags: + - Insights + summary: Warm all AI assessments for the calling user + description: v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the + read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the + worker. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket + (`insights-warm:`, one warm per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is + taken from the session, never the body. + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsPregenerateRequest" + responses: + "200": + description: Warm accepted and enqueued. The work runs on the worker; poll the read-only status routes for the text. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsPregenerateResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 /api/insights/metric-status: get: tags: @@ -2767,6 +2794,11 @@ components: required: - rating description: Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars. + InsightsPregenerateRequest: + type: object + properties: {} + description: No body fields. The user is taken from the session / Bearer and the locale from the session; the warm + covers every assessment for that user. MedicationTreatmentClass: type: string enum: @@ -6267,6 +6299,41 @@ components: additionalProperties: false description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding. + InsightsPregenerateResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsPregenerateResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsPregenerateResponse: + type: object + properties: + queued: + type: boolean + description: True when the full warm was accepted and enqueued. + locale: + type: string + enum: + - de + - en + description: The locale the assessments are being warmed in. + required: + - queued + - locale + additionalProperties: false + description: Acknowledgement that a full assessment warm was enqueued for the calling user. The generation runs out of + band; the text lands in the read-only status routes. MetricStatusResponseEnvelope: type: object properties: diff --git a/src/app/api/insights/pregenerate/route.ts b/src/app/api/insights/pregenerate/route.ts new file mode 100644 index 000000000..a18936cda --- /dev/null +++ b/src/app/api/insights/pregenerate/route.ts @@ -0,0 +1,70 @@ +/** + * v1.8.7.1 — on-demand full assessment warm. + * + * `POST /api/insights/pregenerate` enqueues a forced full warm of every + * AI assessment for the calling user — the comprehensive insight (daily + * briefing), the seven specialised `*-status` cards, and every + * data-bearing generic `metric:` assessment — for the user's active + * locale. The heavy generation runs on the worker (the + * `insight-pregenerate` queue) so this route returns immediately; the + * cards then fill via the existing read-only stale-while-revalidate GETs + * without the user waiting on a provider round-trip. + * + * The forced path bypasses the nightly cron's per-user 20 h budget (so a + * user can warm on demand the moment they land on the page), so this + * route carries its own short anti-spam bucket — one warm per + * `WARM_WINDOW_MS` per user. Empty metrics never reach the provider (the + * worker filters to data-bearing types) and the comprehensive generator + * no-ops without a configured provider, so a spam-free call on a + * provider-less account costs one cheap chain-resolve and no LLM call. + * + * `userId` is always narrowed from the session / Bearer — never a body + * field. Both web and the iOS client can call it; they read the same + * cached routes afterward. + */ +import { NextRequest } from "next/server"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; + +export const dynamic = "force-dynamic"; + +/** Anti-spam window — one forced warm per user per 3 minutes. */ +const WARM_WINDOW_MS = 3 * 60 * 1000; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + // Same surface gate as the read-only status routes — when the operator + // disables the assistant surface there is nothing to warm. + await requireAssistantSurface("coach"); + const userId = user.id; + + // Short per-user bucket so the bypassed nightly budget can't be abused + // into a provider-cost amplifier by a tight POST loop. A blocked call + // is harmless — the caches are already being warmed by the prior call. + const rl = await checkRateLimit(`insights-warm:${userId}`, 1, WARM_WINDOW_MS); + if (!rl.allowed) { + return apiError("A warm is already in progress. Try again shortly.", 429); + } + + const resolved = await resolveServerLocale({ + request, + userLocale: user.locale ?? null, + }); + const locale = resolved === "en" ? "en" : "de"; + + await enqueueForceWarm({ userId, locale }); + + annotate({ + action: { name: "insights.pregenerate.requested" }, + meta: { locale }, + }); + + // The work runs on the worker; report that it was accepted, not that it + // is done. The client polls the read-only status GETs for the text. + return apiSuccess({ queued: true, locale }); +}); diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 0d9ce5d53..572f28e50 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -65,6 +65,7 @@ vi.mock("@/lib/insights/metric-status", () => ({ import { runInsightPregenerate, + forceWarmUser, findPregenerateCandidates, PREGENERATE_STALE_MS, INSIGHT_PREGENERATE_QUEUE, @@ -377,6 +378,84 @@ describe("runInsightPregenerate — generic metric warm pass (v1.8.7.1)", () => }); }); +describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { + it("warms ONLY the active locale, bypasses the budget gate, and tallies counts", async () => { + const { prisma } = makePrisma([]); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(5); + + const result = await forceWarmUser(prisma as never, "u1", "en", { + generate, + statusGenerators, + warmGenericMetrics, + }); + + // Comprehensive forced once, in the active locale only. + expect(generate).toHaveBeenCalledTimes(1); + expect(generate).toHaveBeenCalledWith("u1", { locale: "en", force: true }); + // Each specialised generator forced exactly once — the active locale, + // NOT both de+en like the nightly cron. + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: true }); + } + // Generic warm pass runs for the single active locale. + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["en"]); + // No 20 h budget bucket consulted on the forced path. + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + comprehensive: "generated", + assessmentsWarmed: 7, + metricAssessmentsWarmed: 5, + }); + }); + + it("does NOT warm the per-status or generic caches when the comprehensive pass skipped (no provider)", async () => { + const { prisma } = makePrisma([]); + const generate = vi + .fn() + .mockResolvedValue({ status: "skipped", reason: "no-provider" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: false, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(5); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + }); + + for (const g of statusGenerators) { + expect(g).not.toHaveBeenCalled(); + } + expect(warmGenericMetrics).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + comprehensive: "skipped", + assessmentsWarmed: 0, + metricAssessmentsWarmed: 0, + }); + }); + + it("short-circuits to a no-op when the briefing surface is disabled globally", async () => { + getAssistantFlags.mockResolvedValue({ enabled: false, briefing: false }); + const { prisma } = makePrisma([]); + const generate = vi.fn(); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + }); + + expect(generate).not.toHaveBeenCalled(); + expect(result.comprehensive).toBe("skipped"); + }); +}); + describe("queue registration", () => { const workerSrc = fs.readFileSync( path.resolve(__dirname, "../reminder-worker.ts"), diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts new file mode 100644 index 000000000..564b0ec09 --- /dev/null +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -0,0 +1,80 @@ +/** + * v1.8.7.1 — generator-free contract for the insight-pregenerate queue. + * + * The on-demand warm route (`POST /api/insights/pregenerate`) enqueues a + * forced full-warm job here without importing the concrete generators + * (which would pull the whole `comprehensive-generate` + seven `*-status` + * tree into the route bundle and risk an import cycle). The worker-only + * pipeline (`runInsightPregenerate`, `forceWarmUser`) lives in + * `insight-pregenerate.ts`, which re-exports the queue name from here so + * there is a single source of truth. + * + * Mirrors the `insight-status-generate-shared.ts` split exactly: queue + * name + payload type + enqueue helper in the generator-free module, the + * concrete dispatch in the worker module. + */ +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { annotate } from "@/lib/logging/context"; + +export const INSIGHT_PREGENERATE_QUEUE = "insight-pregenerate"; + +export interface InsightPregeneratePayload { + /** Set on the nightly scheduled tick (informational; the handler keys + * its batch behaviour off the ABSENCE of `userId`/`force`). */ + triggeredAt?: string; + /** + * Forced single-user warm. When `force` is set the worker runs the + * full per-user warm for `userId` directly, bypassing the candidate + * scan AND the per-user 20 h budget bucket. + */ + userId?: string; + force?: boolean; + /** Locale to warm on the forced path; defaults to "de" when absent. */ + locale?: "de" | "en"; +} + +/** + * Fire-and-forget enqueue used by the on-demand warm route. A + * `singletonKey` per `(user, locale)` collapses repeated requests within + * a short window into one queued job, so a client that taps the button + * twice (or the auto-trigger races a manual tap) can't double-warm. No-ops + * cleanly when the global boss instance is not available (e.g. a web + * process without an embedded worker) — the nightly cron remains the + * catch-net. + */ +export async function enqueueForceWarm(payload: { + userId: string; + locale: "de" | "en"; +}): Promise { + const boss = getGlobalBoss(); + if (!boss) { + annotate({ + action: { name: "insights.pregenerate.force.no_boss" }, + meta: { locale: payload.locale }, + }); + return; + } + try { + await boss.send( + INSIGHT_PREGENERATE_QUEUE, + { + userId: payload.userId, + force: true, + locale: payload.locale, + } satisfies InsightPregeneratePayload, + { + singletonKey: `force:${payload.userId}:${payload.locale}`, + // De-dupe within a 2-minute window so a double tap or an + // auto-trigger racing a manual tap enqueues one warm, not two. + singletonSeconds: 120, + }, + ); + annotate({ + action: { name: "insights.pregenerate.force.enqueued" }, + meta: { locale: payload.locale }, + }); + } catch { + // Enqueue is best-effort; a failure here just means the caches stay + // cold until the next poll / nightly cron warms them. + } +} diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index cea5a9418..780dc3037 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -68,8 +68,17 @@ import { getMetricStatusMeta, type MetricStatusMetricId, } from "@/lib/insights/metric-status-registry"; +import { + INSIGHT_PREGENERATE_QUEUE, + enqueueForceWarm, + type InsightPregeneratePayload, +} from "@/lib/jobs/insight-pregenerate-shared"; -export const INSIGHT_PREGENERATE_QUEUE = "insight-pregenerate"; +export { + INSIGHT_PREGENERATE_QUEUE, + enqueueForceWarm, +}; +export type { InsightPregeneratePayload }; /** * 04:30 Europe/Berlin. Slots between the cumulative drain (03:30) and @@ -100,8 +109,14 @@ const PREGENERATE_BUDGET_WINDOW_MS = 20 * 60 * 60 * 1000; */ const WARM_PASS_CONCURRENCY = 3; -export interface InsightPregeneratePayload { - triggeredAt: string; +/** Per-user result of one forced full warm. */ +export interface ForceWarmResult { + /** Outcome of the comprehensive-insight generation. */ + comprehensive: GenerateOutcome["status"]; + /** Count of specialised per-status assessments written warm. */ + assessmentsWarmed: number; + /** Count of generic per-HealthKit-metric assessments written warm. */ + metricAssessmentsWarmed: number; } export interface PregenerateRunResult { @@ -455,3 +470,93 @@ export async function runInsightPregenerate( return result; } + +/** + * v1.8.7.1 — forced full warm for ONE user, on demand. + * + * Runs the same pipeline the nightly tick runs per candidate — the + * comprehensive insight (which carries the daily briefing), the seven + * specialised `*-status` assessments, and every data-bearing generic + * `metric:` assessment — but for a single explicit `userId` and + * WITHOUT the per-user 20 h budget bucket. The endpoint that enqueues this + * job carries its own short anti-spam rate-limit (one warm per few + * minutes), so the forced path can skip the nightly budget while staying + * bounded against abuse. + * + * Safety is preserved by the helpers it reuses, not by a budget gate: + * - The comprehensive generator no-ops to `{ status: "skipped" }` when + * no provider is configured (no LLM call). + * - `warmGenericMetricCaches` filters to metrics the user has rows for + * via one grouped read, so an empty metric never reaches the provider. + * - Each generator forces a fresh write but is idempotent — re-running + * it just rewrites the same cache row; concurrent enqueues collapse via + * the queue `singletonKey`. + * + * Unlike the nightly cron (de+en), the forced path warms only the caller's + * active `locale` so a single-locale user pays half the provider cost. A + * `failed`/`skipped` comprehensive outcome short-circuits the per-status + * warm exactly as the cron does — re-running seven (then ~30) generators + * against an unhealthy or provider-less chain would only multiply the + * failure. + */ +export async function forceWarmUser( + prisma: PrismaClient, + userId: string, + locale: "de" | "en", + options: { + generate?: ( + userId: string, + opts: { locale: "de" | "en"; force?: boolean }, + ) => Promise; + statusGenerators?: ReadonlyArray; + warmGenericMetrics?: ( + userId: string, + locales: ReadonlyArray<"de" | "en">, + ) => Promise; + } = {}, +): Promise { + const generate = options.generate ?? generateComprehensiveInsight; + const statusGenerators = + options.statusGenerators ?? DEFAULT_STATUS_GENERATORS; + const warmGenericMetrics = + options.warmGenericMetrics ?? + ((id: string, locales: ReadonlyArray<"de" | "en">) => + warmGenericMetricCaches(prisma, id, locales)); + + const result: ForceWarmResult = { + comprehensive: "skipped", + assessmentsWarmed: 0, + metricAssessmentsWarmed: 0, + }; + + // Master kill-switch — when the operator disabled the briefing surface + // globally there is nothing to warm, on demand or otherwise. + const flags = await getAssistantFlags(); + if (!flags.briefing) return result; + + const locales: ReadonlyArray<"de" | "en"> = [locale]; + + const outcome = await generate(userId, { locale, force: true }); + result.comprehensive = outcome.status; + + if (outcome.status === "generated" || outcome.status === "cached") { + result.assessmentsWarmed = await warmPerStatusCaches( + userId, + locales, + statusGenerators, + ); + result.metricAssessmentsWarmed = await warmGenericMetrics(userId, locales); + } + + annotate({ + action: { name: "insights.pregenerate.force" }, + meta: { + locale, + comprehensive: result.comprehensive, + assessments_warmed: result.assessmentsWarmed, + metric_assessments_warmed: result.metricAssessmentsWarmed, + }, + }); + + return result; +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 89054b608..2a3cf76d5 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -65,6 +65,7 @@ import { INSIGHT_PREGENERATE_QUEUE, INSIGHT_PREGENERATE_CRON, runInsightPregenerate, + forceWarmUser, type InsightPregeneratePayload, } from "@/lib/jobs/insight-pregenerate"; import { @@ -1692,8 +1693,33 @@ async function handleMedicationInventoryExpire( async function handleInsightPregenerateJob( jobs: Job[], ) { - void jobs; await withBackgroundEvent("job.insight_pregenerate", async (evt) => { + // v1.8.7.1 — a forced single-user warm carries `{ userId, force }`; + // the scheduled tick carries neither. Route each job individually so a + // batch that mixes a cron tick with on-demand warms (it never does in + // practice, but the contract is per-job) stays correct. + const forced = jobs.filter((j) => j.data?.force && j.data?.userId); + const scheduled = jobs.filter((j) => !(j.data?.force && j.data?.userId)); + + for (const job of forced) { + const userId = job.data.userId as string; + const locale = job.data.locale === "en" ? "en" : "de"; + try { + const summary = await forceWarmUser(getWorkerPrisma(), userId, locale); + evt.addMeta( + "force_warm", + `${summary.comprehensive}:${summary.assessmentsWarmed}+${summary.metricAssessmentsWarmed}`, + ); + } catch (err) { + evt.addWarning( + `insight-pregenerate force-warm failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + if (scheduled.length === 0) return; try { const summary = await runInsightPregenerate(getWorkerPrisma()); evt.setBackground({ diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 6a0f3f9cc..5e5fa6e90 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1156,6 +1156,29 @@ const metricStatusResponse = z "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", }); +const insightsPregenerateRequest = z + .object({}) + .meta({ + id: "InsightsPregenerateRequest", + description: + "No body fields. The user is taken from the session / Bearer and the locale from the session; the warm covers every assessment for that user.", + }); + +const insightsPregenerateResponse = z + .object({ + queued: z + .boolean() + .describe("True when the full warm was accepted and enqueued."), + locale: z + .enum(["de", "en"]) + .describe("The locale the assessments are being warmed in."), + }) + .meta({ + id: "InsightsPregenerateResponse", + description: + "Acknowledgement that a full assessment warm was enqueued for the calling user. The generation runs out of band; the text lands in the read-only status routes.", + }); + // v1.7.0 — unified dashboard first-paint snapshot. One GET that // assembles every above-the-fold tile field in a single round-trip. // Two-phase shape: `tiles` (fast, always present) + `extras` (thick, @@ -2409,6 +2432,35 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/insights/pregenerate": { + post: { + tags: ["Insights"], + summary: "Warm all AI assessments for the calling user", + description: + "v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the worker. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket (`insights-warm:`, one warm per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is taken from the session, never the body.", + requestBody: { + required: false, + content: { + "application/json": { schema: insightsPregenerateRequest }, + }, + }, + responses: { + "200": { + description: + "Warm accepted and enqueued. The work runs on the worker; poll the read-only status routes for the text.", + content: { + "application/json": { + schema: dataEnvelope( + insightsPregenerateResponse, + "InsightsPregenerateResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/metric-status": { get: { tags: ["Insights"],