From 23f7c74c04382e4e8de2c2ab27cbf75a90e5f782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 21:17:05 +0200 Subject: [PATCH] perf(insights): regenerate texts only when the underlying data changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sha256 fingerprint of the compacted feature snapshot is stored beside every generated text; nightly, forced and ingest-triggered regeneration all skip the model call when the fingerprint matches and refresh the timestamp instead. Warms cover only the user's resolved locale and the blanket per-status eviction after a comprehensive generation is gone — a lazily-read second locale regenerates on its own miss path. The ingest debounce widens from thirty minutes to six hours and no longer deletes cache rows, the visit-triggered warm re-checks freshness at job start, backs off for an hour after a failure and is capped at two forced runs per user per day, and its dedup window now outlives the client poll horizon. An active day drops from hundreds of model calls to a handful tied to actual data change. --- .../migration.sql | 15 + prisma/schema.prisma | 11 + .../insights/generate/__tests__/route.test.ts | 44 +- src/app/api/insights/generate/route.ts | 42 +- .../comprehensive-generate-force.test.ts | 191 ++++++++- .../invalidate-status-insights.test.ts | 78 ++-- .../insights/__tests__/snapshot-hash.test.ts | 104 +++++ .../insights/__tests__/status-cache.test.ts | 128 +++++- .../insights/__tests__/weight-status.test.ts | 114 +++++ src/lib/insights/blood-pressure-status.ts | 21 + src/lib/insights/bmi-status.ts | 21 + src/lib/insights/comprehensive-generate.ts | 156 +++---- src/lib/insights/general-status.ts | 21 + .../insights/medication-compliance-status.ts | 53 +++ src/lib/insights/metric-status.ts | 21 + src/lib/insights/mood-status.ts | 21 + src/lib/insights/pulse-status.ts | 21 + src/lib/insights/snapshot-hash.ts | 83 ++++ src/lib/insights/status-cache.ts | 65 +++ src/lib/insights/status-shared.ts | 8 + src/lib/insights/weight-status.ts | 21 + .../__tests__/insight-pregenerate.test.ts | 391 ++++++++++++------ src/lib/jobs/insight-pregenerate-shared.ts | 26 +- src/lib/jobs/insight-pregenerate.ts | 351 ++++++++++------ 24 files changed, 1596 insertions(+), 411 deletions(-) create mode 100644 prisma/migrations/0148_v1168_insight_snapshot_hash/migration.sql create mode 100644 src/lib/insights/__tests__/snapshot-hash.test.ts create mode 100644 src/lib/insights/snapshot-hash.ts 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({