diff --git a/src/lib/insights/__tests__/bmi-status.test.ts b/src/lib/insights/__tests__/bmi-status.test.ts index 251f7b56f..2926ca251 100644 --- a/src/lib/insights/__tests__/bmi-status.test.ts +++ b/src/lib/insights/__tests__/bmi-status.test.ts @@ -4,7 +4,8 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, auditLog: { findFirst: vi.fn(), create: vi.fn() }, - measurement: { findMany: vi.fn() }, + // v1.18.11 (P6) — the input gate probes salient inputs via groupBy. + measurement: { findMany: vi.fn(), groupBy: vi.fn() }, measurementRollup: { findMany: vi.fn() }, }, })); @@ -50,6 +51,10 @@ beforeEach(() => { // Cold rollup tier: the BMI graded series scales the WEIGHT tier, which // folds from the full-history `measurement.findMany` fallback on a miss. vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); + // v1.18.11 (P6) — input-gate probe default: empty groups so the fingerprint + // is computed but, with no cached `inputHash`, the gate misses and fixtures + // build normally. The forced fixtures skip the gate entirely. + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); }); describe("generateBmiStatusForUser — graded payload", () => { diff --git a/src/lib/insights/__tests__/payload-budget.test.ts b/src/lib/insights/__tests__/payload-budget.test.ts index 4a10c5acd..a98b0a349 100644 --- a/src/lib/insights/__tests__/payload-budget.test.ts +++ b/src/lib/insights/__tests__/payload-budget.test.ts @@ -16,10 +16,11 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, auditLog: { findFirst: vi.fn(), create: vi.fn() }, - measurement: { findMany: vi.fn() }, + // v1.18.11 (P6) — the slow-mover input gate probes salient inputs. + measurement: { findMany: vi.fn(), groupBy: vi.fn() }, measurementRollup: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, - moodEntry: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn(), aggregate: vi.fn() }, }, })); @@ -76,6 +77,13 @@ beforeEach(() => { [] as never, ); vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); + // v1.18.11 (P6) — input-gate probe: empty groups + zero mood so the gate + // misses (no cached inputHash) and every fixture builds normally. + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.moodEntry.aggregate).mockResolvedValue({ + _count: { _all: 0 }, + _max: { moodLoggedAt: null }, + } as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, gender: null, diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index b87c734c0..9257c6dcc 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -3,6 +3,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { auditLog: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() }, + // v1.18.11 (P6) — the input gate / fingerprint probe salient inputs. + measurement: { groupBy: vi.fn() }, + moodEntry: { aggregate: vi.fn() }, }, })); @@ -21,6 +24,8 @@ vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ import { prisma } from "@/lib/db"; import { + computeStatusInputFingerprint, + gateUnchangedStatusInput, isTimeoutStub, readFreshStatusText, refreshUnchangedStatusInsight, @@ -400,3 +405,177 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => { expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); }); + +describe("computeStatusInputFingerprint (v1.18.11 P6)", () => { + beforeEach(() => { + statusConsentBlocksGeneration.mockResolvedValue(false); + }); + + it("is stable across group order and flips when a count or newest moves", async () => { + const t0 = new Date("2026-05-30T08:00:00.000Z"); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { type: "WEIGHT", _count: { _all: 10 }, _max: { measuredAt: t0 } }, + { + type: "BLOOD_PRESSURE_SYS", + _count: { _all: 3 }, + _max: { measuredAt: t0 }, + }, + ] as never); + const a = await computeStatusInputFingerprint({ + userId: "u1", + types: ["WEIGHT", "BLOOD_PRESSURE_SYS"], + }); + + // Same data, reversed group order → identical fingerprint. + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + type: "BLOOD_PRESSURE_SYS", + _count: { _all: 3 }, + _max: { measuredAt: t0 }, + }, + { type: "WEIGHT", _count: { _all: 10 }, _max: { measuredAt: t0 } }, + ] as never); + const b = await computeStatusInputFingerprint({ + userId: "u1", + types: ["WEIGHT", "BLOOD_PRESSURE_SYS"], + }); + expect(b).toBe(a); + + // One more reading → count moves → fingerprint flips. + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { type: "WEIGHT", _count: { _all: 11 }, _max: { measuredAt: t0 } }, + { + type: "BLOOD_PRESSURE_SYS", + _count: { _all: 3 }, + _max: { measuredAt: t0 }, + }, + ] as never); + const c = await computeStatusInputFingerprint({ + userId: "u1", + types: ["WEIGHT", "BLOOD_PRESSURE_SYS"], + }); + expect(c).not.toBe(a); + }); + + it("folds mood and extra inputs into the hash when requested", async () => { + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + type: "WEIGHT", + _count: { _all: 5 }, + _max: { measuredAt: new Date("2026-05-30T08:00:00.000Z") }, + }, + ] as never); + vi.mocked(prisma.moodEntry.aggregate).mockResolvedValue({ + _count: { _all: 2 }, + _max: { moodLoggedAt: new Date("2026-05-29T20:00:00.000Z") }, + } as never); + + const withMood = await computeStatusInputFingerprint({ + userId: "u1", + types: ["WEIGHT"], + includeMood: true, + }); + const heightChanged = await computeStatusInputFingerprint({ + userId: "u1", + types: ["WEIGHT"], + includeMood: true, + extra: { heightCm: 180 }, + }); + expect(heightChanged).not.toBe(withMood); + // moodEntry.aggregate is only queried when includeMood is set. + await computeStatusInputFingerprint({ userId: "u1", types: ["WEIGHT"] }); + expect(prisma.moodEntry.aggregate).toHaveBeenCalledTimes(2); + }); +}); + +describe("gateUnchangedStatusInput (v1.18.11 P6)", () => { + const INPUT = "b".repeat(64); + + beforeEach(() => { + statusConsentBlocksGeneration.mockResolvedValue(false); + }); + + it("re-stamps the cached text and skips the build on a matching input hash", async () => { + const created = new Date("2026-05-31T02:00:00.000Z"); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: "2026-05-30", + text: "Stable weight.", + model: "gpt-4o-mini", + inputHash: INPUT, + snapshotHash: "c".repeat(64), + }) as never, + ); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: created, + } as never); + + const hit = await gateUnchangedStatusInput({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + inputHash: INPUT, + force: false, + }); + expect(hit?.text).toBe("Stable weight."); + const persisted = JSON.parse( + ( + vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { + data: { details: string }; + } + ).data.details, + ) as { dateKey: string; inputHash: string; snapshotHash: string }; + expect(persisted.dateKey).toBe(TODAY); + // The prior fingerprints are preserved so the next day's gates match. + expect(persisted.inputHash).toBe(INPUT); + expect(persisted.snapshotHash).toBe("c".repeat(64)); + }); + + it("misses on a differing or missing input hash (caller builds)", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: "2026-05-30", + text: "Stable weight.", + model: "gpt-4o-mini", + // no inputHash on the row + }) as never, + ); + expect( + await gateUnchangedStatusInput({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + inputHash: INPUT, + force: false, + }), + ).toBeNull(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it("misses on a forced run without touching the cache", async () => { + expect( + await gateUnchangedStatusInput({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + inputHash: INPUT, + force: true, + }), + ).toBeNull(); + expect(prisma.auditLog.findFirst).not.toHaveBeenCalled(); + }); + + it("misses when the server-managed consent gate would block (no stale re-date)", async () => { + statusConsentBlocksGeneration.mockResolvedValue(true); + expect( + await gateUnchangedStatusInput({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + inputHash: INPUT, + force: false, + }), + ).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 1fea27ab2..9c4469606 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -3,9 +3,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { auditLog: { findFirst: vi.fn(), create: vi.fn() }, - measurement: { findMany: vi.fn() }, + // v1.18.11 (P6) — the input gate probes salient inputs via groupBy + + // moodEntry.aggregate before the heavy findMany build. + measurement: { findMany: vi.fn(), groupBy: vi.fn() }, measurementRollup: { findMany: vi.fn() }, - moodEntry: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn(), aggregate: vi.fn() }, // v1.11.1 — the rollup readers lazy-load the user's // `sourcePriorityJson` via `loadUserSourcePriority`. `null` here → // default rank ladders. @@ -55,6 +57,15 @@ beforeEach(() => { vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); // v1.11.1 — null source-priority blob → default rank ladders. vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + // v1.18.11 (P6) — input-gate probe. Default to empty groups + zero mood so + // the fingerprint is computed but, with no cached `inputHash`, the gate + // misses and every fixture proceeds to its normal build. Tests that + // exercise the gate set these explicitly. + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.moodEntry.aggregate).mockResolvedValue({ + _count: { _all: 0 }, + _max: { moodLoggedAt: null }, + } as never); }); describe("generateWeightStatusForUser — graded payload", () => { diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 51ee25ad2..231359f9a 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -28,6 +28,8 @@ import { summarizeSeries, } from "@/lib/insights/status-shared"; import { + computeStatusInputFingerprint, + gateUnchangedStatusInput, readFreshStatusText, refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, @@ -176,6 +178,38 @@ export async function prepareBmiStatusForUser( }; } + // v1.18.11 (P6) — input gate for this slow-moving metric. The snapshot + // derives from WEIGHT rows and the profile heightCm; a cheap probe over + // both fingerprints the inputs, so a non-forced unchanged week re-stamps + // the cached assessment and skips the whole build (findMany + BMI series + + // provider). A forced run never gates but still records a current + // fingerprint for the next day's gate. + const inputHash = await computeStatusInputFingerprint({ + userId, + types: ["WEIGHT"], + extra: { heightCm: user.heightCm }, + }); + if (!force) { + const unchangedInput = await gateUnchangedStatusInput({ + userId, + cacheAction, + todayKey, + inputHash, + force, + }); + if (unchangedInput) { + return { + phase: "served", + result: { + hasProvider: true, + text: unchangedInput.text, + cached: true, + updatedAt: unchangedInput.updatedAt, + }, + }; + } + } + // v1.4.28 FB-D2 — cap the snapshot input (weight runs at most once // per day for typical users; 365 covers a full year while the // downstream payload budget trims further). @@ -404,6 +438,8 @@ export async function prepareBmiStatusForUser( model: outcome.model, tokensUsed: outcome.tokensUsed, snapshotHash, + // v1.18.11 (P6) — persist the input fingerprint for tomorrow's gate. + inputHash, }); return { hasProvider: true, diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 67db6e30d..28d2021c8 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -1,3 +1,4 @@ +import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { hasUsableStatusProvider, @@ -7,6 +8,7 @@ import { enqueueStatusGeneration, type InsightStatusScope, } from "@/lib/jobs/insight-status-generate-shared"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { annotate } from "@/lib/logging/context"; /** @@ -57,6 +59,8 @@ interface ParsedStatusCache { retryAt?: string; /** v1.16.8 — fingerprint of the data snapshot the text was generated from. */ snapshotHash?: string; + /** v1.18.11 (P6) — cheap fingerprint of the salient inputs (count + newest). */ + inputHash?: string; } /** @@ -198,6 +202,157 @@ export async function refreshUnchangedStatusInsight(args: { return { text: parsed.text, updatedAt: created.createdAt.toISOString() }; } +/** + * v1.18.11 (P6) — cheap input fingerprint for the slow-moving status + * metrics (weight / BMI). + * + * The post-build content-hash gate (`refreshUnchangedStatusInsight`) + * already skips the LLM on unchanged data, but it only runs AFTER the heavy + * snapshot build (the bounded `findMany` + per-series rollup reads + + * correlation math). For metrics that move on a weekly cadence that rebuild + * is paid six days out of seven for nothing. + * + * This probe answers "did any salient input change since the cached + * assessment?" with ONE grouped query — per salient type, the live row + * `count` plus the newest `measuredAt`. A new or removed reading flips one + * of those, which flips the hash; an idle day leaves it byte-identical. The + * caller hashes the result and, on a match, skips the entire build. The + * finer post-build snapshot gate stays in place for the cases this coarse + * probe can't see (e.g. an in-place edit that keeps count + newest stamp). + */ +export async function computeStatusInputFingerprint(args: { + userId: string; + types: readonly MeasurementType[]; + /** + * v1.18.11 (P6) — include the mood-entry table in the fingerprint. The + * weight snapshot folds a mood-context block, so a mood change must flip + * the input hash or the gate would skip a build whose prose could have + * moved. Omit for metrics that don't read mood. + */ + includeMood?: boolean; + /** + * v1.18.11 (P6) — extra non-measurement inputs the snapshot derives from + * (e.g. BMI reads the profile `heightCm`). Folded into the hash so a + * change to one of them flips the gate. Values must be JSON-stable. + */ + extra?: Record; +}): Promise { + const [grouped, mood] = await Promise.all([ + prisma.measurement.groupBy({ + by: ["type"], + where: { + userId: args.userId, + type: { in: [...args.types] }, + deletedAt: null, + }, + _count: { _all: true }, + _max: { measuredAt: true }, + }), + args.includeMood + ? prisma.moodEntry.aggregate({ + where: { userId: args.userId }, + _count: { _all: true }, + _max: { moodLoggedAt: true }, + }) + : Promise.resolve(null), + ]); + // Deterministic shape regardless of group order: sort by type, project a + // stable `{ type, count, newest }` triple. `hashInsightSnapshot` sorts keys + // and collapses Date → ISO, so the hash is order- and clock-stable. + const fingerprint = grouped + .map((row) => ({ + type: row.type, + count: row._count._all, + newest: row._max.measuredAt ? row._max.measuredAt.toISOString() : null, + })) + .sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0)); + return hashInsightSnapshot({ + statusInput: fingerprint, + ...(mood + ? { + mood: { + count: mood._count._all, + newest: mood._max.moodLoggedAt + ? mood._max.moodLoggedAt.toISOString() + : null, + }, + } + : {}), + ...(args.extra ? { extra: args.extra } : {}), + }); +} + +/** + * v1.18.11 (P6) — the INPUT gate for slow-moving status metrics. + * + * Runs BEFORE the snapshot build. When the latest cached assessment is a + * real (non-stub) text whose stored `inputHash` equals the freshly probed + * one, nothing the prompt could see has changed, so the gate re-stamps that + * text under today's `dateKey` and returns it — the caller then skips the + * whole gather AND the provider call. Returns `null` on any miss (no prior + * row, stub, empty text, missing or differing `inputHash`, or a forced + * regeneration), in which case the caller proceeds to the normal build + + * the finer post-build content-hash gate. + * + * Consent is checked first, mirroring `refreshUnchangedStatusInsight`: + * re-dating a cached text presents it as current, so a user who revoked the + * server-managed AI consent must never have stale text re-stamped by an + * unchanged-input refresh. + */ +export async function gateUnchangedStatusInput(args: { + userId: string; + cacheAction: string; + todayKey: string; + inputHash: string; + force: boolean; +}): Promise { + if (args.force) return null; + + if (await statusConsentBlocksGeneration(args.userId, "insights")) { + annotate({ + action: { name: "insights.status.consent_required" }, + meta: { cache_action: args.cacheAction, gate: "unchanged-input" }, + }); + return null; + } + + 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.inputHash || parsed.inputHash !== args.inputHash) return null; + + // Same inputs, valid text — re-stamp the day key WITHOUT rebuilding the + // snapshot or calling the provider. Preserve every field (incl. the prior + // `snapshotHash` + `inputHash`) so the next day's gates still match. + 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, gate: "input" }, + }); + 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 7e6f4ad7b..3113926e5 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -173,6 +173,15 @@ export async function persistStatusInsight(args: { * provider call when nothing changed. */ snapshotHash?: string; + /** + * v1.18.11 (P6) — cheap fingerprint of the SALIENT INPUTS (per-type + * count + newest measuredAt) for slow-moving metrics. The input gate + * compares a freshly probed fingerprint against this BEFORE the heavy + * snapshot build, so a no-change day for weight/BMI skips the whole + * gather (not just the provider call). Absent on metrics that don't + * opt into the input gate. + */ + inputHash?: string; }): Promise { const created = await prisma.auditLog.create({ data: { @@ -186,6 +195,7 @@ export async function persistStatusInsight(args: { model: args.model, tokensUsed: args.tokensUsed, ...(args.snapshotHash ? { snapshotHash: args.snapshotHash } : {}), + ...(args.inputHash ? { inputHash: args.inputHash } : {}), }), }, select: { createdAt: true }, diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 2608c870b..7a2c43499 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -39,6 +39,8 @@ import { summarizeSeries, } from "@/lib/insights/status-shared"; import { + computeStatusInputFingerprint, + gateUnchangedStatusInput, readFreshStatusText, refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, @@ -212,6 +214,40 @@ export async function prepareWeightStatusForUser( }; } + // v1.18.11 (P6) — input gate for this slow-moving metric. A cheap grouped + // probe over the salient inputs (weight + BP channels + mood) fingerprints + // what the snapshot would read. On a non-forced run with an unchanged + // fingerprint the cached assessment is re-stamped under today's key and the + // whole heavy build below (bounded findMany + per-series rollup reads + + // correlation math + the provider call) is skipped. A forced run never + // gates — but still computes the fingerprint so the persisted row carries a + // current one for the next day's gate. + const inputHash = await computeStatusInputFingerprint({ + userId, + types: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + includeMood: true, + }); + if (!force) { + const unchangedInput = await gateUnchangedStatusInput({ + userId, + cacheAction, + todayKey, + inputHash, + force, + }); + if (unchangedInput) { + return { + phase: "served", + result: { + hasProvider: true, + text: unchangedInput.text, + cached: true, + updatedAt: unchangedInput.updatedAt, + }, + }; + } + } + // v1.4.28 FB-D2 — cap the snapshot input. BP captures three types // per day so 1095 = 365 d × 3 channels. Order desc + reverse so we // keep the most recent year of rows. @@ -591,6 +627,9 @@ export async function prepareWeightStatusForUser( model: outcome.model, tokensUsed: outcome.tokensUsed, snapshotHash, + // v1.18.11 (P6) — persist the input fingerprint so tomorrow's input + // gate can skip the rebuild when nothing salient changed. + inputHash, }); return { hasProvider: true,