From 37c33d0205788f02923b9794301fadfe6b1551f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:23:40 +0200 Subject: [PATCH 1/3] fix(insights): carry the reader's locale through the assessment pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-metric assessment pipeline typed every locale as `de | en` and narrowed on the way in, so a French, Spanish, Italian or Polish account never reached a prompt with its own locale — the output-language directive could not fire and the reader got English, or German where the binary defaulted that way. Widen `SupportedLocale` to the six locales the UI ships and replace the narrowing binaries with one validating `normalizeLocale` that passes a recognised locale through unchanged and defaults an unknown value to English. The status-generate payload, status batch, status cache, comprehensive generation, the pre-generate warm passes and the forced-warm handlers all carry the full locale now. Fix the collapse sites that discarded it: the warm enqueue on the comprehensive route and the pregenerate route, the nightly batch cron, and the forced-warm job handler — the last two defaulted to German outright. The deterministic derived-score templates and the previous-context block ship de/en bodies only, so they route through `instructionLocale` (German for German readers, English otherwise) while the prompt names the reader's own language. --- src/app/api/insights/generate/route.ts | 5 +- src/app/api/insights/pregenerate/route.ts | 6 ++- src/lib/insights/comprehensive-generate.ts | 11 ++-- .../insights/derived/derived-assessment-ai.ts | 24 ++++++--- src/lib/insights/memory.ts | 21 ++++++-- src/lib/insights/status-batch.ts | 9 ++-- src/lib/insights/status-cache.ts | 3 +- src/lib/insights/status-invalidation.ts | 9 ++-- src/lib/insights/status-shared.ts | 32 ++++++++--- src/lib/jobs/insight-pregenerate-shared.ts | 13 +++-- src/lib/jobs/insight-pregenerate.ts | 54 ++++++++++++------- .../jobs/insight-status-generate-shared.ts | 5 +- src/lib/jobs/insight-status-generate.ts | 3 +- src/lib/jobs/morning-digest-refresh.ts | 9 ++-- src/lib/jobs/reminder/insights-handlers.ts | 12 +++-- 15 files changed, 150 insertions(+), 66 deletions(-) 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/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 1a9d72ebd..76c5006a7 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, @@ -89,8 +89,13 @@ const SCORE_INPUT_TYPES: readonly MeasurementType[] = [ function scoreSystemPrompt(locale: SupportedLocale): 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 = - locale === "en" + 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: - STANDING — the score and its band, in one short clause. - WHAT DROVE IT — name the contributor(s) in \`signal.contributors[]\` that HELPED (the strongest, highest values) AND the one(s) that HURT (the weakest, lowest values), so a good score earns its win and a soft score is explained honestly. Each contributor is itself 0–100; a low value drags the score down, a high one carries it. Never invent a contributor that is not listed. When none are listed, use the trend (signal.delta) instead. @@ -120,7 +125,7 @@ function scoreUserPrompt( null, 2, ); - if (locale === "en") { + if (instructionLocale(locale) === "en") { return `Date: ${todayKey} (${tz}) OPENER HINT: ${openerHint} Write an assessment of ${signal.metric} today across the four beats — the standing, what helped AND what hurt it, what the band means for the day, and one grounded nudge. Aim for 3–5 sentences, roughly 45–75 words (this overrides the shorter base length cap). Connected prose, not a checklist. @@ -152,10 +157,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). @@ -214,7 +220,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"; @@ -230,7 +242,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/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( From 81065f3f9d769501b93b2b4ebc36e7da95aae95e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:30:57 +0200 Subject: [PATCH 2/3] test(insights): pin the reader's locale from entry point to prompt Covers the assertion that would have caught the narrowing: normalizeLocale passes all six locales through and defaults an unknown value to English, the de/en cache keys stay byte-identical while the four widened locales get their own, and a fr locale entering a status generator reaches the prompt builder as fr and writes the fr cache row. The forced-warm case pins the same for the comprehensive and per-status warms. --- .../__tests__/locale-pipeline.test.ts | 177 ++++++++++++++++++ .../__tests__/insight-pregenerate.test.ts | 30 +++ 2 files changed, 207 insertions(+) create mode 100644 src/lib/insights/__tests__/locale-pipeline.test.ts 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/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 From ae3d291feb30ef4f867f1e89467151afa2f51790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:33:37 +0200 Subject: [PATCH 3/3] refactor(insights): name the instruction-body rule on the derived grid The derived batch grid carries only the deterministic assessment, whose templates ship de/en bodies. Its `locale === "de" ? "de" : "en"` was correct but indistinguishable from the accidental binaries that were just removed; route it through instructionLocale so the intent is explicit. Behaviour unchanged. --- src/app/api/insights/derived/batch/route.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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;