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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 21:43:58 +01:00
1 parent 9227844f42
commit 20fc03bd61
2 files changed
+157 -10

No files matched your search

@@ -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<typeof import("@/lib/insights/derived/baseline")>()),
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();
});
});
+40 -10
View File
@@ -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<CoachCorrelationDriver[] | null> {
const modules = await resolveModuleMap(userId);
const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false);
return cachedSwr(
caches.analytics as ServerCache<CoachCorrelationDriver[] | null>,
`${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,