mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
1 parent
3d95ace218
commit
a2a995e429
25 files changed
+770
-97
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user