perf(insights-generate): consume mood-rollup tier via features extractor

Audit Critical Finding #2 (round-v1439-arch-qa-infra-db). The mood
block inside `extractFeatures` ran an unbounded
`prisma.moodEntry.findMany({ where: { userId } })` and folded the
entire history into the AI feature payload for /api/insights/generate.
On power-user accounts the walk dominated the cold-mount budget.

Swap to `readMoodDayRollups(userId, fiveYearsAgo)` so the feature
payload reads one row per calendar day. The downstream consumers
(`avg7` / `avg30` / `trend30` / `coverage` / cross-metric
correlations) all operate at per-day resolution, matching the rollup
shape. `latest` score reads a single `findFirst` (bounded). Coverage-
fallback runs a bounded one-year live walk + fires
`ensureUserMoodRollupsFresh` so the next request lands on the rollup
tier (same posture as /api/mood/analytics post-v1.4.39).

Existing test fixture updated to mock `moodEntryRollup` + the
warm-up; six features tests stay green.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 11:52:32 +02:00
1 parent 594cd22ff5
commit ca7d00ffbf
2 files changed
+136 -26

No files matched your search

+28 -2
View File
@@ -5,12 +5,28 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
$queryRawUnsafe: vi.fn(),
},
}));
// Stub the mood-rollup warm-up so test runs don't fire the real
// recompute aggregate. The warm-up is fire-and-forget on the route.
vi.mock("@/lib/mood/rollups", async () => {
const actual = await vi.importActual<typeof import("@/lib/mood/rollups")>(
"@/lib/mood/rollups",
);
return {
...actual,
ensureUserMoodRollupsFresh: vi
.fn()
.mockResolvedValue({ recomputed: false }),
};
});
vi.mock("@/lib/medication-category", () => ({
getMedicationCategories: vi.fn(async () => ({})),
}));
@@ -27,7 +43,14 @@ const prismaMock = prisma as unknown as {
user: { findUnique: ReturnType<typeof vi.fn> };
measurement: { findMany: ReturnType<typeof vi.fn> };
measurementRollup: { findMany: ReturnType<typeof vi.fn> };
moodEntry: { findMany: ReturnType<typeof vi.fn> };
moodEntry: {
findMany: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
moodEntryRollup: {
findMany: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
medication: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
};
@@ -58,6 +81,9 @@ beforeEach(() => {
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.measurementRollup.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findFirst.mockResolvedValue(null);
prismaMock.moodEntryRollup.findMany.mockResolvedValue([]);
prismaMock.moodEntryRollup.findFirst.mockResolvedValue(null);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
});
+108 -24
View File
@@ -16,6 +16,10 @@ import {
} from "@/lib/analytics/correlations";
import { getMedicationCategories } from "@/lib/medication-category";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/mood/rollups";
import type {
MeasurementType,
RollupGranularity,
@@ -583,24 +587,104 @@ export async function extractFeatures(
}
// Mood
const moodEntries = await prisma.moodEntry.findMany({
where: { userId },
orderBy: { moodLoggedAt: "asc" },
});
//
// v1.4.40 — swap the unbounded `prisma.moodEntry.findMany` for the
// persistent mood-rollup DAY tier (audit Critical Finding #2). The
// feature block downstream consumes:
// - `avg7` / `avg30` over recent rollup means
// - `trend30` from first-half vs second-half of last 30 days
// - `latest` = newest individual entry score (one bounded row)
// - `totalEntries` = sum of rollup `count` across all DAY rows
// - `oldest` / `newest` = first and last rollup `bucketStart`
// - `moodPoints` for cross-metric correlations (one per-day mean
// DataPoint — same resolution as the legacy raw-score series for
// single-entry-per-day power users, slightly different on
// multi-entry days, which is the rollup-tier semantic the
// v1.4.39 `/api/mood/analytics` already shipped)
//
// Coverage-fallback: when raw entries exist but the rollup tier is
// still empty (legacy account before boot-time backfill caught up),
// we fall back to a bounded 1-year raw walk and fire the warm-up so
// the next request lands on the rollup tier. Same posture as
// `/api/mood/analytics`.
void ensureUserMoodRollupsFresh(userId);
// Five-year window mirrors the mood-rollup writer default; covers
// every realistic user history span without an unbounded scan.
const moodSince = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000);
const moodRollupDayRows = await readMoodDayRollups(userId, moodSince);
if (moodEntries.length > 0) {
type MoodDayPoint = {
measuredAt: Date;
value: number;
count: number;
};
let moodDailyPoints: MoodDayPoint[] = [];
let moodLatestScore: number | null = null;
let moodTotalEntries = 0;
if (moodRollupDayRows.length > 0) {
moodDailyPoints = moodRollupDayRows.map((r) => ({
measuredAt: r.bucketStart,
value: r.mean,
count: r.count,
}));
moodTotalEntries = moodRollupDayRows.reduce((s, r) => s + r.count, 0);
// Latest score = newest individual entry (one bounded row).
const latestEntry = await prisma.moodEntry.findFirst({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
select: { score: true },
});
moodLatestScore = latestEntry?.score ?? null;
} else {
// Coverage-fallback. Bounded 1-year walk (instead of the legacy
// unbounded `findMany`) so even a cache miss is capped.
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const moodEntriesRaw = await prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: oneYearAgo } },
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true, date: true },
});
if (moodEntriesRaw.length > 0) {
// Bucket per `date` (TZ-anchored YYYY-MM-DD label, same key the
// rollup tier would emit for Berlin-anchored single-entry days)
// so the fallback shape matches the rollup-tier shape.
const byDay = new Map<string, { sum: number; count: number; ts: Date }>();
for (const e of moodEntriesRaw) {
const k = e.date;
const cur = byDay.get(k) ?? { sum: 0, count: 0, ts: e.moodLoggedAt };
cur.sum += e.score;
cur.count += 1;
// Pin the bucket's measuredAt to the latest entry's timestamp
// — close enough for the trailing-window filters below.
if (e.moodLoggedAt > cur.ts) cur.ts = e.moodLoggedAt;
byDay.set(k, cur);
}
moodDailyPoints = Array.from(byDay.values())
.map((b) => ({
measuredAt: b.ts,
value: b.sum / b.count,
count: b.count,
}))
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
moodTotalEntries = moodEntriesRaw.length;
moodLatestScore = moodEntriesRaw[moodEntriesRaw.length - 1].score;
}
}
if (moodDailyPoints.length > 0) {
const moodNow = Date.now();
const last7 = moodEntries.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 7 * 24 * 60 * 60 * 1000,
const last7 = moodDailyPoints.filter(
(e) => moodNow - e.measuredAt.getTime() < 7 * 24 * 60 * 60 * 1000,
);
const last30 = moodEntries.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 30 * 24 * 60 * 60 * 1000,
const last30 = moodDailyPoints.filter(
(e) => moodNow - e.measuredAt.getTime() < 30 * 24 * 60 * 60 * 1000,
);
const avg = (entries: typeof moodEntries) =>
entries.length > 0
const avg = (points: MoodDayPoint[]) =>
points.length > 0
? Math.round(
(entries.reduce((s, e) => s + e.score, 0) / entries.length) * 100,
(points.reduce((s, e) => s + e.value, 0) / points.length) * 100,
) / 100
: null;
@@ -608,10 +692,10 @@ export async function extractFeatures(
let trend30: "improving" | "declining" | "stable" | null = null;
if (last30.length >= 4) {
const firstHalf = last30.filter(
(e) => moodNow - e.moodLoggedAt.getTime() >= 15 * 24 * 60 * 60 * 1000,
(e) => moodNow - e.measuredAt.getTime() >= 15 * 24 * 60 * 60 * 1000,
);
const secondHalf = last30.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 15 * 24 * 60 * 60 * 1000,
(e) => moodNow - e.measuredAt.getTime() < 15 * 24 * 60 * 60 * 1000,
);
if (firstHalf.length >= 2 && secondHalf.length >= 2) {
const avgFirst = avg(firstHalf)!;
@@ -623,25 +707,25 @@ export async function extractFeatures(
}
}
const oldest = moodEntries[0].moodLoggedAt;
const newest = moodEntries[moodEntries.length - 1].moodLoggedAt;
const oldest = moodDailyPoints[0].measuredAt;
const newest = moodDailyPoints[moodDailyPoints.length - 1].measuredAt;
const spanDays = Math.round(
(newest.getTime() - oldest.getTime()) / (24 * 60 * 60 * 1000),
);
const avgDaysBetween =
moodEntries.length > 1
? Math.round((spanDays / (moodEntries.length - 1)) * 10) / 10
moodTotalEntries > 1
? Math.round((spanDays / (moodTotalEntries - 1)) * 10) / 10
: null;
features.mood = {
scale: "1=LAUSIG, 2=SCHLECHT, 3=OKAY, 4=GUT, 5=SUPER_GUT",
avg7: avg(last7),
avg30: avg(last30),
latest: moodEntries[moodEntries.length - 1].score,
latest: moodLatestScore,
trend30,
totalEntries: moodEntries.length,
totalEntries: moodTotalEntries,
coverage: {
count: moodEntries.length,
count: moodTotalEntries,
spanDays,
avgDaysBetween,
oldestDaysAgo: Math.round(
@@ -659,9 +743,9 @@ export async function extractFeatures(
const sysPoints = toDataPoints(sysData);
const diaPoints = toDataPoints(diaData);
const pulsePoints = toDataPoints(pulseData);
const moodPoints: DataPoint[] = moodEntries.map((e) => ({
date: e.moodLoggedAt,
value: e.score,
const moodPoints: DataPoint[] = moodDailyPoints.map((e) => ({
date: e.measuredAt,
value: e.value,
}));
const computeCorr = (a: DataPoint[], b: DataPoint[]) =>