diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index 1170e5cd5..c108d0f84 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { buildCoachSnapshot } from "../snapshot"; +import { + __resetCoachSnapshotCacheForTests, + buildCoachSnapshot, +} from "../snapshot"; vi.mock("@/lib/db", () => ({ prisma: { @@ -50,6 +53,9 @@ function daysAgo( describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => { beforeEach(() => { vi.clearAllMocks(); + // v1.4.33 — `buildCoachSnapshot` now memoises results in-process + // for 60 s; reset between tests so each fixture is read fresh. + __resetCoachSnapshotCacheForTests(); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); // v1.4.23 H4 — snapshot now reads `User.coachPrefsJson`. Default diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index df931eee8..fcc65f630 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { buildCoachSnapshot } from "../snapshot"; +import { + __resetCoachSnapshotCacheForTests, + buildCoachSnapshot, +} from "../snapshot"; vi.mock("@/lib/db", () => ({ prisma: { @@ -47,6 +50,10 @@ function daysAgo( describe("buildCoachSnapshot", () => { beforeEach(() => { vi.clearAllMocks(); + // v1.4.33 — `buildCoachSnapshot` now memoises results in-process + // for 60 s. Reset the cache between tests so each test sees its + // own freshly-mocked Prisma fixture. + __resetCoachSnapshotCacheForTests(); prismaMock.measurement.findMany.mockResolvedValue([]); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); @@ -182,4 +189,50 @@ describe("buildCoachSnapshot", () => { const parsed = JSON.parse(out.snapshotJson); expect(parsed.mood.timeline.recent.length).toBe(1); }); + + // v1.4.33 — 60 s in-process snapshot cache. A chat conversation + // sends 2-4 turns within a minute and rebuilding the snapshot from + // ~10 measurement reads on every turn is wasteful. Cache hits skip + // every persistent read; cache misses on a different scope (window + // or sources) compute fresh. + it("memoises the result for repeated (userId, scope) within the 60 s window", async () => { + featuresMock.mockResolvedValue({ + weight: { avg30: 82.1, coverage: { count: 5 } }, + }); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(2, 82.0, "WEIGHT"), + daysAgo(5, 82.4, "WEIGHT"), + ]); + + const first = await buildCoachSnapshot("user-1", { sources: ["weight"] }); + const second = await buildCoachSnapshot("user-1", { sources: ["weight"] }); + + // Same JSON shape on both calls. + expect(second.snapshotJson).toBe(first.snapshotJson); + // Prisma reads ran once for the first call; the second call short- + // circuits on the cache so the count is still 1. + expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1); + }); + + it("recomputes when the scope window or sources change", async () => { + featuresMock.mockResolvedValue({ + weight: { avg30: 82.1, coverage: { count: 5 } }, + }); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(3, 82.0, "WEIGHT"), + ]); + + await buildCoachSnapshot("user-1", { + window: "last7days", + sources: ["weight"], + }); + await buildCoachSnapshot("user-1", { + window: "last30days", + sources: ["weight"], + }); + + // Two distinct window keys → two cache slots → two Prisma reads. + expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 6c500670f..f8d694b62 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -274,6 +274,67 @@ function resolveScope(scope?: CoachScope): { }; } +/** + * v1.4.33 — 60-second in-memory cache for `buildCoachSnapshot()`. The + * snapshot reads only persisted data; a single chat conversation sends + * 2-4 turns within a minute and the snapshot would otherwise rebuild + * from the same rows each turn. Caching the result for 60s shaves the + * ~10 measurement reads + the GLP-1 / mood / intake side-fetches off + * every turn after the first, which `.planning/round-v1433-audit-perf.md` + * §3.3 estimates at 200-800 ms of server-side tail. + * + * Scope is part of the cache key so a switch from `last30days` to + * `last7days` (or a different `sources` set) computes fresh. The map + * is bounded at 64 entries — a multi-tenant deployment with a few + * active power users sits well inside that ceiling even if each cycles + * through several scopes per minute. + */ +const SNAPSHOT_TTL_MS = 60_000; +const SNAPSHOT_LRU_MAX = 64; +const snapshotCache = new Map< + string, + { expiresAt: number; result: CoachSnapshotResult } +>(); + +function snapshotCacheKey(userId: string, scope: CoachScope | undefined): string { + const { sources, window } = resolveScope(scope); + const sourceList = Array.from(sources).sort().join(","); + return `${userId}|${window}|${sourceList}`; +} + +function readSnapshotCache(key: string): CoachSnapshotResult | null { + const entry = snapshotCache.get(key); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + snapshotCache.delete(key); + return null; + } + // Touch for LRU — re-insert moves to the end of the Map's iteration order. + snapshotCache.delete(key); + snapshotCache.set(key, entry); + return entry.result; +} + +function writeSnapshotCache(key: string, result: CoachSnapshotResult): void { + if (snapshotCache.size >= SNAPSHOT_LRU_MAX) { + // Evict the oldest entry — JS Map iteration order is insertion order, + // so the first key is the least-recently inserted/touched. + const oldest = snapshotCache.keys().next().value; + if (oldest !== undefined) { + snapshotCache.delete(oldest); + } + } + snapshotCache.set(key, { + expiresAt: Date.now() + SNAPSHOT_TTL_MS, + result, + }); +} + +/** Clear the snapshot cache. Test-only escape hatch. */ +export function __resetCoachSnapshotCacheForTests(): void { + snapshotCache.clear(); +} + /** * Build the Coach prompt snapshot for `userId`. Always uses * `includeRaw=false` because the Coach replies are conversational and @@ -293,10 +354,27 @@ function resolveScope(scope?: CoachScope): { * user's display timezone (read from `User.timezone`). Falls back to * Europe/Berlin when the column is missing so the legacy snapshot * stays byte-identical for the only path the v1.4.24 suite tested. + * + * v1.4.33 — wraps the previous `buildCoachSnapshotImpl` with a 60s + * in-memory LRU keyed on `(userId, window, sources)`. The Coach's + * chat handler calls this once per turn; within the same conversation + * the second+ turn lands a cache hit and skips the row-level reads. */ export async function buildCoachSnapshot( userId: string, scope?: CoachScope, +): Promise { + const key = snapshotCacheKey(userId, scope); + const cached = readSnapshotCache(key); + if (cached) return cached; + const result = await buildCoachSnapshotImpl(userId, scope); + writeSnapshotCache(key, result); + return result; +} + +async function buildCoachSnapshotImpl( + userId: string, + scope?: CoachScope, ): Promise { const { sources: scopedSources, window } = resolveScope(scope); diff --git a/src/lib/feature-flags/__tests__/index.test.ts b/src/lib/feature-flags/__tests__/index.test.ts index b67fb446e..a15c1c041 100644 --- a/src/lib/feature-flags/__tests__/index.test.ts +++ b/src/lib/feature-flags/__tests__/index.test.ts @@ -8,8 +8,15 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.4.33 — `mockEvent` lets the dedicated memoisation describe-block +// below pin a stable WideEventBuilder stub so the per-request cache +// keys on the same reference across multiple `getAssistantFlags()` +// calls. The legacy describe-blocks leave `mockEvent` null so every +// call gets a fresh `{}` and the memo never collapses — preserves the +// pre-v1.4.33 assertion semantics. +let mockEvent: object | null = null; vi.mock("@/lib/logging/context", () => ({ - getEvent: () => ({ addWarning: vi.fn() }), + getEvent: () => mockEvent ?? { addWarning: vi.fn() }, })); import { prisma } from "@/lib/db"; @@ -25,6 +32,7 @@ const FIND = prisma.appSettings.findUnique as ReturnType; beforeEach(() => { FIND.mockReset(); + mockEvent = null; }); describe("resolveAssistantFlags", () => { @@ -162,3 +170,51 @@ describe("requireAssistantSurface", () => { expect(err.errorCode).toBe("assistant.disabled.correlations"); }); }); + +describe("per-request memoisation", () => { + // v1.4.33 — When the same request opens the Coach drawer, five gated + // routes (`/api/insights/chat`, the rail list, each `-status` + // call, etc.) all funnel through `getAssistantFlags()` → `AppSettings` + // read. The memo on the active WideEventBuilder collapses those to + // one DB hit. + it("collapses repeated reads inside the same request to one DB call", async () => { + FIND.mockResolvedValue({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + }); + // Pin a stable stub object so every `getEvent()` inside this test + // returns the same reference — the memo keys on identity, so a + // shared reference == shared cache. + mockEvent = { addWarning: vi.fn() }; + + const a = await getAssistantFlags(); + const b = await getAssistantFlags(); + const c = await getAssistantFlags(); + + expect(a).toBe(b); + expect(b).toBe(c); + expect(FIND).toHaveBeenCalledTimes(1); + }); + + it("issues independent reads when the request context changes", async () => { + FIND.mockResolvedValue({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + }); + + mockEvent = { addWarning: vi.fn(), id: "req-1" }; + await getAssistantFlags(); + mockEvent = { addWarning: vi.fn(), id: "req-2" }; + await getAssistantFlags(); + + expect(FIND).toHaveBeenCalledTimes(2); + }); +});