diff --git a/src/lib/insights/derived/__tests__/coach-read-drivers-cache.test.ts b/src/lib/insights/derived/__tests__/coach-read-drivers-cache.test.ts new file mode 100644 index 000000000..b4c2a1ed4 --- /dev/null +++ b/src/lib/insights/derived/__tests__/coach-read-drivers-cache.test.ts @@ -0,0 +1,117 @@ +/** + * The "Coach read" strip reads its pattern line from one cached scan per + * record and language, not one scan per metric page: the scan covers every + * channel and does not depend on the metric. A failed scan is not cached, a + * write that sweeps the record's analytics cells forces a new one, and a + * switched-off module reads under its own cell. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const readCoachCorrelations = vi.fn(); +const resolveModuleMap = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: async () => [], + findFirst: async () => null, + }, + }, +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: (userId: string) => resolveModuleMap(userId), +})); +vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => ({ + ...(await importOriginal()), + loadBaselineProfile: async () => ({ ageYears: 40, sex: "MALE" }), + // The band is not what this file is about: no band, the strip says + // "still learning" and only the pattern line varies. + computeVitalsBaseline: async () => ({ status: "insufficient" }), +})); +vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ + readCoachCorrelations: (userId: string, locale: string) => + readCoachCorrelations(userId, locale), +})); + +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { invalidateUserMood } from "@/lib/cache/invalidate"; +import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; + +const SLEEP_DRIVER = { + behaviour: "symptom severity", + outcome: "sleep duration", + direction: "lower" as const, + lagDays: 1, + n: 40, + r: -0.6, + note: "A pattern to watch, not a cause.", +}; +const PULSE_DRIVER = { ...SLEEP_DRIVER, outcome: "pulse" }; + +const strip = (type: "SLEEP_DURATION" | "PULSE", locale: "en" | "de" = "en") => + buildCoachReadStrip("u1", type, locale, { tz: "UTC" }); + +beforeEach(() => { + __resetAllCachesForTests(); + readCoachCorrelations.mockReset().mockResolvedValue({ + present: true, + drivers: [SLEEP_DRIVER, PULSE_DRIVER], + }); + resolveModuleMap.mockReset().mockResolvedValue({}); +}); + +describe("coach read — the pattern scan behind the strip", () => { + it("scans once for two metric pages and picks each page's own driver", async () => { + const sleep = await strip("SLEEP_DURATION"); + const pulse = await strip("PULSE"); + + expect(readCoachCorrelations).toHaveBeenCalledTimes(1); + expect(sleep.driver?.outcome).toBe("sleep duration"); + expect(pulse.driver?.outcome).toBe("pulse"); + }); + + it("scans again per language, since the note is a finished sentence", async () => { + await strip("SLEEP_DURATION", "en"); + await strip("SLEEP_DURATION", "de"); + + expect(readCoachCorrelations).toHaveBeenCalledTimes(2); + expect(readCoachCorrelations).toHaveBeenLastCalledWith("u1", "de"); + }); + + it("does not keep a failed scan", async () => { + readCoachCorrelations.mockResolvedValueOnce({ + present: false, + reason: "retrieval_failed", + }); + + const failed = await strip("SLEEP_DURATION"); + const retried = await strip("SLEEP_DURATION"); + + expect(failed.driver).toBeNull(); + expect(retried.driver?.outcome).toBe("sleep duration"); + expect(readCoachCorrelations).toHaveBeenCalledTimes(2); + }); + + it("scans again after a write sweeps the record's analytics cells", async () => { + await strip("SLEEP_DURATION"); + invalidateUserMood("u1"); + await strip("SLEEP_DURATION"); + + expect(readCoachCorrelations).toHaveBeenCalledTimes(2); + }); + + it("reads a switched-off module under its own cell", async () => { + await strip("SLEEP_DURATION"); + resolveModuleMap.mockResolvedValue({ insights: false }); + readCoachCorrelations.mockResolvedValue({ + present: false, + reason: "module_disabled", + }); + + const off = await strip("SLEEP_DURATION"); + + expect(readCoachCorrelations).toHaveBeenCalledTimes(2); + expect(off.driver).toBeNull(); + }); +}); diff --git a/src/lib/insights/derived/coach-read.ts b/src/lib/insights/derived/coach-read.ts index 55850a81c..0b152516f 100644 --- a/src/lib/insights/derived/coach-read.ts +++ b/src/lib/insights/derived/coach-read.ts @@ -25,8 +25,12 @@ * component and the unit test can share them without pulling Prisma. */ import type { MeasurementType } from "@/generated/prisma/client"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { prisma } from "@/lib/db"; import type { Locale } from "@/lib/i18n/config"; +import { annotate } from "@/lib/logging/context"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { MODULE_KEYS } from "@/lib/modules/registry"; import { loadBaselineProfile, computeVitalsBaseline, @@ -42,7 +46,10 @@ import { isFromToday, } from "@/lib/insights/measurement-freshness"; import { userDayKey } from "@/lib/tz/format"; -import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; +import { + readCoachCorrelations, + type CoachCorrelationDriver, +} from "@/lib/ai/coach/tools/correlations-read"; import { humaniseType, placeAgainstBand, @@ -88,6 +95,34 @@ function latestDay( }; } +/** + * The discovered drivers, through the analytics cache. The scan covers every + * channel over 180 days and does not depend on the metric, so without the cell + * each metric page paid the whole scan again for the one line it picks. Same + * cell contract as `/api/insights/correlations`: measurement, mood, medication, + * illness, custom-metric and pattern writes sweep the `${userId}|` prefix, and + * the switched-off modules are part of the key. A failed read is not cached. + */ +async function readDrivers( + userId: string, + locale: Locale, +): Promise { + const modules = await resolveModuleMap(userId); + const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false); + return cachedSwr( + caches.analytics as ServerCache, + `${userId}|coach-read-drivers|${locale}|off:${modulesOff.join(",")}`, + async () => { + const result = await readCoachCorrelations(userId, locale); + if (result.reason === "retrieval_failed") { + throw new Error("coach read correlations failed"); + } + return result.drivers ?? null; + }, + annotate, + ); +} + /** Trailing window the band engine reads; the day-mean placement matches it. */ const BAND_WINDOW_DAYS = 30; @@ -165,13 +200,11 @@ export async function buildCoachReadStrip( const now = opts.now ?? new Date(); const profile = await loadBaselineProfile(prisma, userId); - const [baselineDerived, latest, correlations] = await Promise.all([ + const [baselineDerived, latest, drivers] = await Promise.all([ computeVitalsBaseline(userId, profile, { type, now }), readLatestValue(userId, type), // Line 2 is best-effort: a correlation failure must never sink the band. - readCoachCorrelations(userId, locale).catch( - () => ({ present: false }) as const, - ), + readDrivers(userId, locale).catch(() => null), ]); let baseline: CoachReadBaseline | null = null; @@ -207,11 +240,8 @@ export async function buildCoachReadStrip( } const driver = ((): CoachReadDriver | null => { - if (!("drivers" in correlations) || !correlations.drivers) return null; - const picked = pickDriverForMetric( - correlations.drivers, - humaniseType(type), - ); + if (!drivers) return null; + const picked = pickDriverForMetric(drivers, humaniseType(type)); if (!picked) return null; return { note: picked.note,