mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(coach): widen tool-mode data reach to every stored domain
The retrieval-tool DATA INVENTORY was built against the user's default
narration cluster (cardio/body/mood/medication), so sleep, glucose,
activity, body composition, vascular, SpO2, gait and environment series
reported absent even when the data existed — and the grounding rule then
told the model not to fetch them. Probe presence against the full source
set instead, and generate the metric-series inventory rows from the
complete source-to-section map so every series the user has rows for is
advertised. Per-tool reads still re-scope to their own domain, so the
wider probe never widens a figure read.
Add three retrieval tools whose snapshot blocks already existed but were
unreachable in tool mode:
- get_workouts: recent sessions + per-sport rollup
- get_cycle: phase / prediction / correlation (gated by cycle access)
- get_correlations: the FDR-controlled lagged cross-metric drivers plus
the coincident-deviation flag, surfaced descriptively
Also honour the caller's window in get_labs and get_illness_recovery so a
cross-metric answer no longer silently mixes horizons.
This commit is contained in:
1 parent
8c09970d4a
commit
6de1f34efb
8 files changed
+821
-59
No files matched your search
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* v1.21.0 (C3) — the Coach correlations reader surfaces the deterministic FDR
|
||||
* discovery + coincident-deviation flag, returns descriptive driver rows, and
|
||||
* degrades to a clean { present: false } when nothing survives or a read fails.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const measurementFindMany = vi.fn();
|
||||
const moodFindMany = vi.fn();
|
||||
const userFindUnique = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: (a: unknown) => measurementFindMany(a) },
|
||||
moodEntry: { findMany: (a: unknown) => moodFindMany(a) },
|
||||
user: { findUnique: (a: unknown) => userFindUnique(a) },
|
||||
},
|
||||
}));
|
||||
|
||||
const loadBaselineProfile = vi.fn();
|
||||
const computeCoincidentDeviation = vi.fn();
|
||||
vi.mock("@/lib/insights/derived", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/insights/derived")>();
|
||||
return {
|
||||
...actual,
|
||||
loadBaselineProfile: () => loadBaselineProfile(),
|
||||
computeCoincidentDeviation: () => computeCoincidentDeviation(),
|
||||
};
|
||||
});
|
||||
|
||||
const discoverCorrelations = vi.fn();
|
||||
vi.mock("@/lib/insights/correlation-discovery", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@/lib/insights/correlation-discovery")
|
||||
>();
|
||||
return { ...actual, discoverCorrelations: () => discoverCorrelations() };
|
||||
});
|
||||
|
||||
import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
|
||||
|
||||
describe("readCoachCorrelations", () => {
|
||||
beforeEach(() => {
|
||||
measurementFindMany.mockReset().mockResolvedValue([]);
|
||||
moodFindMany.mockReset().mockResolvedValue([]);
|
||||
userFindUnique.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" });
|
||||
loadBaselineProfile
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ageYears: 40, sex: "MALE", heightCm: 180 });
|
||||
computeCoincidentDeviation.mockReset().mockResolvedValue(null);
|
||||
discoverCorrelations.mockReset();
|
||||
});
|
||||
|
||||
it("returns descriptive drivers when pairs survive", async () => {
|
||||
discoverCorrelations.mockReturnValue({
|
||||
discovered: [
|
||||
{
|
||||
behaviour: "TIME_IN_DAYLIGHT",
|
||||
outcome: "SLEEP_DURATION",
|
||||
n: 42,
|
||||
r: 0.34,
|
||||
pValue: 0.001,
|
||||
qValue: 0.02,
|
||||
interpretation:
|
||||
"Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
|
||||
lagDays: 1,
|
||||
},
|
||||
],
|
||||
pairsTested: 18,
|
||||
fdrQ: 0.1,
|
||||
minPairs: 20,
|
||||
});
|
||||
const result = await readCoachCorrelations("u1");
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.drivers).toHaveLength(1);
|
||||
expect(result.drivers?.[0]).toMatchObject({
|
||||
behaviour: "time in daylight",
|
||||
outcome: "sleep duration",
|
||||
direction: "higher",
|
||||
lagDays: 1,
|
||||
n: 42,
|
||||
});
|
||||
expect(result.pairsTested).toBe(18);
|
||||
});
|
||||
|
||||
it("returns present:false when nothing survives and no coincident flag", async () => {
|
||||
discoverCorrelations.mockReturnValue({
|
||||
discovered: [],
|
||||
pairsTested: 4,
|
||||
fdrQ: 0.1,
|
||||
minPairs: 20,
|
||||
});
|
||||
const result = await readCoachCorrelations("u1");
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.reason).toBe("no_significant_pattern");
|
||||
});
|
||||
|
||||
it("surfaces a fired coincident flag even without drivers", async () => {
|
||||
discoverCorrelations.mockReturnValue({
|
||||
discovered: [],
|
||||
pairsTested: 4,
|
||||
fdrQ: 0.1,
|
||||
minPairs: 20,
|
||||
});
|
||||
computeCoincidentDeviation.mockResolvedValue({
|
||||
status: "ok",
|
||||
value: {
|
||||
fired: true,
|
||||
vitals: [],
|
||||
contributing: [
|
||||
{ type: "RESTING_HEART_RATE", direction: "above" },
|
||||
{ type: "HEART_RATE_VARIABILITY", direction: "below" },
|
||||
],
|
||||
day: "2026-06-02",
|
||||
illnessExplained: false,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 2,
|
||||
presentInputs: 2,
|
||||
historyDays: 30,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 80, band: "high" },
|
||||
provenance: {
|
||||
inputs: [],
|
||||
source: "DAY",
|
||||
windowDays: 30,
|
||||
computedAt: "x",
|
||||
},
|
||||
});
|
||||
const result = await readCoachCorrelations("u1");
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.coincident?.fired).toBe(true);
|
||||
expect(result.coincident?.contributing).toEqual([
|
||||
{ metric: "resting heart rate", direction: "above" },
|
||||
{ metric: "heart rate variability", direction: "below" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("degrades to present:false on a read failure", async () => {
|
||||
loadBaselineProfile.mockRejectedValue(new Error("db down"));
|
||||
const result = await readCoachCorrelations("u1");
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.reason).toBe("retrieval_failed");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,11 @@ vi.mock("@/lib/ai/coach/snapshot", () => ({
|
||||
buildCoachSnapshot(userId, scope),
|
||||
}));
|
||||
|
||||
const readCoachCorrelations = vi.fn();
|
||||
vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
|
||||
readCoachCorrelations: (userId: string) => readCoachCorrelations(userId),
|
||||
}));
|
||||
|
||||
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
||||
|
||||
function snapshot(
|
||||
@@ -33,6 +38,7 @@ function snapshot(
|
||||
describe("executeCoachTool", () => {
|
||||
beforeEach(() => {
|
||||
buildCoachSnapshot.mockReset();
|
||||
readCoachCorrelations.mockReset();
|
||||
});
|
||||
|
||||
it("returns the matching section for get_metric_series when present", async () => {
|
||||
@@ -182,6 +188,104 @@ describe("executeCoachTool", () => {
|
||||
expect(miss.reason).toBe("analyte_not_found");
|
||||
});
|
||||
|
||||
it("returns the workouts section for get_workouts when present", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(
|
||||
snapshot({ workouts: { recent: [{ sport: "RUN" }], totalInWindow: 3 } }),
|
||||
);
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_workouts",
|
||||
rawArguments: JSON.stringify({ window: "last30days" }),
|
||||
});
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.data).toMatchObject({ totalInWindow: 3 });
|
||||
});
|
||||
|
||||
it("returns { present: false } for get_workouts when no block", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(snapshot({}));
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_workouts",
|
||||
rawArguments: "{}",
|
||||
});
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.reason).toBe("no_data");
|
||||
});
|
||||
|
||||
it("returns the cycle section for get_cycle when present", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(
|
||||
snapshot({ cycle: { phase: "luteal", dayOfCycle: 21 } }),
|
||||
);
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_cycle",
|
||||
rawArguments: "{}",
|
||||
});
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.data).toMatchObject({ phase: "luteal" });
|
||||
});
|
||||
|
||||
it("returns { present: false } for get_cycle when cycle tracking is off", async () => {
|
||||
// A non-cycle account produces no cycle block (gated in the builder).
|
||||
buildCoachSnapshot.mockResolvedValue(snapshot({}));
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_cycle",
|
||||
rawArguments: "{}",
|
||||
});
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.reason).toBe("no_data");
|
||||
});
|
||||
|
||||
it("surfaces discovered drivers + coincident flag for get_correlations", async () => {
|
||||
readCoachCorrelations.mockResolvedValue({
|
||||
present: true,
|
||||
drivers: [
|
||||
{
|
||||
behaviour: "time in daylight",
|
||||
outcome: "sleep duration",
|
||||
direction: "higher",
|
||||
lagDays: 1,
|
||||
n: 42,
|
||||
r: 0.31,
|
||||
note: "Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
|
||||
},
|
||||
],
|
||||
coincident: {
|
||||
fired: false,
|
||||
contributing: [],
|
||||
day: "2026-06-02",
|
||||
illnessExplained: false,
|
||||
},
|
||||
pairsTested: 18,
|
||||
windowDays: 180,
|
||||
});
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_correlations",
|
||||
rawArguments: "{}",
|
||||
});
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.data).toMatchObject({
|
||||
drivers: [{ behaviour: "time in daylight", n: 42 }],
|
||||
pairsTested: 18,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a clean { present: false } for get_correlations on no pattern", async () => {
|
||||
readCoachCorrelations.mockResolvedValue({
|
||||
present: false,
|
||||
reason: "no_significant_pattern",
|
||||
});
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_correlations",
|
||||
rawArguments: "{}",
|
||||
});
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.reason).toBe("no_significant_pattern");
|
||||
});
|
||||
|
||||
it("never passes userId as a tool argument (read from session only)", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(snapshot({}));
|
||||
// A crafted argument trying to smuggle a userId must be rejected by the
|
||||
|
||||
@@ -67,6 +67,50 @@ describe("buildCoachDataInventory", () => {
|
||||
expect(sleep?.present).toBe(false);
|
||||
});
|
||||
|
||||
it("advertises workouts + correlations tools and the cycle tool when enabled", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(
|
||||
snapshot(
|
||||
{
|
||||
workouts: { recent: [], totalInWindow: 4 },
|
||||
mood: {},
|
||||
cycle: { phase: "luteal" },
|
||||
scope: { window: "last30days" },
|
||||
},
|
||||
{ workouts: 4 },
|
||||
),
|
||||
);
|
||||
isCycleAvailableForUser.mockResolvedValue(true);
|
||||
const inv = await buildCoachDataInventory("u1", undefined);
|
||||
const workouts = inv.entries.find((e) => e.tool === "get_workouts");
|
||||
expect(workouts).toMatchObject({ present: true, count: 4 });
|
||||
const correlations = inv.entries.find((e) => e.tool === "get_correlations");
|
||||
expect(correlations?.present).toBe(true); // mood present → correlatable
|
||||
const cycle = inv.entries.find((e) => e.tool === "get_cycle");
|
||||
expect(cycle).toMatchObject({ present: true });
|
||||
});
|
||||
|
||||
it("omits the cycle line when cycle tracking is unavailable", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(snapshot({ bloodPressure: {} }));
|
||||
isCycleAvailableForUser.mockResolvedValue(false);
|
||||
const inv = await buildCoachDataInventory("u1", undefined);
|
||||
expect(inv.entries.find((e) => e.tool === "get_cycle")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("probes a wide source set so synced domains are advertised", async () => {
|
||||
// body composition + spo2 present even though they are not default clusters.
|
||||
buildCoachSnapshot.mockResolvedValue(
|
||||
snapshot(
|
||||
{ bodyFat: {}, oxygenSaturation: {}, scope: { window: "last30days" } },
|
||||
{ body_fat: 12, spo2: 30 },
|
||||
),
|
||||
);
|
||||
const inv = await buildCoachDataInventory("u1", undefined);
|
||||
const bodyFat = inv.entries.find((e) => e.metric === "body_fat");
|
||||
const spo2 = inv.entries.find((e) => e.metric === "spo2");
|
||||
expect(bodyFat).toMatchObject({ present: true, count: 12 });
|
||||
expect(spo2).toMatchObject({ present: true, count: 30 });
|
||||
});
|
||||
|
||||
it("reports restMode + cycleEnabled", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(
|
||||
snapshot({ illness: { restMode: true } }),
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* v1.21.0 (C3) — Coach correlations reader.
|
||||
*
|
||||
* Surfaces the deterministic insight engine's cross-metric intelligence to the
|
||||
* Coach via the `get_correlations` tool. Two read-only sources, both already
|
||||
* computed elsewhere and reused verbatim here (no new statistics):
|
||||
*
|
||||
* - The FDR-controlled, day-D→D+1 lagged all-pairs discovery
|
||||
* (`@/lib/insights/correlation-discovery`). We run the SAME full-matrix scan
|
||||
* the `/api/insights/correlations` route + the per-metric card run, so the
|
||||
* Coach never surfaces a pair the insight pages would not — same Pearson,
|
||||
* same exact p-value, same Benjamini-Hochberg control. We return every
|
||||
* surviving pair (not filtered to one metric) as a descriptive driver row.
|
||||
* - The coincident-deviation flag (`computeCoincidentDeviation`): "two or more
|
||||
* of your vitals are outside their usual band today", with the illness-
|
||||
* explained reframe carried through.
|
||||
*
|
||||
* Grounding posture mirrors the other tools: a structured `{ present: false }`
|
||||
* when too little paired data exists for any pattern to survive (or the read
|
||||
* fails), never a throw and never an ambiguous empty list. The driver rows are
|
||||
* descriptive — direction + lag + n + the engine's own never-causal
|
||||
* interpretation string — so the Coach states the observed linkage without
|
||||
* inventing a relationship.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import {
|
||||
discoverCorrelations,
|
||||
DISCOVERY_BEHAVIOURS,
|
||||
DISCOVERY_OUTCOMES,
|
||||
type DailySeriesPoint,
|
||||
type NamedSeries,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
import {
|
||||
computeCoincidentDeviation,
|
||||
loadBaselineProfile,
|
||||
isDerivedOk,
|
||||
} from "@/lib/insights/derived";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
/** Trailing window for the discovery scan — mirrors the insight route. */
|
||||
const WINDOW_DAYS = 180;
|
||||
|
||||
/** One discovered driver pair, descriptive — never causal. */
|
||||
export interface CoachCorrelationDriver {
|
||||
behaviour: string;
|
||||
outcome: string;
|
||||
/** "higher" / "lower" — sign of the next-day association. */
|
||||
direction: "higher" | "lower";
|
||||
/** Lag in days (always 1 today). */
|
||||
lagDays: number;
|
||||
/** Paired-day sample count after the lag join. */
|
||||
n: number;
|
||||
/** Pearson r, rounded for display. */
|
||||
r: number;
|
||||
/** The engine's conservative, descriptive interpretation. */
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** The coincident-deviation summary the Coach can narrate. */
|
||||
export interface CoachCoincidentFlag {
|
||||
/** True when ≥2 vitals are outside their usual band on the latest day. */
|
||||
fired: boolean;
|
||||
/** The vitals outside their band (possible factors, never a cause). */
|
||||
contributing: Array<{ metric: string; direction: "above" | "below" }>;
|
||||
/** The day the flag was evaluated (YYYY-MM-DD). */
|
||||
day: string;
|
||||
/** True when an active illness episode explains the deviations. */
|
||||
illnessExplained: boolean;
|
||||
}
|
||||
|
||||
export interface CoachCorrelationsResult {
|
||||
present: boolean;
|
||||
drivers?: CoachCorrelationDriver[];
|
||||
coincident?: CoachCoincidentFlag;
|
||||
/** How many behaviour×outcome pairs were tested (honest footer). */
|
||||
pairsTested?: number;
|
||||
/** Trailing-day window the discovery scanned. */
|
||||
windowDays?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */
|
||||
function tzDayKey(at: Date, tz: string): string {
|
||||
const { year, month, day } = wallClockInTz(at, tz);
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Collapse rows to per-day means keyed in the user's tz. */
|
||||
function toDailyMeans(
|
||||
rows: Array<{ value: number; at: Date }>,
|
||||
tz: string,
|
||||
): DailySeriesPoint[] {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of rows) {
|
||||
if (!Number.isFinite(r.value)) continue;
|
||||
const day = tzDayKey(r.at, tz);
|
||||
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
acc.sum += r.value;
|
||||
acc.count += 1;
|
||||
byDay.set(day, acc);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.map(([day, acc]) => ({ day, value: acc.sum / acc.count }))
|
||||
.sort((a, b) => (a.day < b.day ? -1 : 1));
|
||||
}
|
||||
|
||||
/** Lower-case, space-separated label from a discovery channel key. */
|
||||
function humanise(key: string): string {
|
||||
return key.replace(/_/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Coach correlations payload for a user. Returns `{ present: false }`
|
||||
* when no driver survives AND the coincident flag is not informative, when the
|
||||
* user has no correlatable data, or on any read/compute failure (best-effort —
|
||||
* a correlation hiccup must never break the chat turn).
|
||||
*/
|
||||
export async function readCoachCorrelations(
|
||||
userId: string,
|
||||
): Promise<CoachCorrelationsResult> {
|
||||
try {
|
||||
const profile = await loadBaselineProfile(prisma, userId);
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = userRow?.timezone ?? "Europe/Berlin";
|
||||
const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY);
|
||||
|
||||
// MOOD is mood-entry backed, not a measurement type — strip it from the
|
||||
// measurement query and source it separately, exactly like the route.
|
||||
const behaviourTypes = DISCOVERY_BEHAVIOURS.filter(
|
||||
(k) => k !== "MOOD",
|
||||
) as MeasurementType[];
|
||||
const outcomeTypes = DISCOVERY_OUTCOMES.filter(
|
||||
(k) => k !== "MOOD",
|
||||
) as MeasurementType[];
|
||||
|
||||
const [measurements, moodEntries, coincidentDerived] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since },
|
||||
type: { in: [...behaviourTypes, ...outcomeTypes] },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
take: 20000,
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
take: 5000,
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
}),
|
||||
// Coincident-deviation is its own derived metric — fail-soft to null so a
|
||||
// baseline hiccup never sinks the whole correlations read.
|
||||
computeCoincidentDeviation(userId, profile).catch(() => null),
|
||||
]);
|
||||
|
||||
const byType = new Map<string, Array<{ value: number; at: Date }>>();
|
||||
for (const m of measurements) {
|
||||
const list = byType.get(m.type) ?? [];
|
||||
list.push({ value: m.value, at: m.measuredAt });
|
||||
byType.set(m.type, list);
|
||||
}
|
||||
const moodDaily = toDailyMeans(
|
||||
moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })),
|
||||
tz,
|
||||
);
|
||||
|
||||
const series: NamedSeries[] = [];
|
||||
for (const key of DISCOVERY_BEHAVIOURS) {
|
||||
const points =
|
||||
key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz);
|
||||
series.push({ key, role: "behaviour", points });
|
||||
}
|
||||
for (const key of DISCOVERY_OUTCOMES) {
|
||||
const points =
|
||||
key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz);
|
||||
series.push({ key, role: "outcome", points });
|
||||
}
|
||||
|
||||
const discovery = discoverCorrelations(series);
|
||||
const drivers: CoachCorrelationDriver[] = discovery.discovered.map((d) => ({
|
||||
behaviour: humanise(d.behaviour),
|
||||
outcome: humanise(d.outcome),
|
||||
direction: d.r >= 0 ? "higher" : "lower",
|
||||
lagDays: d.lagDays,
|
||||
n: d.n,
|
||||
r: Math.round(d.r * 100) / 100,
|
||||
note: d.interpretation,
|
||||
}));
|
||||
|
||||
const coincident = buildCoincidentFlag(coincidentDerived);
|
||||
|
||||
// Nothing to say: no surviving driver AND the coincident flag is either
|
||||
// insufficient or quiet (not fired). Report a clean miss.
|
||||
if (drivers.length === 0 && (!coincident || !coincident.fired)) {
|
||||
return { present: false, reason: "no_significant_pattern" };
|
||||
}
|
||||
|
||||
return {
|
||||
present: true,
|
||||
...(drivers.length > 0 ? { drivers } : {}),
|
||||
...(coincident ? { coincident } : {}),
|
||||
pairsTested: discovery.pairsTested,
|
||||
windowDays: WINDOW_DAYS,
|
||||
};
|
||||
} catch {
|
||||
return { present: false, reason: "retrieval_failed" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape the derived coincident-deviation value into the Coach summary. */
|
||||
function buildCoincidentFlag(
|
||||
derived: Awaited<ReturnType<typeof computeCoincidentDeviation>> | null,
|
||||
): CoachCoincidentFlag | undefined {
|
||||
if (!derived || !isDerivedOk(derived)) return undefined;
|
||||
const v = derived.value;
|
||||
return {
|
||||
fired: v.fired,
|
||||
contributing: v.contributing.map((c) => ({
|
||||
metric: humanise(String(c.type)),
|
||||
direction: c.direction === "above" ? "above" : "below",
|
||||
})),
|
||||
day: v.day,
|
||||
illnessExplained: v.illnessExplained,
|
||||
};
|
||||
}
|
||||
@@ -9,15 +9,17 @@
|
||||
* narrowed from the session in the executor), and every tool is read-only, so
|
||||
* there is no new mutation or egress surface.
|
||||
*
|
||||
* Six tools ship in v1.20.0 (the research's recommended slice):
|
||||
* Tools ship as a closed catalogue:
|
||||
* 1. get_metric_series — BP / weight / pulse + the ~38 additive series
|
||||
* 2. get_glucose_panel — per-context daily means + the 30-day clinical panel
|
||||
* 3. get_sleep — per-night sleep + sleep-rhythm (debt + chronotype)
|
||||
* 4. get_medication_compliance — dose-weighted compliance + GLP-1
|
||||
* 5. get_labs — latest reading per biomarker (12 months)
|
||||
* 6. get_illness_recovery — restMode + active/resolved illnesses + recovery composites
|
||||
*
|
||||
* cycle / correlation / workouts are deferred to a later release.
|
||||
* 7. get_workouts — recent sessions + per-sport rollup (v1.21.0, C2-4)
|
||||
* 8. get_cycle — menstrual phase / prediction / correlation (v1.21.0, C2-1)
|
||||
* 9. get_correlations — discovered FDR cross-metric drivers + the
|
||||
* coincident-deviation flag (v1.21.0, C3)
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -35,6 +37,9 @@ export const COACH_TOOL_NAMES = [
|
||||
"get_medication_compliance",
|
||||
"get_labs",
|
||||
"get_illness_recovery",
|
||||
"get_workouts",
|
||||
"get_cycle",
|
||||
"get_correlations",
|
||||
] as const;
|
||||
|
||||
export type CoachToolName = (typeof COACH_TOOL_NAMES)[number];
|
||||
@@ -83,6 +88,16 @@ export const getLabsArgsSchema = z
|
||||
|
||||
export const getIllnessRecoveryArgsSchema = z.object({}).strict();
|
||||
|
||||
export const getWorkoutsArgsSchema = z
|
||||
.object({
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const getCycleArgsSchema = z.object({}).strict();
|
||||
|
||||
export const getCorrelationsArgsSchema = z.object({}).strict();
|
||||
|
||||
/**
|
||||
* JSON-Schema parameter shapes handed to the provider. Kept hand-written
|
||||
* (rather than generated from Zod) so the wire description the model reads is
|
||||
@@ -205,4 +220,43 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_workouts",
|
||||
description:
|
||||
"Fetch the user's workouts: the most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. Use for training-load and 'how were my runs / am I overtraining?' questions. Returns { present: false } when no workouts are tracked.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [],
|
||||
properties: {
|
||||
window: {
|
||||
type: "string",
|
||||
enum: WINDOW_ENUM,
|
||||
description: "Analysis window. Defaults to the user's scope window.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_cycle",
|
||||
description:
|
||||
"Fetch the user's menstrual-cycle context: current phase + day-of-cycle, the next predicted event, and the headline phase-correlation finding. Descriptive only — never a contraception-grade or 'safe day' claim. Returns { present: false } when cycle tracking is off or there is no data.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [],
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_correlations",
|
||||
description:
|
||||
"Fetch the user's DISCOVERED cross-metric patterns: statistically-vetted (FDR-controlled) day-to-next-day driver pairs between behaviours (daylight, mood, glucose, blood pressure, steps) and outcomes (sleep, HRV, resting HR, weight, mood), each with direction, lag, sample size and a descriptive — never causal — note. Also reports the coincident-deviation flag (whether two or more vitals are outside their usual band today). Use when a metric is off and you want to state the observed linkage. Returns { present: false } when too little paired data exists for any pattern to survive.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [],
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -37,6 +37,9 @@ import {
|
||||
getMedicationComplianceArgsSchema,
|
||||
getLabsArgsSchema,
|
||||
getIllnessRecoveryArgsSchema,
|
||||
getWorkoutsArgsSchema,
|
||||
getCycleArgsSchema,
|
||||
getCorrelationsArgsSchema,
|
||||
isCoachToolName,
|
||||
type CoachToolName,
|
||||
} from "./definitions";
|
||||
@@ -44,6 +47,7 @@ import {
|
||||
COACH_SOURCE_SNAPSHOT_KEY,
|
||||
METRIC_SERIES_EXCLUDED_SOURCES,
|
||||
} from "./source-keys";
|
||||
import { readCoachCorrelations } from "./correlations-read";
|
||||
|
||||
/** A read-only structured tool result. Serialised to a `role:"tool"` turn. */
|
||||
export interface CoachToolResult {
|
||||
@@ -162,9 +166,15 @@ async function dispatch(
|
||||
case "get_medication_compliance":
|
||||
return getMedicationCompliance(userId, rawArgs, fallbackWindow);
|
||||
case "get_labs":
|
||||
return getLabs(userId, rawArgs);
|
||||
return getLabs(userId, rawArgs, fallbackWindow);
|
||||
case "get_illness_recovery":
|
||||
return getIllnessRecovery(userId);
|
||||
return getIllnessRecovery(userId, fallbackWindow);
|
||||
case "get_workouts":
|
||||
return getWorkouts(userId, rawArgs, fallbackWindow);
|
||||
case "get_cycle":
|
||||
return getCycle(userId, rawArgs);
|
||||
case "get_correlations":
|
||||
return getCorrelations(userId, rawArgs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,13 +310,23 @@ async function getMedicationCompliance(
|
||||
async function getLabs(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getLabsArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_labs", parsed.error);
|
||||
// Labs ride the snapshot regardless of `sources` (attached unconditionally),
|
||||
// so a minimal scope still surfaces them. Pass an empty source set to keep
|
||||
// the read tight.
|
||||
const snapshot = await buildCoachSnapshot(userId, { sources: [] });
|
||||
//
|
||||
// v1.21.0 (A5-F4) — the labs block itself is a "latest reading per biomarker
|
||||
// over the last 12 months" snapshot and is intentionally window-AGNOSTIC
|
||||
// (the read cutoff is fixed). We still thread the conversation's window onto
|
||||
// the scope so the snapshot's `scope` block reports the right horizon for the
|
||||
// turn; it does not move the labs read.
|
||||
const snapshot = await buildCoachSnapshot(userId, {
|
||||
sources: [],
|
||||
window: fallbackWindow,
|
||||
});
|
||||
const labs = pickSection(snapshot.sections, "labs") as
|
||||
| { recent?: Array<{ name?: string; analyte?: string }> }
|
||||
| undefined;
|
||||
@@ -327,15 +347,23 @@ async function getLabs(
|
||||
return { present: true, data: labs };
|
||||
}
|
||||
|
||||
async function getIllnessRecovery(userId: string): Promise<CoachToolResult> {
|
||||
async function getIllnessRecovery(
|
||||
userId: string,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
): Promise<CoachToolResult> {
|
||||
// Validate the (empty) args shape for consistency; an empty object always
|
||||
// passes.
|
||||
getIllnessRecoveryArgsSchema.parse({});
|
||||
// Recovery composites gate on the `recovery` module + the HRV/RHR/VO2max
|
||||
// sources; request them so the derived / dayStrain / trajectory blocks can
|
||||
// build when the module is on. Illness rides the snapshot unconditionally.
|
||||
//
|
||||
// v1.21.0 (A5-F4) — honour the conversation's window so the recovery/strain/
|
||||
// trajectory composites are computed over the horizon the caller asked for
|
||||
// rather than the builder default.
|
||||
const snapshot = await buildCoachSnapshot(userId, {
|
||||
sources: ["hrv", "resting_hr", "vo2_max"],
|
||||
window: fallbackWindow,
|
||||
});
|
||||
const illness = pickSection(snapshot.sections, "illness");
|
||||
const derived = pickSection(snapshot.sections, "derived");
|
||||
@@ -359,3 +387,60 @@ async function getIllnessRecovery(userId: string): Promise<CoachToolResult> {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getWorkouts(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getWorkoutsArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_workouts", parsed.error);
|
||||
// The workouts block builds when the `workouts` cluster is active AND the
|
||||
// user has workout rows in the window. Scope the read to that single source.
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
userId,
|
||||
scopeFor(["workouts"], parsed.data.window, fallbackWindow),
|
||||
);
|
||||
const workouts = pickSection(snapshot.sections, "workouts");
|
||||
if (workouts === undefined) return { present: false, reason: "no_data" };
|
||||
return { present: true, data: workouts };
|
||||
}
|
||||
|
||||
async function getCycle(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getCycleArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_cycle", parsed.error);
|
||||
// The cycle block is gated INSIDE the builder by `isCycleAvailableForUser`
|
||||
// (the per-user toggle AND the operator switch), independent of `sources` —
|
||||
// so a minimal scope still surfaces it when the account tracks cycles, and a
|
||||
// non-cycle account structurally produces no block (→ present:false).
|
||||
const snapshot = await buildCoachSnapshot(userId, { sources: [] });
|
||||
const cycle = pickSection(snapshot.sections, "cycle");
|
||||
if (cycle === undefined) return { present: false, reason: "no_data" };
|
||||
return { present: true, data: cycle };
|
||||
}
|
||||
|
||||
async function getCorrelations(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getCorrelationsArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_correlations", parsed.error);
|
||||
// Reads the deterministic FDR discovery + coincident-deviation flag; returns
|
||||
// a clean `{ present: false }` when too little paired data exists.
|
||||
const result = await readCoachCorrelations(userId);
|
||||
if (!result.present) {
|
||||
return { present: false, reason: result.reason ?? "no_data" };
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
data: {
|
||||
...(result.drivers ? { drivers: result.drivers } : {}),
|
||||
...(result.coincident ? { coincident: result.coincident } : {}),
|
||||
pairsTested: result.pairsTested,
|
||||
windowDays: result.windowDays,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,17 @@
|
||||
*/
|
||||
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import type { CoachProvenanceMetric, CoachScope } from "@/lib/ai/coach/types";
|
||||
import type {
|
||||
CoachProvenanceMetric,
|
||||
CoachScope,
|
||||
CoachScopeSource,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import {
|
||||
COACH_SOURCE_SNAPSHOT_KEY,
|
||||
COACH_SOURCE_DOMAIN_LABEL,
|
||||
METRIC_SERIES_INVENTORY_SOURCES,
|
||||
FULL_INVENTORY_SOURCE_SET,
|
||||
} from "./source-keys";
|
||||
|
||||
/** One row of the inventory: a domain + whether the user has data for it. */
|
||||
export interface InventoryEntry {
|
||||
@@ -36,73 +46,48 @@ export interface CoachDataInventory {
|
||||
entries: InventoryEntry[];
|
||||
/** Illness rest-mode flag, so the safety framing is right before any call. */
|
||||
restMode: boolean;
|
||||
/** Whether cycle tracking is available (the get_cycle tool is deferred). */
|
||||
/** Whether cycle tracking is available (fetched via get_cycle). */
|
||||
cycleEnabled: boolean;
|
||||
/** The window the inventory was built against. */
|
||||
window: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a snapshot section key → an inventory row. The per-metric series tool
|
||||
* carries the `metric` argument; the dedicated-tool domains name their tool.
|
||||
* v1.21.0 (C2-2 / C2-3) — generate the get_metric_series inventory rows from
|
||||
* the FULL source→key map rather than the eight legacy clusters. Every series
|
||||
* the user actually has rows for is advertised, so the model is no longer told
|
||||
* (rule 2) to skip body-composition / vascular / SpO2 / gait / environment.
|
||||
* The `provenance` count key equals the source name for these series sources.
|
||||
*/
|
||||
const METRIC_SERIES_DOMAINS: Array<{
|
||||
sectionKey: string;
|
||||
metric: string;
|
||||
metric: CoachScopeSource;
|
||||
domain: string;
|
||||
provenance: CoachProvenanceMetric;
|
||||
}> = [
|
||||
{
|
||||
sectionKey: "bloodPressure",
|
||||
metric: "bp",
|
||||
domain: "blood pressure",
|
||||
provenance: "bp",
|
||||
},
|
||||
{
|
||||
sectionKey: "weight",
|
||||
metric: "weight",
|
||||
domain: "weight",
|
||||
provenance: "weight",
|
||||
},
|
||||
{
|
||||
sectionKey: "pulse",
|
||||
metric: "pulse",
|
||||
domain: "pulse",
|
||||
provenance: "pulse",
|
||||
},
|
||||
{ sectionKey: "mood", metric: "mood", domain: "mood", provenance: "mood" },
|
||||
{
|
||||
sectionKey: "heartRateVariability",
|
||||
metric: "hrv",
|
||||
domain: "heart-rate variability",
|
||||
provenance: "hrv",
|
||||
},
|
||||
{
|
||||
sectionKey: "restingHeartRate",
|
||||
metric: "resting_hr",
|
||||
domain: "resting heart rate",
|
||||
provenance: "resting_hr",
|
||||
},
|
||||
{
|
||||
sectionKey: "steps",
|
||||
metric: "steps",
|
||||
domain: "steps",
|
||||
provenance: "steps",
|
||||
},
|
||||
{
|
||||
sectionKey: "vo2Max",
|
||||
metric: "vo2_max",
|
||||
domain: "VO2 max",
|
||||
provenance: "vo2_max",
|
||||
},
|
||||
];
|
||||
}> = METRIC_SERIES_INVENTORY_SOURCES.map((metric) => ({
|
||||
sectionKey: COACH_SOURCE_SNAPSHOT_KEY[metric] as string,
|
||||
metric,
|
||||
domain: COACH_SOURCE_DOMAIN_LABEL[metric] ?? (metric as string),
|
||||
provenance: metric as unknown as CoachProvenanceMetric,
|
||||
}));
|
||||
|
||||
export async function buildCoachDataInventory(
|
||||
userId: string,
|
||||
scope: CoachScope | undefined,
|
||||
): Promise<CoachDataInventory> {
|
||||
// v1.21.0 (C2-2) — probe presence against the FULL source set, not the
|
||||
// user's narration-cluster default. `effectiveScope` only carried the
|
||||
// window; expanding `sources` here makes the snapshot build every domain the
|
||||
// user has rows for, so sleep / glucose / activity / body-composition / etc.
|
||||
// no longer report `absent` for a default-prefs user. Each per-tool read
|
||||
// still re-scopes to its own domain, so this presence probe never widens a
|
||||
// figure read — it only widens what the inventory advertises.
|
||||
const probeScope: CoachScope = {
|
||||
sources: [...FULL_INVENTORY_SOURCE_SET],
|
||||
window: scope?.window,
|
||||
};
|
||||
const [snapshot, cycleEnabled] = await Promise.all([
|
||||
buildCoachSnapshot(userId, scope),
|
||||
buildCoachSnapshot(userId, probeScope),
|
||||
isCycleAvailableForUser(userId),
|
||||
]);
|
||||
const sections = snapshot.sections;
|
||||
@@ -147,6 +132,14 @@ export async function buildCoachDataInventory(
|
||||
? { count: counts.compliance }
|
||||
: {}),
|
||||
});
|
||||
// v1.21.0 (C2-4) — workouts has its own tool now (get_metric_series refuses
|
||||
// it). The block builds when the user has workout rows + the cluster is on.
|
||||
entries.push({
|
||||
tool: "get_workouts",
|
||||
domain: "workouts & training",
|
||||
present: has("workouts"),
|
||||
...(typeof counts.workouts === "number" ? { count: counts.workouts } : {}),
|
||||
});
|
||||
entries.push({
|
||||
tool: "get_labs",
|
||||
domain: "lab results",
|
||||
@@ -158,6 +151,31 @@ export async function buildCoachDataInventory(
|
||||
present:
|
||||
has("illness") || has("derived") || has("dayStrain") || has("trajectory"),
|
||||
});
|
||||
// v1.21.0 (C3) — discovered cross-metric correlations (FDR-controlled lagged
|
||||
// drivers) + the coincident-deviation flag. Advertised when the user has any
|
||||
// two discovery channels with data; the tool itself returns present:false
|
||||
// cleanly when nothing survives, so the inventory marks it present whenever a
|
||||
// correlatable signal exists (mood / cardio / activity).
|
||||
entries.push({
|
||||
tool: "get_correlations",
|
||||
domain: "cross-metric patterns (discovered drivers)",
|
||||
present:
|
||||
has("derived") ||
|
||||
has("mood") ||
|
||||
has("heartRateVariability") ||
|
||||
has("restingHeartRate") ||
|
||||
has("steps") ||
|
||||
has("sleep"),
|
||||
});
|
||||
// v1.21.0 (C2-1) — cycle is fetchable via get_cycle when cycle tracking is
|
||||
// available for this account (per-user toggle AND operator switch).
|
||||
if (cycleEnabled) {
|
||||
entries.push({
|
||||
tool: "get_cycle",
|
||||
domain: "menstrual cycle & phase",
|
||||
present: has("cycle"),
|
||||
});
|
||||
}
|
||||
|
||||
// restMode rides the illness section.
|
||||
const illness = sections.illness as { restMode?: boolean } | undefined;
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
* either resolves here or is explicitly one of the dedicated-tool sources, so a
|
||||
* future source addition cannot silently fall through to a fabricated answer.
|
||||
*/
|
||||
import type { CoachScopeSource } from "@/lib/ai/coach/types";
|
||||
import {
|
||||
coachScopeSourceSchema,
|
||||
type CoachScopeSource,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
/** Sources that `get_metric_series` does NOT serve (they have own tools). */
|
||||
export const METRIC_SERIES_EXCLUDED_SOURCES: ReadonlySet<CoachScopeSource> =
|
||||
@@ -73,3 +76,78 @@ export const COACH_SOURCE_SNAPSHOT_KEY: Readonly<
|
||||
sleep: "sleep",
|
||||
body_temp: "bodyTemperature",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.21.0 (C2-3) — human-readable domain label per metric source, for the DATA
|
||||
* INVENTORY lines. Brand-free, lower-case, stable across turns so the cached
|
||||
* prefix holds. Every key in `COACH_SOURCE_SNAPSHOT_KEY` plus the dedicated-tool
|
||||
* sources has an entry; the inventory renders one line per series source the
|
||||
* user actually has rows for.
|
||||
*/
|
||||
export const COACH_SOURCE_DOMAIN_LABEL: Readonly<
|
||||
Partial<Record<CoachScopeSource, string>>
|
||||
> = {
|
||||
bp: "blood pressure",
|
||||
weight: "weight",
|
||||
pulse: "pulse",
|
||||
mood: "mood",
|
||||
hrv: "heart-rate variability",
|
||||
resting_hr: "resting heart rate",
|
||||
walking_hr: "walking heart rate",
|
||||
respiratory_rate: "respiratory rate",
|
||||
spo2: "blood oxygen (SpO2)",
|
||||
pulse_wave_velocity: "pulse-wave velocity",
|
||||
vascular_age: "vascular age",
|
||||
body_fat: "body fat",
|
||||
fat_mass: "fat mass",
|
||||
fat_free_mass: "fat-free mass",
|
||||
muscle_mass: "muscle mass",
|
||||
lean_body_mass: "lean body mass",
|
||||
bone_mass: "bone mass",
|
||||
total_body_water: "total body water",
|
||||
bmi: "BMI",
|
||||
visceral_fat: "visceral fat",
|
||||
steps: "steps",
|
||||
active_energy: "active energy",
|
||||
flights: "flights climbed",
|
||||
distance: "walking/running distance",
|
||||
vo2_max: "VO2 max",
|
||||
walking_steadiness: "walking steadiness",
|
||||
walking_asymmetry: "walking asymmetry",
|
||||
walking_double_support: "walking double support",
|
||||
walking_step_length: "walking step length",
|
||||
walking_speed: "walking speed",
|
||||
audio_env: "environmental audio exposure",
|
||||
audio_headphone: "headphone audio exposure",
|
||||
audio_event: "audio exposure events",
|
||||
daylight: "time in daylight",
|
||||
skin_temp: "skin temperature",
|
||||
sleep: "sleep",
|
||||
body_temp: "body temperature",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.21.0 (C2-2 / C2-3) — the FULL set of metric-series sources the inventory
|
||||
* probes for presence: every source `get_metric_series` can serve (the
|
||||
* `COACH_SOURCE_SNAPSHOT_KEY` keys), minus the few that ride a dedicated tool
|
||||
* (sleep has `get_sleep`; glucose/compliance/workouts are excluded). The
|
||||
* inventory is built against this full set so a domain with data is advertised
|
||||
* regardless of the user's narration-cluster preference — closing the
|
||||
* default-cluster reach gap. `sleep` is omitted here because `get_sleep` owns
|
||||
* its inventory line.
|
||||
*/
|
||||
export const METRIC_SERIES_INVENTORY_SOURCES: ReadonlyArray<CoachScopeSource> =
|
||||
(Object.keys(COACH_SOURCE_SNAPSHOT_KEY) as CoachScopeSource[]).filter(
|
||||
(source) => source !== "sleep",
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.21.0 (C2-2) — the full source set the inventory snapshot is built against,
|
||||
* so every domain with stored rows reports `present` (not the default-cluster
|
||||
* subset). The per-tool reads still re-scope to the exact domain, so widening
|
||||
* the inventory probe never widens a figure read. This is the canonical enum
|
||||
* (every `CoachScopeSource`) — the snapshot builder only emits a block when the
|
||||
* matching source has rows, so probing the full set is presence-only.
|
||||
*/
|
||||
export const FULL_INVENTORY_SOURCE_SET: ReadonlyArray<CoachScopeSource> =
|
||||
coachScopeSourceSchema.options;
|
||||
Reference in new issue
Block a user