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, }); }