diff --git a/prisma/migrations/0148_v1168_insight_snapshot_hash/migration.sql b/prisma/migrations/0148_v1168_insight_snapshot_hash/migration.sql new file mode 100644 index 000000000..e667cdb13 --- /dev/null +++ b/prisma/migrations/0148_v1168_insight_snapshot_hash/migration.sql @@ -0,0 +1,15 @@ +-- v1.16.8 — content-hash gate for insight regeneration. +-- +-- `insights_snapshot_hash` stores the SHA-256 fingerprint of the +-- compacted feature snapshot the cached comprehensive insight was +-- generated from. Nightly, forced, and lazy regenerations compare the +-- fresh snapshot's hash against it and skip the provider call +-- (refreshing only `insights_cached_at`) when nothing the prompt sees +-- has changed. +-- +-- `insights_warm_failed_at` records the last failed/timed-out forced +-- warm of the comprehensive insight, so the forced path can back off +-- for an hour instead of re-attempting a broken provider chain on +-- every page-open. Additive + non-destructive. +ALTER TABLE "users" ADD COLUMN "insights_snapshot_hash" TEXT; +ALTER TABLE "users" ADD COLUMN "insights_warm_failed_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2809aeaea..439ea78ad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,6 +60,17 @@ model User { insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw" insightsCachedAt DateTime? @map("insights_cached_at") insightsCachedText String? @map("insights_cached_text") // cached JSON output + // v1.16.8 — SHA-256 fingerprint of the compacted feature snapshot the + // cached comprehensive text was generated from. Nightly / forced / lazy + // regenerations compare the fresh snapshot's hash against this and skip + // the provider call (refreshing only `insightsCachedAt`) when nothing + // the prompt sees has changed. + insightsSnapshotHash String? @map("insights_snapshot_hash") + // v1.16.8 — last time a forced full warm's comprehensive step failed or + // timed out. The forced warm path backs off for an hour after a failure + // so a persistently broken provider chain cannot be re-driven into a + // full generation attempt by every page-open. + insightsWarmFailedAt DateTime? @map("insights_warm_failed_at") // v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors // `coachPrefsJson.excludeMetrics`. The /api/insights/generate route // filters matching keys off `features` before JSON.stringify so the diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 1bb7c077d..531ad9acf 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -342,32 +342,39 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => { }); }); -// v1.4.16 A7.2: every fresh comprehensive insight evicts the per- -// scope status cache so the dashboard and the insights-page status -// cards never disagree. Without this, force-regeneration repaints -// `/api/insights/generate` while `/api/insights/-status` keeps -// returning yesterday's text until midnight Berlin time. -describe("POST /api/insights/generate — per-status cache eviction (A7.2)", () => { - it("deletes per-status audit-log cache rows after a successful generation", async () => { +// v1.16.8 — a fresh comprehensive insight no longer nukes the per-scope +// status cache (the old A7.2 sweep). The cards track their own data via +// the ingest invalidator + per-card content-hash gates; the route's job +// is to store the snapshot fingerprint so the off-request regeneration +// paths can detect "nothing changed" and skip the provider. +describe("POST /api/insights/generate — cache write (v1.16.8)", () => { + it("does NOT delete per-status audit-log cache rows after a successful generation", async () => { makeWorkingProvider(); const res = await POST(jsonRequest({ force: true }) as never); expect(res.status).toBe(200); - expect(prisma.auditLog.deleteMany).toHaveBeenCalledTimes(1); - const args = vi.mocked(prisma.auditLog.deleteMany).mock.calls[0][0]; - expect(args).toMatchObject({ - where: { - userId: "u-1", - action: { startsWith: "insights." }, - AND: [{ action: { contains: "-status." } }], - }, - }); + expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled(); }); - it("does NOT delete per-status cache when serving from the 24h DB cache", async () => { + it("stores the snapshot fingerprint alongside the cached text", async () => { + makeWorkingProvider(); + + const res = await POST(jsonRequest({ force: true }) as never); + expect(res.status).toBe(200); + + expect(prisma.user.update).toHaveBeenCalledTimes(1); + const args = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect(args.data.insightsCachedText).toEqual(expect.any(String)); + // SHA-256 hex digest of the compacted feature snapshot. + expect(args.data.insightsSnapshotHash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("does NOT touch the cache when serving from the 24h DB cache", async () => { // Cached path: route returns early without touching the LLM or the - // cache-eviction helper. + // cache write. vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(), @@ -379,6 +386,7 @@ describe("POST /api/insights/generate — per-status cache eviction (A7.2)", () expect(res.status).toBe(200); const body = (await res.json()) as { data: { cached: boolean } }; expect(body.data.cached).toBe(true); + expect(prisma.user.update).not.toHaveBeenCalled(); expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 3f0c016af..e1e1c4ee4 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -59,6 +59,7 @@ import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; export const dynamic = "force-dynamic"; @@ -91,32 +92,6 @@ export function resolveInsightsRateLimit(): number { return parsed; } -/** - * Per-status insight cache rows live in `audit_logs` keyed by - * `action = "insights.{scope}-status.{locale}"` (see - * `src/lib/insights/memory.ts` for the canonical scope list and - * `src/lib/insights/{general,blood-pressure,weight,pulse,bmi, - * mood,medication-compliance}-status.ts` for the writers). Each scope - * has its own per-day eviction, so before v1.4.16 a force-regeneration - * of the comprehensive insight would leave yesterday's per-status - * cards visible on the insights page until the next calendar day - * flipped. Drop them here so the next status fetch has to call the - * LLM again with the same fresh feature set the comprehensive blob - * just used. - */ -async function evictPerStatusInsightCache(userId: string): Promise { - await prisma.auditLog.deleteMany({ - where: { - userId, - action: { startsWith: "insights." }, - // Keep the `insights.generate` row this request just wrote and - // the `insights.settings.*` rows — only the per-status cache - // entries (`insights.-status.`) carry stale text. - AND: [{ action: { contains: "-status." } }], - }, - }); -} - /** * v1.4.16 phase B8 — fetch the user's persisted comparison toggle and * build a `ComparisonSnapshot` for the prompt builder. Returns null @@ -739,14 +714,19 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { insightsCachedAt: new Date(), insightsCachedText: JSON.stringify(insights), + // v1.16.8 — store the snapshot fingerprint so the nightly / forced + // regeneration paths can detect "nothing changed" and skip the + // provider call. The user-initiated POST itself stays un-gated: an + // explicit regenerate request is honoured even on unchanged data + // (it is already bounded by the hourly rate limit above). + insightsSnapshotHash: hashInsightSnapshot(compactFeatures), }, }); - // v1.4.16 A7: a fresh comprehensive insight always supersedes the - // per-status cache. Otherwise the dashboard re-paints itself with the - // newly generated comprehensive while the insights-page status cards - // still show yesterday's text until midnight Berlin time. - await evictPerStatusInsightCache(userId); + // v1.16.8 — no blanket per-status eviction here any more. The cards + // track their own data through the ingest invalidator and their own + // content-hash gates; nuking ~45 cache rows per manual regenerate was + // the source of the post-regenerate card-regeneration storm. // v1.7.0 W6 — the dashboard snapshot embeds the pre-generated daily // briefing read-only; drop it so the next snapshot carries the fresh diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 7115fe690..4824c0f22 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -7,24 +7,72 @@ * budget bucket is wasted with no actual regeneration. These tests pin * that `force: true` skips the TTL re-check (and `force: false` still * honours a fresh cache). + * + * v1.16.8 — the content-hash gate sits behind the force flag: even a + * forced generation skips the provider call (and only refreshes the + * cache timestamp) when the compacted feature snapshot is unchanged + * since the cached text was generated. These tests pin the skip, the + * timestamp-only refresh, and that a changed snapshot still generates + * and stores the new fingerprint without the old per-status eviction. */ import { describe, it, expect, vi, beforeEach } from "vitest"; const findUnique = vi.fn(); +const userUpdate = vi.fn(); +const auditDeleteMany = vi.fn(); const resolveProviderChain = vi.fn(); const resolveProvider = vi.fn(); +const runRawCompletionWithFallback = vi.fn(); +const extractFeatures = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { - user: { findUnique: (...a: unknown[]) => findUnique(...a) }, + user: { + findUnique: (...a: unknown[]) => findUnique(...a), + update: (...a: unknown[]) => userUpdate(...a), + }, + auditLog: { + deleteMany: (...a: unknown[]) => auditDeleteMany(...a), + }, }, })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), })); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback: (...a: unknown[]) => + runRawCompletionWithFallback(...a), +})); +vi.mock("@/lib/insights/features", () => ({ + FeaturesPayloadTooLargeError: class extends Error { + sizeBytes = 0; + }, + extractFeatures: (...a: unknown[]) => extractFeatures(...a), +})); +// The post-gate prompt assembly reads GLP-1 + about-me context from the +// DB; stub both so the changed-hash path stays DB-free in this test. +vi.mock("@/lib/insights/glp1-plateau", () => ({ + detectGlp1Plateau: vi.fn(async () => null), + buildGlp1PlateauPrompt: vi.fn(() => ""), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(async () => null), + buildAboutMeInsightBlock: vi.fn(() => ""), +})); import { generateComprehensiveInsight } from "../comprehensive-generate"; +import { hashInsightSnapshot } from "../snapshot-hash"; +import { compactSections } from "@/lib/ai/prompts/compact-sections"; + +/** A small data-bearing feature set that survives `compactSections`. */ +const FEATURES = { + weight: { count: 12, latest: 81.4, mean30: 82.1 }, +}; +const FEATURES_HASH = hashInsightSnapshot( + compactSections(FEATURES as unknown as Record), +); beforeEach(() => { vi.clearAllMocks(); @@ -32,6 +80,8 @@ beforeEach(() => { // past the cache short-circuit, which is enough to prove the branch. resolveProviderChain.mockResolvedValue([]); resolveProvider.mockResolvedValue({ type: "none" }); + extractFeatures.mockResolvedValue(FEATURES); + userUpdate.mockResolvedValue({}); }); describe("generateComprehensiveInsight — cache short-circuit", () => { @@ -41,6 +91,7 @@ describe("generateComprehensiveInsight — cache short-circuit", () => { insightsCachedAt: new Date(Date.now() - 21 * 60 * 60 * 1000), insightsCachedText: "{}", insightsExcludeMetrics: [], + insightsSnapshotHash: null, }); const outcome = await generateComprehensiveInsight("u1", { @@ -59,6 +110,7 @@ describe("generateComprehensiveInsight — cache short-circuit", () => { insightsCachedAt: new Date(Date.now() - 21 * 60 * 60 * 1000), insightsCachedText: "{}", insightsExcludeMetrics: [], + insightsSnapshotHash: null, }); const outcome = await generateComprehensiveInsight("u1", { @@ -73,3 +125,140 @@ describe("generateComprehensiveInsight — cache short-circuit", () => { expect(outcome).toEqual({ status: "skipped", reason: "no-provider" }); }); }); + +describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { + function makeByokChain() { + // A BYOK chain never trips the server-managed consent gate. + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: {} }, + ]); + } + + it("skips the provider and refreshes only the timestamp when the snapshot is unchanged — even when forced", async () => { + makeByokChain(); + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsExcludeMetrics: [], + insightsSnapshotHash: FEATURES_HASH, + }); + + const outcome = await generateComprehensiveInsight("u1", { + locale: "de", + force: true, + }); + + expect(outcome).toEqual({ status: "unchanged" }); + // No completion ran. + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + // Timestamp-only refresh: no new text, no new hash, no eviction. + expect(userUpdate).toHaveBeenCalledTimes(1); + const args = userUpdate.mock.calls[0][0] as { + data: Record; + }; + expect(args.data.insightsCachedAt).toEqual(expect.any(Date)); + expect(args.data).not.toHaveProperty("insightsCachedText"); + expect(args.data).not.toHaveProperty("insightsSnapshotHash"); + expect(auditDeleteMany).not.toHaveBeenCalled(); + }); + + it("generates when the snapshot hash differs, stores the new fingerprint, and runs no per-status eviction", async () => { + makeByokChain(); + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsExcludeMetrics: [], + // Stored fingerprint from an older data state. + insightsSnapshotHash: "0".repeat(64), + }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { + content: JSON.stringify({ dailyBriefing: { p: "new" } }), + tokensUsed: 10, + providerType: "openai", + model: "m", + }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + + const outcome = await generateComprehensiveInsight("u1", { + locale: "de", + force: true, + }); + + expect(outcome).toEqual({ status: "generated", providerType: "openai" }); + expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); + // Find the cache write among the user updates. + const write = userUpdate.mock.calls.find( + (c) => + (c[0] as { data: Record }).data.insightsCachedText !== + undefined, + ); + expect(write).toBeTruthy(); + const data = (write![0] as { data: Record }).data; + expect(data.insightsSnapshotHash).toBe(FEATURES_HASH); + // v1.16.8 — the blanket per-status eviction is gone. + expect(auditDeleteMany).not.toHaveBeenCalled(); + }); + + it("generates when no fingerprint is stored yet (first run after the gate shipped)", async () => { + makeByokChain(); + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsExcludeMetrics: [], + insightsSnapshotHash: null, + }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { + content: JSON.stringify({ dailyBriefing: { p: "new" } }), + tokensUsed: 10, + providerType: "openai", + model: "m", + }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + + const outcome = await generateComprehensiveInsight("u1", { + locale: "de", + force: true, + }); + + expect(outcome).toEqual({ status: "generated", providerType: "openai" }); + expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); + }); + + it("does not treat a matching hash as unchanged when there is no cached text to serve", async () => { + makeByokChain(); + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: null, + insightsCachedText: null, + insightsExcludeMetrics: [], + insightsSnapshotHash: FEATURES_HASH, + }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { + content: JSON.stringify({ dailyBriefing: { p: "new" } }), + tokensUsed: 10, + providerType: "openai", + model: "m", + }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + + const outcome = await generateComprehensiveInsight("u1", { + locale: "de", + force: true, + }); + + expect(outcome).toEqual({ status: "generated", providerType: "openai" }); + expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index 8ba5550e5..72cf19705 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -1,12 +1,16 @@ /** * v1.8.0 — measurement-driven per-metric assessment invalidation. * - * A fresh measurement of a given type must drop the cached + * A fresh measurement of a given type must re-warm the cached * `insights.-status.` rows for the scopes that reading - * dirties, so the next mount / nightly warm pass regenerates them - * against the new data instead of serving the pre-measurement text for - * the rest of the day. These tests pin the type → scope mapping and the - * delete shape without a live DB. + * dirties, so the next mount (or the next nightly warm pass) reflects + * the new data instead of serving the pre-measurement text for the rest + * of the day. These tests pin the type → scope mapping, the debounce, + * and (v1.16.8) that the invalidator enqueues regenerations WITHOUT + * deleting the cache rows — the worker's forced generation runs the + * generator's content-hash gate, which decides whether the data + * actually changed; keeping the row preserves stale-while-revalidate + * and lets a re-synced unchanged batch cost zero LLM calls. */ import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -52,20 +56,23 @@ function enqueuedScopes(): string[] { ].sort(); } -function deletedScopes(): string[] { - const arg = deleteMany.mock.calls[0][0]; - // Each OR clause is `{ action: { startsWith: "insights.-status." } }`. - return (arg.where.OR as Array<{ action: { startsWith: string } }>) - .map((c) => c.action.startsWith.replace(/^insights\.(.+)-status\.$/, "$1")) - .sort(); -} - describe("invalidateStatusInsightsForTypes", () => { it("maps WEIGHT to weight + bmi + general", async () => { await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); - expect(deleteMany).toHaveBeenCalledTimes(1); - expect(deletedScopes()).toEqual(["bmi", "general", "weight"]); - expect(deleteMany.mock.calls[0][0].where.userId).toBe("u1"); + expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); + expect( + enqueueStatusGeneration.mock.calls.every( + (c) => (c[0] as { userId: string }).userId === "u1", + ), + ).toBe(true); + }); + + it("never deletes the cached assessment rows (v1.16.8 — hash gate decides)", async () => { + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + // The rows stay for stale-while-revalidate AND for the content-hash + // gate the worker's forced regeneration runs; deleting them here would + // force a full LLM regeneration even for a re-synced unchanged batch. + expect(deleteMany).not.toHaveBeenCalled(); }); it("maps both blood-pressure components to blood-pressure + general", async () => { @@ -73,7 +80,7 @@ describe("invalidateStatusInsightsForTypes", () => { "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", ]); - expect(deletedScopes()).toEqual(["blood-pressure", "general"]); + expect(enqueuedScopes()).toEqual(["blood-pressure", "general"]); }); it("maps PULSE and RESTING_HEART_RATE to pulse + general (+ the resting-HR generic scope)", async () => { @@ -84,7 +91,7 @@ describe("invalidateStatusInsightsForTypes", () => { // PULSE feeds the specialised pulse + general scopes; RESTING_HEART_RATE // feeds those too AND carries its own generic assessment card // (`metric:RESTING_HEART_RATE`), so all three are dirtied. - expect(deletedScopes()).toEqual([ + expect(enqueuedScopes()).toEqual([ "general", "metric:RESTING_HEART_RATE", "pulse", @@ -96,7 +103,7 @@ describe("invalidateStatusInsightsForTypes", () => { // generic `metric:BLOOD_GLUCOSE` card, so a fresh reading dirties the // general overview AND that generic scope. await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]); - expect(deletedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); + expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); }); it("dedupes scopes across a mixed batch", async () => { @@ -107,7 +114,7 @@ describe("invalidateStatusInsightsForTypes", () => { ]); // weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ // general + metric:BLOOD_GLUCOSE (BLOOD_GLUCOSE) - expect(deletedScopes()).toEqual([ + expect(enqueuedScopes()).toEqual([ "bmi", "general", "metric:BLOOD_GLUCOSE", @@ -118,7 +125,7 @@ describe("invalidateStatusInsightsForTypes", () => { it("is a no-op for an empty type set (no DB call)", async () => { await invalidateStatusInsightsForTypes("u1", []); - expect(deleteMany).not.toHaveBeenCalled(); + expect(auditFindMany).not.toHaveBeenCalled(); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); @@ -147,12 +154,11 @@ describe("invalidateStatusInsightsForTypes", () => { expect([...locales]).toEqual(["en"]); }); - it("does not blanket-evict general for an unrelated synced type", async () => { + it("does not blanket-dirty general for an unrelated synced type", async () => { // A steps sample touches only the general overview, never the weight / // pulse / bp scopes — the constant Apple-Health sync must not thrash // every card's cache. await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]); - expect(deletedScopes()).toEqual(["general", "metric:STEPS"]); expect(enqueuedScopes()).toEqual(["general", "metric:STEPS"]); }); @@ -162,7 +168,6 @@ describe("invalidateStatusInsightsForTypes", () => { // freshly-synced metric's card refreshes in the background instead of // lagging until the nightly warm pass. await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]); - expect(deletedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); }); @@ -184,7 +189,7 @@ describe("invalidateStatusInsightsForTypes", () => { ).toBe(false); }); - describe("ingest-invalidation debounce (v1.9.0)", () => { + describe("ingest-invalidation debounce (v1.9.0, 6 h window since v1.16.8)", () => { /** Build a recent (within-window) real assessment cache row. */ function freshRow(scope: string, locale = "de") { return { @@ -194,22 +199,20 @@ describe("invalidateStatusInsightsForTypes", () => { } it("skips a scope whose assessment was warmed within the window", async () => { - // `general` was regenerated minutes ago; a fresh WEIGHT sample must not - // delete or re-enqueue it. weight + bmi are still stale, so they refresh. + // `general` was regenerated within the window; a fresh WEIGHT sample + // must not re-enqueue it. weight + bmi are still stale, so they refresh. auditFindMany.mockResolvedValue([freshRow("general")]); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); - expect(deletedScopes()).toEqual(["bmi", "weight"]); expect(enqueuedScopes()).toEqual(["bmi", "weight"]); }); - it("is a complete no-op (no delete, no enqueue) when every scope is fresh", async () => { + it("is a complete no-op (no enqueue) when every scope is fresh", async () => { auditFindMany.mockResolvedValue([ freshRow("weight"), freshRow("bmi"), freshRow("general"), ]); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); - expect(deleteMany).not.toHaveBeenCalled(); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); @@ -223,12 +226,12 @@ describe("invalidateStatusInsightsForTypes", () => { }, ]); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); - expect(deletedScopes()).toEqual(["bmi", "general", "weight"]); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); }); - it("scopes the freshness probe to the user's resolved locale and the window cutoff", async () => { + it("scopes the freshness probe to the user's resolved locale and a 6 h window cutoff", async () => { userFindUnique.mockResolvedValue({ locale: "en" }); + const before = Date.now(); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); const where = auditFindMany.mock.calls[0][0].where; expect(where.userId).toBe("u1"); @@ -240,9 +243,14 @@ describe("invalidateStatusInsightsForTypes", () => { "insights.general-status.en", ]), ); - // A recency floor is applied (createdAt >= cutoff in the past). - expect(where.createdAt.gte).toBeInstanceOf(Date); - expect(where.createdAt.gte.getTime()).toBeLessThan(Date.now()); + // The recency floor is six hours in the past (v1.16.8 — widened + // from 30 min so an all-day syncer refreshes a scope at most a few + // times, with the hash gate dropping unchanged refreshes to zero + // LLM calls). + const cutoff = (where.createdAt.gte as Date).getTime(); + const sixHours = 6 * 60 * 60 * 1000; + expect(before - cutoff).toBeGreaterThanOrEqual(sixHours - 5_000); + expect(before - cutoff).toBeLessThanOrEqual(sixHours + 60_000); }); }); }); diff --git a/src/lib/insights/__tests__/snapshot-hash.test.ts b/src/lib/insights/__tests__/snapshot-hash.test.ts new file mode 100644 index 000000000..9538da8b1 --- /dev/null +++ b/src/lib/insights/__tests__/snapshot-hash.test.ts @@ -0,0 +1,104 @@ +/** + * v1.16.8 — the snapshot fingerprint that gates insight regeneration. + * + * The hash must be: deterministic across key order (so two structurally + * equal snapshots always match), locale-agnostic and day-label-agnostic + * (so a midnight rollover or a locale switch alone never forces a + * regeneration), clock-offset-agnostic (dayOffset / *DaysAgo shift with + * `now`, not with the data), and sensitive to every actual data value + * the prompt narrates. All assertions use fixed inputs — no clocks, no + * timezones. + */ +import { describe, it, expect } from "vitest"; + +import { hashInsightSnapshot } from "../snapshot-hash"; + +describe("hashInsightSnapshot", () => { + it("is a sha256 hex digest", () => { + expect(hashInsightSnapshot({ a: 1 })).toMatch(/^[0-9a-f]{64}$/); + }); + + it("is stable across object key order", () => { + const a = hashInsightSnapshot({ weight: { mean: 80, n: 3 }, pulse: 60 }); + const b = hashInsightSnapshot({ pulse: 60, weight: { n: 3, mean: 80 } }); + expect(a).toBe(b); + }); + + it("changes when a data value changes", () => { + const a = hashInsightSnapshot({ weight: { mean: 80.0 } }); + const b = hashInsightSnapshot({ weight: { mean: 80.1 } }); + expect(a).not.toBe(b); + }); + + it("changes when an array gains an entry or reorders", () => { + const base = hashInsightSnapshot({ series: [1, 2, 3] }); + expect(hashInsightSnapshot({ series: [1, 2, 3, 4] })).not.toBe(base); + expect(hashInsightSnapshot({ series: [3, 2, 1] })).not.toBe(base); + }); + + it("ignores locale and day-label fields", () => { + const de = hashInsightSnapshot({ + locale: "de", + generatedForDay: "2026-06-10", + weight: { mean: 80 }, + }); + const en = hashInsightSnapshot({ + locale: "en", + generatedForDay: "2026-06-11", + weight: { mean: 80 }, + }); + expect(de).toBe(en); + }); + + it("ignores clock-relative offsets (dayOffset, *DaysAgo) but not the values", () => { + const yesterday = hashInsightSnapshot({ + dataCoverage: { totalMeasurements: 12, newestMeasurementDaysAgo: 0 }, + daily: [ + { dayOffset: 0, value: 80.2, n: 1 }, + { dayOffset: 1, value: 80.4, n: 2 }, + ], + }); + const today = hashInsightSnapshot({ + dataCoverage: { totalMeasurements: 12, newestMeasurementDaysAgo: 1 }, + daily: [ + { dayOffset: 1, value: 80.2, n: 1 }, + { dayOffset: 2, value: 80.4, n: 2 }, + ], + }); + // Same readings, one day later, nothing new logged → same fingerprint. + expect(yesterday).toBe(today); + + const newReading = hashInsightSnapshot({ + dataCoverage: { totalMeasurements: 13, newestMeasurementDaysAgo: 0 }, + daily: [ + { dayOffset: 0, value: 79.9, n: 1 }, + { dayOffset: 1, value: 80.2, n: 1 }, + { dayOffset: 2, value: 80.4, n: 2 }, + ], + }); + expect(newReading).not.toBe(today); + }); + + it("keeps absolute day keys in the fingerprint", () => { + const a = hashInsightSnapshot({ recent: [{ date: "2026-06-09", mean: 80 }] }); + const b = hashInsightSnapshot({ recent: [{ date: "2026-06-10", mean: 80 }] }); + expect(a).not.toBe(b); + }); + + it("serialises Date values as ISO strings", () => { + const a = hashInsightSnapshot({ at: new Date("2026-06-10T00:00:00.000Z") }); + const b = hashInsightSnapshot({ at: "2026-06-10T00:00:00.000Z" }); + expect(a).toBe(b); + }); + + it("treats null and missing distinctly, and drops undefined members like JSON", () => { + const withNull = hashInsightSnapshot({ mood: null, weight: 80 }); + const withoutKey = hashInsightSnapshot({ weight: 80 }); + const withUndefined = hashInsightSnapshot({ + mood: undefined, + weight: 80, + } as Record); + expect(withNull).not.toBe(withoutKey); + expect(withUndefined).toBe(withoutKey); + }); +}); diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index ba798514f..744937aad 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn(), findMany: vi.fn() }, + auditLog: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() }, }, })); @@ -20,6 +20,7 @@ import { prisma } from "@/lib/db"; import { isTimeoutStub, readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, } from "../status-cache"; @@ -243,3 +244,128 @@ describe("resolveReadOnlyStatusMiss", () => { expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1); }); }); + +describe("refreshUnchangedStatusInsight (v1.16.8)", () => { + const HASH = "a".repeat(64); + + it("re-persists the row under today's dateKey and returns the text on a hash match", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: "2026-05-30", + locale: "en", + text: "Stable weight, no concerns.", + providerType: "openai", + model: "gpt-4o-mini", + tokensUsed: 321, + snapshotHash: HASH, + }) as never, + ); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date("2026-05-31T04:30:00.000Z"), + } as never); + + const hit = await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }); + + expect(hit?.text).toBe("Stable weight, no concerns."); + expect(hit?.updatedAt).toBe("2026-05-31T04:30:00.000Z"); + // The refresh row carries the same payload re-keyed to today, so the + // read path and the ingest debounce both see a current assessment. + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + const arg = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { + data: { userId: string; action: string; details: string }; + }; + expect(arg.data.userId).toBe("u1"); + expect(arg.data.action).toBe("insights.weight-status.en"); + const details = JSON.parse(arg.data.details) as Record; + expect(details.dateKey).toBe(TODAY); + expect(details.text).toBe("Stable weight, no concerns."); + expect(details.snapshotHash).toBe(HASH); + }); + + it("misses (and writes nothing) when the stored hash differs", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: "2026-05-30", + text: "Older text.", + model: "gpt-4o-mini", + snapshotHash: "b".repeat(64), + }) as never, + ); + const hit = await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }); + expect(hit).toBeNull(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it("misses when the latest row carries no hash (pre-gate rows)", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: "2026-05-30", + text: "Older text.", + model: "gpt-4o-mini", + }) as never, + ); + const hit = await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }); + expect(hit).toBeNull(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it("never refreshes off a timeout stub, even with a matching hash", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: "2026-05-30", + text: "Generic fallback advice.", + model: "timeout-stub", + timeout: true, + snapshotHash: HASH, + }) as never, + ); + const hit = await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }); + expect(hit).toBeNull(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it("misses on no prior row and on malformed payloads", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null as never); + expect( + await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }), + ).toBeNull(); + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ + details: "{not json", + } as never); + expect( + await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }), + ).toBeNull(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index 07b454a62..dfa1a2c5c 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -184,6 +184,120 @@ describe("generateWeightStatusForUser — cache-read skips a stub", () => { }); }); +describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => { + it("skips the completion and refreshes the cache row when the snapshot is unchanged", async () => { + // Fixed clock so both generator runs build the identical snapshot. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-10T10:00:00.000Z")); + try { + const now = new Date(); + const records = [ + { type: "WEIGHT", value: 82, measuredAt: new Date(now.getTime() - dayMs) }, + { type: "WEIGHT", value: 81.6, measuredAt: new Date(now.getTime() - 2 * dayMs) }, + ]; + // Fresh copy per call — the generator reverses the result array in + // place, and a shared fixture would flip order between the two runs. + vi.mocked(prisma.measurement.findMany).mockImplementation( + (async () => records.map((r) => ({ ...r }))) as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + stubCompletion('{"summary":"First real assessment."}'); + + // First run: a real generation persists the snapshot fingerprint. + await generateWeightStatusForUser("user-1", { + locale: "en", + force: true, + }); + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + const persisted = JSON.parse( + ( + vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { + data: { details: string }; + } + ).data.details, + ) as { text: string; snapshotHash: string }; + expect(persisted.snapshotHash).toMatch(/^[0-9a-f]{64}$/); + + // Second run, same data: the gate finds the matching fingerprint, + // re-persists the same text under today's dateKey, and never calls + // the provider — even though the run is forced. + vi.mocked(runStatusCompletion).mockClear(); + vi.mocked(prisma.auditLog.create).mockClear(); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: now, + details: JSON.stringify({ + // Yesterday's row — outside the same-day cache read. + dateKey: "2026-06-09", + locale: "en", + text: persisted.text, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + snapshotHash: persisted.snapshotHash, + }), + } as never); + + const result = await generateWeightStatusForUser("user-1", { + locale: "en", + force: true, + }); + + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(result.cached).toBe(true); + expect(result.text).toBe(persisted.text); + // The refresh row re-keys the payload to today. + const refreshed = JSON.parse( + ( + vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { + data: { details: string }; + } + ).data.details, + ) as { dateKey: string; snapshotHash: string }; + expect(refreshed.snapshotHash).toBe(persisted.snapshotHash); + expect(refreshed.dateKey).not.toBe("2026-06-09"); + } finally { + vi.useRealTimers(); + } + }); + + it("regenerates when the stored fingerprint differs from the fresh snapshot", async () => { + const now = new Date(); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", value: 82, measuredAt: now }, + ] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: now, + details: JSON.stringify({ + dateKey: "2026-06-09", + locale: "en", + text: "Older assessment.", + providerType: "anthropic", + model: "x", + tokensUsed: 1, + snapshotHash: "f".repeat(64), + }), + } as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + stubCompletion('{"summary":"Fresh assessment for changed data."}'); + + const result = await generateWeightStatusForUser("user-1", { + locale: "en", + force: true, + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("Fresh assessment for changed data."); + expect(result.cached).toBe(false); + }); +}); + describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 28683a4af..f779671bd 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -51,9 +51,11 @@ import { import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -542,6 +544,24 @@ export async function generateBloodPressureStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + const previousContext = await getPreviousInsightContext( userId, "blood-pressure-status", @@ -628,6 +648,7 @@ export async function generateBloodPressureStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index f30555b77..6bf7244b9 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -29,9 +29,11 @@ import { import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -234,6 +236,24 @@ export async function generateBmiStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + const previousContext = await getPreviousInsightContext( userId, "bmi-status", @@ -313,6 +333,7 @@ export async function generateBmiStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 907a175b4..7f064d98d 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -70,8 +70,8 @@ import { import { isTimeoutStub, statusCacheAction, - statusCacheActionPrefix, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { metricIdForMeasurementType, metricStatusScope, @@ -83,7 +83,7 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; /** * Debounce window for ingest-driven status invalidation. A scope whose * cached assessment was (re)generated within this window is left intact on - * a fresh measurement ingest — neither evicted nor re-enqueued. + * a fresh measurement ingest — not re-enqueued. * * A constantly-syncing client (Apple Health drips batches every few * minutes) used to delete + re-enqueue every dirtied scope on every batch, @@ -93,14 +93,17 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; * times an hour and the assessment felt "regenerated on every visit". This * window coalesces the storm: a genuinely stale scope (no fresh assessment) * still refreshes immediately, but a scope refreshed inside the window is - * skipped, so a fresh assessment survives the day's sync drip and a burst - * of batches costs at most one regeneration per scope per window. + * skipped, so a fresh assessment survives the day's sync drip. * - * 30 min is comfortably longer than a typical sync cadence yet far shorter - * than the 24 h assessment TTL, so a card still tracks new data within the - * same session without thrashing the provider. + * v1.16.8 — widened from 30 min to 6 h. An assessment is a daily prose + * summary, not a live chart; a card that re-narrates the same day's data + * every half hour burns provider budget with no informational gain. Six + * hours bounds an all-day syncer to at most ~3 ingest-driven refreshes per + * scope, and the content-hash gate in each generator drops even those to + * zero LLM calls when the snapshot did not actually change. The 24 h TTL + * and the nightly warm still guarantee a fresh narration every morning. */ -const INGEST_INVALIDATE_DEBOUNCE_MS = 30 * 60 * 1000; +const INGEST_INVALIDATE_DEBOUNCE_MS = 6 * 60 * 60 * 1000; const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ "anthropometrics", @@ -115,6 +118,13 @@ const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ export type GenerateOutcome = | { status: "cached" } | { status: "generated"; providerType: string } + /** + * v1.16.8 — the content-hash gate found the compacted feature snapshot + * unchanged since the cached text was generated. No provider call was + * made; only `insightsCachedAt` was refreshed so the freshness windows + * (GET read, nightly discovery) treat the cache as current. + */ + | { status: "unchanged" } | { status: "skipped"; reason: "no-provider" | "no-consent" } | { status: "failed"; reason: string }; @@ -225,25 +235,12 @@ export async function buildComparisonSnapshotForUser( return { baseline, metrics }; } -/** Evict the per-status insight cache rows (`insights.-status.`). */ -export async function evictPerStatusInsightCache( - userId: string, -): Promise { - await prisma.auditLog.deleteMany({ - where: { - userId, - action: { startsWith: "insights." }, - AND: [{ action: { contains: "-status." } }], - }, - }); -} - /** * 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. + * cached text under `insights.-status.`; the nightly warm + * pass refreshes them through each generator's content-hash gate, and the + * targeted invalidator below re-enqueues only the scopes a fresh + * measurement of a given type actually dirties. */ export const PER_STATUS_SCOPES = [ "blood-pressure", @@ -281,16 +278,19 @@ function statusScopesForMeasurementType(type: MeasurementType): PerStatusScope[] } /** - * Drop the cached per-metric assessment rows that a batch of fresh + * Re-warm the cached per-metric assessments 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. + * pass) reflects 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. + * Fire-and-forget from the measurement ingest path — idempotent and + * never a blocker on the user's write. v1.16.8 — the invalidator no + * longer DELETES the cache rows: the worker regenerates each enqueued + * scope with `force: true`, and the generator's content-hash gate + * decides whether the data actually changed. Keeping the row intact + * preserves the stale-while-revalidate read AND lets the gate skip the + * LLM entirely when the dirtying batch turned out to be a re-sync of + * known data. */ export async function invalidateStatusInsightsForTypes( userId: string, @@ -319,8 +319,8 @@ export async function invalidateStatusInsightsForTypes( // v1.8.7 — regenerate only the user's resolved locale, matching the // read-path (every `*-status` GET serves `normalizeLocale(user.locale)`). // Warming both locales doubled provider spend on every sync, half of it - // for a language the user never opens. The nightly warm pass still covers - // both locales for the rare locale switch. + // for a language the user never opens. A second locale a client actually + // reads warms lazily through the read-path enqueue on its first miss. const localeRow = await prisma.user.findUnique({ where: { id: userId }, select: { locale: true }, @@ -328,15 +328,14 @@ export async function invalidateStatusInsightsForTypes( const locale = normalizeLocale(localeRow?.locale); // v1.9.0 — debounce the ingest-invalidation storm. A constantly-syncing - // client drips batches every few minutes; deleting + re-enqueuing every - // dirtied scope on every batch regenerated the same card several times an - // hour (the per-(user,scope,locale) enqueue singleton is only 120 s, so it - // could not coalesce across batches arriving minutes apart, and each - // delete dropped the row a fresh enqueue would otherwise have found warm). - // Skip any scope whose cached assessment was (re)generated within the - // debounce window — leave its row intact and do NOT re-enqueue. A - // genuinely stale or missing scope still refreshes immediately, so - // correctness holds; only the redundant churn is removed. + // client drips batches every few minutes; re-enqueuing every dirtied + // scope on every batch regenerated the same card several times an hour + // (the per-(user,scope,locale) enqueue singleton is only 120 s, so it + // could not coalesce across batches arriving minutes apart). Skip any + // scope whose cached assessment was (re)generated within the debounce + // window — leave its row intact and do NOT re-enqueue. A genuinely stale + // or missing scope still refreshes immediately, so correctness holds; + // only the redundant churn is removed. const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes); const staleScopes = Array.from(scopes).filter( (scope) => !freshScopes.has(scope), @@ -349,28 +348,14 @@ export async function invalidateStatusInsightsForTypes( return; } - // Drop the cached per-metric assessment rows that are actually stale, so - // the next mount / nightly warm pass regenerates them against the new - // data. The `-status.` substring guard keeps the sweep off the - // comprehensive cache and any unrelated `insights.*` audit row. - await prisma.auditLog.deleteMany({ - where: { - userId, - OR: staleScopes.map((scope) => ({ - action: { startsWith: statusCacheActionPrefix(scope) }, - })), - }, - }); - - // v1.8.7 — regenerate-on-invalidate. Deleting the today-row alone left - // the card to re-warm only on the user's next category open (a miss → a - // worker round-trip while they wait) or the nightly cron. Instead, - // proactively enqueue a debounced regenerate for each dirtied scope so - // the cache is re-warmed in the background. The enqueue is coalesced per - // `(user, metric, locale)` via the queue's `singletonKey` (120 s window); - // the debounce above is the second, wider coalescing layer that survives - // across the sync drip. The stale-while-revalidate read keeps the - // previous assessment visible until the fresh one lands. + // v1.8.7 — regenerate-on-invalidate: enqueue a debounced regenerate for + // each dirtied scope so the cache is re-warmed in the background. The + // enqueue is coalesced per `(user, metric, locale)` via the queue's + // `singletonKey` (120 s window); the debounce above is the second, wider + // coalescing layer that survives across the sync drip. The cache row + // stays in place (stale-while-revalidate keeps the previous assessment + // visible) — the worker forces the generator, whose content-hash gate + // skips the LLM when the batch did not actually change the snapshot. for (const scope of staleScopes) { void enqueueStatusGeneration({ userId, metric: scope, locale }); } @@ -454,9 +439,8 @@ interface GenerateOptions { * The on-demand `forceWarmUser` path bounds the comprehensive step with a * timeout and then proceeds to warm the per-status caches itself. Without * this signal a comprehensive that resolves AFTER the timeout would still - * reach `evictPerStatusInsightCache` and delete the rows the warm passes - * had just written — undoing its own work. The signal lets the caller cut - * the generation off before the eviction so the warmed rows survive. + * write a cache row + timestamp the caller no longer expects. The signal + * lets the caller cut the generation off before the cache write. */ signal?: AbortSignal; } @@ -487,6 +471,7 @@ export async function generateComprehensiveInsight( insightsCachedAt: true, insightsCachedText: true, insightsExcludeMetrics: true, + insightsSnapshotHash: true, }, }); @@ -555,6 +540,28 @@ export async function generateComprehensiveInsight( ); const featuresJson = JSON.stringify(compactFeatures, null, 2); + // v1.16.8 — content-hash gate. When the compacted feature snapshot is + // byte-equivalent (modulo clock-relative offsets, see snapshot-hash.ts) + // to the one the cached text was generated from, nothing the prompt + // sees has changed — refresh the cache timestamp and skip the provider + // call. This runs on the forced paths too (nightly tick, on-demand + // warm), which is exactly where the same-data regeneration waste lived. + const snapshotHash = hashInsightSnapshot(compactFeatures); + if ( + dbUser?.insightsCachedText && + dbUser.insightsSnapshotHash === snapshotHash + ) { + await prisma.user.update({ + where: { id: userId }, + data: { insightsCachedAt: new Date() }, + }); + annotate({ + action: { name: "insights.generate.skipped_unchanged" }, + meta: { locale }, + }); + return { status: "unchanged" }; + } + const comparisonSnapshot = await buildComparisonSnapshotForUser(userId); const plateauContext = await detectGlp1Plateau(userId); let userPrompt = buildUserPrompt( @@ -622,8 +629,8 @@ export async function generateComprehensiveInsight( // v1.9.0 — the caller abandoned this generation (its bounded timeout // fired and it has moved on to warm the per-status caches itself). Bail - // out before the write + evict so a late-resolving comprehensive can never - // delete the rows the warm passes have since written. The provider call + // out before the cache write so a late-resolving comprehensive cannot + // stamp a timestamp/text the caller no longer expects. The provider call // already cost what it cost; what we must NOT do now is touch the cache. if (signal?.aborted) { return { status: "failed", reason: "aborted" }; @@ -634,9 +641,14 @@ export async function generateComprehensiveInsight( data: { insightsCachedAt: new Date(), insightsCachedText: JSON.stringify(insights), + insightsSnapshotHash: snapshotHash, }, }); - await evictPerStatusInsightCache(userId); + // v1.16.8 — no blanket per-status eviction here any more. The cards + // track their own data through the ingest invalidator and their own + // content-hash gates; a fresh comprehensive briefing does not change + // what a per-metric card should say, and the old sweep was the reason + // every warm had to regenerate ~45 cards across both locales. invalidateUserInsights(userId); return { status: "generated", providerType: workingProviderType }; diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index a0c325324..f1aeec4df 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -31,9 +31,11 @@ import { import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -361,6 +363,24 @@ export async function generateGeneralStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + // v1.4: pull the previous cached general-status into the prompt so // the model can compare to the user's last analysis. Falls back // gracefully when there's no history (first-run users). @@ -443,6 +463,7 @@ export async function generateGeneralStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 47000993e..cb6bc1897 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -37,6 +37,7 @@ import { resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -350,6 +351,57 @@ export async function generateMedicationComplianceStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + // This generator caches the richer `{ summary, medications }` envelope, + // so it carries its own gate rather than the shared text-only one. + const snapshotHash = hashInsightSnapshot(snapshot); + const latestRow = await prisma.auditLog.findFirst({ + where: { userId, action: cacheAction }, + orderBy: { createdAt: "desc" }, + select: { details: true }, + }); + if (latestRow?.details) { + try { + const parsed = JSON.parse(latestRow.details) as { + summary?: string; + medications?: MedicationSummaryItem[]; + model?: string; + timeout?: boolean; + snapshotHash?: string; + }; + if ( + !isTimeoutStub(parsed) && + typeof parsed.summary === "string" && + parsed.summary.trim().length > 0 && + Array.isArray(parsed.medications) && + parsed.snapshotHash === snapshotHash + ) { + const refreshed = await prisma.auditLog.create({ + data: { + userId, + action: cacheAction, + details: JSON.stringify({ ...parsed, dateKey: todayKey }), + }, + select: { createdAt: true }, + }); + annotate({ + action: { name: "insights.status.skipped_unchanged" }, + meta: { cache_action: cacheAction }, + }); + return { + hasProvider: true, + summary: parsed.summary, + medications: parsed.medications, + cached: true, + updatedAt: refreshed.createdAt.toISOString(), + }; + } + } catch { + // Malformed cache payload — fall through to a real generation. + } + } + const previousContext = await getPreviousInsightContext( userId, "medication-compliance-status", @@ -463,6 +515,7 @@ export async function generateMedicationComplianceStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }), }, select: { createdAt: true }, diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 631aa5472..2effffdcc 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -70,9 +70,11 @@ import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -385,6 +387,24 @@ export async function generateMetricStatus(args: { }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId: args.userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + const previousContext = await getPreviousInsightContext( args.userId, // memory.ts keys previous context by the `insights..` @@ -480,6 +500,7 @@ export async function generateMetricStatus(args: { providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { hasProvider: true, text, cached: false, updatedAt }; diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 0da269f7c..2fefef2ef 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -48,9 +48,11 @@ import { import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; @@ -552,6 +554,24 @@ export async function generateMoodStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + const previousContext = await getPreviousInsightContext( userId, "mood-status", @@ -632,6 +652,7 @@ export async function generateMoodStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 4eb63d8a7..17b658e89 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -37,9 +37,11 @@ import { import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey, userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; @@ -318,6 +320,24 @@ export async function generatePulseStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + const previousContext = await getPreviousInsightContext( userId, "pulse-status", @@ -395,6 +415,7 @@ export async function generatePulseStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { diff --git a/src/lib/insights/snapshot-hash.ts b/src/lib/insights/snapshot-hash.ts new file mode 100644 index 000000000..1369179c7 --- /dev/null +++ b/src/lib/insights/snapshot-hash.ts @@ -0,0 +1,83 @@ +/** + * v1.16.8 — content hash over the data snapshot that feeds an insight + * generation. + * + * Every insight generator (the comprehensive briefing, the seven + * specialised status cards, the generic metric cards) builds a compacted + * data snapshot and embeds it in the prompt. Before this hash existed, + * every nightly tick, forced warm, and ingest-driven regeneration ran the + * full LLM round-trip even when that snapshot was byte-identical to the + * one the cached text was generated from — a same-data regeneration that + * produced a near-identical paragraph at full provider cost, dozens of + * times per user per day. + * + * `hashInsightSnapshot` turns the snapshot into a deterministic SHA-256 + * fingerprint. Callers store it alongside the cached text and skip the + * provider call whenever the stored hash matches the fresh snapshot's + * hash (refreshing only the cache timestamp). The fingerprint covers the + * data the prompt actually sees, with two deliberate exclusions: + * + * - `locale` + day labels (`generatedForDay`, `dateKey`, `todayKey`): + * the underlying data is locale-independent and the calendar label + * changes at midnight without any data change. Excluding them keeps + * the gate locale-agnostic and stable across the day rollover. + * - clock-relative offsets (`dayOffset`, keys ending in `DaysAgo`): + * these shift with `now`, not with the data. The bucket VALUES (and + * the absolute `date` / `weekISO` / `month` keys the graded series + * carry) stay in the hash, so a genuinely new or changed reading + * still changes the fingerprint. Sliding windows still move at the + * Berlin day boundary, so an active account converges to roughly one + * content change per scope per day — which is the regeneration + * budget the gate is meant to enforce. + * + * Serialisation is canonical: object keys are sorted, arrays keep their + * order, `Date` collapses to ISO-8601, `undefined` members are dropped + * (matching `JSON.stringify`). Two snapshots that would render the same + * prompt data therefore always hash equal, regardless of property + * insertion order. + */ +import { createHash } from "crypto"; + +/** Keys excluded from the fingerprint — see the module doc for why. */ +const VOLATILE_KEYS = new Set([ + "locale", + "generatedForDay", + "dateKey", + "todayKey", + "dayOffset", +]); + +function isVolatileKey(key: string): boolean { + return VOLATILE_KEYS.has(key) || key.endsWith("DaysAgo"); +} + +/** Canonical, key-sorted serialisation with volatile keys dropped. */ +function canonicalise(value: unknown): string { + if (value === null) return "null"; + if (value instanceof Date) return JSON.stringify(value.toISOString()); + if (Array.isArray(value)) { + return `[${value + .map((entry) => canonicalise(entry === undefined ? null : entry)) + .join(",")}]`; + } + if (typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([key, v]) => v !== undefined && !isVolatileKey(key)) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, v]) => `${JSON.stringify(key)}:${canonicalise(v)}`); + return `{${entries.join(",")}}`; + } + // string | number | boolean — JSON.stringify is canonical for these. + // (undefined / function / symbol never appear in the JSON-ready + // snapshots; a top-level undefined canonicalises like JSON's null.) + return JSON.stringify(value) ?? "null"; +} + +/** + * SHA-256 hex fingerprint of an insight data snapshot. Deterministic + * across key order, locale, and intra-day clock drift; changes whenever + * a value the prompt actually narrates changes. + */ +export function hashInsightSnapshot(value: unknown): string { + return createHash("sha256").update(canonicalise(value)).digest("hex"); +} diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 352929dc2..960bd8a4d 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -59,6 +59,8 @@ interface ParsedStatusCache { timeout?: boolean; /** v1.8.3 — ISO timestamp before which a timeout stub suppresses re-enqueue. */ retryAt?: string; + /** v1.16.8 — fingerprint of the data snapshot the text was generated from. */ + snapshotHash?: string; } /** @@ -121,6 +123,69 @@ export async function readFreshStatusText(args: { } } +/** + * v1.16.8 — the content-hash regeneration gate for the per-status and + * generic metric cards. + * + * A generator that has already gathered its data snapshot calls this + * BEFORE the provider round-trip. When the latest cached assessment for + * `(userId, cacheAction)` is a real (non-stub) text whose stored + * `snapshotHash` equals the fresh snapshot's hash, nothing the prompt + * sees has changed — so the gate re-persists the same text under + * today's `dateKey` (a pure timestamp refresh that keeps the read path + * and the ingest debounce satisfied) and returns it, and the caller + * skips the LLM call entirely. Returns `null` on any miss (no prior + * row, stub, empty text, missing or differing hash) — every one of + * those means the caller should generate for real. + * + * This single gate is what turns the nightly warm, the forced warm, and + * the ingest-driven regeneration into no-ops on unchanged data: each of + * those paths forces past the same-day cache read, gathers the + * snapshot, and lands here. + */ +export async function refreshUnchangedStatusInsight(args: { + userId: string; + cacheAction: string; + todayKey: string; + snapshotHash: string; +}): Promise { + const latest = await prisma.auditLog.findFirst({ + where: { userId: args.userId, action: args.cacheAction }, + orderBy: { createdAt: "desc" }, + select: { details: true }, + }); + if (!latest?.details) return null; + + let parsed: ParsedStatusCache; + try { + parsed = JSON.parse(latest.details) as ParsedStatusCache; + } catch { + return null; + } + if (isTimeoutStub(parsed)) return null; + if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { + return null; + } + if (parsed.snapshotHash !== args.snapshotHash) return null; + + // Same data, valid text — refresh the cache row's day key so the + // read path serves it as today's assessment and the ingest debounce + // window restarts, without touching the provider. + const created = await prisma.auditLog.create({ + data: { + userId: args.userId, + action: args.cacheAction, + details: JSON.stringify({ ...parsed, dateKey: args.todayKey }), + }, + select: { createdAt: true }, + }); + annotate({ + action: { name: "insights.status.skipped_unchanged" }, + meta: { cache_action: args.cacheAction }, + }); + return { text: parsed.text, updatedAt: created.createdAt.toISOString() }; +} + export interface LastGoodStatusHit { text: string; updatedAt: string; diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index 04599c6d1..7e6f4ad7b 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -166,6 +166,13 @@ export async function persistStatusInsight(args: { providerType: string; model: string; tokensUsed: number | null; + /** + * v1.16.8 — fingerprint of the data snapshot this assessment was + * generated from (see `snapshot-hash.ts`). The regeneration gate + * compares the fresh snapshot's hash against this and skips the + * provider call when nothing changed. + */ + snapshotHash?: string; }): Promise { const created = await prisma.auditLog.create({ data: { @@ -178,6 +185,7 @@ export async function persistStatusInsight(args: { providerType: args.providerType, model: args.model, tokensUsed: args.tokensUsed, + ...(args.snapshotHash ? { snapshotHash: args.snapshotHash } : {}), }), }, select: { createdAt: true }, diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 14c9faca4..6363cee8c 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -40,9 +40,11 @@ import { import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -426,6 +428,24 @@ export async function generateWeightStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + const previousContext = await getPreviousInsightContext( userId, "weight-status", @@ -507,6 +527,7 @@ export async function generateWeightStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 440ae942b..17dd2a900 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -6,7 +6,10 @@ * - the per-user budget gate blocks a user already generated today; * - the master assistant kill-switch short-circuits the whole run; * - the generator outcomes tally correctly (generated / cached / - * skipped / failed); + * unchanged / skipped / failed); + * - the warm pass is single-locale + refill-only (v1.16.8); + * - the forced single-user warm is idempotent (freshness re-check), + * backs off after a failure, and is capped per day (v1.16.8); * - the queue is registered in `allQueues` AND scheduled in * reminder-worker.ts (the v1.4.37 W10 unregistered-queue catch). */ @@ -68,15 +71,26 @@ import { forceWarmUser, findPregenerateCandidates, PREGENERATE_STALE_MS, + FORCE_WARM_DAILY_LIMIT, INSIGHT_PREGENERATE_QUEUE, INSIGHT_PREGENERATE_CRON, } from "../insight-pregenerate"; function makePrisma(users: Array<{ id: string; locale: string | null }>) { const findMany = vi.fn().mockResolvedValue(users); + // forceWarmUser reads `insightsCachedAt` / `insightsWarmFailedAt` at job + // start and maintains the failure marker; default: never warmed, never + // failed. + const findUnique = vi.fn().mockResolvedValue({ + insightsCachedAt: null, + insightsWarmFailedAt: null, + }); + const update = vi.fn().mockResolvedValue({}); return { - prisma: { user: { findMany } }, + prisma: { user: { findMany, findUnique, update } }, findMany, + findUnique, + update, }; } @@ -170,7 +184,7 @@ describe("runInsightPregenerate — budget gate", () => { // assumption that the 04:30 pass warms their cards. A budget-blocked // candidate must therefore still get the warm — refill-only // (`force: false`), so a card already generated today is a cheap - // cache read while a cold card generates. + // cache read while a cold card generates behind its hash gate. const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); checkRateLimit.mockResolvedValue({ allowed: false }); const generate = vi.fn(); @@ -187,12 +201,13 @@ describe("runInsightPregenerate — budget gate", () => { expect(generate).not.toHaveBeenCalled(); expect(result.budgetBlocked).toBe(1); + // v1.16.8 — single-locale warm: only the user's resolved locale. for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); - expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: false }); } - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false); - expect(result.assessmentsWarmed).toBe(14); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + expect(result.assessmentsWarmed).toBe(7); expect(result.metricAssessmentsWarmed).toBe(2); }); }); @@ -209,9 +224,10 @@ describe("runInsightPregenerate — force flag", () => { // The cron's discovery window (20 h) is shorter than the // generator's 24 h TTL; `force: true` bypasses the TTL re-check so // a 20-24h-old cache actually regenerates rather than returning - // `cached` and wasting the budget bucket. The nightly loop also - // threads an AbortSignal so its bounded budget can cut a stalled - // generation off. + // `cached` and wasting the budget bucket. The generator's own + // content-hash gate is what prevents a same-data force from + // reaching the provider. The nightly loop also threads an + // AbortSignal so its bounded budget can cut a stalled generation off. expect(generate).toHaveBeenCalledWith("u1", { locale: "de", force: true, @@ -221,17 +237,19 @@ describe("runInsightPregenerate — force flag", () => { }); describe("runInsightPregenerate — outcome tally", () => { - it("tallies generated / cached / skipped / failed", async () => { + it("tallies generated / cached / unchanged / skipped / failed", async () => { const { prisma } = makePrisma([ { id: "a", locale: "de" }, { id: "b", locale: "de" }, { id: "c", locale: "de" }, - { id: "d", locale: null }, + { id: "d", locale: "de" }, + { id: "e", locale: null }, ]); const generate = vi .fn() .mockResolvedValueOnce({ status: "generated", providerType: "x" }) .mockResolvedValueOnce({ status: "cached" }) + .mockResolvedValueOnce({ status: "unchanged" }) .mockResolvedValueOnce({ status: "skipped", reason: "no-provider" }) .mockResolvedValueOnce({ status: "failed", reason: "provider-error" }); @@ -240,16 +258,17 @@ describe("runInsightPregenerate — outcome tally", () => { }); expect(result).toMatchObject({ - total: 4, + total: 5, generated: 1, cached: 1, + unchanged: 1, skipped: 1, failed: 1, budgetBlocked: 0, }); // Locale defaulting: v1.15.20 — non-German (incl. null) → "en", // matching the no-key fallback routing. - expect(generate).toHaveBeenLastCalledWith("d", { + expect(generate).toHaveBeenLastCalledWith("e", { locale: "en", force: true, signal: expect.any(AbortSignal), @@ -285,19 +304,15 @@ describe("runInsightPregenerate — outcome tally", () => { const result = await promise; // The timeout fired, aborted the still-running generation (so its - // late resolve cannot evict the rows the warm pass writes), and the - // refill-only warm still ran. + // late resolve cannot write a cache row the loop no longer expects), + // and the refill-only warm still ran. expect(observedSignal?.aborted).toBe(true); expect(result.failed).toBe(1); expect(result.generated).toBe(0); for (const g of statusGenerators) { expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); } - expect(warmGenericMetrics).toHaveBeenCalledWith( - "u1", - ["de", "en"], - false, - ); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); } finally { vi.useRealTimers(); } @@ -309,7 +324,7 @@ describe("runInsightPregenerate — per-metric warm pass", () => { return vi.fn().mockResolvedValue(result); } - it("forces all seven status generators in BOTH locales after a successful comprehensive generation", async () => { + it("runs all seven status generators refill-only in the user's locale after a successful comprehensive generation", async () => { const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); const generate = vi .fn() @@ -323,21 +338,42 @@ describe("runInsightPregenerate — per-metric warm pass", () => { statusGenerators, }); - // v1.8.3 — each generator is forced once per supported locale (de + en) - // so the cache the client reads against (active UI locale, not the - // persisted User.locale) is always warm. + // v1.16.8 — single-locale, refill-only. The comprehensive write no + // longer evicts the per-status rows, so there is nothing to force; + // each generator's content-hash gate decides whether its data changed. for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); - expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true }); - expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: true }); + expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); } - // Seven generators × two locales = 14 fresh, provider-backed assessments. - expect(result.assessmentsWarmed).toBe(14); + expect(result.assessmentsWarmed).toBe(7); }); - it("warms when the comprehensive pass returned `cached` (its write still evicted the per-status caches)", async () => { + it("warms in the user's resolved locale, not both families (v1.16.8)", async () => { + const { prisma } = makePrisma([{ id: "u1", locale: "en" }]); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + const statusGenerators = Array.from({ length: 7 }, () => + warmGen({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + warmGenericMetrics, + }); + + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: false }); + } + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["en"]); + }); + + it("warms when the comprehensive pass returned `unchanged` (hash gate skipped the provider)", async () => { const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); - const generate = vi.fn().mockResolvedValue({ status: "cached" }); + const generate = vi.fn().mockResolvedValue({ status: "unchanged" }); const statusGenerators = Array.from({ length: 7 }, () => warmGen({ hasProvider: true, cached: false }), ); @@ -347,10 +383,12 @@ describe("runInsightPregenerate — per-metric warm pass", () => { statusGenerators, }); + expect(result.unchanged).toBe(1); for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); } - expect(result.assessmentsWarmed).toBe(14); + expect(result.assessmentsWarmed).toBe(7); }); it("does NOT warm when the comprehensive pass skipped (no provider)", async () => { @@ -373,11 +411,11 @@ describe("runInsightPregenerate — per-metric warm pass", () => { expect(result.assessmentsWarmed).toBe(0); }); - it("warms refill-only (force:false) when the comprehensive pass failed (v1.16.1)", async () => { - // A failed comprehensive means no eviction ran, so today's rows (if - // any) are still valid — the warm refills only the cold cards. Before - // v1.16.1 this path skipped the warm entirely, leaving every card of a - // 02:xx-skipped candidate cold until the first on-visit generation. + it("warms refill-only when the comprehensive pass failed (v1.16.1)", async () => { + // A failed comprehensive leaves today's rows (if any) valid — the warm + // refills only the cold cards. Before v1.16.1 this path skipped the + // warm entirely, leaving every card of a 02:xx-skipped candidate cold + // until the first on-visit generation. const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); const generate = vi .fn() @@ -392,11 +430,10 @@ describe("runInsightPregenerate — per-metric warm pass", () => { }); for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); - expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: false }); } - expect(result.assessmentsWarmed).toBe(14); + expect(result.assessmentsWarmed).toBe(7); }); it("warms refill-only when the comprehensive pass skipped for missing consent (v1.16.1)", async () => { @@ -418,10 +455,10 @@ describe("runInsightPregenerate — per-metric warm pass", () => { }); for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); } - expect(result.assessmentsWarmed).toBe(14); + expect(result.assessmentsWarmed).toBe(7); }); it("counts only fresh provider-backed assessments and survives a thrown generator", async () => { @@ -443,12 +480,12 @@ describe("runInsightPregenerate — per-metric warm pass", () => { }); // The throw must not abort the loop — every generator was attempted - // once per supported locale (de + en). + // once (single-locale warm). for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); } - // Two counting generators × two locales = 4 fresh assessments. - expect(result.assessmentsWarmed).toBe(4); + // Two counting generators × one locale = 2 fresh assessments. + expect(result.assessmentsWarmed).toBe(2); }); }); @@ -472,9 +509,8 @@ describe("runInsightPregenerate — generic metric warm pass (v1.8.7.1)", () => }); expect(warmGenericMetrics).toHaveBeenCalledTimes(1); - // Third arg `true` — the comprehensive write evicted the caches, so - // the warm forces fresh generations. - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], true); + // Single-locale, refill-only (v1.16.8) — no force mode left. + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); expect(result.metricAssessmentsWarmed).toBe(9); }); @@ -500,7 +536,7 @@ describe("runInsightPregenerate — generic metric warm pass (v1.8.7.1)", () => }); describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { - it("warms BOTH locale families, bypasses the budget gate, and tallies counts", async () => { + it("warms the caller's locale only, under the daily forced-warm bucket", async () => { const { prisma } = makePrisma([]); const generate = vi .fn() @@ -516,49 +552,50 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { warmGenericMetrics, }); - // Comprehensive forced once, in the caller's active locale. The forced - // path also threads an AbortSignal so a timeout can cut the generation - // off. + // Comprehensive forced once, in the caller's active locale, with an + // AbortSignal so a timeout can cut the generation off. expect(generate).toHaveBeenCalledTimes(1); expect(generate).toHaveBeenCalledWith("u1", { locale: "en", force: true, signal: expect.any(AbortSignal), }); - // v1.16.7 — each specialised generator runs once PER locale family - // (de+en, like the nightly cron): the comprehensive write above - // evicted both families, so refilling only one left the other cold. - // The generated comprehensive means eviction happened → force: true. + // v1.16.8 — single-locale warm, refill-only: the comprehensive write + // no longer evicts the per-status rows, and the second locale family + // warms lazily through the read-path enqueue when actually read. for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); - expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true }); - expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: true }); + expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: false }); } - // Generic warm pass covers both locale families too. - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], true); - // No 20 h budget bucket consulted on the forced path. - expect(checkRateLimit).not.toHaveBeenCalled(); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["en"]); + // The 20 h nightly bucket is bypassed; the forced path consults its + // own daily cap instead. + expect(checkRateLimit).toHaveBeenCalledTimes(1); + expect(checkRateLimit).toHaveBeenCalledWith( + "insight-pregenerate-daily:u1", + FORCE_WARM_DAILY_LIMIT, + expect.any(Number), + ); expect(result).toMatchObject({ comprehensive: "generated", - assessmentsWarmed: 14, + assessmentsWarmed: 7, metricAssessmentsWarmed: 5, }); }); - it("still runs the per-status + generic warm even when the comprehensive pass skipped (no provider)", async () => { - // v1.9.0 — the three sections are decoupled. A skipped comprehensive - // (no provider) must NOT short-circuit the per-status + generic passes: - // each generator resolves its own provider chain and no-ops cheaply when - // none is configured, so running them is safe and is what warms the cards - // when the comprehensive briefing is the only thing missing a provider. - const { prisma } = makePrisma([]); - const generate = vi - .fn() - .mockResolvedValue({ status: "skipped", reason: "no-provider" }); - // Each generator independently reports no-provider — nothing warmed, but - // it WAS invoked (it is the generator, not the caller, that decides). + it("skips the comprehensive when the cache was warmed within the freshness window (idempotent re-enqueue)", async () => { + // A revalidation poll that outlives the enqueue singleton can stack + // several force jobs; the job-start freshness re-check collapses the + // stack into one real warm. + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, findUnique } = makePrisma([]); + findUnique.mockResolvedValue({ + insightsCachedAt: new Date(now.getTime() - 10 * 60 * 1000), + insightsWarmFailedAt: null, + }); + const generate = vi.fn(); const statusGenerators = Array.from({ length: 7 }, () => - vi.fn().mockResolvedValue({ hasProvider: false, cached: true }), + vi.fn().mockResolvedValue({ hasProvider: true, cached: true }), ); const warmGenericMetrics = vi.fn().mockResolvedValue(0); @@ -566,29 +603,80 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { generate, statusGenerators, warmGenericMetrics, + now, }); + expect(generate).not.toHaveBeenCalled(); + // The daily cap is not consumed by a freshness skip. + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(result.comprehensive).toBe("fresh"); + // The refill-only card warm still runs (cheap cache reads). for (const g of statusGenerators) { - // Both locale families, refill-only: a skipped comprehensive ran no - // eviction, so cards already generated today short-circuit cheaply. - expect(g).toHaveBeenCalledTimes(2); - expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); - expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: false }); + expect(g).toHaveBeenCalledTimes(1); } - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false); - expect(result).toMatchObject({ - comprehensive: "skipped", - assessmentsWarmed: 0, - metricAssessmentsWarmed: 0, + }); + + it("backs off after a recent failed attempt instead of re-driving the provider chain", async () => { + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, findUnique } = makePrisma([]); + findUnique.mockResolvedValue({ + insightsCachedAt: null, + insightsWarmFailedAt: new Date(now.getTime() - 10 * 60 * 1000), + }); + const generate = vi.fn(); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + now, + }); + + expect(generate).not.toHaveBeenCalled(); + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(result.comprehensive).toBe("backoff"); + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); + } + }); + + it("retries after the backoff window has elapsed and clears the marker on success", async () => { + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, findUnique, update } = makePrisma([]); + findUnique.mockResolvedValue({ + insightsCachedAt: null, + insightsWarmFailedAt: new Date(now.getTime() - 2 * 60 * 60 * 1000), + }); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + now, + }); + + expect(generate).toHaveBeenCalledTimes(1); + expect(result.comprehensive).toBe("generated"); + expect(update).toHaveBeenCalledWith({ + where: { id: "u1" }, + data: { insightsWarmFailedAt: null }, }); }); - it("warms the per-status + generic caches even when the comprehensive pass fails", async () => { - // v1.9.0 — a failed comprehensive (provider chain unhealthy this run) - // is non-fatal: the cheaper per-status (7) + generic (~30) cards still - // warm so the user's first click is a cache read, not a 30–60 s lazy - // generation. This is the prod regression the stability wave closes. - const { prisma } = makePrisma([]); + it("stamps the failure marker when the comprehensive fails so the next page-open backs off", async () => { + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, update } = makePrisma([]); const generate = vi .fn() .mockResolvedValue({ status: "failed", reason: "all-providers-failed" }); @@ -601,25 +689,85 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { generate, statusGenerators, warmGenericMetrics, + now, }); + expect(update).toHaveBeenCalledWith({ + where: { id: "u1" }, + data: { insightsWarmFailedAt: now }, + }); + // The per-status + generic passes still ran (decoupled sections). for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); } - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false); expect(result).toMatchObject({ comprehensive: "failed", - assessmentsWarmed: 14, + assessmentsWarmed: 7, metricAssessmentsWarmed: 4, }); }); - it("reports a timed-out comprehensive distinctly and still warms the rest", async () => { - // A comprehensive generation that exceeds its bounded budget is - // abandoned and reported as `timeout`, but the per-status + generic - // passes run regardless. The prod incident was a 102 s comprehensive - // that aborted the whole warm with `assessments_warmed: 0`. + it("caps forced comprehensive attempts per day and still refills the cards", async () => { const { prisma } = makePrisma([]); + checkRateLimit.mockResolvedValue({ allowed: false }); + const generate = vi.fn(); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(checkRateLimit).toHaveBeenCalledWith( + "insight-pregenerate-daily:u1", + FORCE_WARM_DAILY_LIMIT, + expect.any(Number), + ); + expect(generate).not.toHaveBeenCalled(); + expect(result.comprehensive).toBe("capped"); + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); + } + }); + + it("still runs the per-status + generic warm even when the comprehensive pass skipped (no provider)", async () => { + // v1.9.0 — the sections are decoupled. A skipped comprehensive + // (no provider) must NOT short-circuit the per-status + generic passes: + // each generator resolves its own provider chain and no-ops cheaply when + // none is configured. + 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(0); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + }); + + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); + } + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + expect(result).toMatchObject({ + comprehensive: "skipped", + assessmentsWarmed: 0, + metricAssessmentsWarmed: 0, + }); + }); + + it("reports a timed-out comprehensive distinctly and still warms the rest", async () => { + const { prisma, update } = makePrisma([]); const generate = vi.fn().mockImplementation( () => new Promise((resolve) => { @@ -648,16 +796,17 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { const result = await promise; for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); } - expect(warmGenericMetrics).toHaveBeenCalledWith( - "u1", - ["de", "en"], - false, - ); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + // A timeout counts as a failure for the backoff marker. + expect(update).toHaveBeenCalledWith({ + where: { id: "u1" }, + data: { insightsWarmFailedAt: expect.any(Date) }, + }); expect(result).toMatchObject({ comprehensive: "timeout", - assessmentsWarmed: 14, + assessmentsWarmed: 7, metricAssessmentsWarmed: 3, }); } finally { @@ -715,24 +864,24 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { // Comprehensive belongs to the `briefing` surface — skipped here. expect(generate).not.toHaveBeenCalled(); - // But the per-status + generic passes warm, both locale families. + // But the per-status + generic passes warm, in the caller's locale. for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); } - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); expect(result).toMatchObject({ comprehensive: "skipped", - assessmentsWarmed: 14, + assessmentsWarmed: 7, metricAssessmentsWarmed: 2, }); }); - it("aborts the comprehensive on timeout so its late resolve cannot evict the warmed rows (M-1)", async () => { + it("aborts the comprehensive on timeout so its late resolve cannot write a stale cache row (M-1)", async () => { // v1.9.0 race fix: `withTimeout` cannot cancel the detached generation, - // so a comprehensive that resolves AFTER the timeout would reach its own - // `evictPerStatusInsightCache` and delete the rows the warm passes wrote. - // The forced path threads an AbortController; on timeout it must abort, - // and the generation observes `signal.aborted` before its evict. + // so a comprehensive that resolves AFTER the timeout would still write + // its cache row + timestamp. The forced path threads an AbortController; + // on timeout it must abort, and the generation observes `signal.aborted` + // before its cache write. const { prisma } = makePrisma([]); let observedSignal: AbortSignal | undefined; const generate = vi.fn().mockImplementation( @@ -768,9 +917,9 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { expect(result.comprehensive).toBe("timeout"); // The warm passes ran and are not undone by the abandoned comprehensive. for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); } - expect(result.assessmentsWarmed).toBe(14); + expect(result.assessmentsWarmed).toBe(7); expect(result.metricAssessmentsWarmed).toBe(3); } finally { vi.useRealTimers(); diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts index 3f5ef20e5..4ca7cfaa2 100644 --- a/src/lib/jobs/insight-pregenerate-shared.ts +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -38,14 +38,13 @@ export interface InsightPregeneratePayload { * `singletonKey` per user 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. The key - * is deliberately locale-free: `forceWarmUser` warms BOTH locale - * families per job (v1.16.7), so a per-(user, locale) key would let a - * German web session and an English Accept-Language client enqueue two - * near-identical full warms back to back — the second one force- - * regenerating the comprehensive briefing and re-evicting the - * per-status rows the first job just wrote. 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. + * is deliberately locale-free: a German web session and an English + * Accept-Language client racing each other would otherwise enqueue two + * near-identical full warms back to back; the first job's locale wins + * the window and the other locale warms lazily through the read-path + * enqueue (v1.16.8). 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; @@ -69,9 +68,14 @@ export async function enqueueForceWarm(payload: { } satisfies InsightPregeneratePayload, { singletonKey: `force:${payload.userId}`, - // 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, + // De-dupe within a 6-minute window. v1.16.8 — widened from 120 s: + // the client's revalidation poll runs up to ~250 s after a stale + // page-open, so a 2-minute singleton let the tail of one poll + // cycle enqueue a second (and third) full warm while the first + // was still running. 360 s outlives the poll horizon, so a stale + // page-open collapses to exactly one queued warm; the worker-side + // freshness re-check in `forceWarmUser` is the second guard. + singletonSeconds: 360, // Transient provider / pool failures retry with backoff instead // of leaving every cache cold until the nightly cron. retryLimit: 3, diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 379cead21..eb7c7c8e8 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -10,15 +10,17 @@ * 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. + * v1.8.0 — warm the per-metric assessment caches too, so the first + * `/insights` mount of the morning is a cache read instead of one live + * status generation per card. + * + * v1.16.8 — the warm is single-locale (the user's resolved locale) and + * refill-only: every generator runs its own content-hash gate, so a card + * whose underlying data did not change since its last real assessment + * costs a cache-row refresh, not an LLM call. The comprehensive write no + * longer evicts the per-status rows, so there is nothing to force-refill; + * a second locale (e.g. an English iOS client against a German account) + * warms lazily through the read-path enqueue on its 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) @@ -111,6 +113,33 @@ export const PREGENERATE_BATCH_CAP = 200; /** Per-user budget bucket window — one pre-generation per 20 h. */ const PREGENERATE_BUDGET_WINDOW_MS = 20 * 60 * 60 * 1000; +/** + * v1.16.8 — freshness re-check for the forced single-user warm. The + * enqueue side already de-dupes via `singletonKey`, but a poll horizon + * that outlives the singleton window can stack several force jobs back + * to back; re-checking `insightsCachedAt` at job start collapses the + * stack into one real warm. + */ +export const FORCE_WARM_FRESH_MS = 60 * 60 * 1000; + +/** + * v1.16.8 — backoff after a failed / timed-out forced comprehensive. + * While `insightsWarmFailedAt` is younger than this, the forced path + * skips the comprehensive attempt instead of re-driving a broken + * provider chain on every page-open. The nightly tick (its own budget + * bucket) and the user's manual regenerate are unaffected. + */ +export const FORCE_WARM_FAILURE_BACKOFF_MS = 60 * 60 * 1000; + +/** + * v1.16.8 — daily cap on forced full warms. At most two forced + * comprehensive attempts per user per rolling 24 h; the nightly tick + * rides its own 20 h bucket, so a capped user still gets the overnight + * refresh. Reuses the Postgres rate-limit bucket mechanism. + */ +export const FORCE_WARM_DAILY_LIMIT = 2; +const FORCE_WARM_DAILY_WINDOW_MS = 24 * 60 * 60 * 1000; + /** * Concurrency for the per-user warm pass. The seven `*StatusForUser` * generators are independent (each writes its own cache row), so they run @@ -145,8 +174,20 @@ export interface ForceWarmResult { * distinct from `"failed"`: the comprehensive step exceeded its own * bounded budget (see `COMPREHENSIVE_WARM_TIMEOUT_MS`) and was * abandoned so the per-status + generic-metric passes could still run. + * + * v1.16.8 — three pre-flight outcomes that never reach the generator: + * `"fresh"` (the cache was warmed within `FORCE_WARM_FRESH_MS`), + * `"backoff"` (a forced attempt failed within + * `FORCE_WARM_FAILURE_BACKOFF_MS`), and `"capped"` (the per-user daily + * forced-warm budget is exhausted). All three still run the refill-only + * card warm below. */ - comprehensive: GenerateOutcome["status"] | "timeout"; + comprehensive: + | GenerateOutcome["status"] + | "timeout" + | "fresh" + | "backoff" + | "capped"; /** Count of specialised per-status assessments written warm. */ assessmentsWarmed: number; /** Count of generic per-HealthKit-metric assessments written warm. */ @@ -157,6 +198,11 @@ export interface PregenerateRunResult { total: number; generated: number; cached: number; + /** + * v1.16.8 — comprehensives whose content-hash gate found the feature + * snapshot unchanged: timestamp refreshed, no provider call. + */ + unchanged: number; skipped: number; failed: number; budgetBlocked: number; @@ -210,38 +256,33 @@ const DEFAULT_STATUS_GENERATORS: ReadonlyArray = [ ]; /** - * 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, + * Re-fill the per-metric assessment caches for one user. Refill-only + * (`force: false`): a card already generated today short-circuits to its + * cache row, and a cold card runs its generator — whose content-hash gate + * (v1.16.8) skips the LLM and refreshes the row when the underlying data + * did not change. 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. + * + * `locales` carries only the user's resolved locale (v1.16.8). The old + * dual-locale warm existed because the comprehensive write evicted every + * locale family at once; that eviction is gone, and a second locale the + * user actually reads (e.g. an English client against a German account) + * warms lazily through `resolveReadOnlyStatusMiss`'s enqueue on its + * first cache-miss read. */ async function warmPerStatusCaches( userId: string, locales: ReadonlyArray<"de" | "en">, generators: ReadonlyArray, - // v1.16.1 — `force: false` is the refill-only mode the nightly fallback - // warm uses: the per-status cache is keyed on today's dateKey, so a card - // already generated today short-circuits to its cache row while a cold - // card still generates. The eviction path (after a generated/cached - // comprehensive) keeps `force: true` because the rows were just deleted. - force = true, ): Promise { const limit = pLimit(WARM_PASS_CONCURRENCY); - // v1.8.3 — warm every locale the user might request, not just the - // User.locale. The per-status cache key is - // `insights.-status.`, but the client sends the active - // UI-cookie / Accept-Language locale (narrowed to de|en), which can - // differ from the persisted User.locale (e.g. null → "de" in the cron - // while the browser cookie is "en"). Warming only the User.locale left - // the locale the client actually reads cold, so the first visit still - // ran a live generation per card — the exact freeze this release closes. const tasks = generators.flatMap((generate) => locales.map((locale) => limit(async () => { try { - const outcome = await generate(userId, { locale, force }); + const outcome = await generate(userId, { locale, force: false }); return outcome.hasProvider && !outcome.cached ? 1 : 0; } catch { // A single card's generation failing must not abort the rest of @@ -271,8 +312,6 @@ async function warmGenericMetricCaches( prisma: PrismaClient, userId: string, locales: ReadonlyArray<"de" | "en">, - // See `warmPerStatusCaches` — `false` is the refill-only fallback mode. - force = true, ): Promise { // One distinct read: the set of MeasurementTypes the user has live // rows for. Best-effort — a read failure (or a worker prisma surface @@ -303,11 +342,14 @@ async function warmGenericMetricCaches( locales.map((locale) => limit(async () => { try { + // Refill-only (see `warmPerStatusCaches`): a today-cached card is + // a cheap read; a cold card runs the generator, whose content-hash + // gate skips the LLM when its data did not change. const outcome = await generateMetricStatus({ metric, userId, locale, - force, + force: false, }); // `insufficient` never reaches here (we pre-filtered to // metrics with data), but guard anyway: count only a fresh @@ -396,7 +438,6 @@ export async function runInsightPregenerate( warmGenericMetrics?: ( userId: string, locales: ReadonlyArray<"de" | "en">, - force?: boolean, ) => Promise; } = {}, ): Promise { @@ -407,13 +448,14 @@ export async function runInsightPregenerate( options.statusGenerators ?? DEFAULT_STATUS_GENERATORS; const warmGenericMetrics = options.warmGenericMetrics ?? - ((userId: string, locales: ReadonlyArray<"de" | "en">, force?: boolean) => - warmGenericMetricCaches(prisma, userId, locales, force)); + ((userId: string, locales: ReadonlyArray<"de" | "en">) => + warmGenericMetricCaches(prisma, userId, locales)); const result: PregenerateRunResult = { total: 0, generated: 0, cached: 0, + unchanged: 0, skipped: 0, failed: 0, budgetBlocked: 0, @@ -455,14 +497,15 @@ export async function runInsightPregenerate( // budget bucket, then short-circuit to `cached` with no actual // 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. + // bucket (above) bounds attempts, and the generator's content-hash + // gate (v1.16.8) turns a same-data force into a timestamp refresh + // (`unchanged`) with no provider call. // // Bounded budget + abort (v1.16.1): one stalled provider must not // pin the whole batch behind a single candidate, and `withTimeout` // alone cannot cancel the detached generation — without the abort a - // late resolve would reach `evictPerStatusInsightCache` and delete - // the rows the warm passes write below (the same race the forced - // single-user warm closes). + // late resolve would still write a cache row + timestamp after the + // loop moved on (the same race the forced single-user warm closes). const controller = new AbortController(); const bounded = await withTimeout( () => @@ -486,6 +529,9 @@ export async function runInsightPregenerate( case "cached": result.cached++; break; + case "unchanged": + result.unchanged++; + break; case "skipped": result.skipped++; break; @@ -496,20 +542,15 @@ export async function runInsightPregenerate( } } - // Warm the per-metric assessment caches. Two modes: - // - // - After a `generated` or `cached` comprehensive the generator's - // write evicted (`evictPerStatusInsightCache`) every per-status - // row, so the warm forces a fresh generation per card. - // - On every other path (budget-blocked, failed, timed-out, - // consent-skipped comprehensive) the warm still runs, but - // refill-only (`force: false`): the per-status cache is keyed on - // today's dateKey, so a card already generated today is a cheap - // cache read while a cold card generates. Before v1.16.1 these - // paths skipped the warm entirely — and because the 02:xx status - // crons skip every pregenerate candidate, a single failed or - // budget-blocked comprehensive left ALL of that user's cards cold - // until the first on-visit generation. + // Warm the per-metric assessment caches — refill-only, single locale + // (v1.16.8). The comprehensive write no longer evicts the per-status + // rows, so there is nothing to force-refill: a card already generated + // today is a cheap cache read, a cold card runs its generator, and the + // generator's content-hash gate turns a same-data regeneration into a + // timestamp refresh with no provider call. The warm runs on every + // comprehensive outcome (budget-blocked, failed, timed-out, + // consent-skipped included) because the 02:xx status crons skip every + // pregenerate candidate on the assumption that THIS pass covers them. // // The only outcome with nothing to warm is a missing provider // (`skipped`/`no-provider`): the per-card generators would no-op to @@ -518,24 +559,17 @@ export async function runInsightPregenerate( if (outcome?.status === "skipped" && outcome.reason === "no-provider") { continue; } - const evicted = - outcome?.status === "generated" || outcome?.status === "cached"; result.assessmentsWarmed += await warmPerStatusCaches( candidate.id, - // Warm both supported locales — the client reads against the active - // UI locale, which can differ from the persisted User.locale. - ["de", "en"], + [locale], statusGenerators, - evicted, ); // v1.8.7.1 — warm the generic per-HealthKit-metric caches too, for // the user's data-bearing metrics only (the helper filters via one // grouped count, so an empty metric never reaches the provider). - result.metricAssessmentsWarmed += await warmGenericMetrics( - candidate.id, - ["de", "en"], - evicted, - ); + result.metricAssessmentsWarmed += await warmGenericMetrics(candidate.id, [ + locale, + ]); } // Wide-event tally so the nightly dashboard can track how many @@ -546,6 +580,7 @@ export async function runInsightPregenerate( total: result.total, generated: result.generated, cached: result.cached, + unchanged: result.unchanged, skipped: result.skipped, failed: result.failed, budget_blocked: result.budgetBlocked, @@ -564,25 +599,26 @@ export async function runInsightPregenerate( * 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. + * WITHOUT the per-user 20 h budget bucket. The forced path carries its + * own bounds instead (v1.16.8): a freshness re-check at job start, an + * hour of backoff after a failed attempt, and a daily cap of + * `FORCE_WARM_DAILY_LIMIT` forced comprehensive attempts — on top of the + * enqueuing endpoint's short anti-spam rate-limit. * * 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`. + * - Each generator is idempotent — re-running it just rewrites the same + * cache row; concurrent enqueues collapse via the queue + * `singletonKey`, and the content-hash gates collapse a same-data + * re-run into a timestamp refresh. * - * Like the nightly cron, the forced path warms BOTH locales (de+en): its - * own comprehensive step evicts every per-status row across both locale - * families, and the refill-only mode keeps the second family's cost at - * exactly the cards that are cold. The caller's `locale` still picks the - * language of the comprehensive briefing itself. + * Like the nightly cron (v1.16.8), the forced path warms ONLY the + * caller's resolved locale — the cross-locale per-status eviction that + * once required dual-locale refills is gone, and a second locale a user + * actually reads warms lazily through the read-path enqueue. * * The three sections are decoupled: the comprehensive step runs under its * own bounded budget (`COMPREHENSIVE_WARM_TIMEOUT_MS`) and its @@ -610,8 +646,9 @@ export async function forceWarmUser( warmGenericMetrics?: ( userId: string, locales: ReadonlyArray<"de" | "en">, - force?: boolean, ) => Promise; + /** Injected for the test — defaults to `new Date()`. */ + now?: Date; } = {}, ): Promise { const generate = options.generate ?? generateComprehensiveInsight; @@ -619,8 +656,9 @@ export async function forceWarmUser( options.statusGenerators ?? DEFAULT_STATUS_GENERATORS; const warmGenericMetrics = options.warmGenericMetrics ?? - ((id: string, locales: ReadonlyArray<"de" | "en">, force?: boolean) => - warmGenericMetricCaches(prisma, id, locales, force)); + ((id: string, locales: ReadonlyArray<"de" | "en">) => + warmGenericMetricCaches(prisma, id, locales)); + const now = options.now ?? new Date(); const result: ForceWarmResult = { comprehensive: "skipped", @@ -640,17 +678,12 @@ export async function forceWarmUser( const flags = await getAssistantFlags(); if (!flags.briefing && !flags.insightStatus) return result; - // Warm BOTH supported locales, matching the nightly pass. A fresh - // comprehensive generation evicts every per-status cache row in both - // locale families (`evictPerStatusInsightCache` deletes by scope, not - // by locale) — refilling only the caller's active locale left the - // other family cold, so an account read through two surfaces (German - // web UI + an English Accept-Language client) regenerated the second - // family card-by-card on its next visits, all day, every day. The - // refill mode below short-circuits to the cache for any card already - // generated today, so the second locale costs only the cards that are - // actually cold. - const locales: ReadonlyArray<"de" | "en"> = ["de", "en"]; + // v1.16.8 — warm only the caller's resolved locale. The old dual-locale + // warm compensated for the comprehensive write's cross-locale per-status + // eviction; that eviction is gone, so the other locale family's rows + // survive untouched and a client actually reading the second locale + // warms it lazily through the read-path enqueue on its first miss. + const locales: ReadonlyArray<"de" | "en"> = [locale]; // The comprehensive step gets its own bounded budget and its failure is // non-fatal. A slow or stalled briefing generation must not short-circuit @@ -658,27 +691,112 @@ export async function forceWarmUser( // user actually clicks first, and warming them is independent of the // comprehensive insight (each generator resolves its own provider chain // and writes its own cache row). A timeout / error / skipped comprehensive - // now falls through to the warm passes instead of aborting the whole job. + // falls through to the warm passes instead of aborting the whole job. // // On a timeout `withTimeout` cannot cancel the detached generation; without - // a cut-off a late-resolving comprehensive would reach its own - // `evictPerStatusInsightCache` and delete the rows the warm passes write - // below. Thread an AbortController so the timeout aborts the generation - // before it can touch the cache. + // a cut-off a late-resolving comprehensive would still write its cache row + // + timestamp after the job moved on. Thread an AbortController so the + // timeout aborts the generation before it can touch the cache. + // + // v1.16.8 — three pre-flight gates make the forced path idempotent and + // bounded (the enqueue-side singleton alone cannot, because the client's + // revalidation poll outlives any reasonable singleton window): + // 1. freshness — a comprehensive warmed within the last hour is + // current; re-forcing it would at best re-discover an unchanged + // hash and at worst burn a generation on intra-day noise. + // 2. failure backoff — a forced attempt that failed within the last + // hour stays failed for most causes (broken key, dead local + // endpoint); re-attempting on every page-open re-runs the full + // feature extraction + provider stall each time. + // 3. daily cap — at most FORCE_WARM_DAILY_LIMIT forced attempts per + // rolling 24 h, so even a client bug that defeats 1+2 cannot turn + // the forced path into an unmetered generation loop. if (flags.briefing) { - const controller = new AbortController(); - const comprehensive = await withTimeout( - () => generate(userId, { locale, force: true, signal: controller.signal }), - COMPREHENSIVE_WARM_TIMEOUT_MS, - null, - () => controller.abort(), - ); - if (comprehensive.timedOut) { - result.comprehensive = "timeout"; - } else if (comprehensive.errored || comprehensive.value === null) { - result.comprehensive = "failed"; + let freshness: { insightsCachedAt: Date | null; insightsWarmFailedAt: Date | null } | null = + null; + try { + freshness = await prisma.user.findUnique({ + where: { id: userId }, + select: { insightsCachedAt: true, insightsWarmFailedAt: true }, + }); + } catch { + // Best-effort pre-flight — on a read failure fall through to the + // budgeted generation attempt below. + } + + const cachedAt = freshness?.insightsCachedAt ?? null; + const failedAt = freshness?.insightsWarmFailedAt ?? null; + const isFresh = + cachedAt !== null && now.getTime() - cachedAt.getTime() < FORCE_WARM_FRESH_MS; + const inBackoff = + !isFresh && + failedAt !== null && + now.getTime() - failedAt.getTime() < FORCE_WARM_FAILURE_BACKOFF_MS; + + if (isFresh) { + result.comprehensive = "fresh"; + annotate({ + action: { name: "insights.pregenerate.force.fresh" }, + meta: { cached_at: cachedAt?.toISOString() ?? null }, + }); + } else if (inBackoff) { + result.comprehensive = "backoff"; + annotate({ + action: { name: "insights.pregenerate.force.backoff" }, + meta: { failed_at: failedAt?.toISOString() ?? null }, + }); } else { - result.comprehensive = comprehensive.value.status; + const budget = await checkRateLimit( + `insight-pregenerate-daily:${userId}`, + FORCE_WARM_DAILY_LIMIT, + FORCE_WARM_DAILY_WINDOW_MS, + ); + if (!budget.allowed) { + result.comprehensive = "capped"; + annotate({ + action: { name: "insights.pregenerate.force.capped" }, + meta: { daily_limit: FORCE_WARM_DAILY_LIMIT }, + }); + } else { + const controller = new AbortController(); + const comprehensive = await withTimeout( + () => + generate(userId, { locale, force: true, signal: controller.signal }), + COMPREHENSIVE_WARM_TIMEOUT_MS, + null, + () => controller.abort(), + ); + if (comprehensive.timedOut) { + result.comprehensive = "timeout"; + } else if (comprehensive.errored || comprehensive.value === null) { + result.comprehensive = "failed"; + } else { + result.comprehensive = comprehensive.value.status; + } + + // Stamp / clear the failure marker for the backoff gate above. A + // `skipped` outcome (no provider / no consent) is not a failure — + // it costs nothing and should not delay a user who configures a + // provider a minute later. + const failed = + result.comprehensive === "failed" || + result.comprehensive === "timeout"; + try { + if (failed) { + await prisma.user.update({ + where: { id: userId }, + data: { insightsWarmFailedAt: now }, + }); + } else if (failedAt !== null) { + await prisma.user.update({ + where: { id: userId }, + data: { insightsWarmFailedAt: null }, + }); + } + } catch { + // Marker maintenance is best-effort; never fail the warm on it. + } + } } } @@ -691,26 +809,17 @@ export async function forceWarmUser( // outcome that has nothing to warm is a missing provider, and the // generators detect that themselves at near-zero cost. if (flags.insightStatus) { - // Mirror the nightly loop's force/refill split: only a `generated` - // comprehensive ran `evictPerStatusInsightCache`, so only then must - // every card re-generate. (The forced generation above never returns - // `cached` — `force: true` skips the 24 h cache-hit branch.) On a - // failed / timed-out / skipped comprehensive the rows survived — - // refill-only (`force: false`) lets a card already generated today - // short-circuit to its cache, which is what keeps the second locale - // family cheap. - const evicted = result.comprehensive === "generated"; + // Refill-only (v1.16.8): a card already generated today is a cheap + // cache read; a cold card runs its generator, whose content-hash gate + // skips the LLM when the underlying data did not change. There is no + // force mode left to mirror — the comprehensive write no longer evicts + // the per-status rows. result.assessmentsWarmed = await warmPerStatusCaches( userId, locales, statusGenerators, - evicted, - ); - result.metricAssessmentsWarmed = await warmGenericMetrics( - userId, - locales, - evicted, ); + result.metricAssessmentsWarmed = await warmGenericMetrics(userId, locales); } annotate({