diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 611290a11..040cadd9a 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -157,6 +157,82 @@ export async function evictPerStatusInsightCache( }); } +/** + * The seven per-metric assessment scopes. Each generator persists its + * cached text under `insights.-status.`; the warm-pass in + * the nightly cron re-fills these after `evictPerStatusInsightCache` + * clears them, and the targeted invalidator below drops only the scopes + * a fresh measurement of a given type actually dirties. + */ +export const PER_STATUS_SCOPES = [ + "blood-pressure", + "pulse", + "weight", + "bmi", + "mood", + "medication-compliance", + "general", +] as const; + +export type PerStatusScope = (typeof PER_STATUS_SCOPES)[number]; + +/** + * Map a measurement type to the assessment scopes a fresh reading of it + * dirties. `general` is the catch-all overview, so every measurement + * type touches it. BMI rides on WEIGHT (it is weight ÷ height²), so a + * new weight reading invalidates both the weight and the BMI card. + */ +function statusScopesForMeasurementType(type: MeasurementType): PerStatusScope[] { + switch (type) { + case "WEIGHT": + return ["weight", "bmi", "general"]; + case "BLOOD_PRESSURE_SYS": + case "BLOOD_PRESSURE_DIA": + return ["blood-pressure", "general"]; + case "PULSE": + case "RESTING_HEART_RATE": + return ["pulse", "general"]; + default: + // Every other tracked metric (body composition, sleep, steps, + // glucose, …) still feeds the general overview assessment. + return ["general"]; + } +} + +/** + * Drop the cached per-metric assessment rows that a batch of fresh + * measurements dirties, so the next mount (or the next nightly warm + * pass) regenerates them against the new data instead of serving the + * pre-measurement text for the rest of the day. + * + * Fire-and-forget from the measurement ingest path — idempotent (a + * redundant delete costs nothing) and never a blocker on the user's + * write. Deletes every locale variant of each affected scope in one + * sweep; the `-status.` substring guard keeps it from touching the + * comprehensive cache or any unrelated `insights.*` audit row. + */ +export async function invalidateStatusInsightsForTypes( + userId: string, + types: Iterable, +): Promise { + const scopes = new Set(); + for (const type of types) { + for (const scope of statusScopesForMeasurementType(type)) { + scopes.add(scope); + } + } + if (scopes.size === 0) return; + + await prisma.auditLog.deleteMany({ + where: { + userId, + OR: Array.from(scopes, (scope) => ({ + action: { startsWith: `insights.${scope}-status.` }, + })), + }, + }); +} + interface GenerateOptions { /** Resolved UI locale for the prompt + cache row. */ locale: "de" | "en"; diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 2c6f91c54..ab8ac000d 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -27,6 +27,30 @@ vi.mock("@/lib/feature-flags", () => ({ vi.mock("@/lib/insights/comprehensive-generate", () => ({ generateComprehensiveInsight: vi.fn(), })); +// The seven status generators import the provider chain transitively; +// stub the modules so the warm-pass never touches a live provider when +// the test does not inject its own generators. +vi.mock("@/lib/insights/blood-pressure-status", () => ({ + generateBloodPressureStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/pulse-status", () => ({ + generatePulseStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/weight-status", () => ({ + generateWeightStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/bmi-status", () => ({ + generateBmiStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/mood-status", () => ({ + generateMoodStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/medication-compliance-status", () => ({ + generateMedicationComplianceStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/general-status", () => ({ + generateGeneralStatusForUser: vi.fn(), +})); import { runInsightPregenerate, @@ -180,6 +204,100 @@ describe("runInsightPregenerate — outcome tally", () => { }); }); +describe("runInsightPregenerate — per-metric warm pass", () => { + function warmGen(result: { hasProvider: boolean; cached: boolean }) { + return vi.fn().mockResolvedValue(result); + } + + it("forces all seven status generators after a successful comprehensive generation", async () => { + const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + const statusGenerators = Array.from({ length: 7 }, () => + warmGen({ hasProvider: true, cached: false }), + ); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + }); + + // Every status generator forced with the resolved locale. + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true }); + } + // Seven fresh, provider-backed assessments warmed. + expect(result.assessmentsWarmed).toBe(7); + }); + + it("does NOT warm when the comprehensive pass skipped (no provider)", async () => { + const { prisma } = makePrisma([{ id: "u1", locale: "en" }]); + const generate = vi + .fn() + .mockResolvedValue({ status: "skipped", reason: "no-provider" }); + const statusGenerators = Array.from({ length: 7 }, () => + warmGen({ hasProvider: false, cached: true }), + ); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + }); + + for (const g of statusGenerators) { + expect(g).not.toHaveBeenCalled(); + } + expect(result.assessmentsWarmed).toBe(0); + }); + + it("does NOT warm when the comprehensive pass failed", async () => { + const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); + const generate = vi + .fn() + .mockResolvedValue({ status: "failed", reason: "provider-error" }); + const statusGenerators = Array.from({ length: 7 }, () => + warmGen({ hasProvider: true, cached: false }), + ); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + }); + + for (const g of statusGenerators) { + expect(g).not.toHaveBeenCalled(); + } + expect(result.assessmentsWarmed).toBe(0); + }); + + it("counts only fresh provider-backed assessments and survives a thrown generator", async () => { + const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + const statusGenerators = [ + warmGen({ hasProvider: true, cached: false }), // counts + warmGen({ hasProvider: false, cached: true }), // no provider — skip + warmGen({ hasProvider: true, cached: true }), // served cache — skip + vi.fn().mockRejectedValue(new Error("boom")), // throws — swallowed + warmGen({ hasProvider: true, cached: false }), // counts + ]; + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + }); + + // The throw must not abort the loop — every generator was attempted. + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); + } + expect(result.assessmentsWarmed).toBe(2); + }); +}); + describe("queue registration", () => { const workerSrc = fs.readFileSync( path.resolve(__dirname, "../reminder-worker.ts"), diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 7413f8231..bbfd9c2bd 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -10,6 +10,16 @@ * snapshot embeds a `briefingState: "ready"` block instead of * `"preparing"`. * + * v1.8.0 — warm the per-metric assessment caches too. The comprehensive + * generator evicts the seven `insights.-status.` caches + * as part of its write (so a stale comprehensive insight can never leave + * a stale per-metric card behind) but does NOT re-fill them. Before this, + * the morning after the cron ran the per-metric status caches were + * empty, so the first `/insights` mount still ran one live status + * generation per card. After a successful comprehensive generation this + * cron now forces the seven `*StatusForUser` generators so their caches + * are warm, not empty, by the user's first visit. + * * Runs once nightly at 04:30 Europe/Berlin — inside the existing * 03:xx–04:xx maintenance window, after the feedback aggregator (04:00) * and the cumulative drain (03:30) so the late-night CPU profile stays @@ -38,10 +48,18 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { checkRateLimit } from "@/lib/rate-limit"; import { getAssistantFlags } from "@/lib/feature-flags"; +import { annotate } from "@/lib/logging/context"; import { generateComprehensiveInsight, type GenerateOutcome, } from "@/lib/insights/comprehensive-generate"; +import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status"; +import { generatePulseStatusForUser } from "@/lib/insights/pulse-status"; +import { generateWeightStatusForUser } from "@/lib/insights/weight-status"; +import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; +import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; +import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; +import { generateGeneralStatusForUser } from "@/lib/insights/general-status"; export const INSIGHT_PREGENERATE_QUEUE = "insight-pregenerate"; @@ -74,6 +92,71 @@ export interface PregenerateRunResult { skipped: number; failed: number; budgetBlocked: number; + /** + * Count of per-metric assessment caches written warm across the whole + * run. The comprehensive generator evicts the seven `*-status` + * caches as part of its write (so a stale comprehensive insight can't + * leave a stale per-metric card), but it does not re-fill them — so + * without this warm pass the first morning visit after the cron runs + * one live status generation per card. This counts the warm writes. + */ + assessmentsWarmed: number; +} + +/** + * Signature for one per-metric status generator. The seven + * `*StatusForUser` functions all share this shape: `(userId, { locale, + * force }) => { hasProvider, text, cached, updatedAt }`. We only need to + * know whether the call produced a fresh (non-cached, provider-backed) + * assessment, so the warm-pass narrows to those two fields. + */ +type StatusGenerator = ( + userId: string, + options: { locale: "de" | "en"; force?: boolean }, +) => Promise<{ hasProvider: boolean; cached: boolean }>; + +/** + * The seven per-metric status generators the warm pass re-fills after + * the comprehensive generator evicted them. Each writes its own + * `insights.-status.` cache row on a fresh generation and + * resolves the user's provider chain internally, so a provider-less + * account costs one cheap chain-resolve and no LLM call (`hasProvider: + * false`). + */ +const DEFAULT_STATUS_GENERATORS: ReadonlyArray = [ + generateBloodPressureStatusForUser, + generatePulseStatusForUser, + generateWeightStatusForUser, + generateBmiStatusForUser, + generateMoodStatusForUser, + generateMedicationComplianceStatusForUser, + generateGeneralStatusForUser, +]; + +/** + * Re-fill the per-metric assessment caches for one user, forcing a fresh + * generation so the row the comprehensive generator just evicted is + * written warm. Returns the count of fresh (provider-backed, + * non-cached) assessments written. A generator that has no provider or + * fails serves its fallback without persisting it, so it does not count + * toward the warmed tally and never throws the batch loop off course. + */ +async function warmPerStatusCaches( + userId: string, + locale: "de" | "en", + generators: ReadonlyArray, +): Promise { + let warmed = 0; + for (const generate of generators) { + try { + const outcome = await generate(userId, { locale, force: true }); + if (outcome.hasProvider && !outcome.cached) warmed++; + } catch { + // A single card's generation failing must not abort the rest of + // the warm pass for this user, nor the cron's user loop. + } + } + return warmed; } interface PregenerateCandidate { @@ -130,11 +213,15 @@ export async function runInsightPregenerate( userId: string, opts: { locale: "de" | "en"; force?: boolean }, ) => Promise; + /** Injected for the test — defaults to the seven real status generators. */ + statusGenerators?: ReadonlyArray; } = {}, ): Promise { const now = options.now ?? new Date(); const cap = options.cap ?? PREGENERATE_BATCH_CAP; const generate = options.generate ?? generateComprehensiveInsight; + const statusGenerators = + options.statusGenerators ?? DEFAULT_STATUS_GENERATORS; const result: PregenerateRunResult = { total: 0, @@ -143,6 +230,7 @@ export async function runInsightPregenerate( skipped: 0, failed: 0, budgetBlocked: 0, + assessmentsWarmed: 0, }; // Master kill-switch — when the operator disabled the briefing @@ -174,8 +262,9 @@ export async function runInsightPregenerate( // pre-generation — defeating the "warm the cache before the user's // morning visit" intent for the common case. The per-user budget // bucket (above) is what bounds cost, not the TTL re-check. + const locale = normalizeLocale(candidate.locale); const outcome = await generate(candidate.id, { - locale: normalizeLocale(candidate.locale), + locale, force: true, }); switch (outcome.status) { @@ -192,7 +281,39 @@ export async function runInsightPregenerate( result.failed++; break; } + + // Warm the per-metric assessment caches the comprehensive generator + // evicts (`evictPerStatusInsightCache`) but does not re-fill. Only + // do this when the comprehensive pass actually produced an insight: + // a `skipped` (no-provider) user has nothing to warm, and a `failed` + // pass means the provider chain is unhealthy this run — re-running + // it seven more times would only multiply the failure. A `cached` + // outcome can't happen here (we always force), but if a future + // change reintroduces it the per-metric caches were not evicted, so + // there is nothing to warm. + if (outcome.status === "generated") { + result.assessmentsWarmed += await warmPerStatusCaches( + candidate.id, + locale, + statusGenerators, + ); + } } + // Wide-event tally so the nightly dashboard can track how many + // comprehensive insights and per-metric assessments each tick wrote. + annotate({ + action: { name: "insights.pregenerate.run" }, + meta: { + total: result.total, + generated: result.generated, + cached: result.cached, + skipped: result.skipped, + failed: result.failed, + budget_blocked: result.budgetBlocked, + assessments_warmed: result.assessmentsWarmed, + }, + }); + return result; }