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