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/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/__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/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/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 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/__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", + }); + }); +}); 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, }); }