fix(insights,medications): close the staleness escape hatches and harden the review findings

Staleness can re-enter a generated text: measurement age is hashed as a
bucketed tier, so the fingerprint stays stable day to day but flips
exactly when a reading crosses the boundary the prompt caveat cares
about. The ingest debounce becomes a one-hour minimum gap with the
hash gate carrying the budget, so a notable morning reading is
narrated the same morning instead of after the next nightly run. A
manual regenerate now refills the status cards through the same
hash-gated pipeline — changed cards regenerate, unchanged cards
refresh free, no eviction touches the hash baselines. The user's
self-context joins the comprehensive fingerprint so a newly remembered
fact reaches the briefing, the sleep writer collapse covers every
caller that reads nights, the batched compliance build fans out
bounded instead of sequentially, and a failed compliance read renders
a quiet retry state in the exact skeleton footprint instead of
swallowing the error.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 22:49:42 +02:00
1 parent 3d95ace218
commit a2a995e429
25 files changed
+770 -97

No files matched your search

+9 -1
View File
@@ -570,7 +570,15 @@ async function buildDashboardSummary(
measuredAt: { gte: streakWindowStart },
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true, source: true },
// Writer-level collapse: two HealthKit apps behind one source
// (watch stages vs phone in-bed) must not blend into one night.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
}),
),
]);
@@ -94,6 +94,13 @@ vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({
enqueueForceWarm: vi.fn(async () => undefined),
}));
// The POST follows a successful generation with a hash-gated refill of the
// per-status / generic-metric cards. Mocked at the module boundary — the
// enqueue pipeline itself is covered by the comprehensive-generate tests.
vi.mock("@/lib/insights/comprehensive-generate", () => ({
enqueueStatusRefillForUser: vi.fn(async () => 7),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
@@ -117,6 +124,7 @@ vi.mock("@/lib/i18n/server-locale", () => ({
}));
import { GET, POST, resolveInsightsRateLimit } from "../route";
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
import { resolveProvider } from "@/lib/ai/provider";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
@@ -389,6 +397,51 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled();
});
it("enqueues the hash-gated card refill after a successful generation", async () => {
makeWorkingProvider();
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(200);
// The manual regenerate takes the cards along: one refill enqueue for
// the caller's resolved locale, annotated for the wide-event pipeline.
expect(enqueueStatusRefillForUser).toHaveBeenCalledTimes(1);
expect(enqueueStatusRefillForUser).toHaveBeenCalledWith("u-1", "en");
const refillAnnotate = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"insights.generate.cards_refill",
);
expect(refillAnnotate, "cards_refill annotate event").toBeTruthy();
});
it("does NOT enqueue the card refill when serving from the 24h DB cache", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify({ changed: "still fresh" }),
locale: "en",
} as never);
const res = await POST(jsonRequest({}) as never);
expect(res.status).toBe(200);
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
});
it("does NOT enqueue the card refill when the provider fails", async () => {
makeProviderThatThrows(
Object.assign(new Error("OpenAI request failed (500)"), {
httpStatus: 500,
}),
);
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(503);
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
});
});
// v1.4.36 QA H1 — when both the raw-mode extraction AND the aggregated
+32 -6
View File
@@ -61,6 +61,7 @@ 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";
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
export const dynamic = "force-dynamic";
@@ -159,6 +160,9 @@ async function buildComparisonSnapshotForUser(
value: true,
sleepStage: true,
source: true,
// Writer-level collapse: two HealthKit apps behind one
// source (watch stages vs phone in-bed) must not blend.
deviceType: true,
},
}),
resolveUserTimezone(userId),
@@ -718,15 +722,37 @@ export const POST = apiHandler(async (request: NextRequest) => {
// 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),
// (it is already bounded by the hourly rate limit above). The
// about-me text joins the fingerprint — it feeds the prompt and can
// change with no data change (Coach remember, Settings → AI edit) —
// and the shape MUST match the gate in `comprehensive-generate.ts`,
// or every off-request warm after a manual regenerate re-pays a
// full generation on unchanged data.
insightsSnapshotHash: hashInsightSnapshot({
features: compactFeatures,
aboutMe: aboutMe ?? null,
}),
},
});
// 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.16.8 — no blanket per-status eviction here any more (nuking ~45
// cache rows per manual regenerate deleted every hash baseline and was
// the source of the post-regenerate card-regeneration storm). Instead,
// the manual regenerate takes the cards along through the hash-gated
// queue: every scope the user has data for is enqueued, the worker
// forces each generator past its same-day cache read, and the content-
// hash gate regenerates exactly the cards whose data changed while
// refreshing the unchanged ones for free. This is the user's escape
// hatch for a card they can SEE is stale — without it the cards had no
// refresh path until the next nightly warm.
const refillScopes = await enqueueStatusRefillForUser(
userId,
locale === "de" ? "de" : "en",
);
annotate({
action: { name: "insights.generate.cards_refill" },
meta: { scopes: refillScopes },
});
// v1.7.0 W6 — the dashboard snapshot embeds the pre-generated daily
// briefing read-only; drop it so the next snapshot carries the fresh
+9 -1
View File
@@ -277,7 +277,15 @@ async function buildTargetsResponse(user: AuthedUser) {
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true, source: true },
// Writer-level collapse: two HealthKit apps behind one source (watch
// stages vs phone in-bed) must not blend into one night.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
}),
// Active medications + their 30-day intake events (section 8). The
// intake read depends on the medication ids, so the pair chains
+28 -19
View File
@@ -1,3 +1,5 @@
import pLimit from "p-limit";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -53,25 +55,32 @@ export const GET = apiHandler(async () => {
orderBy: { createdAt: "desc" },
});
// Sequential on purpose: each cold cell costs one bounded intake read +
// one band-expansion pass, and a sequential walk keeps a many-meds
// account from stampeding the pool. Warm / stale cells return without
// touching the database at all.
const items = [];
for (const medication of medications) {
const payload = await cachedSwr(
caches.medicationCompliance as ServerCache<CompliancePayload>,
complianceCacheKey(user.id, medication.id, userTz),
() => buildCompliancePayload(medication, user.id, userTz),
annotate,
);
items.push({
medicationId: medication.id,
compliance7: payload.compliance7,
compliance30: payload.compliance30,
complianceDisplay: payload.complianceDisplay,
});
}
// Bounded fan-out: each cold cell costs one bounded intake read + one
// band-expansion pass. Three at a time keeps a many-meds account from
// stampeding the pool while bounding the cold wall-clock — the client
// reads this through the shared fetch wrapper's 15 s default timeout,
// and a strictly sequential walk over a large cabinet could outlive it
// on a cold cache. Warm / stale cells return without touching the
// database at all; `Promise.all` keeps the response in list order.
const limit = pLimit(3);
const items = await Promise.all(
medications.map((medication) =>
limit(async () => {
const payload = await cachedSwr(
caches.medicationCompliance as ServerCache<CompliancePayload>,
complianceCacheKey(user.id, medication.id, userTz),
() => buildCompliancePayload(medication, user.id, userTz),
annotate,
);
return {
medicationId: medication.id,
compliance7: payload.compliance7,
compliance30: payload.compliance30,
complianceDisplay: payload.complianceDisplay,
};
}),
),
);
annotate({
action: { name: "medication.compliance_summary.read" },