diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index d8b95d743..3f4a1e17d 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -154,6 +154,7 @@ const { buildCoachDataInventory, renderDataInventory, runCoachToolLoop } = totalTokens: 80, rounds: 2, toolTrace: [{ name: "get_metric_series", present: true }], + toolResults: [], })), })); vi.mock("@/lib/ai/coach/tools", () => ({ @@ -183,6 +184,9 @@ vi.mock("@/lib/validations/coach-prefs", () => ({ })); const { appendMessage } = await import("@/lib/ai/coach/persistence"); +const { parseKeyValuesSentinel } = await import("@/lib/ai/coach/keyvalues"); +const { parseSuggestReminder } = + await import("@/lib/ai/coach/suggest-reminder"); vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: ( @@ -260,6 +264,50 @@ describe("coach chat — tool-mode routing (F1)", () => { ).toEqual([{ name: "get_metric_series", present: true }]); }); + it("soft-strips a prose number the tools never returned (P6)", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + ]); + // Echo the real prose through the sentinel + suggest parsers so the + // verifier sees the model's actual numbers (the default mocks return a + // fixed string). + const drift = "Your systolic averaged about 138 lately."; + (parseKeyValuesSentinel as ReturnType).mockReturnValue({ + prose: drift, + keyValues: [], + malformed: false, + malformedEntries: [], + }); + (parseSuggestReminder as ReturnType).mockReturnValue({ + prose: drift, + }); + // The tool returned systolic 128; the prose drifts to 138. + (runCoachToolLoop as ReturnType).mockImplementation( + async () => ({ + result: { + content: "Your systolic averaged about 138 lately.", + tokensUsed: 80, + model: "m", + }, + workingProviderType: "anthropic", + totalTokens: 80, + rounds: 2, + toolTrace: [{ name: "get_metric_series", present: true }], + toolResults: [ + { present: true, data: { aggregate: { avgSys30: 128 } } }, + ], + }), + ); + await post(chatReq({ message: "How is my BP?" })); + const calls = (appendMessage as ReturnType).mock.calls; + const assistantCall = calls.find( + (c) => (c[0] as { role: string }).role === "assistant", + ); + const content = (assistantCall?.[0] as { content: string }).content; + expect(content).toContain("[unverified]"); + expect(content).not.toContain("138"); + }); + it("falls back to the snapshot-stuffing path when a provider lacks tools", async () => { resolveProviderChain.mockResolvedValue([ { providerType: "anthropic", instance: {} }, diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 341476274..9fca7a8d8 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -84,6 +84,10 @@ import { } from "@/lib/ai/coach/tools"; import type { AiMessage } from "@/lib/ai/types"; import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues"; +import { + findUnverifiedCoachNumbers, + stripUnverifiedNumbers, +} from "@/lib/ai/coach/coach-prose-grounding"; import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder"; import { gateSuggestion } from "@/lib/ai/coach/suggest-gate"; import { @@ -518,6 +522,9 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; let result: CompletionResult; let workingProviderType: string; let toolTrace: CoachToolTrace[] = []; + // v1.21.0 (P6) — the present tool-result payloads this turn, for the post-hoc + // prose number-verifier. Empty on the no-tools path. + let toolResultPayloads: unknown[] = []; let totalTokensSpent: number; try { if (toolMode) { @@ -556,6 +563,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch result = loop.result; workingProviderType = loop.workingProviderType; toolTrace = loop.toolTrace; + toolResultPayloads = (loop.toolResults ?? []).map((r) => r.data); totalTokensSpent = loop.totalTokens; } else { const fallback = await runRawCompletionWithFallback({ @@ -701,6 +709,38 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch }); } + // v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on the Coach prose. On the + // tool path, cross-check every number the model cited against the figures the + // tools actually returned this turn; an unmatched number (transcription / + // paraphrase drift) is soft-stripped to "[unverified]" and annotated. Cheap, + // non-blocking, and a no-op when no tool returned figures (a qualitative turn + // or the no-tools path) — the prompt-level grounding rule remains the + // backstop there, exactly like the briefing's "no signals → skip". A blocked + // turn already carries canned fallback prose, so skip it. + if (!outbound.block && toolResultPayloads.length > 0) { + const unverified = findUnverifiedCoachNumbers( + replyText, + toolResultPayloads, + ); + if (unverified.length > 0) { + const { prose: corrected, stripped } = stripUnverifiedNumbers( + replyText, + unverified, + ); + replyText = corrected; + annotate({ + action: { name: "coach.prose.number_unverified" }, + meta: { + flagged: unverified.length, + stripped, + // No raw values — just the count + truncated tokens for ops triage. + tokens: unverified.slice(0, 6).map((u) => u.source), + promptVersion: PROMPT_VERSION, + }, + }); + } + } + let surfacedSuggestion: CoachSuggestion | null = null; if (!outbound.block && suggestParse.cadence) { const decision = await gateSuggestion({ diff --git a/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts new file mode 100644 index 000000000..c6a61a988 --- /dev/null +++ b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts @@ -0,0 +1,89 @@ +/** + * v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on Coach prose. It must flag + * a number the model cited that no tool returned this turn (transcription + * drift), exempt structural window/ordinal integers, and no-op when no tool + * returned figures. Soft-strip replaces only the flagged tokens. + */ +import { describe, expect, it } from "vitest"; + +import { + collectNumericLeaves, + findUnverifiedCoachNumbers, + stripUnverifiedNumbers, +} from "@/lib/ai/coach/coach-prose-grounding"; + +describe("collectNumericLeaves", () => { + it("walks numbers, numeric strings, arrays and nested objects", () => { + const out = new Set(); + collectNumericLeaves( + { + section: { avgSys30: 128, label: "LDL", value: "120" }, + recent: [{ v: 72 }, { v: "1.2" }], + }, + out, + ); + expect(out.has(128)).toBe(true); + expect(out.has(120)).toBe(true); + expect(out.has(72)).toBe(true); + expect(out.has(1.2)).toBe(true); + }); +}); + +describe("findUnverifiedCoachNumbers", () => { + const payloads = [{ aggregate: { avgSys30: 128, avgDia30: 82 } }]; + + it("returns nothing when every cited number is grounded", () => { + const prose = + "Your systolic averaged 128 and diastolic 82 over the last 30 days."; + expect(findUnverifiedCoachNumbers(prose, payloads)).toEqual([]); + }); + + it("flags a drifted number the tools never returned", () => { + const prose = "Your systolic averaged about 138 recently."; + const findings = findUnverifiedCoachNumbers(prose, payloads); + expect(findings).toHaveLength(1); + expect(findings[0].value).toBe(138); + }); + + it("allows a rounding within tolerance", () => { + const prose = "Diastolic sat around 82.0 this period."; + expect(findUnverifiedCoachNumbers(prose, payloads)).toEqual([]); + }); + + it("exempts structural window + small-ordinal integers", () => { + const prose = "Over the last 30 days, 2 readings stood out of 7 total."; + // 30 + 7 are window integers; 2 is a small ordinal — none graded. + const findings = findUnverifiedCoachNumbers(prose, [{ x: 999 }]); + expect(findings).toEqual([]); + }); + + it("no-ops when no tool returned any figure", () => { + const prose = "Your systolic averaged 138 recently."; + expect(findUnverifiedCoachNumbers(prose, [])).toEqual([]); + expect( + findUnverifiedCoachNumbers(prose, [{ note: "no numbers here" }]), + ).toEqual([]); + }); + + it("returns empty on empty prose", () => { + expect(findUnverifiedCoachNumbers("", payloads)).toEqual([]); + }); +}); + +describe("stripUnverifiedNumbers", () => { + it("replaces only the flagged tokens, leaving grounded numbers intact", () => { + const prose = "Systolic 128, but it spiked to 138 yesterday."; + const findings = [{ value: 138, source: "138" }]; + const { prose: out, stripped } = stripUnverifiedNumbers(prose, findings); + expect(stripped).toBe(1); + expect(out).toContain("128"); + expect(out).toContain("[unverified]"); + expect(out).not.toContain("138"); + }); + + it("is a no-op when nothing was flagged", () => { + const { prose, stripped } = stripUnverifiedNumbers("all good", []); + expect(prose).toBe("all good"); + expect(stripped).toBe(0); + }); +}); 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/__tests__/tools-loop.test.ts b/src/lib/ai/coach/__tests__/tools-loop.test.ts index 7e70c3499..3c4586224 100644 --- a/src/lib/ai/coach/__tests__/tools-loop.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop.test.ts @@ -86,6 +86,9 @@ describe("runCoachToolLoop", () => { expect(out.toolTrace).toEqual([ { name: "get_metric_series", present: true }, ]); + // v1.21.0 (P6) — the present result's payload is retained for the prose + // number-verifier. + expect(out.toolResults).toEqual([{ present: true, data: { x: 1 } }]); // The second call must forbid tools? No — round 2 still offers them // (round <= MAX_ROUNDS). It simply chose to answer. expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(2); diff --git a/src/lib/ai/coach/coach-prose-grounding.ts b/src/lib/ai/coach/coach-prose-grounding.ts new file mode 100644 index 000000000..0772e83b8 --- /dev/null +++ b/src/lib/ai/coach/coach-prose-grounding.ts @@ -0,0 +1,153 @@ +/** + * v1.21.0 (P6 / C2-5) — post-hoc numeric verifier for Coach prose. + * + * The Daily Briefing strips any number absent from the server-computed + * `signalsOfDay` block (`@/lib/ai/briefing-grounding`). The Coach's TOOL path + * had no equivalent: the addendum forbids citing un-fetched figures, but + * nothing deterministically checks the FINAL prose against the figures the + * tools actually returned this turn — so a transcription/paraphrase drift + * (tool says systolic 128, prose says "~138") could ship. + * + * This module closes that gap the same way, scoped to the tool path: + * 1. Collect every numeric leaf from this turn's PRESENT `CoachToolResult` + * data payloads — the authoritative figure set. + * 2. Extract every number the model's prose asserts (reusing the briefing + * verifier's `extractNumbers`). + * 3. Flag any prose number that matches no authoritative figure within a + * rounding tolerance, exempting the structural integers (window lengths, + * small ordinals) the briefing verifier already exempts. + * + * Posture: NON-BLOCKING and cheap. The caller annotates + * `coach.prose.number_unverified` and may SOFT-STRIP the unverified figure + * from the prose (replacing the bare token with a neutral placeholder) — it + * never hard-fails the user's turn. When NO tool returned figures (a + * qualitative answer, or the no-tools path) there is no authoritative set to + * grade against, so the check no-ops and the prompt-level grounding rule + * remains the backstop, exactly like the briefing's "no signals → skip". + */ +import { extractNumbers } from "@/lib/ai/briefing-grounding"; + +/** Absolute + relative tolerance — identical basis to the briefing verifier. */ +const ABS_TOLERANCE = 0.15; +const REL_TOLERANCE = 0.02; + +/** + * Window lengths + small ordinals the prose uses structurally ("last 7 days", + * "2 of your vitals", "3 readings") — never graded, to avoid false positives on + * honest framing. Mirrors the briefing verifier's exemption set. + */ +const STRUCTURAL_INTEGERS = new Set([7, 14, 30, 31, 90, 180, 365]); + +function isStructural(value: number, raw: string): boolean { + if (raw.includes(".") || raw.includes(",")) return false; + if (!Number.isInteger(value)) return false; + const abs = Math.abs(value); + if (abs <= 3) return true; + return STRUCTURAL_INTEGERS.has(abs); +} + +/** One prose number that matched no figure any tool returned this turn. */ +export interface UnverifiedCoachNumber { + /** The numeric value the model wrote, as parsed. */ + value: number; + /** The raw token the value was read from (truncated). */ + source: string; +} + +/** + * Recursively collect every finite numeric leaf from a tool-result payload — + * numbers, and numeric strings ("128", "1.2"). Both forms appear in the + * snapshot sections (a mean may be a number; a lab value a string). The set is + * deliberately broad so a legitimately-cited figure never trips the verifier; + * the verifier's job is only to catch a number the model invented or mis-copied. + */ +export function collectNumericLeaves(value: unknown, out: Set): void { + if (value === null || value === undefined) return; + if (typeof value === "number") { + if (Number.isFinite(value)) out.add(value); + return; + } + if (typeof value === "string") { + // A scalar numeric string ("128", "-1.2", "92%") — pull its magnitudes. + for (const { value: n } of extractNumbers(value)) out.add(n); + return; + } + if (Array.isArray(value)) { + for (const item of value) collectNumericLeaves(item, out); + return; + } + if (typeof value === "object") { + for (const v of Object.values(value as Record)) { + collectNumericLeaves(v, out); + } + } +} + +/** True when `value` matches any authoritative figure within tolerance. */ +function isGrounded( + value: number, + authoritative: ReadonlySet, +): boolean { + for (const a of authoritative) { + const tol = Math.max(ABS_TOLERANCE, Math.abs(a) * REL_TOLERANCE); + if (Math.abs(value - a) <= tol) return true; + } + return false; +} + +/** + * Find every number the Coach prose asserts that does not trace to a figure + * returned by a tool this turn. Returns an empty array when there is no prose, + * no authoritative figure set (no present tool result with numbers), or every + * cited number is grounded. + * + * `toolPayloads` is the `data` payload of each PRESENT tool result this turn. + */ +export function findUnverifiedCoachNumbers( + prose: string, + toolPayloads: ReadonlyArray, +): UnverifiedCoachNumber[] { + if (!prose) return []; + const authoritative = new Set(); + for (const payload of toolPayloads) + collectNumericLeaves(payload, authoritative); + // No authoritative figures (a qualitative turn / no-tools path) — nothing to + // grade against. The prompt-level grounding rule remains the backstop. + if (authoritative.size === 0) return []; + + const findings: UnverifiedCoachNumber[] = []; + for (const { value, raw } of extractNumbers(prose)) { + if (isStructural(value, raw)) continue; + if (isGrounded(value, authoritative)) continue; + findings.push({ value, source: raw.slice(0, 32) }); + } + return findings; +} + +/** + * Soft-correct the prose: replace each unverified numeric token with a neutral + * placeholder so a drifted figure never reaches the user as if authoritative, + * while the surrounding qualitative framing is preserved. Conservative — it + * only rewrites the exact ungrounded tokens the verifier flagged, and only the + * first occurrence of each, leaving every grounded number untouched. + * + * Returns the (possibly unchanged) prose plus the count of tokens stripped. + */ +export function stripUnverifiedNumbers( + prose: string, + findings: ReadonlyArray, +): { prose: string; stripped: number } { + if (findings.length === 0) return { prose, stripped: 0 }; + let out = prose; + let stripped = 0; + for (const f of findings) { + // Replace the first standalone occurrence of the flagged token. The token + // is bounded by non-digit / non-sign edges so we don't clip a larger number. + const token = f.source; + const idx = out.indexOf(token); + if (idx === -1) continue; + out = `${out.slice(0, idx)}[unverified]${out.slice(idx + token.length)}`; + stripped += 1; + } + return { prose: out, stripped }; +} diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts index 51a96bacc..34422b03b 100644 --- a/src/lib/ai/coach/derived-snapshot.ts +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -16,6 +16,7 @@ */ import { computeDerivedMetric, + computeCoincidentDeviation, isDerivedOk, type DerivedMetricId, type BaselineProfile, @@ -55,6 +56,35 @@ interface DerivedSnapshotEntry { historyDays: number; } +/** + * v1.21.0 (C3 / D3) — the coincident-deviation flag, compactly. "Two or more + * of your vitals are outside their usual band today." Descriptive multi-signal + * co-movement, never a cause; carries the illness-explained reframe. Folded + * into the derived block so BOTH Coach paths (the tool path via + * get_illness_recovery/get_correlations AND the no-tools snapshot floor) can + * narrate it. Omitted entirely when it did not fire (no noise). + */ +interface CoincidentSnapshotEntry { + fired: true; + /** The out-of-band vitals (possible factors), e.g. "resting heart rate above". */ + contributing: string[]; + /** The day the flag was evaluated (YYYY-MM-DD). */ + day: string; + /** True when an active illness episode explains the deviations. */ + illnessExplained: boolean; +} + +/** + * The derived block: per-metric compact score entries (keyed by metric id), + * plus the optional fired-only coincident-deviation flag under its own reserved + * `COINCIDENT_DEVIATION` key. The score entries keep their `DerivedSnapshotEntry` + * shape (so callers + tests read `block.READINESS.value` directly); the + * coincident flag rides alongside without widening the score index signature. + */ +type DerivedSnapshotBlock = Record & { + COINCIDENT_DEVIATION?: CoincidentSnapshotEntry; +}; + /** Pull the headline number + band off each metric's value shape. */ function summariseValue( metric: DerivedMetricId, @@ -115,9 +145,18 @@ export async function buildDerivedSnapshotBlock( userId: string, profile: BaselineProfile, now: Date, -): Promise | null> { +): Promise { const block: Record = {}; + // v1.21.0 (C3 / D3) — the coincident-deviation flag, fired-only. Computed + // off the one shared profile alongside the scores; fail-soft to null so a + // baseline hiccup never sinks the derived block. Only attached when it + // FIRED (≥2 vitals out of band today) — a quiet day adds no entry, keeping + // the snapshot noise-free. + const coincidentPromise = computeCoincidentDeviation(userId, profile, { + now, + }).catch(() => null); + // The metrics are independent passthrough reads off the one shared profile — // no ordering dependency — so compute them concurrently. Per-metric fault // isolation: a transient failure on one must never sink the whole Coach turn, @@ -176,5 +215,22 @@ export async function buildDerivedSnapshotBlock( } } - return Object.keys(block).length > 0 ? block : null; + // Attach the coincident-deviation flag (fired-only) — the await happens here + // so the score computes above run concurrently with it. It rides under its + // own reserved key alongside the score entries. + const out: DerivedSnapshotBlock = block; + const coincident = await coincidentPromise; + if (coincident && isDerivedOk(coincident) && coincident.value.fired) { + out.COINCIDENT_DEVIATION = { + fired: true, + contributing: coincident.value.contributing.map( + (c) => + `${String(c.type).replace(/_/g, " ").toLowerCase()} ${c.direction}`, + ), + day: coincident.value.day, + illnessExplained: coincident.value.illnessExplained, + }; + } + + return Object.keys(out).length > 0 ? out : null; } 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/loop.ts b/src/lib/ai/coach/tools/loop.ts index 1f0dfb1d6..5e8208e4a 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -28,7 +28,11 @@ import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; import type { AiMessage, AiToolDef, CompletionResult } from "@/lib/ai/types"; import type { ProviderHealthLedger } from "@/lib/ai/provider-health-ledger"; import type { CoachScopeWindow } from "@/lib/ai/coach/types"; -import { executeCoachTool, type CoachToolTrace } from "./executor"; +import { + executeCoachTool, + type CoachToolResult, + type CoachToolTrace, +} from "./executor"; export const MAX_ROUNDS = 2; /** Absolute ceiling; the final allowed round forces prose (toolChoice none). */ @@ -45,6 +49,13 @@ export interface CoachToolLoopResult { rounds: number; /** Which tools ran + whether each found data (persisted onto provenance). */ toolTrace: CoachToolTrace[]; + /** + * v1.21.0 (P6) — the structured payloads of every PRESENT tool result this + * turn, in call order. The post-hoc prose number-verifier extracts the + * numeric leaves from these to cross-check the figures the model cited. Empty + * on a no-tools answer. + */ + toolResults: CoachToolResult[]; } export async function runCoachToolLoop(args: { @@ -78,6 +89,7 @@ export async function runCoachToolLoop(args: { let rounds = 0; let workingProviderType = ""; const toolTrace: CoachToolTrace[] = []; + const toolResults: CoachToolResult[] = []; // Round budget: rounds 1..HARD_CAP. On the last allowed round we forbid tool // calls so the model must answer. @@ -120,7 +132,14 @@ export async function runCoachToolLoop(args: { forcedFinal: isForcedFinal, }, }); - return { result, workingProviderType, totalTokens, rounds, toolTrace }; + return { + result, + workingProviderType, + totalTokens, + rounds, + toolTrace, + toolResults, + }; } // Append the assistant turn that requested the tools, then execute them in @@ -140,6 +159,10 @@ export async function runCoachToolLoop(args: { fallbackWindow, }); toolTrace.push({ name: call.name, present: toolResult.present }); + // v1.21.0 (P6) — retain the present results' payloads for the post-hoc + // prose number-verifier (the union of numeric leaves grounds the + // figures the model may cite). + if (toolResult.present) toolResults.push(toolResult); return { call, toolResult }; }), ); 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;