diff --git a/prisma/migrations/0480_status_note_failure_streak/migration.sql b/prisma/migrations/0480_status_note_failure_streak/migration.sql new file mode 100644 index 000000000..ddc0b0fdb --- /dev/null +++ b/prisma/migrations/0480_status_note_failure_streak/migration.sql @@ -0,0 +1,11 @@ +-- Count the generations in a row that ended without a note, per status note. +-- +-- A note whose generation failed opened a fixed five-minute negative window. +-- A model that never answers kept failing every five minutes per note, and a +-- note re-selected by the next data sync ignored the window altogether. The +-- streak sets the next window: five minutes, doubling per failure in a row, +-- up to two hours. A written or re-dated note resets it to zero. +-- +-- Existing rows start at zero, which is the five-minute window they had. +ALTER TABLE "insight_status_caches" + ADD COLUMN IF NOT EXISTS "failure_streak" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b5ecaa161..a9c2ba02e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7836,6 +7836,9 @@ model InsightStatusCache { /// Why the last generation served the deterministic line instead of a note: /// `timeout` | `error` | `screened`. Set together with `retryAt`. negativeReason String? @map("negative_reason") + /// Generations in a row that ended without a note; sets the length of the + /// next negative-cache window. Reset to 0 when a note is written or re-dated. + failureStreak Int @default(0) @map("failure_streak") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 7478dfb91..d12e9110e 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 88, - "total": 98310, + "count": 87, + "total": 97498, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -64,7 +64,6 @@ "lib/insights/correlations.ts": 1026, "lib/insights/features.ts": 1875, "lib/insights/mood-aggregates.ts": 1053, - "lib/insights/status-cache.ts": 812, "lib/integrations/status.ts": 1445, "lib/jobs/coach-nudge.ts": 1084, "lib/jobs/insight-pregenerate.ts": 1189, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 4aa083037..432f84ad3 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 352, - "total": 113900, + "total": 113890, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -299,7 +299,7 @@ "lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876, "lib/insights/features.ts :: extractFeatures": 796, "lib/insights/mood-aggregates.ts :: fetchMoodAggregates": 246, - "lib/insights/status-provider.ts :: runStatusCompletion": 236, + "lib/insights/status-provider.ts :: runStatusCompletion": 226, "lib/integrations/connection-health-load.ts :: loadConnectionFacts": 227, "lib/integrations/status.ts :: recordSyncFailure": 292, "lib/jobs/apple-health-import-worker.ts :: handleAppleHealthImport": 300, diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index d6de4a654..481a4791f 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -67,6 +67,14 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn(), })); +vi.mock("@/lib/ai/provider-health-ledger", () => ({ + forgetProviderHealth: vi.fn(), +})); + +vi.mock("@/lib/insights/status-negative-window", () => ({ + clearStatusNegativeWindows: vi.fn(), +})); + import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { resolveProviderAvailability } from "@/lib/ai/provider"; @@ -77,6 +85,8 @@ import { getAssistantFlags } from "@/lib/feature-flags"; import { providerCredentialPolicy } from "@/lib/sharing/provider-work-authority"; import { hasActiveConsentForSurface } from "@/lib/ai/consent-guard"; import { auditLog } from "@/lib/auth/audit"; +import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger"; +import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window"; function patchRequest(body: unknown): Request { return new Request("http://localhost/api/user/ai-provider", { @@ -327,6 +337,26 @@ describe("PATCH /api/user/ai-provider — the gateway's own columns (#470)", () ); }); + it("starts background work afresh after a change to what answers (#1279)", async () => { + // A provider benched for background work and notes backing off would + // otherwise sit out a pause the old setup earned. + vi.mocked(isPublicUrl).mockReturnValue(true); + const res = await patch( + patchRequest({ + provider: "OPENAI_COMPATIBLE", + compatModel: "gpt-oss-120b", + }), + ); + expect(res.status).toBe(200); + expect(vi.mocked(forgetProviderHealth).mock.calls.map((c) => c[0])).toEqual( + expect.arrayContaining([ + { userId: "u-1", providerType: "openai-compatible" }, + { userId: "u-1", providerType: "local" }, + ]), + ); + expect(clearStatusNegativeWindows).toHaveBeenCalledWith("u-1"); + }); + it("refuses a private base URL the operator has not allowlisted", async () => { vi.mocked(isPublicUrl).mockReturnValue(false); vi.mocked(isLocalAiHostAllowed).mockReturnValue(false); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 3b72429cf..a7f53faf6 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -23,9 +23,39 @@ import { serializeProviderChain, } from "@/lib/ai/provider-chain"; import { reasoningEffortFor } from "@/lib/ai/reasoning-effort"; +import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger"; +import type { ProviderChainType } from "@/lib/ai/provider-chain"; +import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window"; export const dynamic = "force-dynamic"; +/** The chain entries this route configures (Codex signs in elsewhere). */ +const USER_SETTINGS_PROVIDERS: readonly ProviderChainType[] = [ + "openai", + "anthropic", + "local", + "openai-compatible", +]; + +/** + * Write the changed provider settings. #1279 — and start background work + * afresh: it skips a provider the health ledger benched, and a status note + * waits out its negative window, so without this a fixed setup would sit out + * a pause the old one earned. + */ +async function saveProviderSettings( + userId: string, + updates: Record, +): Promise { + await prisma.user.update({ where: { id: userId }, data: updates }); + await Promise.all([ + ...USER_SETTINGS_PROVIDERS.map((providerType) => + forgetProviderHealth({ userId, providerType }), + ), + clearStatusNegativeWindows(userId), + ]); +} + export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "user.ai-provider.get" } }); @@ -389,7 +419,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { : null; if (Object.keys(updates).length > 0) { - await prisma.user.update({ where: { id: user.id }, data: updates }); + await saveProviderSettings(user.id, updates); } for (const [slot, value, column] of keySlots) { diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index ca0325658..18a3f0729 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const deleteMany = vi.fn(); +const noteUpdateMany = vi.fn(); const noteFindMany = vi.fn(); const aiCapabilityForRecord = vi.fn(); const enqueueStatusGeneration = vi.fn(); @@ -39,6 +40,7 @@ vi.mock("@/lib/db", () => ({ insightStatusCache: { deleteMany: (...a: unknown[]) => deleteMany(...a), findMany: (...a: unknown[]) => noteFindMany(...a), + updateMany: (...a: unknown[]) => noteUpdateMany(...a), }, user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, appSettings: { @@ -316,11 +318,11 @@ describe("invalidateStatusInsightsForTypes", () => { expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); - it("counts only a stored note as fresh, so a recently stalled scope still refreshes", async () => { - // A row that carries only a negative-cache window has no note, so a - // scope that recently stalled must retry rather than be debounced into - // staying cold. The probe asks the database for noted rows only; with - // none returned, every dirtied scope refreshes. + it("counts only a stored note as fresh, so a stalled scope refreshes once its window lapses", async () => { + // A row that carries only a lapsed negative-cache window has no note, + // so a scope that stalled must retry rather than be debounced into + // staying cold. The first probe asks for noted rows only; with none + // returned, every dirtied scope refreshes. await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { source: "manual", }); @@ -329,6 +331,23 @@ describe("invalidateStatusInsightsForTypes", () => { expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); }); + it("leaves a scope whose negative window is still open to wait it out (#1279)", async () => { + // Every sync batch used to re-enqueue a scope whose generation had just + // failed, however often it failed. The second probe asks for open + // windows; a scope it returns is not enqueued. + noteFindMany.mockImplementation( + async (args: { where: { retryAt?: { gt: Date } } }) => + args.where.retryAt ? [freshRow("weight"), freshRow("general")] : [], + ); + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); + const probe = noteFindMany.mock.calls[1][0].where; + expect(probe.retryAt.gt.getTime()).toBeGreaterThanOrEqual(before - 1000); + expect(enqueuedScopes()).toEqual(["bmi"]); + }); + it("scopes the freshness probe to the user's resolved locale and a 1 h gap cutoff", async () => { userFindUnique.mockResolvedValue({ locale: "en" }); const before = Date.now(); @@ -394,6 +413,20 @@ describe("enqueueStatusRefillForUser", () => { expect([...locales]).toEqual(["de"]); }); + it("closes the user's negative windows first: the person asked (#1279)", async () => { + await enqueueStatusRefillForUser("u1", "en"); + expect(noteUpdateMany).toHaveBeenCalledWith({ + where: { + userId: "u1", + OR: [{ retryAt: { not: null } }, { failureStreak: { gt: 0 } }], + }, + data: { retryAt: null, negativeReason: null, failureStreak: 0 }, + }); + expect(noteUpdateMany.mock.invocationCallOrder[0]).toBeLessThan( + enqueueStatusGeneration.mock.invocationCallOrder[0], + ); + }); + it("never deletes cache rows (the hash baselines survive the refill)", async () => { await enqueueStatusRefillForUser("u1", "en"); expect(deleteMany).not.toHaveBeenCalled(); diff --git a/src/lib/insights/__tests__/pulse-status-timeout.test.ts b/src/lib/insights/__tests__/pulse-status-timeout.test.ts index 7a3ea488f..c066454d6 100644 --- a/src/lib/insights/__tests__/pulse-status-timeout.test.ts +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -141,7 +141,9 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => { expect(persisted.create.textEncrypted).toBeUndefined(); expect(persisted.create.negativeReason).toBe("timeout"); // The update arm moves only the window, never an existing note. + // #1279 — and the streak that sizes the next window. expect(Object.keys(persisted.update).sort()).toEqual([ + "failureStreak", "negativeReason", "retryAt", ]); diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index cfd3515d0..88d859d77 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -53,7 +53,6 @@ import { readStatusNegativeCache, refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, - writeStatusNegativeWindow, writeStatusNote, } from "../status-cache"; @@ -100,7 +99,7 @@ beforeEach(() => { probeProviderPresence.mockResolvedValue(true); }); -describe("writeStatusNote / writeStatusNegativeWindow", () => { +describe("writeStatusNote", () => { it("upserts one encrypted row per (user, metric, locale) and clears the negative window", async () => { vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); await writeStatusNote({ @@ -128,21 +127,8 @@ describe("writeStatusNote / writeStatusNegativeWindow", () => { expect(arg.update.snapshotHash).toBe("s".repeat(64)); expect(arg.update.retryAt).toBeNull(); expect(arg.update.negativeReason).toBeNull(); - }); - - it("opens a negative window without touching the stored note", async () => { - const retryAt = new Date(Date.now() + 60_000); - await writeStatusNegativeWindow({ - userId: "u1", - cacheAction: "insights.pulse-status.de", - todayKey: TODAY, - reason: "timeout", - retryAt, - }); - const arg = vi.mocked(prisma.insightStatusCache.upsert).mock - .calls[0][0] as { update: Record }; - // Only the window moves: a stall must not hide yesterday's text. - expect(arg.update).toEqual({ retryAt, negativeReason: "timeout" }); + // #1279 — a note ends the run of failures that sized its window. + expect(arg.update.failureStreak).toBe(0); }); }); diff --git a/src/lib/insights/__tests__/status-negative-window.test.ts b/src/lib/insights/__tests__/status-negative-window.test.ts new file mode 100644 index 000000000..63cf60d1e --- /dev/null +++ b/src/lib/insights/__tests__/status-negative-window.test.ts @@ -0,0 +1,160 @@ +/** + * #1279 — the negative-cache window of a status note grows with the + * generations in a row that ended without a note, is not moved or counted + * by a failure inside it, resets when the person asks again, and is what the + * provider chokepoint reads before any generation. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findUnique = vi.fn(); +const upsert = vi.fn(); +const updateMany = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + insightStatusCache: { + findUnique: (...a: unknown[]) => findUnique(...a), + upsert: (...a: unknown[]) => upsert(...a), + updateMany: (...a: unknown[]) => updateMany(...a), + }, + }, +})); + +import { + STATUS_NEGATIVE_WINDOW_BASE_MS, + clearStatusNegativeWindows, + statusNegativeWindowMs, + statusNoteCoolingDown, + writeStatusNegativeWindow, +} from "../status-negative-window"; + +const TODAY = "2026-05-31"; +const MINUTE = 60 * 1000; + +interface UpsertArgs { + where: { userId_metric_locale: Record }; + create: Record; + update: { retryAt: Date; negativeReason: string; failureStreak: number }; +} + +function written(): UpsertArgs { + return upsert.mock.calls[0][0] as UpsertArgs; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("writeStatusNegativeWindow", () => { + it("opens a five-minute window without touching the stored note", async () => { + findUnique.mockResolvedValue(null); + const before = Date.now(); + await writeStatusNegativeWindow({ + userId: "u1", + cacheAction: "insights.pulse-status.de", + todayKey: TODAY, + reason: "timeout", + }); + expect(written().where.userId_metric_locale).toEqual({ + userId: "u1", + metric: "pulse", + locale: "de", + }); + // Only the window moves: a stall must not hide yesterday's text. + expect(Object.keys(written().update).sort()).toEqual([ + "failureStreak", + "negativeReason", + "retryAt", + ]); + expect(written().update.negativeReason).toBe("timeout"); + expect(written().update.failureStreak).toBe(1); + const opened = written().update.retryAt.getTime() - before; + expect(opened).toBeGreaterThanOrEqual(STATUS_NEGATIVE_WINDOW_BASE_MS); + expect(opened).toBeLessThan(STATUS_NEGATIVE_WINDOW_BASE_MS + 5000); + }); + + it("grows the window with each failure in a row", async () => { + findUnique.mockResolvedValue({ + retryAt: new Date(Date.now() - 1000), + failureStreak: 3, + }); + const before = Date.now(); + await writeStatusNegativeWindow({ + userId: "u1", + cacheAction: "insights.metric:HEART_RATE-status.en", + todayKey: TODAY, + reason: "error", + }); + expect(written().update.failureStreak).toBe(4); + expect(written().update.retryAt.getTime() - before).toBeGreaterThanOrEqual( + 40 * MINUTE, + ); + }); + + it("neither moves nor counts a window that is still open", async () => { + findUnique.mockResolvedValue({ + retryAt: new Date(Date.now() + MINUTE), + failureStreak: 2, + }); + await writeStatusNegativeWindow({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + reason: "error", + }); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("ignores a name that is not a status note", async () => { + await writeStatusNegativeWindow({ + userId: "u1", + cacheAction: "workouts.insight", + todayKey: TODAY, + reason: "error", + }); + expect(findUnique).not.toHaveBeenCalled(); + expect(upsert).not.toHaveBeenCalled(); + }); +}); + +describe("statusNegativeWindowMs", () => { + it("doubles from five minutes to a two-hour ceiling", () => { + expect([1, 2, 3, 4, 5, 6, 7, 30].map(statusNegativeWindowMs)).toEqual( + [5, 10, 20, 40, 80, 120, 120, 120].map((m) => m * MINUTE), + ); + }); +}); + +describe("statusNoteCoolingDown", () => { + it("is true only while the window is open, and reads nothing for other names", async () => { + findUnique.mockResolvedValueOnce({ + retryAt: new Date(Date.now() + MINUTE), + }); + expect(await statusNoteCoolingDown("u1", "insights.weight-status.en")).toBe( + true, + ); + findUnique.mockResolvedValueOnce({ retryAt: new Date(Date.now() - 1) }); + expect(await statusNoteCoolingDown("u1", "insights.weight-status.en")).toBe( + false, + ); + expect(await statusNoteCoolingDown("u1", "workouts.insight")).toBe(false); + expect(findUnique).toHaveBeenCalledTimes(2); + }); +}); + +describe("clearStatusNegativeWindows", () => { + it("closes every open window and streak of one record", async () => { + await clearStatusNegativeWindows("u1"); + expect(updateMany).toHaveBeenCalledWith({ + where: { + userId: "u1", + OR: [{ retryAt: { not: null } }, { failureStreak: { gt: 0 } }], + }, + data: { retryAt: null, negativeReason: null, failureStreak: 0 }, + }); + }); + + it("never throws: the windows lapse on their own", async () => { + updateMany.mockRejectedValue(new Error("pool exhausted")); + await expect(clearStatusNegativeWindows("u1")).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index 488c15501..a6aaca6be 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -20,6 +20,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; let ledgerTotal = 0; /** …and of `coach_usage.operator_tokens`. */ let ledgerOperator = 0; +/** The status note row the cooling-down gate reads. */ +let noteWindow: { retryAt: Date | null } | null = null; vi.mock("@/lib/db", () => ({ prisma: { @@ -55,6 +57,10 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn(async () => ({ aiResponseTimeoutSeconds: null })), }, + // #1279 — the note's negative window, read before any provider work. + insightStatusCache: { + findUnique: vi.fn(async () => noteWindow), + }, }, })); @@ -126,6 +132,7 @@ beforeEach(() => { vi.clearAllMocks(); ledgerTotal = 0; ledgerOperator = 0; + noteWindow = null; resolveProviderChain.mockResolvedValue(OPERATOR_CHAIN); resolveProvider.mockResolvedValue({ type: "none" }); aiCapabilityForRecord.mockResolvedValue({ @@ -313,6 +320,24 @@ describe("runStatusCompletion — ledger accounting", () => { expect(ledgerTotal).toBe(0); }); + it("asks no provider about a note inside its negative window (#1279)", async () => { + noteWindow = { retryAt: new Date(Date.now() + 60_000) }; + const result = await runStatusCompletion( + completionArgs({ cacheAction: "insights.weight-status.en" }), + ); + expect(result.kind).toBe("error"); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + expect(ledgerTotal).toBe(0); + + // The window lapsed: the note is generated again. + noteWindow = { retryAt: new Date(Date.now() - 1) }; + mockProviderReply(100); + const again = await runStatusCompletion( + completionArgs({ cacheAction: "insights.weight-status.en" }), + ); + expect(again.kind).toBe("ok"); + }); + it("charges what a failed chain reported spending (#1279)", async () => { // A model that reasons and never answers is billed for every call; a // full refund let background work spend without the ceiling seeing it. diff --git a/src/lib/insights/__tests__/usage-level-simulation.test.ts b/src/lib/insights/__tests__/usage-level-simulation.test.ts index f8891903e..1f51ab309 100644 --- a/src/lib/insights/__tests__/usage-level-simulation.test.ts +++ b/src/lib/insights/__tests__/usage-level-simulation.test.ts @@ -41,14 +41,18 @@ vi.mock("@/lib/db", () => ({ async (args: { where: { OR: Array<{ metric: string; locale: string }>; - generatedAt: { gte: Date }; + generatedAt?: { gte: Date }; }; - }) => - args.where.OR.filter( + }) => { + // The second probe asks for open negative windows; every + // generation in this simulation succeeds, so there are none. + const since = args.where.generatedAt?.gte; + if (!since) return []; + return args.where.OR.filter( (key) => - (sim.notes.get(key.metric) ?? -Infinity) >= - args.where.generatedAt.gte.getTime(), - ).map((key) => ({ metric: key.metric, locale: key.locale })), + (sim.notes.get(key.metric) ?? -Infinity) >= since.getTime(), + ).map((key) => ({ metric: key.metric, locale: key.locale })); + }, ), }, }, diff --git a/src/lib/insights/derived/__tests__/derived-assessment-ai.test.ts b/src/lib/insights/derived/__tests__/derived-assessment-ai.test.ts index 7a99ef388..553b3c8bc 100644 --- a/src/lib/insights/derived/__tests__/derived-assessment-ai.test.ts +++ b/src/lib/insights/derived/__tests__/derived-assessment-ai.test.ts @@ -18,6 +18,12 @@ vi.mock("@/lib/insights/status-cache", () => ({ gateUnchangedStatusInput: vi.fn().mockResolvedValue(null), })); +const persistTimeoutNegativeStub = vi.fn(); +vi.mock("@/lib/insights/timeout-fallback", () => ({ + persistTimeoutNegativeStub: (...a: unknown[]) => + persistTimeoutNegativeStub(...a), +})); + const findUnique = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: (a: unknown) => findUnique(a) } }, @@ -156,3 +162,25 @@ describe("generateDerivedScoreAssessment — per-user tz label (QA F5)", () => { expect(capturedPrompt).not.toContain("(Europe/Berlin)"); }); }); + +describe("generateDerivedScoreAssessment — a failed generation (#1279)", () => { + it("opens the note's negative window, so the next sync does not ask again", async () => { + vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "error" }); + + const result = await generateDerivedScoreAssessment({ + metric: "RECOVERY_SCORE", + userId: "u-ny", + derived: okDerived(), + locale: "en", + now: new Date("2026-06-20T12:00:00.000Z"), + }); + + expect(result).toMatchObject({ kind: "error", retryable: true }); + expect(persistTimeoutNegativeStub).toHaveBeenCalledWith({ + userId: "u-ny", + cacheAction: "insights.derived-score:RECOVERY_SCORE-status.en", + todayKey: "2026-06-20", + reason: "error", + }); + }); +}); diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts index 0835dc46a..18aa3330c 100644 --- a/src/lib/insights/derived/derived-assessment-ai.ts +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -37,6 +37,7 @@ import { type AssessmentStatus, } from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { persistTimeoutNegativeStub } from "@/lib/insights/timeout-fallback"; import { readFreshStatusText, resolveReadOnlyStatusMiss, @@ -223,6 +224,32 @@ export async function resolveDerivedAssessment(args: { // ── generate (worker entry) ────────────────────────────────────────────── +/** + * A generation that timed out or failed. #1279 — it opens the same negative + * window every other note opens, so the score is not asked about again on + * the next sync while it keeps failing. + */ +async function failedAssessment( + userId: string, + cacheAction: string, + todayKey: string, + kind: "timeout" | "error", +): Promise { + await persistTimeoutNegativeStub({ + userId, + cacheAction, + todayKey, + reason: kind, + }); + return { + kind, + text: null, + hasProvider: true, + updatedAt: null, + retryable: true, + }; +} + /** * Worker entry: build the signal-led prompt for one derived score, run the * provider chain, and persist the AI assessment under the shared status-cache @@ -341,13 +368,7 @@ export async function generateDerivedScoreAssessment(args: { retryable: false, }; } - return { - kind: outcome.kind, - text: null, - hasProvider: true, - updatedAt: null, - retryable: true, - }; + return failedAssessment(args.userId, cacheAction, todayKey, outcome.kind); } // Parse and screen through the same fail-closed chokepoint as every other diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 4ccf2249a..785606490 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -84,7 +84,7 @@ export function statusCacheAction(scope: string, locale: string): string { const CACHE_ACTION_SHAPE = /^insights\.(.+)-status\.([^.]+)$/; /** The row key behind a note's name, or `null` for a name of another shape. */ -function statusCacheKey( +export function statusCacheKey( cacheAction: string, ): { metric: string; locale: string } | null { const match = CACHE_ACTION_SHAPE.exec(cacheAction); @@ -200,6 +200,7 @@ export async function writeStatusNote(args: { generatedAt, retryAt: null, negativeReason: null, + failureStreak: 0, }; await prisma.insightStatusCache.upsert({ where: { userId_metric_locale: { userId: args.userId, ...key } }, @@ -230,38 +231,13 @@ async function redateStatusNote( generatedAt, retryAt: null, negativeReason: null, + failureStreak: 0, }, }); } return generatedAt; } -/** - * Open a negative-cache window for a note: the generation stalled, erred, or - * was screened. Leaves any stored note exactly as it was. - */ -export async function writeStatusNegativeWindow(args: { - userId: string; - cacheAction: string; - todayKey: string; - reason: "timeout" | "error" | "screened"; - retryAt: Date; -}): Promise { - const key = statusCacheKey(args.cacheAction); - if (!key) return; - await prisma.insightStatusCache.upsert({ - where: { userId_metric_locale: { userId: args.userId, ...key } }, - create: { - userId: args.userId, - ...key, - dateKey: args.todayKey, - retryAt: args.retryAt, - negativeReason: args.reason, - }, - update: { retryAt: args.retryAt, negativeReason: args.reason }, - }); -} - export interface FreshStatusCacheHit { kind: "generated"; text: string; @@ -635,10 +611,12 @@ export async function readPreviousStatusNote(args: { } /** - * Which of `cacheActions` carry a note written at or after `since`. The - * ingest invalidator skips those, so a fresh note survives a sync drip. + * Which of `cacheActions` are not due for another generation: a note written + * at or after `since`, or (#1279) an open negative-cache window. The ingest + * invalidator skips those, so a fresh note survives a sync drip and a note + * whose generation keeps failing is not selected again by every sync. */ -export async function statusNotesWrittenSince(args: { +export async function statusNotesNotDue(args: { userId: string; cacheActions: readonly string[]; since: Date; @@ -653,17 +631,23 @@ export async function statusNotesWrittenSince(args: { const [metric, locale] = k.split("\u0000"); return { metric, locale }; }); - const rows = await prisma.insightStatusCache.findMany({ - where: { - userId: args.userId, - OR: keys, - generatedAt: { gte: args.since }, - textEncrypted: { not: null }, - }, - select: { metric: true, locale: true }, - }); + const [written, coolingDown] = await Promise.all([ + prisma.insightStatusCache.findMany({ + where: { + userId: args.userId, + OR: keys, + generatedAt: { gte: args.since }, + textEncrypted: { not: null }, + }, + select: { metric: true, locale: true }, + }), + prisma.insightStatusCache.findMany({ + where: { userId: args.userId, OR: keys, retryAt: { gt: new Date() } }, + select: { metric: true, locale: true }, + }), + ]); const fresh = new Set(); - for (const row of rows) { + for (const row of [...written, ...coolingDown]) { const action = byKey.get(`${row.metric}\u0000${row.locale}`); if (action) fresh.add(action); } diff --git a/src/lib/insights/status-invalidation.ts b/src/lib/insights/status-invalidation.ts index d3f818ccf..bcbff7d4c 100644 --- a/src/lib/insights/status-invalidation.ts +++ b/src/lib/insights/status-invalidation.ts @@ -22,8 +22,9 @@ import { } from "@/lib/insights/status-shared"; import { statusCacheAction, - statusNotesWrittenSince, + statusNotesNotDue, } from "@/lib/insights/status-cache"; +import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window"; import { metricIdForMeasurementType, metricStatusScope, @@ -275,6 +276,9 @@ export async function enqueueStatusRefillForUser( }); return 0; } + // #1279 — the person asked for fresh notes: a note backing off after + // failed generations is asked again now, not when its window lapses. + await clearStatusNegativeWindows(userId); const scopes = new Set(PER_STATUS_SCOPES); try { // v1.42 — a loose index scan over the live-rows index (`live-types.ts`) @@ -301,9 +305,9 @@ export async function enqueueStatusRefillForUser( * invalidator so a fresh assessment survives the sync drip. * * One indexed read per user answers it for every candidate scope at once. - * Only a stored note counts: a row that carries nothing but a negative-cache - * window is not fresh, so a scope that recently stalled still gets a retry - * enqueued. + * #1279 — a scope inside an open negative-cache window counts too: it used to + * be re-enqueued by every sync batch however often its generation had just + * failed. Once the window lapses the next sync enqueues it again. */ async function findRecentlyWarmedScopes( userId: string, @@ -321,7 +325,7 @@ async function findRecentlyWarmedScopes( actionToScope.set(statusCacheAction(scope, locale), scope); } - const written = await statusNotesWrittenSince({ + const written = await statusNotesNotDue({ userId, cacheActions: candidateActions, since: cutoff, diff --git a/src/lib/insights/status-negative-window.ts b/src/lib/insights/status-negative-window.ts new file mode 100644 index 000000000..bafb032a2 --- /dev/null +++ b/src/lib/insights/status-negative-window.ts @@ -0,0 +1,113 @@ +/** + * The negative-cache window of a status note: how long a note whose + * generation stalled, erred or was screened waits before it is generated + * again. + * + * The window used to be a fixed five minutes that only the read path + * honoured. #1279 — a model that never answers then failed every five + * minutes per note, and a note the next data sync selected again ignored the + * window altogether. The window now grows with the failures in a row + * (`failureStreak`), and every generation waits it out at the provider + * chokepoint (`runStatusCompletion`), whichever path selected the note. + */ +import { prisma } from "@/lib/db"; +import { statusCacheKey } from "@/lib/insights/status-cache"; + +/** The first negative-cache window after a generation that left no note. */ +export const STATUS_NEGATIVE_WINDOW_BASE_MS = 5 * 60 * 1000; + +/** The longest window: a note is tried again at least this often. */ +export const STATUS_NEGATIVE_WINDOW_MAX_MS = 2 * 60 * 60 * 1000; + +/** + * The window after `streak` generations in a row ended without a note: + * five minutes, doubling per further failure, up to two hours (#1279). + */ +export function statusNegativeWindowMs(streak: number): number { + const doublings = Math.min(Math.max(streak - 1, 0), 16); + return Math.min( + STATUS_NEGATIVE_WINDOW_BASE_MS * 2 ** doublings, + STATUS_NEGATIVE_WINDOW_MAX_MS, + ); +} + +/** + * Open a negative-cache window for a note: the generation stalled, erred, or + * was screened. Leaves any stored note exactly as it was. + * + * #1279 — the window grows with the failures in a row (`failureStreak`, + * `statusNegativeWindowMs`), so a model that never answers is asked about a + * note less and less often instead of every five minutes. A failure while a + * window is still open (a generation that was refused for it, or one that + * raced another) neither moves it nor counts. + */ +export async function writeStatusNegativeWindow(args: { + userId: string; + cacheAction: string; + todayKey: string; + reason: "timeout" | "error" | "screened"; +}): Promise { + const key = statusCacheKey(args.cacheAction); + if (!key) return; + const where = { userId_metric_locale: { userId: args.userId, ...key } }; + const row = await prisma.insightStatusCache.findUnique({ + where, + select: { retryAt: true, failureStreak: true }, + }); + const now = Date.now(); + if (row?.retryAt && row.retryAt.getTime() > now) return; + const failureStreak = (row?.failureStreak ?? 0) + 1; + const retryAt = new Date(now + statusNegativeWindowMs(failureStreak)); + await prisma.insightStatusCache.upsert({ + where, + create: { + userId: args.userId, + ...key, + dateKey: args.todayKey, + retryAt, + negativeReason: args.reason, + failureStreak, + }, + update: { retryAt, negativeReason: args.reason, failureStreak }, + }); +} + +/** + * True while the note's negative-cache window is open. #1279 — checked right + * before a status generation reaches a provider (`runStatusCompletion`), so + * no path that selects the note again (the next data sync, a warm pass, a + * queued job) can ask about it inside the window. + */ +export async function statusNoteCoolingDown( + userId: string, + cacheAction: string, +): Promise { + const key = statusCacheKey(cacheAction); + if (!key) return false; + const row = await prisma.insightStatusCache.findUnique({ + where: { userId_metric_locale: { userId, ...key } }, + select: { retryAt: true }, + }); + return row?.retryAt != null && row.retryAt.getTime() > Date.now(); +} + +/** + * Close every open window on one record's notes, streaks included: the person + * asked for fresh notes (the regenerate action) or changed what answers them. + * Best-effort: a failed write leaves windows that lapse on their own. + */ +export async function clearStatusNegativeWindows( + userId: string, +): Promise { + try { + await prisma.insightStatusCache.updateMany({ + where: { + userId, + OR: [{ retryAt: { not: null } }, { failureStreak: { gt: 0 } }], + }, + data: { retryAt: null, negativeReason: null, failureStreak: 0 }, + }); + } catch { + // The windows lapse by themselves. + } +} diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index edea35c09..0f6945bcf 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -36,6 +36,7 @@ import { type BackgroundReasoningJob, } from "@/lib/ai/reasoning/levels"; import { resolveJobReasoning } from "@/lib/ai/reasoning/controls"; +import { statusNoteCoolingDown } from "./status-negative-window"; /** * v1.41 — the fallback ceiling for a status-path call that reasons. Thinking @@ -192,6 +193,17 @@ export async function runStatusCompletion( return { kind: "none", reason: "no_provider" }; } + // #1279 — a note whose last generations ended without one waits out its + // window here, whichever path selected it again (a data sync, a warm pass, + // a queued job). Reported as `error`, the transient miss, like a stall. + if (await statusNoteCoolingDown(userId, cacheAction)) { + annotate({ + action: { name: "insights.status.cooling_down" }, + meta: { cacheAction }, + }); + return { kind: "error" }; + } + // The wire re-check for exactly this chain (`aiEgressRefusal`, the one // helper every chokepoint uses): the capability again, and whether sending // to these providers needs a consent receipt under the capability's rule @@ -286,28 +298,7 @@ export async function runStatusCompletion( "job", ); if (!reservation.allowed) { - // Over the day's ceiling. Reported as `error` — a TRANSIENT miss the caller - // serves the fallback for without persisting it — deliberately NOT `none`, - // which callers cache as the settled "no provider configured" assessment. - // The distinct annotation keeps the refusal observable even though the - // result shape is shared. - // v1.38.19 — the shared `error` outcome cannot say - // WHY the generation stopped, so the annotation has to. `surface: "job"` - // plus `cap` separates "this job used up the background share" from "the - // whole day is spent", and `limit` says which counter tripped. The operator - // sees the same split on the admin provider-health card. - annotate({ - action: { name: "insights.status.budget_exceeded" }, - meta: { - cacheAction, - owner: reservation.owner, - surface: "job", - limit: reservation.limit, - cap: jobCap, - totalAfter: reservation.totalAfter, - operatorAfter: reservation.operatorAfter, - }, - }); + annotateBudgetRefusal(cacheAction, reservation, jobCap); return { kind: "error" }; } @@ -405,3 +396,35 @@ export async function runStatusCompletion( tokensUsed: result.tokensUsed ?? null, }; } + +/** + * Over the day's ceiling. Reported as `error` — a TRANSIENT miss the caller + * serves the fallback for without persisting it — deliberately NOT `none`, + * which callers cache as the settled "no provider configured" assessment. + * The distinct annotation keeps the refusal observable even though the + * result shape is shared. + * + * v1.38.19 — the shared `error` outcome cannot say WHY the generation + * stopped, so the annotation has to. `surface: "job"` plus `cap` separates + * "this job used up the background share" from "the whole day is spent", and + * `limit` says which counter tripped. The operator sees the same split on the + * admin provider-health card. + */ +function annotateBudgetRefusal( + cacheAction: string, + reservation: Awaited>, + jobCap: number, +): void { + annotate({ + action: { name: "insights.status.budget_exceeded" }, + meta: { + cacheAction, + owner: reservation.owner, + surface: "job", + limit: reservation.limit, + cap: jobCap, + totalAfter: reservation.totalAfter, + operatorAfter: reservation.operatorAfter, + }, + }); +} diff --git a/src/lib/insights/timeout-fallback.ts b/src/lib/insights/timeout-fallback.ts index 7b7010e01..f0332716c 100644 --- a/src/lib/insights/timeout-fallback.ts +++ b/src/lib/insights/timeout-fallback.ts @@ -1,4 +1,4 @@ -import { writeStatusNegativeWindow } from "@/lib/insights/status-cache"; +import { writeStatusNegativeWindow } from "@/lib/insights/status-negative-window"; import { annotate } from "@/lib/logging/context"; /** @@ -31,19 +31,18 @@ import { annotate } from "@/lib/logging/context"; * but it tripped a safety contract (see `@/lib/ai/safety/outbound-screen`). * It rides the same envelope as a stall on purpose — the surface policy for a * background-generated cached card is WITHHOLD, and this path already means - * exactly "serve the deterministic line, persist no model text". The 5-minute - * negative stub gives the next generation a fresh attempt rather than blanking - * the card until midnight. + * exactly "serve the deterministic line, persist no model text". The negative + * stub gives the next generation a fresh attempt once its window lapses + * rather than blanking the card until midnight. */ export type StatusFallbackReason = "timeout" | "error" | "screened"; -/** Short retry window after a provider stall before a re-enqueue is allowed. */ -export const TIMEOUT_NEGATIVE_CACHE_MS = 5 * 60 * 1000; - /** - * Persist a short-TTL negative-cache stub for `(userId, cacheAction)`. - * Best-effort: a failure here just means the read-only resolver may - * re-enqueue sooner than the window intended. + * Persist a negative-cache stub for `(userId, cacheAction)`. Its window is five + * minutes after a first failure and grows with each failure in a row, up to + * two hours (#1279, `writeStatusNegativeWindow`). Best-effort: a failure here + * just means the read-only resolver may re-enqueue sooner than the window + * intended. */ export async function persistTimeoutNegativeStub(args: { userId: string; @@ -57,7 +56,6 @@ export async function persistTimeoutNegativeStub(args: { cacheAction: args.cacheAction, todayKey: args.todayKey, reason: args.reason, - retryAt: new Date(Date.now() + TIMEOUT_NEGATIVE_CACHE_MS), }); } catch { // Negative cache is best-effort — never throw out of the timeout path. diff --git a/tests/integration/__golden__/status-parity/batch.json b/tests/integration/__golden__/status-parity/batch.json index 054f02094..9f6cf622e 100644 --- a/tests/integration/__golden__/status-parity/batch.json +++ b/tests/integration/__golden__/status-parity/batch.json @@ -1631,15 +1631,15 @@ }, { "dateKey": "2026-06-15", - "generatedAt": "2026-06-15T10:00:00.000Z", + "generatedAt": null, "inputHash": null, "items": null, "locale": "en", "metric": "mood", - "negativeReason": null, - "retryAt": null, - "snapshotHash": "7dd495a6d92c3bc42320044bbbcbe89154afa13e0a954d9f62dee6239bced6f4", - "text": "Parity note for batch-unchanged-en.", + "negativeReason": "screened", + "retryAt": "2026-06-15T10:05:00.000Z", + "snapshotHash": null, + "text": null, "userId": "parity-rich" }, { @@ -1703,120 +1703,7 @@ } }, "batch-unchanged-en": { - "calls": [ - { - "maxTokens": 250, - "messages": [ - { - "content": [ - "Date: 2026-06-15", - "OPENER HINT: Open with what this means for the user today, then the number behind it.", - "Write one short assessment of this person's mood. Open with what the recent pattern MEANS in plain words — the read, not the number (e.g. \"a brighter stretch than usual\", \"steady, holding where it's been\") — then bring in the recent level right after as support, placed against their own weekly/monthly baseline; never lead with the value. Close with one kind, doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the entry count and recency.", - "", - "PREVIOUS ANALYSIS: none on file. Treat this as the user's first analysis for this domain — no improvement/regression delta to surface.", - "", - "", - "VARIETY: to keep your cards from reading the same, you may open this one by leading with the recent direction of travel (rising / falling / flat vs the baseline) — only if the data genuinely supports that angle; never force it, never invent a trend.", - "", - "DATA STRENGTH: 70 day-buckets in the window, latest reading today.", - "", - "", - "{\"locale\":\"en\",\"generatedForDay\":\"2026-06-15\",\"focus\":\"mood\",\"dataCoverage\":{\"totalEntries\":70,\"totalSpanDays\":69,\"newestEntryDaysAgo\":0},\"mood\":{\"signal\":{\"metric\":\"your mood\",\"unit\":\"/5\",\"current\":3.15,\"currentWindowDays\":20,\"baseline\":3.24,\"baselineLabel\":\"your recent-weeks average\",\"delta\":-0.09,\"deltaPct\":-2.7,\"spread\":0.75,\"outsideNormalSwing\":false,\"direction\":\"higher-better\",\"n\":20,\"newestDaysAgo\":0,\"normalRange\":{\"low\":3.5,\"high\":5},\"placement\":\"below band\"},\"summary\":{\"points\":70,\"start\":1,\"end\":2,\"delta\":1,\"mean\":3.03,\"min\":1,\"max\":5},\"series\":{\"recent\":[{\"date\":\"2026-05-26\",\"min\":2,\"max\":2,\"mean\":2,\"n\":1},{\"date\":\"2026-05-27\",\"min\":5,\"max\":5,\"mean\":5,\"n\":1},{\"date\":\"2026-05-28\",\"min\":3,\"max\":3,\"mean\":3,\"n\":1},{\"date\":\"2026-05-29\",\"min\":5,\"max\":5,\"mean\":5,\"n\":1},{\"date\":\"2026-05-30\",\"min\":3,\"max\":3,\"mean\":3,\"n\":1},{\"date\":\"2026-05-31\",\"min\":1,\"max\":1,\"mean\":1,\"n\":1},{\"date\":\"2026-06-01\",\"min\":3,\"max\":3,\"mean\":3,\"n\":1},{\"date\":\"2026-06-02\",\"min\":1,\"max\":1,\"mean\":1,\"n\":1},{\"date\":\"2026-06-03\",\"min\":4,\"max\":4,\"mean\":4,\"n\":1},{\"date\":\"2026-06-04\",\"min\":1,\"max\":1,\"mean\":1,\"n\":1},{\"date\":\"2026-06-05\",\"min\":4,\"max\":4,\"mean\":4,\"n\":1},{\"date\":\"2026-06-06\",\"min\":2,\"max\":2,\"mean\":2,\"n\":1},{\"date\":\"2026-06-07\",\"min\":4,\"max\":4,\"mean\":4,\"n\":1},{\"date\":\"2026-06-08\",\"min\":2,\"max\":2,\"mean\":2,\"n\":1},{\"date\":\"2026-06-09\",\"min\":5,\"max\":5,\"mean\":5,\"n\":1},{\"date\":\"2026-06-10\",\"min\":2,\"max\":2,\"mean\":2,\"n\":1},{\"date\":\"2026-06-11\",\"min\":5,\"max\":5,\"mean\":5,\"n\":1},{\"date\":\"2026-06-12\",\"min\":3,\"max\":3,\"mean\":3,\"n\":1},{\"date\":\"2026-06-13\",\"min\":5,\"max\":5,\"mean\":5,\"n\":1},{\"date\":\"2026-06-14\",\"min\":3,\"max\":3,\"mean\":3,\"n\":1}],\"weekly\":[{\"weekISO\":\"2026-W15\",\"min\":2,\"max\":5,\"mean\":3.33,\"n\":6},{\"weekISO\":\"2026-W16\",\"min\":1,\"max\":5,\"mean\":2.86,\"n\":7},{\"weekISO\":\"2026-W17\",\"min\":1,\"max\":5,\"mean\":2.86,\"n\":7},{\"weekISO\":\"2026-W18\",\"min\":1,\"max\":5,\"mean\":3,\"n\":7},{\"weekISO\":\"2026-W19\",\"min\":1,\"max\":5,\"mean\":3.14,\"n\":7},{\"weekISO\":\"2026-W20\",\"min\":1,\"max\":5,\"mean\":3.14,\"n\":7},{\"weekISO\":\"2026-W21\",\"min\":1,\"max\":4,\"mean\":2.57,\"n\":7},{\"weekISO\":\"2026-W22\",\"min\":5,\"max\":5,\"mean\":5,\"n\":1}],\"monthly\":[],\"yearly\":[]},\"latestDayFocus\":{\"dayOffset\":0,\"value\":1,\"deltaToPreviousDailyPoint\":-2},\"target\":{\"greenMin\":3.5,\"greenMax\":5,\"orangeMin\":2,\"orangeMax\":3.5,\"inTargetPctLast30DailyPoints\":40},\"tags\":null,\"tagInfluence\":{\"flat\":null,\"structured\":[{\"tag\":\"alcohol\",\"labelKey\":\"mood.tag.alcohol\",\"label\":null,\"categoryKey\":\"health\",\"icon\":\"Wine\",\"withDays\":24,\"withoutDays\":46,\"withAvg\":2.96,\"withoutAvg\":3.07,\"delta\":-0.11,\"pValue\":0.7704186651066071,\"confidence\":\"low\"}]},\"betterDays\":[{\"source\":\"metric\",\"key\":\"weight\",\"labelKey\":null,\"label\":null,\"categoryKey\":null,\"icon\":null,\"direction\":\"up\",\"n\":35,\"confidence\":\"medium\",\"effectSize\":0.523,\"delta\":null,\"r\":0.523},{\"source\":\"tag\",\"key\":\"alcohol\",\"labelKey\":\"mood.tag.alcohol\",\"label\":null,\"categoryKey\":\"health\",\"icon\":\"Wine\",\"direction\":\"down\",\"n\":24,\"confidence\":\"low\",\"effectSize\":0.07670850767085077,\"delta\":-0.11,\"r\":null}],\"factorCrosstab\":null,\"narratives\":[{\"kind\":\"trend\",\"messageKey\":\"insights.mood.narrative.trendUp\",\"vars\":{\"direction\":\"up\",\"delta\":\"0.9\",\"days\":\"7\"},\"strength\":0.86},{\"kind\":\"weekday-dip\",\"messageKey\":\"insights.mood.narrative.weekdayDip\",\"vars\":{\"weekdayKey\":\"charts.weekdaysFull.mon\",\"delta\":\"0.3\"},\"strength\":0.33},{\"kind\":\"in-target\",\"messageKey\":\"insights.mood.narrative.inTarget\",\"vars\":{\"pct\":\"40\"},\"strength\":0.2},{\"kind\":\"streak\",\"messageKey\":\"insights.mood.narrative.streak\",\"vars\":{\"days\":\"70\"},\"strength\":0.15}],\"timeOfDay\":{\"buckets\":[{\"bucket\":\"morning\",\"avgScore\":2.91,\"count\":35},{\"bucket\":\"afternoon\",\"avgScore\":null,\"count\":0},{\"bucket\":\"evening\",\"avgScore\":null,\"count\":0},{\"bucket\":\"night\",\"avgScore\":3.14,\"count\":35}],\"best\":\"night\",\"worst\":\"morning\"},\"stability\":{\"score\":6,\"stdDev\":1.414,\"band\":\"veryVariable\",\"days\":70}},\"crossMetricContext\":{\"weight\":{\"summary\":{\"points\":70,\"start\":84.2,\"end\":82.38,\"delta\":-1.82,\"mean\":83.19,\"min\":82.03,\"max\":84.48},\"correlation\":{\"r\":0.523,\"strength\":\"moderat\",\"n\":35}},\"bloodPressureSystolic\":{\"summary\":{\"points\":94,\"start\":128,\"end\":125,\"delta\":-3,\"mean\":125.39,\"min\":116,\"max\":134},\"correlation\":null},\"pulse\":{\"summary\":{\"points\":140,\"start\":70,\"end\":72,\"delta\":2,\"mean\":70.37,\"min\":65,\"max\":75},\"correlation\":null}}}" - ], - "role": "user" - } - ], - "responseFormat": "json", - "seed": 1618033, - "system": [ - "You are writing a short, personal assessment of ONE of this user's health metrics. You know their data and you read it against their OWN baseline — never against a population norm. Your clinical grounding is established guidelines (ESH 2023, WHO, AASM, DGE, DEGAM); you reference them without diagnosing.", - "", - "YOUR DATA (graded snapshot):", - "- signal: the finished comparison — { metric, unit, current, currentWindowDays, baseline, baselineLabel, delta, deltaPct, spread, outsideNormalSwing, direction, normalRange?, placement?, n, newestDaysAgo, contributors? }. `current` is the recent-window mean; `baseline` is the user's OWN longer average; `delta`/`deltaPct` are the SIGNED change vs that baseline; `spread` is their normal night-to-night swing; `outsideNormalSwing` is the pre-computed verdict on whether the change exceeds that swing. These are already computed — state them, do NOT recompute them. `contributors[]` (composite scores only) names the 1–2 sub-scores that moved the score.", - "- summary: { points, start, end, delta, mean, min, max } — the headline figures across the whole read window.", - "- series.recent[]: the last ~21 days, one row per day ({ date, min, max, mean, n }).", - "- series.weekly[]: the ~10 weeks before that, one ISO-week aggregate each.", - "- series.monthly[]: the ~12 months before that, one calendar-month aggregate each.", - "- series.yearly[]: everything older, one row per year ({ year, mean, min, max, n, slope }).", - "- So the most recent days are present individually; older spans are folded into week, month and year aggregates. Consult the series only for a specific day; lead from the signal block.", - "- A slice can be empty when the history is short — do not pretend it exists. When `signal.baseline` is null there is no established baseline yet; say so honestly instead of inventing a comparison.", - "", - "HOW TO BUILD THE ASSESSMENT — pick the 2–3 MOVES that fit THIS data and order them so they read as one connected thought, not a fixed checklist. Lead per the OPENER HINT in the user prompt when one is given — do NOT always open with the number. Vary the shape from one metric to the next; never open two metrics in a row the same way.", - "Moves available (choose the few that genuinely apply):", - "(a) the current finding — the signal block's `current` (or a value from series.recent for a day-level question);", - "(b) place it against the user's OWN baseline via the pre-computed signed `delta` vs `baselineLabel` — state it, do not re-derive it. Treat `outsideNormalSwing: false` as \"inside your usual range — not a finding\" and do NOT manufacture a trend; `outsideNormalSwing: true` is the real, reportable change. A `normalRange`/`placement` is only a coarse secondary anchor — the personal delta leads;", - "(c) name a genuine, earned win or a steady streak when the data shows one;", - "(d) one watch-item worth keeping an eye on;", - "(e) an association ONLY when an r-value is present and |r| > 0.4, phrased as \"moves with\" / \"tends to show up alongside\", never causal;", - "(f) EXACTLY ONE doable next step — and only when the finding genuinely implies one. One message = one behaviour, no list. When the value is steady and in a good place, do NOT manufacture a step — affirm briefly and name one thing to watch instead. A fabricated step is exactly the platitude we ban.", - "Synthesize, don't recite: the story of what the data means matters more than re-stating every number.", - "", - "TONE — a warm, motivating premium advisor (someone really looked at this person's data and found something worth their attention):", - "- Second person (\"your blood pressure\", \"your values\"), warm, direct, honest.", - "- Motivating and forward-looking: when the data earns it, name the genuine win plainly and build a little momentum — make the person feel seen and supported, not lectured. The encouragement must be EARNED by the numbers, never a reflexive compliment.", - "- Autonomy-supporting: \"can help\", \"worth a try\", never \"you must\".", - "- Never alarming, never moralising, never diagnostic — make no disease claim. Frame as a reasoned observation, not medical advice.", - "- Name unfavourable values honestly too: finding -> place against the user's own baseline -> one small doable step, framed as an opportunity rather than a failing. Do not downplay, do not dramatise.", - "- No platitudes and no bare number-echoing: every warm line is anchored to a real figure or a real change. A generic positivity opener (\"Your numbers look good\") is banned — earn the encouragement with the specific finding.", - "", - "OPENING — lead with meaning, not the metric value", - "Open on the read in plain words — what this says about the person today — and bring the number in right after, as support, never as the first thing. A line that opens on a value (\"Your resting heart rate is 61 …\") reads as a readout; the same line that opens on the meaning (\"Your heart's running a little calmer this week — about 61 …\") reads as an advisor who actually looked. Keep every claim tied to a real figure; only the ORDER changes, meaning first and the number right behind it. This holds whether the surface covers one metric or many.", - "", - "LENGTH: 2-4 sentences, roughly 30-60 words — but when the metric is steady and there is nothing to act on, ONE tight sentence is better than padding it to length. Concise and high-quality. No bare number-echoing, no filler, no generic platitudes.", - "", - "JUDGE THE DATA HONESTLY (never invent a trend):", - "- Only a few measurement points/entries/events or an empty recent slice: say honestly that there is too little data for a reliable trend yet, and give ONE pointer (e.g. log more regularly). Do not claim a trend. What \"few\" means depends on the metric — for mood it is entries, for adherence it is scheduled doses.", - "- Newest measurement clearly older than ~7 days (dataCoverage.newestMeasurementDaysAgo): note that the values may be out of date.", - "- Mention correlations ONLY when the r-value is present in the snapshot and |r| > 0.4. If the field is missing, do not interpret or invent one. Always phrase as an \"association\", never a \"cause\".", - "", - "FORBIDDEN FILLER (signals ungrounded platitudes — never emit, except in a disclaimer)", - "\"make sure to get enough sleep\", \"drink enough water\", \"regular exercise\", \"consult your doctor\".", - "", - "METRIC IDENTIFIERS STAY OUT OF PROSE", - "Never write database / enum-style names like \"BLOOD_PRESSURE_SYS\", \"PULSE_BPM\", \"MOOD_SCORE\", \"MEDICATION_COMPLIANCE_PCT\", \"HEART_RATE_VARIABILITY\", \"RESTING_HEART_RATE\", \"ACTIVE_ENERGY_BURNED\", \"FLIGHTS_CLIMBED\", \"WALKING_RUNNING_DISTANCE\", \"VO2_MAX\", \"BODY_TEMPERATURE\" or \"SLEEP_DURATION\" in any user-facing string, and never write the literal \"metric:\" chart-token in prose. Reference each metric with the natural-language label the user sees — \"your systolic\", \"your weight\", \"your pulse\", \"your mood\", \"your medication adherence\", \"your resting heart rate\", \"your sleep duration\", \"your steps\".", - "", - "GLP-1 DOSE SAFETY (contract, not style)", - "NEVER prescribe, recommend, or modify medication doses, even when the snapshot names a GLP-1 receptor agonist (Mounjaro, Ozempic, Wegovy, Zepbound, Trulicity, Saxenda, Rybelsus). You may NOTE the named medication and the current titration step (\"week 3 on 7.5 mg\") when the snapshot carries it, but never write \"step up to X mg\", \"consider increasing to Y mg\", \"stop at Z mg\", or any variation. A plateau always frames the next decision as a conversation with the prescribing clinician. If you are unsure whether something is dose-prescriptive, treat it as if it is and defer.", - "", - "ACUTE RED FLAGS (contract, not style)", - "If the user describes an acute crisis sign — chest pain or chest pressure, fainting or near-fainting (syncope), a sudden severe symptom (e.g. worst-ever headache, sudden weakness or trouble speaking, trouble breathing), a hypertensive-crisis reading (systolic ≥ 180 or diastolic ≥ 120 with symptoms), a severe-low or very-high glucose reading (below 54 mg/dL (3.0 mmol/L), or below 70 mg/dL (3.9 mmol/L) with symptoms, or at/above 250 mg/dL (13.9 mmol/L) with symptoms), a sustained fever at/above 38.5 °C (101.3 °F), or any mention of suicidal thoughts or self-harm — say plainly, in one calm sentence, that this needs prompt medical attention or emergency services now, and do NOT data-coach it. Do not diagnose, do not name a condition, do not estimate severity from the numbers — just point to prompt/emergency care and stop. This is a closed list; outside it, stay calm and non-alarmist as usual.", - "", - "GROUNDING — ZERO HALLUCINATIONS", - "Every claim must trace to a number visible in the snapshot you were given. If you cannot point to a snapshot field, do NOT make the claim, and never invent a measurement to satisfy a request. Read each value against the user's OWN baseline (their recent vs longer-window averages), never against a population norm. State a correlation only when its r-value is present and |r| > 0.4, always as an \"association\", never a \"cause\".", - "", - "TONE — a warm, motivating advisor, never clinical or alarming", - "Write in the second person, warm and direct. When the data earns it, name the genuine win plainly and build a little momentum — the person should feel seen and supported, not lectured. The encouragement must be EARNED by the numbers, never a reflexive compliment. Affirmation is anchored, specific, and proportionate: tie it to a real figure or change, make it non-transferable to another user, and keep credit quiet for quiet wins. Over-validation is a safety regression, not warmth — never affirm a worsening trend or an unsafe choice; validate the effort, not the choice, and when there is nothing to praise, stay neutral (\"nothing to act on — the good kind of boring\") rather than manufacturing a compliment. Be autonomy-supporting (\"worth a try\", \"can help\", never \"you must\"). Name unfavourable values honestly too — finding, then place it against the user's own baseline, then one small doable step framed as an opportunity. Never alarm, never moralise, never diagnose. No platitudes and no bare number-echoing. Do NOT open with a compliment about the data quantity or quality; banned openers include \"Your data foundation is strong\", \"Datengrundlage ist sehr stark\", \"You have a solid baseline\", \"Great dataset\", a generic \"Your numbers look good\", and any rephrasing of the same sentiment.", - "", - "NOT AN INSIGHT — never recite mechanics", - "A measurement count (\"logged 3 times\"), a logging cadence (\"measured every few days\"), or page-mechanics narration is NOT an insight and does not belong in the prose on its own. Use such a fact ONLY when it carries a consequence, and then state the consequence, not the count: e.g. \"too few readings this week to call a trend — one morning reading would settle it\", not \"you measured twice this week\". Every sentence must earn its place by telling the person what a value MEANS or what follows from it, never by narrating how the data was collected.", - "", - "INTERPRET, DON'T ENUMERATE", - "Say what a value MEANS, not just what it is. When the user prompt carries an INTERPRETATION CONTEXT block, use it: name where the value sits on the guideline scale, what that band means in plain, dignified words, and what practically follows. Judge the trend IN CONTEXT — a \"slightly rising\" reading deep inside a favourable band is a footnote; the same movement at or near a boundary is the headline. Frame any consequence WITHOUT diagnosis: \"values in this range are considered … per guidelines\", never \"you have X\" or \"you are at risk of X\". When NO interpretation block is present, the metric has no general reference band — interpret it relative to the person's OWN baseline and say plainly that no general reference band is attached, rather than inventing one.", - "", - "FORMATTING — write in short paragraphs separated by a BLANK LINE, each 1–3 sentences; a longer reply is 2–4 paragraphs, never one block. Put a blank line between distinct ideas (e.g. \"where things stand\" vs \"one thing to try\"). A steady one-liner stays a single paragraph — do NOT pad to fill a second. When you genuinely enumerate three or more parallel items (options, findings, steps), format them as a plain list: one item per line, each line starting with \"- \". You may bold the single most important takeaway with **double asterisks**, at most once per reply. Nothing else: no headings, no italics, no backticks, no numbered lists, no emojis.", - "", - "EXAMPLES — note the DIFFERENT shapes (verdict-led, trend-led, one-liner). They illustrate form and grounding only; never copy them — every assessment uses the real snapshot numbers:", - "- VERDICT-LED (meaning first, number as support, one step): \"Your resting heart rate is running a touch lower than usual — about 61 bpm this week, 5 below your monthly mean of 66, and your lowest in weeks. That kind of dip usually tracks with more movement; worth keeping the routine that earned it.\"", - "- TREND-LED (direction first, then where it stands): \"Your systolic pressure has been easing down steadily for three weeks now, sitting around 124 mmHg — about 4 under your 30-day average. Nothing dramatic, just a consistent direction.\"", - "- ONE-LINER (steady, NO forced step): \"Your SpO₂ is steady at 97 %, right inside your usual range — nothing to act on, the good kind of boring.\"", - "- BAD (banned filler, ungrounded — do NOT write this): \"Your numbers look good. Make sure to get enough sleep and keep up regular exercise.\"", - "", - "OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The \"summary\" field holds the complete assessment in English: 1-3 short paragraphs of 1-3 sentences each, separated by a blank line written as \\n\\n INSIDE the JSON string. A steady one-liner stays a single paragraph:", - "{ \"summary\": \"...\" }", - "", - "METRIC — MOOD / WELL-BEING:", - "- Scale 1 (very bad) to 5 (very good). The snapshot carries mood.signal (the finished comparison) + mood.summary + mood.series (graded daily means), mood.target (green/orange band), mood.latestDayFocus and, where present, mood.tags (most frequent mood tags).", - "- The weekly pattern matters: lead from mood.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Treat outsideNormalSwing: false as \"inside the usual swing — not a finding\".", - "- Name persistent phases seriously but calmly: several days below ~2.5 or above ~4.5 are worth noting — as an observation, never a diagnosis and never alarming.", - "- When mood stays very low for several days (clearly below ~2.5), offer a support pathway calmly and in an autonomy-preserving way — e.g. \"if the strain persists, talking it through with someone you trust or a professional can help\". Never alarming, never a diagnosis, no emergency tone; a gentle option, not an imperative.", - "- Tags: if mood.tags is present, you may name a recurring tag as possible context, without claiming a link.", - "- Cross-metric: crossMetricContext carries correlations to weight, blood pressure and pulse; mention only when present and |r| > 0.4 — as an association, never a cause. Do not force a cross-link without a clear pattern.", - "- One message: close with ONE doable, kind step ONLY when the finding implies one (e.g. on good days, briefly note what helped). When mood is steadily good, acknowledge that honestly and name one thing worth keeping an eye on instead of manufacturing a step." - ], - "temperature": 0.45, - "timeoutMs": 60000 - } - ], + "calls": [], "enqueued": [], "notes": [ { @@ -1882,15 +1769,15 @@ }, { "dateKey": "2026-06-15", - "generatedAt": "2026-06-15T10:00:00.000Z", + "generatedAt": null, "inputHash": null, "items": null, "locale": "en", "metric": "mood", - "negativeReason": null, - "retryAt": null, - "snapshotHash": "7dd495a6d92c3bc42320044bbbcbe89154afa13e0a954d9f62dee6239bced6f4", - "text": "Parity note for batch-unchanged-en.", + "negativeReason": "screened", + "retryAt": "2026-06-15T10:05:00.000Z", + "snapshotHash": null, + "text": null, "userId": "parity-rich" }, { diff --git a/tests/integration/insights-status-real-text.test.ts b/tests/integration/insights-status-real-text.test.ts index 248965294..c0f9c3517 100644 --- a/tests/integration/insights-status-real-text.test.ts +++ b/tests/integration/insights-status-real-text.test.ts @@ -9,8 +9,10 @@ * sticky-stub bug used to break. * * 2. A negative-cache window opened by an earlier stall never stands in - * for a note: with no note stored for today, the generator regenerates - * and stores the real text, and writing it closes the window. + * for a note: once it lapses, with no note stored for today, the + * generator regenerates and stores the real text, and writing it closes + * the window and resets the failure streak. While it is open (#1279), + * no provider is asked and the window stays as it was. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -117,19 +119,20 @@ describe("status path delivers real provider text", () => { ).toBe(0); }); - it("regenerates past a negative-cache window and closes it", async () => { + it("regenerates past a lapsed negative-cache window and closes it", async () => { const user = await seedRichWeightUser("status-stub-skip-user"); const prisma = getPrismaClient(); - // An earlier stall opened a window, with no note stored. + // Earlier stalls opened a window, now lapsed, with no note stored. await prisma.insightStatusCache.create({ data: { userId: user.id, metric: "weight", locale: "en", dateKey: "2026-01-01", - retryAt: new Date(Date.now() + 60_000), + retryAt: new Date(Date.now() - 1_000), negativeReason: "timeout", + failureStreak: 3, }, }); @@ -153,5 +156,49 @@ describe("status path delivers real provider text", () => { }); expect(stored.retryAt).toBeNull(); expect(stored.negativeReason).toBeNull(); + expect(stored.failureStreak).toBe(0); + }); + + it("asks no provider while the window is open (#1279)", async () => { + const user = await seedRichWeightUser("status-cooling-user"); + const prisma = getPrismaClient(); + const retryAt = new Date(Date.now() + 60_000); + await prisma.insightStatusCache.create({ + data: { + userId: user.id, + metric: "weight", + locale: "en", + dateKey: "2026-01-01", + retryAt, + negativeReason: "error", + failureStreak: 2, + }, + }); + const { resolveProviderChain } = await import("@/lib/ai/provider"); + const [entry] = await vi.mocked(resolveProviderChain)(user.id); + const generate = vi.mocked(entry.instance.generateCompletion); + generate.mockClear(); + + const { generateWeightStatusForUser } = + await import("@/lib/insights/weight-status"); + const result = await generateWeightStatusForUser(user.id, { + locale: "en", + }); + + expect(generate).not.toHaveBeenCalled(); + expect(result.text).not.toBe(KNOWN_TEXT); + const stored = await prisma.insightStatusCache.findUniqueOrThrow({ + where: { + userId_metric_locale: { + userId: user.id, + metric: "weight", + locale: "en", + }, + }, + }); + // The window neither moved nor counted the refused generation. + expect(stored.retryAt?.getTime()).toBe(retryAt.getTime()); + expect(stored.failureStreak).toBe(2); + expect(stored.textEncrypted).toBeNull(); }); }); diff --git a/tests/integration/status-generator-parity.test.ts b/tests/integration/status-generator-parity.test.ts index 9b34eddbe..2af0152b5 100644 --- a/tests/integration/status-generator-parity.test.ts +++ b/tests/integration/status-generator-parity.test.ts @@ -85,9 +85,11 @@ vi.mock( }), ); -vi.mock("@/lib/insights/status-cache", async (importOriginal) => { +vi.mock("@/lib/insights/status-negative-window", async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal< + typeof import("@/lib/insights/status-negative-window") + >(); return { ...actual, writeStatusNegativeWindow: (