diff --git a/src/__tests__/helpers/narrated-locale.ts b/src/__tests__/helpers/narrated-locale.ts new file mode 100644 index 000000000..a7042029b --- /dev/null +++ b/src/__tests__/helpers/narrated-locale.ts @@ -0,0 +1,300 @@ +/** + * Detector for the "a narrated sentence's locale stays required" guard. + * + * Lives beside the guard rather than inside it so the guard's own + * counter-tests can run this exact code against disguised sources without + * re-running the guard itself. See `narrated-locale-required-guard.test.ts` + * for why the rule exists and `narrated-locale-guard-defeat.test.ts` for the + * disguises it has to see through. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import ts from "typescript"; + +const REPO_ROOT = resolve(__dirname, "../../.."); + +/** + * The chain. Each entry is a declaration whose output is, or becomes, prose a + * person reads. Add to it when a new one joins the chain; never remove one + * because the check became inconvenient. + */ +export const CHAIN: ReadonlyArray<{ file: string; declaration: string }> = [ + { + file: "src/lib/insights/correlation-discovery.ts", + declaration: "discoverCorrelations", + }, + { + file: "src/lib/insights/correlation-discovery.ts", + declaration: "discoverEmergingCorrelations", + }, + { + file: "src/lib/insights/correlation-discovery.ts", + declaration: "discoverLabOutcomeCorrelations", + }, + { + file: "src/lib/ai/coach/tools/correlations-read.ts", + declaration: "readCoachCorrelations", + }, + { + file: "src/lib/insights/derived/coach-read.ts", + declaration: "buildCoachReadStrip", + }, + { + file: "src/lib/insights/metric-correlation-context.ts", + declaration: "getRelevantCorrelationsForMetric", + }, + { + file: "src/lib/cycle/phase-crosstab.ts", + declaration: "discoverPhaseCorrelations", + }, + { + file: "src/lib/insights/narrative/period-narrative.ts", + declaration: "buildPeriodNarrativeContext", + }, + { + file: "src/lib/insights/narrative/period-narrative.ts", + declaration: "assemblePeriodNarrativeContext", + }, + { + file: "src/lib/ai/coach/correlations-snapshot.ts", + declaration: "buildCorrelationsSnapshotBlock", + }, +]; + +export const WHY_NO_DEFAULT = + "A locale on this chain must be REQUIRED, with no `?` and no default. " + + "These functions return finished sentences that a page prints verbatim. " + + "An optional locale lets a caller say nothing and still get a sentence — " + + "which is exactly how the metric page's Coach-read strip came to answer a " + + "German page in English, under a German first line, for months. A default " + + "cannot fail loudly; a missing argument can. Pass the reader's locale " + + "instead of restoring the fallback."; + +export interface LocaleSite { + /** Where it sits, for the failure message. */ + where: string; + optional: boolean; + hasInitializer: boolean; +} + +/** The local name `Locale` is bound to in this file (it may be aliased). */ +function localeTypeName(source: ts.SourceFile): string { + let name = "Locale"; + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement)) continue; + const bindings = statement.importClause?.namedBindings; + if (!bindings || !ts.isNamedImports(bindings)) continue; + for (const element of bindings.elements) { + if ((element.propertyName ?? element.name).text === "Locale") { + name = element.name.text; + } + } + } + return name; +} + +/** Does this type node denote the locale type (possibly a union with it)? */ +function isLocaleType(node: ts.TypeNode | undefined, alias: string): boolean { + if (!node) return false; + if (ts.isTypeReferenceNode(node)) return node.typeName.getText() === alias; + if (ts.isUnionTypeNode(node)) + return node.types.some((t) => isLocaleType(t, alias)); + return false; +} + +/** + * Does the type still let a caller say nothing? `Locale | undefined` and + * `Locale | null` carry no `?` and no default, so a check that only looked at + * the question mark read them as required — and they are the original bug + * wearing a different hat: the caller passes `undefined`, the body picks a + * language, and the sentence comes back in English again. `any` and `unknown` + * are the same hole with the door taken off. + */ +function toleratesAbsence(node: ts.TypeNode | undefined): boolean { + if (!node) return false; + if ( + node.kind === ts.SyntaxKind.UndefinedKeyword || + node.kind === ts.SyntaxKind.NullKeyword || + node.kind === ts.SyntaxKind.AnyKeyword || + node.kind === ts.SyntaxKind.UnknownKeyword || + node.kind === ts.SyntaxKind.VoidKeyword + ) { + return true; + } + if (ts.isLiteralTypeNode(node)) { + return node.literal.kind === ts.SyntaxKind.NullKeyword; + } + if (ts.isUnionTypeNode(node)) return node.types.some(toleratesAbsence); + return false; +} + +/** + * Property names given a default inside an object binding pattern — + * `function narrate({ locale = "en" }: { locale: Locale })`. The default sits + * on the binding, not on the parameter, so `parameter.initializer` is empty + * and the signature reads clean. + */ +function defaultedBindingNames( + parameter: ts.ParameterDeclaration, +): Set { + const names = new Set(); + if (!ts.isObjectBindingPattern(parameter.name)) return names; + for (const element of parameter.name.elements) { + if (element.initializer === undefined) continue; + names.add((element.propertyName ?? element.name).getText()); + } + return names; +} + +/** Find a top-level function/interface declaration by name. */ +function findDeclaration( + source: ts.SourceFile, + name: string, +): ts.FunctionDeclaration | ts.InterfaceDeclaration | undefined { + for (const statement of source.statements) { + if ( + (ts.isFunctionDeclaration(statement) || + ts.isInterfaceDeclaration(statement)) && + statement.name?.text === name + ) { + return statement; + } + } + return undefined; +} + +/** + * Every place a locale is accepted by `declaration` — as a parameter in its own + * right, as a member of an inline options object, or as a member of a + * same-file interface used as a parameter type. Keyed on the TYPE, so renaming + * the parameter changes nothing. + */ +export function localeSites( + source: ts.SourceFile, + declarationName: string, +): LocaleSite[] { + const alias = localeTypeName(source); + const declaration = findDeclaration(source, declarationName); + if (!declaration) { + throw new Error( + `${WHY_NO_DEFAULT}\n\nGuard could not find \`${declarationName}\` in ` + + `${source.fileName}. If it was renamed or moved, update the chain ` + + `list — do not delete the entry.`, + ); + } + + const sites: LocaleSite[] = []; + + const collectFromMembers = ( + members: ts.NodeArray, + owner: string, + ownerOptional: boolean, + ownerHasInitializer: boolean, + defaultedNames: Set = new Set(), + ) => { + for (const member of members) { + if (!ts.isPropertySignature(member)) continue; + if (!isLocaleType(member.type, alias)) continue; + const name = member.name.getText(); + sites.push({ + where: `${owner}.${name}`, + // The property itself may be required while the object it lives on is + // optional or defaulted — which puts the silence right back. So does a + // type that merely admits `undefined` / `null`: no question mark, and a + // caller can still hand over nothing. + optional: + member.questionToken !== undefined || + ownerOptional || + toleratesAbsence(member.type), + hasInitializer: ownerHasInitializer || defaultedNames.has(name), + }); + } + }; + + const visitParameters = ( + parameters: ts.NodeArray, + ) => { + for (const parameter of parameters) { + const owner = parameter.name.getText(); + const optional = parameter.questionToken !== undefined; + const hasInitializer = parameter.initializer !== undefined; + const defaultedNames = defaultedBindingNames(parameter); + if (isLocaleType(parameter.type, alias)) { + sites.push({ + where: owner, + optional: optional || toleratesAbsence(parameter.type), + hasInitializer, + }); + continue; + } + const type = parameter.type; + if (type && ts.isTypeLiteralNode(type)) { + collectFromMembers( + type.members, + owner, + optional, + hasInitializer, + defaultedNames, + ); + } else if (type && ts.isTypeReferenceNode(type)) { + const referenced = findDeclaration(source, type.typeName.getText()); + if (referenced && ts.isInterfaceDeclaration(referenced)) { + collectFromMembers( + referenced.members, + type.typeName.getText(), + optional, + hasInitializer, + defaultedNames, + ); + } + } + } + }; + + if (ts.isInterfaceDeclaration(declaration)) { + collectFromMembers( + declaration.members, + declaration.name.text, + false, + false, + ); + } else { + visitParameters(declaration.parameters); + } + + return sites; +} + +/** + * Every `??` / `||` in the file whose left side reads a locale. This is the + * "move the default into a variable" escape: `opts.locale ?? fallback` is the + * same bug as `opts.locale ?? defaultLocale`, and neither is allowed here. + */ +export function localeFallbacks(source: ts.SourceFile): string[] { + const found: string[] = []; + const visit = (node: ts.Node): void => { + if (ts.isBinaryExpression(node)) { + const op = node.operatorToken.kind; + if ( + op === ts.SyntaxKind.QuestionQuestionToken || + op === ts.SyntaxKind.BarBarToken + ) { + const left = node.left.getText(); + if (/locale/i.test(left)) found.push(node.getText()); + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(source, visit); + return found; +} + +export function parse(file: string, text?: string): ts.SourceFile { + return ts.createSourceFile( + file, + text ?? readFileSync(resolve(REPO_ROOT, file), "utf8"), + ts.ScriptTarget.Latest, + true, + ); +} diff --git a/src/__tests__/narrated-locale-guard-defeat.test.ts b/src/__tests__/narrated-locale-guard-defeat.test.ts new file mode 100644 index 000000000..cd94e11b1 --- /dev/null +++ b/src/__tests__/narrated-locale-guard-defeat.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; + +import { localeSites, localeFallbacks, parse } from "./helpers/narrated-locale"; + +/** + * The guard checked against a guard. + * + * A structural check that cannot fail is worse than no check: it reads as + * proof and is not. So each case below is a disguise someone would plausibly + * reach for while putting the English default back — renaming the parameter, + * aliasing the type, hiding the fallback behind a variable, defaulting the + * whole options object instead of the field — and each one is asserted to be + * caught by the SAME detector the real guard runs. + * + * These sources are strings on purpose. They are the shapes that must not + * ship, so nothing here may live in a file the app can import. + */ + +const FILE = "disguise.ts"; + +describe("the locale guard cannot be talked around", () => { + it("catches the plain optional it was written for", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale?: Locale }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a renamed parameter — it reads the type, not the name", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { lang?: Locale }) { return opts; }`, + ), + "narrate", + ); + expect(site.where).toBe("opts.lang"); + expect(site.optional).toBe(true); + }); + + it("catches an aliased type import", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale as L } from "@/lib/i18n/config"; + export function narrate(opts: { lang?: L }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a required field on a DEFAULTED options object", () => { + // The field says `locale: Locale`, so a field-only check reads as clean — + // but `= {}` means the caller can still say nothing. + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale } = {} as never) { return opts; }`, + ), + "narrate", + ); + expect(site.hasInitializer).toBe(true); + }); + + it("catches a required field on an OPTIONAL options object", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts?: { locale: Locale }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches an optional locale hidden in a same-file interface", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export interface Opts { period: string; locale?: Locale } + export function narrate(opts: Opts) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a union that merely tolerates undefined", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale?: Locale | undefined }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches `Locale | undefined` written WITHOUT the question mark", () => { + // The nastiest disguise, because it reads as required at a glance: no `?`, + // no default, and the field is spelled out in the signature. But the caller + // can still pass `undefined`, the body picks a language, and the sentence + // comes back in English — the original bug, one hat later. + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale | undefined }) { + return opts.locale === undefined ? "en" : opts.locale; + }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches `Locale | null` the same way", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale | null }) { + return opts.locale === null ? "en" : opts.locale; + }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a locale widened to `any`", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale | any }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a default hidden in the parameter's own binding pattern", () => { + // The field is required in the type and the parameter has no initializer, + // so both of the earlier checks read clean — the default sits on the + // destructuring instead. + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate({ locale = "en" }: { locale: Locale }) { return locale; }`, + ), + "narrate", + ); + expect(site.hasInitializer).toBe(true); + }); + + it("leaves a plainly required locale alone", () => { + // The shape the whole chain is supposed to have. If this ever reads as a + // violation the guard stops being a guard and starts being noise. + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale }) { return opts.locale; }`, + ), + "narrate", + ); + expect(site.optional).toBe(false); + expect(site.hasInitializer).toBe(false); + }); + + it("refuses to pass a declaration that dropped the locale entirely", () => { + expect(() => + localeSites( + parse( + FILE, + `export function narrate(series: string[]) { return series; }`, + ), + "narrate", + ), + ).not.toThrow(); + expect( + localeSites( + parse( + FILE, + `export function narrate(series: string[]) { return series; }`, + ), + "narrate", + ), + ).toEqual([]); // the guard's own `length > 0` assertion is what fails on this + }); + + it("throws rather than quietly passing when the declaration is gone", () => { + expect(() => + localeSites(parse(FILE, `export const x = 1;`), "narrate"), + ).toThrow(/could not find/); + }); + + it("catches the default moved into a variable", () => { + expect( + localeFallbacks( + parse( + FILE, + `const fallback = "en"; + export function narrate(opts: { locale?: string }) { + return opts.locale ?? fallback; + }`, + ), + ), + ).not.toEqual([]); + }); + + it("catches the default written with || instead of ??", () => { + expect( + localeFallbacks( + parse( + FILE, + `export const l = (o: { locale?: string }) => o.locale || "en";`, + ), + ), + ).not.toEqual([]); + }); + + it("catches a renamed field's fallback when it still reads like a locale", () => { + expect( + localeFallbacks( + parse( + FILE, + `export const l = (o: { readerLocale?: string }) => o.readerLocale ?? "en";`, + ), + ), + ).not.toEqual([]); + }); + + it("does not fire on a fallback that has nothing to do with language", () => { + // A guard that flags everything gets silenced, so it has to stay quiet + // where it should. + expect( + localeFallbacks( + parse( + FILE, + `export const n = (o: { minPairs?: number }) => o.minPairs ?? 20;`, + ), + ), + ).toEqual([]); + }); +}); diff --git a/src/__tests__/narrated-locale-required-guard.test.ts b/src/__tests__/narrated-locale-required-guard.test.ts new file mode 100644 index 000000000..a9fceaee7 --- /dev/null +++ b/src/__tests__/narrated-locale-required-guard.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { queryKeys } from "@/lib/query-keys"; +import { + CHAIN, + WHY_NO_DEFAULT, + localeFallbacks, + localeSites, + parse, +} from "./helpers/narrated-locale"; + +/** + * A function that writes a sentence for a person must be told which language. + * + * The metric page's "Coach read" strip printed its own summary in German and + * the pattern line under it in English, one under the other, for months. The + * translations existed in all six languages the whole time. What broke was the + * shape of the call: the discovery engine took `locale?: Locale` and fell back + * to English, and the route that knew who was reading simply never said. Nobody + * had to write anything wrong — staying quiet was enough, and the result read + * like a finished sentence, so nothing looked broken from the code's side. + * + * A default is what made silence survivable. So on this chain there is none: + * every locale a narrated sentence depends on is a REQUIRED parameter, and a + * caller that cannot name one does not compile. This guard is what keeps that + * true, because "optional with a sensible default" reads like a kindness every + * time someone adds the next caller. + * + * It reads types, not names. Renaming the parameter, or lifting the default + * into a variable, does not get past it — see the disguises in + * `narrated-locale-guard-defeat.test.ts`, which run this same detector. + */ + +describe("a narrated sentence's locale stays required", () => { + for (const { file, declaration } of CHAIN) { + it(`${declaration} (${file}) accepts a locale, and it is required`, () => { + const sites = localeSites(parse(file), declaration); + + // Deleting the parameter must not pass as "nothing optional found". + expect( + sites.length, + `${WHY_NO_DEFAULT}\n\n\`${declaration}\` no longer accepts a locale at ` + + `all. It writes prose; it has to be told the language.`, + ).toBeGreaterThan(0); + + for (const site of sites) { + expect( + site.optional, + `${WHY_NO_DEFAULT}\n\nOptional locale at \`${declaration}\` → ${site.where}.`, + ).toBe(false); + expect( + site.hasInitializer, + `${WHY_NO_DEFAULT}\n\nDefaulted locale at \`${declaration}\` → ${site.where}.`, + ).toBe(false); + } + }); + } + + for (const file of [...new Set(CHAIN.map((entry) => entry.file))]) { + it(`${file} falls back to no language of its own`, () => { + const fallbacks = localeFallbacks(parse(file)); + expect( + fallbacks, + `${WHY_NO_DEFAULT}\n\nA locale fallback in ${file}: ` + + `${fallbacks.join(", ")}. Naming the fallback something else does ` + + `not change what it does.`, + ).toEqual([]); + }); + } + + it("keys the strip's browser cache by language", () => { + // The server-side fix is invisible to anyone who switches language rather + // than reloads if the cached entry is shared across languages. + const german = queryKeys.insightsCoachRead("WEIGHT", "de"); + const english = queryKeys.insightsCoachRead("WEIGHT", "en"); + expect(german).not.toEqual(english); + expect(german).toContain("de"); + expect(english).toContain("en"); + }); +}); diff --git a/src/app/api/cycle/insights/__tests__/route-locale.test.ts b/src/app/api/cycle/insights/__tests__/route-locale.test.ts new file mode 100644 index 000000000..a55be82e6 --- /dev/null +++ b/src/app/api/cycle/insights/__tests__/route-locale.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * The cycle insights route has to say who is reading, too. + * + * Each surviving lagged row carries a finished `interpretation` sentence that + * this route serves straight to the client. The engine underneath can write it + * in any of the six languages, and it only writes the right one if the route + * resolves the reader's language and hands it over — the same handoff the + * metric page's "Coach read" strip was missing when it answered a German page + * in English. + * + * So what is pinned here is the handoff itself: the resolved locale arriving at + * `discoverPhaseCorrelations`, from the cookie a language switch sets and from + * the stored preference when there is no cookie. Without this the route could + * go back to naming one language outright and nothing in the suite would + * notice. + */ + +/** Stands in for the discovery engine so the ARGUMENTS are what is inspected. */ +const discoverPhaseCorrelations = vi.fn< + (args: { locale?: unknown }) => { + discovered: never[]; + pairsTested: number; + fdrQ: number; + minPairs: number; + } +>(() => ({ + discovered: [], + pairsTested: 0, + fdrQ: 0.1, + minPairs: 20, +})); +vi.mock("@/lib/cycle/phase-crosstab", async (importOriginal) => ({ + ...(await importOriginal()), + discoverPhaseCorrelations: (args: { locale?: unknown }) => + discoverPhaseCorrelations(args), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { create: vi.fn() }, + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + user: { + findUnique: vi.fn().mockResolvedValue({ sourcePriorityJson: null }), + }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cycle/gate", () => ({ + requireCycleEnabled: vi.fn(async () => ({ + enabled: true, + profile: { lutealPhaseLength: 14, averageCycleLength: 28 }, + })), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const localeCookie = vi.fn<() => string | undefined>(() => undefined); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: (name: string) => + name === "healthlog-locale" && localeCookie() !== undefined + ? { value: localeCookie() } + : undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; + +function sessionWithLocale(locale: string | null) { + return { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + gender: "FEMALE" as const, + timezone: "Europe/Berlin", + locale, + }, + }; +} + +beforeEach(() => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale(null) as unknown as Awaited< + ReturnType + >, + ); + discoverPhaseCorrelations.mockClear(); + localeCookie.mockReturnValue(undefined); +}); + +/** The locale the route handed the discovery engine on the last call. */ +function passedLocale(): unknown { + return discoverPhaseCorrelations.mock.calls.at(-1)?.[0]?.locale; +} + +describe("GET /api/cycle/insights — the reader's language", () => { + it("passes the language the switcher's cookie names", async () => { + localeCookie.mockReturnValue("de"); + + const res = await GET(); + + expect(res.status).toBe(200); + expect(passedLocale()).toBe("de"); + }); + + it("falls back to the stored preference when there is no cookie", async () => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale("pl") as unknown as Awaited< + ReturnType + >, + ); + + await GET(); + + expect(passedLocale()).toBe("pl"); + }); + + it("never hard-codes a language of its own", async () => { + // Two readers, two languages, from the same handler. A route that named + // one outright would pass this only by accident. + localeCookie.mockReturnValue("it"); + await GET(); + const first = passedLocale(); + localeCookie.mockReturnValue("fr"); + await GET(); + + expect([first, passedLocale()]).toEqual(["it", "fr"]); + }); +}); diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 3f16a02ed..7a9d5fdfc 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -20,6 +20,7 @@ import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { checkRateLimit } from "@/lib/rate-limit"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { predictCycle, type NightlyTempInput } from "@/lib/cycle"; import { LUTEAL_DEFAULT } from "@/lib/cycle/types"; import { @@ -208,10 +209,16 @@ export const GET = apiHandler(async () => { userPriorityJson, }); const headline = selectHeadlinePhaseRow(rows); + // The lagged rows are served with their narrated `interpretation` intact, so + // the reader's language is resolved here the same way the correlations route + // resolves it. This surface had the same silent-English gap the metric-page + // strip had. + const locale = await resolveServerLocale({ userLocale: user.locale ?? null }); const lagged = discoverPhaseCorrelations({ phaseByDay, measurements, userPriorityJson, + locale, }); // Cycle-NATIVE insight: where each logged symptom clusters across the phases. diff --git a/src/app/api/insights/coach-read/__tests__/route.test.ts b/src/app/api/insights/coach-read/__tests__/route.test.ts new file mode 100644 index 000000000..13d083137 --- /dev/null +++ b/src/app/api/insights/coach-read/__tests__/route.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * The route has to say who is reading. + * + * The strip's second line is a finished sentence written server-side. The + * builder can write it in any of the six languages; for months it wrote + * English on a German page because this route never passed a language and the + * engine underneath quietly defaulted. So what is pinned here is the handoff + * itself: the resolved locale reaching `buildCoachReadStrip`, from the cookie a + * language switch sets and from the stored preference when there is no cookie. + */ + +const buildCoachReadStrip = vi.fn(); +vi.mock("@/lib/insights/derived/coach-read", () => ({ + buildCoachReadStrip: (userId: string, metric: string, locale: string) => + buildCoachReadStrip(userId, metric, locale), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { create: vi.fn() }, + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); +vi.mock("@/lib/feature-flags", () => ({ + requireAssistantSurface: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const localeCookie = vi.fn<() => string | undefined>(() => undefined); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: (name: string) => + name === "healthlog-locale" && localeCookie() !== undefined + ? { value: localeCookie() } + : undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; + +function sessionWithLocale(locale: string | null) { + return { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale, + }, + }; +} + +const REQUEST = () => + new NextRequest("http://localhost/api/insights/coach-read?metric=WEIGHT"); + +beforeEach(() => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale(null) as unknown as Awaited< + ReturnType + >, + ); + vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ + allowed: true, + } as unknown as Awaited>); + buildCoachReadStrip.mockReset().mockResolvedValue({ + baseline: null, + learning: true, + driver: null, + }); + localeCookie.mockReturnValue(undefined); +}); + +describe("GET /api/insights/coach-read — the reader's language", () => { + it("passes the language the switcher's cookie names", async () => { + localeCookie.mockReturnValue("de"); + + const res = await GET(REQUEST()); + + expect(res.status).toBe(200); + expect(buildCoachReadStrip).toHaveBeenCalledWith("user-1", "WEIGHT", "de"); + }); + + it("falls back to the stored preference when there is no cookie", async () => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale("fr") as unknown as Awaited< + ReturnType + >, + ); + + await GET(REQUEST()); + + expect(buildCoachReadStrip).toHaveBeenCalledWith("user-1", "WEIGHT", "fr"); + }); + + it("never hard-codes a language of its own", async () => { + // Two readers, two languages, from the same handler. A route that pinned + // one would pass this only by accident. + localeCookie.mockReturnValue("es"); + await GET(REQUEST()); + localeCookie.mockReturnValue("de"); + await GET(REQUEST()); + + expect(buildCoachReadStrip.mock.calls.map((call) => call[2])).toEqual([ + "es", + "de", + ]); + }); +}); diff --git a/src/app/api/insights/coach-read/route.ts b/src/app/api/insights/coach-read/route.ts index 58db34f5c..443d1597b 100644 --- a/src/app/api/insights/coach-read/route.ts +++ b/src/app/api/insights/coach-read/route.ts @@ -23,6 +23,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; import type { MeasurementType } from "@/generated/prisma/client"; export const dynamic = "force-dynamic"; @@ -60,12 +61,20 @@ export const GET = apiHandler(async (request: NextRequest) => { } const metric = parsed.data.metric as MeasurementType; - const strip = await buildCoachReadStrip(user.id, metric); + // Line 2 of the strip is a finished sentence the clients print verbatim, so + // the reader's language is decided here — same resolution the correlations + // and analytics routes use (cookie, then the stored preference, then the + // Accept-Language header). Without it the strip answered a German page in + // English, one line under the other. + const locale = await resolveServerLocale({ userLocale: user.locale ?? null }); + + const strip = await buildCoachReadStrip(user.id, metric, locale); annotate({ action: { name: "insights.coach-read" }, meta: { metric, + locale, has_baseline: strip.baseline !== null, learning: strip.learning, has_driver: strip.driver !== null, diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 591971cfd..323a7ee19 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -204,8 +204,12 @@ export function useCycleProfile() { * insights tab. */ export function useCycleInsights() { + // The lagged rows come back with a narrated `interpretation` in the reader's + // language, so the cache cell belongs to one language — otherwise a switch + // would keep serving the sentence written for the previous one. + const { locale } = useTranslations(); return useQuery({ - queryKey: queryKeys.cycleInsights(), + queryKey: queryKeys.cycleInsights(locale), queryFn: () => apiGet("/api/cycle/insights"), staleTime: 5 * 60_000, }); diff --git a/src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx b/src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx new file mode 100644 index 000000000..d52646886 --- /dev/null +++ b/src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; + +/** + * A cached strip belongs to ONE language. + * + * The strip's second line is prose the server writes in the reader's language. + * The browser cache used to key it by metric alone, so after a language switch + * the page re-used the sentence written for the previous language and the + * server-side fix would have been invisible to anyone who switched rather than + * reloaded. `/api/analytics` carries the same warning in its own cache key. + * + * Both entries are seeded, so a key that has stopped separating them shows up + * as the WRONG sentence rather than as an empty page — a test that only proved + * "the other locale misses" would also pass if the key stopped matching + * anything at all. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { timezone: "UTC", dateOfBirth: null, gender: null }, + isAuthenticated: true, + }), +})); + +// The strip paints nothing until it is client-mounted (React #418). +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); + +import { CoachReadStrip } from "../coach-read-strip"; + +const GERMAN_NOTE = + "Wenn die Symptomstärke höher liegt, scheint die Schlafdauer am Folgetag " + + "nach bisheriger Datenlage eher niedriger auszufallen — ein Muster, das " + + "Beachtung verdient, keine Ursache."; +const ENGLISH_NOTE = + "Higher symptom severity looks like it goes with lower next-day sleep " + + "duration in your data, on the evidence so far — a pattern worth watching, " + + "not a cause."; + +function payload(note: string): CoachReadStripData { + return { + baseline: null, + learning: true, + driver: { note, behaviour: "symptom severity", outcome: "sleep duration" }, + }; +} + +const METRIC = "SLEEP_DURATION"; + +/** + * One client holding BOTH languages' entries — the state a person is in the + * instant after switching language on a page they had already loaded. + */ +function seededClient(): QueryClient { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData( + queryKeys.insightsCoachRead(METRIC, "de"), + payload(GERMAN_NOTE), + ); + client.setQueryData( + queryKeys.insightsCoachRead(METRIC, "en"), + payload(ENGLISH_NOTE), + ); + return client; +} + +function renderStrip(client: QueryClient, locale: "de" | "en"): string { + return renderToStaticMarkup( + + + + + , + ); +} + +describe("CoachReadStrip — a cached strip is not served to another language", () => { + it("reads the German entry for a German reader", () => { + const markup = renderStrip(seededClient(), "de"); + expect(markup).toContain(GERMAN_NOTE); + expect(markup).not.toContain(ENGLISH_NOTE); + }); + + it("reads the English entry for an English reader", () => { + const markup = renderStrip(seededClient(), "en"); + expect(markup).toContain(ENGLISH_NOTE); + expect(markup).not.toContain(GERMAN_NOTE); + }); + + it("does not carry one language's cached sentence into the other on a switch", () => { + // One client, both renders — exactly what a language switch does to a page + // that is already warm. + const client = seededClient(); + const german = renderStrip(client, "de"); + const english = renderStrip(client, "en"); + + expect(german).toContain(GERMAN_NOTE); + expect(english).toContain(ENGLISH_NOTE); + expect(english).not.toContain(GERMAN_NOTE); + }); +}); diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index 8dfad52fe..1558ecbbd 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -77,7 +77,7 @@ export function CoachReadStrip({ const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit; const { data } = useQuery({ - queryKey: queryKeys.insightsCoachRead(metricType), + queryKey: queryKeys.insightsCoachRead(metricType, locale), queryFn: () => apiGet( `/api/insights/coach-read?metric=${encodeURIComponent(metricType)}`, @@ -120,6 +120,10 @@ export function CoachReadStrip({ }); })(); + // Only the wrapper is translated here. `note` is a finished sentence the + // server wrote in the reader's language, so it goes in as it arrived — which + // is exactly why the route has to know the language, and why this query is + // keyed by locale. const driverLine = data.driver ? t("insights.coach.readStrip.driver", { note: data.driver.note }) : null; diff --git a/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts b/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts index dba6f3418..50aef2e7c 100644 --- a/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts @@ -45,14 +45,14 @@ beforeEach(() => { describe("readCoachCorrelations — insights module gate", () => { it("gates on `insights`, not on a per-domain key", async () => { - await readCoachCorrelations(USER); + await readCoachCorrelations(USER, "en"); expect(isModuleEnabled).toHaveBeenCalledWith(USER, "insights"); }); it("omits with the module OFF, and reads nothing at all", async () => { vi.mocked(isModuleEnabled).mockImplementation(async () => false); - const res = await readCoachCorrelations(USER); + const res = await readCoachCorrelations(USER, "en"); expect(res.present).toBe(false); // Distinct from `no_data`: the assistant is told the domain is switched @@ -67,7 +67,7 @@ describe("readCoachCorrelations — insights module gate", () => { }); it("proceeds to the engine with the module ON", async () => { - const res = await readCoachCorrelations(USER); + const res = await readCoachCorrelations(USER, "en"); // The synthetic fixture has no paired data, so the honest answer is a // miss — but it must be the ENGINE's miss, reached after the real reads // ran, not the gate's. @@ -85,7 +85,7 @@ describe("readCoachCorrelations — insights module gate", () => { async (_u: string, key: string) => key !== "insights", ); - const res = await readCoachCorrelations(USER); + const res = await readCoachCorrelations(USER, "en"); expect(res.present).toBe(false); expect(res.reason).toBe("module_disabled"); @@ -99,6 +99,8 @@ describe("readCoachCorrelations — insights module gate", () => { // loud, not silently indistinguishable from "no pattern found". vi.mocked(isModuleEnabled).mockRejectedValue(new Error("gate down")); - await expect(readCoachCorrelations(USER)).rejects.toThrow("gate down"); + await expect(readCoachCorrelations(USER, "en")).rejects.toThrow( + "gate down", + ); }); }); diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index 14b72f915..98f93b41f 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -91,7 +91,7 @@ describe("readCoachCorrelations", () => { fdrQ: 0.1, minPairs: 20, }); - const result = await readCoachCorrelations("u1"); + const result = await readCoachCorrelations("u1", "en"); expect(result.present).toBe(true); expect(result.drivers).toHaveLength(1); expect(result.drivers?.[0]).toMatchObject({ @@ -111,7 +111,7 @@ describe("readCoachCorrelations", () => { fdrQ: 0.1, minPairs: 20, }); - const result = await readCoachCorrelations("u1"); + const result = await readCoachCorrelations("u1", "en"); expect(result.present).toBe(false); expect(result.reason).toBe("no_significant_pattern"); }); @@ -149,7 +149,7 @@ describe("readCoachCorrelations", () => { computedAt: "x", }, }); - const result = await readCoachCorrelations("u1"); + const result = await readCoachCorrelations("u1", "en"); expect(result.present).toBe(true); expect(result.coincident?.fired).toBe(true); expect(result.coincident?.contributing).toEqual([ @@ -160,7 +160,7 @@ describe("readCoachCorrelations", () => { it("degrades to present:false on a read failure", async () => { loadBaselineProfile.mockRejectedValue(new Error("db down")); - const result = await readCoachCorrelations("u1"); + const result = await readCoachCorrelations("u1", "en"); expect(result.present).toBe(false); expect(result.reason).toBe("retrieval_failed"); }); @@ -175,7 +175,7 @@ describe("readCoachCorrelations", () => { fdrQ: 0.1, minPairs: 20, }); - await readCoachCorrelations("u1"); + await readCoachCorrelations("u1", "en"); const where = measurementFindMany.mock.calls[0]?.[0]?.where as { type: { in: string[] }; }; @@ -198,6 +198,7 @@ describe("readCoachCorrelations", () => { discoverCorrelations.mockImplementation((series) => real.discoverCorrelations( series as Parameters[0], + { locale: "en" }, ), ); @@ -248,7 +249,7 @@ describe("readCoachCorrelations", () => { ]); illnessDayLogFindMany.mockResolvedValue(dayLogs); - const result = await readCoachCorrelations("u1"); + const result = await readCoachCorrelations("u1", "en"); expect(result.present).toBe(true); const symptomDriver = result.drivers?.find( diff --git a/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts b/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts index ef0ae83f2..cbaa17b37 100644 --- a/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts @@ -47,7 +47,7 @@ describe("buildCorrelationsSnapshotBlock (D5-5)", () => { pairsTested: 16, windowDays: 180, }); - const block = await buildCorrelationsSnapshotBlock("user-1"); + const block = await buildCorrelationsSnapshotBlock("user-1", "en"); expect(block).not.toBeNull(); expect(block!.drivers).toHaveLength(2); expect(block!.drivers[0].behaviour).toBe("time in daylight"); @@ -65,7 +65,7 @@ describe("buildCorrelationsSnapshotBlock (D5-5)", () => { pairsTested: 30, windowDays: 180, }); - const block = await buildCorrelationsSnapshotBlock("user-1"); + const block = await buildCorrelationsSnapshotBlock("user-1", "en"); expect(block!.drivers).toHaveLength(SNAPSHOT_DRIVER_CAP); // Order preserved from the already-ranked tool output (highest effect first). expect(block!.drivers[0].behaviour).toBe("behaviour 0"); @@ -76,7 +76,7 @@ describe("buildCorrelationsSnapshotBlock (D5-5)", () => { present: false, reason: "no_significant_pattern", }); - expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull(); + expect(await buildCorrelationsSnapshotBlock("user-1", "en")).toBeNull(); }); it("attaches nothing when present but the driver list is empty (coincident-only)", async () => { @@ -89,6 +89,6 @@ describe("buildCorrelationsSnapshotBlock (D5-5)", () => { pairsTested: 12, windowDays: 180, }); - expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull(); + expect(await buildCorrelationsSnapshotBlock("user-1", "en")).toBeNull(); }); }); diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index 930c09401..ff751c4ab 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -56,7 +56,15 @@ function emptyAggregate() { const readCoachCorrelations = vi.fn(); vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ - readCoachCorrelations: (userId: string) => readCoachCorrelations(userId), + readCoachCorrelations: (userId: string, locale: string) => + readCoachCorrelations(userId, locale), +})); + +// The executor asks for the account's language before it asks for a narrated +// correlation, so the sentences it hands the model are not silently English. +const resolveLocaleForUser = vi.fn(async (_userId: string) => "de"); +vi.mock("@/lib/i18n/user-locale", () => ({ + resolveLocaleForUser: (userId: string) => resolveLocaleForUser(userId), })); // v1.21.0 (NEW-B B-2) — the illness-recovery tool augments its snapshot blocks @@ -357,6 +365,9 @@ describe("executeCoachTool", () => { drivers: [{ behaviour: "time in daylight", n: 42 }], pairsTested: 18, }); + // The account's language reached the reader — the notes are finished + // sentences, and an MCP client shows some of them as they stand. + expect(readCoachCorrelations).toHaveBeenCalledWith("u1", "de"); }); it("returns a clean { present: false } for get_correlations on no pattern", async () => { diff --git a/src/lib/ai/coach/correlations-snapshot.ts b/src/lib/ai/coach/correlations-snapshot.ts index 8f80545b7..489252c76 100644 --- a/src/lib/ai/coach/correlations-snapshot.ts +++ b/src/lib/ai/coach/correlations-snapshot.ts @@ -18,6 +18,7 @@ * Server-only — `readCoachCorrelations` reads `@/lib/db`. Fail-soft: a null * return (no surviving driver / read hiccup) attaches nothing. */ +import type { Locale } from "@/lib/i18n/config"; import { readCoachCorrelations, type CoachCorrelationDriver, @@ -45,8 +46,15 @@ export interface CorrelationsSnapshotBlock { */ export async function buildCorrelationsSnapshotBlock( userId: string, + /** + * The Coach's resolved UI locale. The driver notes go into the prompt as + * finished sentences, and a model handed English prose for a German + * conversation echoes English phrasings back. Required — the snapshot knows + * the locale, so it says it. + */ + locale: Locale, ): Promise { - const result = await readCoachCorrelations(userId); + const result = await readCoachCorrelations(userId, locale); if (!result.present || !result.drivers || result.drivers.length === 0) { return null; } diff --git a/src/lib/ai/coach/memory-snapshot.ts b/src/lib/ai/coach/memory-snapshot.ts index 4093b3e28..9c262a8aa 100644 --- a/src/lib/ai/coach/memory-snapshot.ts +++ b/src/lib/ai/coach/memory-snapshot.ts @@ -168,6 +168,7 @@ export async function buildCoachMemoryBlock( const ctx = await buildPeriodNarrativeContext(userId, { period: MEMORY_PERIOD, now, + locale, }); if (ctx.status === "ready") { for (const transition of ctx.bandTransitions) { diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 7d7ff7c0b..ca34a5df1 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -843,7 +843,7 @@ async function buildCoachSnapshotImpl( // instead of getting only the coincident flag. Gated on `derivedActive` (it // is the recovery / cross-metric layer) and fail-soft to null. const correlationsBlockPromise = derivedActive - ? buildCorrelationsSnapshotBlock(userId) + ? buildCorrelationsSnapshotBlock(userId, coachLocale) : null; const memoryBlockPromise = buildCoachMemoryBlock( userId, diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index 15b322a33..8bf7f8fd4 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -24,6 +24,7 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import type { Locale } from "@/lib/i18n/config"; import { annotate } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; import { wallClockInTz } from "@/lib/tz/wall-clock"; @@ -151,6 +152,15 @@ function humanise(key: string): string { */ export async function readCoachCorrelations( userId: string, + /** + * The reader's locale. Every `note` this returns is a finished sentence that + * some surface prints verbatim — the metric page's "Coach read" strip does + * exactly that — so the language has to be decided by whoever knows who is + * reading. No default: a caller that cannot name a locale is a caller that + * does not know, and guessing English is how the strip came to answer a + * German page in English. + */ + locale: Locale, ): Promise { // v1.30.22 — the `insights` gate lives HERE, at the read, not at each // caller. This reader is reached from four places (the Coach @@ -271,7 +281,7 @@ export async function readCoachCorrelations( }, }); - const discovery = discoverCorrelations(series); + const discovery = discoverCorrelations(series, { locale }); const drivers: CoachCorrelationDriver[] = discovery.discovered.map((d) => ({ behaviour: humanise(d.behaviour), outcome: humanise(d.outcome), @@ -292,6 +302,7 @@ export async function readCoachCorrelations( ); const emergingResult = discoverEmergingCorrelations(series, discovery, { recentFromDayKey, + locale, }); const emerging: CoachEmergingDriver[] = emergingResult.emerging.map( (d) => ({ @@ -307,7 +318,9 @@ export async function readCoachCorrelations( ); // v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws). - const labResult = discoverLabOutcomeCorrelations(labDraws, series); + const labResult = discoverLabOutcomeCorrelations(labDraws, series, { + locale, + }); const labDrivers: CoachLabCorrelation[] = labResult.discovered.map((d) => ({ lab: d.lab.startsWith("LAB:") ? d.lab.slice("LAB:".length) : d.lab, outcome: humanise(d.outcome), diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index 9ee361bcb..099c8e1cb 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -62,6 +62,7 @@ import { METRIC_SERIES_EXCLUDED_SOURCES, } from "./source-keys"; import { readCoachCorrelations } from "./correlations-read"; +import { resolveLocaleForUser } from "@/lib/i18n/user-locale"; import { resolveEmptyRead, subjectForTool, @@ -599,7 +600,12 @@ async function getCorrelations( // days, and the reader already reports WHY nothing survived (its own reason // code). "No pattern survived the correction" is an honest absence of a // finding, not an absence of data, and nothing older would change it. - const result = await readCoachCorrelations(userId); + // The reader's language, from their stored preference — the executor is + // reached from the chat loop and from MCP, neither of which carries a request + // of its own. The notes below are finished sentences; some MCP clients show + // them to the person verbatim rather than letting the model re-word them. + const locale = await resolveLocaleForUser(userId); + const result = await readCoachCorrelations(userId, locale); if (!result.present) { return { present: false, reason: result.reason ?? "no_pattern" }; } diff --git a/src/lib/cycle/__tests__/phase-crosstab.test.ts b/src/lib/cycle/__tests__/phase-crosstab.test.ts index 14ec830fc..ea8e79147 100644 --- a/src/lib/cycle/__tests__/phase-crosstab.test.ts +++ b/src/lib/cycle/__tests__/phase-crosstab.test.ts @@ -286,7 +286,11 @@ describe("discoverPhaseCorrelations", () => { ), ); } - const result = discoverPhaseCorrelations({ phaseByDay, measurements }); + const result = discoverPhaseCorrelations({ + phaseByDay, + measurements, + locale: "en", + }); const pair = result.discovered.find( (p) => p.behaviour === CYCLE_PHASE_CHANNEL_KEY, ); @@ -315,7 +319,11 @@ describe("discoverPhaseCorrelations", () => { phaseByDay.set(key, phases[i % phases.length]); measurements.push(m("RESTING_HEART_RATE", key, 60)); // constant → no variance } - const result = discoverPhaseCorrelations({ phaseByDay, measurements }); + const result = discoverPhaseCorrelations({ + phaseByDay, + measurements, + locale: "en", + }); expect(result.discovered).toEqual([]); }); }); diff --git a/src/lib/cycle/phase-crosstab.ts b/src/lib/cycle/phase-crosstab.ts index 30160944a..3971a22ca 100644 --- a/src/lib/cycle/phase-crosstab.ts +++ b/src/lib/cycle/phase-crosstab.ts @@ -51,6 +51,7 @@ import { import { round } from "@/lib/insights/status-shared"; import type { MeasurementType } from "@/generated/prisma/enums"; import type { CyclePhase } from "@/lib/cycle/types"; +import type { Locale } from "@/lib/i18n/config"; /** Stable channel key for the continuous CYCLE_PHASE discovery series. */ export const CYCLE_PHASE_CHANNEL_KEY = "CYCLE_PHASE"; @@ -292,8 +293,15 @@ export function discoverPhaseCorrelations(args: { phaseByDay: Map; measurements: CrossMetricMeasurement[]; userPriorityJson?: unknown; + /** + * The reader's locale. Each surviving row carries a finished `interpretation` + * sentence that the cycle route serves straight to the client, so it has to + * be written in the reader's language. Required — no default, because a + * default is what let the metric page's driver line ship in English. + */ + locale: Locale; }): CorrelationDiscoveryResult { - const { phaseByDay, measurements } = args; + const { phaseByDay, measurements, locale } = args; const userPriorityJson = args.userPriorityJson ?? null; const phaseSeries = buildPhaseDiscoverySeries(phaseByDay); @@ -307,7 +315,7 @@ export function discoverPhaseCorrelations(args: { outcomeSeries.push({ key: cfg.type, role: "outcome", points }); } - return discoverCorrelations([phaseSeries, ...outcomeSeries]); + return discoverCorrelations([phaseSeries, ...outcomeSeries], { locale }); } /** diff --git a/src/lib/i18n/user-locale.ts b/src/lib/i18n/user-locale.ts new file mode 100644 index 000000000..85b9615c8 --- /dev/null +++ b/src/lib/i18n/user-locale.ts @@ -0,0 +1,26 @@ +/** + * The reader's locale for a server-internal caller that only has a user id. + * + * Route handlers resolve the locale from the request (cookie first, then the + * stored preference, then Accept-Language) via `resolveServerLocale`. A few + * server-internal fan-outs reach a prose-writing engine without a request of + * their own — the Coach tool executor and the MCP rich reads both take a user + * id and nothing else. This looks their stored preference up and hands it to + * the SAME resolver, so there is one place that decides what language a + * sentence is written in, not two. + * + * Deliberately its own module rather than a second export on `server-locale`: + * that file is imported by page components, and it must not start pulling + * Prisma along with it. + */ +import { prisma } from "@/lib/db"; +import type { Locale } from "./config"; +import { resolveServerLocale } from "./server-locale"; + +export async function resolveLocaleForUser(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + return resolveServerLocale({ userLocale: row?.locale ?? null }); +} diff --git a/src/lib/insights/__tests__/correlation-discovery.test.ts b/src/lib/insights/__tests__/correlation-discovery.test.ts index 0e68d24bc..8b09fdc4c 100644 --- a/src/lib/insights/__tests__/correlation-discovery.test.ts +++ b/src/lib/insights/__tests__/correlation-discovery.test.ts @@ -56,7 +56,9 @@ describe("discoverCorrelations", () => { points: series([400, 410, 420]), }, ]; - const result = discoverCorrelations([...behaviours, ...outcomes]); + const result = discoverCorrelations([...behaviours, ...outcomes], { + locale: "en", + }); expect(result.discovered).toHaveLength(0); expect(result.pairsTested).toBe(0); }); @@ -79,7 +81,7 @@ describe("discoverCorrelations", () => { }, { key: "SLEEP_DURATION", role: "outcome", points: series(outcomeVals) }, ], - { fdrQ: 0.1 }, + { fdrQ: 0.1, locale: "en" }, ); expect(result.pairsTested).toBe(1); expect(result.discovered).toHaveLength(1); @@ -100,10 +102,13 @@ describe("discoverCorrelations", () => { { length: 30 }, (_, i) => Math.cos(i * 1.7) * 10 + 50, ); - const result = discoverCorrelations([ - { key: "BLOOD_GLUCOSE", role: "behaviour", points: series(noiseA) }, - { key: "WEIGHT", role: "outcome", points: series(noiseB) }, - ]); + const result = discoverCorrelations( + [ + { key: "BLOOD_GLUCOSE", role: "behaviour", points: series(noiseA) }, + { key: "WEIGHT", role: "outcome", points: series(noiseB) }, + ], + { locale: "en" }, + ); // It is tested (n ≥ 20) but should not survive p < 0.05 + FDR. expect(result.pairsTested).toBe(1); expect(result.discovered).toHaveLength(0); @@ -122,7 +127,7 @@ describe("discoverCorrelations", () => { { key: "FACTOR:work", role: "behaviour", points: series(factorVals) }, { key: "SLEEP_DURATION", role: "outcome", points: series(sleepVals) }, ], - { fdrQ: 0.1 }, + { fdrQ: 0.1, locale: "en" }, ); expect(result.discovered).toHaveLength(1); const pair = result.discovered[0]; @@ -153,10 +158,17 @@ describe("MOOD as outcome (F3)", () => { const driver = i === 0 ? 60 : 60 + ((i - 1) % 10) * 5; return 1 + (driver - 60) / 45 + (i % 2 === 0 ? 0.02 : -0.02); }); - const result = discoverCorrelations([ - { key: "TIME_IN_DAYLIGHT", role: "behaviour", points: series(daylight) }, - { key: "MOOD", role: "outcome", points: series(mood) }, - ]); + const result = discoverCorrelations( + [ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: series(daylight), + }, + { key: "MOOD", role: "outcome", points: series(mood) }, + ], + { locale: "en" }, + ); const pair = result.discovered.find( (p) => p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "MOOD", ); @@ -167,10 +179,13 @@ describe("MOOD as outcome (F3)", () => { it("never tests the MOOD → MOOD self-pair", () => { const n = 30; const mood = Array.from({ length: n }, (_, i) => 3 + (i % 3)); - const result = discoverCorrelations([ - { key: "MOOD", role: "behaviour", points: series(mood) }, - { key: "MOOD", role: "outcome", points: series(mood) }, - ]); + const result = discoverCorrelations( + [ + { key: "MOOD", role: "behaviour", points: series(mood) }, + { key: "MOOD", role: "outcome", points: series(mood) }, + ], + { locale: "en" }, + ); expect( result.discovered.find( (p) => p.behaviour === "MOOD" && p.outcome === "MOOD", @@ -222,10 +237,13 @@ describe("D2-1 — same-family lagged pairs are excluded from discovery", () => const n = 40; const factor = Array.from({ length: n }, (_, i) => 1 + (i % 5)); const mood = [3, ...factor.slice(0, n - 1).map((v) => v)]; - const result = discoverCorrelations([ - { key: "FACTOR:work", role: "behaviour", points: longSeries(factor) }, - { key: "MOOD", role: "outcome", points: longSeries(mood) }, - ]); + const result = discoverCorrelations( + [ + { key: "FACTOR:work", role: "behaviour", points: longSeries(factor) }, + { key: "MOOD", role: "outcome", points: longSeries(mood) }, + ], + { locale: "en" }, + ); expect(result.pairsTested).toBe(0); expect(result.discovered).toHaveLength(0); }); @@ -283,14 +301,17 @@ describe("D2-2 — effect-size floor on discovered drivers", () => { .slice(0, n - 1) .map((v, i) => v * 0.12 + Math.cos(i * 1.3) * 40 + 100), ]; - const result = discoverCorrelations([ - { - key: "TIME_IN_DAYLIGHT", - role: "behaviour", - points: longSeries(behaviour), - }, - { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, - ]); + const result = discoverCorrelations( + [ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: longSeries(behaviour), + }, + { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, + ], + { locale: "en" }, + ); // Either it failed significance, or it was floored out — either way no // confident driver row reaches the Coach. const surfaced = result.discovered.find( @@ -309,14 +330,17 @@ describe("D2-2 — effect-size floor on discovered drivers", () => { const n = 90; const behaviour = Array.from({ length: n }, (_, i) => i + (i % 4)); const outcome = [0, ...behaviour.slice(0, n - 1).map((v) => v * 2 + 5)]; - const result = discoverCorrelations([ - { - key: "TIME_IN_DAYLIGHT", - role: "behaviour", - points: longSeries(behaviour), - }, - { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, - ]); + const result = discoverCorrelations( + [ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: longSeries(behaviour), + }, + { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, + ], + { locale: "en" }, + ); const pair = result.discovered.find( (p) => p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION", @@ -343,14 +367,17 @@ describe("D2-2 — effect-size floor on discovered drivers", () => { .slice(0, n - 1) .map((v, i) => v * 0.35 + Math.cos(i * 2.1) * 9 + 100), ]; - const result = discoverCorrelations([ - { - key: "TIME_IN_DAYLIGHT", - role: "behaviour", - points: longSeries(behaviour), - }, - { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, - ]); + const result = discoverCorrelations( + [ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: longSeries(behaviour), + }, + { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, + ], + { locale: "en" }, + ); const pair = result.discovered.find( (p) => p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION", @@ -462,18 +489,21 @@ describe("compliance + symptom channels (FDREXTEND)", () => { // pair is not a perfect line (a real-world-shaped strong link). .map((a, i) => ((100 - a) / 20) * (i % 2 === 0 ? 1.0 : 0.98)), ]; - const result = discoverCorrelations([ - { - key: MEDICATION_COMPLIANCE_CHANNEL_KEY, - role: "behaviour", - points: longSeries(adherence), - }, - { - key: SYMPTOM_SEVERITY_CHANNEL_KEY, - role: "outcome", - points: longSeries(symptom), - }, - ]); + const result = discoverCorrelations( + [ + { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points: longSeries(adherence), + }, + { + key: SYMPTOM_SEVERITY_CHANNEL_KEY, + role: "outcome", + points: longSeries(symptom), + }, + ], + { locale: "en" }, + ); const pair = result.discovered.find( (p) => p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY && @@ -500,18 +530,21 @@ describe("compliance + symptom channels (FDREXTEND)", () => { 0, ...adherence.slice(0, n - 1).map((a) => (100 - a) / 30), ]; - const result = discoverCorrelations([ - { - key: MEDICATION_COMPLIANCE_CHANNEL_KEY, - role: "behaviour", - points: longSeries(adherence), - }, - { - key: SYMPTOM_SEVERITY_CHANNEL_KEY, - role: "outcome", - points: longSeries(symptom), - }, - ]); + const result = discoverCorrelations( + [ + { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points: longSeries(adherence), + }, + { + key: SYMPTOM_SEVERITY_CHANNEL_KEY, + role: "outcome", + points: longSeries(symptom), + }, + ], + { locale: "en" }, + ); // Below the floor the pair is never even tested. expect(result.pairsTested).toBe(0); expect(result.discovered).toHaveLength(0); @@ -520,18 +553,21 @@ describe("compliance + symptom channels (FDREXTEND)", () => { it("never tests the symptom → symptom self-lag", () => { const n = 40; const symptom = Array.from({ length: n }, (_, i) => (i % 4) / 2); - const result = discoverCorrelations([ - { - key: SYMPTOM_SEVERITY_CHANNEL_KEY, - role: "behaviour", - points: longSeries(symptom), - }, - { - key: SYMPTOM_SEVERITY_CHANNEL_KEY, - role: "outcome", - points: longSeries(symptom), - }, - ]); + const result = discoverCorrelations( + [ + { + key: SYMPTOM_SEVERITY_CHANNEL_KEY, + role: "behaviour", + points: longSeries(symptom), + }, + { + key: SYMPTOM_SEVERITY_CHANNEL_KEY, + role: "outcome", + points: longSeries(symptom), + }, + ], + { locale: "en" }, + ); expect( result.discovered.find( (p) => @@ -555,18 +591,21 @@ describe("compliance + symptom channels (FDREXTEND)", () => { .slice(0, n - 1) .map((a, i) => 55 + (100 - a) / 6 + (i % 2 === 0 ? 0.1 : -0.1)), ]; - const result = discoverCorrelations([ - { - key: MEDICATION_COMPLIANCE_CHANNEL_KEY, - role: "behaviour", - points: longSeries(adherence), - }, - { - key: "RESTING_HEART_RATE", - role: "outcome", - points: longSeries(rhr), - }, - ]); + const result = discoverCorrelations( + [ + { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points: longSeries(adherence), + }, + { + key: "RESTING_HEART_RATE", + role: "outcome", + points: longSeries(rhr), + }, + ], + { locale: "en" }, + ); const pair = result.discovered.find( (p) => p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY && @@ -621,17 +660,24 @@ describe("v1.22 — new curated linkages", () => { .slice(0, n - 1) .map((a, i) => 160 - a / 4 + (i % 2 ? 0.2 : -0.2)), ]; - const result = discoverCorrelations([ - { - key: MEDICATION_COMPLIANCE_CHANNEL_KEY, - role: "behaviour", - points: longSeries(adherence), - }, - // BP is both a behaviour and an outcome in the real matrix; the - // same-family guard must keep BP_SYS(behaviour)→BP_SYS(outcome) out. - { key: "BLOOD_PRESSURE_SYS", role: "behaviour", points: longSeries(sys) }, - { key: "BLOOD_PRESSURE_SYS", role: "outcome", points: longSeries(sys) }, - ]); + const result = discoverCorrelations( + [ + { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points: longSeries(adherence), + }, + // BP is both a behaviour and an outcome in the real matrix; the + // same-family guard must keep BP_SYS(behaviour)→BP_SYS(outcome) out. + { + key: "BLOOD_PRESSURE_SYS", + role: "behaviour", + points: longSeries(sys), + }, + { key: "BLOOD_PRESSURE_SYS", role: "outcome", points: longSeries(sys) }, + ], + { locale: "en" }, + ); expect( result.discovered.find( (p) => @@ -681,12 +727,13 @@ describe("discoverEmergingCorrelations (early detection)", () => { points: longSeries(out), }, ]; - const retrospective = discoverCorrelations(series); + const retrospective = discoverCorrelations(series, { locale: "en" }); expect(retrospective.discovered).toHaveLength(0); // n too low for the floor const cutoff = longSeries(beh)[0].day; // whole (short) window const { emerging } = discoverEmergingCorrelations(series, retrospective, { recentFromDayKey: cutoff, + locale: "en", minPairs: 10, }); const pair = emerging.find( @@ -711,7 +758,7 @@ describe("discoverEmergingCorrelations (early detection)", () => { points: longSeries(out), }, ]; - const retrospective = discoverCorrelations(series); + const retrospective = discoverCorrelations(series, { locale: "en" }); expect( retrospective.discovered.some( (p) => p.behaviour === "MOOD" && p.outcome === "HEART_RATE_VARIABILITY", @@ -724,6 +771,7 @@ describe("discoverEmergingCorrelations (early detection)", () => { .slice(0, 10); const { emerging } = discoverEmergingCorrelations(series, retrospective, { recentFromDayKey: cutoff, + locale: "en", minPairs: 10, }); expect( @@ -745,10 +793,11 @@ describe("discoverEmergingCorrelations (early detection)", () => { points: longSeries(out), }, ]; - const retrospective = discoverCorrelations(series); + const retrospective = discoverCorrelations(series, { locale: "en" }); const cutoff = longSeries(beh)[Math.max(0, n - EARLY_WINDOW_DAYS)].day; const { emerging } = discoverEmergingCorrelations(series, retrospective, { recentFromDayKey: cutoff, + locale: "en", minPairs: 10, }); expect(emerging).toHaveLength(0); @@ -778,6 +827,7 @@ describe("discoverLabOutcomeCorrelations (labs ↔ outcomes)", () => { draws.push({ key: "LAB:HbA1c", day, value: v }); } const result = discoverLabOutcomeCorrelations(draws, [weight], { + locale: "en", minDraws: 5, minWindowPoints: 5, }); @@ -799,6 +849,7 @@ describe("discoverLabOutcomeCorrelations (labs ↔ outcomes)", () => { value: 5 + i, })); const result = discoverLabOutcomeCorrelations(draws, [weight], { + locale: "en", minDraws: 5, }); expect(result.discovered).toHaveLength(0); @@ -820,6 +871,7 @@ describe("discoverLabOutcomeCorrelations (labs ↔ outcomes)", () => { value: 5 + i, })); const result = discoverLabOutcomeCorrelations(draws, [hrv], { + locale: "en", minDraws: 5, }); // HRV is not a curated lab-outcome target → no pair tested. diff --git a/src/lib/insights/__tests__/correlation-series-builders.test.ts b/src/lib/insights/__tests__/correlation-series-builders.test.ts index 5c3b9a78c..1bd103d9e 100644 --- a/src/lib/insights/__tests__/correlation-series-builders.test.ts +++ b/src/lib/insights/__tests__/correlation-series-builders.test.ts @@ -185,7 +185,9 @@ describe("integration — built series flow through the discovery gates", () => role: "outcome", }); - const result = discoverCorrelations([compliance, symptom]); + const result = discoverCorrelations([compliance, symptom], { + locale: "en", + }); expect(result.pairsTested).toBe(0); expect(result.discovered).toHaveLength(0); }); @@ -222,7 +224,9 @@ describe("integration — built series flow through the discovery gates", () => role: "outcome", }); - const result = discoverCorrelations([compliance, symptom]); + const result = discoverCorrelations([compliance, symptom], { + locale: "en", + }); const pair = result.discovered.find( (p) => p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY && @@ -256,7 +260,7 @@ describe("integration — built series flow through the discovery gates", () => value: 55 + (i > 0 && (i - 1) % 4 === 0 ? 8 : 0) + (i % 2) * 0.1, })), }); - const result = discoverCorrelations(series); + const result = discoverCorrelations(series, { locale: "en" }); for (const p of result.discovered) { expect(p.interpretation).toMatch(/not a cause|never a cause/); } diff --git a/src/lib/insights/__tests__/metric-correlation-context.test.ts b/src/lib/insights/__tests__/metric-correlation-context.test.ts index cf8cd9c8f..703af6931 100644 --- a/src/lib/insights/__tests__/metric-correlation-context.test.ts +++ b/src/lib/insights/__tests__/metric-correlation-context.test.ts @@ -42,7 +42,7 @@ beforeEach(() => { describe("getRelevantCorrelationsForMetric", () => { it("returns [] without any DB read for a non-discovery metric", async () => { // VO2_MAX is not part of the curated discovery matrix. - const out = await getRelevantCorrelationsForMetric("u-1", "VO2_MAX"); + const out = await getRelevantCorrelationsForMetric("u-1", "VO2_MAX", "en"); expect(out).toEqual([]); expect(prisma.measurement.findMany).not.toHaveBeenCalled(); }); @@ -70,6 +70,7 @@ describe("getRelevantCorrelationsForMetric", () => { const out = await getRelevantCorrelationsForMetric( "u-1", "RESTING_HEART_RATE", + "en", ); expect(out.length).toBeGreaterThan(0); // Every surfaced relation mentions resting heart rate (the metric's channel). @@ -87,6 +88,7 @@ describe("getRelevantCorrelationsForMetric", () => { const out = await getRelevantCorrelationsForMetric( "u-1", "RESTING_HEART_RATE", + "en", ); expect(out).toEqual([]); }); diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 122f7478a..d930ac00f 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -411,6 +411,7 @@ describe("generateMetricStatus — generation path", () => { expect(getRelevantCorrelationsForMetric).toHaveBeenCalledWith( "user-1", "RESTING_HEART_RATE", + "en", ); }); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index eddd1425d..0efd5f868 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -678,6 +678,7 @@ export async function prepareBloodPressureStatusForUser( const relations = await getRelevantCorrelationsForMetric( userId, "BLOOD_PRESSURE_SYS", + locale, ); const assessmentContextBlock = buildAssessmentContextBlock( { diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index 05ddbbd5d..49852dd93 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -39,7 +39,7 @@ * link. The series builders live in `correlation-series-builders.ts`. */ import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations"; -import { defaultLocale, type Locale } from "@/lib/i18n/config"; +import { type Locale } from "@/lib/i18n/config"; import { getServerTranslator } from "@/lib/i18n/server-translator"; /** @@ -447,21 +447,25 @@ export function discoverCorrelations( minPairs?: number; fdrQ?: number; /** - * Reader's locale for the narrated `interpretation`. Defaults to English - * so the many server callers that feed the string into an LLM prompt (which - * re-localises) keep their existing behaviour; the user-facing correlations - * route passes the resolved locale so the card renders in the reader's - * language. + * Reader's locale for the narrated `interpretation`. + * + * REQUIRED, and it must stay required. It used to be optional with an + * English default, and the default is what shipped the bug: a caller could + * say nothing and still get a finished sentence back, so the "Coach read" + * strip on a German metric page printed its second line in English under a + * German first line for months. Nobody had to write anything wrong for that + * to happen — silence was enough. With no default, a caller that forgets + * cannot compile, which is the only version of this that stays fixed. */ - locale?: Locale; - } = {}, + locale: Locale; + }, ): CorrelationDiscoveryResult { const lagDays = opts.lagDays ?? 1; const minPairs = opts.minPairs ?? MIN_PAIRED_N; const fdrQ = opts.fdrQ ?? FDR_Q; // Named `translate` (not `t`) — the pair-mapping callbacks below bind `t` to // the RawPair element, so the translator must not shadow-collide with them. - const { t: translate } = getServerTranslator(opts.locale ?? defaultLocale); + const { t: translate } = getServerTranslator(opts.locale); const behaviours = series.filter((s) => s.role === "behaviour"); const outcomes = series.filter((s) => s.role === "outcome"); @@ -717,8 +721,12 @@ export function discoverEmergingCorrelations( lagDays?: number; minPairs?: number; fdrQ?: number; - /** Reader's locale, threaded to the inner scan's narration. */ - locale?: Locale; + /** + * Reader's locale, threaded to the inner scan's narration. Required for the + * same reason it is required on {@link discoverCorrelations} — an optional + * locale here would put the English default straight back, one call deeper. + */ + locale: Locale; }, ): EmergingCorrelationResult { const windowDays = opts.windowDays ?? EARLY_WINDOW_DAYS; @@ -880,16 +888,20 @@ export function discoverLabOutcomeCorrelations( minDraws?: number; minWindowPoints?: number; fdrQ?: number; - /** Reader's locale for the narrated `interpretation`. Defaults to English. */ - locale?: Locale; - } = {}, + /** + * Reader's locale for the narrated `interpretation`. Required — see + * {@link discoverCorrelations}; a lab sentence leaks English just as + * happily as a daily one. + */ + locale: Locale; + }, ): LabCorrelationResult { const windowDays = opts.windowDays ?? LAB_OUTCOME_WINDOW_DAYS; const minDraws = opts.minDraws ?? LAB_MIN_DRAWS; const minWindowPoints = opts.minWindowPoints ?? LAB_MIN_WINDOW_POINTS; const fdrQ = opts.fdrQ ?? FDR_Q; // Named `translate` — the pair-mapping callback below binds `t` to its element. - const { t: translate } = getServerTranslator(opts.locale ?? defaultLocale); + const { t: translate } = getServerTranslator(opts.locale); // Group draws by biomarker key (newest-irrelevant; Pearson is order-free). const drawsByLab = new Map(); diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts new file mode 100644 index 000000000..504d628af --- /dev/null +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -0,0 +1,173 @@ +/** + * The "Coach read" strip's second line has to arrive in the reader's language. + * + * On a German metric page the strip used to print its own summary in German + * and the pattern line under it in English, one under the other. Nothing was + * mistranslated: the pattern line is a finished sentence the server writes, + * the route never said who was reading, and the engine's optional locale + * quietly answered in English. + * + * So this drives the REAL pipe — `buildCoachReadStrip` → `readCoachCorrelations` + * → `discoverCorrelations` — with only the database reads faked, and asserts the + * German sentence the German templates produce. Testing the engine alone would + * have stayed green through the whole outage: the engine could always speak + * German, it was simply never asked. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const measurementFindMany = vi.fn(); +const measurementFindFirst = vi.fn(); +const moodFindMany = vi.fn(); +const userFindUnique = vi.fn(); +const medicationFindMany = vi.fn(); +const intakeEventFindMany = vi.fn(); +const illnessEpisodeFindMany = vi.fn(); +const illnessDayLogFindMany = vi.fn(); +const labResultFindMany = vi.fn(); + +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: (a: unknown) => measurementFindMany(a), + findFirst: (a: unknown) => measurementFindFirst(a), + }, + moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, + user: { findUnique: (a: unknown) => userFindUnique(a) }, + medication: { findMany: (a: unknown) => medicationFindMany(a) }, + medicationIntakeEvent: { findMany: (a: unknown) => intakeEventFindMany(a) }, + illnessEpisode: { findMany: (a: unknown) => illnessEpisodeFindMany(a) }, + illnessDayLog: { findMany: (a: unknown) => illnessDayLogFindMany(a) }, + labResult: { findMany: (a: unknown) => labResultFindMany(a) }, + }, +})); + +// The band engine is `baseline.test.ts`'s subject; the strip's FIRST line is +// not what is under test here. Only the second line is, so the band degrades to +// "still learning" and the driver line stands alone. +const PROFILE = { ageYears: 40, sex: "MALE", heightCm: 180 }; +vi.mock("@/lib/insights/derived", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadBaselineProfile: async () => PROFILE, + computeCoincidentDeviation: async () => null, + }; +}); +vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadBaselineProfile: async () => PROFILE, + computeVitalsBaseline: async () => ({ + status: "insufficient" as const, + reason: "not under test", + }), + }; +}); + +import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; + +const DAYS = 45; +const BASE = Date.UTC(2026, 4, 1, 12, 0, 0); // midday → a tz-stable day key + +function dayKey(offset: number): string { + const d = new Date(BASE + offset * 86_400_000); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; +} + +/** + * 45 days where today's symptom burden runs against tomorrow's sleep, strongly + * enough to clear n ≥ 20, p < 0.05, the FDR control and the effect-size floor. + * Same fixture shape the correlations reader's own real-engine test uses. + */ +function seedSymptomDrivesSleep() { + const dayLogs: Array<{ + date: string; + functionalImpact: number; + symptomLinks: Array<{ severity: number }>; + }> = []; + const measurements: Array<{ type: string; value: number; measuredAt: Date }> = + []; + for (let i = 0; i < DAYS; i++) { + const burden = i % 4; + dayLogs.push({ + date: dayKey(i), + functionalImpact: burden, + symptomLinks: [], + }); + measurements.push({ + type: "SLEEP_DURATION", + value: 8 - burden, + measuredAt: new Date(BASE + (i + 1) * 86_400_000), + }); + } + measurementFindMany.mockResolvedValue(measurements); + illnessEpisodeFindMany.mockResolvedValue([ + { + id: "ep1", + onsetAt: new Date(BASE), + resolvedAt: new Date(BASE + DAYS * 86_400_000), + }, + ]); + illnessDayLogFindMany.mockResolvedValue(dayLogs); +} + +describe("buildCoachReadStrip — the pattern line speaks the reader's language", () => { + beforeEach(() => { + measurementFindMany.mockReset().mockResolvedValue([]); + measurementFindFirst.mockReset().mockResolvedValue({ value: 7 }); + moodFindMany.mockReset().mockResolvedValue([]); + userFindUnique.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" }); + medicationFindMany.mockReset().mockResolvedValue([]); + intakeEventFindMany.mockReset().mockResolvedValue([]); + illnessEpisodeFindMany.mockReset().mockResolvedValue([]); + illnessDayLogFindMany.mockReset().mockResolvedValue([]); + labResultFindMany.mockReset().mockResolvedValue([]); + seedSymptomDrivesSleep(); + }); + + it("writes the driver note in German for a German reader", async () => { + const strip = await buildCoachReadStrip("u1", "SLEEP_DURATION", "de"); + + expect(strip.driver).not.toBeNull(); + const note = strip.driver!.note; + + // The exact German sentence, not merely "different from the English one". + // A sentence that is different but wrong is still a broken page. + expect(note).toBe( + "Wenn die Symptomstärke höher liegt, scheint die Schlafdauer am " + + "Folgetag nach bisheriger Datenlage eher niedriger auszufallen — ein " + + "Muster, das Beachtung verdient, keine Ursache.", + ); + }); + + it("writes the same driver note in English for an English reader", async () => { + const strip = await buildCoachReadStrip("u1", "SLEEP_DURATION", "en"); + + expect(strip.driver).not.toBeNull(); + expect(strip.driver!.note).toBe( + "Higher symptom severity looks like it goes with lower next-day sleep " + + "duration in your data, on the evidence so far — a pattern worth " + + "watching, not a cause.", + ); + }); + + it("keeps the driver's matching keys language-independent", async () => { + // Only the sentence is localised. `outcome` is how the strip finds the + // driver that belongs to THIS metric, so it has to stay the same string in + // every language or a German page would find no driver at all. + const de = await buildCoachReadStrip("u1", "SLEEP_DURATION", "de"); + const en = await buildCoachReadStrip("u1", "SLEEP_DURATION", "en"); + + expect(de.driver!.outcome).toBe("sleep duration"); + expect(en.driver!.outcome).toBe("sleep duration"); + expect(de.driver!.behaviour).toBe(en.driver!.behaviour); + expect(de.driver!.note).not.toBe(en.driver!.note); + }); +}); diff --git a/src/lib/insights/derived/coach-read.ts b/src/lib/insights/derived/coach-read.ts index c18300f82..346df7bae 100644 --- a/src/lib/insights/derived/coach-read.ts +++ b/src/lib/insights/derived/coach-read.ts @@ -23,6 +23,7 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import type { Locale } from "@/lib/i18n/config"; import { loadBaselineProfile, computeVitalsBaseline, @@ -71,6 +72,15 @@ async function readLatestValue( export async function buildCoachReadStrip( userId: string, type: MeasurementType, + /** + * The reader's locale. Line 2 arrives as a finished sentence and is printed + * verbatim by both clients, so it has to be written in the reader's language + * HERE — the strip component only translates the wrapper around it. Required + * on purpose: while this parameter did not exist, the route said nothing and + * the sentence came back in English, which is how a German weight page ended + * up with a German first line and an English second one. + */ + locale: Locale, ): Promise { const profile = await loadBaselineProfile(prisma, userId); @@ -78,7 +88,9 @@ export async function buildCoachReadStrip( computeVitalsBaseline(userId, profile, { type }), readLatestValue(userId, type), // Line 2 is best-effort: a correlation failure must never sink the band. - readCoachCorrelations(userId).catch(() => ({ present: false }) as const), + readCoachCorrelations(userId, locale).catch( + () => ({ present: false }) as const, + ), ]); let baseline: CoachReadBaseline | null = null; diff --git a/src/lib/insights/metric-correlation-context.ts b/src/lib/insights/metric-correlation-context.ts index b5b269a27..10cea2f33 100644 --- a/src/lib/insights/metric-correlation-context.ts +++ b/src/lib/insights/metric-correlation-context.ts @@ -22,6 +22,7 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import type { Locale } from "@/lib/i18n/config"; import { discoverCorrelations, discoveryMeasurementTypes, @@ -82,6 +83,13 @@ function channelKeyForType(type: MeasurementType): string | null { export async function getRelevantCorrelationsForMetric( userId: string, measurementType: MeasurementType, + /** + * The reader's locale. These interpretations are finished sentences that go + * into the assessment prompt as grounded context; handing a German card's + * prompt English prose invites the model to echo it. Required — every caller + * is a status builder that already knows the reader's language. + */ + locale: Locale, ): Promise { const channel = channelKeyForType(measurementType); if (!channel) return []; @@ -162,7 +170,7 @@ export async function getRelevantCorrelationsForMetric( } series.push(...environmentSeries, ...customMetricSeries); - const result = discoverCorrelations(series); + const result = discoverCorrelations(series, { locale }); const decisions = await syncAcceptedPatterns({ userId, family: PATTERN_FAMILIES.discoveryRetrospective, diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index e1e085585..d9defd0d2 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -454,6 +454,7 @@ export async function generateMetricStatus(args: { const relations = await getRelevantCorrelationsForMetric( args.userId, meta.measurementType, + locale, ); const contextBlock = buildAssessmentContextBlock( { diff --git a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts index ccc68d8e5..2415fde27 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts @@ -103,6 +103,7 @@ describe("buildPeriodNarrativeContext — grain (QA F3)", () => { const result = await buildPeriodNarrativeContext("u1", { period: "week", now, + locale: "en", }); expect(result.status).toBe("ready"); @@ -122,7 +123,11 @@ describe("buildPeriodNarrativeContext — grain (QA F3)", () => { const now = new Date("2026-06-30T12:00:00.000Z"); measurementFindMany.mockResolvedValue([]); - await buildPeriodNarrativeContext("u1", { period: "week", now }); + await buildPeriodNarrativeContext("u1", { + period: "week", + now, + locale: "en", + }); expect(measurementFindMany).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/lib/insights/narrative/__tests__/period-narrative.test.ts b/src/lib/insights/narrative/__tests__/period-narrative.test.ts index 2d2f6e483..292aa268c 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative.test.ts @@ -40,6 +40,7 @@ function monthInput( }, seriesByMetric, discoverySeries, + locale: "en", computedAt: "2026-04-30T12:00:00.000Z", }; } diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index bbcde109c..adacbf36f 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -329,7 +329,7 @@ export async function generatePeriodNarrative( } } - const context = await buildContext(userId, { period, now }); + const context = await buildContext(userId, { period, now, locale }); if (context.status === "insufficient") { annotate({ action: { name: "insights.narrative.insufficient" }, diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index 66ddcd8f9..2a160b63e 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -31,6 +31,7 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import type { Locale } from "@/lib/i18n/config"; import { annotate } from "@/lib/logging/context"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { @@ -286,6 +287,12 @@ export interface AssembleInput { seriesByMetric: Map; /** Named series feeding the discovery matrix (same window). */ discoverySeries: NamedSeries[]; + /** + * The reader's locale for the drivers' narrated `interpretation`. Required — + * the assembler writes sentences, so it has to be told which language they + * are in rather than assuming one. + */ + locale: Locale; /** Compute time (ISO). */ computedAt: string; } @@ -305,6 +312,7 @@ export function assemblePeriodNarrativeContext( window, seriesByMetric, discoverySeries, + locale, computedAt, } = input; @@ -380,7 +388,7 @@ export function assemblePeriodNarrativeContext( } // ── drivers (FDR-surviving correlations, descriptive-only) ────────────── - const discovery = discoverCorrelations(discoverySeries); + const discovery = discoverCorrelations(discoverySeries, { locale }); const drivers: NarrativeDriver[] = discovery.discovered.map((d) => ({ behaviour: d.behaviour, outcome: d.outcome, @@ -486,6 +494,14 @@ export interface BuildPeriodNarrativeContextOpts { period: NarrativePeriod; /** Injected clock for deterministic behaviour; defaults to now. */ now?: Date; + /** + * The reader's locale, for the drivers' narrated `interpretation`. Required: + * those are finished sentences that reach the prompt and the deterministic + * fallback, and both callers already know which language the narrative is + * being written in. An optional locale here would mean an English driver line + * inside a German summary whenever a caller stayed quiet. + */ + locale: Locale; } /** @@ -673,6 +689,7 @@ export async function buildPeriodNarrativeContext( window: { from: since.toISOString(), to: now.toISOString() }, seriesByMetric, discoverySeries, + locale: opts.locale, computedAt: now.toISOString(), }); } diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 66965d70a..8fe30a89c 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -427,7 +427,11 @@ export async function preparePulseStatusForUser( // v1.12.7 — diversity / anti-repetition block (see blood-pressure-status). const varietyLead = pickVarietyLead(userId, "pulse", todayKey); const steadyRun = computeSteadyRun(pulseGraded.weekly, pulseGraded.monthly); - const relations = await getRelevantCorrelationsForMetric(userId, "PULSE"); + const relations = await getRelevantCorrelationsForMetric( + userId, + "PULSE", + locale, + ); const assessmentContextBlock = buildAssessmentContextBlock( { varietyLead, diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index dcf4ceaf3..607e334bb 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -586,7 +586,11 @@ export async function prepareWeightStatusForUser( // v1.12.7 — diversity / anti-repetition block (see blood-pressure-status). const varietyLead = pickVarietyLead(userId, "weight", todayKey); const steadyRun = computeSteadyRun(weightGraded.weekly, weightGraded.monthly); - const relations = await getRelevantCorrelationsForMetric(userId, "WEIGHT"); + const relations = await getRelevantCorrelationsForMetric( + userId, + "WEIGHT", + locale, + ); const assessmentContextBlock = buildAssessmentContextBlock( { varietyLead, diff --git a/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts b/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts index be6811fc2..8e942c4a5 100644 --- a/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts +++ b/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts @@ -32,11 +32,14 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => { await importOriginal(); return { ...actual, readBestGranularityRollups: vi.fn() }; }); -const { labResult, measurement } = vi.hoisted(() => ({ +const { labResult, measurement, user } = vi.hoisted(() => ({ labResult: { findMany: vi.fn() }, measurement: { count: vi.fn(async () => 0), groupBy: vi.fn(async () => []) }, + // The rich reads resolve the account's language before asking for a + // narrated sentence, which reads the stored preference. + user: { findUnique: vi.fn(async () => ({ locale: "de" })) }, })); -vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement } })); +vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement, user } })); import { compareMetric, diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index e6a4f1460..923016292 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -18,13 +18,16 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => { await importOriginal(); return { ...actual, readBestGranularityRollups: vi.fn() }; }); -const { labResult, measurement } = vi.hoisted(() => ({ +const { labResult, measurement, user } = vi.hoisted(() => ({ labResult: { findMany: vi.fn() }, // `withHrvFallback`'s presence probe (`count`) + the discovery presence // query (`groupBy`). Unused by metrics that don't exercise them. measurement: { count: vi.fn(), groupBy: vi.fn(async () => []) }, + // The rich reads resolve the account's language before asking for a + // narrated sentence, which reads the stored preference. + user: { findUnique: vi.fn(async () => ({ locale: "de" })) }, })); -vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement } })); +vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement, user } })); import { getCorrelation, @@ -215,6 +218,10 @@ describe("get_correlation", () => { expect(res.pair?.r).toBe(-0.42); expect(res.association).toBe("descriptive"); expect(res.windowDays).toBe(180); + // The `note` on the pair is a finished sentence an MCP client shows as it + // stands, so the account's language has to reach the reader. Nothing else + // in this file would notice if the read went back to asking in English. + expect(readCoachCorrelations).toHaveBeenCalledWith(USER, "de"); }); it("honest-null when the pair has no surviving association", async () => { @@ -396,7 +403,7 @@ describe("get_metric_baseline", () => { const res = await getMetricBaseline(USER, { metric: "hrv" }); expect(res.present).toBe(true); - expect(buildCoachReadStrip).toHaveBeenCalledWith(USER, "HRV_RMSSD"); + expect(buildCoachReadStrip).toHaveBeenCalledWith(USER, "HRV_RMSSD", "de"); }); it("stays on SDNN when the user has any HEART_RATE_VARIABILITY rows", async () => { @@ -419,6 +426,7 @@ describe("get_metric_baseline", () => { expect(buildCoachReadStrip).toHaveBeenCalledWith( USER, "HEART_RATE_VARIABILITY", + "de", ); }); }); diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 62e9ebe42..14d22261f 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -36,6 +36,7 @@ import { isModuleEnabled } from "@/lib/modules/gate"; import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; +import { resolveLocaleForUser } from "@/lib/i18n/user-locale"; import { readBestGranularityRollups, aggregateWmyBuckets, @@ -668,7 +669,12 @@ export async function getCorrelation( ): Promise { const a = norm(args.metricA); const b = norm(args.metricB); - const result = await readCoachCorrelations(userId); + // The `note` on each driver is a finished sentence the MCP client shows as + // it stands, so it is written in the account's language, not English. + const result = await readCoachCorrelations( + userId, + await resolveLocaleForUser(userId), + ); if (!result.present || !result.drivers || result.drivers.length === 0) { annotate({ @@ -973,7 +979,11 @@ export async function getMetricBaseline( } const metric = resolved.metric; - const strip = await buildCoachReadStrip(userId, metric.measurementType); + const strip = await buildCoachReadStrip( + userId, + metric.measurementType, + await resolveLocaleForUser(userId), + ); if (!strip.baseline) { annotate({ diff --git a/src/lib/query-keys/cycle.ts b/src/lib/query-keys/cycle.ts index c4ca520d9..09e394aa7 100644 --- a/src/lib/query-keys/cycle.ts +++ b/src/lib/query-keys/cycle.ts @@ -22,7 +22,14 @@ export const cycleKeys = { * on-ramp reads this so a non-FEMALE account can opt in before the gated * cycle page is reachable. */ cyclePrefs: () => ["cycle", "prefs"] as const, - cycleInsights: () => ["cycle", "insights"] as const, + /** + * The FDR-guarded phase-correlation read (`/api/cycle/insights`). Keyed by + * locale: each surviving lagged row now carries an `interpretation` the + * server writes in the reader's language, so a cached payload belongs to one + * language only. `["cycle"]` stays the prefix, so every cycle write still + * evicts it through `cycleDependentKeys`. + */ + cycleInsights: (locale: string) => ["cycle", "insights", locale] as const, cycleDayLog: (date: string) => ["cycle", "day-log", date] as const, /** The caller's own custom symptoms (decrypted labels) the log-day sheet * merges into the seeded chip grid. */ diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index 517528d57..1fdf45da5 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -172,9 +172,16 @@ export const insightsKeys = { * MeasurementType. Pure compute over the baseline + correlation engines — * the `["insights"]` prefix keeps it in the standard invalidation fan-out * (a measurement write busts it so the placement refreshes). + * + * Keyed by locale as well: the strip's second line is prose the server + * writes in the reader's language, so a cache entry belongs to one language + * only. Without the segment, switching language re-used the German page's + * cached English sentence — the fix on the server would have been invisible + * to anyone who switched rather than reloaded. Same reason + * `insightsMetricStatus` carries it. */ - insightsCoachRead: (metric: string) => - ["insights", "coach-read", metric] as const, + insightsCoachRead: (metric: string, locale: string) => + ["insights", "coach-read", metric, locale] as const, /** * v1.25 — baseline-drift read (`/api/insights/health-status`). Combines the