mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'wt/l4-pipeline' into release/v1.30.15
This commit is contained in:
commit
5c83156dcf
18 files changed
+363
-66
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user