mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
perf(insights): regenerate texts only when the underlying data changed
A sha256 fingerprint of the compacted feature snapshot is stored beside every generated text; nightly, forced and ingest-triggered regeneration all skip the model call when the fingerprint matches and refresh the timestamp instead. Warms cover only the user's resolved locale and the blanket per-status eviction after a comprehensive generation is gone — a lazily-read second locale regenerates on its own miss path. The ingest debounce widens from thirty minutes to six hours and no longer deletes cache rows, the visit-triggered warm re-checks freshness at job start, backs off for an hour after a failure and is capped at two forced runs per user per day, and its dedup window now outlives the client poll horizon. An active day drops from hundreds of model calls to a handful tied to actual data change.
This commit is contained in:
1 parent
9fa4962314
commit
23f7c74c04
24 files changed
+1596
-411
No files matched your search
@@ -342,32 +342,39 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.16 A7.2: every fresh comprehensive insight evicts the per-
|
||||
// scope status cache so the dashboard and the insights-page status
|
||||
// cards never disagree. Without this, force-regeneration repaints
|
||||
// `/api/insights/generate` while `/api/insights/<scope>-status` keeps
|
||||
// returning yesterday's text until midnight Berlin time.
|
||||
describe("POST /api/insights/generate — per-status cache eviction (A7.2)", () => {
|
||||
it("deletes per-status audit-log cache rows after a successful generation", async () => {
|
||||
// v1.16.8 — a fresh comprehensive insight no longer nukes the per-scope
|
||||
// status cache (the old A7.2 sweep). The cards track their own data via
|
||||
// the ingest invalidator + per-card content-hash gates; the route's job
|
||||
// is to store the snapshot fingerprint so the off-request regeneration
|
||||
// paths can detect "nothing changed" and skip the provider.
|
||||
describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
it("does NOT delete per-status audit-log cache rows after a successful generation", async () => {
|
||||
makeWorkingProvider();
|
||||
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.auditLog.deleteMany).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.auditLog.deleteMany).mock.calls[0][0];
|
||||
expect(args).toMatchObject({
|
||||
where: {
|
||||
userId: "u-1",
|
||||
action: { startsWith: "insights." },
|
||||
AND: [{ action: { contains: "-status." } }],
|
||||
},
|
||||
});
|
||||
expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT delete per-status cache when serving from the 24h DB cache", async () => {
|
||||
it("stores the snapshot fingerprint alongside the cached text", async () => {
|
||||
makeWorkingProvider();
|
||||
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.user.update).mock.calls[0][0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(args.data.insightsCachedText).toEqual(expect.any(String));
|
||||
// SHA-256 hex digest of the compacted feature snapshot.
|
||||
expect(args.data.insightsSnapshotHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("does NOT touch the cache when serving from the 24h DB cache", async () => {
|
||||
// Cached path: route returns early without touching the LLM or the
|
||||
// cache-eviction helper.
|
||||
// cache write.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: new Date(),
|
||||
@@ -379,6 +386,7 @@ describe("POST /api/insights/generate — per-status cache eviction (A7.2)", ()
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { cached: boolean } };
|
||||
expect(body.data.cached).toBe(true);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
|
||||
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -91,32 +92,6 @@ export function resolveInsightsRateLimit(): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-status insight cache rows live in `audit_logs` keyed by
|
||||
* `action = "insights.{scope}-status.{locale}"` (see
|
||||
* `src/lib/insights/memory.ts` for the canonical scope list and
|
||||
* `src/lib/insights/{general,blood-pressure,weight,pulse,bmi,
|
||||
* mood,medication-compliance}-status.ts` for the writers). Each scope
|
||||
* has its own per-day eviction, so before v1.4.16 a force-regeneration
|
||||
* of the comprehensive insight would leave yesterday's per-status
|
||||
* cards visible on the insights page until the next calendar day
|
||||
* flipped. Drop them here so the next status fetch has to call the
|
||||
* LLM again with the same fresh feature set the comprehensive blob
|
||||
* just used.
|
||||
*/
|
||||
async function evictPerStatusInsightCache(userId: string): Promise<void> {
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
action: { startsWith: "insights." },
|
||||
// Keep the `insights.generate` row this request just wrote and
|
||||
// the `insights.settings.*` rows — only the per-status cache
|
||||
// entries (`insights.<scope>-status.<locale>`) carry stale text.
|
||||
AND: [{ action: { contains: "-status." } }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B8 — fetch the user's persisted comparison toggle and
|
||||
* build a `ComparisonSnapshot` for the prompt builder. Returns null
|
||||
@@ -739,14 +714,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
data: {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(insights),
|
||||
// v1.16.8 — store the snapshot fingerprint so the nightly / forced
|
||||
// regeneration paths can detect "nothing changed" and skip the
|
||||
// provider call. The user-initiated POST itself stays un-gated: an
|
||||
// explicit regenerate request is honoured even on unchanged data
|
||||
// (it is already bounded by the hourly rate limit above).
|
||||
insightsSnapshotHash: hashInsightSnapshot(compactFeatures),
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.16 A7: a fresh comprehensive insight always supersedes the
|
||||
// per-status cache. Otherwise the dashboard re-paints itself with the
|
||||
// newly generated comprehensive while the insights-page status cards
|
||||
// still show yesterday's text until midnight Berlin time.
|
||||
await evictPerStatusInsightCache(userId);
|
||||
// v1.16.8 — no blanket per-status eviction here any more. The cards
|
||||
// track their own data through the ingest invalidator and their own
|
||||
// content-hash gates; nuking ~45 cache rows per manual regenerate was
|
||||
// the source of the post-regenerate card-regeneration storm.
|
||||
|
||||
// v1.7.0 W6 — the dashboard snapshot embeds the pre-generated daily
|
||||
// briefing read-only; drop it so the next snapshot carries the fresh
|
||||
|
||||
Reference in new issue
Block a user