Write the Coach read strip's second line in the reader's language

On a German metric page the strip printed its own summary in German and
the pattern line under it in English. The pattern line is a finished
sentence the server writes, and the route never told the server who was
reading, so it wrote English.

The route now resolves the reader's language the same way the
correlations and analytics routes do and passes it down. The language is
a required argument the whole way through — it used to be optional with
an English fallback, which is why a caller could stay silent and still
get a sentence back. The cycle page's lagged rows had the same gap and
are fixed with it.

The strip's browser cache is keyed by language too, so switching
language no longer re-uses the sentence written for the previous one.
This commit is contained in:
Marc-André Bombeck committed 2026-07-31 08:52:23 +02:00
1 parent 340d1db6d9
commit 4ed6f7d1ce
30 files changed
+394 -161

No files matched your search

+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.
+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,
@@ -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();
});
});
+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([]);
});
@@ -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[]>();
+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,
+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({
+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