Merge work/coach-read-locale

This commit is contained in:
Marc-André Bombeck committed 2026-07-31 11:48:21 +02:00
commit 2d01b9846d
43 files changed
+1636 -169

No files matched your search

+300
View File
@@ -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<string> {
const names = new Set<string>();
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<ts.TypeElement>,
owner: string,
ownerOptional: boolean,
ownerHasInitializer: boolean,
defaultedNames: Set<string> = 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<ts.ParameterDeclaration>,
) => {
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,
);
}
@@ -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([]);
});
});
@@ -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");
});
});
@@ -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<typeof import("@/lib/cycle/phase-crosstab")>()),
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<typeof getSession>
>,
);
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<typeof getSession>
>,
);
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"]);
});
});
+7
View File
@@ -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.
@@ -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<typeof import("@/lib/modules/gate")>()),
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<typeof getSession>
>,
);
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: true,
} as unknown as Awaited<ReturnType<typeof checkAnalyticsReadRateLimit>>);
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<typeof getSession>
>,
);
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",
]);
});
});
+10 -1
View File
@@ -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,
+5 -1
View File
@@ -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<CycleInsightsResponse>("/api/cycle/insights"),
staleTime: 5 * 60_000,
});
@@ -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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>
<CoachReadStrip metricType={METRIC} unit="h" />
</I18nProvider>
</QueryClientProvider>,
);
}
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);
});
});
@@ -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<CoachReadStripData>(
`/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;
@@ -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",
);
});
});
@@ -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<typeof real.discoverCorrelations>[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(
@@ -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();
});
});
@@ -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 () => {
+9 -1
View File
@@ -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<CorrelationsSnapshotBlock | null> {
const result = await readCoachCorrelations(userId);
const result = await readCoachCorrelations(userId, locale);
if (!result.present || !result.drivers || result.drivers.length === 0) {
return null;
}
+1
View File
@@ -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) {
+1 -1
View File
@@ -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,
+15 -2
View File
@@ -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<CoachCorrelationsResult> {
// 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),
+7 -1
View File
@@ -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" };
}
+10 -2
View File
@@ -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([]);
});
});
+10 -2
View File
@@ -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<string, CyclePhase>;
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 });
}
/**
+26
View File
@@ -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<Locale> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
return resolveServerLocale({ userLocale: row?.locale ?? null });
}
@@ -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.
@@ -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/);
}
@@ -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([]);
});
@@ -411,6 +411,7 @@ describe("generateMetricStatus — generation path", () => {
expect(getRelevantCorrelationsForMetric).toHaveBeenCalledWith(
"user-1",
"RESTING_HEART_RATE",
"en",
);
});
@@ -678,6 +678,7 @@ export async function prepareBloodPressureStatusForUser(
const relations = await getRelevantCorrelationsForMetric(
userId,
"BLOOD_PRESSURE_SYS",
locale,
);
const assessmentContextBlock = buildAssessmentContextBlock(
{
+27 -15
View File
@@ -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<string, LabDrawPoint[]>();
@@ -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<typeof import("@/lib/insights/derived")>();
return {
...actual,
loadBaselineProfile: async () => PROFILE,
computeCoincidentDeviation: async () => null,
};
});
vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/insights/derived/baseline")>();
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);
});
});
+13 -1
View File
@@ -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<CoachReadStripData> {
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;
@@ -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<RelevantCorrelation[]> {
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,
+1
View File
@@ -454,6 +454,7 @@ export async function generateMetricStatus(args: {
const relations = await getRelevantCorrelationsForMetric(
args.userId,
meta.measurementType,
locale,
);
const contextBlock = buildAssessmentContextBlock(
{
@@ -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({
@@ -40,6 +40,7 @@ function monthInput(
},
seriesByMetric,
discoverySeries,
locale: "en",
computedAt: "2026-04-30T12:00:00.000Z",
};
}
@@ -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" },
+18 -1
View File
@@ -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<string, DailySeriesPoint[]>;
/** 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(),
});
}
+5 -1
View File
@@ -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,
+5 -1
View File
@@ -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,
@@ -32,11 +32,14 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => {
await importOriginal<typeof import("@/lib/rollups/measurement-read-wmy")>();
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,
+11 -3
View File
@@ -18,13 +18,16 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => {
await importOriginal<typeof import("@/lib/rollups/measurement-read-wmy")>();
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",
);
});
});
+12 -2
View File
@@ -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<CorrelationResult> {
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({
+8 -1
View File
@@ -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. */
+9 -2
View File
@@ -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