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 01/13] 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; +} From 2032b74fc61f5638b676ed7d5d3eeeaeae1a7de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:14:45 +0200 Subject: [PATCH 02/13] test(ai): pin the output-language contract across all six locales The parity test joined raw fragments, which no longer matches now that the English output clause carries a language token; it substitutes the expected name from a literal map instead of from the module under test. Adds the contract test: which body each locale composes, that its own language is named, that its own translated directive is present and last, that no German reaches a non-German prompt, and that no two locales collapse to the same prompt. What it cannot show - whether the prose reads well in the four languages - is stated in the file. --- .../__tests__/locale-skeleton-parity.test.ts | 21 ++- .../output-language-contract.test.ts | 149 ++++++++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 src/lib/ai/prompts/__tests__/output-language-contract.test.ts diff --git a/src/lib/ai/prompts/__tests__/locale-skeleton-parity.test.ts b/src/lib/ai/prompts/__tests__/locale-skeleton-parity.test.ts index 1d4c97382..5d81e269c 100644 --- a/src/lib/ai/prompts/__tests__/locale-skeleton-parity.test.ts +++ b/src/lib/ai/prompts/__tests__/locale-skeleton-parity.test.ts @@ -40,13 +40,28 @@ describe("base-system assessment prompt — locale skeleton parity", () => { }); it("the composed prompt equals the fragments joined by a blank line", () => { + // The English output clause carries a language token so a locale riding + // the English body can name its own language. For these two locales the + // substitution is their own language name — written as a literal here, not + // read from the module under test, so the assertion stays independent. + const EXPECTED_LANGUAGE_NAME = { en: "English", de: "German" } as const; for (const locale of ["en", "de"] as const) { - const expected = ASSESSMENT_SECTION_PAIRS.map((s) => s[locale]).join( - "\n\n", - ); + const expected = ASSESSMENT_SECTION_PAIRS.map((s) => + s[locale] + .split("{{OUTPUT_LANGUAGE}}") + .join(EXPECTED_LANGUAGE_NAME[locale]), + ).join("\n\n"); expect(getBaseSystemPrompt(locale)).toBe(expected); } }); + + it("de and en carry no appended output-language directive", () => { + // Their bodies name the language natively; appending a directive would + // change two prompts that are deliberately byte-stable across this change. + for (const locale of ["en", "de"] as const) { + expect(getBaseSystemPrompt(locale)).not.toContain("OUTPUT LANGUAGE:"); + } + }); }); describe("insight system prompt — locale skeleton parity", () => { diff --git a/src/lib/ai/prompts/__tests__/output-language-contract.test.ts b/src/lib/ai/prompts/__tests__/output-language-contract.test.ts new file mode 100644 index 000000000..4bfd05e25 --- /dev/null +++ b/src/lib/ai/prompts/__tests__/output-language-contract.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; + +import { locales, type Locale } from "@/lib/i18n/config"; +import { getBaseSystemPrompt } from "@/lib/ai/prompts/base-system"; +import { + instructionLocale, + outputLanguageDirective, + targetLanguageName, + withOutputLanguage, +} from "@/lib/ai/prompts/output-language"; +import { loadSafetyContracts } from "@/lib/ai/prompts/safety-contracts"; + +/** + * The output-language contract. + * + * These tests exist because four of the six locales cannot be eyeballed at + * review time. They pin the mechanical guarantees — which body a locale + * composes, that its own language is named, that the locale's own directive is + * present and last, and that no German leaks into a non-German prompt. + * + * What they deliberately do NOT claim: that the model's prose is idiomatic or + * correct in French, Spanish, Italian or Polish. No offline test can show + * that. The precedent is the native insights/briefing prompts, which have + * carried the same directive in production, and the residual risk is covered + * by a production check, not by this file. + */ + +/** + * A word that appears in the German instruction body and in no English one. + * If it shows up in a non-German prompt, the de-default collapse is back. + */ +const GERMAN_SENTINEL = /AUSGABEFORMAT|Antworte ausschließlich|Einschätzung/; + +/** English names, written literally — never derived from the module under test. */ +const EXPECTED_LANGUAGE_NAME: Record = { + de: "German", + en: "English", + fr: "French", + es: "Spanish", + it: "Italian", + pl: "Polish", +}; + +/** Which body each locale is expected to compose. */ +const EXPECTED_BODY: Record = { + de: "de", + en: "en", + fr: "en", + es: "en", + it: "en", + pl: "en", +}; + +describe("output-language helper", () => { + it("routes only German to the German body — every other locale to English", () => { + for (const locale of locales) { + expect(instructionLocale(locale), `instruction body for ${locale}`).toBe( + EXPECTED_BODY[locale], + ); + } + }); + + it("names each locale's language", () => { + for (const locale of locales) { + expect(targetLanguageName(locale)).toBe(EXPECTED_LANGUAGE_NAME[locale]); + } + }); + + it("emits the locale's OWN directive for the four riding the English body", () => { + for (const locale of ["fr", "es", "it", "pl"] as const) { + const directive = outputLanguageDirective(locale); + expect(directive, `directive for ${locale}`).toContain( + "OUTPUT LANGUAGE:", + ); + // The directive text must be the locale's own translated clause, not a + // generic English one — that is what makes it authoritative to the model. + expect(directive).toContain( + loadSafetyContracts(locale).reply_language_directive, + ); + } + }); + + it("emits no directive for de/en (their bodies name the language natively)", () => { + expect(outputLanguageDirective("de")).toBe(""); + expect(outputLanguageDirective("en")).toBe(""); + }); + + it("appends the directive last, blank-line separated", () => { + const composed = withOutputLanguage("BODY", "fr"); + expect(composed.startsWith("BODY\n\n")).toBe(true); + expect( + composed.endsWith(loadSafetyContracts("fr").reply_language_directive), + ).toBe(true); + }); + + it("leaves a de/en prompt untouched", () => { + expect(withOutputLanguage("BODY", "de")).toBe("BODY"); + expect(withOutputLanguage("BODY", "en")).toBe("BODY"); + }); +}); + +describe("base system prompt — every locale", () => { + it("asks for the assessment in the reader's own language", () => { + for (const locale of locales) { + const prompt = getBaseSystemPrompt(locale); + if (locale === "de") { + // The German body states its language in German. + expect(prompt).toContain("auf Deutsch"); + } else { + expect( + prompt, + `${locale} must name its own language in the output clause`, + ).toContain(`complete assessment in ${EXPECTED_LANGUAGE_NAME[locale]}`); + } + } + }); + + it("never sends German instructions to a non-German locale", () => { + // This is the regression the whole change exists to prevent. + for (const locale of locales) { + if (locale === "de") continue; + expect( + GERMAN_SENTINEL.test(getBaseSystemPrompt(locale)), + `${locale} prompt contains German instruction text`, + ).toBe(false); + } + }); + + it("ends the four non-de/en prompts with their own directive", () => { + for (const locale of ["fr", "es", "it", "pl"] as const) { + expect(getBaseSystemPrompt(locale).trimEnd()).toMatch( + /OUTPUT LANGUAGE: .+$/s, + ); + } + }); + + it("produces a distinct prompt per locale (no silent collapse)", () => { + const seen = new Map(); + for (const locale of locales) { + const prompt = getBaseSystemPrompt(locale); + const clash = seen.get(prompt); + expect( + clash, + `${locale} produced a prompt identical to ${clash} — a locale collapsed`, + ).toBeUndefined(); + seen.set(prompt, locale); + } + }); +}); From fff84ea84f4244f483d8d4369d3676a9297259fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:18:58 +0200 Subject: [PATCH 03/13] fix(ai): route the metric assessment prompts by instruction locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-metric system and user prompt builders branched `locale === "en" ? EN : DE`, so a French, Spanish, Italian or Polish reader composed the German instruction body — whose output clause asks for German prose. Route them through `instructionLocale` instead, so German is the only locale that takes the German body and everyone else takes the English one plus the reader's own output-language directive already carried by the base prompt. The German and English prompts are unchanged, byte for byte. --- src/lib/ai/prompts/blood-pressure.ts | 8 ++++++-- src/lib/ai/prompts/bmi.ts | 8 ++++++-- src/lib/ai/prompts/general-status.ts | 10 ++++++++-- src/lib/ai/prompts/insight-system-prompt.ts | 9 +++++---- src/lib/ai/prompts/interpretation-block.ts | 3 ++- src/lib/ai/prompts/medication-compliance.ts | 8 ++++++-- src/lib/ai/prompts/metric-archetypes.ts | 14 ++++++++------ src/lib/ai/prompts/mood.ts | 8 ++++++-- src/lib/ai/prompts/pulse.ts | 8 ++++++-- src/lib/ai/prompts/status-batch.ts | 3 ++- src/lib/ai/prompts/weight.ts | 8 ++++++-- 11 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/lib/ai/prompts/blood-pressure.ts b/src/lib/ai/prompts/blood-pressure.ts index c8ee05c7f..29943d94f 100644 --- a/src/lib/ai/prompts/blood-pressure.ts +++ b/src/lib/ai/prompts/blood-pressure.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; +import { instructionLocale } from "./output-language"; const BP_SECTION_DE = `METRIK — BLUTDRUCK: - Der Snapshot trägt systolisch (bloodPressure.systolic) und diastolisch (bloodPressure.diastolic) jeweils mit signal (der fertige Vergleich) + summary + graded series; beurteile beide Komponenten GEMEINSAM, nie isoliert. @@ -26,7 +27,10 @@ const BP_SECTION_EN = `METRIC — BLOOD PRESSURE: - One message: if the values sit above the person's baseline, close with ONE doable step ONLY when the finding implies one (e.g. take a few readings at the same time of day for a few days, or — when adherence is patchy — make the medication routine more reliable). When values are stable and in target, say so honestly and name one thing worth keeping an eye on instead of manufacturing a step.`; export function getBloodPressureSystemPrompt(locale: Locale): string { - const section = locale === "en" ? BP_SECTION_EN : BP_SECTION_DE; + // fr/es/it/pl compose the ENGLISH body (the base prompt names their + // language and appends their own directive); only de takes the German one. + const section = + instructionLocale(locale) === "en" ? BP_SECTION_EN : BP_SECTION_DE; return `${getBaseSystemPrompt(locale)} ${section}`; @@ -59,7 +63,7 @@ export function getBloodPressureUserPrompt( openerHint && openerHint.trim().length > 0 ? `\nOPENER HINT: ${openerHint}` : ""; - if (locale === "en") { + if (instructionLocale(locale) === "en") { return `Date: ${todayKey} (Europe/Berlin)${openerLine} Write one short assessment of this person's blood pressure. Open with what the reading MEANS in plain words — the overall read, not the number (e.g. "calm and right where you want it", "running a touch higher than your usual this week") — then bring in the systolic/diastolic figures right after as support, judged as ONE pair and placed against their own weekly/monthly baseline; never lead with the numbers. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} diff --git a/src/lib/ai/prompts/bmi.ts b/src/lib/ai/prompts/bmi.ts index fb444ae32..f8d152162 100644 --- a/src/lib/ai/prompts/bmi.ts +++ b/src/lib/ai/prompts/bmi.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; +import { instructionLocale } from "./output-language"; const BMI_SECTION_DE = `METRIK — BMI: - Der Snapshot trägt bmi.signal (der fertige Vergleich) + bmi.summary + bmi.series (graded). bmi.latestDayFocus zeigt den jüngsten Wert, dessen WHO-Klassifikation und den Schritt zum Vortag; bmi.target ist das grüne Band (18.5-24.9). @@ -18,7 +19,10 @@ const BMI_SECTION_EN = `METRIC — BMI: - One message: close with ONE doable step that fits the band placement ONLY when the finding implies one — when the value is stable in the favourable band, acknowledge that honestly and name one thing worth keeping an eye on rather than forcing a finding or a step.`; export function getBmiSystemPrompt(locale: Locale): string { - const section = locale === "en" ? BMI_SECTION_EN : BMI_SECTION_DE; + // fr/es/it/pl compose the ENGLISH body (the base prompt names their + // language and appends their own directive); only de takes the German one. + const section = + instructionLocale(locale) === "en" ? BMI_SECTION_EN : BMI_SECTION_DE; return `${getBaseSystemPrompt(locale)} ${section}`; @@ -46,7 +50,7 @@ export function getBmiUserPrompt( openerHint && openerHint.trim().length > 0 ? `\nOPENER HINT: ${openerHint}` : ""; - if (locale === "en") { + if (instructionLocale(locale) === "en") { return `Date: ${todayKey} (Europe/Berlin)${openerLine} Write one short assessment of this person's BMI. Open with what its band placement MEANS in plain words — where it sits and whether that's holding or shifting, not the number (e.g. "sitting comfortably in the healthy band", "edging toward the next band up") — then bring in the current value and WHO band right after as support, saying against their own weekly/monthly baseline whether it has crossed a band or is nearing a boundary; never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Leave the weight pace to the weight card. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} diff --git a/src/lib/ai/prompts/general-status.ts b/src/lib/ai/prompts/general-status.ts index bc21486d4..0c6677cd7 100644 --- a/src/lib/ai/prompts/general-status.ts +++ b/src/lib/ai/prompts/general-status.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; +import { instructionLocale } from "./output-language"; const GENERAL_SECTION_DE = `METRIK — GESAMTBEWERTUNG: - Der Snapshot trägt measurementSeries (eine Map je Metriktyp mit summary + graded series), medicationAdherence (summary + series), bloodPressureTargets (falls vorhanden) und moodContext (falls ≥ 3 Tage). dataCoverage.avgDaysBetweenMeasurements zeigt die Messdichte. @@ -20,7 +21,12 @@ const GENERAL_SECTION_EN = `METRIC — OVERALL ASSESSMENT: - One message: even across all metrics, close with EXACTLY ONE most-important, doable suggestion ("one thing") ONLY when the overall picture implies one. When everything is steady and there is nothing useful to do, say so honestly and name one thing worth keeping an eye on instead of forcing a recommendation.`; export function getGeneralStatusSystemPrompt(locale: Locale): string { - const section = locale === "en" ? GENERAL_SECTION_EN : GENERAL_SECTION_DE; + // fr/es/it/pl compose the ENGLISH body (the base prompt names their + // language and appends their own directive); only de takes the German one. + const section = + instructionLocale(locale) === "en" + ? GENERAL_SECTION_EN + : GENERAL_SECTION_DE; return `${getBaseSystemPrompt(locale)} ${section}`; @@ -52,7 +58,7 @@ export function getGeneralStatusUserPrompt( openerHint && openerHint.trim().length > 0 ? `\nOPENER HINT: ${openerHint}` : ""; - if (locale === "en") { + if (instructionLocale(locale) === "en") { return `Date: ${todayKey} (Europe/Berlin)${openerLine} Write one short overall assessment across the available health metrics. Open with the overall read in plain words — how things are looking taken together, not a number (e.g. "a steady stretch across the board", "one thing standing out this week") — then bring in the one or two metrics that stand out as support, each placed against the person's own weekly/monthly baseline; never lead with a value. Close with the single most important doable step only when the overall picture genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count, density and recency.${ctxBlock}${extraBlock} diff --git a/src/lib/ai/prompts/insight-system-prompt.ts b/src/lib/ai/prompts/insight-system-prompt.ts index 18fa44368..bc6f79d80 100644 --- a/src/lib/ai/prompts/insight-system-prompt.ts +++ b/src/lib/ai/prompts/insight-system-prompt.ts @@ -2,6 +2,7 @@ * System prompt and output formatting for OpenAI insights. */ import type { Locale } from "@/lib/i18n/config"; +import { instructionLocale } from "./output-language"; import type { ComparisonBaseline } from "@/lib/dashboard-layout"; /** @@ -50,7 +51,7 @@ export function buildUserPrompt( ? buildComparisonBlock(locale, comparison) : ""; - if (locale === "en") { + if (instructionLocale(locale) === "en") { const modeLabel = privacyMode === "raw" ? "Aggregated data + raw values (entire available period, anonymised)" @@ -92,7 +93,7 @@ export function buildComparisonBlock( snapshot: ComparisonSnapshot, ): string { const baselineLabel = - locale === "en" + instructionLocale(locale) === "en" ? snapshot.baseline === "lastMonth" ? "the same window 30 days ago" : "the same window 365 days ago" @@ -122,7 +123,7 @@ export function buildComparisonBlock( }; if (snapshot.metrics.length === 0) { - return locale === "en" + return instructionLocale(locale) === "en" ? ` SYSTEM CONTEXT — COMPARISON MODE ACTIVE @@ -133,7 +134,7 @@ SYSTEM CONTEXT — VERGLEICHSMODUS AKTIV Der Nutzer hat den Vergleichsmodus gegen ${baselineLabel} aktiviert, aber für keine Metrik liegen ausreichend Daten aus der Vergleichsperiode vor. Erwähne das explizit im ersten Satz der Zusammenfassung.`; } - if (locale === "en") { + if (instructionLocale(locale) === "en") { return ` SYSTEM CONTEXT — COMPARISON MODE ACTIVE diff --git a/src/lib/ai/prompts/interpretation-block.ts b/src/lib/ai/prompts/interpretation-block.ts index 9d83b7663..84bad478e 100644 --- a/src/lib/ai/prompts/interpretation-block.ts +++ b/src/lib/ai/prompts/interpretation-block.ts @@ -13,6 +13,7 @@ * assessment then stays personal-relative, exactly as before this landed). */ import type { Locale } from "@/lib/i18n/config"; +import { instructionLocale } from "./output-language"; import { classifyBandPosition, resolveInterpretation, @@ -136,7 +137,7 @@ export function buildInterpretationBlock(args: { const resolved = resolveInterpretation(args.metricKey, args.sex ?? null); if (!resolved) return undefined; - const loc: "en" | "de" = args.locale === "en" ? "en" : "de"; + const loc: "en" | "de" = instructionLocale(args.locale); const pos = classifyBandPosition(args.value, resolved.bands); const rows = resolved.bands diff --git a/src/lib/ai/prompts/medication-compliance.ts b/src/lib/ai/prompts/medication-compliance.ts index d0f903dd6..c0983dbc0 100644 --- a/src/lib/ai/prompts/medication-compliance.ts +++ b/src/lib/ai/prompts/medication-compliance.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; +import { instructionLocale } from "./output-language"; const MEDCO_SECTION_DE = `METRIK — MEDIKAMENTEN-EINNAHMETREUE: - Der Snapshot trägt overall (medicationCount, averageCompliance7, averageCompliance30) und medications[] je Medikament mit name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, signal (der fertige Trend-Vergleich), dailySeries (graded) und latestDay. @@ -20,7 +21,10 @@ const MEDCO_SECTION_EN = `METRIC — MEDICATION ADHERENCE: - One message: close with ONE doable step that fits the gap ONLY when the finding implies one (e.g. evening doses are missed more often — a fixed routine or a reminder at the critical time can help). When adherence is consistently high, acknowledge that honestly rather than manufacturing a step. No blame.`; export function getMedicationComplianceSystemPrompt(locale: Locale): string { - const section = locale === "en" ? MEDCO_SECTION_EN : MEDCO_SECTION_DE; + // fr/es/it/pl compose the ENGLISH body (the base prompt names their + // language and appends their own directive); only de takes the German one. + const section = + instructionLocale(locale) === "en" ? MEDCO_SECTION_EN : MEDCO_SECTION_DE; return `${getBaseSystemPrompt(locale)} ${section}`; @@ -48,7 +52,7 @@ export function getMedicationComplianceUserPrompt( openerHint && openerHint.trim().length > 0 ? `\nOPENER HINT: ${openerHint}` : ""; - if (locale === "en") { + if (instructionLocale(locale) === "en") { return `Date: ${todayKey} (Europe/Berlin)${openerLine} Write one short assessment of this person's medication adherence. Open with what it MEANS in plain words — how reliable the routine is looking, not the number (e.g. "your routine's been rock-solid", "a few doses have slipped lately") — then bring in the recent rate right after as support, placed against their own baseline (compliance7 vs compliance30); never lead with the value. Close with one doable, blame-free step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the event count and recency.${ctxBlock}${extraBlock} diff --git a/src/lib/ai/prompts/metric-archetypes.ts b/src/lib/ai/prompts/metric-archetypes.ts index 23ea4bbab..c8c08952c 100644 --- a/src/lib/ai/prompts/metric-archetypes.ts +++ b/src/lib/ai/prompts/metric-archetypes.ts @@ -22,13 +22,14 @@ */ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; +import { instructionLocale } from "./output-language"; import type { MetricArchetype, MetricStatusMeta, } from "@/lib/insights/metric-status-registry"; function directionPhrase(meta: MetricStatusMeta, locale: Locale): string { - if (locale === "en") { + if (instructionLocale(locale) === "en") { switch (meta.direction) { case "higher-better": return "Higher values are generally more favourable for this metric."; @@ -55,13 +56,13 @@ function directionPhrase(meta: MetricStatusMeta, locale: Locale): string { */ function metaBlock(meta: MetricStatusMeta, locale: Locale): string { const range = meta.normalRange - ? locale === "en" + ? instructionLocale(locale) === "en" ? `Coarse population reference band: ${meta.normalRange.low}–${meta.normalRange.high} ${meta.unit} (a placement aid only — the user's OWN baseline leads).` : `Grobes Referenzband (Population): ${meta.normalRange.low}–${meta.normalRange.high} ${meta.unit} (nur Orientierung — die EIGENE Baseline führt).` - : locale === "en" + : instructionLocale(locale) === "en" ? "No fixed population band applies — judge purely against the user's own baseline." : "Kein festes Referenzband — beurteile rein gegen die eigene Baseline der Person."; - if (locale === "en") { + if (instructionLocale(locale) === "en") { return [ `METRIC: ${meta.displayName} (unit: ${meta.unit}).`, directionPhrase(meta, locale), @@ -107,7 +108,8 @@ export function getMetricArchetypeSystemPrompt( locale: Locale, ): string { const section = ARCHETYPE_SECTION[meta.archetype]; - const archetypeText = locale === "en" ? section.en : section.de; + const archetypeText = + instructionLocale(locale) === "en" ? section.en : section.de; return `${getBaseSystemPrompt(locale)} ${archetypeText} @@ -159,7 +161,7 @@ export function getMetricArchetypeUserPrompt( openerHint && openerHint.trim().length > 0 ? `\nOPENER HINT: ${openerHint}` : ""; - if (locale === "en") { + if (instructionLocale(locale) === "en") { return `Date: ${todayKey} (Europe/Berlin)${openerLine} Write one short assessment of this person's ${meta.displayName.toLowerCase()}. Open with what it MEANS in plain words — the read, not the number (e.g. "running a little calmer than usual", "steady and right where it's been") — then bring in ONE concrete number from the snapshot right after as support, placed against their own weekly/monthly baseline; never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}${interpBlock} diff --git a/src/lib/ai/prompts/mood.ts b/src/lib/ai/prompts/mood.ts index 8419ec02d..1f0b2b672 100644 --- a/src/lib/ai/prompts/mood.ts +++ b/src/lib/ai/prompts/mood.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; +import { instructionLocale } from "./output-language"; const MOOD_SECTION_DE = `METRIK — STIMMUNG / WOHLBEFINDEN: - Skala 1 (sehr schlecht) bis 5 (sehr gut). Der Snapshot trägt mood.signal (der fertige Vergleich) + mood.summary + mood.series (graded, Tagesmittel), mood.target (grünes/oranges Band), mood.latestDayFocus und ggf. mood.tags (häufigste Stimmungs-Tags). @@ -20,7 +21,10 @@ const MOOD_SECTION_EN = `METRIC — MOOD / WELL-BEING: - One message: close with ONE doable, kind step ONLY when the finding implies one (e.g. on good days, briefly note what helped). When mood is steadily good, acknowledge that honestly and name one thing worth keeping an eye on instead of manufacturing a step.`; export function getMoodSystemPrompt(locale: Locale): string { - const section = locale === "en" ? MOOD_SECTION_EN : MOOD_SECTION_DE; + // fr/es/it/pl compose the ENGLISH body (the base prompt names their + // language and appends their own directive); only de takes the German one. + const section = + instructionLocale(locale) === "en" ? MOOD_SECTION_EN : MOOD_SECTION_DE; return `${getBaseSystemPrompt(locale)} ${section}`; @@ -48,7 +52,7 @@ export function getMoodUserPrompt( openerHint && openerHint.trim().length > 0 ? `\nOPENER HINT: ${openerHint}` : ""; - if (locale === "en") { + if (instructionLocale(locale) === "en") { return `Date: ${todayKey} (Europe/Berlin)${openerLine} Write one short assessment of this person's mood. Open with what the recent pattern MEANS in plain words — the read, not the number (e.g. "a brighter stretch than usual", "steady, holding where it's been") — then bring in the recent level right after as support, placed against their own weekly/monthly baseline; never lead with the value. Close with one kind, doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the entry count and recency.${ctxBlock}${extraBlock} diff --git a/src/lib/ai/prompts/pulse.ts b/src/lib/ai/prompts/pulse.ts index b558b3df3..7a10338f3 100644 --- a/src/lib/ai/prompts/pulse.ts +++ b/src/lib/ai/prompts/pulse.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; +import { instructionLocale } from "./output-language"; const PULSE_SECTION_DE = `METRIK — PULS / HERZFREQUENZ: - Der Snapshot trägt pulse.signal (der fertige Vergleich), pulse.summary + pulse.series (graded) und pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus zeigt den jüngsten Tageswert und den Schritt zum Vortag. @@ -18,7 +19,10 @@ const PULSE_SECTION_EN = `METRIC — PULSE / HEART RATE: - One message: if the resting pulse sits above the person's baseline, close with ONE doable step ONLY when the finding implies one (e.g. take readings on waking for a few days to get a clean resting baseline). If it is improving or steady, acknowledge that honestly and name one thing worth keeping an eye on instead of manufacturing a step.`; export function getPulseSystemPrompt(locale: Locale): string { - const section = locale === "en" ? PULSE_SECTION_EN : PULSE_SECTION_DE; + // fr/es/it/pl compose the ENGLISH body (the base prompt names their + // language and appends their own directive); only de takes the German one. + const section = + instructionLocale(locale) === "en" ? PULSE_SECTION_EN : PULSE_SECTION_DE; return `${getBaseSystemPrompt(locale)} ${section}`; @@ -46,7 +50,7 @@ export function getPulseUserPrompt( openerHint && openerHint.trim().length > 0 ? `\nOPENER HINT: ${openerHint}` : ""; - if (locale === "en") { + if (instructionLocale(locale) === "en") { return `Date: ${todayKey} (Europe/Berlin)${openerLine} Write one short assessment of this person's resting pulse. Open with what it MEANS in plain words — the read, not the number (e.g. "running a little calmer than usual", "steady, right where it's been") — then bring in ONE concrete number from the snapshot right after as support, placed against their own weekly/monthly baseline; never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} diff --git a/src/lib/ai/prompts/status-batch.ts b/src/lib/ai/prompts/status-batch.ts index c5590406b..2f57b3ea0 100644 --- a/src/lib/ai/prompts/status-batch.ts +++ b/src/lib/ai/prompts/status-batch.ts @@ -19,6 +19,7 @@ */ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; +import { instructionLocale } from "./output-language"; /** * The stable per-metric output keys the batch envelope uses. The InsightStatus @@ -58,7 +59,7 @@ Include a key ONLY if its snapshot block is present below (keys present: ${keyLi AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. "perMetric" enthält je vorhandenem Key eine kurze Einschätzung (je 2-4 Sätze, derselbe Vertrag wie eine Einzelkarte): { "perMetric": { ${presentKeys.map((k) => `"${k}": "..."`).join(", ")} } } Nimm einen Key NUR auf, wenn sein Snapshot-Block unten vorhanden ist (vorhandene Keys: ${keyList}). Lass jede Metrik ohne Block weg — erfinde niemals eine.`; - return `${base}\n\n${locale === "en" ? en : de}`; + return `${base}\n\n${instructionLocale(locale) === "en" ? en : de}`; } /** diff --git a/src/lib/ai/prompts/weight.ts b/src/lib/ai/prompts/weight.ts index f0b3ec1b9..02db48761 100644 --- a/src/lib/ai/prompts/weight.ts +++ b/src/lib/ai/prompts/weight.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; +import { instructionLocale } from "./output-language"; const WEIGHT_SECTION_DE = `METRIK — GEWICHT: - Der Snapshot trägt weight.signal (der fertige Vergleich) + weight.summary + weight.series (graded). weight.latestDayFocus zeigt den jüngsten Tageswert, den Schritt zum vorherigen Messtag und ggf. den Blutdruck desselben Tages. @@ -24,7 +25,10 @@ const WEIGHT_SECTION_EN = `METRIC — WEIGHT: - One message: close with ONE doable step that fits the direction ONLY when the finding implies one (e.g. on a plateau, weigh at the same time of day to see the real trend rather than the daily noise). When the trend is steady and there is nothing useful to do, affirm it honestly and name one thing worth keeping an eye on instead of manufacturing a step.`; export function getWeightSystemPrompt(locale: Locale): string { - const section = locale === "en" ? WEIGHT_SECTION_EN : WEIGHT_SECTION_DE; + // fr/es/it/pl compose the ENGLISH body (the base prompt names their + // language and appends their own directive); only de takes the German one. + const section = + instructionLocale(locale) === "en" ? WEIGHT_SECTION_EN : WEIGHT_SECTION_DE; return `${getBaseSystemPrompt(locale)} ${section}`; @@ -52,7 +56,7 @@ export function getWeightUserPrompt( openerHint && openerHint.trim().length > 0 ? `\nOPENER HINT: ${openerHint}` : ""; - if (locale === "en") { + if (instructionLocale(locale) === "en") { return `Date: ${todayKey} (Europe/Berlin)${openerLine} Write one short assessment of this person's weight. Open with what the trend MEANS in plain words — the direction and momentum, not the number (e.g. "easing down steadily", "holding right where it's settled") — then bring in ONE concrete number from the snapshot right after as support, read as a continuous trend and pace against their own weekly/monthly baseline (kg/week, plateau, milestone — not the single value, and not the WHO band, which the BMI card covers); never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} From 13197c7ee943860699a31edb2c7e75cbf805ed35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:22:15 +0200 Subject: [PATCH 04/13] fix(ai): route the score, biomarker and coach prompts by instruction locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The derived-score archetype and the Coach preference overrides branched on the raw locale, so fr/es/it/pl composed German instruction text. Both now route through `instructionLocale`. The score archetype rides on `getBaseSystemPrompt` and therefore appends no directive of its own — the base prompt already carries it. The biomarker assessment is the one prompt in the family that does not compose the base scaffold, so it appends the reader's directive itself; the append collapses into the shared path once that prompt moves onto the base scaffold. The two prompt builders in each of those modules now take the full `Locale` rather than the narrowed de/en alias, so the routing is right the moment the pipeline stops collapsing the four locales on the way in. Also targets the output-language contract test's regex at the compile target instead of the dotAll flag, which does not compile. German and English prompts are unchanged, byte for byte. --- src/lib/ai/coach/system-prompt.ts | 9 +++- .../output-language-contract.test.ts | 4 +- src/lib/insights/biomarker-status.ts | 43 ++++++++++++++----- .../insights/derived/derived-assessment-ai.ts | 19 ++++++-- 4 files changed, 57 insertions(+), 18 deletions(-) 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. From 37c33d0205788f02923b9794301fadfe6b1551f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:23:40 +0200 Subject: [PATCH 05/13] fix(insights): carry the reader's locale through the assessment pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-metric assessment pipeline typed every locale as `de | en` and narrowed on the way in, so a French, Spanish, Italian or Polish account never reached a prompt with its own locale — the output-language directive could not fire and the reader got English, or German where the binary defaulted that way. Widen `SupportedLocale` to the six locales the UI ships and replace the narrowing binaries with one validating `normalizeLocale` that passes a recognised locale through unchanged and defaults an unknown value to English. The status-generate payload, status batch, status cache, comprehensive generation, the pre-generate warm passes and the forced-warm handlers all carry the full locale now. Fix the collapse sites that discarded it: the warm enqueue on the comprehensive route and the pregenerate route, the nightly batch cron, and the forced-warm job handler — the last two defaulted to German outright. The deterministic derived-score templates and the previous-context block ship de/en bodies only, so they route through `instructionLocale` (German for German readers, English otherwise) while the prompt names the reader's own language. --- src/app/api/insights/generate/route.ts | 5 +- src/app/api/insights/pregenerate/route.ts | 6 ++- src/lib/insights/comprehensive-generate.ts | 11 ++-- .../insights/derived/derived-assessment-ai.ts | 24 ++++++--- src/lib/insights/memory.ts | 21 ++++++-- src/lib/insights/status-batch.ts | 9 ++-- src/lib/insights/status-cache.ts | 3 +- src/lib/insights/status-invalidation.ts | 9 ++-- src/lib/insights/status-shared.ts | 32 ++++++++--- src/lib/jobs/insight-pregenerate-shared.ts | 13 +++-- src/lib/jobs/insight-pregenerate.ts | 54 ++++++++++++------- .../jobs/insight-status-generate-shared.ts | 5 +- src/lib/jobs/insight-status-generate.ts | 3 +- src/lib/jobs/morning-digest-refresh.ts | 9 ++-- src/lib/jobs/reminder/insights-handlers.ts | 12 +++-- 15 files changed, 150 insertions(+), 66 deletions(-) diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 4ff5763aa..ff439cae9 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -71,6 +71,7 @@ import { requireAssistantSurface } from "@/lib/feature-flags"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { normalizeLocale } from "@/lib/insights/status-shared"; import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; @@ -202,7 +203,7 @@ export const GET = apiHandler(async (request: NextRequest) => { request, userLocale: dbUser?.locale ?? user.locale ?? null, }); - const locale = resolved === "de" ? "de" : "en"; + const locale = normalizeLocale(resolved); void enqueueForceWarm({ userId, locale }); revalidating = true; } @@ -950,7 +951,7 @@ export const POST = apiHandler((request: NextRequest) => // refresh path until the next nightly warm. const refillScopes = await enqueueStatusRefillForUser( userId, - locale === "de" ? "de" : "en", + normalizeLocale(locale), ); annotate({ action: { name: "insights.generate.cards_refill" }, diff --git a/src/app/api/insights/pregenerate/route.ts b/src/app/api/insights/pregenerate/route.ts index 411803cc4..ad3b06759 100644 --- a/src/app/api/insights/pregenerate/route.ts +++ b/src/app/api/insights/pregenerate/route.ts @@ -30,6 +30,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { normalizeLocale } from "@/lib/insights/status-shared"; import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; export const dynamic = "force-dynamic"; @@ -59,7 +60,10 @@ export const POST = apiHandler(async (request: NextRequest) => { request, userLocale: user.locale ?? null, }); - const locale = resolved === "en" ? "en" : "de"; + // Carry the reader's ACTUAL locale into the warm payload. The former + // `resolved === "en" ? "en" : "de"` sent every fr/es/it/pl account down the + // German warm, so the whole warmed cache family was in the wrong language. + const locale = normalizeLocale(resolved); await enqueueForceWarm({ userId, locale }); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 61e1d33ea..f9fdff775 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -81,7 +81,10 @@ import { hasActiveConsentForSurface, } from "@/lib/ai/consent-guard"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; -import { stripJsonFences } from "@/lib/insights/status-shared"; +import { + stripJsonFences, + type SupportedLocale, +} from "@/lib/insights/status-shared"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { annotate } from "@/lib/logging/context"; import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets"; @@ -149,7 +152,7 @@ async function rerollBriefingParagraph(args: { systemPrompt: string; userPrompt: string; cachedText: string; - locale: "de" | "en"; + locale: SupportedLocale; /** * v1.18.10 (MEDIUM-3) — the pre-computed signals the briefing must match. * The reroll runs at a higher, seedless temperature for phrasing variety, @@ -455,7 +458,7 @@ export async function buildComparisonSnapshotForUser( */ function failBeforeProvider( userId: string, - locale: "de" | "en", + locale: SupportedLocale, reason: "payload-too-large" | "features-error" | "aborted", err?: unknown, ): GenerateOutcome { @@ -475,7 +478,7 @@ function failBeforeProvider( interface GenerateOptions { /** Resolved UI locale for the prompt + cache row. */ - locale: "de" | "en"; + locale: SupportedLocale; /** Skip the 24 h cache short-circuit and force a fresh generation. */ force?: boolean; /** diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts index 1a9d72ebd..76c5006a7 100644 --- a/src/lib/insights/derived/derived-assessment-ai.ts +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -43,7 +43,7 @@ import { import type { MeasurementType } from "@/generated/prisma/client"; import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver"; import { annotate } from "@/lib/logging/context"; -import type { Locale } from "@/lib/i18n/config"; +import { instructionLocale } from "@/lib/ai/prompts/output-language"; import { openerArchetypeHint, dayRotatedSeed, @@ -89,8 +89,13 @@ const SCORE_INPUT_TYPES: readonly MeasurementType[] = [ function scoreSystemPrompt(locale: SupportedLocale): string { const base = getBaseSystemPrompt(locale); + // The archetype section rides the same reviewed instruction body the base + // composes: German for a German reader, English for everyone else. The base + // already names the reader's own language, so a French reader gets the + // English archetype and French prose. The former `locale === "en" ? EN : DE` + // handed fr/es/it/pl the GERMAN section. 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. @@ -120,7 +125,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. @@ -152,10 +157,11 @@ export async function resolveDerivedAssessment(args: { const now = args.now ?? new Date(); const locale = normalizeLocale(args.locale); + // Deterministic templates ship de/en only — same instruction-body rule. const deterministic = resolveDeterministicAssessment( args.metric, args.derived, - locale, + instructionLocale(locale), now, ); // Not assessable, or status !== ok → no field (the locked contract). @@ -214,7 +220,13 @@ export async function generateDerivedScoreAssessment(args: { if (!isAssessableDerivedScore(args.metric)) return; if (args.derived.status !== "ok") return; - const signal = buildScoreSignal(args.metric, args.derived.value, locale); + // The deterministic signal labels ship de/en templates only; route them + // through the same instruction-body rule rather than a German default. + const signal = buildScoreSignal( + args.metric, + args.derived.value, + instructionLocale(locale), + ); if (!signal) return; const band = (args.derived.value as { band?: string }).band ?? "yellow"; @@ -230,7 +242,7 @@ export async function generateDerivedScoreAssessment(args: { // assessment varies across scores and across days instead of riding the // fixed reference seed. The key is per (user, score, day). const seedKey = `${args.userId}:${scope}:${todayKey}`; - const openerHint = openerArchetypeHint(seedKey, locale as Locale); + const openerHint = openerArchetypeHint(seedKey, locale); // v1.22 (W6) — score-warm input-hash gate. A score is re-warmed daily even on // a day with no new wearable data; this skips the LLM and re-stamps the cached diff --git a/src/lib/insights/memory.ts b/src/lib/insights/memory.ts index 450c74df4..f58e1e15c 100644 --- a/src/lib/insights/memory.ts +++ b/src/lib/insights/memory.ts @@ -18,6 +18,8 @@ */ import { prisma } from "@/lib/db"; +import { instructionLocale } from "@/lib/ai/prompts/output-language"; +import type { SupportedLocale } from "@/lib/insights/status-shared"; /** * Stable identifiers for the seven insight scopes. The string MUST @@ -70,7 +72,7 @@ export interface PreviousInsightContext { export async function getPreviousInsightContext( userId: string, scope: PreviousContextScope, - locale: "de" | "en", + locale: SupportedLocale, minAgeHours: number = 12, ): Promise { const action = `insights.${scope}.${locale}`; @@ -125,7 +127,15 @@ export async function getPreviousInsightContext( /** * Format a previous-context block for direct injection into the user - * prompt. Output already locale-aware. + * prompt. + * + * The block is written in the same reviewed instruction body the prompt + * composes (`instructionLocale`): German for a German reader, English for + * everyone else. It is an INSTRUCTION to the model, not user-facing prose — + * the reader's own language is named by the prompt's output-language + * directive, so a French reader gets the English instruction block and a + * French assessment. The former `locale === "en" ? EN : DE` handed the + * GERMAN block to fr/es/it/pl. * * Example (de): * "VORHERIGE ANALYSE (vor 7 Tagen, 2026-05-01): @@ -133,16 +143,17 @@ export async function getPreviousInsightContext( */ export function formatPreviousContextForPrompt( ctx: PreviousInsightContext | null, - locale: "de" | "en", + locale: SupportedLocale, ): string { + const body = instructionLocale(locale); if (!ctx) { - return locale === "en" + return body === "en" ? "PREVIOUS ANALYSIS: none on file. Treat this as the user's first analysis for this domain — no improvement/regression delta to surface." : "VORHERIGE ANALYSE: keine vorhanden. Behandle dies als erste Analyse in diesem Bereich — kein Verbesserungs-/Verschlechterungs-Delta zu nennen."; } const dateLabel = ctx.generatedAt.slice(0, 10); - if (locale === "en") { + if (body === "en") { const ageLabel = ctx.ageDays === 0 ? "earlier today" diff --git a/src/lib/insights/status-batch.ts b/src/lib/insights/status-batch.ts index ff267ba6a..2afd0c5bf 100644 --- a/src/lib/insights/status-batch.ts +++ b/src/lib/insights/status-batch.ts @@ -39,7 +39,10 @@ import { type PreparedStatusCard, } from "@/lib/insights/status-card-generation"; import { runStatusCompletion } from "@/lib/insights/status-provider"; -import { stripJsonFences } from "@/lib/insights/status-shared"; +import { + stripJsonFences, + type SupportedLocale, +} from "@/lib/insights/status-shared"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { buildStatusBatchUserPrompt, @@ -53,7 +56,7 @@ import { annotate } from "@/lib/logging/context"; const PREPARES: ReadonlyArray< ( userId: string, - options: { locale: "de" | "en"; force?: boolean }, + options: { locale: SupportedLocale; force?: boolean }, ) => Promise > = [ prepareBloodPressureStatusForUser, @@ -109,7 +112,7 @@ function parsePerMetric(content: string): Record | null { */ export async function generateStatusBatchForUser( userId: string, - options: { locale: "de" | "en"; force?: boolean }, + options: { locale: SupportedLocale; force?: boolean }, ): Promise { const result: StatusBatchResult = { served: 0, diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 2e309ba98..03679f230 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -15,6 +15,7 @@ import { DISCOVERY_OUTCOMES, } from "@/lib/insights/correlation-discovery"; import { annotate } from "@/lib/logging/context"; +import type { SupportedLocale } from "@/lib/insights/status-shared"; /** * v1.18.11 (P6-tighten) — the measurement types the FDR correlation-discovery @@ -499,7 +500,7 @@ export type ReadOnlyMissOutcome = export async function resolveReadOnlyStatusMiss(args: { userId: string; metric: InsightStatusScope; - locale: "de" | "en"; + locale: SupportedLocale; }): Promise { const hasProvider = await hasUsableStatusProvider(args.userId); if (!hasProvider) return { kind: "no-provider" }; diff --git a/src/lib/insights/status-invalidation.ts b/src/lib/insights/status-invalidation.ts index 6bacb2291..977dea890 100644 --- a/src/lib/insights/status-invalidation.ts +++ b/src/lib/insights/status-invalidation.ts @@ -16,7 +16,10 @@ import { enqueueStatusGeneration, type InsightStatusScope, } from "@/lib/jobs/insight-status-generate-shared"; -import { normalizeLocale } from "@/lib/insights/status-shared"; +import { + normalizeLocale, + type SupportedLocale, +} from "@/lib/insights/status-shared"; import { isTimeoutStub, statusCacheAction } from "@/lib/insights/status-cache"; import { metricIdForMeasurementType, @@ -218,7 +221,7 @@ export async function invalidateStatusInsightsForTypes( */ export async function enqueueStatusRefillForUser( userId: string, - locale: "de" | "en", + locale: SupportedLocale, ): Promise { const scopes = new Set(PER_STATUS_SCOPES); try { @@ -257,7 +260,7 @@ export async function enqueueStatusRefillForUser( */ async function findRecentlyWarmedScopes( userId: string, - locale: "de" | "en", + locale: SupportedLocale, scopes: ReadonlySet, ): Promise> { const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS); diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index 3113926e5..dc3191e41 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -9,17 +9,28 @@ * to the rounding precision, the chart-token scrub, or the cache-row * shape lands once rather than seven times. * - * The shapes are intentionally narrow — `SupportedLocale` is `de | en` - * because the status prompts ship only those two locales. The + * `SupportedLocale` is the full six-locale UI union. The status prompts + * compose one of two reviewed instruction bodies (de / en) but name the + * reader's own language in an explicit output directive, so the reader's + * locale must survive the whole pipeline rather than being collapsed to a + * binary on the way in. The * medication-compliance generator writes a richer cache `details` shape * (it carries a per-medication array), so it shares `round`, * `normalizeSummaryText`, `normalizeLocale`, and `parseSummaryFromContent` * but keeps its own `auditLog.create`. */ import { prisma } from "@/lib/db"; +import { locales, type Locale } from "@/lib/i18n/config"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; -export type SupportedLocale = "de" | "en"; +/** + * The locales the assessment pipeline carries end-to-end — all six the UI + * ships. Aliased to the i18n `Locale` so adding a UI locale widens the + * pipeline in one place rather than needing a second edit here. + */ +export type SupportedLocale = Locale; + +const SUPPORTED_LOCALES: ReadonlySet = new Set(locales); /** Round to `digits` decimal places. */ export function round(value: number, digits = 1): number { @@ -33,15 +44,20 @@ export function normalizeSummaryText(value: string): string { } /** - * Narrow an arbitrary locale string to the two the prompts support. - * Non-German locales (fr/es/it/pl, unknown values) resolve to ENGLISH — - * the same routing the no-key fallbacks use — so a French UI never - * receives German AI prose just because the prompts ship DE + EN only. + * Validate an arbitrary locale string against the six the UI ships. + * + * A recognised locale passes through UNCHANGED — that is the whole point: + * the former `value === "de" ? "de" : "en"` binary erased a French reader's + * locale before any prompt saw it, so the output-language directive could + * never fire. An unrecognised or missing value defaults to ENGLISH, never + * German; a German default is the bug class this replaces. */ export function normalizeLocale( value: string | null | undefined, ): SupportedLocale { - return value === "de" ? "de" : "en"; + return typeof value === "string" && SUPPORTED_LOCALES.has(value) + ? (value as SupportedLocale) + : "en"; } export interface SeriesSummary { diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts index d4afd79db..22aca36c8 100644 --- a/src/lib/jobs/insight-pregenerate-shared.ts +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -13,6 +13,7 @@ * name + payload type + enqueue helper in the generator-free module, the * concrete dispatch in the worker module. */ +import type { SupportedLocale } from "@/lib/insights/status-shared"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; @@ -29,8 +30,12 @@ export interface InsightPregeneratePayload { */ userId?: string; force?: boolean; - /** Locale to warm on the forced path; defaults to "de" when absent. */ - locale?: "de" | "en"; + /** + * Locale to warm on the forced path. Carries the reader's full UI locale + * (one of the six); the handler validates it and defaults an absent or + * unrecognised value to English. + */ + locale?: SupportedLocale; } /** @@ -48,7 +53,7 @@ export interface InsightPregeneratePayload { */ export async function enqueueForceWarm(payload: { userId: string; - locale: "de" | "en"; + locale: SupportedLocale; }): Promise { const boss = getGlobalBoss(); if (!boss) { @@ -119,7 +124,7 @@ export const PREGENERATE_RETRY_DELAY_SECONDS = 45 * 60; */ export async function enqueuePregenerateFailureRetry(payload: { userId: string; - locale: "de" | "en"; + locale: SupportedLocale; }): Promise { const boss = getGlobalBoss(); if (!boss) { diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index d2d1f7da2..d23291610 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -54,6 +54,10 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { checkRateLimit } from "@/lib/rate-limit"; import { getAssistantFlags } from "@/lib/feature-flags"; import { annotate } from "@/lib/logging/context"; +import { + normalizeLocale as normalizeStatusLocale, + type SupportedLocale, +} from "@/lib/insights/status-shared"; import { generateComprehensiveInsight, type GenerateOutcome, @@ -301,7 +305,7 @@ export interface PregenerateRunResult { */ type StatusGenerator = ( userId: string, - options: { locale: "de" | "en"; force?: boolean }, + options: { locale: SupportedLocale; force?: boolean }, ) => Promise<{ hasProvider: boolean; cached: boolean }>; /** @@ -323,7 +327,7 @@ type StatusGenerator = ( */ async function warmPerStatusCaches( userId: string, - locales: ReadonlyArray<"de" | "en">, + locales: ReadonlyArray, generators: ReadonlyArray, ): Promise { const limit = pLimit(WARM_PASS_CONCURRENCY); @@ -372,7 +376,7 @@ async function warmPerStatusCaches( */ async function warmStatusBatch( userId: string, - locales: ReadonlyArray<"de" | "en">, + locales: ReadonlyArray, ): Promise { let warmed = 0; for (const locale of locales) { @@ -413,7 +417,7 @@ async function warmStatusBatch( async function warmGenericMetricCaches( prisma: PrismaClient, userId: string, - locales: ReadonlyArray<"de" | "en">, + locales: ReadonlyArray, ): Promise { // One distinct read: the set of MeasurementTypes the user has live // rows for. Best-effort — a read failure (or a worker prisma surface @@ -532,12 +536,16 @@ export async function findPregenerateCandidates( } /** - * Non-German locales resolve to ENGLISH (matching - * `status-shared.normalizeLocale` and the no-key fallback routing) so a - * fr/es/it/pl account gets English AI prose, not German. + * The user's stored locale, validated against the six the UI ships. + * + * This was a local `value === "de" ? "de" : "en"` copy, which flattened a + * fr/es/it/pl account to English before the warm pass ever reached a prompt. + * It now defers to the one shared validator so the reader's locale survives + * into the prompt's output-language directive; an unknown value still + * defaults to English, never German. */ -function normalizeLocale(value: string | null): "de" | "en" { - return value === "de" ? "de" : "en"; +function normalizeLocale(value: string | null): SupportedLocale { + return normalizeStatusLocale(value); } /** @@ -555,7 +563,7 @@ export async function runInsightPregenerate( /** Injected for the test — defaults to the real generator. */ generate?: ( userId: string, - opts: { locale: "de" | "en"; force?: boolean; signal?: AbortSignal }, + opts: { locale: SupportedLocale; force?: boolean; signal?: AbortSignal }, ) => Promise; /** Injected for the test — defaults to the seven real status generators. */ statusGenerators?: ReadonlyArray; @@ -566,7 +574,7 @@ export async function runInsightPregenerate( */ warmGenericMetrics?: ( userId: string, - locales: ReadonlyArray<"de" | "en">, + locales: ReadonlyArray, ) => Promise; /** * v1.28.30 — injected for the test. Defaults to the real delayed @@ -576,7 +584,7 @@ export async function runInsightPregenerate( */ enqueueRetry?: (payload: { userId: string; - locale: "de" | "en"; + locale: SupportedLocale; }) => Promise; } = {}, ): Promise { @@ -589,13 +597,16 @@ export async function runInsightPregenerate( // production injects nothing, so the seven warm calls collapse into ONE // batched generation. The injected per-card path stays the test seam. const injectedStatusGenerators = options.statusGenerators; - const warmStatus = (userId: string, locales: ReadonlyArray<"de" | "en">) => + const warmStatus = ( + userId: string, + locales: ReadonlyArray, + ) => injectedStatusGenerators ? warmPerStatusCaches(userId, locales, injectedStatusGenerators) : warmStatusBatch(userId, locales); const warmGenericMetrics = options.warmGenericMetrics ?? - ((userId: string, locales: ReadonlyArray<"de" | "en">) => + ((userId: string, locales: ReadonlyArray) => warmGenericMetricCaches(prisma, userId, locales)); const result: PregenerateRunResult = { @@ -840,16 +851,16 @@ export async function runInsightPregenerate( export async function forceWarmUser( prisma: PrismaClient, userId: string, - locale: "de" | "en", + locale: SupportedLocale, options: { generate?: ( userId: string, - opts: { locale: "de" | "en"; force?: boolean; signal?: AbortSignal }, + opts: { locale: SupportedLocale; force?: boolean; signal?: AbortSignal }, ) => Promise; statusGenerators?: ReadonlyArray; warmGenericMetrics?: ( userId: string, - locales: ReadonlyArray<"de" | "en">, + locales: ReadonlyArray, ) => Promise; /** Injected for the test — defaults to `new Date()`. */ now?: Date; @@ -863,13 +874,16 @@ export async function forceWarmUser( // v1.18.7 (HIGH-1) — batch the seven status warms into ONE provider call // in production; keep the per-card path when a test injects generators. const injectedStatusGenerators = options.statusGenerators; - const warmStatus = (userId: string, locales: ReadonlyArray<"de" | "en">) => + const warmStatus = ( + userId: string, + locales: ReadonlyArray, + ) => injectedStatusGenerators ? warmPerStatusCaches(userId, locales, injectedStatusGenerators) : warmStatusBatch(userId, locales); const warmGenericMetrics = options.warmGenericMetrics ?? - ((id: string, locales: ReadonlyArray<"de" | "en">) => + ((id: string, locales: ReadonlyArray) => warmGenericMetricCaches(prisma, id, locales)); const now = options.now ?? new Date(); @@ -896,7 +910,7 @@ export async function forceWarmUser( // eviction; that eviction is gone, so the other locale family's rows // survive untouched and a client actually reading the second locale // warms it lazily through the read-path enqueue on its first miss. - const locales: ReadonlyArray<"de" | "en"> = [locale]; + const locales: ReadonlyArray = [locale]; // The comprehensive step gets its own bounded budget and its failure is // non-fatal. A slow or stalled briefing generation must not short-circuit diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index 906847142..b1b4ae5e5 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -10,6 +10,9 @@ * concrete generators) lives in `insight-status-generate.ts` and is * imported solely by the worker boot file. */ +// Type-only — erased at compile time, so the generator-free boundary of +// this module (see the header) is preserved. +import type { SupportedLocale } from "@/lib/insights/status-shared"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; @@ -87,7 +90,7 @@ export interface InsightStatusGeneratePayload { */ metric: InsightStatusScope; /** Resolved client locale — the cache key the route reads against. */ - locale: "de" | "en"; + locale: SupportedLocale; } /** diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts index 4b96cd16d..bb20d30c0 100644 --- a/src/lib/jobs/insight-status-generate.ts +++ b/src/lib/jobs/insight-status-generate.ts @@ -27,6 +27,7 @@ * import cycle). This module re-exports them for the worker's convenience. */ import { annotate } from "@/lib/logging/context"; +import type { SupportedLocale } from "@/lib/insights/status-shared"; import { generateGeneralStatusForUser } from "@/lib/insights/general-status"; import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status"; import { generateWeightStatusForUser } from "@/lib/insights/weight-status"; @@ -70,7 +71,7 @@ export type { InsightStatusMetric, InsightStatusGeneratePayload }; */ type StatusGenerator = ( userId: string, - options: { locale: "de" | "en"; force: boolean }, + options: { locale: SupportedLocale; force: boolean }, ) => Promise; const GENERATORS: Record = { diff --git a/src/lib/jobs/morning-digest-refresh.ts b/src/lib/jobs/morning-digest-refresh.ts index acaa6151d..28f1063da 100644 --- a/src/lib/jobs/morning-digest-refresh.ts +++ b/src/lib/jobs/morning-digest-refresh.ts @@ -35,7 +35,10 @@ import { type GenerateOutcome, } from "@/lib/insights/comprehensive-generate"; import { enqueuePregenerateFailureRetry } from "@/lib/jobs/insight-pregenerate-shared"; -import { normalizeLocale } from "@/lib/insights/status-shared"; +import { + normalizeLocale, + type SupportedLocale, +} from "@/lib/insights/status-shared"; import { MORNING_DIGEST_REFRESH_QUEUE, type MorningDigestRefreshPayload, @@ -52,7 +55,7 @@ export interface MorningDigestRefreshResult { type GenerateFn = ( userId: string, - options: { locale: "de" | "en"; force?: boolean }, + options: { locale: SupportedLocale; force?: boolean }, ) => Promise; /** @@ -70,7 +73,7 @@ export async function runMorningDigestRefresh( generate?: GenerateFn; enqueueRetry?: (args: { userId: string; - locale: "de" | "en"; + locale: SupportedLocale; }) => Promise; } = {}, ): Promise { diff --git a/src/lib/jobs/reminder/insights-handlers.ts b/src/lib/jobs/reminder/insights-handlers.ts index aa9e76ceb..2c7117714 100644 --- a/src/lib/jobs/reminder/insights-handlers.ts +++ b/src/lib/jobs/reminder/insights-handlers.ts @@ -6,6 +6,7 @@ */ import { type Job } from "pg-boss"; import { reportWorkerError } from "@/lib/jobs/report-worker-error"; +import { normalizeLocale } from "@/lib/insights/status-shared"; import { recordError, recordInsightsRun } from "@/lib/jobs/worker-status"; import { INSIGHT_PREGENERATE_QUEUE, @@ -142,9 +143,10 @@ async function runStatusBatchCron(taskName: string): Promise { for (const user of users) { try { const result = await generateStatusBatchForUser(user.id, { - // The batch needs a concrete `de`/`en`; the per-metric generators - // normalise the same way (de stays de, everything else English). - locale: user.locale === "de" ? "de" : "en", + // Validate against the six UI locales rather than collapsing to a + // binary — the per-metric generators normalise identically, and the + // prompt names the reader's own language from this value. + locale: normalizeLocale(user.locale), force: false, }); generated += result.batched + result.fellBack; @@ -238,7 +240,9 @@ export async function handleInsightPregenerateJob( for (const job of forced) { const userId = job.data.userId as string; - const locale = job.data.locale === "en" ? "en" : "de"; + // The former `=== "en" ? "en" : "de"` defaulted a fr/es/it/pl payload to + // GERMAN, so a forced warm produced German prose for a French reader. + const locale = normalizeLocale(job.data.locale); try { const summary = await forceWarmUser(getWorkerPrisma(), userId, locale); evt.addMeta( From e4ec0eec498c94d4160f6b4b9dc6e37a27ae02db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:25:44 +0200 Subject: [PATCH 06/13] fix(insights): stop sending German period narratives to fr/es/it/pl readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/insights/chat/route.ts | 10 +- src/app/api/insights/narrative/route.ts | 14 ++- .../ai/coach/coach-memory-refresh-worker.ts | 5 +- src/lib/ai/coach/coach-memory-shared.ts | 2 +- src/lib/ai/coach/memory-snapshot.ts | 8 +- src/lib/ai/coach/snapshot.ts | 11 ++- src/lib/dashboard/snapshot.ts | 16 ++-- .../narrative/period-narrative-generate.ts | 91 +++++++++++++++---- src/lib/jobs/period-narrative-shared.ts | 7 +- src/lib/jobs/period-narrative-warm.ts | 16 +++- 10 files changed, 138 insertions(+), 42 deletions(-) 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, }); } From decc6681f6f89efab1292aa27bc0cab27d827c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:29:25 +0200 Subject: [PATCH 07/13] test(ai): pin the output-language adoption across the prompt family Holds every sibling prompt module to one invariant: strip the directive and the interpolated language name out of a French, Spanish, Italian or Polish prompt and what remains is the English prompt, byte for byte. A German instruction body leaking back into a non-German prompt, an English body drifting away from what the four locales receive, and a module appending a second directive on top of the one the base prompt already carries all fail here. Also drops the now-unused narrow locale alias from the two modules whose prompt builders take the full locale. --- .../output-language-adoption.test.ts | 271 ++++++++++++++++++ src/lib/insights/biomarker-status.ts | 1 - .../insights/derived/derived-assessment-ai.ts | 1 - 3 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 src/lib/ai/prompts/__tests__/output-language-adoption.test.ts diff --git a/src/lib/ai/prompts/__tests__/output-language-adoption.test.ts b/src/lib/ai/prompts/__tests__/output-language-adoption.test.ts new file mode 100644 index 000000000..309f71fae --- /dev/null +++ b/src/lib/ai/prompts/__tests__/output-language-adoption.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from "vitest"; + +import { locales, type Locale } from "@/lib/i18n/config"; +import { + outputLanguageDirective, + targetLanguageName, +} from "@/lib/ai/prompts/output-language"; +import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt"; +import { + getGeneralStatusSystemPrompt, + getGeneralStatusUserPrompt, +} from "@/lib/ai/prompts/general-status"; +import { getBmiSystemPrompt, getBmiUserPrompt } from "@/lib/ai/prompts/bmi"; +import { + getBloodPressureSystemPrompt, + getBloodPressureUserPrompt, +} from "@/lib/ai/prompts/blood-pressure"; +import { + getWeightSystemPrompt, + getWeightUserPrompt, +} from "@/lib/ai/prompts/weight"; +import { + getPulseSystemPrompt, + getPulseUserPrompt, +} from "@/lib/ai/prompts/pulse"; +import { getMoodSystemPrompt, getMoodUserPrompt } from "@/lib/ai/prompts/mood"; +import { + getMedicationComplianceSystemPrompt, + getMedicationComplianceUserPrompt, +} from "@/lib/ai/prompts/medication-compliance"; +import { + getMetricArchetypeSystemPrompt, + getMetricArchetypeUserPrompt, +} from "@/lib/ai/prompts/metric-archetypes"; +import { getStatusBatchSystemPrompt } from "@/lib/ai/prompts/status-batch"; +import { buildInterpretationBlock } from "@/lib/ai/prompts/interpretation-block"; +import { + buildUserPrompt, + buildComparisonBlock, +} from "@/lib/ai/prompts/insight-system-prompt"; +import type { MetricStatusMeta } from "@/lib/insights/metric-status-registry"; + +/** + * Adoption contract for the assessment prompt family. + * + * `output-language-contract.test.ts` pins the helper and the base system + * prompt. This file pins the SIBLING modules that compose on top of it — the + * per-metric system prompts, their user-prompt scaffolding, the batch wrapper, + * the interpretation block, the insights user prompt and the Coach prefs + * prefix. + * + * The invariant each module is held to: strip the one directive out of the + * four-locale prompt and what remains is the English prompt, byte for byte. + * That single assertion carries three guarantees. The four locales compose the + * ENGLISH body (no German leak); the body they compose is byte-identical to + * the English one, so an English prompt edit can never silently diverge from + * what a French reader receives; and the directive rides EXACTLY once — the + * double-append a module would produce by adding its own directive on top of + * the one `getBaseSystemPrompt` already carries shows up here as a leftover, + * and again in the occurrence count below. + * + * Note where the directive sits. `getBaseSystemPrompt` appends it last, and + * each of these modules then appends its per-metric section AFTER that — so on + * a composed prompt the directive sits at the base/section boundary rather + * than at the very end. It is present and unambiguous either way; moving it + * back to terminal position would mean composing the base without the + * directive and re-appending after the section, which is a change to the + * shared base prompt, not to these modules. + * + * User-prompt scaffolding carries no directive of its own (it rides the system + * prompt), so there the invariant is plain equality with the English text. + */ + +/** Words that appear in a German instruction body and in no English one. */ +const GERMAN_SENTINEL = + /AUSGABEFORMAT|Antworte ausschließlich|Einschätzung|Schreibe eine|GEBÜNDELTE|EINORDNUNGS-KONTEXT|VERGLEICHSMODUS|Analysiere/; + +/** The four locales that ride the English body plus their own directive. */ +const RIDERS = ["fr", "es", "it", "pl"] as const; + +const ARGS = ["{SNAPSHOT}", "2026-07-18"] as const; + +const META: MetricStatusMeta = { + key: "STEP_COUNT", + displayName: "Steps", + unit: "steps", + direction: "higher-better", + archetype: "activity-fitness", + normalRange: { low: 7000, high: 12000 }, +} as unknown as MetricStatusMeta; + +/** + * System-prompt builders: every one of these composes `getBaseSystemPrompt`, + * which already carries the directive — so none of them may append its own. + */ +const SYSTEM_BUILDERS: ReadonlyArray<{ + name: string; + build: (locale: Locale) => string; +}> = [ + { name: "general-status", build: getGeneralStatusSystemPrompt }, + { name: "bmi", build: getBmiSystemPrompt }, + { name: "blood-pressure", build: getBloodPressureSystemPrompt }, + { name: "weight", build: getWeightSystemPrompt }, + { name: "pulse", build: getPulseSystemPrompt }, + { name: "mood", build: getMoodSystemPrompt }, + { + name: "medication-compliance", + build: getMedicationComplianceSystemPrompt, + }, + { + name: "metric-archetypes", + build: (l) => getMetricArchetypeSystemPrompt(META, l), + }, + { + name: "status-batch", + build: (l) => getStatusBatchSystemPrompt(l, ["bp", "weight"]), + }, +]; + +/** User-prompt scaffolding — English text for every non-German locale. */ +const USER_BUILDERS: ReadonlyArray<{ + name: string; + build: (locale: Locale) => string | undefined; +}> = [ + { + name: "general-status", + build: (l) => getGeneralStatusUserPrompt(...ARGS, l), + }, + { name: "bmi", build: (l) => getBmiUserPrompt(...ARGS, l) }, + { + name: "blood-pressure", + build: (l) => getBloodPressureUserPrompt(...ARGS, l), + }, + { name: "weight", build: (l) => getWeightUserPrompt(...ARGS, l) }, + { name: "pulse", build: (l) => getPulseUserPrompt(...ARGS, l) }, + { name: "mood", build: (l) => getMoodUserPrompt(...ARGS, l) }, + { + name: "medication-compliance", + build: (l) => getMedicationComplianceUserPrompt(...ARGS, l), + }, + { + name: "metric-archetypes", + build: (l) => getMetricArchetypeUserPrompt(META, ...ARGS, l), + }, + { + name: "interpretation-block", + build: (l) => + buildInterpretationBlock({ + metricKey: "RESTING_HEART_RATE", + value: 58, + sex: "MALE", + locale: l, + }), + }, + { + name: "insight-system-prompt", + build: (l) => buildUserPrompt("{FEATURES}", "raw", l), + }, + { + name: "insight-comparison-block", + build: (l) => + buildComparisonBlock(l, { + baseline: "lastMonth", + metrics: [ + { + type: "weight", + currentAvg: 82.1, + baselineAvg: 83.2, + delta: -1.1, + deltaPercent: -1.3, + unit: "kg", + }, + ], + }), + }, + { + name: "insight-comparison-block (no metrics)", + build: (l) => + buildComparisonBlock(l, { baseline: "lastYear", metrics: [] }), + }, +]; + +describe.each(SYSTEM_BUILDERS)("$name system prompt", ({ build }) => { + it.each(RIDERS)("%s composes the English body plus its directive", (l) => { + const directive = outputLanguageDirective(l); + expect(directive).not.toBe(""); + // Two things legitimately differ from the English prompt: the directive + // itself, and the language name the base body interpolates into its + // output clause ("the complete assessment in Polish"). Undo exactly those + // two and nothing else may remain. + const normalised = build(l) + .replace(`\n\n${directive}`, "") + .split(targetLanguageName(l)) + .join("English"); + expect(normalised).toBe(build("en")); + }); + + it("appends the directive exactly once — never twice", () => { + // A module that appended its own directive on top of the one + // `getBaseSystemPrompt` already carries would count two here. + for (const l of RIDERS) { + expect(occurrences(build(l), "OUTPUT LANGUAGE:"), `${l}`).toBe(1); + } + // de and en name their language inside their own body; no directive. + expect(occurrences(build("de"), "OUTPUT LANGUAGE:")).toBe(0); + expect(occurrences(build("en"), "OUTPUT LANGUAGE:")).toBe(0); + }); + + it("sends German instruction text to German readers only", () => { + expect(GERMAN_SENTINEL.test(build("de"))).toBe(true); + for (const l of locales) { + if (l === "de") continue; + expect(GERMAN_SENTINEL.test(build(l)), `${l}`).toBe(false); + } + }); + + it("keeps German and English distinct", () => { + expect(build("de")).not.toBe(build("en")); + }); +}); + +describe.each(USER_BUILDERS)("$name user prompt", ({ build }) => { + it.each(RIDERS)("%s receives the English scaffolding verbatim", (l) => { + expect(build(l)).toBe(build("en")); + }); + + it("sends German scaffolding to German readers only", () => { + expect(GERMAN_SENTINEL.test(build("de") ?? "")).toBe(true); + for (const l of locales) { + if (l === "de") continue; + expect(GERMAN_SENTINEL.test(build(l) ?? ""), `${l}`).toBe(false); + } + }); +}); + +describe("coach preference overrides", () => { + const prefs = { tone: "neutral", verbosity: "detailed" } as never; + + it("never carries German override lines on a non-German prompt", () => { + for (const l of locales) { + const prompt = getCoachSystemPrompt(l, prefs); + if (l === "de") { + expect(prompt).toContain("TONFALL-OVERRIDE:"); + continue; + } + // The French body is native; only the override prefix is at issue here. + expect(prompt, `${l} carries a German tone override`).not.toContain( + "TONFALL-OVERRIDE:", + ); + expect(prompt).toContain("TONE OVERRIDE:"); + expect(prompt).not.toContain("AUSFÜHRLICHKEITS-OVERRIDE:"); + expect(prompt).toContain("VERBOSITY OVERRIDE:"); + } + }); + + it("never carries a German v1.22 addendum on a non-German prompt", () => { + for (const l of locales) { + const prompt = getCoachSystemPrompt(l); + if (l === "de") { + expect(prompt).toContain("COACH-ZUSATZ (v1.22)"); + continue; + } + expect(prompt, `${l}`).not.toContain("COACH-ZUSATZ (v1.22)"); + expect(prompt).toContain("COACH ADDENDUM (v1.22)"); + } + }); +}); + +function occurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index a2f388e92..de66cff7e 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -32,7 +32,6 @@ import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { - type SupportedLocale, normalizeLocale, normalizeSummaryText, parseSummaryFromContent, diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts index 9ee22913f..bd6d002c4 100644 --- a/src/lib/insights/derived/derived-assessment-ai.ts +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -31,7 +31,6 @@ import { normalizeSummaryText, parseSummaryFromContent, persistStatusInsight, - type SupportedLocale, } from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { From 81065f3f9d769501b93b2b4ebc36e7da95aae95e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:30:57 +0200 Subject: [PATCH 08/13] test(insights): pin the reader's locale from entry point to prompt Covers the assertion that would have caught the narrowing: normalizeLocale passes all six locales through and defaults an unknown value to English, the de/en cache keys stay byte-identical while the four widened locales get their own, and a fr locale entering a status generator reaches the prompt builder as fr and writes the fr cache row. The forced-warm case pins the same for the comprehensive and per-status warms. --- .../__tests__/locale-pipeline.test.ts | 177 ++++++++++++++++++ .../__tests__/insight-pregenerate.test.ts | 30 +++ 2 files changed, 207 insertions(+) create mode 100644 src/lib/insights/__tests__/locale-pipeline.test.ts diff --git a/src/lib/insights/__tests__/locale-pipeline.test.ts b/src/lib/insights/__tests__/locale-pipeline.test.ts new file mode 100644 index 000000000..356539dc0 --- /dev/null +++ b/src/lib/insights/__tests__/locale-pipeline.test.ts @@ -0,0 +1,177 @@ +/** + * The assessment pipeline must carry the reader's locale end-to-end. + * + * The bug these tests would have caught: every stage typed its locale as + * `de | en` and narrowed on the way in, so a French account's locale was + * destroyed before any prompt saw it. The output-language directive in the + * prompt layer is INERT unless `fr` actually arrives there, so the assertion + * that matters is not "the pipeline compiles" but "the locale handed to the + * prompt builder is the one the reader has". + * + * Three things are pinned here: + * 1. `normalizeLocale` passes all six through and defaults unknown values to + * ENGLISH — a German default is the whole bug class. + * 2. Cache-key stability: an existing de/en account keeps reading its + * existing `insights.-status.` rows; only the four new + * locales get new (cold) keys. + * 3. End-to-end: a `fr` locale entering a generator reaches the prompt + * builder as `fr`, and a `fr` forced-warm job reaches `forceWarmUser` + * as `fr` (that site previously defaulted to German). + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { locales } from "@/lib/i18n/config"; +import { normalizeLocale } from "@/lib/insights/status-shared"; +import { statusCacheAction } from "@/lib/insights/status-cache"; + +describe("normalizeLocale", () => { + it("passes every shipped UI locale through unchanged", () => { + for (const locale of locales) { + expect(normalizeLocale(locale)).toBe(locale); + } + // The four that used to be flattened away are explicitly covered. + expect(normalizeLocale("fr")).toBe("fr"); + expect(normalizeLocale("es")).toBe("es"); + expect(normalizeLocale("it")).toBe("it"); + expect(normalizeLocale("pl")).toBe("pl"); + }); + + it("defaults an unknown, empty, or missing locale to English, never German", () => { + for (const value of [null, undefined, "", "xx", "de-DE", "en_US", "🙂"]) { + expect(normalizeLocale(value)).toBe("en"); + expect(normalizeLocale(value)).not.toBe("de"); + } + }); +}); + +describe("status cache keys", () => { + it("leaves the de/en cache keys byte-identical (no drift for existing accounts)", () => { + expect(statusCacheAction("weight", normalizeLocale("de"))).toBe( + "insights.weight-status.de", + ); + expect(statusCacheAction("weight", normalizeLocale("en"))).toBe( + "insights.weight-status.en", + ); + }); + + it("gives the four widened locales their own keys instead of reusing the English row", () => { + expect(statusCacheAction("weight", normalizeLocale("fr"))).toBe( + "insights.weight-status.fr", + ); + // Before the widening this collapsed onto the English row. + expect(statusCacheAction("weight", normalizeLocale("fr"))).not.toBe( + statusCacheAction("weight", "en"), + ); + }); +}); + +// ── end-to-end: entry point → prompt builder ────────────────────────────── + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + measurement: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), + statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock("@/lib/insights/memory", () => ({ + getPreviousInsightContext: vi.fn().mockResolvedValue(null), + formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), +})); + +/** + * The capture seam. `getGeneralStatusSystemPrompt` is the LAST hop before the + * provider call — whatever locale arrives here is the locale the prompt (and + * therefore its output-language directive) is composed for. + */ +const systemPromptLocales: string[] = []; +vi.mock("@/lib/ai/prompts/general-status", () => ({ + getGeneralStatusSystemPrompt: (locale: string) => { + systemPromptLocales.push(locale); + return "SYSTEM"; + }, + getGeneralStatusUserPrompt: () => "USER", +})); + +import { prisma } from "@/lib/db"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { generateGeneralStatusForUser } from "@/lib/insights/general-status"; + +const dayMs = 24 * 60 * 60 * 1000; + +beforeEach(() => { + // Clear recorded calls so each case asserts only its own cache writes. + vi.clearAllMocks(); + systemPromptLocales.length = 0; + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + heightCm: null, + insightsExcludeMetrics: [], + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + + const now = Date.now(); + vi.mocked(prisma.measurement.findMany).mockResolvedValue( + Array.from({ length: 40 }, (_, day) => ({ + type: "WEIGHT", + value: 80 + (day % 3), + measuredAt: new Date(now - day * dayMs), + })) as never, + ); + + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "ok", + content: JSON.stringify({ summary: "Résumé." }), + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never); +}); + +describe("a fr locale survives from the generator entry point to the prompt", () => { + it("hands 'fr' to the prompt builder rather than collapsing it to en or de", async () => { + await generateGeneralStatusForUser("u-fr", { locale: "fr", force: true }); + + expect(systemPromptLocales).toContain("fr"); + // The two failure modes the old binary produced. + expect(systemPromptLocales).not.toContain("de"); + expect(systemPromptLocales).not.toContain("en"); + }); + + it("still hands 'de' to a German reader and 'en' to an English one", async () => { + await generateGeneralStatusForUser("u-de", { locale: "de", force: true }); + expect(systemPromptLocales).toEqual(["de"]); + + systemPromptLocales.length = 0; + await generateGeneralStatusForUser("u-en", { locale: "en", force: true }); + expect(systemPromptLocales).toEqual(["en"]); + }); + + it("writes the fr assessment to the fr cache row, leaving the en row untouched", async () => { + await generateGeneralStatusForUser("u-fr", { locale: "fr", force: true }); + + const actions = vi + .mocked(prisma.auditLog.create) + .mock.calls.map( + (call) => (call[0] as { data: { action: string } }).data.action, + ); + expect(actions).toContain("insights.general-status.fr"); + expect(actions).not.toContain("insights.general-status.en"); + }); +}); diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index d3b359212..e8bb06131 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -783,6 +783,36 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { }); }); + it("carries a fr locale into the comprehensive and status warms instead of collapsing it", async () => { + // The regression this pins: the warm typed its locale `de | en`, so a + // French account's warm ran (and cached) in the wrong language — and the + // prompt layer's output-language directive never saw `fr` to act on. + const { prisma } = makePrisma([]); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(3); + + await forceWarmUser(prisma as never, "u1", "fr", { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(generate).toHaveBeenCalledWith("u1", { + locale: "fr", + force: true, + signal: expect.any(AbortSignal), + }); + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledWith("u1", { locale: "fr", force: false }); + } + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["fr"]); + }); + it("skips the comprehensive when the cache was warmed within the freshness window (idempotent re-enqueue)", async () => { // A revalidation poll that outlives the enqueue singleton can stack // several force jobs; the job-start freshness re-check collapses the From 93738ae9688091a689fdd1068fdf4fab9c6768d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:32:49 +0200 Subject: [PATCH 09/13] test(insights): pin the period-narrative and coach output-language contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layers. The German and English narrative prompts are frozen by content hash, normalised for the prompt-version string that a prompt change is required to move — any other drift fails. The four riding locales must name their language, carry the verbatim reply-language directive from their own safety-contract file as the last instruction, and contain no German-body sentinel. A generator-level test drives a French run end to end and asserts the row is keyed fr, the cache action is locale-scoped, and the system prompt reaches the provider in English with the French directive. Alongside it a source-level ban on the exact shape the bug took (`=== "en" ? "en" : "de"`, `?? "de"`) across the coach and narrative surfaces, with a self-check so a typo in the pattern cannot turn the guard into a silent pass, and coach-memory recall cases for all four locales. --- .../coach-locale-no-german-default.test.ts | 76 +++++++ .../coach/__tests__/memory-snapshot.test.ts | 15 ++ .../period-narrative-generate.test.ts | 2 +- .../__tests__/period-narrative-locale.test.ts | 215 ++++++++++++++++++ 4 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts create mode 100644 src/lib/insights/narrative/__tests__/period-narrative-locale.test.ts diff --git a/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts b/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts new file mode 100644 index 000000000..0572940cf --- /dev/null +++ b/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts @@ -0,0 +1,76 @@ +/** + * Regression-class ban for the German-default locale binary. + * + * The bug this pins had exactly one shape: `locale === "en" ? "en" : "de"` + * (and its `=== "de" ? ... : ...` inversions with a `de` fallback). Every + * occurrence sent French, Spanish, Italian and Polish accounts down the German + * branch. This walks the source of the surfaces that carried it and fails on + * the shape itself, so a future edit cannot quietly reintroduce it. + * + * Scoped to the files this change owns — the coach memory refresh, the coach + * snapshot's narrative recall, the narrative route + generator + warm, and the + * dashboard briefing recall. Blunt by design: it matches text, not behaviour, + * which is precisely what makes it survive a refactor of the logic around it. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const ROOT = resolve(__dirname, "../../../.."); + +const GUARDED_FILES = [ + "app/api/insights/chat/route.ts", + "app/api/insights/narrative/route.ts", + "lib/ai/coach/snapshot.ts", + "lib/ai/coach/memory-snapshot.ts", + "lib/ai/coach/coach-memory-refresh-worker.ts", + "lib/dashboard/snapshot.ts", + "lib/insights/narrative/period-narrative-generate.ts", + "lib/jobs/period-narrative-warm.ts", + "lib/jobs/period-narrative-shared.ts", +]; + +/** + * A ternary whose FALSE branch is the German literal — the de-default shape. + * Deliberately narrow: it must not flag `locale === "de" ? deBody : enBody`, + * which is the correct polarity (German only for German readers). + */ +const DE_DEFAULT_TERNARY = /\?\s*"[a-z]{2}"\s*:\s*"de"/; + +/** `payload.locale ?? "de"` and friends — a German default by omission. */ +const DE_DEFAULT_NULLISH = /\?\?\s*"de"/; + +/** + * Read a guarded file with comments stripped. + * + * The comments at these call sites deliberately QUOTE the banned shape to + * explain what was fixed; scanning them would make the guard self-tripping. + * Only executable source is checked. + */ +function readCode(rel: string): string { + return readFileSync(resolve(ROOT, rel), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); +} + +describe("no German-default locale binary", () => { + it.each(GUARDED_FILES)("%s carries no de-default ternary", (rel) => { + expect(readCode(rel)).not.toMatch(DE_DEFAULT_TERNARY); + }); + + it.each(GUARDED_FILES)("%s carries no de-default fallback", (rel) => { + expect(readCode(rel)).not.toMatch(DE_DEFAULT_NULLISH); + }); + + it("recognises the shape it bans", () => { + // Guard the guard: the patterns must actually match the bug they describe, + // so a typo cannot turn this file into a silent pass. + expect(`locale === "en" ? "en" : "de"`).toMatch(DE_DEFAULT_TERNARY); + expect(`payload.locale ?? "de"`).toMatch(DE_DEFAULT_NULLISH); + // ...and must NOT match the correct polarity. + expect(`instructionLocale(locale)`).not.toMatch(DE_DEFAULT_TERNARY); + expect(`locale === "de" ? DE_BODY : EN_BODY`).not.toMatch( + DE_DEFAULT_TERNARY, + ); + }); +}); diff --git a/src/lib/ai/coach/__tests__/memory-snapshot.test.ts b/src/lib/ai/coach/__tests__/memory-snapshot.test.ts index ecc316bc5..7b620f5a2 100644 --- a/src/lib/ai/coach/__tests__/memory-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/memory-snapshot.test.ts @@ -168,4 +168,19 @@ describe("buildCoachMemoryBlock", () => { await buildCoachMemoryBlock(USER, PROFILE, NOW, "en"); expect(readPeriodNarrative).toHaveBeenCalledWith(USER, "month", "en"); }); + + // The narrative row is keyed by the full UI locale union. A French account's + // recall must read the `fr` row — narrowing it to `de` (the old behaviour) + // recalled a German narrative, and narrowing it to `en` would read a row + // that account never has. + it.each(["fr", "es", "it", "pl"] as const)( + "reads the %s narrative row without narrowing the locale", + async (locale) => { + readPeriodNarrative.mockResolvedValue(null); + buildPeriodNarrativeContext.mockResolvedValue(readyContext([])); + + await buildCoachMemoryBlock(USER, PROFILE, NOW, locale); + expect(readPeriodNarrative).toHaveBeenCalledWith(USER, "month", locale); + }, + ); }); diff --git a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts index 04f0b6bcd..12fec86c8 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts @@ -115,7 +115,7 @@ describe("buildNarrativeUserPrompt", () => { expect(prompt).toContain("WEIGHT"); expect(prompt).toContain("ACTIVITY_STEPS ~ SLEEP_DURATION"); expect(prompt).toContain("12 pairs tested"); - expect(NARRATIVE_PROMPT_VERSION).toBe("1.13.0"); + expect(NARRATIVE_PROMPT_VERSION).toBe("1.14.0"); }); }); diff --git a/src/lib/insights/narrative/__tests__/period-narrative-locale.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-locale.test.ts new file mode 100644 index 000000000..c2531088e --- /dev/null +++ b/src/lib/insights/narrative/__tests__/period-narrative-locale.test.ts @@ -0,0 +1,215 @@ +/** + * Output-language contract for the PERIOD NARRATIVE surface. + * + * The bug this pins: the read route narrowed every non-English locale to `de`, + * so a French, Spanish, Italian or Polish reader received a German + * retrospective composed from the German instruction body. The fix keeps the + * two reviewed bodies (de, en) and lets the other four ride the English body + * with their language named and their OWN reply-language directive appended. + * + * Three things are asserted here, in ascending strength: + * 1. the four locales name their language and carry the verbatim directive + * from the translator-facing `safety-contracts..yaml`; + * 2. no non-German prompt contains a German-body sentinel — the "nothing + * silently falls back to German" assertion this whole change exists for; + * 3. the German and English prompts are byte-identical to their pre-change + * form, modulo the prompt-version string, pinned by content hash. + */ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc:${s}`, + decrypt: (s: string) => s.replace(/^enc:/, ""), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { createHash } from "node:crypto"; +import { + buildNarrativeSystemPrompt, + generatePeriodNarrative, + NARRATIVE_PROMPT_VERSION, +} from "@/lib/insights/narrative/period-narrative-generate"; +import { loadSafetyContracts } from "@/lib/ai/prompts/safety-contracts"; +import { locales, type Locale } from "@/lib/i18n/config"; +import type { PeriodNarrativeContext } from "@/lib/insights/narrative/period-narrative"; + +/** The four locales that ride the English body. */ +const RIDING_LOCALES: Locale[] = ["fr", "es", "it", "pl"]; + +const LANGUAGE_NAMES: Record = { + fr: "French", + es: "Spanish", + it: "Italian", + pl: "Polish", +}; + +/** + * Sentinels that only ever appear in the GERMAN instruction body. A hit on a + * non-German prompt means that locale fell back to German — the exact bug. + */ +const GERMAN_SENTINELS = [ + "AUSGABEFORMAT", + "Antworte", + "auf Deutsch", + "Feste Regeln", + "Prompt-Version", +]; + +/** + * sha256 of the composed prompt with the version string normalised away. + * + * The version is deliberately excluded: bumping `NARRATIVE_PROMPT_VERSION` is + * a required part of any prompt change and is the ONE byte the de/en prompts + * are allowed to move by. Everything else is frozen. + */ +function promptHash(locale: "de" | "en"): string { + const normalised = buildNarrativeSystemPrompt(locale) + .split(NARRATIVE_PROMPT_VERSION) + .join("VERSION"); + return createHash("sha256").update(normalised, "utf8").digest("hex"); +} + +/** + * Hashes captured from the prompts as they stood at NARRATIVE_PROMPT_VERSION + * 1.13.0, before the output-language change. If either moves, the de or en + * prompt drifted — which this change promised would not happen. + */ +const FROZEN_PROMPT_HASHES: Record<"de" | "en", string> = { + de: "be667f16beeb44f51a81fedd929716b2249579fe914b45bb8cba0cd9e64a8c6d", + en: "bb0b080e21a2b9729bdf0047ae221c326ec29ecd74d0999f19ea8deabcbf078a", +}; + +describe("period-narrative output language", () => { + it("keeps the German and English prompts byte-identical modulo the version", () => { + expect(promptHash("de")).toBe(FROZEN_PROMPT_HASHES.de); + expect(promptHash("en")).toBe(FROZEN_PROMPT_HASHES.en); + }); + + it("appends no directive to the two reviewed bodies", () => { + expect(buildNarrativeSystemPrompt("de")).not.toContain("OUTPUT LANGUAGE:"); + expect(buildNarrativeSystemPrompt("en")).not.toContain("OUTPUT LANGUAGE:"); + // The English body must not grow a language rule for its own locale. + expect(buildNarrativeSystemPrompt("en")).not.toContain( + "Write the summary in English.", + ); + }); + + it.each(RIDING_LOCALES)( + "names the language and carries the verbatim directive for %s", + (locale) => { + const prompt = buildNarrativeSystemPrompt(locale); + const directive = loadSafetyContracts(locale).reply_language_directive; + + expect(prompt).toContain( + `Write the summary in ${LANGUAGE_NAMES[locale]}.`, + ); + expect(prompt).toContain(`OUTPUT LANGUAGE: ${directive}`); + // The directive is the LAST instruction the model reads. + expect(prompt.trimEnd().endsWith(directive.trimEnd())).toBe(true); + // The scaffolding is the English body, not a translation of it. + expect(prompt).toContain("Hard rules:"); + }, + ); + + it("specifically composes the French prompt in English with the French directive", () => { + const prompt = buildNarrativeSystemPrompt("fr"); + expect(prompt).toContain("Write the summary in French."); + expect(prompt).toContain( + loadSafetyContracts("fr").reply_language_directive, + ); + expect(prompt).toContain( + "You summarise one person's health-tracking PERIOD", + ); + }); + + it.each(locales.filter((l) => l !== "de"))( + "leaks no German instruction body into the %s prompt", + (locale) => { + const prompt = buildNarrativeSystemPrompt(locale); + for (const sentinel of GERMAN_SENTINELS) { + expect(prompt).not.toContain(sentinel); + } + }, + ); + + it("keeps the German prompt German", () => { + const prompt = buildNarrativeSystemPrompt("de"); + expect(prompt).toContain("Feste Regeln:"); + expect(prompt).toContain("Prompt-Version:"); + }); +}); + +describe("period-narrative locale storage", () => { + function readyContext(): PeriodNarrativeContext { + return { + status: "ready", + period: "week", + metricDeltas: [ + { + type: "WEIGHT", + unit: "kg", + current: 80, + prior: 81, + delta: -1, + deltaPercent: -1.2, + currentDays: 6, + priorDays: 7, + }, + ], + bandTransitions: [], + drivers: [], + coincidentFlags: [], + pairsTested: 12, + fdrQ: 0.1, + provenance: { + metrics: ["WEIGHT"], + window: { + from: "2026-05-01T00:00:00.000Z", + to: "2026-05-15T00:00:00.000Z", + }, + computedAt: "2026-05-15T05:00:00.000Z", + }, + }; + } + + it("keys the row by the reader's own locale and prompts in their language", async () => { + const upsert = vi.fn(async (args: { create: { locale: string } }) => args); + const prisma = { + user: { findUnique: vi.fn(async () => ({ timezone: "Europe/Berlin" })) }, + insightNarrative: { findUnique: vi.fn(async () => null), upsert }, + }; + let capturedSystemPrompt = ""; + const runCompletion = vi.fn(async (args: { systemPrompt: string }) => { + capturedSystemPrompt = args.systemPrompt; + return { + kind: "ok" as const, + content: "Votre semaine.", + providerType: "mock", + }; + }); + + const outcome = await generatePeriodNarrative("user-1", { + period: "week", + locale: "fr", + force: true, + buildContext: vi.fn(async () => readyContext()) as never, + runCompletion: runCompletion as never, + prisma: prisma as never, + }); + + expect(outcome).toEqual({ status: "generated", providerType: "mock" }); + // The row is stored under `fr` — NOT collapsed into the de or en row, so a + // French retrospective is never served to a German or English reader. + expect(upsert.mock.calls[0]?.[0]).toMatchObject({ + create: { locale: "fr" }, + where: { userId_period_locale: { locale: "fr" } }, + }); + expect(capturedSystemPrompt).toContain("Write the summary in French."); + expect(capturedSystemPrompt).not.toContain("AUSGABEFORMAT"); + // The provider cache action is locale-scoped too. + expect(runCompletion.mock.calls[0]?.[0]).toMatchObject({ + cacheAction: "insights.narrative.week.fr", + }); + }); +}); From ae3d291feb30ef4f867f1e89467151afa2f51790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:33:37 +0200 Subject: [PATCH 10/13] refactor(insights): name the instruction-body rule on the derived grid The derived batch grid carries only the deterministic assessment, whose templates ship de/en bodies. Its `locale === "de" ? "de" : "en"` was correct but indistinguishable from the accidental binaries that were just removed; route it through instructionLocale so the intent is explicit. Behaviour unchanged. --- src/app/api/insights/derived/batch/route.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index ae7f56332..bb21aa98b 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -39,6 +39,7 @@ import { } from "@/lib/insights/derived"; import { resolveDeterministicAssessment } from "@/lib/insights/derived/derived-assessment"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { instructionLocale } from "@/lib/ai/prompts/output-language"; import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; import { DERIVED_MODULE } from "../route"; @@ -177,7 +178,12 @@ export const GET = apiHandler(async (request: NextRequest) => { userLocale: user.locale ?? null, override: request.nextUrl.searchParams.get("locale"), }); - const assessmentLocale = locale === "de" ? "de" : "en"; + // This grid only carries the DETERMINISTIC assessment, whose templates ship + // de/en bodies — so the same instruction-body rule the prompts use applies: + // German for a German reader, English for everyone else. Deliberately NOT + // widened to the six; the AI-warm prose (which does carry the full locale) + // is served lazily by the per-score route. + const assessmentLocale = instructionLocale(locale); // v1.16.8 — read-through the per-user derived cache with // stale-while-revalidate. The cold build below walks the rollup tier @@ -244,7 +250,7 @@ export const GET = apiHandler(async (request: NextRequest) => { async function buildDerivedBatch(input: { userId: string; items: BatchItem[]; - assessmentLocale: "de" | "en"; + assessmentLocale: ReturnType; }) { const { userId, items, assessmentLocale } = input; From d6692e3a4b7efa351c2ed7c96a93e690d0db3e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:46:34 +0200 Subject: [PATCH 11/13] fix(coach): compose deterministic memory facts in English by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deterministic fact-pattern pass fell back to German when no locale was passed, the same de-default polarity the narrative surface just lost. Fact text is model-facing memory, so English is the fallback for every locale without a reviewed body; German stays for German readers. Also correct the InsightNarrative.locale doc comment — the column holds any shipped UI locale, not just de/en. Comment only, no SQL change. --- prisma/schema.prisma | 3 ++- src/lib/ai/coach/facts.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca42d421f..f62e1a639 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5226,7 +5226,8 @@ model InsightNarrative { userId String @map("user_id") /// "week" | "month". period String - /// Resolved UI locale of the prose ("de" | "en"). + /// Resolved UI locale of the prose — any shipped UI locale, not just de/en: + /// the prose is generated per locale, so the row is keyed by it. locale String /// UTC YYYY-MM-DD boundary the narrative was generated for. dateKey String @map("date_key") diff --git a/src/lib/ai/coach/facts.ts b/src/lib/ai/coach/facts.ts index c210823f1..265598d64 100644 --- a/src/lib/ai/coach/facts.ts +++ b/src/lib/ai/coach/facts.ts @@ -558,7 +558,10 @@ function deterministicFactText( subject: string, locale: string | undefined, ): string { - const de = locale?.toLowerCase().startsWith("de") ?? true; + // German only for a German reader; every other locale — and an absent one — + // composes ENGLISH. The fact text is model-facing memory, so English is the + // fallback body; the previous `?? true` made an unknown locale German. + const de = locale?.toLowerCase().startsWith("de") ?? false; switch (kind) { case "allergy": return de From d0a74e37da2a3c712030012b36aba54482073a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:54:09 +0200 Subject: [PATCH 12/13] fix(ai): keep the output-language directive terminal on composed prompts getBaseSystemPrompt appended the directive, then each metric module appended its own section after it, so the directive was no longer the last instruction the model reads - the one property that makes it authoritative. The base now exposes its body, and every composing module wraps the finished prompt so the directive ends it. Also drops a duplicate import that survived the merge of the two prompt branches. --- package.json | 2 +- public/sw.js | 2 +- src/lib/ai/prompts/base-system.ts | 18 +++++++++++++++--- src/lib/ai/prompts/blood-pressure.ts | 10 +++++++--- src/lib/ai/prompts/bmi.ts | 10 +++++++--- src/lib/ai/prompts/general-status.ts | 10 +++++++--- src/lib/ai/prompts/medication-compliance.ts | 10 +++++++--- src/lib/ai/prompts/metric-archetypes.ts | 11 +++++++---- src/lib/ai/prompts/mood.ts | 10 +++++++--- src/lib/ai/prompts/pulse.ts | 10 +++++++--- src/lib/ai/prompts/status-batch.ts | 11 +++++++---- src/lib/ai/prompts/weight.ts | 10 +++++++--- .../insights/derived/derived-assessment-ai.ts | 13 ++++++++----- 13 files changed, 88 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index 18aa38d37..0965332cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.14", + "version": "1.30.15", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 5f5202b79..59c89043e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.14"; + /* @sw-version-fallback */ "v1.30.15"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index 6f16d1576..687bf4e03 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -303,14 +303,26 @@ export const ASSESSMENT_SECTION_PAIRS: readonly { de: string; }[] = ASSESSMENT_SECTIONS; -export function getBaseSystemPrompt(locale: Locale): string { +export function getBaseSystemPromptBody(locale: Locale): string { // 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( + return composeAssessmentPrompt( instructionLocale(locale), targetLanguageName(locale), ); - return withOutputLanguage(composed, locale); +} + +/** + * The base prompt as a STANDALONE system prompt, language directive included. + * + * Use this only when the result is handed to the provider as-is. A module that + * appends its own metric section must instead compose + * `getBaseSystemPromptBody` and wrap the finished string in + * `withOutputLanguage`, so the directive stays the last instruction the model + * reads rather than being buried mid-prompt by the appended section. + */ +export function getBaseSystemPrompt(locale: Locale): string { + return withOutputLanguage(getBaseSystemPromptBody(locale), locale); } diff --git a/src/lib/ai/prompts/blood-pressure.ts b/src/lib/ai/prompts/blood-pressure.ts index 29943d94f..0b913a55b 100644 --- a/src/lib/ai/prompts/blood-pressure.ts +++ b/src/lib/ai/prompts/blood-pressure.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; -import { getBaseSystemPrompt } from "./base-system"; +import { getBaseSystemPromptBody } from "./base-system"; +import { withOutputLanguage } from "./output-language"; import { instructionLocale } from "./output-language"; const BP_SECTION_DE = `METRIK — BLUTDRUCK: @@ -31,9 +32,12 @@ export function getBloodPressureSystemPrompt(locale: Locale): string { // language and appends their own directive); only de takes the German one. const section = instructionLocale(locale) === "en" ? BP_SECTION_EN : BP_SECTION_DE; - return `${getBaseSystemPrompt(locale)} + return withOutputLanguage( + `${getBaseSystemPromptBody(locale)} -${section}`; +${section}`, + locale, + ); } export function getBloodPressureUserPrompt( diff --git a/src/lib/ai/prompts/bmi.ts b/src/lib/ai/prompts/bmi.ts index f8d152162..9816b1412 100644 --- a/src/lib/ai/prompts/bmi.ts +++ b/src/lib/ai/prompts/bmi.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; -import { getBaseSystemPrompt } from "./base-system"; +import { getBaseSystemPromptBody } from "./base-system"; +import { withOutputLanguage } from "./output-language"; import { instructionLocale } from "./output-language"; const BMI_SECTION_DE = `METRIK — BMI: @@ -23,9 +24,12 @@ export function getBmiSystemPrompt(locale: Locale): string { // language and appends their own directive); only de takes the German one. const section = instructionLocale(locale) === "en" ? BMI_SECTION_EN : BMI_SECTION_DE; - return `${getBaseSystemPrompt(locale)} + return withOutputLanguage( + `${getBaseSystemPromptBody(locale)} -${section}`; +${section}`, + locale, + ); } export function getBmiUserPrompt( diff --git a/src/lib/ai/prompts/general-status.ts b/src/lib/ai/prompts/general-status.ts index 0c6677cd7..33b4755b4 100644 --- a/src/lib/ai/prompts/general-status.ts +++ b/src/lib/ai/prompts/general-status.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; -import { getBaseSystemPrompt } from "./base-system"; +import { getBaseSystemPromptBody } from "./base-system"; +import { withOutputLanguage } from "./output-language"; import { instructionLocale } from "./output-language"; const GENERAL_SECTION_DE = `METRIK — GESAMTBEWERTUNG: @@ -27,9 +28,12 @@ export function getGeneralStatusSystemPrompt(locale: Locale): string { instructionLocale(locale) === "en" ? GENERAL_SECTION_EN : GENERAL_SECTION_DE; - return `${getBaseSystemPrompt(locale)} + return withOutputLanguage( + `${getBaseSystemPromptBody(locale)} -${section}`; +${section}`, + locale, + ); } export function getGeneralStatusUserPrompt( diff --git a/src/lib/ai/prompts/medication-compliance.ts b/src/lib/ai/prompts/medication-compliance.ts index c0983dbc0..8db71fd31 100644 --- a/src/lib/ai/prompts/medication-compliance.ts +++ b/src/lib/ai/prompts/medication-compliance.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; -import { getBaseSystemPrompt } from "./base-system"; +import { getBaseSystemPromptBody } from "./base-system"; +import { withOutputLanguage } from "./output-language"; import { instructionLocale } from "./output-language"; const MEDCO_SECTION_DE = `METRIK — MEDIKAMENTEN-EINNAHMETREUE: @@ -25,9 +26,12 @@ export function getMedicationComplianceSystemPrompt(locale: Locale): string { // language and appends their own directive); only de takes the German one. const section = instructionLocale(locale) === "en" ? MEDCO_SECTION_EN : MEDCO_SECTION_DE; - return `${getBaseSystemPrompt(locale)} + return withOutputLanguage( + `${getBaseSystemPromptBody(locale)} -${section}`; +${section}`, + locale, + ); } export function getMedicationComplianceUserPrompt( diff --git a/src/lib/ai/prompts/metric-archetypes.ts b/src/lib/ai/prompts/metric-archetypes.ts index c8c08952c..1d8f45279 100644 --- a/src/lib/ai/prompts/metric-archetypes.ts +++ b/src/lib/ai/prompts/metric-archetypes.ts @@ -21,8 +21,8 @@ * block, exactly like the specialised generators. */ import type { Locale } from "@/lib/i18n/config"; -import { getBaseSystemPrompt } from "./base-system"; -import { instructionLocale } from "./output-language"; +import { getBaseSystemPromptBody } from "./base-system"; +import { instructionLocale, withOutputLanguage } from "./output-language"; import type { MetricArchetype, MetricStatusMeta, @@ -110,11 +110,14 @@ export function getMetricArchetypeSystemPrompt( const section = ARCHETYPE_SECTION[meta.archetype]; const archetypeText = instructionLocale(locale) === "en" ? section.en : section.de; - return `${getBaseSystemPrompt(locale)} + return withOutputLanguage( + `${getBaseSystemPromptBody(locale)} ${archetypeText} -${metaBlock(meta, locale)}`; +${metaBlock(meta, locale)}`, + locale, + ); } export function getMetricArchetypeUserPrompt( diff --git a/src/lib/ai/prompts/mood.ts b/src/lib/ai/prompts/mood.ts index 1f0b2b672..7e10f6f37 100644 --- a/src/lib/ai/prompts/mood.ts +++ b/src/lib/ai/prompts/mood.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; -import { getBaseSystemPrompt } from "./base-system"; +import { getBaseSystemPromptBody } from "./base-system"; +import { withOutputLanguage } from "./output-language"; import { instructionLocale } from "./output-language"; const MOOD_SECTION_DE = `METRIK — STIMMUNG / WOHLBEFINDEN: @@ -25,9 +26,12 @@ export function getMoodSystemPrompt(locale: Locale): string { // language and appends their own directive); only de takes the German one. const section = instructionLocale(locale) === "en" ? MOOD_SECTION_EN : MOOD_SECTION_DE; - return `${getBaseSystemPrompt(locale)} + return withOutputLanguage( + `${getBaseSystemPromptBody(locale)} -${section}`; +${section}`, + locale, + ); } export function getMoodUserPrompt( diff --git a/src/lib/ai/prompts/pulse.ts b/src/lib/ai/prompts/pulse.ts index 7a10338f3..5249b3164 100644 --- a/src/lib/ai/prompts/pulse.ts +++ b/src/lib/ai/prompts/pulse.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; -import { getBaseSystemPrompt } from "./base-system"; +import { getBaseSystemPromptBody } from "./base-system"; +import { withOutputLanguage } from "./output-language"; import { instructionLocale } from "./output-language"; const PULSE_SECTION_DE = `METRIK — PULS / HERZFREQUENZ: @@ -23,9 +24,12 @@ export function getPulseSystemPrompt(locale: Locale): string { // language and appends their own directive); only de takes the German one. const section = instructionLocale(locale) === "en" ? PULSE_SECTION_EN : PULSE_SECTION_DE; - return `${getBaseSystemPrompt(locale)} + return withOutputLanguage( + `${getBaseSystemPromptBody(locale)} -${section}`; +${section}`, + locale, + ); } export function getPulseUserPrompt( diff --git a/src/lib/ai/prompts/status-batch.ts b/src/lib/ai/prompts/status-batch.ts index 2f57b3ea0..340a94f28 100644 --- a/src/lib/ai/prompts/status-batch.ts +++ b/src/lib/ai/prompts/status-batch.ts @@ -18,8 +18,8 @@ * very snapshot + per-metric suffix it would have sent on its own. */ import type { Locale } from "@/lib/i18n/config"; -import { getBaseSystemPrompt } from "./base-system"; -import { instructionLocale } from "./output-language"; +import { getBaseSystemPromptBody } from "./base-system"; +import { instructionLocale, withOutputLanguage } from "./output-language"; /** * The stable per-metric output keys the batch envelope uses. The InsightStatus @@ -47,7 +47,7 @@ export function getStatusBatchSystemPrompt( locale: Locale, presentKeys: readonly string[], ): string { - const base = getBaseSystemPrompt(locale); + const base = getBaseSystemPromptBody(locale); const keyList = presentKeys.join(", "); const en = `BATCHED OUTPUT — you are assessing SEVERAL of this user's metrics in one pass. Each metric below carries its OWN graded snapshot under a "### " heading; assess each one STRICTLY from its own snapshot block, applying every rule above per metric. Do NOT let one metric's finding leak into another. Do NOT invent a metric: assess ONLY the keys present below. @@ -59,7 +59,10 @@ Include a key ONLY if its snapshot block is present below (keys present: ${keyLi AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. "perMetric" enthält je vorhandenem Key eine kurze Einschätzung (je 2-4 Sätze, derselbe Vertrag wie eine Einzelkarte): { "perMetric": { ${presentKeys.map((k) => `"${k}": "..."`).join(", ")} } } Nimm einen Key NUR auf, wenn sein Snapshot-Block unten vorhanden ist (vorhandene Keys: ${keyList}). Lass jede Metrik ohne Block weg — erfinde niemals eine.`; - return `${base}\n\n${instructionLocale(locale) === "en" ? en : de}`; + return withOutputLanguage( + `${base}\n\n${instructionLocale(locale) === "en" ? en : de}`, + locale, + ); } /** diff --git a/src/lib/ai/prompts/weight.ts b/src/lib/ai/prompts/weight.ts index 02db48761..2ef68d7cd 100644 --- a/src/lib/ai/prompts/weight.ts +++ b/src/lib/ai/prompts/weight.ts @@ -1,5 +1,6 @@ import type { Locale } from "@/lib/i18n/config"; -import { getBaseSystemPrompt } from "./base-system"; +import { getBaseSystemPromptBody } from "./base-system"; +import { withOutputLanguage } from "./output-language"; import { instructionLocale } from "./output-language"; const WEIGHT_SECTION_DE = `METRIK — GEWICHT: @@ -29,9 +30,12 @@ export function getWeightSystemPrompt(locale: Locale): string { // language and appends their own directive); only de takes the German one. const section = instructionLocale(locale) === "en" ? WEIGHT_SECTION_EN : WEIGHT_SECTION_DE; - return `${getBaseSystemPrompt(locale)} + return withOutputLanguage( + `${getBaseSystemPromptBody(locale)} -${section}`; +${section}`, + locale, + ); } export function getWeightUserPrompt( diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts index 0b8dc81fb..3b142ef60 100644 --- a/src/lib/insights/derived/derived-assessment-ai.ts +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -22,10 +22,14 @@ * Server-only — reads the DB + the provider chain. */ import { - getBaseSystemPrompt, + getBaseSystemPromptBody, PROMPT_VERSION, } from "@/lib/ai/prompts/base-system"; -import { instructionLocale } from "@/lib/ai/prompts/output-language"; +import type { Locale } from "@/lib/i18n/config"; +import { + instructionLocale, + withOutputLanguage, +} from "@/lib/ai/prompts/output-language"; import { normalizeLocale, normalizeSummaryText, @@ -43,7 +47,6 @@ import { import type { MeasurementType } from "@/generated/prisma/client"; import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver"; import { annotate } from "@/lib/logging/context"; -import { instructionLocale } from "@/lib/ai/prompts/output-language"; import { openerArchetypeHint, dayRotatedSeed, @@ -98,7 +101,7 @@ const SCORE_INPUT_TYPES: readonly MeasurementType[] = [ * the way in; today's callers still pass a narrowed locale. */ function scoreSystemPrompt(locale: Locale): string { - const base = getBaseSystemPrompt(locale); + const base = getBaseSystemPromptBody(locale); // The archetype section rides the same reviewed instruction body the base // composes: German for a German reader, English for everyone else. The base // already names the reader's own language, so a French reader gets the @@ -118,7 +121,7 @@ This is a daily wellness proxy, not a clinical or training-recovery verdict. Sta - WAS ES HEUTE BEDEUTET — übersetze das Band in einen Ausblick: ein starker Tagesform-/Erholungs-Score → ein guter Tag, um mehr zu wagen / etwas zu pushen; ein schwacher Score → ein ruhigerer Tag tut gut, nimm ihn als Erholungshinweis. Das ist eine band-bedingte Deutung, KEINE neue Zahl — erfinde dafür keine Zahl. - EIN ANSTOSS — schließe mit dem einen gegroundeten nächsten Schritt am schwächsten verhaltens-adressierbaren Beitrag. Ist nichts verhaltens-adressierbar (der schwache Treiber ist reine Physiologie), bestätige und nenne einen Punkt zum Beobachten, statt einen Schritt zu erfinden. Das ist ein täglicher Wellness-Indikator, kein klinisches Urteil und keine Trainings-Recovery-Bewertung. Bleibe beschreibend, nie diagnostisch, nie alarmierend; die Ermutigung ist durch die Beiträge / den Trend verdient, nie ein reflexhaftes Kompliment und nie bloße Zahlenwiederholung.`; - return `${base}\n\n${section}`; + return withOutputLanguage(`${base}\n\n${section}`, locale); } function scoreUserPrompt( From e04383f015979a9a46ad4413391751d3f61a1e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 23:54:56 +0200 Subject: [PATCH 13/13] =?UTF-8?q?chore(release):=20v1.30.15=20=E2=80=94=20?= =?UTF-8?q?write=20generated=20health=20texts=20in=20the=20account's=20own?= =?UTF-8?q?=20language?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ docs/api/openapi.yaml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dde2a2770..b13ebb8d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [1.30.15] — 2026-07-18 + +Generated health texts now come in the language you set. + +- **French, Spanish, Italian and Polish accounts get their assessments in their own language.** Every locale other than English previously fell back to the German instruction set, so those four either read German text or — where the pipeline narrowed them earlier — English text. Insight cards, the per-metric assessments, derived scores, the biomarker card, the period narrative and the assistant's tone settings all now follow the account's own language, with the wording rules that were already translated for each of the six languages. German and English output is unchanged, character for character. +- **The language instruction is the last thing the model reads**, rather than being buried before the metric-specific section that follows it. +- Under the hood: one shared resolver replaces the scattered English-or-else-German branches, and the locale now survives the whole generation path instead of being narrowed on the way in. Stored texts for the four languages regenerate on their next scheduled run; German and English keep reading exactly the rows they already had. + +No breaking changes. + ## [1.30.14] — 2026-07-18 The workouts page joins the pages that paint on first load. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0658bfef3..c989d9dc7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.14 + version: 1.30.15 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.