From 20fc03bd613e8d2e1df667f8245bbe1ac8d33684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:43:58 +0100 Subject: [PATCH] perf(insights): read the coach strip's pattern scan once per record Every metric page asked `/api/insights/coach-read`, and every call ran the full 180-day discovery scan over every channel, plus the emerging, lab and coincident passes, to pick the one driver whose outcome is the page's metric. The scan does not depend on the metric, so each page in a row paid over a second for the same answer and the strip stayed a skeleton long after the chart had drawn. The strip now reads the drivers through an analytics cell keyed on the record, the language and the switched-off modules, the same contract `/api/insights/correlations` uses: every measurement, mood, medication, illness, custom-metric and pattern write sweeps the record's cells. A failed scan is not kept. The band and the latest reading stay per call. --- .../coach-read-drivers-cache.test.ts | 117 ++++++++++++++++++ src/lib/insights/derived/coach-read.ts | 50 ++++++-- 2 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 src/lib/insights/derived/__tests__/coach-read-drivers-cache.test.ts 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,