mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(insights): add on-demand full assessment warm
Add POST /api/insights/pregenerate so every AI assessment text — the
comprehensive insight, the seven specialised status cards, and every
data-bearing generic metric assessment — can be warmed for the calling
user on demand, in the active locale, instead of waiting for the nightly
cron or a lazy first-visit generation.
The route narrows userId from the session/Bearer, runs the assistant
surface gate, and enqueues a forced full-warm job onto the existing
insight-pregenerate queue, returning immediately. A short per-user bucket
(insights-warm:<userId>, one per three minutes) returns 429 on a tight
loop so the bypassed nightly 20h budget cannot be turned into a provider
cost amplifier.
The worker branches on the payload: a job carrying { userId, force }
runs forceWarmUser for that user, skipping the candidate scan and the
20h budget; a scheduled tick with no payload keeps its existing batch
behaviour. forceWarmUser reuses the existing comprehensive generator and
warm helpers — no new generation logic — and stays safe by construction:
the comprehensive generator no-ops without a provider, the generic warm
pass filters to metrics the user has rows for so an empty metric never
reaches the provider, and every write is idempotent.
The queue name and a generator-free enqueue helper move to
insight-pregenerate-shared.ts, mirroring the insight-status-generate
split, so the route enqueues without pulling the generator tree.
Regenerate the OpenAPI contract for the new route.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit e10ae9bfeca544c7e5aecd9102459d97ddfad6db)
This commit is contained in:
1 parent
d81ab69dc8
commit
bf5052bb01
7 files changed
+483
-4
No files matched your search
@@ -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:<ID>` 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 });
|
||||
});
|
||||
Reference in new issue
Block a user