diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index ae7f56332..bb21aa98b 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -39,6 +39,7 @@ import { } from "@/lib/insights/derived"; import { resolveDeterministicAssessment } from "@/lib/insights/derived/derived-assessment"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { instructionLocale } from "@/lib/ai/prompts/output-language"; import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; import { DERIVED_MODULE } from "../route"; @@ -177,7 +178,12 @@ export const GET = apiHandler(async (request: NextRequest) => { userLocale: user.locale ?? null, override: request.nextUrl.searchParams.get("locale"), }); - const assessmentLocale = locale === "de" ? "de" : "en"; + // This grid only carries the DETERMINISTIC assessment, whose templates ship + // de/en bodies — so the same instruction-body rule the prompts use applies: + // German for a German reader, English for everyone else. Deliberately NOT + // widened to the six; the AI-warm prose (which does carry the full locale) + // is served lazily by the per-score route. + const assessmentLocale = instructionLocale(locale); // v1.16.8 — read-through the per-user derived cache with // stale-while-revalidate. The cold build below walks the rollup tier @@ -244,7 +250,7 @@ export const GET = apiHandler(async (request: NextRequest) => { async function buildDerivedBatch(input: { userId: string; items: BatchItem[]; - assessmentLocale: "de" | "en"; + assessmentLocale: ReturnType; }) { const { userId, items, assessmentLocale } = input; diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 4ff5763aa..ff439cae9 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -71,6 +71,7 @@ import { requireAssistantSurface } from "@/lib/feature-flags"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { normalizeLocale } from "@/lib/insights/status-shared"; import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; @@ -202,7 +203,7 @@ export const GET = apiHandler(async (request: NextRequest) => { request, userLocale: dbUser?.locale ?? user.locale ?? null, }); - const locale = resolved === "de" ? "de" : "en"; + const locale = normalizeLocale(resolved); void enqueueForceWarm({ userId, locale }); revalidating = true; } @@ -950,7 +951,7 @@ export const POST = apiHandler((request: NextRequest) => // refresh path until the next nightly warm. const refillScopes = await enqueueStatusRefillForUser( userId, - locale === "de" ? "de" : "en", + normalizeLocale(locale), ); annotate({ action: { name: "insights.generate.cards_refill" }, diff --git a/src/app/api/insights/pregenerate/route.ts b/src/app/api/insights/pregenerate/route.ts index 411803cc4..ad3b06759 100644 --- a/src/app/api/insights/pregenerate/route.ts +++ b/src/app/api/insights/pregenerate/route.ts @@ -30,6 +30,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { normalizeLocale } from "@/lib/insights/status-shared"; import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; export const dynamic = "force-dynamic"; @@ -59,7 +60,10 @@ export const POST = apiHandler(async (request: NextRequest) => { request, userLocale: user.locale ?? null, }); - const locale = resolved === "en" ? "en" : "de"; + // Carry the reader's ACTUAL locale into the warm payload. The former + // `resolved === "en" ? "en" : "de"` sent every fr/es/it/pl account down the + // German warm, so the whole warmed cache family was in the wrong language. + const locale = normalizeLocale(resolved); await enqueueForceWarm({ userId, locale }); diff --git a/src/lib/insights/__tests__/locale-pipeline.test.ts b/src/lib/insights/__tests__/locale-pipeline.test.ts new file mode 100644 index 000000000..356539dc0 --- /dev/null +++ b/src/lib/insights/__tests__/locale-pipeline.test.ts @@ -0,0 +1,177 @@ +/** + * The assessment pipeline must carry the reader's locale end-to-end. + * + * The bug these tests would have caught: every stage typed its locale as + * `de | en` and narrowed on the way in, so a French account's locale was + * destroyed before any prompt saw it. The output-language directive in the + * prompt layer is INERT unless `fr` actually arrives there, so the assertion + * that matters is not "the pipeline compiles" but "the locale handed to the + * prompt builder is the one the reader has". + * + * Three things are pinned here: + * 1. `normalizeLocale` passes all six through and defaults unknown values to + * ENGLISH — a German default is the whole bug class. + * 2. Cache-key stability: an existing de/en account keeps reading its + * existing `insights.-status.` rows; only the four new + * locales get new (cold) keys. + * 3. End-to-end: a `fr` locale entering a generator reaches the prompt + * builder as `fr`, and a `fr` forced-warm job reaches `forceWarmUser` + * as `fr` (that site previously defaulted to German). + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { locales } from "@/lib/i18n/config"; +import { normalizeLocale } from "@/lib/insights/status-shared"; +import { statusCacheAction } from "@/lib/insights/status-cache"; + +describe("normalizeLocale", () => { + it("passes every shipped UI locale through unchanged", () => { + for (const locale of locales) { + expect(normalizeLocale(locale)).toBe(locale); + } + // The four that used to be flattened away are explicitly covered. + expect(normalizeLocale("fr")).toBe("fr"); + expect(normalizeLocale("es")).toBe("es"); + expect(normalizeLocale("it")).toBe("it"); + expect(normalizeLocale("pl")).toBe("pl"); + }); + + it("defaults an unknown, empty, or missing locale to English, never German", () => { + for (const value of [null, undefined, "", "xx", "de-DE", "en_US", "🙂"]) { + expect(normalizeLocale(value)).toBe("en"); + expect(normalizeLocale(value)).not.toBe("de"); + } + }); +}); + +describe("status cache keys", () => { + it("leaves the de/en cache keys byte-identical (no drift for existing accounts)", () => { + expect(statusCacheAction("weight", normalizeLocale("de"))).toBe( + "insights.weight-status.de", + ); + expect(statusCacheAction("weight", normalizeLocale("en"))).toBe( + "insights.weight-status.en", + ); + }); + + it("gives the four widened locales their own keys instead of reusing the English row", () => { + expect(statusCacheAction("weight", normalizeLocale("fr"))).toBe( + "insights.weight-status.fr", + ); + // Before the widening this collapsed onto the English row. + expect(statusCacheAction("weight", normalizeLocale("fr"))).not.toBe( + statusCacheAction("weight", "en"), + ); + }); +}); + +// ── end-to-end: entry point → prompt builder ────────────────────────────── + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + measurement: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), + statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock("@/lib/insights/memory", () => ({ + getPreviousInsightContext: vi.fn().mockResolvedValue(null), + formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), +})); + +/** + * The capture seam. `getGeneralStatusSystemPrompt` is the LAST hop before the + * provider call — whatever locale arrives here is the locale the prompt (and + * therefore its output-language directive) is composed for. + */ +const systemPromptLocales: string[] = []; +vi.mock("@/lib/ai/prompts/general-status", () => ({ + getGeneralStatusSystemPrompt: (locale: string) => { + systemPromptLocales.push(locale); + return "SYSTEM"; + }, + getGeneralStatusUserPrompt: () => "USER", +})); + +import { prisma } from "@/lib/db"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { generateGeneralStatusForUser } from "@/lib/insights/general-status"; + +const dayMs = 24 * 60 * 60 * 1000; + +beforeEach(() => { + // Clear recorded calls so each case asserts only its own cache writes. + vi.clearAllMocks(); + systemPromptLocales.length = 0; + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + heightCm: null, + insightsExcludeMetrics: [], + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + + const now = Date.now(); + vi.mocked(prisma.measurement.findMany).mockResolvedValue( + Array.from({ length: 40 }, (_, day) => ({ + type: "WEIGHT", + value: 80 + (day % 3), + measuredAt: new Date(now - day * dayMs), + })) as never, + ); + + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "ok", + content: JSON.stringify({ summary: "Résumé." }), + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never); +}); + +describe("a fr locale survives from the generator entry point to the prompt", () => { + it("hands 'fr' to the prompt builder rather than collapsing it to en or de", async () => { + await generateGeneralStatusForUser("u-fr", { locale: "fr", force: true }); + + expect(systemPromptLocales).toContain("fr"); + // The two failure modes the old binary produced. + expect(systemPromptLocales).not.toContain("de"); + expect(systemPromptLocales).not.toContain("en"); + }); + + it("still hands 'de' to a German reader and 'en' to an English one", async () => { + await generateGeneralStatusForUser("u-de", { locale: "de", force: true }); + expect(systemPromptLocales).toEqual(["de"]); + + systemPromptLocales.length = 0; + await generateGeneralStatusForUser("u-en", { locale: "en", force: true }); + expect(systemPromptLocales).toEqual(["en"]); + }); + + it("writes the fr assessment to the fr cache row, leaving the en row untouched", async () => { + await generateGeneralStatusForUser("u-fr", { locale: "fr", force: true }); + + const actions = vi + .mocked(prisma.auditLog.create) + .mock.calls.map( + (call) => (call[0] as { data: { action: string } }).data.action, + ); + expect(actions).toContain("insights.general-status.fr"); + expect(actions).not.toContain("insights.general-status.en"); + }); +}); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 61e1d33ea..f9fdff775 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -81,7 +81,10 @@ import { hasActiveConsentForSurface, } from "@/lib/ai/consent-guard"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; -import { stripJsonFences } from "@/lib/insights/status-shared"; +import { + stripJsonFences, + type SupportedLocale, +} from "@/lib/insights/status-shared"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { annotate } from "@/lib/logging/context"; import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets"; @@ -149,7 +152,7 @@ async function rerollBriefingParagraph(args: { systemPrompt: string; userPrompt: string; cachedText: string; - locale: "de" | "en"; + locale: SupportedLocale; /** * v1.18.10 (MEDIUM-3) — the pre-computed signals the briefing must match. * The reroll runs at a higher, seedless temperature for phrasing variety, @@ -455,7 +458,7 @@ export async function buildComparisonSnapshotForUser( */ function failBeforeProvider( userId: string, - locale: "de" | "en", + locale: SupportedLocale, reason: "payload-too-large" | "features-error" | "aborted", err?: unknown, ): GenerateOutcome { @@ -475,7 +478,7 @@ function failBeforeProvider( interface GenerateOptions { /** Resolved UI locale for the prompt + cache row. */ - locale: "de" | "en"; + locale: SupportedLocale; /** Skip the 24 h cache short-circuit and force a fresh generation. */ force?: boolean; /** diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts index bd6d002c4..0b8dc81fb 100644 --- a/src/lib/insights/derived/derived-assessment-ai.ts +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -43,7 +43,7 @@ import { import type { MeasurementType } from "@/generated/prisma/client"; import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver"; import { annotate } from "@/lib/logging/context"; -import type { Locale } from "@/lib/i18n/config"; +import { instructionLocale } from "@/lib/ai/prompts/output-language"; import { openerArchetypeHint, dayRotatedSeed, @@ -99,6 +99,11 @@ const SCORE_INPUT_TYPES: readonly MeasurementType[] = [ */ function scoreSystemPrompt(locale: Locale): string { const base = getBaseSystemPrompt(locale); + // The archetype section rides the same reviewed instruction body the base + // composes: German for a German reader, English for everyone else. The base + // already names the reader's own language, so a French reader gets the + // English archetype and French prose. The former `locale === "en" ? EN : DE` + // handed fr/es/it/pl the GERMAN section. const section = instructionLocale(locale) === "en" ? `ARCHETYPE — COMPOSITE WELLNESS SCORE (write like a premium recovery coach — WHOOP / Oura — who genuinely wants this person to do well). This is a 0–100 composite, not a raw measurement. Weave these FOUR beats into varied, connected prose (NOT a fixed template, NOT a labelled list) — lead per the OPENER HINT if one is given: @@ -162,10 +167,11 @@ export async function resolveDerivedAssessment(args: { const now = args.now ?? new Date(); const locale = normalizeLocale(args.locale); + // Deterministic templates ship de/en only — same instruction-body rule. const deterministic = resolveDeterministicAssessment( args.metric, args.derived, - locale, + instructionLocale(locale), now, ); // Not assessable, or status !== ok → no field (the locked contract). @@ -224,7 +230,13 @@ export async function generateDerivedScoreAssessment(args: { if (!isAssessableDerivedScore(args.metric)) return; if (args.derived.status !== "ok") return; - const signal = buildScoreSignal(args.metric, args.derived.value, locale); + // The deterministic signal labels ship de/en templates only; route them + // through the same instruction-body rule rather than a German default. + const signal = buildScoreSignal( + args.metric, + args.derived.value, + instructionLocale(locale), + ); if (!signal) return; const band = (args.derived.value as { band?: string }).band ?? "yellow"; @@ -240,7 +252,7 @@ export async function generateDerivedScoreAssessment(args: { // assessment varies across scores and across days instead of riding the // fixed reference seed. The key is per (user, score, day). const seedKey = `${args.userId}:${scope}:${todayKey}`; - const openerHint = openerArchetypeHint(seedKey, locale as Locale); + const openerHint = openerArchetypeHint(seedKey, locale); // v1.22 (W6) — score-warm input-hash gate. A score is re-warmed daily even on // a day with no new wearable data; this skips the LLM and re-stamps the cached diff --git a/src/lib/insights/memory.ts b/src/lib/insights/memory.ts index 450c74df4..f58e1e15c 100644 --- a/src/lib/insights/memory.ts +++ b/src/lib/insights/memory.ts @@ -18,6 +18,8 @@ */ import { prisma } from "@/lib/db"; +import { instructionLocale } from "@/lib/ai/prompts/output-language"; +import type { SupportedLocale } from "@/lib/insights/status-shared"; /** * Stable identifiers for the seven insight scopes. The string MUST @@ -70,7 +72,7 @@ export interface PreviousInsightContext { export async function getPreviousInsightContext( userId: string, scope: PreviousContextScope, - locale: "de" | "en", + locale: SupportedLocale, minAgeHours: number = 12, ): Promise { const action = `insights.${scope}.${locale}`; @@ -125,7 +127,15 @@ export async function getPreviousInsightContext( /** * Format a previous-context block for direct injection into the user - * prompt. Output already locale-aware. + * prompt. + * + * The block is written in the same reviewed instruction body the prompt + * composes (`instructionLocale`): German for a German reader, English for + * everyone else. It is an INSTRUCTION to the model, not user-facing prose — + * the reader's own language is named by the prompt's output-language + * directive, so a French reader gets the English instruction block and a + * French assessment. The former `locale === "en" ? EN : DE` handed the + * GERMAN block to fr/es/it/pl. * * Example (de): * "VORHERIGE ANALYSE (vor 7 Tagen, 2026-05-01): @@ -133,16 +143,17 @@ export async function getPreviousInsightContext( */ export function formatPreviousContextForPrompt( ctx: PreviousInsightContext | null, - locale: "de" | "en", + locale: SupportedLocale, ): string { + const body = instructionLocale(locale); if (!ctx) { - return locale === "en" + return body === "en" ? "PREVIOUS ANALYSIS: none on file. Treat this as the user's first analysis for this domain — no improvement/regression delta to surface." : "VORHERIGE ANALYSE: keine vorhanden. Behandle dies als erste Analyse in diesem Bereich — kein Verbesserungs-/Verschlechterungs-Delta zu nennen."; } const dateLabel = ctx.generatedAt.slice(0, 10); - if (locale === "en") { + if (body === "en") { const ageLabel = ctx.ageDays === 0 ? "earlier today" diff --git a/src/lib/insights/status-batch.ts b/src/lib/insights/status-batch.ts index ff267ba6a..2afd0c5bf 100644 --- a/src/lib/insights/status-batch.ts +++ b/src/lib/insights/status-batch.ts @@ -39,7 +39,10 @@ import { type PreparedStatusCard, } from "@/lib/insights/status-card-generation"; import { runStatusCompletion } from "@/lib/insights/status-provider"; -import { stripJsonFences } from "@/lib/insights/status-shared"; +import { + stripJsonFences, + type SupportedLocale, +} from "@/lib/insights/status-shared"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { buildStatusBatchUserPrompt, @@ -53,7 +56,7 @@ import { annotate } from "@/lib/logging/context"; const PREPARES: ReadonlyArray< ( userId: string, - options: { locale: "de" | "en"; force?: boolean }, + options: { locale: SupportedLocale; force?: boolean }, ) => Promise > = [ prepareBloodPressureStatusForUser, @@ -109,7 +112,7 @@ function parsePerMetric(content: string): Record | null { */ export async function generateStatusBatchForUser( userId: string, - options: { locale: "de" | "en"; force?: boolean }, + options: { locale: SupportedLocale; force?: boolean }, ): Promise { const result: StatusBatchResult = { served: 0, diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 2e309ba98..03679f230 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -15,6 +15,7 @@ import { DISCOVERY_OUTCOMES, } from "@/lib/insights/correlation-discovery"; import { annotate } from "@/lib/logging/context"; +import type { SupportedLocale } from "@/lib/insights/status-shared"; /** * v1.18.11 (P6-tighten) — the measurement types the FDR correlation-discovery @@ -499,7 +500,7 @@ export type ReadOnlyMissOutcome = export async function resolveReadOnlyStatusMiss(args: { userId: string; metric: InsightStatusScope; - locale: "de" | "en"; + locale: SupportedLocale; }): Promise { const hasProvider = await hasUsableStatusProvider(args.userId); if (!hasProvider) return { kind: "no-provider" }; diff --git a/src/lib/insights/status-invalidation.ts b/src/lib/insights/status-invalidation.ts index 6bacb2291..977dea890 100644 --- a/src/lib/insights/status-invalidation.ts +++ b/src/lib/insights/status-invalidation.ts @@ -16,7 +16,10 @@ import { enqueueStatusGeneration, type InsightStatusScope, } from "@/lib/jobs/insight-status-generate-shared"; -import { normalizeLocale } from "@/lib/insights/status-shared"; +import { + normalizeLocale, + type SupportedLocale, +} from "@/lib/insights/status-shared"; import { isTimeoutStub, statusCacheAction } from "@/lib/insights/status-cache"; import { metricIdForMeasurementType, @@ -218,7 +221,7 @@ export async function invalidateStatusInsightsForTypes( */ export async function enqueueStatusRefillForUser( userId: string, - locale: "de" | "en", + locale: SupportedLocale, ): Promise { const scopes = new Set(PER_STATUS_SCOPES); try { @@ -257,7 +260,7 @@ export async function enqueueStatusRefillForUser( */ async function findRecentlyWarmedScopes( userId: string, - locale: "de" | "en", + locale: SupportedLocale, scopes: ReadonlySet, ): Promise> { const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS); diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index 3113926e5..dc3191e41 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -9,17 +9,28 @@ * to the rounding precision, the chart-token scrub, or the cache-row * shape lands once rather than seven times. * - * The shapes are intentionally narrow — `SupportedLocale` is `de | en` - * because the status prompts ship only those two locales. The + * `SupportedLocale` is the full six-locale UI union. The status prompts + * compose one of two reviewed instruction bodies (de / en) but name the + * reader's own language in an explicit output directive, so the reader's + * locale must survive the whole pipeline rather than being collapsed to a + * binary on the way in. The * medication-compliance generator writes a richer cache `details` shape * (it carries a per-medication array), so it shares `round`, * `normalizeSummaryText`, `normalizeLocale`, and `parseSummaryFromContent` * but keeps its own `auditLog.create`. */ import { prisma } from "@/lib/db"; +import { locales, type Locale } from "@/lib/i18n/config"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; -export type SupportedLocale = "de" | "en"; +/** + * The locales the assessment pipeline carries end-to-end — all six the UI + * ships. Aliased to the i18n `Locale` so adding a UI locale widens the + * pipeline in one place rather than needing a second edit here. + */ +export type SupportedLocale = Locale; + +const SUPPORTED_LOCALES: ReadonlySet = new Set(locales); /** Round to `digits` decimal places. */ export function round(value: number, digits = 1): number { @@ -33,15 +44,20 @@ export function normalizeSummaryText(value: string): string { } /** - * Narrow an arbitrary locale string to the two the prompts support. - * Non-German locales (fr/es/it/pl, unknown values) resolve to ENGLISH — - * the same routing the no-key fallbacks use — so a French UI never - * receives German AI prose just because the prompts ship DE + EN only. + * Validate an arbitrary locale string against the six the UI ships. + * + * A recognised locale passes through UNCHANGED — that is the whole point: + * the former `value === "de" ? "de" : "en"` binary erased a French reader's + * locale before any prompt saw it, so the output-language directive could + * never fire. An unrecognised or missing value defaults to ENGLISH, never + * German; a German default is the bug class this replaces. */ export function normalizeLocale( value: string | null | undefined, ): SupportedLocale { - return value === "de" ? "de" : "en"; + return typeof value === "string" && SUPPORTED_LOCALES.has(value) + ? (value as SupportedLocale) + : "en"; } export interface SeriesSummary { diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index d3b359212..e8bb06131 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -783,6 +783,36 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { }); }); + it("carries a fr locale into the comprehensive and status warms instead of collapsing it", async () => { + // The regression this pins: the warm typed its locale `de | en`, so a + // French account's warm ran (and cached) in the wrong language — and the + // prompt layer's output-language directive never saw `fr` to act on. + const { prisma } = makePrisma([]); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(3); + + await forceWarmUser(prisma as never, "u1", "fr", { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(generate).toHaveBeenCalledWith("u1", { + locale: "fr", + force: true, + signal: expect.any(AbortSignal), + }); + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledWith("u1", { locale: "fr", force: false }); + } + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["fr"]); + }); + it("skips the comprehensive when the cache was warmed within the freshness window (idempotent re-enqueue)", async () => { // A revalidation poll that outlives the enqueue singleton can stack // several force jobs; the job-start freshness re-check collapses the diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts index d4afd79db..22aca36c8 100644 --- a/src/lib/jobs/insight-pregenerate-shared.ts +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -13,6 +13,7 @@ * name + payload type + enqueue helper in the generator-free module, the * concrete dispatch in the worker module. */ +import type { SupportedLocale } from "@/lib/insights/status-shared"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; @@ -29,8 +30,12 @@ export interface InsightPregeneratePayload { */ userId?: string; force?: boolean; - /** Locale to warm on the forced path; defaults to "de" when absent. */ - locale?: "de" | "en"; + /** + * Locale to warm on the forced path. Carries the reader's full UI locale + * (one of the six); the handler validates it and defaults an absent or + * unrecognised value to English. + */ + locale?: SupportedLocale; } /** @@ -48,7 +53,7 @@ export interface InsightPregeneratePayload { */ export async function enqueueForceWarm(payload: { userId: string; - locale: "de" | "en"; + locale: SupportedLocale; }): Promise { const boss = getGlobalBoss(); if (!boss) { @@ -119,7 +124,7 @@ export const PREGENERATE_RETRY_DELAY_SECONDS = 45 * 60; */ export async function enqueuePregenerateFailureRetry(payload: { userId: string; - locale: "de" | "en"; + locale: SupportedLocale; }): Promise { const boss = getGlobalBoss(); if (!boss) { diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index d2d1f7da2..d23291610 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -54,6 +54,10 @@ 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 { + normalizeLocale as normalizeStatusLocale, + type SupportedLocale, +} from "@/lib/insights/status-shared"; import { generateComprehensiveInsight, type GenerateOutcome, @@ -301,7 +305,7 @@ export interface PregenerateRunResult { */ type StatusGenerator = ( userId: string, - options: { locale: "de" | "en"; force?: boolean }, + options: { locale: SupportedLocale; force?: boolean }, ) => Promise<{ hasProvider: boolean; cached: boolean }>; /** @@ -323,7 +327,7 @@ type StatusGenerator = ( */ async function warmPerStatusCaches( userId: string, - locales: ReadonlyArray<"de" | "en">, + locales: ReadonlyArray, generators: ReadonlyArray, ): Promise { const limit = pLimit(WARM_PASS_CONCURRENCY); @@ -372,7 +376,7 @@ async function warmPerStatusCaches( */ async function warmStatusBatch( userId: string, - locales: ReadonlyArray<"de" | "en">, + locales: ReadonlyArray, ): Promise { let warmed = 0; for (const locale of locales) { @@ -413,7 +417,7 @@ async function warmStatusBatch( async function warmGenericMetricCaches( prisma: PrismaClient, userId: string, - locales: ReadonlyArray<"de" | "en">, + locales: ReadonlyArray, ): Promise { // One distinct read: the set of MeasurementTypes the user has live // rows for. Best-effort — a read failure (or a worker prisma surface @@ -532,12 +536,16 @@ export async function findPregenerateCandidates( } /** - * Non-German locales resolve to ENGLISH (matching - * `status-shared.normalizeLocale` and the no-key fallback routing) so a - * fr/es/it/pl account gets English AI prose, not German. + * The user's stored locale, validated against the six the UI ships. + * + * This was a local `value === "de" ? "de" : "en"` copy, which flattened a + * fr/es/it/pl account to English before the warm pass ever reached a prompt. + * It now defers to the one shared validator so the reader's locale survives + * into the prompt's output-language directive; an unknown value still + * defaults to English, never German. */ -function normalizeLocale(value: string | null): "de" | "en" { - return value === "de" ? "de" : "en"; +function normalizeLocale(value: string | null): SupportedLocale { + return normalizeStatusLocale(value); } /** @@ -555,7 +563,7 @@ export async function runInsightPregenerate( /** Injected for the test — defaults to the real generator. */ generate?: ( userId: string, - opts: { locale: "de" | "en"; force?: boolean; signal?: AbortSignal }, + opts: { locale: SupportedLocale; force?: boolean; signal?: AbortSignal }, ) => Promise; /** Injected for the test — defaults to the seven real status generators. */ statusGenerators?: ReadonlyArray; @@ -566,7 +574,7 @@ export async function runInsightPregenerate( */ warmGenericMetrics?: ( userId: string, - locales: ReadonlyArray<"de" | "en">, + locales: ReadonlyArray, ) => Promise; /** * v1.28.30 — injected for the test. Defaults to the real delayed @@ -576,7 +584,7 @@ export async function runInsightPregenerate( */ enqueueRetry?: (payload: { userId: string; - locale: "de" | "en"; + locale: SupportedLocale; }) => Promise; } = {}, ): Promise { @@ -589,13 +597,16 @@ export async function runInsightPregenerate( // production injects nothing, so the seven warm calls collapse into ONE // batched generation. The injected per-card path stays the test seam. const injectedStatusGenerators = options.statusGenerators; - const warmStatus = (userId: string, locales: ReadonlyArray<"de" | "en">) => + const warmStatus = ( + userId: string, + locales: ReadonlyArray, + ) => injectedStatusGenerators ? warmPerStatusCaches(userId, locales, injectedStatusGenerators) : warmStatusBatch(userId, locales); const warmGenericMetrics = options.warmGenericMetrics ?? - ((userId: string, locales: ReadonlyArray<"de" | "en">) => + ((userId: string, locales: ReadonlyArray) => warmGenericMetricCaches(prisma, userId, locales)); const result: PregenerateRunResult = { @@ -840,16 +851,16 @@ export async function runInsightPregenerate( export async function forceWarmUser( prisma: PrismaClient, userId: string, - locale: "de" | "en", + locale: SupportedLocale, options: { generate?: ( userId: string, - opts: { locale: "de" | "en"; force?: boolean; signal?: AbortSignal }, + opts: { locale: SupportedLocale; force?: boolean; signal?: AbortSignal }, ) => Promise; statusGenerators?: ReadonlyArray; warmGenericMetrics?: ( userId: string, - locales: ReadonlyArray<"de" | "en">, + locales: ReadonlyArray, ) => Promise; /** Injected for the test — defaults to `new Date()`. */ now?: Date; @@ -863,13 +874,16 @@ export async function forceWarmUser( // v1.18.7 (HIGH-1) — batch the seven status warms into ONE provider call // in production; keep the per-card path when a test injects generators. const injectedStatusGenerators = options.statusGenerators; - const warmStatus = (userId: string, locales: ReadonlyArray<"de" | "en">) => + const warmStatus = ( + userId: string, + locales: ReadonlyArray, + ) => injectedStatusGenerators ? warmPerStatusCaches(userId, locales, injectedStatusGenerators) : warmStatusBatch(userId, locales); const warmGenericMetrics = options.warmGenericMetrics ?? - ((id: string, locales: ReadonlyArray<"de" | "en">) => + ((id: string, locales: ReadonlyArray) => warmGenericMetricCaches(prisma, id, locales)); const now = options.now ?? new Date(); @@ -896,7 +910,7 @@ export async function forceWarmUser( // eviction; that eviction is gone, so the other locale family's rows // survive untouched and a client actually reading the second locale // warms it lazily through the read-path enqueue on its first miss. - const locales: ReadonlyArray<"de" | "en"> = [locale]; + const locales: ReadonlyArray = [locale]; // The comprehensive step gets its own bounded budget and its failure is // non-fatal. A slow or stalled briefing generation must not short-circuit diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index 906847142..b1b4ae5e5 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -10,6 +10,9 @@ * concrete generators) lives in `insight-status-generate.ts` and is * imported solely by the worker boot file. */ +// Type-only — erased at compile time, so the generator-free boundary of +// this module (see the header) is preserved. +import type { SupportedLocale } from "@/lib/insights/status-shared"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; @@ -87,7 +90,7 @@ export interface InsightStatusGeneratePayload { */ metric: InsightStatusScope; /** Resolved client locale — the cache key the route reads against. */ - locale: "de" | "en"; + locale: SupportedLocale; } /** diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts index 4b96cd16d..bb20d30c0 100644 --- a/src/lib/jobs/insight-status-generate.ts +++ b/src/lib/jobs/insight-status-generate.ts @@ -27,6 +27,7 @@ * import cycle). This module re-exports them for the worker's convenience. */ import { annotate } from "@/lib/logging/context"; +import type { SupportedLocale } from "@/lib/insights/status-shared"; import { generateGeneralStatusForUser } from "@/lib/insights/general-status"; import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status"; import { generateWeightStatusForUser } from "@/lib/insights/weight-status"; @@ -70,7 +71,7 @@ export type { InsightStatusMetric, InsightStatusGeneratePayload }; */ type StatusGenerator = ( userId: string, - options: { locale: "de" | "en"; force: boolean }, + options: { locale: SupportedLocale; force: boolean }, ) => Promise; const GENERATORS: Record = { diff --git a/src/lib/jobs/morning-digest-refresh.ts b/src/lib/jobs/morning-digest-refresh.ts index acaa6151d..28f1063da 100644 --- a/src/lib/jobs/morning-digest-refresh.ts +++ b/src/lib/jobs/morning-digest-refresh.ts @@ -35,7 +35,10 @@ import { type GenerateOutcome, } from "@/lib/insights/comprehensive-generate"; import { enqueuePregenerateFailureRetry } from "@/lib/jobs/insight-pregenerate-shared"; -import { normalizeLocale } from "@/lib/insights/status-shared"; +import { + normalizeLocale, + type SupportedLocale, +} from "@/lib/insights/status-shared"; import { MORNING_DIGEST_REFRESH_QUEUE, type MorningDigestRefreshPayload, @@ -52,7 +55,7 @@ export interface MorningDigestRefreshResult { type GenerateFn = ( userId: string, - options: { locale: "de" | "en"; force?: boolean }, + options: { locale: SupportedLocale; force?: boolean }, ) => Promise; /** @@ -70,7 +73,7 @@ export async function runMorningDigestRefresh( generate?: GenerateFn; enqueueRetry?: (args: { userId: string; - locale: "de" | "en"; + locale: SupportedLocale; }) => Promise; } = {}, ): Promise { diff --git a/src/lib/jobs/reminder/insights-handlers.ts b/src/lib/jobs/reminder/insights-handlers.ts index aa9e76ceb..2c7117714 100644 --- a/src/lib/jobs/reminder/insights-handlers.ts +++ b/src/lib/jobs/reminder/insights-handlers.ts @@ -6,6 +6,7 @@ */ import { type Job } from "pg-boss"; import { reportWorkerError } from "@/lib/jobs/report-worker-error"; +import { normalizeLocale } from "@/lib/insights/status-shared"; import { recordError, recordInsightsRun } from "@/lib/jobs/worker-status"; import { INSIGHT_PREGENERATE_QUEUE, @@ -142,9 +143,10 @@ async function runStatusBatchCron(taskName: string): Promise { for (const user of users) { try { const result = await generateStatusBatchForUser(user.id, { - // The batch needs a concrete `de`/`en`; the per-metric generators - // normalise the same way (de stays de, everything else English). - locale: user.locale === "de" ? "de" : "en", + // Validate against the six UI locales rather than collapsing to a + // binary — the per-metric generators normalise identically, and the + // prompt names the reader's own language from this value. + locale: normalizeLocale(user.locale), force: false, }); generated += result.batched + result.fellBack; @@ -238,7 +240,9 @@ export async function handleInsightPregenerateJob( for (const job of forced) { const userId = job.data.userId as string; - const locale = job.data.locale === "en" ? "en" : "de"; + // The former `=== "en" ? "en" : "de"` defaulted a fr/es/it/pl payload to + // GERMAN, so a forced warm produced German prose for a French reader. + const locale = normalizeLocale(job.data.locale); try { const summary = await forceWarmUser(getWorkerPrisma(), userId, locale); evt.addMeta(