feat(insights): warm the per-metric assessment caches in the nightly pre-gen

The comprehensive generator evicts the seven `insights.<scope>-status`
caches as part of its write so a stale comprehensive insight can never
leave a stale per-metric card behind — but it does not re-fill them.
Before this, the morning after the nightly pre-gen cron ran the
per-metric status caches were empty, so the first `/insights` mount
still ran one live status generation per card.

After a successful comprehensive generation the cron now forces the
seven `*StatusForUser` generators so their caches are warm, not empty,
by the user's first visit. The warm pass only runs on a `generated`
outcome (a no-provider `skipped` has nothing to warm; a `failed` run
means the chain is unhealthy this tick — re-running it seven more times
would only multiply the failure). Each generator resolves the provider
chain internally, so a provider-less account stays cheap. The per-user
budget bucket that already bounds the comprehensive pass also gates the
warm pass — both run behind the same once-per-20h gate.

`runInsightPregenerate` now reports `assessmentsWarmed` and emits an
`insights.pregenerate.run` wide-event with the full per-run tally so the
nightly dashboard can track how many assessments each tick wrote.

Also adds `invalidateStatusInsightsForTypes` + `PER_STATUS_SCOPES` to
the comprehensive generator: a type → scope map (WEIGHT → weight + bmi +
general, BP → blood-pressure + general, PULSE/resting-HR → pulse +
general, everything else → general) so the ingest path can drop only the
scopes a fresh reading dirties.
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 22:05:31 +02:00
1 parent 68a9f114a5
commit 177ce576d3
3 files changed
+316 -1

No files matched your search

