Merge pull request #539 from MBombeck/release/v1.30.15

release/v1.30.15 — write generated health texts in the account's own language
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-19 00:06:05 +02:00
commit 2d00b3e258
56 files changed
+1613 -197

No files matched your search

+10
View File
@@ -2,6 +2,16 @@
## [Unreleased]
## [1.30.15] — 2026-07-18
Generated health texts now come in the language you set.
- **French, Spanish, Italian and Polish accounts get their assessments in their own language.** Every locale other than English previously fell back to the German instruction set, so those four either read German text or — where the pipeline narrowed them earlier — English text. Insight cards, the per-metric assessments, derived scores, the biomarker card, the period narrative and the assistant's tone settings all now follow the account's own language, with the wording rules that were already translated for each of the six languages. German and English output is unchanged, character for character.
- **The language instruction is the last thing the model reads**, rather than being buried before the metric-specific section that follows it.
- Under the hood: one shared resolver replaces the scattered English-or-else-German branches, and the locale now survives the whole generation path instead of being narrowed on the way in. Stored texts for the four languages regenerate on their next scheduled run; German and English keep reading exactly the rows they already had.
No breaking changes.
## [1.30.14] — 2026-07-18
The workouts page joins the pages that paint on first load.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.30.14
version: 1.30.15
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+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",
+2 -1
View File
@@ -5226,7 +5226,8 @@ model InsightNarrative {
userId String @map("user_id")
/// "week" | "month".
period String
/// Resolved UI locale of the prose ("de" | "en").
/// Resolved UI locale of the prose — any shipped UI locale, not just de/en:
/// the prose is generated per locale, so the row is keyed by it.
locale String
/// UTC YYYY-MM-DD boundary the narrative was generated for.
dateKey String @map("date_key")
+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/*`
+7 -3
View File
@@ -60,6 +60,7 @@ import {
listConversations,
} from "@/lib/ai/coach/persistence";
import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared";
import { instructionLocale } from "@/lib/ai/prompts/output-language";
import { storeDeterministicFacts } from "@/lib/ai/coach/facts";
import {
buildDateKey,
@@ -485,9 +486,12 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
void enqueueCoachMemoryRefresh({
conversationId: workingConversationId,
userId,
// Coach memory prose is composed in de/en only (the snapshot's
// coachLocale); collapse the wider UI locale union here.
locale: locale === "en" ? "en" : "de",
// Coach memory prose is composed in de/en only — it is MODEL-FACING
// context (a rolling conversation summary + extracted durable facts),
// not user-facing prose, so English is the correct target for every
// locale without a reviewed body. The former `=== "en" ? "en" : "de"`
// binary composed and keyed a French account's memory in German.
locale: instructionLocale(locale),
});
}
// v1.19.1 (C4) — token efficiency. The full SNAPSHOT (now incl. labs,
+8 -2
View File
@@ -39,6 +39,7 @@ import {
} from "@/lib/insights/derived";
import { resolveDeterministicAssessment } from "@/lib/insights/derived/derived-assessment";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { instructionLocale } from "@/lib/ai/prompts/output-language";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { DERIVED_MODULE } from "../route";
@@ -177,7 +178,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
userLocale: user.locale ?? null,
override: request.nextUrl.searchParams.get("locale"),
});
const assessmentLocale = locale === "de" ? "de" : "en";
// This grid only carries the DETERMINISTIC assessment, whose templates ship
// de/en bodies — so the same instruction-body rule the prompts use applies:
// German for a German reader, English for everyone else. Deliberately NOT
// widened to the six; the AI-warm prose (which does carry the full locale)
// is served lazily by the per-score route.
const assessmentLocale = instructionLocale(locale);
// v1.16.8 — read-through the per-user derived cache with
// stale-while-revalidate. The cold build below walks the rollup tier
@@ -244,7 +250,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
async function buildDerivedBatch(input: {
userId: string;
items: BatchItem[];
assessmentLocale: "de" | "en";
assessmentLocale: ReturnType<typeof instructionLocale>;
}) {
const { userId, items, assessmentLocale } = input;
+3 -2
View File
@@ -71,6 +71,7 @@ import { requireAssistantSurface } from "@/lib/feature-flags";
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { normalizeLocale } from "@/lib/insights/status-shared";
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
@@ -202,7 +203,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
request,
userLocale: dbUser?.locale ?? user.locale ?? null,
});
const locale = resolved === "de" ? "de" : "en";
const locale = normalizeLocale(resolved);
void enqueueForceWarm({ userId, locale });
revalidating = true;
}
@@ -950,7 +951,7 @@ export const POST = apiHandler((request: NextRequest) =>
// refresh path until the next nightly warm.
const refillScopes = await enqueueStatusRefillForUser(
userId,
locale === "de" ? "de" : "en",
normalizeLocale(locale),
);
annotate({
action: { name: "insights.generate.cards_refill" },
+12 -2
View File
@@ -17,6 +17,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate";
@@ -37,8 +38,17 @@ const narrativeQuerySchema = z.object({
/** A narrative read this recently is considered fresh; no warm is enqueued. */
const NARRATIVE_FRESH_MS = 20 * 60 * 60 * 1000;
function narrowLocale(locale: string): "de" | "en" {
return locale === "en" ? "en" : "de";
/**
* The narrative row is keyed `(user, period, locale)` across the full UI
* locale union, so the resolved locale is carried through as-is.
*
* This used to be `locale === "en" ? "en" : "de"` — which sent every French,
* Spanish, Italian and Polish reader to the German narrative row and the
* German prompt behind it. `resolveServerLocale` already returns a validated
* `Locale`; an unknown value falls back to the app default (`en`) there.
*/
function narrowLocale(locale: Locale): Locale {
return locales.includes(locale) ? locale : defaultLocale;
}
export const GET = apiHandler(async (request: NextRequest) => {
+5 -1
View File
@@ -30,6 +30,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { normalizeLocale } from "@/lib/insights/status-shared";
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
export const dynamic = "force-dynamic";
@@ -59,7 +60,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
request,
userLocale: user.locale ?? null,
});
const locale = resolved === "en" ? "en" : "de";
// Carry the reader's ACTUAL locale into the warm payload. The former
// `resolved === "en" ? "en" : "de"` sent every fr/es/it/pl account down the
// German warm, so the whole warmed cache family was in the wrong language.
const locale = normalizeLocale(resolved);
await enqueueForceWarm({ userId, locale });
@@ -0,0 +1,76 @@
/**
* Regression-class ban for the German-default locale binary.
*
* The bug this pins had exactly one shape: `locale === "en" ? "en" : "de"`
* (and its `=== "de" ? ... : ...` inversions with a `de` fallback). Every
* occurrence sent French, Spanish, Italian and Polish accounts down the German
* branch. This walks the source of the surfaces that carried it and fails on
* the shape itself, so a future edit cannot quietly reintroduce it.
*
* Scoped to the files this change owns — the coach memory refresh, the coach
* snapshot's narrative recall, the narrative route + generator + warm, and the
* dashboard briefing recall. Blunt by design: it matches text, not behaviour,
* which is precisely what makes it survive a refactor of the logic around it.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const ROOT = resolve(__dirname, "../../../..");
const GUARDED_FILES = [
"app/api/insights/chat/route.ts",
"app/api/insights/narrative/route.ts",
"lib/ai/coach/snapshot.ts",
"lib/ai/coach/memory-snapshot.ts",
"lib/ai/coach/coach-memory-refresh-worker.ts",
"lib/dashboard/snapshot.ts",
"lib/insights/narrative/period-narrative-generate.ts",
"lib/jobs/period-narrative-warm.ts",
"lib/jobs/period-narrative-shared.ts",
];
/**
* A ternary whose FALSE branch is the German literal — the de-default shape.
* Deliberately narrow: it must not flag `locale === "de" ? deBody : enBody`,
* which is the correct polarity (German only for German readers).
*/
const DE_DEFAULT_TERNARY = /\?\s*"[a-z]{2}"\s*:\s*"de"/;
/** `payload.locale ?? "de"` and friends — a German default by omission. */
const DE_DEFAULT_NULLISH = /\?\?\s*"de"/;
/**
* Read a guarded file with comments stripped.
*
* The comments at these call sites deliberately QUOTE the banned shape to
* explain what was fixed; scanning them would make the guard self-tripping.
* Only executable source is checked.
*/
function readCode(rel: string): string {
return readFileSync(resolve(ROOT, rel), "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "");
}
describe("no German-default locale binary", () => {
it.each(GUARDED_FILES)("%s carries no de-default ternary", (rel) => {
expect(readCode(rel)).not.toMatch(DE_DEFAULT_TERNARY);
});
it.each(GUARDED_FILES)("%s carries no de-default fallback", (rel) => {
expect(readCode(rel)).not.toMatch(DE_DEFAULT_NULLISH);
});
it("recognises the shape it bans", () => {
// Guard the guard: the patterns must actually match the bug they describe,
// so a typo cannot turn this file into a silent pass.
expect(`locale === "en" ? "en" : "de"`).toMatch(DE_DEFAULT_TERNARY);
expect(`payload.locale ?? "de"`).toMatch(DE_DEFAULT_NULLISH);
// ...and must NOT match the correct polarity.
expect(`instructionLocale(locale)`).not.toMatch(DE_DEFAULT_TERNARY);
expect(`locale === "de" ? DE_BODY : EN_BODY`).not.toMatch(
DE_DEFAULT_TERNARY,
);
});
});
@@ -168,4 +168,19 @@ describe("buildCoachMemoryBlock", () => {
await buildCoachMemoryBlock(USER, PROFILE, NOW, "en");
expect(readPeriodNarrative).toHaveBeenCalledWith(USER, "month", "en");
});
// The narrative row is keyed by the full UI locale union. A French account's
// recall must read the `fr` row — narrowing it to `de` (the old behaviour)
// recalled a German narrative, and narrowing it to `en` would read a row
// that account never has.
it.each(["fr", "es", "it", "pl"] as const)(
"reads the %s narrative row without narrowing the locale",
async (locale) => {
readPeriodNarrative.mockResolvedValue(null);
buildPeriodNarrativeContext.mockResolvedValue(readyContext([]));
await buildCoachMemoryBlock(USER, PROFILE, NOW, locale);
expect(readPeriodNarrative).toHaveBeenCalledWith(USER, "month", locale);
},
);
});
@@ -19,7 +19,10 @@ export async function runCoachMemoryRefresh(
payload: CoachMemoryRefreshPayload,
): Promise<void> {
const { conversationId, userId } = payload;
const locale = payload.locale ?? "de";
// A payload without a locale (an older queued job) composes ENGLISH memory,
// not German — the memory prose is model-facing context and English is the
// fallback body for every locale that has no reviewed one.
const locale = payload.locale ?? "en";
let summaryStatus = "error";
try {
+1 -1
View File
@@ -22,7 +22,7 @@ export const COACH_MEMORY_REFRESH_QUEUE = "coach-memory-refresh";
export interface CoachMemoryRefreshPayload {
conversationId: string;
userId: string;
/** Locale to compose the summary / facts prose in; defaults to "de". */
/** Locale to compose the summary / facts prose in; defaults to "en". */
locale?: "de" | "en";
}
+4 -1
View File
@@ -558,7 +558,10 @@ function deterministicFactText(
subject: string,
locale: string | undefined,
): string {
const de = locale?.toLowerCase().startsWith("de") ?? true;
// German only for a German reader; every other locale — and an absent one —
// composes ENGLISH. The fact text is model-facing memory, so English is the
// fallback body; the previous `?? true` made an unknown locale German.
const de = locale?.toLowerCase().startsWith("de") ?? false;
switch (kind) {
case "allergy":
return de
+7 -1
View File
@@ -38,6 +38,7 @@ import {
type BandTransition,
} from "@/lib/insights/narrative/period-narrative";
import type { BaselineProfile } from "@/lib/insights/derived";
import type { Locale } from "@/lib/i18n/config";
import { buildCoachFactsBlock } from "./facts";
import {
buildCoachPlansBlock,
@@ -138,7 +139,12 @@ export async function buildCoachMemoryBlock(
userId: string,
_profile: BaselineProfile,
now: Date,
locale: "de" | "en",
/**
* The reader's UI locale — the narrative row is keyed by it, across the full
* six-locale union. Narrowing it here would read a row the narrative
* pipeline never writes for that user.
*/
locale: Locale,
): Promise<CoachMemoryBlock | null> {
let priorNarrative: PriorNarrativeRecall | undefined;
// Sub-source 1: the latest period-narrative headline + driver recall.
+8 -3
View File
@@ -23,6 +23,7 @@ import {
type CoachDataCluster,
} from "@/lib/validations/coach-prefs";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
import { resolveGlucoseUnit } from "@/lib/glucose";
import type { SleepStageRow } from "@/lib/analytics/sleep-night";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
@@ -339,9 +340,13 @@ async function buildCoachSnapshotImpl(
);
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
// Resolve the UI locale for the rolling-profile narrative recall. The
// narrative rows are keyed by ("de" | "en"); default to "de" (the app
// default locale) when the user never picked one.
const coachLocale: "de" | "en" = prefsRow?.locale === "en" ? "en" : "de";
// narrative rows are keyed by the full UI locale union, so the stored value
// is carried through as-is; an unset or unknown value falls back to the app
// default (`en`), never to German. The former `=== "en" ? "en" : "de"`
// binary made a French account recall a German narrative row.
const coachLocale: Locale = locales.includes(prefsRow?.locale as Locale)
? (prefsRow?.locale as Locale)
: defaultLocale;
const clusterDefault = clusterSourcesFromPrefs(prefs.dataClusters);
const { sources: scopedSources, window } = resolveScope(
scope,
+7 -2
View File
@@ -19,6 +19,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 { instructionLocale } from "@/lib/ai/prompts/output-language";
import { learnCatalogPromptBlock } from "./learn-catalog";
import {
DEFAULT_COACH_PREFS,
@@ -1224,7 +1225,7 @@ function buildCoachV122Addendum(
locale: Locale,
personalization: CoachPersonalization,
): string {
const isDe = locale === "de";
const isDe = instructionLocale(locale) === "de";
const parts: string[] = [];
parts.push(isDe ? "COACH-ZUSATZ (v1.22)" : "COACH ADDENDUM (v1.22)");
@@ -1347,7 +1348,11 @@ ${fenceSelfReport(aboutMe)}`;
*/
function buildPrefsPrefix(locale: Locale, prefs: CoachPrefs): string {
const parts: string[] = [];
const isEn = locale === "en";
// These override lines are internal instructions, so they follow the same
// routing as every other prompt body: German only for German readers,
// English for everyone else. The former `locale === "en"` sent a French
// Coach prompt a German tone override on top of its French body.
const isEn = instructionLocale(locale) === "en";
// Tone
if (prefs.tone === "neutral") {
@@ -40,13 +40,28 @@ describe("base-system assessment prompt — locale skeleton parity", () => {
});
it("the composed prompt equals the fragments joined by a blank line", () => {
// The English output clause carries a language token so a locale riding
// the English body can name its own language. For these two locales the
// substitution is their own language name — written as a literal here, not
// read from the module under test, so the assertion stays independent.
const EXPECTED_LANGUAGE_NAME = { en: "English", de: "German" } as const;
for (const locale of ["en", "de"] as const) {
const expected = ASSESSMENT_SECTION_PAIRS.map((s) => s[locale]).join(
"\n\n",
);
const expected = ASSESSMENT_SECTION_PAIRS.map((s) =>
s[locale]
.split("{{OUTPUT_LANGUAGE}}")
.join(EXPECTED_LANGUAGE_NAME[locale]),
).join("\n\n");
expect(getBaseSystemPrompt(locale)).toBe(expected);
}
});
it("de and en carry no appended output-language directive", () => {
// Their bodies name the language natively; appending a directive would
// change two prompts that are deliberately byte-stable across this change.
for (const locale of ["en", "de"] as const) {
expect(getBaseSystemPrompt(locale)).not.toContain("OUTPUT LANGUAGE:");
}
});
});
describe("insight system prompt — locale skeleton parity", () => {
@@ -0,0 +1,271 @@
import { describe, expect, it } from "vitest";
import { locales, type Locale } from "@/lib/i18n/config";
import {
outputLanguageDirective,
targetLanguageName,
} from "@/lib/ai/prompts/output-language";
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
import {
getGeneralStatusSystemPrompt,
getGeneralStatusUserPrompt,
} from "@/lib/ai/prompts/general-status";
import { getBmiSystemPrompt, getBmiUserPrompt } from "@/lib/ai/prompts/bmi";
import {
getBloodPressureSystemPrompt,
getBloodPressureUserPrompt,
} from "@/lib/ai/prompts/blood-pressure";
import {
getWeightSystemPrompt,
getWeightUserPrompt,
} from "@/lib/ai/prompts/weight";
import {
getPulseSystemPrompt,
getPulseUserPrompt,
} from "@/lib/ai/prompts/pulse";
import { getMoodSystemPrompt, getMoodUserPrompt } from "@/lib/ai/prompts/mood";
import {
getMedicationComplianceSystemPrompt,
getMedicationComplianceUserPrompt,
} from "@/lib/ai/prompts/medication-compliance";
import {
getMetricArchetypeSystemPrompt,
getMetricArchetypeUserPrompt,
} from "@/lib/ai/prompts/metric-archetypes";
import { getStatusBatchSystemPrompt } from "@/lib/ai/prompts/status-batch";
import { buildInterpretationBlock } from "@/lib/ai/prompts/interpretation-block";
import {
buildUserPrompt,
buildComparisonBlock,
} from "@/lib/ai/prompts/insight-system-prompt";
import type { MetricStatusMeta } from "@/lib/insights/metric-status-registry";
/**
* Adoption contract for the assessment prompt family.
*
* `output-language-contract.test.ts` pins the helper and the base system
* prompt. This file pins the SIBLING modules that compose on top of it — the
* per-metric system prompts, their user-prompt scaffolding, the batch wrapper,
* the interpretation block, the insights user prompt and the Coach prefs
* prefix.
*
* The invariant each module is held to: strip the one directive out of the
* four-locale prompt and what remains is the English prompt, byte for byte.
* That single assertion carries three guarantees. The four locales compose the
* ENGLISH body (no German leak); the body they compose is byte-identical to
* the English one, so an English prompt edit can never silently diverge from
* what a French reader receives; and the directive rides EXACTLY once — the
* double-append a module would produce by adding its own directive on top of
* the one `getBaseSystemPrompt` already carries shows up here as a leftover,
* and again in the occurrence count below.
*
* Note where the directive sits. `getBaseSystemPrompt` appends it last, and
* each of these modules then appends its per-metric section AFTER that — so on
* a composed prompt the directive sits at the base/section boundary rather
* than at the very end. It is present and unambiguous either way; moving it
* back to terminal position would mean composing the base without the
* directive and re-appending after the section, which is a change to the
* shared base prompt, not to these modules.
*
* User-prompt scaffolding carries no directive of its own (it rides the system
* prompt), so there the invariant is plain equality with the English text.
*/
/** Words that appear in a German instruction body and in no English one. */
const GERMAN_SENTINEL =
/AUSGABEFORMAT|Antworte ausschließlich|Einschätzung|Schreibe eine|GEBÜNDELTE|EINORDNUNGS-KONTEXT|VERGLEICHSMODUS|Analysiere/;
/** The four locales that ride the English body plus their own directive. */
const RIDERS = ["fr", "es", "it", "pl"] as const;
const ARGS = ["{SNAPSHOT}", "2026-07-18"] as const;
const META: MetricStatusMeta = {
key: "STEP_COUNT",
displayName: "Steps",
unit: "steps",
direction: "higher-better",
archetype: "activity-fitness",
normalRange: { low: 7000, high: 12000 },
} as unknown as MetricStatusMeta;
/**
* System-prompt builders: every one of these composes `getBaseSystemPrompt`,
* which already carries the directive — so none of them may append its own.
*/
const SYSTEM_BUILDERS: ReadonlyArray<{
name: string;
build: (locale: Locale) => string;
}> = [
{ name: "general-status", build: getGeneralStatusSystemPrompt },
{ name: "bmi", build: getBmiSystemPrompt },
{ name: "blood-pressure", build: getBloodPressureSystemPrompt },
{ name: "weight", build: getWeightSystemPrompt },
{ name: "pulse", build: getPulseSystemPrompt },
{ name: "mood", build: getMoodSystemPrompt },
{
name: "medication-compliance",
build: getMedicationComplianceSystemPrompt,
},
{
name: "metric-archetypes",
build: (l) => getMetricArchetypeSystemPrompt(META, l),
},
{
name: "status-batch",
build: (l) => getStatusBatchSystemPrompt(l, ["bp", "weight"]),
},
];
/** User-prompt scaffolding — English text for every non-German locale. */
const USER_BUILDERS: ReadonlyArray<{
name: string;
build: (locale: Locale) => string | undefined;
}> = [
{
name: "general-status",
build: (l) => getGeneralStatusUserPrompt(...ARGS, l),
},
{ name: "bmi", build: (l) => getBmiUserPrompt(...ARGS, l) },
{
name: "blood-pressure",
build: (l) => getBloodPressureUserPrompt(...ARGS, l),
},
{ name: "weight", build: (l) => getWeightUserPrompt(...ARGS, l) },
{ name: "pulse", build: (l) => getPulseUserPrompt(...ARGS, l) },
{ name: "mood", build: (l) => getMoodUserPrompt(...ARGS, l) },
{
name: "medication-compliance",
build: (l) => getMedicationComplianceUserPrompt(...ARGS, l),
},
{
name: "metric-archetypes",
build: (l) => getMetricArchetypeUserPrompt(META, ...ARGS, l),
},
{
name: "interpretation-block",
build: (l) =>
buildInterpretationBlock({
metricKey: "RESTING_HEART_RATE",
value: 58,
sex: "MALE",
locale: l,
}),
},
{
name: "insight-system-prompt",
build: (l) => buildUserPrompt("{FEATURES}", "raw", l),
},
{
name: "insight-comparison-block",
build: (l) =>
buildComparisonBlock(l, {
baseline: "lastMonth",
metrics: [
{
type: "weight",
currentAvg: 82.1,
baselineAvg: 83.2,
delta: -1.1,
deltaPercent: -1.3,
unit: "kg",
},
],
}),
},
{
name: "insight-comparison-block (no metrics)",
build: (l) =>
buildComparisonBlock(l, { baseline: "lastYear", metrics: [] }),
},
];
describe.each(SYSTEM_BUILDERS)("$name system prompt", ({ build }) => {
it.each(RIDERS)("%s composes the English body plus its directive", (l) => {
const directive = outputLanguageDirective(l);
expect(directive).not.toBe("");
// Two things legitimately differ from the English prompt: the directive
// itself, and the language name the base body interpolates into its
// output clause ("the complete assessment in Polish"). Undo exactly those
// two and nothing else may remain.
const normalised = build(l)
.replace(`\n\n${directive}`, "")
.split(targetLanguageName(l))
.join("English");
expect(normalised).toBe(build("en"));
});
it("appends the directive exactly once — never twice", () => {
// A module that appended its own directive on top of the one
// `getBaseSystemPrompt` already carries would count two here.
for (const l of RIDERS) {
expect(occurrences(build(l), "OUTPUT LANGUAGE:"), `${l}`).toBe(1);
}
// de and en name their language inside their own body; no directive.
expect(occurrences(build("de"), "OUTPUT LANGUAGE:")).toBe(0);
expect(occurrences(build("en"), "OUTPUT LANGUAGE:")).toBe(0);
});
it("sends German instruction text to German readers only", () => {
expect(GERMAN_SENTINEL.test(build("de"))).toBe(true);
for (const l of locales) {
if (l === "de") continue;
expect(GERMAN_SENTINEL.test(build(l)), `${l}`).toBe(false);
}
});
it("keeps German and English distinct", () => {
expect(build("de")).not.toBe(build("en"));
});
});
describe.each(USER_BUILDERS)("$name user prompt", ({ build }) => {
it.each(RIDERS)("%s receives the English scaffolding verbatim", (l) => {
expect(build(l)).toBe(build("en"));
});
it("sends German scaffolding to German readers only", () => {
expect(GERMAN_SENTINEL.test(build("de") ?? "")).toBe(true);
for (const l of locales) {
if (l === "de") continue;
expect(GERMAN_SENTINEL.test(build(l) ?? ""), `${l}`).toBe(false);
}
});
});
describe("coach preference overrides", () => {
const prefs = { tone: "neutral", verbosity: "detailed" } as never;
it("never carries German override lines on a non-German prompt", () => {
for (const l of locales) {
const prompt = getCoachSystemPrompt(l, prefs);
if (l === "de") {
expect(prompt).toContain("TONFALL-OVERRIDE:");
continue;
}
// The French body is native; only the override prefix is at issue here.
expect(prompt, `${l} carries a German tone override`).not.toContain(
"TONFALL-OVERRIDE:",
);
expect(prompt).toContain("TONE OVERRIDE:");
expect(prompt).not.toContain("AUSFÜHRLICHKEITS-OVERRIDE:");
expect(prompt).toContain("VERBOSITY OVERRIDE:");
}
});
it("never carries a German v1.22 addendum on a non-German prompt", () => {
for (const l of locales) {
const prompt = getCoachSystemPrompt(l);
if (l === "de") {
expect(prompt).toContain("COACH-ZUSATZ (v1.22)");
continue;
}
expect(prompt, `${l}`).not.toContain("COACH-ZUSATZ (v1.22)");
expect(prompt).toContain("COACH ADDENDUM (v1.22)");
}
});
});
function occurrences(haystack: string, needle: string): number {
return haystack.split(needle).length - 1;
}
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import { locales, type Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "@/lib/ai/prompts/base-system";
import {
instructionLocale,
outputLanguageDirective,
targetLanguageName,
withOutputLanguage,
} from "@/lib/ai/prompts/output-language";
import { loadSafetyContracts } from "@/lib/ai/prompts/safety-contracts";
/**
* The output-language contract.
*
* These tests exist because four of the six locales cannot be eyeballed at
* review time. They pin the mechanical guarantees — which body a locale
* composes, that its own language is named, that the locale's own directive is
* present and last, and that no German leaks into a non-German prompt.
*
* What they deliberately do NOT claim: that the model's prose is idiomatic or
* correct in French, Spanish, Italian or Polish. No offline test can show
* that. The precedent is the native insights/briefing prompts, which have
* carried the same directive in production, and the residual risk is covered
* by a production check, not by this file.
*/
/**
* A word that appears in the German instruction body and in no English one.
* If it shows up in a non-German prompt, the de-default collapse is back.
*/
const GERMAN_SENTINEL = /AUSGABEFORMAT|Antworte ausschließlich|Einschätzung/;
/** English names, written literally — never derived from the module under test. */
const EXPECTED_LANGUAGE_NAME: Record<Locale, string> = {
de: "German",
en: "English",
fr: "French",
es: "Spanish",
it: "Italian",
pl: "Polish",
};
/** Which body each locale is expected to compose. */
const EXPECTED_BODY: Record<Locale, "de" | "en"> = {
de: "de",
en: "en",
fr: "en",
es: "en",
it: "en",
pl: "en",
};
describe("output-language helper", () => {
it("routes only German to the German body — every other locale to English", () => {
for (const locale of locales) {
expect(instructionLocale(locale), `instruction body for ${locale}`).toBe(
EXPECTED_BODY[locale],
);
}
});
it("names each locale's language", () => {
for (const locale of locales) {
expect(targetLanguageName(locale)).toBe(EXPECTED_LANGUAGE_NAME[locale]);
}
});
it("emits the locale's OWN directive for the four riding the English body", () => {
for (const locale of ["fr", "es", "it", "pl"] as const) {
const directive = outputLanguageDirective(locale);
expect(directive, `directive for ${locale}`).toContain(
"OUTPUT LANGUAGE:",
);
// The directive text must be the locale's own translated clause, not a
// generic English one — that is what makes it authoritative to the model.
expect(directive).toContain(
loadSafetyContracts(locale).reply_language_directive,
);
}
});
it("emits no directive for de/en (their bodies name the language natively)", () => {
expect(outputLanguageDirective("de")).toBe("");
expect(outputLanguageDirective("en")).toBe("");
});
it("appends the directive last, blank-line separated", () => {
const composed = withOutputLanguage("BODY", "fr");
expect(composed.startsWith("BODY\n\n")).toBe(true);
expect(
composed.endsWith(loadSafetyContracts("fr").reply_language_directive),
).toBe(true);
});
it("leaves a de/en prompt untouched", () => {
expect(withOutputLanguage("BODY", "de")).toBe("BODY");
expect(withOutputLanguage("BODY", "en")).toBe("BODY");
});
});
describe("base system prompt — every locale", () => {
it("asks for the assessment in the reader's own language", () => {
for (const locale of locales) {
const prompt = getBaseSystemPrompt(locale);
if (locale === "de") {
// The German body states its language in German.
expect(prompt).toContain("auf Deutsch");
} else {
expect(
prompt,
`${locale} must name its own language in the output clause`,
).toContain(`complete assessment in ${EXPECTED_LANGUAGE_NAME[locale]}`);
}
}
});
it("never sends German instructions to a non-German locale", () => {
// This is the regression the whole change exists to prevent.
for (const locale of locales) {
if (locale === "de") continue;
expect(
GERMAN_SENTINEL.test(getBaseSystemPrompt(locale)),
`${locale} prompt contains German instruction text`,
).toBe(false);
}
});
it("ends the four non-de/en prompts with their own directive", () => {
for (const locale of ["fr", "es", "it", "pl"] as const) {
// `[\s\S]` rather than `.` + the `s` flag — the compile target predates
// dotAll, and the directive can span lines.
expect(getBaseSystemPrompt(locale).trimEnd()).toMatch(
/OUTPUT LANGUAGE: [\s\S]+$/,
);
}
});
it("produces a distinct prompt per locale (no silent collapse)", () => {
const seen = new Map<string, Locale>();
for (const locale of locales) {
const prompt = getBaseSystemPrompt(locale);
const clash = seen.get(prompt);
expect(
clash,
`${locale} produced a prompt identical to ${clash} — a locale collapsed`,
).toBeUndefined();
seen.set(prompt, locale);
}
});
});
+58 -6
View File
@@ -11,6 +11,19 @@ import {
forbiddenFiller,
formattingContract,
} from "./shared-contracts";
import {
instructionLocale,
targetLanguageName,
withOutputLanguage,
} from "./output-language";
/**
* Placeholder for the reply language inside the English output clause.
*
* Kept as a token (rather than a template literal in the section array) so the
* section array stays a plain data structure the parity tests can iterate.
*/
const OUTPUT_LANGUAGE_TOKEN = "{{OUTPUT_LANGUAGE}}";
/**
* Version of the per-metric assessment base prompt + its signal contract.
@@ -42,8 +55,13 @@ import {
* rotation. This activates the dormant "do NOT open with the number" branch so
* the per-metric card reads as warm as the overview. Clause ORDER only —
* grounding, non-diagnostic framing, and every safety contract are unchanged.
* 6.2.0 — output language: French, Spanish, Italian and Polish readers used to
* fall to the German instruction body, whose output clause asks for German
* prose. They now compose the English body with their language named in the
* output clause and the locale's own reply-language directive appended last.
* The German and English prompts are byte-identical to 6.1.0 (test-pinned).
*/
export const PROMPT_VERSION = "6.1.0" as const;
export const PROMPT_VERSION = "6.2.0" as const;
/**
* Base system prompt for the per-metric Insights *assessment* cards.
@@ -245,16 +263,30 @@ Synthese statt Aufzählung: die Geschichte dessen, was die Daten bedeuten, zähl
},
{
id: "output",
en: `OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The "summary" field holds the complete assessment in English: 1-3 short paragraphs of 1-3 sentences each, separated by a blank line written as \\n\\n INSIDE the JSON string. A steady one-liner stays a single paragraph:
// The language name is interpolated so a locale riding the English
// instruction body still asks for prose in the reader's own language. For
// `en` it renders "English" — byte-identical to the pre-6.2.0 literal.
en: `OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The "summary" field holds the complete assessment in ${OUTPUT_LANGUAGE_TOKEN}: 1-3 short paragraphs of 1-3 sentences each, separated by a blank line written as \\n\\n INSIDE the JSON string. A steady one-liner stays a single paragraph:
{ "summary": "..." }`,
de: `AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. Das Feld "summary" enthält die komplette Einschätzung auf Deutsch: 1-3 kurze Absätze mit je 1-3 Sätzen, getrennt durch eine Leerzeile als \\n\\n INNERHALB des JSON-Strings. Ein stabiler Einzeiler bleibt EIN Absatz:
{ "summary": "..." }`,
},
];
/** The locale text fragments are joined by a blank line, in section order. */
function composeAssessmentPrompt(locale: "en" | "de"): string {
return ASSESSMENT_SECTIONS.map((s) => s[locale]).join("\n\n");
/**
* The locale text fragments are joined by a blank line, in section order.
*
* `languageName` fills the output clause's language token on the English body.
* The German body names its language natively and carries no token, so the
* replacement is a no-op there.
*/
function composeAssessmentPrompt(
instructionBody: "en" | "de",
languageName: string,
): string {
return ASSESSMENT_SECTIONS.map((s) =>
s[instructionBody].split(OUTPUT_LANGUAGE_TOKEN).join(languageName),
).join("\n\n");
}
/**
@@ -271,6 +303,26 @@ export const ASSESSMENT_SECTION_PAIRS: readonly {
de: string;
}[] = ASSESSMENT_SECTIONS;
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.
return composeAssessmentPrompt(
instructionLocale(locale),
targetLanguageName(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 composeAssessmentPrompt(locale === "en" ? "en" : "de");
return withOutputLanguage(getBaseSystemPromptBody(locale), locale);
}
+13 -5
View File
@@ -1,5 +1,7 @@
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:
- 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,10 +28,16 @@ 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;
return `${getBaseSystemPrompt(locale)}
// 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 withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
${section}`;
${section}`,
locale,
);
}
export function getBloodPressureUserPrompt(
@@ -59,7 +67,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}
+13 -5
View File
@@ -1,5 +1,7 @@
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:
- 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,10 +20,16 @@ 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;
return `${getBaseSystemPrompt(locale)}
// 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 withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
${section}`;
${section}`,
locale,
);
}
export function getBmiUserPrompt(
@@ -46,7 +54,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}
+15 -5
View File
@@ -1,5 +1,7 @@
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:
- 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,10 +22,18 @@ 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;
return `${getBaseSystemPrompt(locale)}
// 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 withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
${section}`;
${section}`,
locale,
);
}
export function getGeneralStatusUserPrompt(
@@ -52,7 +62,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
+13 -5
View File
@@ -1,5 +1,7 @@
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:
- 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,10 +22,16 @@ 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;
return `${getBaseSystemPrompt(locale)}
// 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 withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
${section}`;
${section}`,
locale,
);
}
export function getMedicationComplianceUserPrompt(
@@ -48,7 +56,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}
+14 -9
View File
@@ -21,14 +21,15 @@
* block, exactly like the specialised generators.
*/
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
import { getBaseSystemPromptBody } from "./base-system";
import { instructionLocale, withOutputLanguage } 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,12 +108,16 @@ export function getMetricArchetypeSystemPrompt(
locale: Locale,
): string {
const section = ARCHETYPE_SECTION[meta.archetype];
const archetypeText = locale === "en" ? section.en : section.de;
return `${getBaseSystemPrompt(locale)}
const archetypeText =
instructionLocale(locale) === "en" ? section.en : section.de;
return withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
${archetypeText}
${metaBlock(meta, locale)}`;
${metaBlock(meta, locale)}`,
locale,
);
}
export function getMetricArchetypeUserPrompt(
@@ -159,7 +164,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}
+13 -5
View File
@@ -1,5 +1,7 @@
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:
- 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,10 +22,16 @@ 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;
return `${getBaseSystemPrompt(locale)}
// 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 withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
${section}`;
${section}`,
locale,
);
}
export function getMoodUserPrompt(
@@ -48,7 +56,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}
+107
View File
@@ -0,0 +1,107 @@
/**
* Output-language resolution for the assessment prompt family.
*
* The problem this solves: the prompt modules historically branched
* `locale === "en" ? EN_BODY : DE_BODY`. That binary is correct for a de/en
* product, but HealthLog ships six UI locales — so every fr/es/it/pl reader
* fell to the GERMAN instruction body, whose output clause explicitly asks for
* a German assessment. A French account therefore received German prose (on
* the surfaces that reached the prompts at all).
*
* The fix keeps ONE instruction body per prompt in the two languages that have
* a reviewed body (de, en) and names the reader's language in an explicit
* directive, rather than growing four more hand-maintained translations of
* every prompt. The directive text itself is NOT invented here: it is the
* `reply_language_directive` from `safety-contracts.<locale>.yaml`, written
* natively per locale and already carried in production by the native
* insights/briefing prompts (`native-prompts.ts:338,507`). This module only
* makes that same mechanism reachable from the assessment family.
*
* Polarity matters and is the whole point: the fallback for an unknown or
* non-German locale is ENGLISH, never German. `instructionLocale` is the
* single replacement for every scattered de-default binary.
*/
import type { Locale } from "@/lib/i18n/config";
import { loadSafetyContracts } from "./safety-contracts";
/**
* Which reviewed instruction body a locale composes.
*
* German for German readers (its body is reviewed and its register is the one
* the largest non-English audience reads); English for everyone else. The
* reader's actual language is then named by `outputLanguageDirective`.
*/
export function instructionLocale(locale: Locale): "de" | "en" {
return locale === "de" ? "de" : "en";
}
/**
* English name of the language the assessment must be WRITTEN in.
*
* English names because they are interpolated into the English instruction
* body; the directive that follows is in the reader's own language.
*/
export function targetLanguageName(locale: Locale): string {
switch (locale) {
case "de":
return "German";
case "fr":
return "French";
case "es":
return "Spanish";
case "it":
return "Italian";
case "pl":
return "Polish";
default:
return "English";
}
}
/**
* Fallback directive when the safety matrix cannot be loaded.
*
* The matrix read is a synchronous file read + schema parse that throws on a
* malformed or missing file. A prompt that silently loses its language
* directive would regress exactly the bug this module fixes, so fall back to a
* plain English instruction naming the language rather than to an empty
* string.
*/
function fallbackDirective(locale: Locale): string {
return `Write your reply in ${targetLanguageName(locale)}.`;
}
/**
* The terminal OUTPUT LANGUAGE section for a prompt, or `""` when the
* composed instruction body already carries the language natively.
*
* de and en compose their own reviewed body, whose output clause already names
* the language — appending a directive there would be redundant and would
* change two prompts that are deliberately byte-stable. The four locales that
* ride the English body get the directive, placed last so it is the most
* recent instruction the model reads.
*/
export function outputLanguageDirective(locale: Locale): string {
if (locale === "de" || locale === "en") return "";
let directive: string;
try {
directive = loadSafetyContracts(locale).reply_language_directive;
} catch {
directive = fallbackDirective(locale);
}
return `OUTPUT LANGUAGE: ${directive}`;
}
/**
* Append the directive to a composed prompt when one applies.
*
* Kept here so every adopting module joins it identically (blank-line
* separated, directive last) instead of each re-deriving the spacing.
*/
export function withOutputLanguage(prompt: string, locale: Locale): string {
const directive = outputLanguageDirective(locale);
return directive ? `${prompt}\n\n${directive}` : prompt;
}
+13 -5
View File
@@ -1,5 +1,7 @@
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:
- 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,10 +20,16 @@ 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;
return `${getBaseSystemPrompt(locale)}
// 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 withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
${section}`;
${section}`,
locale,
);
}
export function getPulseUserPrompt(
@@ -46,7 +54,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}
+7 -3
View File
@@ -18,7 +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 { getBaseSystemPromptBody } from "./base-system";
import { instructionLocale, withOutputLanguage } from "./output-language";
/**
* The stable per-metric output keys the batch envelope uses. The InsightStatus
@@ -46,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.
@@ -58,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${locale === "en" ? en : de}`;
return withOutputLanguage(
`${base}\n\n${instructionLocale(locale) === "en" ? en : de}`,
locale,
);
}
/**
+13 -5
View File
@@ -1,5 +1,7 @@
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:
- 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,10 +26,16 @@ 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;
return `${getBaseSystemPrompt(locale)}
// 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 withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
${section}`;
${section}`,
locale,
);
}
export function getWeightUserPrompt(
@@ -52,7 +60,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}
+9 -7
View File
@@ -1046,10 +1046,12 @@ function localisedMeasurementLabel(
return resolved === key ? type.replace(/_/g, " ").toLowerCase() : resolved;
}
/** Narrow a 6-locale `Locale` to the de/en the narrative pipeline generates. */
function narrativeLocale(locale: Locale): "de" | "en" {
return locale === "de" ? "de" : "en";
}
/**
* The narrative row is keyed by the full six-locale union — the pipeline
* generates and stores a row per UI locale — so the briefing memory recall
* reads the caller's locale directly. This used to narrow to de/en, which
* would now read a row a fr/es/it/pl account never has.
*/
/**
* Assemble the full snapshot in ONE `Promise.all`. Every sub-read is
@@ -1079,8 +1081,8 @@ export async function buildDashboardSnapshot(
/**
* v1.21.2 — active locale for the A4 briefing memory prose. Defaults to
* English; the route resolves it from the request (cookie / header / user
* preference). The recall headline is read locale-specific; non-de locales
* fall back to the English narrative read inside `buildCoachMemoryBlock`.
* preference). The recall headline is read locale-specific: the narrative
* row `buildCoachMemoryBlock` reads is keyed by this exact locale.
*/
locale?: Locale;
/**
@@ -1269,7 +1271,7 @@ export async function buildDashboardSnapshot(
user.id,
narrativeProfile,
now,
narrativeLocale(locale),
locale,
),
).catch(() => null)
: Promise.resolve(null),
@@ -0,0 +1,177 @@
/**
* The assessment pipeline must carry the reader's locale end-to-end.
*
* The bug these tests would have caught: every stage typed its locale as
* `de | en` and narrowed on the way in, so a French account's locale was
* destroyed before any prompt saw it. The output-language directive in the
* prompt layer is INERT unless `fr` actually arrives there, so the assertion
* that matters is not "the pipeline compiles" but "the locale handed to the
* prompt builder is the one the reader has".
*
* Three things are pinned here:
* 1. `normalizeLocale` passes all six through and defaults unknown values to
* ENGLISH — a German default is the whole bug class.
* 2. Cache-key stability: an existing de/en account keeps reading its
* existing `insights.<scope>-status.<locale>` rows; only the four new
* locales get new (cold) keys.
* 3. End-to-end: a `fr` locale entering a generator reaches the prompt
* builder as `fr`, and a `fr` forced-warm job reaches `forceWarmUser`
* as `fr` (that site previously defaulted to German).
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { locales } from "@/lib/i18n/config";
import { normalizeLocale } from "@/lib/insights/status-shared";
import { statusCacheAction } from "@/lib/insights/status-cache";
describe("normalizeLocale", () => {
it("passes every shipped UI locale through unchanged", () => {
for (const locale of locales) {
expect(normalizeLocale(locale)).toBe(locale);
}
// The four that used to be flattened away are explicitly covered.
expect(normalizeLocale("fr")).toBe("fr");
expect(normalizeLocale("es")).toBe("es");
expect(normalizeLocale("it")).toBe("it");
expect(normalizeLocale("pl")).toBe("pl");
});
it("defaults an unknown, empty, or missing locale to English, never German", () => {
for (const value of [null, undefined, "", "xx", "de-DE", "en_US", "🙂"]) {
expect(normalizeLocale(value)).toBe("en");
expect(normalizeLocale(value)).not.toBe("de");
}
});
});
describe("status cache keys", () => {
it("leaves the de/en cache keys byte-identical (no drift for existing accounts)", () => {
expect(statusCacheAction("weight", normalizeLocale("de"))).toBe(
"insights.weight-status.de",
);
expect(statusCacheAction("weight", normalizeLocale("en"))).toBe(
"insights.weight-status.en",
);
});
it("gives the four widened locales their own keys instead of reusing the English row", () => {
expect(statusCacheAction("weight", normalizeLocale("fr"))).toBe(
"insights.weight-status.fr",
);
// Before the widening this collapsed onto the English row.
expect(statusCacheAction("weight", normalizeLocale("fr"))).not.toBe(
statusCacheAction("weight", "en"),
);
});
});
// ── end-to-end: entry point → prompt builder ──────────────────────────────
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock("@/lib/insights/memory", () => ({
getPreviousInsightContext: vi.fn().mockResolvedValue(null),
formatPreviousContextForPrompt: vi.fn().mockReturnValue(""),
}));
/**
* The capture seam. `getGeneralStatusSystemPrompt` is the LAST hop before the
* provider call — whatever locale arrives here is the locale the prompt (and
* therefore its output-language directive) is composed for.
*/
const systemPromptLocales: string[] = [];
vi.mock("@/lib/ai/prompts/general-status", () => ({
getGeneralStatusSystemPrompt: (locale: string) => {
systemPromptLocales.push(locale);
return "SYSTEM";
},
getGeneralStatusUserPrompt: () => "USER",
}));
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateGeneralStatusForUser } from "@/lib/insights/general-status";
const dayMs = 24 * 60 * 60 * 1000;
beforeEach(() => {
// Clear recorded calls so each case asserts only its own cache writes.
vi.clearAllMocks();
systemPromptLocales.length = 0;
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
heightCm: null,
insightsExcludeMetrics: [],
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
const now = Date.now();
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
Array.from({ length: 40 }, (_, day) => ({
type: "WEIGHT",
value: 80 + (day % 3),
measuredAt: new Date(now - day * dayMs),
})) as never,
);
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "ok",
content: JSON.stringify({ summary: "Résumé." }),
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never);
});
describe("a fr locale survives from the generator entry point to the prompt", () => {
it("hands 'fr' to the prompt builder rather than collapsing it to en or de", async () => {
await generateGeneralStatusForUser("u-fr", { locale: "fr", force: true });
expect(systemPromptLocales).toContain("fr");
// The two failure modes the old binary produced.
expect(systemPromptLocales).not.toContain("de");
expect(systemPromptLocales).not.toContain("en");
});
it("still hands 'de' to a German reader and 'en' to an English one", async () => {
await generateGeneralStatusForUser("u-de", { locale: "de", force: true });
expect(systemPromptLocales).toEqual(["de"]);
systemPromptLocales.length = 0;
await generateGeneralStatusForUser("u-en", { locale: "en", force: true });
expect(systemPromptLocales).toEqual(["en"]);
});
it("writes the fr assessment to the fr cache row, leaving the en row untouched", async () => {
await generateGeneralStatusForUser("u-fr", { locale: "fr", force: true });
const actions = vi
.mocked(prisma.auditLog.create)
.mock.calls.map(
(call) => (call[0] as { data: { action: string } }).data.action,
);
expect(actions).toContain("insights.general-status.fr");
expect(actions).not.toContain("insights.general-status.en");
});
});
+32 -12
View File
@@ -22,12 +22,16 @@
*/
import { prisma } from "@/lib/db";
import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system";
import {
instructionLocale,
withOutputLanguage,
} from "@/lib/ai/prompts/output-language";
import type { Locale } from "@/lib/i18n/config";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
type SupportedLocale,
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
@@ -82,8 +86,21 @@ function insufficient(): BiomarkerStatusResult {
};
}
function getSystemPrompt(locale: SupportedLocale, markerName: string): string {
if (locale === "de") {
/**
* Unlike every other assessment prompt in the family, this one does NOT
* compose `getBaseSystemPrompt` — it is a self-contained inline scaffold. So
* the reader's output-language directive, which the base prompt would have
* carried, has to be appended here. When this prompt moves onto the shared
* base scaffold the append collapses into the shared path and this call goes
* away; until then it is the only thing making a non-de/en reader's language
* explicit on this surface.
*
* The parameter is the full `Locale` rather than `SupportedLocale` so the
* routing is correct the moment the pipeline stops collapsing fr/es/it/pl on
* the way in; today's callers still pass a narrowed locale.
*/
function getSystemPrompt(locale: Locale, markerName: string): string {
if (instructionLocale(locale) === "de") {
return [
`Du bist ein vorsichtiger, faktenbasierter Gesundheitsbegleiter und beurteilst den Laborwert „${markerName}" einer Person.`,
"Beziehe dich AUSSCHLIESSLICH auf die im Snapshot gelieferten Zahlen. Erfinde keine Werte, keine Diagnosen und keine Medikamente.",
@@ -92,21 +109,24 @@ function getSystemPrompt(locale: SupportedLocale, markerName: string): string {
'Antworte als JSON-Objekt der Form { "summary": "…" } ohne weiteren Text.',
].join(" ");
}
return [
`You are a careful, fact-based health companion assessing a person's lab marker "${markerName}".`,
"Use ONLY the figures provided in the snapshot. Do not invent values, diagnoses, or medications.",
"Write 2 to 4 calm sentences in plain language: state the current value and how it sits against the reference range, place the trend across recent readings, and add at most one factual pointer.",
"No alarmism, no marketing tone, no commands. For notably out-of-range values, refer neutrally to a clinician for interpretation.",
'Respond as a JSON object of the form { "summary": "…" } with no other text.',
].join(" ");
return withOutputLanguage(
[
`You are a careful, fact-based health companion assessing a person's lab marker "${markerName}".`,
"Use ONLY the figures provided in the snapshot. Do not invent values, diagnoses, or medications.",
"Write 2 to 4 calm sentences in plain language: state the current value and how it sits against the reference range, place the trend across recent readings, and add at most one factual pointer.",
"No alarmism, no marketing tone, no commands. For notably out-of-range values, refer neutrally to a clinician for interpretation.",
'Respond as a JSON object of the form { "summary": "…" } with no other text.',
].join(" "),
locale,
);
}
function getUserPrompt(
snapshotJson: string,
todayKey: string,
locale: SupportedLocale,
locale: Locale,
): string {
return locale === "de"
return instructionLocale(locale) === "de"
? `Heutiges Datum: ${todayKey}. Beurteile den folgenden Laborwert-Snapshot:\n${snapshotJson}`
: `Today's date: ${todayKey}. Assess the following lab-marker snapshot:\n${snapshotJson}`;
}
+7 -4
View File
@@ -81,7 +81,10 @@ import {
hasActiveConsentForSurface,
} from "@/lib/ai/consent-guard";
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { stripJsonFences } from "@/lib/insights/status-shared";
import {
stripJsonFences,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { annotate } from "@/lib/logging/context";
import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets";
@@ -149,7 +152,7 @@ async function rerollBriefingParagraph(args: {
systemPrompt: string;
userPrompt: string;
cachedText: string;
locale: "de" | "en";
locale: SupportedLocale;
/**
* v1.18.10 (MEDIUM-3) — the pre-computed signals the briefing must match.
* The reroll runs at a higher, seedless temperature for phrasing variety,
@@ -455,7 +458,7 @@ export async function buildComparisonSnapshotForUser(
*/
function failBeforeProvider(
userId: string,
locale: "de" | "en",
locale: SupportedLocale,
reason: "payload-too-large" | "features-error" | "aborted",
err?: unknown,
): GenerateOutcome {
@@ -475,7 +478,7 @@ function failBeforeProvider(
interface GenerateOptions {
/** Resolved UI locale for the prompt + cache row. */
locale: "de" | "en";
locale: SupportedLocale;
/** Skip the 24 h cache short-circuit and force a fresh generation. */
force?: boolean;
/**
@@ -22,15 +22,19 @@
* Server-only — reads the DB + the provider chain.
*/
import {
getBaseSystemPrompt,
getBaseSystemPromptBody,
PROMPT_VERSION,
} from "@/lib/ai/prompts/base-system";
import type { Locale } from "@/lib/i18n/config";
import {
instructionLocale,
withOutputLanguage,
} from "@/lib/ai/prompts/output-language";
import {
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
@@ -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 type { Locale } from "@/lib/i18n/config";
import {
openerArchetypeHint,
dayRotatedSeed,
@@ -87,10 +90,25 @@ const SCORE_INPUT_TYPES: readonly MeasurementType[] = [
// ── prompt ────────────────────────────────────────────────────────────────
function scoreSystemPrompt(locale: SupportedLocale): string {
const base = getBaseSystemPrompt(locale);
/**
* The archetype section rides on top of `getBaseSystemPrompt`, which already
* appends the reader's own output-language directive — so this composer must
* NOT append one of its own. It only picks the instruction body: German for
* German readers, English for every other locale.
*
* The parameter is the full `Locale` rather than `SupportedLocale` so the
* routing is correct the moment the pipeline stops collapsing fr/es/it/pl on
* the way in; today's callers still pass a narrowed locale.
*/
function scoreSystemPrompt(locale: Locale): string {
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
// English archetype and French prose. The former `locale === "en" ? EN : DE`
// handed fr/es/it/pl the GERMAN section.
const section =
locale === "en"
instructionLocale(locale) === "en"
? `ARCHETYPE — COMPOSITE WELLNESS SCORE (write like a premium recovery coach — WHOOP / Oura — who genuinely wants this person to do well). This is a 0–100 composite, not a raw measurement. Weave these FOUR beats into varied, connected prose (NOT a fixed template, NOT a labelled list) — lead per the OPENER HINT if one is given:
- STANDING — the score and its band, in one short clause.
- WHAT DROVE IT — name the contributor(s) in \`signal.contributors[]\` that HELPED (the strongest, highest values) AND the one(s) that HURT (the weakest, lowest values), so a good score earns its win and a soft score is explained honestly. Each contributor is itself 0–100; a low value drags the score down, a high one carries it. Never invent a contributor that is not listed. When none are listed, use the trend (signal.delta) instead.
@@ -103,14 +121,14 @@ 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(
signal: MetricSignal,
band: string,
todayKey: string,
locale: SupportedLocale,
locale: Locale,
openerHint: string,
/** v1.30.3 (QA F5) — the user's own IANA tz; was hardcoded "Europe/Berlin". */
tz: string,
@@ -120,7 +138,7 @@ function scoreUserPrompt(
null,
2,
);
if (locale === "en") {
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (${tz})
OPENER HINT: ${openerHint}
Write an assessment of ${signal.metric} today across the four beats — the standing, what helped AND what hurt it, what the band means for the day, and one grounded nudge. Aim for 3–5 sentences, roughly 45–75 words (this overrides the shorter base length cap). Connected prose, not a checklist.
@@ -152,10 +170,11 @@ export async function resolveDerivedAssessment(args: {
const now = args.now ?? new Date();
const locale = normalizeLocale(args.locale);
// Deterministic templates ship de/en only — same instruction-body rule.
const deterministic = resolveDeterministicAssessment(
args.metric,
args.derived,
locale,
instructionLocale(locale),
now,
);
// Not assessable, or status !== ok → no field (the locked contract).
@@ -214,7 +233,13 @@ export async function generateDerivedScoreAssessment(args: {
if (!isAssessableDerivedScore(args.metric)) return;
if (args.derived.status !== "ok") return;
const signal = buildScoreSignal(args.metric, args.derived.value, locale);
// The deterministic signal labels ship de/en templates only; route them
// through the same instruction-body rule rather than a German default.
const signal = buildScoreSignal(
args.metric,
args.derived.value,
instructionLocale(locale),
);
if (!signal) return;
const band = (args.derived.value as { band?: string }).band ?? "yellow";
@@ -230,7 +255,7 @@ export async function generateDerivedScoreAssessment(args: {
// assessment varies across scores and across days instead of riding the
// fixed reference seed. The key is per (user, score, day).
const seedKey = `${args.userId}:${scope}:${todayKey}`;
const openerHint = openerArchetypeHint(seedKey, locale as Locale);
const openerHint = openerArchetypeHint(seedKey, locale);
// v1.22 (W6) — score-warm input-hash gate. A score is re-warmed daily even on
// a day with no new wearable data; this skips the LLM and re-stamps the cached
+16 -5
View File
@@ -18,6 +18,8 @@
*/
import { prisma } from "@/lib/db";
import { instructionLocale } from "@/lib/ai/prompts/output-language";
import type { SupportedLocale } from "@/lib/insights/status-shared";
/**
* Stable identifiers for the seven insight scopes. The string MUST
@@ -70,7 +72,7 @@ export interface PreviousInsightContext {
export async function getPreviousInsightContext(
userId: string,
scope: PreviousContextScope,
locale: "de" | "en",
locale: SupportedLocale,
minAgeHours: number = 12,
): Promise<PreviousInsightContext | null> {
const action = `insights.${scope}.${locale}`;
@@ -125,7 +127,15 @@ export async function getPreviousInsightContext(
/**
* Format a previous-context block for direct injection into the user
* prompt. Output already locale-aware.
* prompt.
*
* The block is written in the same reviewed instruction body the prompt
* composes (`instructionLocale`): German for a German reader, English for
* everyone else. It is an INSTRUCTION to the model, not user-facing prose —
* the reader's own language is named by the prompt's output-language
* directive, so a French reader gets the English instruction block and a
* French assessment. The former `locale === "en" ? EN : DE` handed the
* GERMAN block to fr/es/it/pl.
*
* Example (de):
* "VORHERIGE ANALYSE (vor 7 Tagen, 2026-05-01):
@@ -133,16 +143,17 @@ export async function getPreviousInsightContext(
*/
export function formatPreviousContextForPrompt(
ctx: PreviousInsightContext | null,
locale: "de" | "en",
locale: SupportedLocale,
): string {
const body = instructionLocale(locale);
if (!ctx) {
return locale === "en"
return body === "en"
? "PREVIOUS ANALYSIS: none on file. Treat this as the user's first analysis for this domain — no improvement/regression delta to surface."
: "VORHERIGE ANALYSE: keine vorhanden. Behandle dies als erste Analyse in diesem Bereich — kein Verbesserungs-/Verschlechterungs-Delta zu nennen.";
}
const dateLabel = ctx.generatedAt.slice(0, 10);
if (locale === "en") {
if (body === "en") {
const ageLabel =
ctx.ageDays === 0
? "earlier today"
@@ -115,7 +115,7 @@ describe("buildNarrativeUserPrompt", () => {
expect(prompt).toContain("WEIGHT");
expect(prompt).toContain("ACTIVITY_STEPS ~ SLEEP_DURATION");
expect(prompt).toContain("12 pairs tested");
expect(NARRATIVE_PROMPT_VERSION).toBe("1.13.0");
expect(NARRATIVE_PROMPT_VERSION).toBe("1.14.0");
});
});
@@ -0,0 +1,215 @@
/**
* Output-language contract for the PERIOD NARRATIVE surface.
*
* The bug this pins: the read route narrowed every non-English locale to `de`,
* so a French, Spanish, Italian or Polish reader received a German
* retrospective composed from the German instruction body. The fix keeps the
* two reviewed bodies (de, en) and lets the other four ride the English body
* with their language named and their OWN reply-language directive appended.
*
* Three things are asserted here, in ascending strength:
* 1. the four locales name their language and carry the verbatim directive
* from the translator-facing `safety-contracts.<locale>.yaml`;
* 2. no non-German prompt contains a German-body sentinel — the "nothing
* silently falls back to German" assertion this whole change exists for;
* 3. the German and English prompts are byte-identical to their pre-change
* form, modulo the prompt-version string, pinned by content hash.
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc:${s}`,
decrypt: (s: string) => s.replace(/^enc:/, ""),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
import { createHash } from "node:crypto";
import {
buildNarrativeSystemPrompt,
generatePeriodNarrative,
NARRATIVE_PROMPT_VERSION,
} from "@/lib/insights/narrative/period-narrative-generate";
import { loadSafetyContracts } from "@/lib/ai/prompts/safety-contracts";
import { locales, type Locale } from "@/lib/i18n/config";
import type { PeriodNarrativeContext } from "@/lib/insights/narrative/period-narrative";
/** The four locales that ride the English body. */
const RIDING_LOCALES: Locale[] = ["fr", "es", "it", "pl"];
const LANGUAGE_NAMES: Record<string, string> = {
fr: "French",
es: "Spanish",
it: "Italian",
pl: "Polish",
};
/**
* Sentinels that only ever appear in the GERMAN instruction body. A hit on a
* non-German prompt means that locale fell back to German — the exact bug.
*/
const GERMAN_SENTINELS = [
"AUSGABEFORMAT",
"Antworte",
"auf Deutsch",
"Feste Regeln",
"Prompt-Version",
];
/**
* sha256 of the composed prompt with the version string normalised away.
*
* The version is deliberately excluded: bumping `NARRATIVE_PROMPT_VERSION` is
* a required part of any prompt change and is the ONE byte the de/en prompts
* are allowed to move by. Everything else is frozen.
*/
function promptHash(locale: "de" | "en"): string {
const normalised = buildNarrativeSystemPrompt(locale)
.split(NARRATIVE_PROMPT_VERSION)
.join("VERSION");
return createHash("sha256").update(normalised, "utf8").digest("hex");
}
/**
* Hashes captured from the prompts as they stood at NARRATIVE_PROMPT_VERSION
* 1.13.0, before the output-language change. If either moves, the de or en
* prompt drifted — which this change promised would not happen.
*/
const FROZEN_PROMPT_HASHES: Record<"de" | "en", string> = {
de: "be667f16beeb44f51a81fedd929716b2249579fe914b45bb8cba0cd9e64a8c6d",
en: "bb0b080e21a2b9729bdf0047ae221c326ec29ecd74d0999f19ea8deabcbf078a",
};
describe("period-narrative output language", () => {
it("keeps the German and English prompts byte-identical modulo the version", () => {
expect(promptHash("de")).toBe(FROZEN_PROMPT_HASHES.de);
expect(promptHash("en")).toBe(FROZEN_PROMPT_HASHES.en);
});
it("appends no directive to the two reviewed bodies", () => {
expect(buildNarrativeSystemPrompt("de")).not.toContain("OUTPUT LANGUAGE:");
expect(buildNarrativeSystemPrompt("en")).not.toContain("OUTPUT LANGUAGE:");
// The English body must not grow a language rule for its own locale.
expect(buildNarrativeSystemPrompt("en")).not.toContain(
"Write the summary in English.",
);
});
it.each(RIDING_LOCALES)(
"names the language and carries the verbatim directive for %s",
(locale) => {
const prompt = buildNarrativeSystemPrompt(locale);
const directive = loadSafetyContracts(locale).reply_language_directive;
expect(prompt).toContain(
`Write the summary in ${LANGUAGE_NAMES[locale]}.`,
);
expect(prompt).toContain(`OUTPUT LANGUAGE: ${directive}`);
// The directive is the LAST instruction the model reads.
expect(prompt.trimEnd().endsWith(directive.trimEnd())).toBe(true);
// The scaffolding is the English body, not a translation of it.
expect(prompt).toContain("Hard rules:");
},
);
it("specifically composes the French prompt in English with the French directive", () => {
const prompt = buildNarrativeSystemPrompt("fr");
expect(prompt).toContain("Write the summary in French.");
expect(prompt).toContain(
loadSafetyContracts("fr").reply_language_directive,
);
expect(prompt).toContain(
"You summarise one person's health-tracking PERIOD",
);
});
it.each(locales.filter((l) => l !== "de"))(
"leaks no German instruction body into the %s prompt",
(locale) => {
const prompt = buildNarrativeSystemPrompt(locale);
for (const sentinel of GERMAN_SENTINELS) {
expect(prompt).not.toContain(sentinel);
}
},
);
it("keeps the German prompt German", () => {
const prompt = buildNarrativeSystemPrompt("de");
expect(prompt).toContain("Feste Regeln:");
expect(prompt).toContain("Prompt-Version:");
});
});
describe("period-narrative locale storage", () => {
function readyContext(): PeriodNarrativeContext {
return {
status: "ready",
period: "week",
metricDeltas: [
{
type: "WEIGHT",
unit: "kg",
current: 80,
prior: 81,
delta: -1,
deltaPercent: -1.2,
currentDays: 6,
priorDays: 7,
},
],
bandTransitions: [],
drivers: [],
coincidentFlags: [],
pairsTested: 12,
fdrQ: 0.1,
provenance: {
metrics: ["WEIGHT"],
window: {
from: "2026-05-01T00:00:00.000Z",
to: "2026-05-15T00:00:00.000Z",
},
computedAt: "2026-05-15T05:00:00.000Z",
},
};
}
it("keys the row by the reader's own locale and prompts in their language", async () => {
const upsert = vi.fn(async (args: { create: { locale: string } }) => args);
const prisma = {
user: { findUnique: vi.fn(async () => ({ timezone: "Europe/Berlin" })) },
insightNarrative: { findUnique: vi.fn(async () => null), upsert },
};
let capturedSystemPrompt = "";
const runCompletion = vi.fn(async (args: { systemPrompt: string }) => {
capturedSystemPrompt = args.systemPrompt;
return {
kind: "ok" as const,
content: "Votre semaine.",
providerType: "mock",
};
});
const outcome = await generatePeriodNarrative("user-1", {
period: "week",
locale: "fr",
force: true,
buildContext: vi.fn(async () => readyContext()) as never,
runCompletion: runCompletion as never,
prisma: prisma as never,
});
expect(outcome).toEqual({ status: "generated", providerType: "mock" });
// The row is stored under `fr` — NOT collapsed into the de or en row, so a
// French retrospective is never served to a German or English reader.
expect(upsert.mock.calls[0]?.[0]).toMatchObject({
create: { locale: "fr" },
where: { userId_period_locale: { locale: "fr" } },
});
expect(capturedSystemPrompt).toContain("Write the summary in French.");
expect(capturedSystemPrompt).not.toContain("AUSGABEFORMAT");
// The provider cache action is locale-scoped too.
expect(runCompletion.mock.calls[0]?.[0]).toMatchObject({
cacheAction: "insights.narrative.week.fr",
});
});
});
@@ -42,6 +42,12 @@ import {
DETERMINISTIC_PROVIDER_TYPE,
} from "@/lib/insights/narrative/period-narrative-deterministic";
import { composeSharedContracts } from "@/lib/ai/prompts/shared-contracts";
import {
instructionLocale,
targetLanguageName,
withOutputLanguage,
} from "@/lib/ai/prompts/output-language";
import type { Locale } from "@/lib/i18n/config";
import {
validateNarrativeText,
buildNarrativeCorrection,
@@ -51,8 +57,15 @@ import {
* Stable identifier for the narrative prompt revision. Bumped whenever the
* prompt below changes so the cross-feature attribution aggregator can slice
* quality per (provider × prompt) and a prompt change is observable.
*
* 1.14.0 — output language: French, Spanish, Italian and Polish readers used
* to fall to the German instruction body (the route narrowed every non-`en`
* locale to `de`), so they received German retrospectives. They now compose
* the English body with their language named in an added rule and the
* locale's own reply-language directive appended last. The German and English
* prompts are unchanged apart from this version string (test-pinned).
*/
export const NARRATIVE_PROMPT_VERSION = "1.13.0" as const;
export const NARRATIVE_PROMPT_VERSION = "1.14.0" as const;
/** A narrative cached this recently is served without regenerating. */
const NARRATIVE_FRESH_MS = 20 * 60 * 60 * 1000;
@@ -67,8 +80,14 @@ export type NarrativeGenerateOutcome =
interface GenerateOptions {
/** Which period to narrate. */
period: NarrativePeriod;
/** Resolved UI locale for the prompt + row. */
locale: "de" | "en";
/**
* Resolved UI locale for the prompt + row.
*
* The full six-locale union: the row is keyed `(user, period, locale)`, so a
* French reader's French retrospective is stored under `fr` rather than
* mislabelled as German or English prose.
*/
locale: Locale;
/** Skip the freshness short-circuit and force a fresh generation. */
force?: boolean;
/** Injected clock for deterministic tests; defaults to now. */
@@ -105,7 +124,24 @@ function dateKeyFor(now: Date, tz: string): string {
// ── prompt ──────────────────────────────────────────────────────────────
const SYSTEM_PROMPT_EN = `You summarise one person's health-tracking PERIOD (a week or a month) for that person.
/**
* Placeholder for the extra output-language rule inside the English body.
*
* Empty for `en` (whose body is written in the language it asks for), so the
* composed English prompt stays byte-stable; for the four locales that ride
* the English body it renders one more hard rule naming the reader's
* language. The German body carries no token — it names its language by being
* written in it.
*/
const LANGUAGE_RULE_TOKEN = "{{LANGUAGE_RULE}}";
function languageRule(locale: Locale): string {
return locale === "de" || locale === "en"
? ""
: `\n- Write the summary in ${targetLanguageName(locale)}.`;
}
const SYSTEM_PROMPT_EN_TEMPLATE = `You summarise one person's health-tracking PERIOD (a week or a month) for that person.
Prompt version: ${NARRATIVE_PROMPT_VERSION}.
Hard rules:
@@ -115,7 +151,7 @@ Hard rules:
- No diagnosis, no medical advice, no alarm. Calm, factual, second person ("your"). When the period earned it, name one genuine win plainly; keep it warm and second-person, never a reflexive compliment.
- SHAPE: lead with the period's one-line verdict (the overall read in plain words), then connect the top movers and surviving associations into a short arc, and close on continuity or one watch-item — synthesize, do not list each metric in turn.
- 2 to 4 short sentences. Plain text only — no markdown, no headings, no bullet points, no emojis.
- If the context is thin, say plainly that there is little to report this period rather than inventing detail.
- If the context is thin, say plainly that there is little to report this period rather than inventing detail.${LANGUAGE_RULE_TOKEN}
${composeSharedContracts("en", ["toneContract", "grounding", "openingShape", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller", "formattingContract"])}`;
@@ -133,14 +169,33 @@ Feste Regeln:
${composeSharedContracts("de", ["toneContract", "grounding", "openingShape", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller", "formattingContract"])}`;
/**
* The system prompt for one reader.
*
* German readers compose the reviewed German body; every other locale
* composes the English body and is told, in its own language, which language
* to write in. The former `locale === "de" ? DE : EN` binary was fed by a
* route that narrowed every non-`en` locale to `de`, so French, Spanish,
* Italian and Polish readers received German retrospectives.
*/
export function buildNarrativeSystemPrompt(locale: Locale): string {
const body =
instructionLocale(locale) === "de"
? SYSTEM_PROMPT_DE
: SYSTEM_PROMPT_EN_TEMPLATE.split(LANGUAGE_RULE_TOKEN).join(
languageRule(locale),
);
return withOutputLanguage(body, locale);
}
/**
* Test-only view of the composed system prompts (incl. the appended shared
* contracts), so the cross-surface coverage test can assert fragment presence
* without re-deriving the composition.
*/
export const SYSTEM_PROMPTS_FOR_TEST: Record<"de" | "en", string> = {
de: SYSTEM_PROMPT_DE,
en: SYSTEM_PROMPT_EN,
de: buildNarrativeSystemPrompt("de"),
en: buildNarrativeSystemPrompt("en"),
};
/** Render the typed context into a compact, model-readable block. */
@@ -249,6 +304,10 @@ export async function generatePeriodNarrative(
options: GenerateOptions,
): Promise<NarrativeGenerateOutcome> {
const { locale } = options;
// The de/en body language the user prompt, the deterministic fallback and
// the grounding correction are composed in. The ROW stays keyed by the full
// `locale`; only the prose scaffolding collapses to a reviewed body.
const bodyLocale = instructionLocale(locale);
const force = options.force === true;
const now = options.now ?? new Date();
const prisma = options.prisma ?? defaultPrisma;
@@ -289,8 +348,8 @@ export async function generatePeriodNarrative(
userId,
cacheAction: `insights.narrative.${period}.${locale}`,
consentSurface: "insights",
systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN,
userPrompt: buildNarrativeUserPrompt(context, locale),
systemPrompt: buildNarrativeSystemPrompt(locale),
userPrompt: buildNarrativeUserPrompt(context, bodyLocale),
temperature: AI_BUDGETS.narrative.temperature,
maxTokens: AI_BUDGETS.narrative.maxTokens,
// v1.18.7 / v1.22 (W6) — the reference/diff path pins the deterministic
@@ -363,7 +422,7 @@ export async function generatePeriodNarrative(
// the same structured context, so the retrospective card is never empty for
// an active account (iOS H2). A later AI warm overwrites this row in place.
if (completion.kind === "none") {
const text = buildDeterministicNarrative(context, locale);
const text = buildDeterministicNarrative(context, bodyLocale);
await persist(text, DETERMINISTIC_PROVIDER_TYPE);
return { status: "generated", providerType: DETERMINISTIC_PROVIDER_TYPE };
}
@@ -395,10 +454,10 @@ export async function generatePeriodNarrative(
userId,
cacheAction: `insights.narrative.${period}.${locale}`,
consentSurface: "insights",
systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN,
systemPrompt: buildNarrativeSystemPrompt(locale),
userPrompt:
buildNarrativeUserPrompt(context, locale) +
buildNarrativeCorrection(findings, locale),
buildNarrativeUserPrompt(context, bodyLocale) +
buildNarrativeCorrection(findings, bodyLocale),
temperature: AI_BUDGETS.narrative.temperature,
maxTokens: AI_BUDGETS.narrative.maxTokens,
seed: REFERENCE_AI_SEED,
@@ -430,7 +489,7 @@ export async function generatePeriodNarrative(
/** The narrative row, decrypted for a read. */
export interface NarrativeRead {
period: NarrativePeriod;
locale: "de" | "en";
locale: Locale;
text: string;
dateKey: string;
provenance: NarrativeProvenancePayload | null;
@@ -448,7 +507,7 @@ export interface NarrativeRead {
export async function readPeriodNarrative(
userId: string,
period: NarrativePeriod,
locale: "de" | "en",
locale: Locale,
prisma: PrismaClient = defaultPrisma,
): Promise<NarrativeRead | null> {
const row = await prisma.insightNarrative.findUnique({
@@ -475,7 +534,7 @@ export async function readPeriodNarrative(
return {
period: row.period as NarrativePeriod,
locale: row.locale as "de" | "en",
locale: row.locale as Locale,
text,
dateKey: row.dateKey,
provenance,
+6 -3
View File
@@ -39,7 +39,10 @@ import {
type PreparedStatusCard,
} from "@/lib/insights/status-card-generation";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { stripJsonFences } from "@/lib/insights/status-shared";
import {
stripJsonFences,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import {
buildStatusBatchUserPrompt,
@@ -53,7 +56,7 @@ import { annotate } from "@/lib/logging/context";
const PREPARES: ReadonlyArray<
(
userId: string,
options: { locale: "de" | "en"; force?: boolean },
options: { locale: SupportedLocale; force?: boolean },
) => Promise<PreparedStatusCard>
> = [
prepareBloodPressureStatusForUser,
@@ -109,7 +112,7 @@ function parsePerMetric(content: string): Record<string, string> | null {
*/
export async function generateStatusBatchForUser(
userId: string,
options: { locale: "de" | "en"; force?: boolean },
options: { locale: SupportedLocale; force?: boolean },
): Promise<StatusBatchResult> {
const result: StatusBatchResult = {
served: 0,
+2 -1
View File
@@ -15,6 +15,7 @@ import {
DISCOVERY_OUTCOMES,
} from "@/lib/insights/correlation-discovery";
import { annotate } from "@/lib/logging/context";
import type { SupportedLocale } from "@/lib/insights/status-shared";
/**
* v1.18.11 (P6-tighten) — the measurement types the FDR correlation-discovery
@@ -499,7 +500,7 @@ export type ReadOnlyMissOutcome =
export async function resolveReadOnlyStatusMiss(args: {
userId: string;
metric: InsightStatusScope;
locale: "de" | "en";
locale: SupportedLocale;
}): Promise<ReadOnlyMissOutcome> {
const hasProvider = await hasUsableStatusProvider(args.userId);
if (!hasProvider) return { kind: "no-provider" };
+6 -3
View File
@@ -16,7 +16,10 @@ import {
enqueueStatusGeneration,
type InsightStatusScope,
} from "@/lib/jobs/insight-status-generate-shared";
import { normalizeLocale } from "@/lib/insights/status-shared";
import {
normalizeLocale,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import { isTimeoutStub, statusCacheAction } from "@/lib/insights/status-cache";
import {
metricIdForMeasurementType,
@@ -218,7 +221,7 @@ export async function invalidateStatusInsightsForTypes(
*/
export async function enqueueStatusRefillForUser(
userId: string,
locale: "de" | "en",
locale: SupportedLocale,
): Promise<number> {
const scopes = new Set<InsightStatusScope>(PER_STATUS_SCOPES);
try {
@@ -257,7 +260,7 @@ export async function enqueueStatusRefillForUser(
*/
async function findRecentlyWarmedScopes(
userId: string,
locale: "de" | "en",
locale: SupportedLocale,
scopes: ReadonlySet<InsightStatusScope>,
): Promise<Set<InsightStatusScope>> {
const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS);
+24 -8
View File
@@ -9,17 +9,28 @@
* to the rounding precision, the chart-token scrub, or the cache-row
* shape lands once rather than seven times.
*
* The shapes are intentionally narrow — `SupportedLocale` is `de | en`
* because the status prompts ship only those two locales. The
* `SupportedLocale` is the full six-locale UI union. The status prompts
* compose one of two reviewed instruction bodies (de / en) but name the
* reader's own language in an explicit output directive, so the reader's
* locale must survive the whole pipeline rather than being collapsed to a
* binary on the way in. The
* medication-compliance generator writes a richer cache `details` shape
* (it carries a per-medication array), so it shares `round`,
* `normalizeSummaryText`, `normalizeLocale`, and `parseSummaryFromContent`
* but keeps its own `auditLog.create`.
*/
import { prisma } from "@/lib/db";
import { locales, type Locale } from "@/lib/i18n/config";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
export type SupportedLocale = "de" | "en";
/**
* The locales the assessment pipeline carries end-to-end — all six the UI
* ships. Aliased to the i18n `Locale` so adding a UI locale widens the
* pipeline in one place rather than needing a second edit here.
*/
export type SupportedLocale = Locale;
const SUPPORTED_LOCALES: ReadonlySet<string> = new Set(locales);
/** Round to `digits` decimal places. */
export function round(value: number, digits = 1): number {
@@ -33,15 +44,20 @@ export function normalizeSummaryText(value: string): string {
}
/**
* Narrow an arbitrary locale string to the two the prompts support.
* Non-German locales (fr/es/it/pl, unknown values) resolve to ENGLISH —
* the same routing the no-key fallbacks use — so a French UI never
* receives German AI prose just because the prompts ship DE + EN only.
* Validate an arbitrary locale string against the six the UI ships.
*
* A recognised locale passes through UNCHANGED — that is the whole point:
* the former `value === "de" ? "de" : "en"` binary erased a French reader's
* locale before any prompt saw it, so the output-language directive could
* never fire. An unrecognised or missing value defaults to ENGLISH, never
* German; a German default is the bug class this replaces.
*/
export function normalizeLocale(
value: string | null | undefined,
): SupportedLocale {
return value === "de" ? "de" : "en";
return typeof value === "string" && SUPPORTED_LOCALES.has(value)
? (value as SupportedLocale)
: "en";
}
export interface SeriesSummary {
@@ -783,6 +783,36 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
});
});
it("carries a fr locale into the comprehensive and status warms instead of collapsing it", async () => {
// The regression this pins: the warm typed its locale `de | en`, so a
// French account's warm ran (and cached) in the wrong language — and the
// prompt layer's output-language directive never saw `fr` to act on.
const { prisma } = makePrisma([]);
const generate = vi
.fn()
.mockResolvedValue({ status: "generated", providerType: "x" });
const statusGenerators = Array.from({ length: 7 }, () =>
vi.fn().mockResolvedValue({ hasProvider: true, cached: false }),
);
const warmGenericMetrics = vi.fn().mockResolvedValue(3);
await forceWarmUser(prisma as never, "u1", "fr", {
generate,
statusGenerators,
warmGenericMetrics,
});
expect(generate).toHaveBeenCalledWith("u1", {
locale: "fr",
force: true,
signal: expect.any(AbortSignal),
});
for (const g of statusGenerators) {
expect(g).toHaveBeenCalledWith("u1", { locale: "fr", force: false });
}
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["fr"]);
});
it("skips the comprehensive when the cache was warmed within the freshness window (idempotent re-enqueue)", async () => {
// A revalidation poll that outlives the enqueue singleton can stack
// several force jobs; the job-start freshness re-check collapses the
+9 -4
View File
@@ -13,6 +13,7 @@
* name + payload type + enqueue helper in the generator-free module, the
* concrete dispatch in the worker module.
*/
import type { SupportedLocale } from "@/lib/insights/status-shared";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
@@ -29,8 +30,12 @@ export interface InsightPregeneratePayload {
*/
userId?: string;
force?: boolean;
/** Locale to warm on the forced path; defaults to "de" when absent. */
locale?: "de" | "en";
/**
* Locale to warm on the forced path. Carries the reader's full UI locale
* (one of the six); the handler validates it and defaults an absent or
* unrecognised value to English.
*/
locale?: SupportedLocale;
}
/**
@@ -48,7 +53,7 @@ export interface InsightPregeneratePayload {
*/
export async function enqueueForceWarm(payload: {
userId: string;
locale: "de" | "en";
locale: SupportedLocale;
}): Promise<void> {
const boss = getGlobalBoss();
if (!boss) {
@@ -119,7 +124,7 @@ export const PREGENERATE_RETRY_DELAY_SECONDS = 45 * 60;
*/
export async function enqueuePregenerateFailureRetry(payload: {
userId: string;
locale: "de" | "en";
locale: SupportedLocale;
}): Promise<void> {
const boss = getGlobalBoss();
if (!boss) {
+34 -20
View File
@@ -54,6 +54,10 @@ import type { PrismaClient } from "@/generated/prisma/client";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAssistantFlags } from "@/lib/feature-flags";
import { annotate } from "@/lib/logging/context";
import {
normalizeLocale as normalizeStatusLocale,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import {
generateComprehensiveInsight,
type GenerateOutcome,
@@ -301,7 +305,7 @@ export interface PregenerateRunResult {
*/
type StatusGenerator = (
userId: string,
options: { locale: "de" | "en"; force?: boolean },
options: { locale: SupportedLocale; force?: boolean },
) => Promise<{ hasProvider: boolean; cached: boolean }>;
/**
@@ -323,7 +327,7 @@ type StatusGenerator = (
*/
async function warmPerStatusCaches(
userId: string,
locales: ReadonlyArray<"de" | "en">,
locales: ReadonlyArray<SupportedLocale>,
generators: ReadonlyArray<StatusGenerator>,
): Promise<number> {
const limit = pLimit(WARM_PASS_CONCURRENCY);
@@ -372,7 +376,7 @@ async function warmPerStatusCaches(
*/
async function warmStatusBatch(
userId: string,
locales: ReadonlyArray<"de" | "en">,
locales: ReadonlyArray<SupportedLocale>,
): Promise<number> {
let warmed = 0;
for (const locale of locales) {
@@ -413,7 +417,7 @@ async function warmStatusBatch(
async function warmGenericMetricCaches(
prisma: PrismaClient,
userId: string,
locales: ReadonlyArray<"de" | "en">,
locales: ReadonlyArray<SupportedLocale>,
): Promise<number> {
// One distinct read: the set of MeasurementTypes the user has live
// rows for. Best-effort — a read failure (or a worker prisma surface
@@ -532,12 +536,16 @@ export async function findPregenerateCandidates(
}
/**
* Non-German locales resolve to ENGLISH (matching
* `status-shared.normalizeLocale` and the no-key fallback routing) so a
* fr/es/it/pl account gets English AI prose, not German.
* The user's stored locale, validated against the six the UI ships.
*
* This was a local `value === "de" ? "de" : "en"` copy, which flattened a
* fr/es/it/pl account to English before the warm pass ever reached a prompt.
* It now defers to the one shared validator so the reader's locale survives
* into the prompt's output-language directive; an unknown value still
* defaults to English, never German.
*/
function normalizeLocale(value: string | null): "de" | "en" {
return value === "de" ? "de" : "en";
function normalizeLocale(value: string | null): SupportedLocale {
return normalizeStatusLocale(value);
}
/**
@@ -555,7 +563,7 @@ export async function runInsightPregenerate(
/** Injected for the test — defaults to the real generator. */
generate?: (
userId: string,
opts: { locale: "de" | "en"; force?: boolean; signal?: AbortSignal },
opts: { locale: SupportedLocale; force?: boolean; signal?: AbortSignal },
) => Promise<GenerateOutcome>;
/** Injected for the test — defaults to the seven real status generators. */
statusGenerators?: ReadonlyArray<StatusGenerator>;
@@ -566,7 +574,7 @@ export async function runInsightPregenerate(
*/
warmGenericMetrics?: (
userId: string,
locales: ReadonlyArray<"de" | "en">,
locales: ReadonlyArray<SupportedLocale>,
) => Promise<number>;
/**
* v1.28.30 — injected for the test. Defaults to the real delayed
@@ -576,7 +584,7 @@ export async function runInsightPregenerate(
*/
enqueueRetry?: (payload: {
userId: string;
locale: "de" | "en";
locale: SupportedLocale;
}) => Promise<void>;
} = {},
): Promise<PregenerateRunResult> {
@@ -589,13 +597,16 @@ export async function runInsightPregenerate(
// production injects nothing, so the seven warm calls collapse into ONE
// batched generation. The injected per-card path stays the test seam.
const injectedStatusGenerators = options.statusGenerators;
const warmStatus = (userId: string, locales: ReadonlyArray<"de" | "en">) =>
const warmStatus = (
userId: string,
locales: ReadonlyArray<SupportedLocale>,
) =>
injectedStatusGenerators
? warmPerStatusCaches(userId, locales, injectedStatusGenerators)
: warmStatusBatch(userId, locales);
const warmGenericMetrics =
options.warmGenericMetrics ??
((userId: string, locales: ReadonlyArray<"de" | "en">) =>
((userId: string, locales: ReadonlyArray<SupportedLocale>) =>
warmGenericMetricCaches(prisma, userId, locales));
const result: PregenerateRunResult = {
@@ -840,16 +851,16 @@ export async function runInsightPregenerate(
export async function forceWarmUser(
prisma: PrismaClient,
userId: string,
locale: "de" | "en",
locale: SupportedLocale,
options: {
generate?: (
userId: string,
opts: { locale: "de" | "en"; force?: boolean; signal?: AbortSignal },
opts: { locale: SupportedLocale; force?: boolean; signal?: AbortSignal },
) => Promise<GenerateOutcome>;
statusGenerators?: ReadonlyArray<StatusGenerator>;
warmGenericMetrics?: (
userId: string,
locales: ReadonlyArray<"de" | "en">,
locales: ReadonlyArray<SupportedLocale>,
) => Promise<number>;
/** Injected for the test — defaults to `new Date()`. */
now?: Date;
@@ -863,13 +874,16 @@ export async function forceWarmUser(
// v1.18.7 (HIGH-1) — batch the seven status warms into ONE provider call
// in production; keep the per-card path when a test injects generators.
const injectedStatusGenerators = options.statusGenerators;
const warmStatus = (userId: string, locales: ReadonlyArray<"de" | "en">) =>
const warmStatus = (
userId: string,
locales: ReadonlyArray<SupportedLocale>,
) =>
injectedStatusGenerators
? warmPerStatusCaches(userId, locales, injectedStatusGenerators)
: warmStatusBatch(userId, locales);
const warmGenericMetrics =
options.warmGenericMetrics ??
((id: string, locales: ReadonlyArray<"de" | "en">) =>
((id: string, locales: ReadonlyArray<SupportedLocale>) =>
warmGenericMetricCaches(prisma, id, locales));
const now = options.now ?? new Date();
@@ -896,7 +910,7 @@ export async function forceWarmUser(
// eviction; that eviction is gone, so the other locale family's rows
// survive untouched and a client actually reading the second locale
// warms it lazily through the read-path enqueue on its first miss.
const locales: ReadonlyArray<"de" | "en"> = [locale];
const locales: ReadonlyArray<SupportedLocale> = [locale];
// The comprehensive step gets its own bounded budget and its failure is
// non-fatal. A slow or stalled briefing generation must not short-circuit
@@ -10,6 +10,9 @@
* concrete generators) lives in `insight-status-generate.ts` and is
* imported solely by the worker boot file.
*/
// Type-only — erased at compile time, so the generator-free boundary of
// this module (see the header) is preserved.
import type { SupportedLocale } from "@/lib/insights/status-shared";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
@@ -87,7 +90,7 @@ export interface InsightStatusGeneratePayload {
*/
metric: InsightStatusScope;
/** Resolved client locale — the cache key the route reads against. */
locale: "de" | "en";
locale: SupportedLocale;
}
/**
+2 -1
View File
@@ -27,6 +27,7 @@
* import cycle). This module re-exports them for the worker's convenience.
*/
import { annotate } from "@/lib/logging/context";
import type { SupportedLocale } from "@/lib/insights/status-shared";
import { generateGeneralStatusForUser } from "@/lib/insights/general-status";
import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status";
import { generateWeightStatusForUser } from "@/lib/insights/weight-status";
@@ -70,7 +71,7 @@ export type { InsightStatusMetric, InsightStatusGeneratePayload };
*/
type StatusGenerator = (
userId: string,
options: { locale: "de" | "en"; force: boolean },
options: { locale: SupportedLocale; force: boolean },
) => Promise<unknown>;
const GENERATORS: Record<InsightStatusMetric, StatusGenerator> = {
+6 -3
View File
@@ -35,7 +35,10 @@ import {
type GenerateOutcome,
} from "@/lib/insights/comprehensive-generate";
import { enqueuePregenerateFailureRetry } from "@/lib/jobs/insight-pregenerate-shared";
import { normalizeLocale } from "@/lib/insights/status-shared";
import {
normalizeLocale,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import {
MORNING_DIGEST_REFRESH_QUEUE,
type MorningDigestRefreshPayload,
@@ -52,7 +55,7 @@ export interface MorningDigestRefreshResult {
type GenerateFn = (
userId: string,
options: { locale: "de" | "en"; force?: boolean },
options: { locale: SupportedLocale; force?: boolean },
) => Promise<GenerateOutcome>;
/**
@@ -70,7 +73,7 @@ export async function runMorningDigestRefresh(
generate?: GenerateFn;
enqueueRetry?: (args: {
userId: string;
locale: "de" | "en";
locale: SupportedLocale;
}) => Promise<void>;
} = {},
): Promise<MorningDigestRefreshResult> {
+4 -3
View File
@@ -14,6 +14,7 @@
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative";
import type { Locale } from "@/lib/i18n/config";
export const PERIOD_NARRATIVE_QUEUE = "period-narrative-warm";
@@ -33,8 +34,8 @@ export interface PeriodNarrativePayload {
userId?: string;
/** Period to warm on the single-user path. */
period?: NarrativePeriod;
/** Locale to warm; defaults to "de" when absent. */
locale?: "de" | "en";
/** Locale to warm; defaults to the app default (`en`) when absent. */
locale?: Locale;
}
/**
@@ -47,7 +48,7 @@ export interface PeriodNarrativePayload {
export async function enqueueNarrativeWarm(payload: {
userId: string;
period: NarrativePeriod;
locale: "de" | "en";
locale: Locale;
}): Promise<void> {
const boss = getGlobalBoss();
if (!boss) return;
+11 -5
View File
@@ -28,6 +28,7 @@ import {
type NarrativeGenerateOutcome,
} from "@/lib/insights/narrative/period-narrative-generate";
import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
import {
PERIOD_NARRATIVE_QUEUE,
PERIOD_NARRATIVE_CRON,
@@ -128,11 +129,16 @@ export async function findNarrativeCandidates(
}
/**
* Non-German locales resolve to ENGLISH (matching
* `status-shared.normalizeLocale` and the no-key fallback routing).
* The user's stored locale, validated against the shipped UI locale union.
*
* The narrative row is keyed `(user, period, locale)`, so the nightly warm has
* to write the SAME key the read route reads. Collapsing fr/es/it/pl to `en`
* here would leave those users' rows permanently cold on the nightly path.
* An unset or unknown value falls back to the app default (`en`), never to
* German.
*/
function normalizeLocale(value: string | null): "de" | "en" {
return value === "de" ? "de" : "en";
function normalizeLocale(value: string | null): Locale {
return locales.includes(value as Locale) ? (value as Locale) : defaultLocale;
}
/**
@@ -254,7 +260,7 @@ export async function warmOneNarrative(
if (!flags.briefing && !flags.insightStatus) return null;
return generatePeriodNarrative(payload.userId, {
period: payload.period,
locale: payload.locale ?? "de",
locale: payload.locale ?? defaultLocale,
force: true,
});
}
+8 -4
View File
@@ -6,6 +6,7 @@
*/
import { type Job } from "pg-boss";
import { reportWorkerError } from "@/lib/jobs/report-worker-error";
import { normalizeLocale } from "@/lib/insights/status-shared";
import { recordError, recordInsightsRun } from "@/lib/jobs/worker-status";
import {
INSIGHT_PREGENERATE_QUEUE,
@@ -142,9 +143,10 @@ async function runStatusBatchCron(taskName: string): Promise<void> {
for (const user of users) {
try {
const result = await generateStatusBatchForUser(user.id, {
// The batch needs a concrete `de`/`en`; the per-metric generators
// normalise the same way (de stays de, everything else English).
locale: user.locale === "de" ? "de" : "en",
// Validate against the six UI locales rather than collapsing to a
// binary — the per-metric generators normalise identically, and the
// prompt names the reader's own language from this value.
locale: normalizeLocale(user.locale),
force: false,
});
generated += result.batched + result.fellBack;
@@ -238,7 +240,9 @@ export async function handleInsightPregenerateJob(
for (const job of forced) {
const userId = job.data.userId as string;
const locale = job.data.locale === "en" ? "en" : "de";
// The former `=== "en" ? "en" : "de"` defaulted a fr/es/it/pl payload to
// GERMAN, so a forced warm produced German prose for a French reader.
const locale = normalizeLocale(job.data.locale);
try {
const summary = await forceWarmUser(getWorkerPrisma(), userId, locale);
evt.addMeta(