From ec4e3adfdcbcbf8db71a86167054b95c9200d2e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:13:34 +0200 Subject: [PATCH] fix(ai): write assessments in the reader's language, not German getBaseSystemPrompt collapsed every non-English locale to the German instruction body, whose output clause asks for a German assessment. A French, Spanish, Italian or Polish reader therefore received German prose on any surface that reached this prompt. Those four locales now compose the English instruction body with their language named in the output clause, and the locale's own reply-language directive - already carried in production by the native insights and briefing prompts - appended last. German and English compose exactly as before, byte for byte. Adds the shared output-language helper the remaining prompt modules adopt, replacing the scattered en-or-else-German binaries. --- src/lib/ai/prompts/base-system.ts | 52 +++++++++++-- src/lib/ai/prompts/output-language.ts | 107 ++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 src/lib/ai/prompts/output-language.ts diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index 8140acaa4..6f16d1576 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -11,6 +11,19 @@ import { forbiddenFiller, formattingContract, } from "./shared-contracts"; +import { + instructionLocale, + targetLanguageName, + withOutputLanguage, +} from "./output-language"; + +/** + * Placeholder for the reply language inside the English output clause. + * + * Kept as a token (rather than a template literal in the section array) so the + * section array stays a plain data structure the parity tests can iterate. + */ +const OUTPUT_LANGUAGE_TOKEN = "{{OUTPUT_LANGUAGE}}"; /** * Version of the per-metric assessment base prompt + its signal contract. @@ -42,8 +55,13 @@ import { * rotation. This activates the dormant "do NOT open with the number" branch so * the per-metric card reads as warm as the overview. Clause ORDER only — * grounding, non-diagnostic framing, and every safety contract are unchanged. + * 6.2.0 — output language: French, Spanish, Italian and Polish readers used to + * fall to the German instruction body, whose output clause asks for German + * prose. They now compose the English body with their language named in the + * output clause and the locale's own reply-language directive appended last. + * The German and English prompts are byte-identical to 6.1.0 (test-pinned). */ -export const PROMPT_VERSION = "6.1.0" as const; +export const PROMPT_VERSION = "6.2.0" as const; /** * Base system prompt for the per-metric Insights *assessment* cards. @@ -245,16 +263,30 @@ Synthese statt Aufzählung: die Geschichte dessen, was die Daten bedeuten, zähl }, { id: "output", - en: `OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The "summary" field holds the complete assessment in English: 1-3 short paragraphs of 1-3 sentences each, separated by a blank line written as \\n\\n INSIDE the JSON string. A steady one-liner stays a single paragraph: + // The language name is interpolated so a locale riding the English + // instruction body still asks for prose in the reader's own language. For + // `en` it renders "English" — byte-identical to the pre-6.2.0 literal. + en: `OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The "summary" field holds the complete assessment in ${OUTPUT_LANGUAGE_TOKEN}: 1-3 short paragraphs of 1-3 sentences each, separated by a blank line written as \\n\\n INSIDE the JSON string. A steady one-liner stays a single paragraph: { "summary": "..." }`, de: `AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. Das Feld "summary" enthält die komplette Einschätzung auf Deutsch: 1-3 kurze Absätze mit je 1-3 Sätzen, getrennt durch eine Leerzeile als \\n\\n INNERHALB des JSON-Strings. Ein stabiler Einzeiler bleibt EIN Absatz: { "summary": "..." }`, }, ]; -/** The locale text fragments are joined by a blank line, in section order. */ -function composeAssessmentPrompt(locale: "en" | "de"): string { - return ASSESSMENT_SECTIONS.map((s) => s[locale]).join("\n\n"); +/** + * The locale text fragments are joined by a blank line, in section order. + * + * `languageName` fills the output clause's language token on the English body. + * The German body names its language natively and carries no token, so the + * replacement is a no-op there. + */ +function composeAssessmentPrompt( + instructionBody: "en" | "de", + languageName: string, +): string { + return ASSESSMENT_SECTIONS.map((s) => + s[instructionBody].split(OUTPUT_LANGUAGE_TOKEN).join(languageName), + ).join("\n\n"); } /** @@ -272,5 +304,13 @@ export const ASSESSMENT_SECTION_PAIRS: readonly { }[] = ASSESSMENT_SECTIONS; export function getBaseSystemPrompt(locale: Locale): string { - return composeAssessmentPrompt(locale === "en" ? "en" : "de"); + // German readers compose the German body; every other locale composes the + // English body and is told, in its own language, which language to write in. + // The former `locale === "en" ? "en" : "de"` sent French, Spanish, Italian + // and Polish readers a German prompt that asked for German prose. + const composed = composeAssessmentPrompt( + instructionLocale(locale), + targetLanguageName(locale), + ); + return withOutputLanguage(composed, locale); } diff --git a/src/lib/ai/prompts/output-language.ts b/src/lib/ai/prompts/output-language.ts new file mode 100644 index 000000000..4ba920db4 --- /dev/null +++ b/src/lib/ai/prompts/output-language.ts @@ -0,0 +1,107 @@ +/** + * Output-language resolution for the assessment prompt family. + * + * The problem this solves: the prompt modules historically branched + * `locale === "en" ? EN_BODY : DE_BODY`. That binary is correct for a de/en + * product, but HealthLog ships six UI locales — so every fr/es/it/pl reader + * fell to the GERMAN instruction body, whose output clause explicitly asks for + * a German assessment. A French account therefore received German prose (on + * the surfaces that reached the prompts at all). + * + * The fix keeps ONE instruction body per prompt in the two languages that have + * a reviewed body (de, en) and names the reader's language in an explicit + * directive, rather than growing four more hand-maintained translations of + * every prompt. The directive text itself is NOT invented here: it is the + * `reply_language_directive` from `safety-contracts..yaml`, written + * natively per locale and already carried in production by the native + * insights/briefing prompts (`native-prompts.ts:338,507`). This module only + * makes that same mechanism reachable from the assessment family. + * + * Polarity matters and is the whole point: the fallback for an unknown or + * non-German locale is ENGLISH, never German. `instructionLocale` is the + * single replacement for every scattered de-default binary. + */ +import type { Locale } from "@/lib/i18n/config"; + +import { loadSafetyContracts } from "./safety-contracts"; + +/** + * Which reviewed instruction body a locale composes. + * + * German for German readers (its body is reviewed and its register is the one + * the largest non-English audience reads); English for everyone else. The + * reader's actual language is then named by `outputLanguageDirective`. + */ +export function instructionLocale(locale: Locale): "de" | "en" { + return locale === "de" ? "de" : "en"; +} + +/** + * English name of the language the assessment must be WRITTEN in. + * + * English names because they are interpolated into the English instruction + * body; the directive that follows is in the reader's own language. + */ +export function targetLanguageName(locale: Locale): string { + switch (locale) { + case "de": + return "German"; + case "fr": + return "French"; + case "es": + return "Spanish"; + case "it": + return "Italian"; + case "pl": + return "Polish"; + default: + return "English"; + } +} + +/** + * Fallback directive when the safety matrix cannot be loaded. + * + * The matrix read is a synchronous file read + schema parse that throws on a + * malformed or missing file. A prompt that silently loses its language + * directive would regress exactly the bug this module fixes, so fall back to a + * plain English instruction naming the language rather than to an empty + * string. + */ +function fallbackDirective(locale: Locale): string { + return `Write your reply in ${targetLanguageName(locale)}.`; +} + +/** + * The terminal OUTPUT LANGUAGE section for a prompt, or `""` when the + * composed instruction body already carries the language natively. + * + * de and en compose their own reviewed body, whose output clause already names + * the language — appending a directive there would be redundant and would + * change two prompts that are deliberately byte-stable. The four locales that + * ride the English body get the directive, placed last so it is the most + * recent instruction the model reads. + */ +export function outputLanguageDirective(locale: Locale): string { + if (locale === "de" || locale === "en") return ""; + + let directive: string; + try { + directive = loadSafetyContracts(locale).reply_language_directive; + } catch { + directive = fallbackDirective(locale); + } + + return `OUTPUT LANGUAGE: ${directive}`; +} + +/** + * Append the directive to a composed prompt when one applies. + * + * Kept here so every adopting module joins it identically (blank-line + * separated, directive last) instead of each re-deriving the spacing. + */ +export function withOutputLanguage(prompt: string, locale: Locale): string { + const directive = outputLanguageDirective(locale); + return directive ? `${prompt}\n\n${directive}` : prompt; +}