fix(insights): stop sending German period narratives to fr/es/it/pl readers

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 23:25:44 +02:00
1 parent 2032b74fc6
commit e4ec0eec49
10 files changed
+138 -42

No files matched your search

+7 -3
View File
@@ -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<Response> {
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,
+12 -2
View File
@@ -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) => {
@@ -19,7 +19,10 @@ export async function runCoachMemoryRefresh(
payload: CoachMemoryRefreshPayload,
): Promise<void> {
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 {
+1 -1
View File
@@ -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";
}
+7 -1
View File
@@ -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<CoachMemoryBlock | null> {
let priorNarrative: PriorNarrativeRecall | undefined;
// Sub-source 1: the latest period-narrative headline + driver recall.
+8 -3
View File
@@ -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,
+9 -7
View File
@@ -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),
@@ -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<NarrativeGenerateOutcome> {
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<NarrativeRead | null> {
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,
+4 -3
View File
@@ -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<void> {
const boss = getGlobalBoss();
if (!boss) return;
+11 -5
View File
@@ -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,
});
}