From 6de1f34efbdc3cb3165e6a6b61ac752c33417711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:35:57 +0200 Subject: [PATCH] feat(coach): widen tool-mode data reach to every stored domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../coach/__tests__/correlations-read.test.ts | 146 +++++++++++ .../ai/coach/__tests__/tools-executor.test.ts | 104 ++++++++ .../coach/__tests__/tools-inventory.test.ts | 44 ++++ src/lib/ai/coach/tools/correlations-read.ts | 233 ++++++++++++++++++ src/lib/ai/coach/tools/definitions.ts | 60 ++++- src/lib/ai/coach/tools/executor.ts | 93 ++++++- src/lib/ai/coach/tools/inventory.ts | 120 +++++---- src/lib/ai/coach/tools/source-keys.ts | 80 +++++- 8 files changed, 821 insertions(+), 59 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/correlations-read.test.ts create mode 100644 src/lib/ai/coach/tools/correlations-read.ts diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts new file mode 100644 index 000000000..b6e813f83 --- /dev/null +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -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(); + 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"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index 9b7e96c87..e4edce158 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -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 diff --git a/src/lib/ai/coach/__tests__/tools-inventory.test.ts b/src/lib/ai/coach/__tests__/tools-inventory.test.ts index 8387dfd08..d2cfa9675 100644 --- a/src/lib/ai/coach/__tests__/tools-inventory.test.ts +++ b/src/lib/ai/coach/__tests__/tools-inventory.test.ts @@ -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 } }), diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts new file mode 100644 index 000000000..1d1424ab3 --- /dev/null +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -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(); + 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 { + 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>(); + 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> | 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, + }; +} diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts index 4fa65c033..d41caf2f6 100644 --- a/src/lib/ai/coach/tools/definitions.ts +++ b/src/lib/ai/coach/tools/definitions.ts @@ -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: {}, + }, + }, ]; diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index f9502c7d9..60baa2ba1 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -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 { 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 { +async function getIllnessRecovery( + userId: string, + fallbackWindow: CoachScopeWindow | undefined, +): Promise { // 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 { }, }; } + +async function getWorkouts( + userId: string, + rawArgs: unknown, + fallbackWindow: CoachScopeWindow | undefined, +): Promise { + 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 { + 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 { + 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, + }, + }; +} diff --git a/src/lib/ai/coach/tools/inventory.ts b/src/lib/ai/coach/tools/inventory.ts index 7e4e93bb8..10a5431e4 100644 --- a/src/lib/ai/coach/tools/inventory.ts +++ b/src/lib/ai/coach/tools/inventory.ts @@ -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 { + // 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; diff --git a/src/lib/ai/coach/tools/source-keys.ts b/src/lib/ai/coach/tools/source-keys.ts index d0d821347..a5fc0d878 100644 --- a/src/lib/ai/coach/tools/source-keys.ts +++ b/src/lib/ai/coach/tools/source-keys.ts @@ -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 = @@ -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> +> = { + 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 = + (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 = + coachScopeSourceSchema.options;