mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user