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(