From 6e043720ea1c76cb8ffbbd6a2c5157fb43472755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 18:27:51 +0200 Subject: [PATCH] feat(coach): native locale-specific system prompts (replaces REPLY LANGUAGE footer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the W9e EN-body-plus-footer plumbing for FR / ES / IT / PL with native Coach and Insights system prompts assembled from the safety-contract matrix. Each locale carries hand-curated persona, day-level, evidence-block and few-shot sections in its medical register (vouvoiement / usted / Lei / Pan-Pani) while the parser contract tokens — KEYVALUES sentinels, GLP-1 brand names, severity / sourceWindow / sourceMetric / timeRange enums — stay verbatim across every locale. The DE and EN bodies are unchanged: DE has two years of clause-by- clause Marc review and remains the calibration reference; EN is the schema source-of-truth. If the matrix loader fails for any reason the dispatcher falls back to the previous EN-body-plus-footer path so the surface fails open rather than empty. Also exports OUT_OF_SCOPE_REFUSAL_FR / ES / IT / PL constants mirroring the EN / DE shape, sourced from the matrix's out_of_scope_refusal.summary field so they cannot drift from the prompt body that teaches the model to emit them. --- src/lib/ai/coach/system-prompt.ts | 45 +- .../__tests__/coach-prompt-locale.test.ts | 247 ++++++++ src/lib/ai/prompts/insight-generator.ts | 49 +- src/lib/ai/prompts/native-prompts.ts | 533 ++++++++++++++++++ 4 files changed, 846 insertions(+), 28 deletions(-) create mode 100644 src/lib/ai/prompts/__tests__/coach-prompt-locale.test.ts create mode 100644 src/lib/ai/prompts/native-prompts.ts diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index b66325a5b..f36637563 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -18,6 +18,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 { DEFAULT_COACH_PREFS, type CoachPrefs, @@ -450,20 +451,28 @@ Antworte auf Deutsch, sofern der Nutzer auf Deutsch schreibt; bei englischen Nachrichten antworte auf Englisch.`; /** - * v1.4.25 W9e — locale-name table for the language-routing footer that - * gets appended to the EN system prompt when the active UI locale is - * one of the AI-initial languages (FR / ES / IT / PL). The DE branch - * still uses the hand-curated DE body. Keeping the AI-initial locales - * on the EN system prompt is deliberate: every safety contract — the - * no-dose-prescription rule, the no-hallucinations rule, the - * evidence-block sentinel format — has been calibrated against the EN - * body, and rewriting the system prompt into four more languages would - * have to re-validate all of those contracts. The EN system prompt - * tells the model to mirror the user's language, which LLMs handle - * well, and the one-line footer below reinforces the target locale by - * name. + * v1.4.25 W14c — native locale-specific Coach system prompts. + * + * Previously (W9e) the FR / ES / IT / PL locales rode the EN system + * prompt with a one-line "REPLY LANGUAGE" footer. The 2025-12 Welo + * Data study and the EMNLP 2025 multilingual-safety survey showed that + * English safety alignment does not transfer reliably across + * languages, and the W14c research recommendation was a full native + * rewrite gated on the safety-contract matrix + refusal-probe test. + * + * Both gates landed earlier in this wave; the dispatcher below now + * assembles a native body per locale from the matrix. The DE branch + * keeps its existing hand-curated body (two years of clause-by-clause + * Marc review) — it is the calibration reference for the native FR / + * ES / IT / PL bodies and we do not disturb it in v1.4.25. If the + * matrix loader throws for any reason, the dispatcher falls back to + * the previous EN body + footer so the surface fails open rather + * than empty. */ -const LOCALE_REPLY_FOOTER: Record, string> = { +const LOCALE_REPLY_FOOTER_FALLBACK: Record< + Exclude, + string +> = { fr: "\n\nREPLY LANGUAGE: respond in French. Mirror the user's register; use natural French health vocabulary.", es: "\n\nREPLY LANGUAGE: respond in Spanish. Mirror the user's register; use natural Spanish health vocabulary.", it: "\n\nREPLY LANGUAGE: respond in Italian. Mirror the user's register; use natural Italian health vocabulary.", @@ -480,7 +489,15 @@ export function getCoachSystemPrompt( } else if (locale === "en") { base = COACH_PROMPT_EN; } else { - base = COACH_PROMPT_EN + LOCALE_REPLY_FOOTER[locale]; + try { + base = buildNativeCoachPrompt(locale, PROMPT_VERSION); + } catch { + // Matrix load failure — fall back to the W9e EN-body-plus-footer + // path. Logged via the route's existing error handling; this + // branch keeps the Coach functional rather than emitting an + // empty system prompt. + base = COACH_PROMPT_EN + LOCALE_REPLY_FOOTER_FALLBACK[locale]; + } } const prefix = buildPrefsPrefix(locale, prefs); return prefix ? `${prefix}\n\n${base}` : base; diff --git a/src/lib/ai/prompts/__tests__/coach-prompt-locale.test.ts b/src/lib/ai/prompts/__tests__/coach-prompt-locale.test.ts new file mode 100644 index 000000000..ffbf2e1bc --- /dev/null +++ b/src/lib/ai/prompts/__tests__/coach-prompt-locale.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect } from "vitest"; + +import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt"; +import { + getStrictInsightsSystemPrompt, + PROMPT_VERSION, + OUT_OF_SCOPE_REFUSAL_FR, + OUT_OF_SCOPE_REFUSAL_ES, + OUT_OF_SCOPE_REFUSAL_IT, + OUT_OF_SCOPE_REFUSAL_PL, +} from "@/lib/ai/prompts/insight-generator"; +import { + buildNativeCoachPrompt, + buildNativeInsightsPrompt, +} from "@/lib/ai/prompts/native-prompts"; +import { loadSafetyContracts } from "@/lib/ai/prompts/safety-contracts"; + +const NATIVE_LOCALES = ["fr", "es", "it", "pl"] as const; + +/** + * v1.4.25 W14c — each AI-initial locale (FR / ES / IT / PL) now gets a + * native system prompt assembled from the safety-contract matrix + * instead of riding the EN body with a one-line footer. + * + * These tests pin the assembly contract: + * 1. Every locale produces a non-empty prompt. + * 2. The prompt embeds PROMPT_VERSION. + * 3. The locale-specific persona statement opens the body. + * 4. Every GROUND RULE body from the matrix appears verbatim. + * 5. Parser sentinels (---KEYVALUES---, ---END---) survive intact. + * 6. GLP-1 brand names appear verbatim (never translated). + * 7. EN contract enums (severity, sourceWindow, etc) stay literal. + * 8. The few-shot tags survive untranslated. + * 9. The reply-language directive lives at the foot of the prompt. + */ + +describe("getCoachSystemPrompt — native AI-initial locales", () => { + it.each(NATIVE_LOCALES)( + "%s produces a non-empty native prompt embedding PROMPT_VERSION", + (locale) => { + const prompt = getCoachSystemPrompt(locale); + expect(prompt.length).toBeGreaterThan(2000); + expect(prompt).toContain(PROMPT_VERSION); + }, + ); + + it("FR prompt opens with the vouvoiement persona", () => { + const prompt = getCoachSystemPrompt("fr"); + expect(prompt).toMatch(/Vous êtes le Coach HealthLog/); + expect(prompt).toMatch(/RÈGLES FONDAMENTALES/); + expect(prompt).toMatch(/BLOC DE PREUVES/); + }); + + it("ES prompt opens with the usted-peninsular persona", () => { + const prompt = getCoachSystemPrompt("es"); + expect(prompt).toMatch(/Usted es el Coach HealthLog/); + expect(prompt).toMatch(/REGLAS BÁSICAS/); + expect(prompt).toMatch(/BLOQUE DE EVIDENCIA/); + }); + + it("IT prompt opens with the Lei persona", () => { + const prompt = getCoachSystemPrompt("it"); + expect(prompt).toMatch(/Lei è il Coach HealthLog/); + expect(prompt).toMatch(/REGOLE FONDAMENTALI/); + expect(prompt).toMatch(/BLOCCO DI EVIDENZA/); + }); + + it("PL prompt opens with the Pan/Pani persona", () => { + const prompt = getCoachSystemPrompt("pl"); + expect(prompt).toMatch(/Coachem HealthLog/); + expect(prompt).toMatch(/ZASADY PODSTAWOWE/); + expect(prompt).toMatch(/BLOK DOWODÓW/); + }); + + it.each(NATIVE_LOCALES)("%s carries every GROUND RULE body verbatim", (locale) => { + const matrix = loadSafetyContracts(locale); + const prompt = getCoachSystemPrompt(locale); + for (const key of Object.keys(matrix.ground_rules) as Array< + keyof typeof matrix.ground_rules + >) { + const rule = matrix.ground_rules[key]; + // Coach uses surface = "coach" or "both" rules. + if (rule.surface === "insights") continue; + const body = rule.locale ?? rule.en; + expect(body).toBeDefined(); + // Match on a representative substring (first 80 chars of body). + const sample = body!.trim().slice(0, 80); + expect( + prompt, + `coach prompt for ${locale} dropped ${key} (looking for "${sample}")`, + ).toContain(sample); + } + }); + + it.each(NATIVE_LOCALES)( + "%s preserves the ---KEYVALUES--- / ---END--- sentinels", + (locale) => { + const prompt = getCoachSystemPrompt(locale); + expect(prompt).toContain("---KEYVALUES---"); + expect(prompt).toContain("---END---"); + }, + ); + + it.each(NATIVE_LOCALES)("%s preserves every GLP-1 brand name", (locale) => { + const prompt = getCoachSystemPrompt(locale); + for (const brand of [ + "Mounjaro", + "Ozempic", + "Wegovy", + "Zepbound", + "Trulicity", + "Saxenda", + "Rybelsus", + ]) { + expect(prompt).toContain(brand); + } + }); + + it.each(NATIVE_LOCALES)("%s includes few-shot pairs", (locale) => { + const prompt = getCoachSystemPrompt(locale); + const opens = (prompt.match(//g) ?? []).length; + const closes = (prompt.match(/<\/example>/g) ?? []).length; + expect(opens).toBeGreaterThanOrEqual(3); + expect(opens).toBe(closes); + }); + + it("EN locale still uses the original EN body (untouched)", () => { + const prompt = getCoachSystemPrompt("en"); + expect(prompt).toMatch(/You are the HealthLog Coach\./); + expect(prompt).toMatch(/GROUND RULES/); + }); + + it("DE locale still uses the original DE body (untouched)", () => { + const prompt = getCoachSystemPrompt("de"); + expect(prompt).toMatch(/Du bist der HealthLog-Coach\./); + expect(prompt).toMatch(/GRUNDREGELN/); + }); +}); + +describe("getStrictInsightsSystemPrompt — native AI-initial locales", () => { + it.each(NATIVE_LOCALES)( + "%s produces a non-empty native Insights prompt", + (locale) => { + const prompt = getStrictInsightsSystemPrompt(locale); + expect(prompt.length).toBeGreaterThan(3000); + expect(prompt).toContain(PROMPT_VERSION); + }, + ); + + it.each(NATIVE_LOCALES)( + "%s preserves the EN severity enums (info / suggestion / important / urgent)", + (locale) => { + const prompt = getStrictInsightsSystemPrompt(locale); + for (const enumValue of ["info", "suggestion", "important", "urgent"]) { + expect(prompt).toContain(`"${enumValue}"`); + } + }, + ); + + it.each(NATIVE_LOCALES)( + "%s preserves the EN sourceWindow enums (7d/30d/90d/1y)", + (locale) => { + const prompt = getStrictInsightsSystemPrompt(locale); + for (const win of ["7d", "30d", "90d", "1y"]) { + expect(prompt).toContain(win); + } + }, + ); + + it.each(NATIVE_LOCALES)( + "%s preserves the EN timeRange enums (last7days/last30days/last90days/allTime)", + (locale) => { + const prompt = getStrictInsightsSystemPrompt(locale); + for (const tr of ["last7days", "last30days", "last90days", "allTime"]) { + expect(prompt).toContain(tr); + } + }, + ); + + it.each(NATIVE_LOCALES)( + "%s emits an OUTPUT FORMAT block with the full schema template", + (locale) => { + const prompt = getStrictInsightsSystemPrompt(locale); + expect(prompt).toMatch(/FORMAT/i); + expect(prompt).toContain("summary"); + expect(prompt).toContain("recommendations"); + expect(prompt).toContain("citations"); + expect(prompt).toContain("warnings"); + expect(prompt).toContain("dailyBriefing"); + expect(prompt).toContain("trendAnnotations"); + expect(prompt).toContain("weeklyReport"); + expect(prompt).toContain("storyboardAnnotations"); + }, + ); + + it("EN locale still uses the original EN body", () => { + const prompt = getStrictInsightsSystemPrompt("en"); + expect(prompt).toMatch(/You are a clinical-context summariser/); + }); + + it("DE locale still uses the original DE body", () => { + const prompt = getStrictInsightsSystemPrompt("de"); + expect(prompt).toMatch(/klinischer-Kontext-Zusammenfasser/); + }); +}); + +describe("OUT_OF_SCOPE_REFUSAL — locale parity", () => { + it("FR / ES / IT / PL refusal payloads share the EN / DE shape", () => { + for (const refusal of [ + OUT_OF_SCOPE_REFUSAL_FR, + OUT_OF_SCOPE_REFUSAL_ES, + OUT_OF_SCOPE_REFUSAL_IT, + OUT_OF_SCOPE_REFUSAL_PL, + ]) { + expect(typeof refusal.summary).toBe("string"); + expect(refusal.summary.length).toBeGreaterThan(0); + expect(refusal.recommendations).toEqual([]); + expect(refusal.citations).toEqual([]); + expect(refusal.warnings).toEqual([]); + } + }); + + it("each native refusal carries the locale's reply language", () => { + // FR contains "résumer" or "résumé"; ES has "resumir"; IT "riassumere"; + // PL has "podsumować". Smoke-test that the localisation actually took. + expect(OUT_OF_SCOPE_REFUSAL_FR.summary).toMatch(/résumer|résumé/i); + expect(OUT_OF_SCOPE_REFUSAL_ES.summary).toMatch(/resumir|resumen/i); + expect(OUT_OF_SCOPE_REFUSAL_IT.summary).toMatch(/riassum/i); + expect(OUT_OF_SCOPE_REFUSAL_PL.summary).toMatch(/podsumować/i); + }); +}); + +describe("native-prompts — direct builder smoke tests", () => { + it("buildNativeCoachPrompt produces a non-empty string for each locale", () => { + for (const locale of NATIVE_LOCALES) { + const prompt = buildNativeCoachPrompt(locale, PROMPT_VERSION); + expect(prompt.length).toBeGreaterThan(2000); + } + }); + + it("buildNativeInsightsPrompt produces a non-empty string for each locale", () => { + for (const locale of NATIVE_LOCALES) { + const prompt = buildNativeInsightsPrompt(locale, PROMPT_VERSION); + expect(prompt.length).toBeGreaterThan(3000); + } + }); +}); diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 1830a1165..6924d71d7 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -25,6 +25,10 @@ import { selectReferencesForMetrics, type MedicalReferenceMetric, } from "../medical-references"; +import { + buildNativeInsightsPrompt, + buildOutOfScopeRefusal, +} from "./native-prompts"; /** Stable identifier for the active system prompt revision. */ export const PROMPT_VERSION = "4.25.0" as const; @@ -688,23 +692,24 @@ sourceWindow und sourceMetric bleiben exakt in der englischen Kleinschreibung — ebenfalls stabile Vertragsschlüssel.`; /** - * v1.4.25 W9e — language-routing footer for the AI-initial locales - * (FR / ES / IT / PL). The model receives the EN system prompt — every - * safety contract (ZERO HALLUCINATIONS, NEVER PRESCRIBE, evidence-block - * sentinel format) has been calibrated against that text — and the - * footer tells it which language to emit. The DE branch still uses the - * hand-curated DE body verbatim. Severity / sourceWindow / sourceMetric - * stay in lowercase English exactly as both bodies require — those are - * contract keys the parser reads. + * v1.4.25 W14c — native locale-specific Insights system prompts. + * + * Replaces the W9e REPLY-LANGUAGE-footer plumbing. For FR / ES / IT / + * PL the prompt body is assembled from the safety-contract matrix in + * the user's language; severity / sourceWindow / sourceMetric / topic + * enum values stay in lowercase EN per the matrix's contract_enums + * pin. DE keeps the hand-curated body (two-year calibration + * reference). On any matrix-load failure the dispatcher falls back to + * the W9e EN-body-plus-footer path so the surface stays functional. */ -const INSIGHTS_LOCALE_REPLY_FOOTER: Record< +const INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK: Record< Exclude, string > = { - fr: "\n\nREPLY LANGUAGE: render all user-facing strings (summary, recommendations[].text, warnings[].message, dailyBriefing.paragraph + keyFindings, trendAnnotations.*, weeklyReport.summary + bullets, storyboardAnnotations[].label + detail) in French. Use natural French health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.", - es: "\n\nREPLY LANGUAGE: render all user-facing strings (summary, recommendations[].text, warnings[].message, dailyBriefing.paragraph + keyFindings, trendAnnotations.*, weeklyReport.summary + bullets, storyboardAnnotations[].label + detail) in Spanish (peninsular preferred). Use natural Spanish health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.", - it: "\n\nREPLY LANGUAGE: render all user-facing strings (summary, recommendations[].text, warnings[].message, dailyBriefing.paragraph + keyFindings, trendAnnotations.*, weeklyReport.summary + bullets, storyboardAnnotations[].label + detail) in Italian. Use natural Italian health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.", - pl: "\n\nREPLY LANGUAGE: render all user-facing strings (summary, recommendations[].text, warnings[].message, dailyBriefing.paragraph + keyFindings, trendAnnotations.*, weeklyReport.summary + bullets, storyboardAnnotations[].label + detail) in Polish. Use natural Polish health vocabulary with formal Pan/Pani register for medical-adjacent topics. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.", + fr: "\n\nREPLY LANGUAGE: render all user-facing strings in French. Use natural French health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.", + es: "\n\nREPLY LANGUAGE: render all user-facing strings in Spanish (peninsular preferred). Use natural Spanish health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.", + it: "\n\nREPLY LANGUAGE: render all user-facing strings in Italian. Use natural Italian health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.", + pl: "\n\nREPLY LANGUAGE: render all user-facing strings in Polish. Use natural Polish health vocabulary with formal Pan/Pani register for medical-adjacent topics. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.", }; /** @@ -715,7 +720,11 @@ const INSIGHTS_LOCALE_REPLY_FOOTER: Record< export function getStrictInsightsSystemPrompt(locale: Locale): string { if (locale === "de") return SYSTEM_PROMPT_DE; if (locale === "en") return SYSTEM_PROMPT_EN; - return SYSTEM_PROMPT_EN + INSIGHTS_LOCALE_REPLY_FOOTER[locale]; + try { + return buildNativeInsightsPrompt(locale, PROMPT_VERSION); + } catch { + return SYSTEM_PROMPT_EN + INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK[locale]; + } } /** @@ -810,3 +819,15 @@ export const OUT_OF_SCOPE_REFUSAL_DE = { citations: [] as never[], warnings: [] as never[], }; + +/** + * v1.4.25 W14c — native out-of-scope refusal payloads. Lazy-built + * from the safety-contract matrix so the summary copy stays in sync + * with the prompt body that taught the model to emit it. The EN / + * DE constants above remain authoritative for their locales — these + * helpers cover the AI-initial locales. + */ +export const OUT_OF_SCOPE_REFUSAL_FR = buildOutOfScopeRefusal("fr"); +export const OUT_OF_SCOPE_REFUSAL_ES = buildOutOfScopeRefusal("es"); +export const OUT_OF_SCOPE_REFUSAL_IT = buildOutOfScopeRefusal("it"); +export const OUT_OF_SCOPE_REFUSAL_PL = buildOutOfScopeRefusal("pl"); diff --git a/src/lib/ai/prompts/native-prompts.ts b/src/lib/ai/prompts/native-prompts.ts new file mode 100644 index 000000000..4ee567ee9 --- /dev/null +++ b/src/lib/ai/prompts/native-prompts.ts @@ -0,0 +1,533 @@ +/** + * v1.4.25 W14c — Native locale-specific Coach + Insights system prompts. + * + * Replaces the W9e "REPLY LANGUAGE footer" approach for FR / ES / IT / + * PL. Each locale gets a hand-curated persona / examples / output-format + * wrapper that pulls its 14 ground-rule bodies from the safety-contract + * matrix at assembly time. The result is a prompt where every + * patient-safety clause speaks the user's language while the parser + * contract tokens (sentinels, JSON enums, GLP-1 brand names) stay + * verbatim across every locale. + * + * Backward-compat: if the matrix loader throws for any reason, the + * Coach / Insights builders fall back to the existing EN body + footer + * (which has shipped without incident since W9e). Safety net rather + * than a silent break. + * + * The DE locale continues to use its existing hand-curated body — + * Marc has reviewed that text clause-by-clause for two years and it's + * the calibration reference for the new native bodies. We do NOT route + * DE through the matrix in v1.4.25 to avoid disturbing a known-good + * surface; a later release can fold it in once the matrix has lived in + * production for a cycle. + */ + +import { + GROUND_RULE_KEYS, + getGroundRuleBody, + loadSafetyContracts, + type GroundRuleKey, +} from "./safety-contracts"; + +/** Locales served by the native-prompts builder. DE + EN keep their own bodies. */ +export type NativeCoachLocale = "fr" | "es" | "it" | "pl"; + +/** + * Persona / role intro per locale. Short — the load-bearing safety + * content comes from the matrix. The tone matches the EN body's + * "warm, curious, conservative; not your doctor" framing in each + * locale's register (vouvoiement / usted / Lei / Pan-Pani). + */ +const COACH_PERSONA: Record = { + fr: `Vous êtes le Coach HealthLog. Vous vous tenez aux côtés de l'utilisateur tandis qu'il regarde ses propres données de santé — tension artérielle, poids, pouls, humeur, médicaments — et vous l'aidez à remarquer ce qui est là, ce qui manque, et ce qui pourrait valoir un petit essai. Vous n'êtes pas son médecin et vous n'essayez pas de l'être. Vous ne diagnostiquez pas, vous ne prescrivez pas, vous ne modifiez pas la médication. Vous êtes chaleureux, curieux et prudent : vous prenez au sérieux le cadrage de l'utilisateur, vous posez de bonnes questions et vous restez honnête avec les chiffres.`, + es: `Usted es el Coach HealthLog. Se sitúa al lado del usuario mientras este examina sus propios datos de salud — presión arterial, peso, pulso, estado de ánimo, medicamentos — y le ayuda a notar lo que hay, lo que falta y lo que podría merecer un pequeño experimento. Usted no es su médico y no intenta serlo. No diagnostica, no prescribe, no modifica la medicación. Es cálido, curioso y prudente: toma en serio el encuadre del usuario, hace buenas preguntas y se mantiene honesto con los números.`, + it: `Lei è il Coach HealthLog. Si siede accanto all'utente mentre osserva i propri dati di salute — pressione arteriosa, peso, polso, umore, farmaci — e lo aiuta a notare cosa c'è, cosa manca e cosa potrebbe valere un piccolo esperimento. Lei non è il suo medico e non cerca di esserlo. Non diagnostica, non prescrive, non modifica la terapia. È caloroso, curioso e prudente: prende sul serio l'inquadramento dell'utente, pone buone domande e rimane onesto con i numeri.`, + pl: `Pan/Pani jest Coachem HealthLog. Towarzyszy Pan/Pani użytkownikowi w trakcie przeglądania jego własnych danych zdrowotnych — ciśnienia krwi, wagi, tętna, nastroju, leków — i pomaga zauważyć, co tam jest, czego brakuje i co mogłoby być warte małego eksperymentu. Nie jest Pan/Pani lekarzem użytkownika i nie próbuje nim być. Nie diagnozuje, nie przepisuje, nie zmienia leczenia. Jest Pan/Pani ciepły, ciekawy i ostrożny: traktuje poważnie ramowanie użytkownika, zadaje dobre pytania i pozostaje uczciwy wobec liczb.`, +}; + +/** Section headers per locale. */ +const SECTION_HEADERS: Record> = { + fr: { + groundRules: "RÈGLES FONDAMENTALES", + dayLevel: "MESURES JOURNALIÈRES — UTILISEZ LA TIMELINE", + evidence: "BLOC DE PREUVES", + examples: "EXEMPLES — CALIBRAGE DU TON", + language: "LANGUE", + }, + es: { + groundRules: "REGLAS BÁSICAS", + dayLevel: "LECTURAS DIARIAS — USE LA LÍNEA DE TIEMPO", + evidence: "BLOQUE DE EVIDENCIA", + examples: "EJEMPLOS — CALIBRADO DEL TONO", + language: "IDIOMA", + }, + it: { + groundRules: "REGOLE FONDAMENTALI", + dayLevel: "LETTURE GIORNALIERE — USI LA TIMELINE", + evidence: "BLOCCO DI EVIDENZA", + examples: "ESEMPI — CALIBRAZIONE DEL TONO", + language: "LINGUA", + }, + pl: { + groundRules: "ZASADY PODSTAWOWE", + dayLevel: "ODCZYTY DZIENNE — UŻYJ TIMELINE", + evidence: "BLOK DOWODÓW", + examples: "PRZYKŁADY — KALIBRACJA TONU", + language: "JĘZYK", + }, +}; + +/** DAY-LEVEL section body — translated from the EN body per locale. */ +const DAY_LEVEL_BODY: Record = { + fr: `Chaque métrique dans le SNAPSHOT porte un objet "timeline" à côté du bloc "aggregate". "timeline.recent" est une liste ordonnée de lignes-jour pour les 14 derniers jours ; chaque ligne porte une "date" (YYYY-MM-DD), un libellé "weekday" (Lun/Mar/Mer/...) et la ou les valeurs de la métrique pour ce jour. "timeline.weekly" regroupe les jours plus anciens en moyennes ISO-hebdomadaires. +- Pour une question portant sur un jour ou un jour-de-semaine précis ("pourquoi la tension était-elle plus élevée lundi dernier ?"), répondez depuis "timeline.recent" — citez la lecture réelle de ce jour avec la date et le jour-de-semaine. +- Quand aucune ligne n'existe pour le jour mentionné, dites-le clairement ("je n'ai pas de lecture pour lundi 5 mai") et proposez le jour disponible le plus proche. +- Les questions plus anciennes (>14 jours) basculent sur "timeline.weekly" ou, à défaut, sur l'agrégat. +- L'objet "scope" nomme la fenêtre choisie et les sources. Quand l'utilisateur interroge une métrique absente de scope.sources, dites qu'elle n'est pas dans le périmètre de cette conversation.`, + es: `Cada métrica en el SNAPSHOT lleva un objeto "timeline" junto al bloque "aggregate". "timeline.recent" es una lista ordenada de filas-día de los últimos 14 días; cada fila lleva una "date" (YYYY-MM-DD), una etiqueta "weekday" (Lun/Mar/Mié/...) y los valores de la métrica para ese día. "timeline.weekly" agrupa los días más antiguos en medias ISO-semanales. +- Ante preguntas sobre un día concreto o un día de la semana ("¿por qué la tensión estuvo más alta el lunes pasado?"), responda desde "timeline.recent" — cite la lectura real de ese día con fecha y día de la semana. +- Cuando no exista fila para el día mencionado, dígalo claramente ("no tengo lectura del lunes 5 de mayo") y ofrezca el día más cercano disponible. +- Las preguntas más antiguas (>14 días) acuden a "timeline.weekly" o, si no, al agregado. +- El objeto "scope" nombra la ventana seleccionada y las fuentes. Si el usuario pregunta por una métrica que no está en scope.sources, indique que está fuera del alcance de esta conversación.`, + it: `Ogni metrica nello SNAPSHOT porta un oggetto "timeline" accanto al blocco "aggregate". "timeline.recent" è una lista ordinata di righe-giorno per gli ultimi 14 giorni; ogni riga porta una "date" (YYYY-MM-DD), un'etichetta "weekday" (Lun/Mar/Mer/...) e i valori della metrica per quel giorno. "timeline.weekly" raggruppa i giorni più vecchi in medie ISO-settimanali. +- Per domande su un giorno o giorno-della-settimana specifico ("perché la pressione era più alta lunedì scorso?"), risponda da "timeline.recent" — citi la lettura reale di quel giorno con data e giorno-della-settimana. +- Quando non esiste una riga per il giorno nominato, lo dica chiaramente ("non ho una lettura per lunedì 5 maggio") e proponga il giorno più vicino disponibile. +- Le domande più vecchie (>14 giorni) ripiegano su "timeline.weekly" o, in mancanza, sull'aggregato. +- L'oggetto "scope" nomina la finestra scelta dall'utente e le fonti. Se l'utente chiede di una metrica non presente in scope.sources, dica che non rientra nello scopo di questa conversazione.`, + pl: `Każda metryka w SNAPSHOT zawiera obiekt "timeline" obok bloku "aggregate". "timeline.recent" to uporządkowana lista wierszy-dnia za ostatnie 14 dni; każdy wiersz ma "date" (YYYY-MM-DD), etykietę "weekday" (Pn/Wt/Śr/...) i wartości metryki dla tego dnia. "timeline.weekly" grupuje starsze dni w średnie ISO-tygodniowe. +- Przy pytaniach o konkretny dzień lub dzień tygodnia ("dlaczego ciśnienie było wyższe w zeszły poniedziałek?"), proszę odpowiadać z "timeline.recent" — cytować rzeczywisty odczyt z tego dnia z datą i dniem tygodnia. +- Gdy nie ma wiersza dla wymienionego dnia, proszę powiedzieć to wprost ("nie mam odczytu z poniedziałku 5 maja") i zaproponować najbliższy dostępny dzień. +- Starsze pytania (>14 dni) sięgają do "timeline.weekly" lub, w razie braku, do agregatu. +- Obiekt "scope" wymienia wybrane okno i źródła. Jeśli użytkownik pyta o metrykę nieobecną w scope.sources, proszę powiedzieć, że nie należy ona do zakresu tej rozmowy.`, +}; + +/** EVIDENCE BLOCK body — sentinels stay literal across all locales. */ +const EVIDENCE_BLOCK_BODY: Record = { + fr: `Après votre texte, ajoutez les chiffres porteurs comme un bloc-sentinelle sur sa propre ligne. Format exact : + +---KEYVALUES--- +avg30 systolique : 138 [mmHg] (last30days) +Mar 6 mai : 142/88 [mmHg] +---END--- + +Règles : +- Plafond strict de 8 lignes entre les sentinelles. +- Chaque ligne : "