Merge branch 'wt/l5-narrative' into release/v1.30.15

This commit is contained in:
Marc-André Bombeck committed 2026-07-18 23:48:03 +02:00
commit f8c4032515
16 files changed
+451 -45

No files matched your search

+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")
+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,
+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) => {
@@ -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,
+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),
@@ -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,
+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,
});
}