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:
Marc-André Bombeck committed 2026-06-26 12:35:57 +02:00
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 } }),
+233
View File
@@ -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,
};
}
+57 -3
View File
@@ -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: {},
},
},
];
+89 -4
View File
@@ -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,
},
};
}
+69 -51
View File
@@ -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;
+79 -1
View File
@@ -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;