diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index d06c0f108..8aa773ecd 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -19,6 +19,7 @@ import type { Locale } from "@/lib/i18n/config"; import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; import { buildNativeCoachPrompt } from "@/lib/ai/prompts/native-prompts"; +import { instructionLocale } from "@/lib/ai/prompts/output-language"; import { learnCatalogPromptBlock } from "./learn-catalog"; import { DEFAULT_COACH_PREFS, @@ -1224,7 +1225,7 @@ function buildCoachV122Addendum( locale: Locale, personalization: CoachPersonalization, ): string { - const isDe = locale === "de"; + const isDe = instructionLocale(locale) === "de"; const parts: string[] = []; parts.push(isDe ? "COACH-ZUSATZ (v1.22)" : "COACH ADDENDUM (v1.22)"); @@ -1347,7 +1348,11 @@ ${fenceSelfReport(aboutMe)}`; */ function buildPrefsPrefix(locale: Locale, prefs: CoachPrefs): string { const parts: string[] = []; - const isEn = locale === "en"; + // These override lines are internal instructions, so they follow the same + // routing as every other prompt body: German only for German readers, + // English for everyone else. The former `locale === "en"` sent a French + // Coach prompt a German tone override on top of its French body. + const isEn = instructionLocale(locale) === "en"; // Tone if (prefs.tone === "neutral") { diff --git a/src/lib/ai/prompts/__tests__/output-language-contract.test.ts b/src/lib/ai/prompts/__tests__/output-language-contract.test.ts index 4bfd05e25..f71d64db0 100644 --- a/src/lib/ai/prompts/__tests__/output-language-contract.test.ts +++ b/src/lib/ai/prompts/__tests__/output-language-contract.test.ts @@ -128,8 +128,10 @@ describe("base system prompt — every locale", () => { it("ends the four non-de/en prompts with their own directive", () => { for (const locale of ["fr", "es", "it", "pl"] as const) { + // `[\s\S]` rather than `.` + the `s` flag — the compile target predates + // dotAll, and the directive can span lines. expect(getBaseSystemPrompt(locale).trimEnd()).toMatch( - /OUTPUT LANGUAGE: .+$/s, + /OUTPUT LANGUAGE: [\s\S]+$/, ); } }); diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index 29652ed8c..a2f388e92 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -22,6 +22,11 @@ */ import { prisma } from "@/lib/db"; import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system"; +import { + instructionLocale, + withOutputLanguage, +} from "@/lib/ai/prompts/output-language"; +import type { Locale } from "@/lib/i18n/config"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; @@ -82,8 +87,21 @@ function insufficient(): BiomarkerStatusResult { }; } -function getSystemPrompt(locale: SupportedLocale, markerName: string): string { - if (locale === "de") { +/** + * Unlike every other assessment prompt in the family, this one does NOT + * compose `getBaseSystemPrompt` — it is a self-contained inline scaffold. So + * the reader's output-language directive, which the base prompt would have + * carried, has to be appended here. When this prompt moves onto the shared + * base scaffold the append collapses into the shared path and this call goes + * away; until then it is the only thing making a non-de/en reader's language + * explicit on this surface. + * + * The parameter is the full `Locale` rather than `SupportedLocale` so the + * routing is correct the moment the pipeline stops collapsing fr/es/it/pl on + * the way in; today's callers still pass a narrowed locale. + */ +function getSystemPrompt(locale: Locale, markerName: string): string { + if (instructionLocale(locale) === "de") { return [ `Du bist ein vorsichtiger, faktenbasierter Gesundheitsbegleiter und beurteilst den Laborwert „${markerName}" einer Person.`, "Beziehe dich AUSSCHLIESSLICH auf die im Snapshot gelieferten Zahlen. Erfinde keine Werte, keine Diagnosen und keine Medikamente.", @@ -92,21 +110,24 @@ function getSystemPrompt(locale: SupportedLocale, markerName: string): string { 'Antworte als JSON-Objekt der Form { "summary": "…" } ohne weiteren Text.', ].join(" "); } - return [ - `You are a careful, fact-based health companion assessing a person's lab marker "${markerName}".`, - "Use ONLY the figures provided in the snapshot. Do not invent values, diagnoses, or medications.", - "Write 2 to 4 calm sentences in plain language: state the current value and how it sits against the reference range, place the trend across recent readings, and add at most one factual pointer.", - "No alarmism, no marketing tone, no commands. For notably out-of-range values, refer neutrally to a clinician for interpretation.", - 'Respond as a JSON object of the form { "summary": "…" } with no other text.', - ].join(" "); + return withOutputLanguage( + [ + `You are a careful, fact-based health companion assessing a person's lab marker "${markerName}".`, + "Use ONLY the figures provided in the snapshot. Do not invent values, diagnoses, or medications.", + "Write 2 to 4 calm sentences in plain language: state the current value and how it sits against the reference range, place the trend across recent readings, and add at most one factual pointer.", + "No alarmism, no marketing tone, no commands. For notably out-of-range values, refer neutrally to a clinician for interpretation.", + 'Respond as a JSON object of the form { "summary": "…" } with no other text.', + ].join(" "), + locale, + ); } function getUserPrompt( snapshotJson: string, todayKey: string, - locale: SupportedLocale, + locale: Locale, ): string { - return locale === "de" + return instructionLocale(locale) === "de" ? `Heutiges Datum: ${todayKey}. Beurteile den folgenden Laborwert-Snapshot:\n${snapshotJson}` : `Today's date: ${todayKey}. Assess the following lab-marker snapshot:\n${snapshotJson}`; } diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts index 1a9d72ebd..9ee22913f 100644 --- a/src/lib/insights/derived/derived-assessment-ai.ts +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -25,6 +25,7 @@ import { getBaseSystemPrompt, PROMPT_VERSION, } from "@/lib/ai/prompts/base-system"; +import { instructionLocale } from "@/lib/ai/prompts/output-language"; import { normalizeLocale, normalizeSummaryText, @@ -87,10 +88,20 @@ const SCORE_INPUT_TYPES: readonly MeasurementType[] = [ // ── prompt ──────────────────────────────────────────────────────────────── -function scoreSystemPrompt(locale: SupportedLocale): string { +/** + * The archetype section rides on top of `getBaseSystemPrompt`, which already + * appends the reader's own output-language directive — so this composer must + * NOT append one of its own. It only picks the instruction body: German for + * German readers, English for every other locale. + * + * The parameter is the full `Locale` rather than `SupportedLocale` so the + * routing is correct the moment the pipeline stops collapsing fr/es/it/pl on + * the way in; today's callers still pass a narrowed locale. + */ +function scoreSystemPrompt(locale: Locale): string { const base = getBaseSystemPrompt(locale); const section = - locale === "en" + instructionLocale(locale) === "en" ? `ARCHETYPE — COMPOSITE WELLNESS SCORE (write like a premium recovery coach — WHOOP / Oura — who genuinely wants this person to do well). This is a 0–100 composite, not a raw measurement. Weave these FOUR beats into varied, connected prose (NOT a fixed template, NOT a labelled list) — lead per the OPENER HINT if one is given: - STANDING — the score and its band, in one short clause. - WHAT DROVE IT — name the contributor(s) in \`signal.contributors[]\` that HELPED (the strongest, highest values) AND the one(s) that HURT (the weakest, lowest values), so a good score earns its win and a soft score is explained honestly. Each contributor is itself 0–100; a low value drags the score down, a high one carries it. Never invent a contributor that is not listed. When none are listed, use the trend (signal.delta) instead. @@ -110,7 +121,7 @@ function scoreUserPrompt( signal: MetricSignal, band: string, todayKey: string, - locale: SupportedLocale, + locale: Locale, openerHint: string, /** v1.30.3 (QA F5) — the user's own IANA tz; was hardcoded "Europe/Berlin". */ tz: string, @@ -120,7 +131,7 @@ function scoreUserPrompt( null, 2, ); - if (locale === "en") { + if (instructionLocale(locale) === "en") { return `Date: ${todayKey} (${tz}) OPENER HINT: ${openerHint} Write an assessment of ${signal.metric} today across the four beats — the standing, what helped AND what hurt it, what the band means for the day, and one grounded nudge. Aim for 3–5 sentences, roughly 45–75 words (this overrides the shorter base length cap). Connected prose, not a checklist.