merge(COACHDATA): v1.21.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 12:39:17 +02:00
commit a96ae9bc2b
15 files changed
+1237 -63

No files matched your search

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