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] 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(