mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
@@ -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:<userId>`, 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:
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<void> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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:<ID>` 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<GenerateOutcome>;
|
||||
statusGenerators?: ReadonlyArray<StatusGenerator>;
|
||||
warmGenericMetrics?: (
|
||||
userId: string,
|
||||
locales: ReadonlyArray<"de" | "en">,
|
||||
) => Promise<number>;
|
||||
} = {},
|
||||
): Promise<ForceWarmResult> {
|
||||
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;
|
||||
}
|
||||
@@ -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<InsightPregeneratePayload>[],
|
||||
) {
|
||||
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({
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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:<userId>`, 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"],
|
||||
|
||||
Reference in new issue
Block a user