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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 23:54:09 +02:00
1 parent 5c83156dcf
commit d0a74e37da
13 files changed
+88 -39

No files matched your search

+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
+15 -3
View File
@@ -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);
}
+7 -3
View File
@@ -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(
+7 -3
View File
@@ -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(
+7 -3
View File
@@ -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(
+7 -3
View File
@@ -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(
+7 -4
View File
@@ -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(
+7 -3
View File
@@ -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(
+7 -3
View File
@@ -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(
+7 -4
View File
@@ -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 "### <key>" 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,
);
}
/**
+7 -3
View File
@@ -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(
@@ -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(