mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge work/coach-read-locale
This commit is contained in:
commit
2d01b9846d
43 files changed
+1636
-169
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user