mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Tag the briefing cache with its language and refuse a foreign one
The comprehensive-briefing cache (User.insightsCachedText) is one slot per account with no record of the language it was written in. Every reader served whatever was there, so a German-language account whose nightly warm had written English kept seeing English on the dashboard lead and the Today briefing. The request path only replaced it once the row was a day old and the provider answered, which with a provider at its usage limit was never. Add User.insightsCachedLocale (migration 0335). Both writers store the locale they generated in; the timestamp-only refresh on an unchanged snapshot tags the row too, since the fingerprint covers the generation locale. Every reset that nulls the text nulls the tag. The snapshot briefing lift and GET /api/insights/generate treat a tag that differs from the reader's locale as an empty cache: no prose, no timestamp, the preparing / no-provider state, and the out-of-band warm enqueued in the reader's locale through the existing path. The generator's own short-circuit and the POST short-circuit refuse to call a foreign-language row fresh. A NULL tag (rows written before this change) is served as before until the next write tags it. The Today digest derives from the snapshot and follows. Covered by unit tests on the lift, the route and the generator, and an integration test that writes the row through Prisma and reads the snapshot for a reader in another language.
This commit is contained in:
1 parent
d5cf82fb6e
commit
ba4e34f09e
21 files changed
+404
-26
No files matched your search
@@ -756,9 +756,12 @@ describe("backup round trip — export, wire schema, restore", () => {
|
||||
userUpdate,
|
||||
"a scope-narrowing restore must clear the cached briefing",
|
||||
).toBeDefined();
|
||||
// The language tag is cleared with the text: an empty slot carries
|
||||
// no language, and the next write tags it afresh.
|
||||
expect(userUpdate!.data).toEqual({
|
||||
insightsCachedText: null,
|
||||
insightsCachedAt: null,
|
||||
insightsCachedLocale: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
codexConnectionStatus: "connected",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
codexConnectionStatus: "disconnected",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -216,6 +216,7 @@ describe("about-me module gate", () => {
|
||||
data: {
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
expect(invalidateInsights).toHaveBeenCalledWith("u1");
|
||||
|
||||
@@ -271,6 +271,7 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
data: {
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -447,6 +447,9 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
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}$/);
|
||||
// The cache row records the language it was written in, so a reader
|
||||
// in another language is never served this text.
|
||||
expect(args.data.insightsCachedLocale).toBe("en");
|
||||
});
|
||||
|
||||
it("refuses a stale cache write when AI profile inclusion changes during generation", async () => {
|
||||
@@ -797,6 +800,55 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
expect(body.data.revalidating).toBe(true);
|
||||
});
|
||||
|
||||
// `resolveServerLocale` is mocked to "en" in this suite, so the reader is
|
||||
// an English session throughout; the cache tag is what varies.
|
||||
it("does NOT serve a fresh cache tagged with another language; warms in the reader's", async () => {
|
||||
const cached = { dailyBriefing: { paragraph: "Deutsch", keyFindings: [] } };
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(cached),
|
||||
insightsCachedLocale: "de",
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/generate"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { insights: unknown; cached: boolean; revalidating: boolean };
|
||||
};
|
||||
expect(body.data.cached).toBe(false);
|
||||
expect(body.data.insights).toBeNull();
|
||||
expect(body.data.revalidating).toBe(true);
|
||||
expect(enqueueForceWarm).toHaveBeenCalledWith({
|
||||
userId: "u-1",
|
||||
locale: "en",
|
||||
});
|
||||
expect(resolveProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves a fresh cache whose tag matches the reader's language", async () => {
|
||||
const cached = { dailyBriefing: { paragraph: "English", keyFindings: [] } };
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(cached),
|
||||
insightsCachedLocale: "en",
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/generate"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { insights: unknown; cached: boolean; revalidating: boolean };
|
||||
};
|
||||
expect(body.data.cached).toBe(true);
|
||||
expect(body.data.insights).toEqual(cached);
|
||||
expect(body.data.revalidating).toBe(false);
|
||||
expect(enqueueForceWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an empty payload (no warm) on a cold cache without a provider", async () => {
|
||||
vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
|
||||
@@ -154,13 +154,38 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
select: {
|
||||
insightsCachedAt: true,
|
||||
insightsCachedText: true,
|
||||
insightsCachedLocale: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Resolve the locale the caller is actually reading (cookie /
|
||||
// Accept-Language fall-back when `User.locale` is unset) and validate it
|
||||
// against the shipped list — the same convention the nightly's
|
||||
// `normalizeLocale` and the status routes follow. The previous
|
||||
// `(locale) === "en" ? "en" : "de"` collapsed a NULL `User.locale`
|
||||
// to German even for a client reading the app in English, so the
|
||||
// visit-triggered warm filled the wrong cache family.
|
||||
const readerLocale = normalizeLocale(
|
||||
await resolveServerLocale({
|
||||
request,
|
||||
userLocale: dbUser?.locale ?? user.locale ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
// The cache is one slot per user. Text written in another language is
|
||||
// never the reader's briefing, however fresh: treat the slot as empty for
|
||||
// prose, and re-warm in the reader's language below. An untagged row
|
||||
// predates the tag and is served as before.
|
||||
const languageMismatch =
|
||||
dbUser?.insightsCachedLocale != null &&
|
||||
dbUser.insightsCachedLocale !== readerLocale;
|
||||
|
||||
const cachedAt = dbUser?.insightsCachedAt ?? null;
|
||||
const isFresh =
|
||||
cachedAt !== null && Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
|
||||
!languageMismatch &&
|
||||
cachedAt !== null &&
|
||||
Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
|
||||
|
||||
// v1.18.9 (#4) — resolve provider availability once, up front. The
|
||||
// read path never blocks on or generates through the provider, but it
|
||||
@@ -204,23 +229,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// empty / connect-AI state instead of a wasted enqueue).
|
||||
let revalidating = false;
|
||||
if (!isFresh && hasProvider) {
|
||||
// Resolve the locale the caller is actually reading (cookie /
|
||||
// Accept-Language fall-back when `User.locale` is unset) and narrow
|
||||
// non-German to English — the same convention the nightly's
|
||||
// `normalizeLocale` and the status routes follow. The previous
|
||||
// `(locale) === "en" ? "en" : "de"` collapsed a NULL `User.locale`
|
||||
// to German even for a client reading the app in English, so the
|
||||
// visit-triggered warm filled the wrong cache family.
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: dbUser?.locale ?? user.locale ?? null,
|
||||
});
|
||||
const locale = normalizeLocale(resolved);
|
||||
void enqueueForceWarm({ userId, locale });
|
||||
void enqueueForceWarm({ userId, locale: readerLocale });
|
||||
revalidating = true;
|
||||
}
|
||||
|
||||
if (dbUser?.insightsCachedText) {
|
||||
if (dbUser?.insightsCachedText && !languageMismatch) {
|
||||
try {
|
||||
const cached = JSON.parse(dbUser.insightsCachedText);
|
||||
const legacyPayload = isLegacyInsightPayload(cached);
|
||||
@@ -260,7 +273,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.generate.read" },
|
||||
meta: { cached: false, revalidating, hasProvider },
|
||||
meta: {
|
||||
cached: false,
|
||||
revalidating,
|
||||
hasProvider,
|
||||
languageMismatch,
|
||||
readerLocale,
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
insights: null,
|
||||
@@ -298,6 +317,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
insightsPrivacyMode: true,
|
||||
insightsCachedAt: true,
|
||||
insightsCachedText: true,
|
||||
insightsCachedLocale: true,
|
||||
// v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach
|
||||
// settings. Filtered off `features` before serialisation so the
|
||||
// LLM never sees the excluded blocks.
|
||||
@@ -366,10 +386,16 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
legacyPayload: boolean;
|
||||
cachedAt: Date;
|
||||
} | null = null;
|
||||
// A cache tagged with another language is not this reader's cache and
|
||||
// never short-circuits; the generation below writes the reader's own.
|
||||
const cachedLocaleMatches =
|
||||
dbUser?.insightsCachedLocale == null ||
|
||||
dbUser.insightsCachedLocale === locale;
|
||||
if (
|
||||
!forceRefresh &&
|
||||
dbUser?.insightsCachedAt &&
|
||||
dbUser.insightsCachedText &&
|
||||
cachedLocaleMatches &&
|
||||
Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000
|
||||
) {
|
||||
try {
|
||||
@@ -999,6 +1025,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
data: {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(insights),
|
||||
insightsCachedLocale: locale,
|
||||
insightsSnapshotHash: hashInsightSnapshot({
|
||||
features: compactFeatures,
|
||||
aboutMe: aboutMe ?? null,
|
||||
|
||||
@@ -77,6 +77,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
data.insightsPrivacyMode = mode;
|
||||
data.insightsCachedAt = null;
|
||||
data.insightsCachedText = null;
|
||||
data.insightsCachedLocale = null;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
|
||||
Reference in new issue
Block a user