From e4ec0eec498c94d4160f6b4b9dc6e37a27ae02db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:25:44 +0200 Subject: [PATCH 1/3] fix(insights): stop sending German period narratives to fr/es/it/pl readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The narrative read route narrowed every non-English locale to German, so a French, Spanish, Italian or Polish account read the German narrative row and warmed it from the German prompt body. Carry the resolved locale through instead: the row is keyed (user, period, locale) across the full UI locale union, and the four locales now compose the English instruction body with their language named and the locale's own reply-language directive appended last. The German and English prompts are unchanged apart from the version string. The nightly warm keeps the user's stored locale for the same reason — it has to write the key the read route reads — and the coach and dashboard narrative recalls read that same key. Coach memory stays de/en: it is model-facing context, not user-facing prose, so the four locales compose and key English rather than German. NARRATIVE_PROMPT_VERSION 1.13.0 -> 1.14.0. --- src/app/api/insights/chat/route.ts | 10 +- src/app/api/insights/narrative/route.ts | 14 ++- .../ai/coach/coach-memory-refresh-worker.ts | 5 +- src/lib/ai/coach/coach-memory-shared.ts | 2 +- src/lib/ai/coach/memory-snapshot.ts | 8 +- src/lib/ai/coach/snapshot.ts | 11 ++- src/lib/dashboard/snapshot.ts | 16 ++-- .../narrative/period-narrative-generate.ts | 91 +++++++++++++++---- src/lib/jobs/period-narrative-shared.ts | 7 +- src/lib/jobs/period-narrative-warm.ts | 16 +++- 10 files changed, 138 insertions(+), 42 deletions(-) diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index ec846ba12..ed809c26d 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -60,6 +60,7 @@ import { listConversations, } from "@/lib/ai/coach/persistence"; import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared"; +import { instructionLocale } from "@/lib/ai/prompts/output-language"; import { storeDeterministicFacts } from "@/lib/ai/coach/facts"; import { buildDateKey, @@ -485,9 +486,12 @@ async function handleChatRequest(request: NextRequest): Promise { void enqueueCoachMemoryRefresh({ conversationId: workingConversationId, userId, - // Coach memory prose is composed in de/en only (the snapshot's - // coachLocale); collapse the wider UI locale union here. - locale: locale === "en" ? "en" : "de", + // Coach memory prose is composed in de/en only — it is MODEL-FACING + // context (a rolling conversation summary + extracted durable facts), + // not user-facing prose, so English is the correct target for every + // locale without a reviewed body. The former `=== "en" ? "en" : "de"` + // binary composed and keyed a French account's memory in German. + locale: instructionLocale(locale), }); } // v1.19.1 (C4) — token efficiency. The full SNAPSHOT (now incl. labs, diff --git a/src/app/api/insights/narrative/route.ts b/src/app/api/insights/narrative/route.ts index 43d781c72..fd707f024 100644 --- a/src/app/api/insights/narrative/route.ts +++ b/src/app/api/insights/narrative/route.ts @@ -17,6 +17,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { locales, defaultLocale, type Locale } from "@/lib/i18n/config"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate"; @@ -37,8 +38,17 @@ const narrativeQuerySchema = z.object({ /** A narrative read this recently is considered fresh; no warm is enqueued. */ const NARRATIVE_FRESH_MS = 20 * 60 * 60 * 1000; -function narrowLocale(locale: string): "de" | "en" { - return locale === "en" ? "en" : "de"; +/** + * The narrative row is keyed `(user, period, locale)` across the full UI + * locale union, so the resolved locale is carried through as-is. + * + * This used to be `locale === "en" ? "en" : "de"` — which sent every French, + * Spanish, Italian and Polish reader to the German narrative row and the + * German prompt behind it. `resolveServerLocale` already returns a validated + * `Locale`; an unknown value falls back to the app default (`en`) there. + */ +function narrowLocale(locale: Locale): Locale { + return locales.includes(locale) ? locale : defaultLocale; } export const GET = apiHandler(async (request: NextRequest) => { diff --git a/src/lib/ai/coach/coach-memory-refresh-worker.ts b/src/lib/ai/coach/coach-memory-refresh-worker.ts index 1f93d7f4b..3867844f8 100644 --- a/src/lib/ai/coach/coach-memory-refresh-worker.ts +++ b/src/lib/ai/coach/coach-memory-refresh-worker.ts @@ -19,7 +19,10 @@ export async function runCoachMemoryRefresh( payload: CoachMemoryRefreshPayload, ): Promise { const { conversationId, userId } = payload; - const locale = payload.locale ?? "de"; + // A payload without a locale (an older queued job) composes ENGLISH memory, + // not German — the memory prose is model-facing context and English is the + // fallback body for every locale that has no reviewed one. + const locale = payload.locale ?? "en"; let summaryStatus = "error"; try { diff --git a/src/lib/ai/coach/coach-memory-shared.ts b/src/lib/ai/coach/coach-memory-shared.ts index 31fc00b2e..6fcebdd67 100644 --- a/src/lib/ai/coach/coach-memory-shared.ts +++ b/src/lib/ai/coach/coach-memory-shared.ts @@ -22,7 +22,7 @@ export const COACH_MEMORY_REFRESH_QUEUE = "coach-memory-refresh"; export interface CoachMemoryRefreshPayload { conversationId: string; userId: string; - /** Locale to compose the summary / facts prose in; defaults to "de". */ + /** Locale to compose the summary / facts prose in; defaults to "en". */ locale?: "de" | "en"; } diff --git a/src/lib/ai/coach/memory-snapshot.ts b/src/lib/ai/coach/memory-snapshot.ts index 5909dfa2f..4093b3e28 100644 --- a/src/lib/ai/coach/memory-snapshot.ts +++ b/src/lib/ai/coach/memory-snapshot.ts @@ -38,6 +38,7 @@ import { type BandTransition, } from "@/lib/insights/narrative/period-narrative"; import type { BaselineProfile } from "@/lib/insights/derived"; +import type { Locale } from "@/lib/i18n/config"; import { buildCoachFactsBlock } from "./facts"; import { buildCoachPlansBlock, @@ -138,7 +139,12 @@ export async function buildCoachMemoryBlock( userId: string, _profile: BaselineProfile, now: Date, - locale: "de" | "en", + /** + * The reader's UI locale — the narrative row is keyed by it, across the full + * six-locale union. Narrowing it here would read a row the narrative + * pipeline never writes for that user. + */ + locale: Locale, ): Promise { let priorNarrative: PriorNarrativeRecall | undefined; // Sub-source 1: the latest period-narrative headline + driver recall. diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index e7363b863..d93b12c2f 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -23,6 +23,7 @@ import { type CoachDataCluster, } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { locales, defaultLocale, type Locale } from "@/lib/i18n/config"; import { resolveGlucoseUnit } from "@/lib/glucose"; import type { SleepStageRow } from "@/lib/analytics/sleep-night"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; @@ -339,9 +340,13 @@ async function buildCoachSnapshotImpl( ); const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson); // Resolve the UI locale for the rolling-profile narrative recall. The - // narrative rows are keyed by ("de" | "en"); default to "de" (the app - // default locale) when the user never picked one. - const coachLocale: "de" | "en" = prefsRow?.locale === "en" ? "en" : "de"; + // narrative rows are keyed by the full UI locale union, so the stored value + // is carried through as-is; an unset or unknown value falls back to the app + // default (`en`), never to German. The former `=== "en" ? "en" : "de"` + // binary made a French account recall a German narrative row. + const coachLocale: Locale = locales.includes(prefsRow?.locale as Locale) + ? (prefsRow?.locale as Locale) + : defaultLocale; const clusterDefault = clusterSourcesFromPrefs(prefs.dataClusters); const { sources: scopedSources, window } = resolveScope( scope, diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index c1ee08a27..5ce296350 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -1046,10 +1046,12 @@ function localisedMeasurementLabel( return resolved === key ? type.replace(/_/g, " ").toLowerCase() : resolved; } -/** Narrow a 6-locale `Locale` to the de/en the narrative pipeline generates. */ -function narrativeLocale(locale: Locale): "de" | "en" { - return locale === "de" ? "de" : "en"; -} +/** + * The narrative row is keyed by the full six-locale union — the pipeline + * generates and stores a row per UI locale — so the briefing memory recall + * reads the caller's locale directly. This used to narrow to de/en, which + * would now read a row a fr/es/it/pl account never has. + */ /** * Assemble the full snapshot in ONE `Promise.all`. Every sub-read is @@ -1079,8 +1081,8 @@ export async function buildDashboardSnapshot( /** * v1.21.2 — active locale for the A4 briefing memory prose. Defaults to * English; the route resolves it from the request (cookie / header / user - * preference). The recall headline is read locale-specific; non-de locales - * fall back to the English narrative read inside `buildCoachMemoryBlock`. + * preference). The recall headline is read locale-specific: the narrative + * row `buildCoachMemoryBlock` reads is keyed by this exact locale. */ locale?: Locale; /** @@ -1269,7 +1271,7 @@ export async function buildDashboardSnapshot( user.id, narrativeProfile, now, - narrativeLocale(locale), + locale, ), ).catch(() => null) : Promise.resolve(null), diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index 3d7df2654..41c862547 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -42,6 +42,12 @@ import { DETERMINISTIC_PROVIDER_TYPE, } from "@/lib/insights/narrative/period-narrative-deterministic"; import { composeSharedContracts } from "@/lib/ai/prompts/shared-contracts"; +import { + instructionLocale, + targetLanguageName, + withOutputLanguage, +} from "@/lib/ai/prompts/output-language"; +import type { Locale } from "@/lib/i18n/config"; import { validateNarrativeText, buildNarrativeCorrection, @@ -51,8 +57,15 @@ import { * Stable identifier for the narrative prompt revision. Bumped whenever the * prompt below changes so the cross-feature attribution aggregator can slice * quality per (provider × prompt) and a prompt change is observable. + * + * 1.14.0 — output language: French, Spanish, Italian and Polish readers used + * to fall to the German instruction body (the route narrowed every non-`en` + * locale to `de`), so they received German retrospectives. They now compose + * the English body with their language named in an added rule and the + * locale's own reply-language directive appended last. The German and English + * prompts are unchanged apart from this version string (test-pinned). */ -export const NARRATIVE_PROMPT_VERSION = "1.13.0" as const; +export const NARRATIVE_PROMPT_VERSION = "1.14.0" as const; /** A narrative cached this recently is served without regenerating. */ const NARRATIVE_FRESH_MS = 20 * 60 * 60 * 1000; @@ -67,8 +80,14 @@ export type NarrativeGenerateOutcome = interface GenerateOptions { /** Which period to narrate. */ period: NarrativePeriod; - /** Resolved UI locale for the prompt + row. */ - locale: "de" | "en"; + /** + * Resolved UI locale for the prompt + row. + * + * The full six-locale union: the row is keyed `(user, period, locale)`, so a + * French reader's French retrospective is stored under `fr` rather than + * mislabelled as German or English prose. + */ + locale: Locale; /** Skip the freshness short-circuit and force a fresh generation. */ force?: boolean; /** Injected clock for deterministic tests; defaults to now. */ @@ -105,7 +124,24 @@ function dateKeyFor(now: Date, tz: string): string { // ── prompt ────────────────────────────────────────────────────────────── -const SYSTEM_PROMPT_EN = `You summarise one person's health-tracking PERIOD (a week or a month) for that person. +/** + * Placeholder for the extra output-language rule inside the English body. + * + * Empty for `en` (whose body is written in the language it asks for), so the + * composed English prompt stays byte-stable; for the four locales that ride + * the English body it renders one more hard rule naming the reader's + * language. The German body carries no token — it names its language by being + * written in it. + */ +const LANGUAGE_RULE_TOKEN = "{{LANGUAGE_RULE}}"; + +function languageRule(locale: Locale): string { + return locale === "de" || locale === "en" + ? "" + : `\n- Write the summary in ${targetLanguageName(locale)}.`; +} + +const SYSTEM_PROMPT_EN_TEMPLATE = `You summarise one person's health-tracking PERIOD (a week or a month) for that person. Prompt version: ${NARRATIVE_PROMPT_VERSION}. Hard rules: @@ -115,7 +151,7 @@ Hard rules: - No diagnosis, no medical advice, no alarm. Calm, factual, second person ("your"). When the period earned it, name one genuine win plainly; keep it warm and second-person, never a reflexive compliment. - SHAPE: lead with the period's one-line verdict (the overall read in plain words), then connect the top movers and surviving associations into a short arc, and close on continuity or one watch-item — synthesize, do not list each metric in turn. - 2 to 4 short sentences. Plain text only — no markdown, no headings, no bullet points, no emojis. -- If the context is thin, say plainly that there is little to report this period rather than inventing detail. +- If the context is thin, say plainly that there is little to report this period rather than inventing detail.${LANGUAGE_RULE_TOKEN} ${composeSharedContracts("en", ["toneContract", "grounding", "openingShape", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller", "formattingContract"])}`; @@ -133,14 +169,33 @@ Feste Regeln: ${composeSharedContracts("de", ["toneContract", "grounding", "openingShape", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller", "formattingContract"])}`; +/** + * The system prompt for one reader. + * + * German readers compose the reviewed German body; every other locale + * composes the English body and is told, in its own language, which language + * to write in. The former `locale === "de" ? DE : EN` binary was fed by a + * route that narrowed every non-`en` locale to `de`, so French, Spanish, + * Italian and Polish readers received German retrospectives. + */ +export function buildNarrativeSystemPrompt(locale: Locale): string { + const body = + instructionLocale(locale) === "de" + ? SYSTEM_PROMPT_DE + : SYSTEM_PROMPT_EN_TEMPLATE.split(LANGUAGE_RULE_TOKEN).join( + languageRule(locale), + ); + return withOutputLanguage(body, locale); +} + /** * Test-only view of the composed system prompts (incl. the appended shared * contracts), so the cross-surface coverage test can assert fragment presence * without re-deriving the composition. */ export const SYSTEM_PROMPTS_FOR_TEST: Record<"de" | "en", string> = { - de: SYSTEM_PROMPT_DE, - en: SYSTEM_PROMPT_EN, + de: buildNarrativeSystemPrompt("de"), + en: buildNarrativeSystemPrompt("en"), }; /** Render the typed context into a compact, model-readable block. */ @@ -249,6 +304,10 @@ export async function generatePeriodNarrative( options: GenerateOptions, ): Promise { const { locale } = options; + // The de/en body language the user prompt, the deterministic fallback and + // the grounding correction are composed in. The ROW stays keyed by the full + // `locale`; only the prose scaffolding collapses to a reviewed body. + const bodyLocale = instructionLocale(locale); const force = options.force === true; const now = options.now ?? new Date(); const prisma = options.prisma ?? defaultPrisma; @@ -289,8 +348,8 @@ export async function generatePeriodNarrative( userId, cacheAction: `insights.narrative.${period}.${locale}`, consentSurface: "insights", - systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN, - userPrompt: buildNarrativeUserPrompt(context, locale), + systemPrompt: buildNarrativeSystemPrompt(locale), + userPrompt: buildNarrativeUserPrompt(context, bodyLocale), temperature: AI_BUDGETS.narrative.temperature, maxTokens: AI_BUDGETS.narrative.maxTokens, // v1.18.7 / v1.22 (W6) — the reference/diff path pins the deterministic @@ -363,7 +422,7 @@ export async function generatePeriodNarrative( // the same structured context, so the retrospective card is never empty for // an active account (iOS H2). A later AI warm overwrites this row in place. if (completion.kind === "none") { - const text = buildDeterministicNarrative(context, locale); + const text = buildDeterministicNarrative(context, bodyLocale); await persist(text, DETERMINISTIC_PROVIDER_TYPE); return { status: "generated", providerType: DETERMINISTIC_PROVIDER_TYPE }; } @@ -395,10 +454,10 @@ export async function generatePeriodNarrative( userId, cacheAction: `insights.narrative.${period}.${locale}`, consentSurface: "insights", - systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN, + systemPrompt: buildNarrativeSystemPrompt(locale), userPrompt: - buildNarrativeUserPrompt(context, locale) + - buildNarrativeCorrection(findings, locale), + buildNarrativeUserPrompt(context, bodyLocale) + + buildNarrativeCorrection(findings, bodyLocale), temperature: AI_BUDGETS.narrative.temperature, maxTokens: AI_BUDGETS.narrative.maxTokens, seed: REFERENCE_AI_SEED, @@ -430,7 +489,7 @@ export async function generatePeriodNarrative( /** The narrative row, decrypted for a read. */ export interface NarrativeRead { period: NarrativePeriod; - locale: "de" | "en"; + locale: Locale; text: string; dateKey: string; provenance: NarrativeProvenancePayload | null; @@ -448,7 +507,7 @@ export interface NarrativeRead { export async function readPeriodNarrative( userId: string, period: NarrativePeriod, - locale: "de" | "en", + locale: Locale, prisma: PrismaClient = defaultPrisma, ): Promise { const row = await prisma.insightNarrative.findUnique({ @@ -475,7 +534,7 @@ export async function readPeriodNarrative( return { period: row.period as NarrativePeriod, - locale: row.locale as "de" | "en", + locale: row.locale as Locale, text, dateKey: row.dateKey, provenance, diff --git a/src/lib/jobs/period-narrative-shared.ts b/src/lib/jobs/period-narrative-shared.ts index c6829e148..8c2ee54db 100644 --- a/src/lib/jobs/period-narrative-shared.ts +++ b/src/lib/jobs/period-narrative-shared.ts @@ -14,6 +14,7 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative"; +import type { Locale } from "@/lib/i18n/config"; export const PERIOD_NARRATIVE_QUEUE = "period-narrative-warm"; @@ -33,8 +34,8 @@ export interface PeriodNarrativePayload { userId?: string; /** Period to warm on the single-user path. */ period?: NarrativePeriod; - /** Locale to warm; defaults to "de" when absent. */ - locale?: "de" | "en"; + /** Locale to warm; defaults to the app default (`en`) when absent. */ + locale?: Locale; } /** @@ -47,7 +48,7 @@ export interface PeriodNarrativePayload { export async function enqueueNarrativeWarm(payload: { userId: string; period: NarrativePeriod; - locale: "de" | "en"; + locale: Locale; }): Promise { const boss = getGlobalBoss(); if (!boss) return; diff --git a/src/lib/jobs/period-narrative-warm.ts b/src/lib/jobs/period-narrative-warm.ts index 3d72e43cd..785b10739 100644 --- a/src/lib/jobs/period-narrative-warm.ts +++ b/src/lib/jobs/period-narrative-warm.ts @@ -28,6 +28,7 @@ import { type NarrativeGenerateOutcome, } from "@/lib/insights/narrative/period-narrative-generate"; import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative"; +import { locales, defaultLocale, type Locale } from "@/lib/i18n/config"; import { PERIOD_NARRATIVE_QUEUE, PERIOD_NARRATIVE_CRON, @@ -128,11 +129,16 @@ export async function findNarrativeCandidates( } /** - * Non-German locales resolve to ENGLISH (matching - * `status-shared.normalizeLocale` and the no-key fallback routing). + * The user's stored locale, validated against the shipped UI locale union. + * + * The narrative row is keyed `(user, period, locale)`, so the nightly warm has + * to write the SAME key the read route reads. Collapsing fr/es/it/pl to `en` + * here would leave those users' rows permanently cold on the nightly path. + * An unset or unknown value falls back to the app default (`en`), never to + * German. */ -function normalizeLocale(value: string | null): "de" | "en" { - return value === "de" ? "de" : "en"; +function normalizeLocale(value: string | null): Locale { + return locales.includes(value as Locale) ? (value as Locale) : defaultLocale; } /** @@ -254,7 +260,7 @@ export async function warmOneNarrative( if (!flags.briefing && !flags.insightStatus) return null; return generatePeriodNarrative(payload.userId, { period: payload.period, - locale: payload.locale ?? "de", + locale: payload.locale ?? defaultLocale, force: true, }); } From 93738ae9688091a689fdd1068fdf4fab9c6768d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:32:49 +0200 Subject: [PATCH 2/3] test(insights): pin the period-narrative and coach output-language contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layers. The German and English narrative prompts are frozen by content hash, normalised for the prompt-version string that a prompt change is required to move — any other drift fails. The four riding locales must name their language, carry the verbatim reply-language directive from their own safety-contract file as the last instruction, and contain no German-body sentinel. A generator-level test drives a French run end to end and asserts the row is keyed fr, the cache action is locale-scoped, and the system prompt reaches the provider in English with the French directive. Alongside it a source-level ban on the exact shape the bug took (`=== "en" ? "en" : "de"`, `?? "de"`) across the coach and narrative surfaces, with a self-check so a typo in the pattern cannot turn the guard into a silent pass, and coach-memory recall cases for all four locales. --- .../coach-locale-no-german-default.test.ts | 76 +++++++ .../coach/__tests__/memory-snapshot.test.ts | 15 ++ .../period-narrative-generate.test.ts | 2 +- .../__tests__/period-narrative-locale.test.ts | 215 ++++++++++++++++++ 4 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts create mode 100644 src/lib/insights/narrative/__tests__/period-narrative-locale.test.ts diff --git a/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts b/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts new file mode 100644 index 000000000..0572940cf --- /dev/null +++ b/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts @@ -0,0 +1,76 @@ +/** + * Regression-class ban for the German-default locale binary. + * + * The bug this pins had exactly one shape: `locale === "en" ? "en" : "de"` + * (and its `=== "de" ? ... : ...` inversions with a `de` fallback). Every + * occurrence sent French, Spanish, Italian and Polish accounts down the German + * branch. This walks the source of the surfaces that carried it and fails on + * the shape itself, so a future edit cannot quietly reintroduce it. + * + * Scoped to the files this change owns — the coach memory refresh, the coach + * snapshot's narrative recall, the narrative route + generator + warm, and the + * dashboard briefing recall. Blunt by design: it matches text, not behaviour, + * which is precisely what makes it survive a refactor of the logic around it. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const ROOT = resolve(__dirname, "../../../.."); + +const GUARDED_FILES = [ + "app/api/insights/chat/route.ts", + "app/api/insights/narrative/route.ts", + "lib/ai/coach/snapshot.ts", + "lib/ai/coach/memory-snapshot.ts", + "lib/ai/coach/coach-memory-refresh-worker.ts", + "lib/dashboard/snapshot.ts", + "lib/insights/narrative/period-narrative-generate.ts", + "lib/jobs/period-narrative-warm.ts", + "lib/jobs/period-narrative-shared.ts", +]; + +/** + * A ternary whose FALSE branch is the German literal — the de-default shape. + * Deliberately narrow: it must not flag `locale === "de" ? deBody : enBody`, + * which is the correct polarity (German only for German readers). + */ +const DE_DEFAULT_TERNARY = /\?\s*"[a-z]{2}"\s*:\s*"de"/; + +/** `payload.locale ?? "de"` and friends — a German default by omission. */ +const DE_DEFAULT_NULLISH = /\?\?\s*"de"/; + +/** + * Read a guarded file with comments stripped. + * + * The comments at these call sites deliberately QUOTE the banned shape to + * explain what was fixed; scanning them would make the guard self-tripping. + * Only executable source is checked. + */ +function readCode(rel: string): string { + return readFileSync(resolve(ROOT, rel), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); +} + +describe("no German-default locale binary", () => { + it.each(GUARDED_FILES)("%s carries no de-default ternary", (rel) => { + expect(readCode(rel)).not.toMatch(DE_DEFAULT_TERNARY); + }); + + it.each(GUARDED_FILES)("%s carries no de-default fallback", (rel) => { + expect(readCode(rel)).not.toMatch(DE_DEFAULT_NULLISH); + }); + + it("recognises the shape it bans", () => { + // Guard the guard: the patterns must actually match the bug they describe, + // so a typo cannot turn this file into a silent pass. + expect(`locale === "en" ? "en" : "de"`).toMatch(DE_DEFAULT_TERNARY); + expect(`payload.locale ?? "de"`).toMatch(DE_DEFAULT_NULLISH); + // ...and must NOT match the correct polarity. + expect(`instructionLocale(locale)`).not.toMatch(DE_DEFAULT_TERNARY); + expect(`locale === "de" ? DE_BODY : EN_BODY`).not.toMatch( + DE_DEFAULT_TERNARY, + ); + }); +}); diff --git a/src/lib/ai/coach/__tests__/memory-snapshot.test.ts b/src/lib/ai/coach/__tests__/memory-snapshot.test.ts index ecc316bc5..7b620f5a2 100644 --- a/src/lib/ai/coach/__tests__/memory-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/memory-snapshot.test.ts @@ -168,4 +168,19 @@ describe("buildCoachMemoryBlock", () => { await buildCoachMemoryBlock(USER, PROFILE, NOW, "en"); expect(readPeriodNarrative).toHaveBeenCalledWith(USER, "month", "en"); }); + + // The narrative row is keyed by the full UI locale union. A French account's + // recall must read the `fr` row — narrowing it to `de` (the old behaviour) + // recalled a German narrative, and narrowing it to `en` would read a row + // that account never has. + it.each(["fr", "es", "it", "pl"] as const)( + "reads the %s narrative row without narrowing the locale", + async (locale) => { + readPeriodNarrative.mockResolvedValue(null); + buildPeriodNarrativeContext.mockResolvedValue(readyContext([])); + + await buildCoachMemoryBlock(USER, PROFILE, NOW, locale); + expect(readPeriodNarrative).toHaveBeenCalledWith(USER, "month", locale); + }, + ); }); diff --git a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts index 04f0b6bcd..12fec86c8 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts @@ -115,7 +115,7 @@ describe("buildNarrativeUserPrompt", () => { expect(prompt).toContain("WEIGHT"); expect(prompt).toContain("ACTIVITY_STEPS ~ SLEEP_DURATION"); expect(prompt).toContain("12 pairs tested"); - expect(NARRATIVE_PROMPT_VERSION).toBe("1.13.0"); + expect(NARRATIVE_PROMPT_VERSION).toBe("1.14.0"); }); }); diff --git a/src/lib/insights/narrative/__tests__/period-narrative-locale.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-locale.test.ts new file mode 100644 index 000000000..c2531088e --- /dev/null +++ b/src/lib/insights/narrative/__tests__/period-narrative-locale.test.ts @@ -0,0 +1,215 @@ +/** + * Output-language contract for the PERIOD NARRATIVE surface. + * + * The bug this pins: the read route narrowed every non-English locale to `de`, + * so a French, Spanish, Italian or Polish reader received a German + * retrospective composed from the German instruction body. The fix keeps the + * two reviewed bodies (de, en) and lets the other four ride the English body + * with their language named and their OWN reply-language directive appended. + * + * Three things are asserted here, in ascending strength: + * 1. the four locales name their language and carry the verbatim directive + * from the translator-facing `safety-contracts..yaml`; + * 2. no non-German prompt contains a German-body sentinel — the "nothing + * silently falls back to German" assertion this whole change exists for; + * 3. the German and English prompts are byte-identical to their pre-change + * form, modulo the prompt-version string, pinned by content hash. + */ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc:${s}`, + decrypt: (s: string) => s.replace(/^enc:/, ""), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { createHash } from "node:crypto"; +import { + buildNarrativeSystemPrompt, + generatePeriodNarrative, + NARRATIVE_PROMPT_VERSION, +} from "@/lib/insights/narrative/period-narrative-generate"; +import { loadSafetyContracts } from "@/lib/ai/prompts/safety-contracts"; +import { locales, type Locale } from "@/lib/i18n/config"; +import type { PeriodNarrativeContext } from "@/lib/insights/narrative/period-narrative"; + +/** The four locales that ride the English body. */ +const RIDING_LOCALES: Locale[] = ["fr", "es", "it", "pl"]; + +const LANGUAGE_NAMES: Record = { + fr: "French", + es: "Spanish", + it: "Italian", + pl: "Polish", +}; + +/** + * Sentinels that only ever appear in the GERMAN instruction body. A hit on a + * non-German prompt means that locale fell back to German — the exact bug. + */ +const GERMAN_SENTINELS = [ + "AUSGABEFORMAT", + "Antworte", + "auf Deutsch", + "Feste Regeln", + "Prompt-Version", +]; + +/** + * sha256 of the composed prompt with the version string normalised away. + * + * The version is deliberately excluded: bumping `NARRATIVE_PROMPT_VERSION` is + * a required part of any prompt change and is the ONE byte the de/en prompts + * are allowed to move by. Everything else is frozen. + */ +function promptHash(locale: "de" | "en"): string { + const normalised = buildNarrativeSystemPrompt(locale) + .split(NARRATIVE_PROMPT_VERSION) + .join("VERSION"); + return createHash("sha256").update(normalised, "utf8").digest("hex"); +} + +/** + * Hashes captured from the prompts as they stood at NARRATIVE_PROMPT_VERSION + * 1.13.0, before the output-language change. If either moves, the de or en + * prompt drifted — which this change promised would not happen. + */ +const FROZEN_PROMPT_HASHES: Record<"de" | "en", string> = { + de: "be667f16beeb44f51a81fedd929716b2249579fe914b45bb8cba0cd9e64a8c6d", + en: "bb0b080e21a2b9729bdf0047ae221c326ec29ecd74d0999f19ea8deabcbf078a", +}; + +describe("period-narrative output language", () => { + it("keeps the German and English prompts byte-identical modulo the version", () => { + expect(promptHash("de")).toBe(FROZEN_PROMPT_HASHES.de); + expect(promptHash("en")).toBe(FROZEN_PROMPT_HASHES.en); + }); + + it("appends no directive to the two reviewed bodies", () => { + expect(buildNarrativeSystemPrompt("de")).not.toContain("OUTPUT LANGUAGE:"); + expect(buildNarrativeSystemPrompt("en")).not.toContain("OUTPUT LANGUAGE:"); + // The English body must not grow a language rule for its own locale. + expect(buildNarrativeSystemPrompt("en")).not.toContain( + "Write the summary in English.", + ); + }); + + it.each(RIDING_LOCALES)( + "names the language and carries the verbatim directive for %s", + (locale) => { + const prompt = buildNarrativeSystemPrompt(locale); + const directive = loadSafetyContracts(locale).reply_language_directive; + + expect(prompt).toContain( + `Write the summary in ${LANGUAGE_NAMES[locale]}.`, + ); + expect(prompt).toContain(`OUTPUT LANGUAGE: ${directive}`); + // The directive is the LAST instruction the model reads. + expect(prompt.trimEnd().endsWith(directive.trimEnd())).toBe(true); + // The scaffolding is the English body, not a translation of it. + expect(prompt).toContain("Hard rules:"); + }, + ); + + it("specifically composes the French prompt in English with the French directive", () => { + const prompt = buildNarrativeSystemPrompt("fr"); + expect(prompt).toContain("Write the summary in French."); + expect(prompt).toContain( + loadSafetyContracts("fr").reply_language_directive, + ); + expect(prompt).toContain( + "You summarise one person's health-tracking PERIOD", + ); + }); + + it.each(locales.filter((l) => l !== "de"))( + "leaks no German instruction body into the %s prompt", + (locale) => { + const prompt = buildNarrativeSystemPrompt(locale); + for (const sentinel of GERMAN_SENTINELS) { + expect(prompt).not.toContain(sentinel); + } + }, + ); + + it("keeps the German prompt German", () => { + const prompt = buildNarrativeSystemPrompt("de"); + expect(prompt).toContain("Feste Regeln:"); + expect(prompt).toContain("Prompt-Version:"); + }); +}); + +describe("period-narrative locale storage", () => { + function readyContext(): PeriodNarrativeContext { + return { + status: "ready", + period: "week", + metricDeltas: [ + { + type: "WEIGHT", + unit: "kg", + current: 80, + prior: 81, + delta: -1, + deltaPercent: -1.2, + currentDays: 6, + priorDays: 7, + }, + ], + bandTransitions: [], + drivers: [], + coincidentFlags: [], + pairsTested: 12, + fdrQ: 0.1, + provenance: { + metrics: ["WEIGHT"], + window: { + from: "2026-05-01T00:00:00.000Z", + to: "2026-05-15T00:00:00.000Z", + }, + computedAt: "2026-05-15T05:00:00.000Z", + }, + }; + } + + it("keys the row by the reader's own locale and prompts in their language", async () => { + const upsert = vi.fn(async (args: { create: { locale: string } }) => args); + const prisma = { + user: { findUnique: vi.fn(async () => ({ timezone: "Europe/Berlin" })) }, + insightNarrative: { findUnique: vi.fn(async () => null), upsert }, + }; + let capturedSystemPrompt = ""; + const runCompletion = vi.fn(async (args: { systemPrompt: string }) => { + capturedSystemPrompt = args.systemPrompt; + return { + kind: "ok" as const, + content: "Votre semaine.", + providerType: "mock", + }; + }); + + const outcome = await generatePeriodNarrative("user-1", { + period: "week", + locale: "fr", + force: true, + buildContext: vi.fn(async () => readyContext()) as never, + runCompletion: runCompletion as never, + prisma: prisma as never, + }); + + expect(outcome).toEqual({ status: "generated", providerType: "mock" }); + // The row is stored under `fr` — NOT collapsed into the de or en row, so a + // French retrospective is never served to a German or English reader. + expect(upsert.mock.calls[0]?.[0]).toMatchObject({ + create: { locale: "fr" }, + where: { userId_period_locale: { locale: "fr" } }, + }); + expect(capturedSystemPrompt).toContain("Write the summary in French."); + expect(capturedSystemPrompt).not.toContain("AUSGABEFORMAT"); + // The provider cache action is locale-scoped too. + expect(runCompletion.mock.calls[0]?.[0]).toMatchObject({ + cacheAction: "insights.narrative.week.fr", + }); + }); +}); From d6692e3a4b7efa351c2ed7c96a93e690d0db3e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:46:34 +0200 Subject: [PATCH 3/3] fix(coach): compose deterministic memory facts in English by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deterministic fact-pattern pass fell back to German when no locale was passed, the same de-default polarity the narrative surface just lost. Fact text is model-facing memory, so English is the fallback for every locale without a reviewed body; German stays for German readers. Also correct the InsightNarrative.locale doc comment — the column holds any shipped UI locale, not just de/en. Comment only, no SQL change. --- prisma/schema.prisma | 3 ++- src/lib/ai/coach/facts.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca42d421f..f62e1a639 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5226,7 +5226,8 @@ model InsightNarrative { userId String @map("user_id") /// "week" | "month". period String - /// Resolved UI locale of the prose ("de" | "en"). + /// Resolved UI locale of the prose — any shipped UI locale, not just de/en: + /// the prose is generated per locale, so the row is keyed by it. locale String /// UTC YYYY-MM-DD boundary the narrative was generated for. dateKey String @map("date_key") diff --git a/src/lib/ai/coach/facts.ts b/src/lib/ai/coach/facts.ts index c210823f1..265598d64 100644 --- a/src/lib/ai/coach/facts.ts +++ b/src/lib/ai/coach/facts.ts @@ -558,7 +558,10 @@ function deterministicFactText( subject: string, locale: string | undefined, ): string { - const de = locale?.toLowerCase().startsWith("de") ?? true; + // German only for a German reader; every other locale — and an absent one — + // composes ENGLISH. The fact text is model-facing memory, so English is the + // fallback body; the previous `?? true` made an unknown locale German. + const de = locale?.toLowerCase().startsWith("de") ?? false; switch (kind) { case "allergy": return de