fix(ai): route the metric assessment prompts by instruction locale

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 23:18:58 +02:00
1 parent 2032b74fc6
commit fff84ea84f
11 files changed
+61 -26

No files matched your search

+6 -2
View File
@@ -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}
+6 -2
View File
@@ -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}
+8 -2
View File
@@ -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}
+5 -4
View File
@@ -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
+2 -1
View File
@@ -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
+6 -2
View File
@@ -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}
+8 -6
View File
@@ -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}
+6 -2
View File
@@ -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}
+6 -2
View File
@@ -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}
+2 -1
View File
@@ -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}`;
}
/**
+6 -2
View File
@@ -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}