Merge branch 'wt/l4-pipeline' into release/v1.30.15

This commit is contained in:
Marc-André Bombeck committed 2026-07-18 23:48:03 +02:00
commit 5c83156dcf
18 files changed
+363 -66

No files matched your search

+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" },
+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,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");
});
});
+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;
/**
@@ -43,7 +43,7 @@ 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 { instructionLocale } from "@/lib/ai/prompts/output-language";
import {
openerArchetypeHint,
dayRotatedSeed,
@@ -99,6 +99,11 @@ const SCORE_INPUT_TYPES: readonly MeasurementType[] = [
*/
function scoreSystemPrompt(locale: Locale): string {
const base = getBaseSystemPrompt(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 =
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:
@@ -162,10 +167,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).
@@ -224,7 +230,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";
@@ -240,7 +252,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"
+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> {
+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(