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; +}