@@ -157,6 +157,82 @@ export async function evictPerStatusInsightCache(
});
}
/**
* The seven per-metric assessment scopes. Each generator persists its
* cached text under `insights.<scope>-status.<locale>`; the warm-pass in
* the nightly cron re-fills these after `evictPerStatusInsightCache`
* clears them, and the targeted invalidator below drops only the scopes
* a fresh measurement of a given type actually dirties.
*/
export const PER_STATUS_SCOPES = [
"blood-pressure",
"pulse",
"weight",
"bmi",
"mood",
"medication-compliance",
"general",
] as const;
export type PerStatusScope = (typeof PER_STATUS_SCOPES)[number];
/**
* Map a measurement type to the assessment scopes a fresh reading of it
* dirties. `general` is the catch-all overview, so every measurement
* type touches it. BMI rides on WEIGHT (it is weight ÷ height²), so a
* new weight reading invalidates both the weight and the BMI card.
*/
function statusScopesForMeasurementType(type: MeasurementType): PerStatusScope[] {
switch (type) {
case "WEIGHT":
return ["weight", "bmi", "general"];
case "BLOOD_PRESSURE_SYS":
case "BLOOD_PRESSURE_DIA":
return ["blood-pressure", "general"];
case "PULSE":
case "RESTING_HEART_RATE":
return ["pulse", "general"];
default:
// Every other tracked metric (body composition, sleep, steps,
// glucose, …) still feeds the general overview assessment.
return ["general"];
}
}
/**
* Drop the cached per-metric assessment rows that a batch of fresh
* measurements dirties, so the next mount (or the next nightly warm
* pass) regenerates them against the new data instead of serving the
* pre-measurement text for the rest of the day.
*
* Fire-and-forget from the measurement ingest path — idempotent (a
* redundant delete costs nothing) and never a blocker on the user's
* write. Deletes every locale variant of each affected scope in one
* sweep; the `-status.` substring guard keeps it from touching the
* comprehensive cache or any unrelated `insights.*` audit row.
*/
export async function invalidateStatusInsightsForTypes(
userId: string,
types: Iterable<MeasurementType>,
): Promise<void> {
const scopes = new Set<PerStatusScope>();
for (const type of types) {
for (const scope of statusScopesForMeasurementType(type)) {
scopes.add(scope);
}
}
if (scopes.size === 0) return;
await prisma.auditLog.deleteMany({
where: {
userId,
OR: Array.from(scopes, (scope) => ({
action: { startsWith: `insights.${scope}-status.` },
})),
},
});
}
interface GenerateOptions {
/** Resolved UI locale for the prompt + cache row. */
locale: "de" | "en";
@@ -27,6 +27,30 @@ vi.mock("@/lib/feature-flags", () => ({
vi.mock("@/lib/insights/comprehensive-generate", () => ({
generateComprehensiveInsight: vi.fn(),
}));
// The seven status generators import the provider chain transitively;
// stub the modules so the warm-pass never touches a live provider when
// the test does not inject its own generators.
vi.mock("@/lib/insights/blood-pressure-status", () => ({
generateBloodPressureStatusForUser: vi.fn(),
}));
vi.mock("@/lib/insights/pulse-status", () => ({
generatePulseStatusForUser: vi.fn(),
}));
vi.mock("@/lib/insights/weight-status", () => ({
generateWeightStatusForUser: vi.fn(),
}));
vi.mock("@/lib/insights/bmi-status", () => ({
generateBmiStatusForUser: vi.fn(),
}));
vi.mock("@/lib/insights/mood-status", () => ({
generateMoodStatusForUser: vi.fn(),
}));
vi.mock("@/lib/insights/medication-compliance-status", () => ({
generateMedicationComplianceStatusForUser: vi.fn(),
}));
vi.mock("@/lib/insights/general-status", () => ({
generateGeneralStatusForUser: vi.fn(),
}));
import {
runInsightPregenerate,
@@ -180,6 +204,100 @@ describe("runInsightPregenerate — outcome tally", () => {
});
});
describe("runInsightPregenerate — per-metric warm pass", () => {
function warmGen(result: { hasProvider: boolean; cached: boolean }) {
return vi.fn().mockResolvedValue(result);
}
it("forces all seven status generators after a successful comprehensive generation", async () => {
const { prisma } = makePrisma([{ id: "u1", locale: "de" }]);
const generate = vi
.fn()
.mockResolvedValue({ status: "generated", providerType: "x" });
const statusGenerators = Array.from({ length: 7 }, () =>
warmGen({ hasProvider: true, cached: false }),
);
const result = await runInsightPregenerate(prisma as never, {
generate,
statusGenerators,
});
// Every status generator forced with the resolved locale.
for (const g of statusGenerators) {
expect(g).toHaveBeenCalledTimes(1);
expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true });
}
// Seven fresh, provider-backed assessments warmed.
expect(result.assessmentsWarmed).toBe(7);
});
it("does NOT warm when the comprehensive pass skipped (no provider)", async () => {
const { prisma } = makePrisma([{ id: "u1", locale: "en" }]);
const generate = vi
.fn()
.mockResolvedValue({ status: "skipped", reason: "no-provider" });
const statusGenerators = Array.from({ length: 7 }, () =>
warmGen({ hasProvider: false, cached: true }),
);
const result = await runInsightPregenerate(prisma as never, {
generate,
statusGenerators,
});
for (const g of statusGenerators) {
expect(g).not.toHaveBeenCalled();
}
expect(result.assessmentsWarmed).toBe(0);
});
it("does NOT warm when the comprehensive pass failed", async () => {
const { prisma } = makePrisma([{ id: "u1", locale: "de" }]);
const generate = vi
.fn()
.mockResolvedValue({ status: "failed", reason: "provider-error" });
const statusGenerators = Array.from({ length: 7 }, () =>
warmGen({ hasProvider: true, cached: false }),
);
const result = await runInsightPregenerate(prisma as never, {
generate,
statusGenerators,
});
for (const g of statusGenerators) {
expect(g).not.toHaveBeenCalled();
}
expect(result.assessmentsWarmed).toBe(0);
});
it("counts only fresh provider-backed assessments and survives a thrown generator", async () => {
const { prisma } = makePrisma([{ id: "u1", locale: "de" }]);
const generate = vi
.fn()
.mockResolvedValue({ status: "generated", providerType: "x" });
const statusGenerators = [
warmGen({ hasProvider: true, cached: false }), // counts
warmGen({ hasProvider: false, cached: true }), // no provider — skip
warmGen({ hasProvider: true, cached: true }), // served cache — skip
vi.fn().mockRejectedValue(new Error("boom")), // throws — swallowed
warmGen({ hasProvider: true, cached: false }), // counts
];
const result = await runInsightPregenerate(prisma as never, {
generate,
statusGenerators,
});
// The throw must not abort the loop — every generator was attempted.
for (const g of statusGenerators) {
expect(g).toHaveBeenCalledTimes(1);
}
expect(result.assessmentsWarmed).toBe(2);
});
});
describe("queue registration", () => {
const workerSrc = fs.readFileSync(
path.resolve(__dirname, "../reminder-worker.ts"),
+122 -1
View File
@@ -10,6 +10,16 @@
* snapshot embeds a `briefingState: "ready"` block instead of
* `"preparing"`.
*
* v1.8.0 — warm the per-metric assessment caches too. The comprehensive
* generator evicts the seven `insights.<scope>-status.<locale>` caches
* as part of its write (so a stale comprehensive insight can never leave
* a stale per-metric card behind) but does NOT re-fill them. Before this,
* the morning after the cron ran the per-metric status caches were
* empty, so the first `/insights` mount still ran one live status
* generation per card. After a successful comprehensive generation this
* cron now forces the seven `*StatusForUser` generators so their caches
* are warm, not empty, by the user's first visit.
*
* Runs once nightly at 04:30 Europe/Berlin — inside the existing
* 03:xx–04:xx maintenance window, after the feedback aggregator (04:00)
* and the cumulative drain (03:30) so the late-night CPU profile stays
@@ -38,10 +48,18 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAssistantFlags } from "@/lib/feature-flags";
import { annotate } from "@/lib/logging/context";
import {
generateComprehensiveInsight,
type GenerateOutcome,
} from "@/lib/insights/comprehensive-generate";
import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status";
import { generatePulseStatusForUser } from "@/lib/insights/pulse-status";
import { generateWeightStatusForUser } from "@/lib/insights/weight-status";
import { generateBmiStatusForUser } from "@/lib/insights/bmi-status";
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status";
import { generateGeneralStatusForUser } from "@/lib/insights/general-status";
export const INSIGHT_PREGENERATE_QUEUE = "insight-pregenerate";
@@ -74,6 +92,71 @@ export interface PregenerateRunResult {
skipped: number;
failed: number;
budgetBlocked: number;
/**
* Count of per-metric assessment caches written warm across the whole
* run. The comprehensive generator evicts the seven `*-status`
* caches as part of its write (so a stale comprehensive insight can't
* leave a stale per-metric card), but it does not re-fill them — so
* without this warm pass the first morning visit after the cron runs
* one live status generation per card. This counts the warm writes.
*/
assessmentsWarmed: number;
}
/**
* Signature for one per-metric status generator. The seven
* `*StatusForUser` functions all share this shape: `(userId, { locale,
* force }) => { hasProvider, text, cached, updatedAt }`. We only need to
* know whether the call produced a fresh (non-cached, provider-backed)
* assessment, so the warm-pass narrows to those two fields.
*/
type StatusGenerator = (
userId: string,
options: { locale: "de" | "en"; force?: boolean },
) => Promise<{ hasProvider: boolean; cached: boolean }>;
/**
* The seven per-metric status generators the warm pass re-fills after
* the comprehensive generator evicted them. Each writes its own
* `insights.<scope>-status.<locale>` cache row on a fresh generation and
* resolves the user's provider chain internally, so a provider-less
* account costs one cheap chain-resolve and no LLM call (`hasProvider:
* false`).
*/
const DEFAULT_STATUS_GENERATORS: ReadonlyArray<StatusGenerator> = [
generateBloodPressureStatusForUser,
generatePulseStatusForUser,
generateWeightStatusForUser,
generateBmiStatusForUser,
generateMoodStatusForUser,
generateMedicationComplianceStatusForUser,
generateGeneralStatusForUser,
];
/**
* Re-fill the per-metric assessment caches for one user, forcing a fresh
* generation so the row the comprehensive generator just evicted is
* written warm. Returns the count of fresh (provider-backed,
* non-cached) assessments written. A generator that has no provider or
* fails serves its fallback without persisting it, so it does not count
* toward the warmed tally and never throws the batch loop off course.
*/
async function warmPerStatusCaches(
userId: string,
locale: "de" | "en",
generators: ReadonlyArray<StatusGenerator>,
): Promise<number> {
let warmed = 0;
for (const generate of generators) {
try {
const outcome = await generate(userId, { locale, force: true });
if (outcome.hasProvider && !outcome.cached) warmed++;
} catch {
// A single card's generation failing must not abort the rest of
// the warm pass for this user, nor the cron's user loop.
}
}
return warmed;
}
interface PregenerateCandidate {
@@ -130,11 +213,15 @@ export async function runInsightPregenerate(
userId: string,
opts: { locale: "de" | "en"; force?: boolean },
) => Promise<GenerateOutcome>;
/** Injected for the test — defaults to the seven real status generators. */
statusGenerators?: ReadonlyArray<StatusGenerator>;
} = {},
): Promise<PregenerateRunResult> {
const now = options.now ?? new Date();
const cap = options.cap ?? PREGENERATE_BATCH_CAP;
const generate = options.generate ?? generateComprehensiveInsight;
const statusGenerators =
options.statusGenerators ?? DEFAULT_STATUS_GENERATORS;
const result: PregenerateRunResult = {
total: 0,
@@ -143,6 +230,7 @@ export async function runInsightPregenerate(
skipped: 0,
failed: 0,
budgetBlocked: 0,
assessmentsWarmed: 0,
};
// Master kill-switch — when the operator disabled the briefing
@@ -174,8 +262,9 @@ export async function runInsightPregenerate(
// pre-generation — defeating the "warm the cache before the user's
// morning visit" intent for the common case. The per-user budget
// bucket (above) is what bounds cost, not the TTL re-check.
const locale = normalizeLocale(candidate.locale);
const outcome = await generate(candidate.id, {
locale: normalizeLocale(candidate.locale),
locale,
force: true,
});
switch (outcome.status) {
@@ -192,7 +281,39 @@ export async function runInsightPregenerate(
result.failed++;
break;
}
// Warm the per-metric assessment caches the comprehensive generator
// evicts (`evictPerStatusInsightCache`) but does not re-fill. Only
// do this when the comprehensive pass actually produced an insight:
// a `skipped` (no-provider) user has nothing to warm, and a `failed`
// pass means the provider chain is unhealthy this run — re-running
// it seven more times would only multiply the failure. A `cached`
// outcome can't happen here (we always force), but if a future
// change reintroduces it the per-metric caches were not evicted, so
// there is nothing to warm.
if (outcome.status === "generated") {
result.assessmentsWarmed += await warmPerStatusCaches(
candidate.id,
locale,
statusGenerators,
);
}
}
// Wide-event tally so the nightly dashboard can track how many
// comprehensive insights and per-metric assessments each tick wrote.
annotate({
action: { name: "insights.pregenerate.run" },
meta: {
total: result.total,
generated: result.generated,
cached: result.cached,
skipped: result.skipped,
failed: result.failed,
budget_blocked: result.budgetBlocked,
assessments_warmed: result.assessmentsWarmed,
},
});
return result;
}