mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
refactor(coach): run each Coach tool from a module of its own under tools/run
This commit is contained in:
1 parent
e375a0e0ba
commit
faeebfc63a
38 files changed
+1514
-1324
No files matched your search
@@ -17,6 +17,12 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
readSource,
|
||||
readSourceFolder,
|
||||
requireFloor,
|
||||
} from "@/__tests__/helpers/source-files";
|
||||
|
||||
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
|
||||
|
||||
const buildCoachSnapshot =
|
||||
@@ -348,12 +354,24 @@ describe("the reported case: a metric whose whole history is outside the window"
|
||||
|
||||
describe("no empty window read can bypass the probe again", () => {
|
||||
it("the executor returns no hand-written no_data for a missing section", () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/lib/ai/coach/tools/executor.ts"),
|
||||
"utf8",
|
||||
);
|
||||
// The dispatcher plus one module per tool under `tools/run/`.
|
||||
const sources = [
|
||||
[
|
||||
"executor.ts",
|
||||
readSource("src/lib/ai/coach/tools/executor.ts", [
|
||||
"async function dispatchRead(",
|
||||
]),
|
||||
],
|
||||
...readSourceFolder("src/lib/ai/coach/tools/run", { floor: 15 }),
|
||||
];
|
||||
const source = sources.map(([, text]) => text).join("\n");
|
||||
// Every miss goes through `emptyRead(...)`. A hand-written literal is how
|
||||
// the defect got in: four sites, one reason, two meanings.
|
||||
requireFloor(
|
||||
"emptyRead calls in the tool executors",
|
||||
[...source.matchAll(/\bemptyRead\(/g)],
|
||||
9,
|
||||
);
|
||||
expect(source).not.toMatch(
|
||||
/return \{ present: false, reason: "no_data" \}/,
|
||||
);
|
||||
|
||||
@@ -26,6 +26,8 @@ import { join } from "node:path";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
|
||||
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
|
||||
import type {
|
||||
@@ -187,10 +189,8 @@ vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
import {
|
||||
admittedPriorResults,
|
||||
executeCoachTool,
|
||||
} from "@/lib/ai/coach/tools/executor";
|
||||
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
||||
import { admittedPriorResults } from "@/lib/ai/coach/tools/run/prior-results";
|
||||
import { renderPriorResultRefs } from "@/lib/ai/coach/chat-request-builder";
|
||||
import {
|
||||
COACH_TOOL_DEFS,
|
||||
@@ -426,7 +426,10 @@ function callArguments(raw: string, fn: string): string[] {
|
||||
|
||||
describe("the source hands the limit on", () => {
|
||||
it("passes the limit in every dispatch branch of the executor", () => {
|
||||
const source = readFileSync(join(COACH_DIR, "tools/executor.ts"), "utf8");
|
||||
const source = readSource("src/lib/ai/coach/tools/executor.ts", [
|
||||
"async function dispatchRead(",
|
||||
/case\s+"get_/,
|
||||
]);
|
||||
const dispatch = source.slice(
|
||||
source.indexOf("async function dispatchRead("),
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
|
||||
|
||||
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
|
||||
vi.mock("@/lib/ai/coach/tools/executor", () => ({
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
|
||||
|
||||
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
|
||||
vi.mock("@/lib/ai/coach/tools/executor", () => ({
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
|
||||
|
||||
vi.mock("undici", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("undici")>();
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
|
||||
|
||||
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
|
||||
vi.mock("@/lib/ai/coach/tools/executor", () => ({
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
|
||||
|
||||
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
|
||||
vi.mock("@/lib/ai/coach/tools/executor", () => ({
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
|
||||
|
||||
const executeCoachTool = vi.fn<(args: unknown) => Promise<CoachToolResult>>();
|
||||
vi.mock("@/lib/ai/coach/tools/executor", () => ({
|
||||
|
||||
@@ -44,11 +44,9 @@ vi.mock("@/lib/modules/gate", () => ({
|
||||
resolveModuleMap: (...a: unknown[]) => resolveModuleMap(...a),
|
||||
}));
|
||||
|
||||
import {
|
||||
admittedPriorResults,
|
||||
executeCoachTool,
|
||||
type CoachToolTurnContext,
|
||||
} from "@/lib/ai/coach/tools/executor";
|
||||
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
||||
import { admittedPriorResults } from "@/lib/ai/coach/tools/run/prior-results";
|
||||
import type { CoachToolTurnContext } from "@/lib/ai/coach/tools/run/types";
|
||||
import { createResultRefAllocator } from "../refs";
|
||||
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
|
||||
import { findUnverifiedCoachNumbers } from "@/lib/ai/coach/coach-prose-grounding";
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
|
||||
import type { CoachResultTable } from "@/lib/ai/coach/types";
|
||||
|
||||
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { CoachResultTable } from "@/lib/ai/coach/types";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
|
||||
|
||||
import { MAX_RESULTS_PER_TURN, isTurnResultRef } from "./refs";
|
||||
|
||||
|
||||
@@ -35,11 +35,8 @@ import { createResultRefAllocator } from "@/lib/ai/coach/results/refs";
|
||||
import { projectCompare } from "@/lib/ai/coach/results/projections";
|
||||
|
||||
import { coachResultGranularitySchema } from "./definitions";
|
||||
import {
|
||||
executeCoachTool,
|
||||
type CoachToolResult,
|
||||
type CoachToolTurnContext,
|
||||
} from "./executor";
|
||||
import { executeCoachTool } from "./executor";
|
||||
import type { CoachToolResult, CoachToolTurnContext } from "./run/types";
|
||||
|
||||
export const COMPARE_SERIES_TOOL_NAME = "compare_series";
|
||||
|
||||
|
||||
+20
-1273
File diff suppressed because it is too large.
Load diff
@@ -7,7 +7,7 @@
|
||||
* stays alive as the no-tools fallback (per-provider `supportsTools`).
|
||||
*/
|
||||
export { COACH_TOOL_DEFS, parseCoachToolArgs } from "./definitions";
|
||||
export type { CoachToolTrace } from "./executor";
|
||||
export type { CoachToolTrace } from "./run/types";
|
||||
export {
|
||||
buildCoachDataInventory,
|
||||
renderDataInventory,
|
||||
|
||||
@@ -65,12 +65,12 @@ import {
|
||||
} from "@/lib/ai/coach/activity/catalog";
|
||||
import { screenCheckpoint } from "@/lib/ai/coach/activity/screen";
|
||||
import { parseCoachToolArgs, type CoachToolName } from "./definitions";
|
||||
import {
|
||||
executeCoachTool,
|
||||
type CoachToolResult,
|
||||
type CoachToolTrace,
|
||||
type CoachToolTurnContext,
|
||||
} from "./executor";
|
||||
import { executeCoachTool } from "./executor";
|
||||
import type {
|
||||
CoachToolResult,
|
||||
CoachToolTrace,
|
||||
CoachToolTurnContext,
|
||||
} from "./run/types";
|
||||
import {
|
||||
COMPARE_SERIES_TOOL_NAME,
|
||||
compareSeriesArgsSchema,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*
|
||||
* Pure: no database, no provider.
|
||||
*/
|
||||
import type { CoachToolResult } from "./executor";
|
||||
import type { CoachToolResult } from "./run/types";
|
||||
|
||||
/** Misses that tell the model nothing new about the record. */
|
||||
const EMPTY_REASONS: ReadonlySet<string> = new Set([
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { resolveLocaleForUser } from "@/lib/i18n/user-locale";
|
||||
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
|
||||
import { getCorrelationsArgsSchema } from "../definitions";
|
||||
import { readCoachCorrelations } from "../correlations-read";
|
||||
import { badArgs } from "./shared";
|
||||
import type { CoachToolResult } from "./types";
|
||||
|
||||
export async function getCorrelations(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
reach: CoachHistoryReach,
|
||||
): 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.
|
||||
//
|
||||
// No availability probe here, and no window either: a discovered pattern is
|
||||
// not a slice of one table — it needs two channels paired over overlapping
|
||||
// days, and the reader already reports WHY nothing survived (its own reason
|
||||
// code). "No pattern survived the correction" is an honest absence of a
|
||||
// finding, not an absence of data, and nothing older would change it.
|
||||
// The reader's language, from their stored preference — the executor is
|
||||
// reached from the chat loop and from MCP, neither of which carries a request
|
||||
// of its own. The notes below are finished sentences; some MCP clients show
|
||||
// them to the person verbatim rather than letting the model re-word them.
|
||||
const locale = await resolveLocaleForUser(userId);
|
||||
// The scan reads 180 days, or the lookback limit when that is shorter.
|
||||
const result = await readCoachCorrelations(userId, locale, { reach });
|
||||
if (!result.present) {
|
||||
return { present: false, reason: result.reason ?? "no_pattern" };
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
data: {
|
||||
...(result.drivers ? { drivers: result.drivers } : {}),
|
||||
...(result.coincident ? { coincident: result.coincident } : {}),
|
||||
pairsTested: result.pairsTested,
|
||||
windowDays: result.windowDays,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import type { CoachScope } from "@/lib/ai/coach/types";
|
||||
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
|
||||
import {
|
||||
isBounded,
|
||||
type CoachHistoryReach,
|
||||
} from "@/lib/ai/coach/history-reach";
|
||||
import { getCycleArgsSchema } from "../definitions";
|
||||
import {
|
||||
OUTSIDE_REACH_REASON,
|
||||
cycleRecorded,
|
||||
subjectForTool,
|
||||
} from "../availability";
|
||||
import { badArgs, emptyRead, pickSection } from "./shared";
|
||||
import type { CoachToolResult } from "./types";
|
||||
|
||||
export async function getCycle(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
sharedScope: CoachScope | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getCycleArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_cycle", parsed.error);
|
||||
// The cycle block predicts from every logged cycle, so the snapshot leaves
|
||||
// it out under any lookback limit. Say why, rather than that it is absent.
|
||||
if (isBounded(reach)) {
|
||||
return (await isCycleAvailableForUser(userId)) &&
|
||||
(await cycleRecorded(userId))
|
||||
? { present: false, reason: OUTSIDE_REACH_REASON }
|
||||
: { present: false, reason: "no_data" };
|
||||
}
|
||||
// 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).
|
||||
//
|
||||
// Prefer the turn's shared full-source scope so this read
|
||||
// lands the inventory's cache entry; the empty-source scope is the fallback
|
||||
// when no shared scope is threaded.
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
userId,
|
||||
sharedScope ?? { sources: [] },
|
||||
{ reach },
|
||||
);
|
||||
const cycle = pickSection(snapshot.sections, "cycle");
|
||||
if (cycle === undefined) {
|
||||
// No row-backed subject: the cycle block is gated by the per-user toggle
|
||||
// AND the operator switch inside the builder, so an absent block is a gate
|
||||
// verdict rather than a window artefact.
|
||||
return emptyRead(
|
||||
userId,
|
||||
"cycle",
|
||||
subjectForTool("get_cycle"),
|
||||
sharedScope?.window,
|
||||
reach,
|
||||
);
|
||||
}
|
||||
return { present: true, data: cycle };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readCoachExclusions } from "@/lib/ai/coach/history-reach-read";
|
||||
import { fenceUserText } from "@/lib/ai/coach/data-fence";
|
||||
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
|
||||
import { getDayArgsSchema } from "../definitions";
|
||||
import { readDayForTool } from "../day-read";
|
||||
import { badArgs } from "./shared";
|
||||
import type { CoachToolResult } from "./types";
|
||||
|
||||
/**
|
||||
* #613: one local day across the record, projected for a model:
|
||||
* never the person's life events or notes. A day older than the lookback
|
||||
* limit is refused before anything is read.
|
||||
*/
|
||||
export async function getDay(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
reach: CoachHistoryReach,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getDayArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_day", parsed.error);
|
||||
const result = await readDayForTool({
|
||||
userId,
|
||||
date: parsed.data.date,
|
||||
reach,
|
||||
// The person's Coach exclusions hold here as in every other read.
|
||||
loadExcluded: () => readCoachExclusions(userId),
|
||||
text: fenceUserText,
|
||||
});
|
||||
if (result.present) return { present: true, data: result.data };
|
||||
return { present: false, reason: result.reason };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
|
||||
import { getEnvironmentArgsSchema } from "../definitions";
|
||||
import { readEnvironmentForTool } from "../environment-read";
|
||||
import { badArgs } from "./shared";
|
||||
import type { CoachToolResult } from "./types";
|
||||
|
||||
/**
|
||||
* #615: the stored days' weather and air quality, without any
|
||||
* location. Module-gated inside the reader (off answers `module_disabled`
|
||||
* without reading a row) and clamped to the lookback limit.
|
||||
*/
|
||||
export async function getEnvironment(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getEnvironmentArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_environment", parsed.error);
|
||||
const result = await readEnvironmentForTool({
|
||||
userId,
|
||||
window: parsed.data.window ?? fallbackWindow ?? "last30days",
|
||||
reach,
|
||||
});
|
||||
if (result.present) return { present: true, data: result.data };
|
||||
return {
|
||||
present: false,
|
||||
reason: result.reason,
|
||||
...(result.searchedWindow ? { searchedWindow: result.searchedWindow } : {}),
|
||||
...(result.available ? { available: result.available } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
|
||||
import { getGlucosePanelArgsSchema } from "../definitions";
|
||||
import { subjectForTool } from "../availability";
|
||||
import {
|
||||
badArgs,
|
||||
emptyRead,
|
||||
limitWindow,
|
||||
pickSection,
|
||||
readToolSnapshot,
|
||||
} from "./shared";
|
||||
import type { CoachToolResult } from "./types";
|
||||
|
||||
export async function getGlucosePanel(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
sharedScope: CoachScope | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getGlucosePanelArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_glucose_panel", parsed.error);
|
||||
const snapshot = await readToolSnapshot(
|
||||
userId,
|
||||
["glucose"],
|
||||
["glucose"],
|
||||
parsed.data.window,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
);
|
||||
const section = pickSection(snapshot.sections, "glucose");
|
||||
if (section === undefined) {
|
||||
return emptyRead(
|
||||
userId,
|
||||
"glucose",
|
||||
subjectForTool("get_glucose_panel"),
|
||||
limitWindow(parsed.data.window ?? fallbackWindow, reach),
|
||||
reach,
|
||||
);
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
data: section,
|
||||
grounding: snapshot.referenceGrounding ?? undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import { buildIllnessScores } from "@/lib/ai/coach/illness-snapshot";
|
||||
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import {
|
||||
isBounded,
|
||||
type CoachHistoryReach,
|
||||
} from "@/lib/ai/coach/history-reach";
|
||||
import { getIllnessRecoveryArgsSchema } from "../definitions";
|
||||
import {
|
||||
OUTSIDE_REACH_REASON,
|
||||
illnessBeyondReach,
|
||||
subjectForTool,
|
||||
} from "../availability";
|
||||
import { emptyRead, limitWindow, pickSection, scopeFor } from "./shared";
|
||||
import type { CoachToolResult } from "./types";
|
||||
|
||||
export async function getIllnessRecovery(
|
||||
userId: string,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
sharedScope: CoachScope | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
): 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.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Prefer the turn's shared full-source scope (which already
|
||||
// carries hrv/resting_hr/vo2_max + illness + derived) so this read lands the
|
||||
// inventory's cache entry; the narrow scope is the window-mismatch fallback.
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
userId,
|
||||
scopeFor(
|
||||
["hrv", "resting_hr", "vo2_max"],
|
||||
fallbackWindow,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
),
|
||||
{ reach },
|
||||
);
|
||||
const illness = pickSection(snapshot.sections, "illness");
|
||||
const derived = pickSection(snapshot.sections, "derived");
|
||||
const dayStrain = pickSection(snapshot.sections, "dayStrain");
|
||||
const trajectory = pickSection(snapshot.sections, "trajectory");
|
||||
// The computed illness retrospective the card shows
|
||||
// (recovery-gap, gap-driver, nadir, pre-onset, red flags) for the most
|
||||
// relevant episode. Read-only, coverage-gated (null when the engine
|
||||
// withholds), and the SAME engine the card + the red-flag notifier run, so
|
||||
// the Coach restates the numbers the user sees rather than the composite.
|
||||
const illnessScores = await buildIllnessScores(userId, undefined, reach);
|
||||
if (
|
||||
illness === undefined &&
|
||||
derived === undefined &&
|
||||
dayStrain === undefined &&
|
||||
trajectory === undefined &&
|
||||
illnessScores === null
|
||||
) {
|
||||
// No row-backed subject: illness episodes + the recovery composites are
|
||||
// computed / module-gated, not a window slice of one table, so there is
|
||||
// nothing an availability probe could establish. Honest absence, unless
|
||||
// a lookback limit is what hid the episodes.
|
||||
if (isBounded(reach) && (await illnessBeyondReach(userId, reach))) {
|
||||
return { present: false, reason: OUTSIDE_REACH_REASON };
|
||||
}
|
||||
return emptyRead(
|
||||
userId,
|
||||
"illness_recovery",
|
||||
subjectForTool("get_illness_recovery"),
|
||||
limitWindow(fallbackWindow, reach),
|
||||
reach,
|
||||
);
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
data: {
|
||||
...(illness !== undefined ? { illness } : {}),
|
||||
...(illnessScores !== null ? { illnessScores } : {}),
|
||||
...(derived !== undefined ? { derived } : {}),
|
||||
...(dayStrain !== undefined ? { dayStrain } : {}),
|
||||
...(trajectory !== undefined ? { trajectory } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import { buildLabsSnapshotBlock } from "@/lib/ai/coach/labs-snapshot";
|
||||
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
|
||||
import { getLabsArgsSchema } from "../definitions";
|
||||
import { subjectForTool } from "../availability";
|
||||
import { badArgs, emptyRead, pickSection, scopeFor } from "./shared";
|
||||
import type { CoachToolResult } from "./types";
|
||||
|
||||
export async function getLabs(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
sharedScope: CoachScope | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
): 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.
|
||||
//
|
||||
// The labs block is intentionally window-AGNOSTIC: the latest
|
||||
// reading per biomarker with its summarised history, over the whole record
|
||||
// or the user's lookback limit (#1263). 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.
|
||||
//
|
||||
// Prefer the turn's shared full-source scope so this read
|
||||
// lands the inventory's cache entry; fall back to a tight empty-source scope
|
||||
// (labs ride unconditionally) when no shared scope or a window mismatch.
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
userId,
|
||||
scopeFor([], fallbackWindow, fallbackWindow, sharedScope, reach),
|
||||
{ reach },
|
||||
);
|
||||
const labs = pickSection(snapshot.sections, "labs") as
|
||||
{ recent?: Array<{ name?: string; analyte?: string }> } | undefined;
|
||||
if (labs === undefined) {
|
||||
// The labs read is window-AGNOSTIC: it searched the whole record, or up to
|
||||
// the lookback limit when there is one. A panel older than the limit must
|
||||
// not read as "no labs on file", so the limit is the searched window.
|
||||
return emptyRead(
|
||||
userId,
|
||||
"labs",
|
||||
subjectForTool("get_labs"),
|
||||
reach.window,
|
||||
reach,
|
||||
);
|
||||
}
|
||||
|
||||
const analyte = parsed.data.analyte?.trim().toLowerCase();
|
||||
if (analyte) {
|
||||
// #1263 — a named read carries each matching marker's history over the
|
||||
// whole record (or the limit), summarised. Read on its own rather than
|
||||
// out of the snapshot, whose labs block holds the latest values only.
|
||||
const named = await buildLabsSnapshotBlock(userId, new Date(), reach, {
|
||||
analyte,
|
||||
});
|
||||
if (!named) return { present: false, reason: "analyte_not_found" };
|
||||
return { present: true, data: named };
|
||||
}
|
||||
return { present: true, data: labs };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
|
||||
import { getMedicationComplianceArgsSchema } from "../definitions";
|
||||
import { subjectForTool } from "../availability";
|
||||
import {
|
||||
badArgs,
|
||||
emptyRead,
|
||||
limitWindow,
|
||||
pickSection,
|
||||
readToolSnapshot,
|
||||
} from "./shared";
|
||||
import type { CoachToolResult } from "./types";
|
||||
|
||||
export async function getMedicationCompliance(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
sharedScope: CoachScope | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getMedicationComplianceArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) {
|
||||
return badArgs("get_medication_compliance", parsed.error);
|
||||
}
|
||||
const snapshot = await readToolSnapshot(
|
||||
userId,
|
||||
["compliance"],
|
||||
["compliance", "weeklyContext"],
|
||||
parsed.data.window,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
);
|
||||
const compliance = pickSection(snapshot.sections, "compliance");
|
||||
// GLP-1 context rides the `weeklyContext` block.
|
||||
const weeklyContext = pickSection(snapshot.sections, "weeklyContext") as
|
||||
{ glp1?: unknown } | undefined;
|
||||
const glp1 = weeklyContext?.glp1;
|
||||
if (compliance === undefined && glp1 === undefined) {
|
||||
return emptyRead(
|
||||
userId,
|
||||
"compliance",
|
||||
subjectForTool("get_medication_compliance"),
|
||||
limitWindow(parsed.data.window ?? fallbackWindow, reach),
|
||||
reach,
|
||||
);
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
data: {
|
||||
...(compliance !== undefined ? { compliance } : {}),
|
||||
...(glp1 !== undefined ? { glp1 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
|
||||
import { buildCoachSourceSnapshot } from "@/lib/ai/coach/source-snapshot";
|
||||
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
|
||||
import { getMetricSeriesArgsSchema } from "../definitions";
|
||||
import {
|
||||
COACH_SOURCE_SNAPSHOT_KEY,
|
||||
METRIC_SERIES_EXCLUDED_SOURCES,
|
||||
} from "../source-keys";
|
||||
import { subjectForTool } from "../availability";
|
||||
import {
|
||||
badArgs,
|
||||
emptyRead,
|
||||
limitWindow,
|
||||
pickSection,
|
||||
readToolSnapshot,
|
||||
} from "./shared";
|
||||
import type { CoachToolResult } from "./types";
|
||||
|
||||
export async function getMetricSeries(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
sharedScope: CoachScope | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
sourceSnapshot: boolean,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getMetricSeriesArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_metric_series", parsed.error);
|
||||
const { metric, window } = parsed.data;
|
||||
|
||||
// Glucose / workouts / compliance have dedicated tools (or are deferred);
|
||||
// refuse to answer them here so the model is pointed at the right tool
|
||||
// rather than getting a fabricated-shape miss.
|
||||
if (METRIC_SERIES_EXCLUDED_SOURCES.has(metric)) {
|
||||
return {
|
||||
present: false,
|
||||
reason:
|
||||
metric === "glucose"
|
||||
? "use_get_glucose_panel"
|
||||
: metric === "compliance"
|
||||
? "use_get_medication_compliance"
|
||||
: "unsupported_metric",
|
||||
};
|
||||
}
|
||||
|
||||
const sectionKey = COACH_SOURCE_SNAPSHOT_KEY[metric];
|
||||
if (!sectionKey) {
|
||||
return { present: false, reason: "unsupported_metric" };
|
||||
}
|
||||
|
||||
// An MCP read needs one section and nothing else, so it is built from that
|
||||
// source's own rows: the same section, without the context blocks a Coach
|
||||
// turn carries. See `source-snapshot.ts`. Only MCP asks for it, and MCP
|
||||
// reads without the Coach's lookback limit, so it takes no reach.
|
||||
const snapshot = sourceSnapshot
|
||||
? await buildCoachSourceSnapshot(userId, metric, window ?? fallbackWindow)
|
||||
: await readToolSnapshot(
|
||||
userId,
|
||||
[metric],
|
||||
[sectionKey],
|
||||
window,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
);
|
||||
const section = pickSection(snapshot.sections, sectionKey);
|
||||
if (section === undefined) {
|
||||
return emptyRead(
|
||||
userId,
|
||||
metric,
|
||||
subjectForTool("get_metric_series", metric),
|
||||
limitWindow(window ?? fallbackWindow, reach),
|
||||
reach,
|
||||
);
|
||||
}
|
||||
if (sourceSnapshot) {
|
||||
// MCP: the section exactly as it always was, without the figure check
|
||||
// below: an MCP client reads `present` by its own contract (absence is
|
||||
// "not recorded"), and its output stays byte for byte what it was.
|
||||
return {
|
||||
present: true,
|
||||
data: { metric, section },
|
||||
grounding: snapshot.referenceGrounding ?? undefined,
|
||||
};
|
||||
}
|
||||
// A block without a single figure (a bare unit, an `omitted` marker) is
|
||||
// not an answer: "present" with nothing in it is what the model then
|
||||
// repeats to the person. Say the read failed instead.
|
||||
if (!carriesFigures(section)) {
|
||||
annotate({
|
||||
action: { name: "coach.tool.section_without_values" },
|
||||
meta: { tool: "get_metric_series", metric },
|
||||
});
|
||||
return { present: false, reason: "retrieval_failed" };
|
||||
}
|
||||
// Outside MCP the read is a Coach snapshot, which counts what it read.
|
||||
const counts = (snapshot as Partial<CoachSnapshotResult>).provenance
|
||||
?.counts as Record<string, number | undefined> | undefined;
|
||||
const readings = counts?.[metric];
|
||||
const effectiveWindow = limitWindow(window ?? fallbackWindow, reach);
|
||||
const condensed = isRecord(section) && Array.isArray(section.condensed);
|
||||
const long = effectiveWindow === "allTime" || effectiveWindow === "lastYear";
|
||||
return {
|
||||
present: true,
|
||||
data: {
|
||||
metric,
|
||||
section,
|
||||
...(typeof readings === "number" ? { readings } : {}),
|
||||
...(long || condensed ? { coverage: SERIES_COVERAGE_NOTE } : {}),
|
||||
},
|
||||
grounding: snapshot.referenceGrounding ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What a long or condensed series read carries, and where the rest is. Rides
|
||||
* the result so the model switches to the table tool on its own instead of
|
||||
* asking the person whether it should.
|
||||
*/
|
||||
const SERIES_COVERAGE_NOTE =
|
||||
"Daily values for the newest 14 days, weekly means before that, back at most 12 months; " +
|
||||
"timeline.coarse (when present) adds monthly and yearly means for older history. " +
|
||||
"A 'condensed' list names what was cut to fit. A 'summary', when present, is taken from the series' daily values over the whole read ('from' to 'to', 'days'): " +
|
||||
"the first and last day, the lowest and highest day, the mean of the days, and 'change' = 'latestWeekMean' minus 'firstWeekMean' (the latest 7 calendar days against the first 7). " +
|
||||
"For the full history or a finer table, call get_metric_table yourself in this answer (window allTime, granularity month or week); do not ask the person first.";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields that describe a block rather than measure anything: the freshness
|
||||
* stamp (`asOf.daysAgo` is a number, but no reading), and the scope echo. A
|
||||
* block trimmed to `{ unit, asOf }` holds no figure.
|
||||
*/
|
||||
const METADATA_FIELDS: ReadonlySet<string> = new Set(["asOf", "scope"]);
|
||||
|
||||
/** Whether a payload holds at least one finite measured number anywhere. */
|
||||
function carriesFigures(value: unknown): boolean {
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value)) return value.some(carriesFigures);
|
||||
if (isRecord(value)) {
|
||||
return Object.entries(value).some(
|
||||
([key, child]) => !METADATA_FIELDS.has(key) && carriesFigures(child),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import type {
|
||||
CoachResultTable,
|
||||
CoachScope,
|
||||
CoachScopeWindow,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import {
|
||||
METRIC_TABLE_EXCLUDED_SOURCES,
|
||||
compareWithCurrent,
|
||||
readMetricTable,
|
||||
summariseTable,
|
||||
} from "@/lib/ai/coach/results/metric-table-tool";
|
||||
import { DEFAULT_WINDOW } from "@/lib/ai/coach/snapshot-cache";
|
||||
import {
|
||||
clampWindow,
|
||||
type CoachHistoryReach,
|
||||
} from "@/lib/ai/coach/history-reach";
|
||||
import { getMetricTableArgsSchema } from "../definitions";
|
||||
import { OUTSIDE_REACH_REASON, subjectForTool } from "../availability";
|
||||
import { scopeAdmits, tableRangeWithinReach } from "./prior-results";
|
||||
import { badArgs, emptyRead, limitWindow } from "./shared";
|
||||
import type { CoachToolResult, CoachToolTurnContext } from "./types";
|
||||
|
||||
export async function getMetricTable(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
sharedScope: CoachScope | undefined,
|
||||
turn: CoachToolTurnContext | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getMetricTableArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_metric_table", parsed.error);
|
||||
const { metric, granularity } = parsed.data;
|
||||
// All time becomes the lookback limit; a narrower window stays.
|
||||
const window = clampWindow(
|
||||
parsed.data.window ?? fallbackWindow ?? DEFAULT_WINDOW,
|
||||
reach,
|
||||
);
|
||||
const period =
|
||||
window === "allTime" ? "current" : (parsed.data.period ?? "current");
|
||||
|
||||
const excluded = METRIC_TABLE_EXCLUDED_SOURCES[metric];
|
||||
if (excluded) return { present: false, reason: excluded };
|
||||
|
||||
// An earlier period is read only when all of it lies inside the limit: the
|
||||
// previous window or the same window a year ago, cut short, would be a
|
||||
// different comparison under the same name.
|
||||
if (
|
||||
period !== "current" &&
|
||||
!tableRangeWithinReach(window, period, reach, turn?.now)
|
||||
) {
|
||||
return {
|
||||
present: false,
|
||||
reason: OUTSIDE_REACH_REASON,
|
||||
searchedWindow: window,
|
||||
};
|
||||
}
|
||||
|
||||
// Same gate as every other read: a module switched off, or a metric the
|
||||
// person excluded from the Coach, is not read here either.
|
||||
const gate = await buildCoachSnapshot(
|
||||
userId,
|
||||
sharedScope ?? {
|
||||
sources: [metric],
|
||||
window: limitWindow(fallbackWindow, reach),
|
||||
},
|
||||
{ reach },
|
||||
);
|
||||
if (!scopeAdmits(gate.sections, metric)) {
|
||||
return {
|
||||
present: false,
|
||||
reason: "unavailable_in_scope",
|
||||
searchedWindow: window,
|
||||
};
|
||||
}
|
||||
|
||||
const timeZone = await resolveUserTimezone(userId);
|
||||
const table = await readMetricTable({
|
||||
userId,
|
||||
metric,
|
||||
window,
|
||||
period,
|
||||
granularity,
|
||||
timeZone,
|
||||
locale: turn?.locale ?? "en",
|
||||
ref: "r0",
|
||||
units: gate.units,
|
||||
now: turn?.now,
|
||||
});
|
||||
if (!table) {
|
||||
return emptyRead(
|
||||
userId,
|
||||
metric,
|
||||
subjectForTool("get_metric_table", metric),
|
||||
window,
|
||||
reach,
|
||||
);
|
||||
}
|
||||
// An earlier window is read to be compared: read the current one too and
|
||||
// hand the model both, and the change between them, in one summary.
|
||||
let comparison: Record<string, unknown> | null = null;
|
||||
if (period !== "current") {
|
||||
const current = await readMetricTable({
|
||||
userId,
|
||||
metric,
|
||||
window,
|
||||
period: "current",
|
||||
granularity,
|
||||
timeZone,
|
||||
locale: turn?.locale ?? "en",
|
||||
ref: "r0",
|
||||
units: gate.units,
|
||||
now: turn?.now,
|
||||
});
|
||||
comparison = current ? compareWithCurrent(table, current) : null;
|
||||
}
|
||||
const summarise = (t: CoachResultTable) => ({
|
||||
...summariseTable(t),
|
||||
...(comparison ? { comparison } : {}),
|
||||
});
|
||||
const ref = turn?.refs.next() ?? null;
|
||||
if (!ref) {
|
||||
// Outside a chat turn, or past the eighth table: the model still gets
|
||||
// the figures, there is just no table to name.
|
||||
return { present: true, data: summarise(table) };
|
||||
}
|
||||
const named = { ...table, ref };
|
||||
return {
|
||||
present: true,
|
||||
resultRef: ref,
|
||||
data: summarise(named),
|
||||
table: named,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
|
||||
import { getSleepArgsSchema } from "../definitions";
|
||||
import { subjectForTool } from "../availability";
|
||||
import {
|
||||
badArgs,
|
||||
emptyRead,
|
||||
limitWindow,
|
||||
pickSection,
|
||||
readToolSnapshot,
|
||||
} from "./shared";
|
||||
import type { CoachToolResult } from "./types";
|
||||
|
||||
export async function getSleep(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
sharedScope: CoachScope | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getSleepArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_sleep", parsed.error);
|
||||
const snapshot = await readToolSnapshot(
|
||||
userId,
|
||||
["sleep"],
|
||||
["sleep", "sleepRhythm"],
|
||||
parsed.data.window,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
);
|
||||
const nights = pickSection(snapshot.sections, "sleep");
|
||||
const rhythm = pickSection(snapshot.sections, "sleepRhythm");
|
||||
if (nights === undefined && rhythm === undefined) {
|
||||
return emptyRead(
|
||||
userId,
|
||||
"sleep",
|
||||
subjectForTool("get_sleep"),
|
||||
limitWindow(parsed.data.window ?? fallbackWindow, reach),
|
||||
reach,
|
||||
);
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
data: {
|
||||
...(nights !== undefined ? { nights } : {}),
|
||||
...(rhythm !== undefined ? { rhythm } : {}),
|
||||
},
|
||||
grounding: snapshot.referenceGrounding ?? undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
|
||||
import { getWorkoutsArgsSchema } from "../definitions";
|
||||
import { subjectForTool } from "../availability";
|
||||
import {
|
||||
badArgs,
|
||||
emptyRead,
|
||||
limitWindow,
|
||||
pickSection,
|
||||
readToolSnapshot,
|
||||
} from "./shared";
|
||||
import type { CoachToolResult } from "./types";
|
||||
|
||||
export async function getWorkouts(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
sharedScope: CoachScope | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
): 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 readToolSnapshot(
|
||||
userId,
|
||||
["workouts"],
|
||||
["workouts"],
|
||||
parsed.data.window,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
);
|
||||
const workouts = pickSection(snapshot.sections, "workouts");
|
||||
if (workouts === undefined) {
|
||||
return emptyRead(
|
||||
userId,
|
||||
"workouts",
|
||||
subjectForTool("get_workouts"),
|
||||
limitWindow(parsed.data.window ?? fallbackWindow, reach),
|
||||
reach,
|
||||
);
|
||||
}
|
||||
return { present: true, data: workouts };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Which earlier result tables a turn may name and show again, under the
|
||||
* same gates and lookback limit a fresh read passes.
|
||||
*/
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate";
|
||||
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
import type {
|
||||
CoachResultPeriod,
|
||||
CoachScope,
|
||||
CoachScopeSource,
|
||||
CoachScopeWindow,
|
||||
CoachStepDomain,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
|
||||
import { resolveTableRange } from "@/lib/ai/coach/results/metric-table-tool";
|
||||
import type { PriorResultTurn } from "@/lib/ai/coach/results/refs";
|
||||
import {
|
||||
exceedsReach,
|
||||
isBounded,
|
||||
withinReach,
|
||||
type CoachHistoryReach,
|
||||
} from "@/lib/ai/coach/history-reach";
|
||||
|
||||
/**
|
||||
* True when the snapshot builder admitted `metric` for this account: the
|
||||
* resolved scope it pins on every snapshot is the source set left after the
|
||||
* module switches and the person's own exclusions. The table tool reads
|
||||
* rows the builder does not, so it asks the builder's gate first rather
|
||||
* than keeping a second copy of it.
|
||||
*/
|
||||
export function scopeAdmits(
|
||||
sections: Record<string, unknown>,
|
||||
metric: CoachScopeSource,
|
||||
): boolean {
|
||||
const scope = sections.scope as { sources?: unknown } | undefined;
|
||||
return Array.isArray(scope?.sources) && scope.sources.includes(metric);
|
||||
}
|
||||
|
||||
/**
|
||||
* The earlier tables a turn may name and show again: those whose metric the
|
||||
* same gate would read now. With a conversation scope that is the scope's
|
||||
* sources less the person's exclusions and switched-off modules; without
|
||||
* one, every metric not excluded. The exclusion is the snapshot's own
|
||||
* (`coachExclusions`, `admitCoachSources`), so a metric the person excluded
|
||||
* after a table was stored is neither listed for the model nor sent to it.
|
||||
* Tables of other domains (labs) answer to their module when read.
|
||||
*
|
||||
* The lookback limit applies the same way: a table whose range reaches past
|
||||
* it (stored before the limit was set or narrowed) is not listed, because
|
||||
* its line hands the model the window and the row count of history the
|
||||
* Coach may no longer read. `show_result` refuses it on the same test.
|
||||
*/
|
||||
export async function admittedPriorResults(args: {
|
||||
userId: string;
|
||||
prefs: Pick<CoachPrefs, "excludeMetrics">;
|
||||
scope: CoachScope | undefined;
|
||||
reach: CoachHistoryReach;
|
||||
prior: readonly PriorResultTurn[];
|
||||
}): Promise<PriorResultTurn[]> {
|
||||
if (args.prior.length === 0) return [];
|
||||
const excluded = coachExclusions(
|
||||
args.prefs,
|
||||
await resolveModuleMap(args.userId),
|
||||
);
|
||||
const scoped =
|
||||
args.scope?.sources && args.scope.sources.length > 0
|
||||
? admitCoachSources(args.scope.sources, excluded)
|
||||
: null;
|
||||
const admits = (domain: CoachStepDomain) =>
|
||||
!isCoachScopeSource(domain) ||
|
||||
(scoped
|
||||
? scoped.has(domain)
|
||||
: admitCoachSources([domain], excluded).size > 0);
|
||||
return args.prior
|
||||
.map((turn) => ({
|
||||
...turn,
|
||||
results: turn.results.filter(
|
||||
(meta) =>
|
||||
admits(meta.source.domain) &&
|
||||
tableRangeWithinReach(
|
||||
meta.source.window,
|
||||
meta.source.period ?? "current",
|
||||
args.reach,
|
||||
),
|
||||
),
|
||||
}))
|
||||
.filter((turn) => turn.results.length > 0);
|
||||
}
|
||||
|
||||
/** True for a step domain the snapshot scope gates (a metric, not labs). */
|
||||
export function isCoachScopeSource(
|
||||
domain: CoachStepDomain,
|
||||
): domain is CoachScopeSource {
|
||||
return Object.hasOwn(COACH_SOURCE_MEASUREMENT_TYPES, domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a table's whole range lies inside the lookback limit. An earlier
|
||||
* period needs twice its window (previous) or a year more (a year ago), so a
|
||||
* range either fits with room to spare or does not fit at all; the UTC day
|
||||
* edges are close enough.
|
||||
*/
|
||||
export function tableRangeWithinReach(
|
||||
window: CoachScopeWindow,
|
||||
period: CoachResultPeriod,
|
||||
reach: CoachHistoryReach,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!isBounded(reach)) return true;
|
||||
if (exceedsReach(window, reach)) return false;
|
||||
const range = resolveTableRange({ window, period, timeZone: "UTC", now });
|
||||
return withinReach(range.from, reach, now);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import type { CoachResultTable, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import { readMetricTable } from "@/lib/ai/coach/results/metric-table-tool";
|
||||
import {
|
||||
projectCompliance,
|
||||
projectLabs,
|
||||
projectWorkouts,
|
||||
} from "@/lib/ai/coach/results/projections";
|
||||
import { COACH_RESULT_TITLE_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { DEFAULT_WINDOW } from "@/lib/ai/coach/snapshot-cache";
|
||||
import {
|
||||
clampWindow,
|
||||
type CoachHistoryReach,
|
||||
} from "@/lib/ai/coach/history-reach";
|
||||
import type { CoachToolName } from "../definitions";
|
||||
import type { CoachToolResult, CoachToolTurnContext } from "./types";
|
||||
|
||||
/**
|
||||
* The table an older tool's present result projects to, named with the
|
||||
* turn's next ref. A projection that fails leaves the answer as it was: the
|
||||
* table is a view of the result, never a condition for it.
|
||||
*/
|
||||
export async function withProjectedTable(
|
||||
name: CoachToolName,
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
result: CoachToolResult,
|
||||
turn: CoachToolTurnContext,
|
||||
reach: CoachHistoryReach,
|
||||
): Promise<CoachToolResult> {
|
||||
const argWindow =
|
||||
rawArgs !== null &&
|
||||
typeof rawArgs === "object" &&
|
||||
typeof (rawArgs as { window?: unknown }).window === "string"
|
||||
? ((rawArgs as { window: CoachScopeWindow }).window as CoachScopeWindow)
|
||||
: undefined;
|
||||
const window = clampWindow(
|
||||
argWindow ?? fallbackWindow ?? DEFAULT_WINDOW,
|
||||
reach,
|
||||
);
|
||||
try {
|
||||
let table: CoachResultTable | null = null;
|
||||
switch (name) {
|
||||
case "get_workouts":
|
||||
table = projectWorkouts(result.data, {
|
||||
ref: "r0",
|
||||
locale: turn.locale,
|
||||
window,
|
||||
timeZone: "UTC",
|
||||
});
|
||||
break;
|
||||
case "get_medication_compliance":
|
||||
table = projectCompliance(result.data, {
|
||||
ref: "r0",
|
||||
locale: turn.locale,
|
||||
window,
|
||||
timeZone: "UTC",
|
||||
});
|
||||
break;
|
||||
case "get_labs":
|
||||
table = projectLabs(result.data, {
|
||||
ref: "r0",
|
||||
locale: turn.locale,
|
||||
window,
|
||||
timeZone: await resolveUserTimezone(userId),
|
||||
});
|
||||
break;
|
||||
case "get_sleep": {
|
||||
const read = await readMetricTable({
|
||||
userId,
|
||||
metric: "sleep",
|
||||
window,
|
||||
period: "current",
|
||||
granularity: "day",
|
||||
timeZone: await resolveUserTimezone(userId),
|
||||
locale: turn.locale,
|
||||
ref: "r0",
|
||||
// Sleep is read in minutes for every reader; no preference applies.
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: turn.now,
|
||||
});
|
||||
table =
|
||||
read && read.source.granularity === "day"
|
||||
? {
|
||||
...read,
|
||||
source: { ...read.source, tool: "get_sleep" },
|
||||
titleKey: COACH_RESULT_TITLE_KEYS.sleepByNight,
|
||||
title: getServerTranslator(turn.locale).t(
|
||||
COACH_RESULT_TITLE_KEYS.sleepByNight,
|
||||
),
|
||||
}
|
||||
: read && {
|
||||
...read,
|
||||
source: { ...read.source, tool: "get_sleep" },
|
||||
};
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return result;
|
||||
}
|
||||
if (!table) return result;
|
||||
const ref = turn.refs.next();
|
||||
if (!ref) return result;
|
||||
return { ...result, resultRef: ref, table: { ...table, ref } };
|
||||
} catch (err) {
|
||||
annotate({
|
||||
action: { name: "coach.result.projection_failed" },
|
||||
meta: {
|
||||
tool: name,
|
||||
reason: err instanceof Error ? err.name : "unknown",
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* What every Coach tool executor shares: the snapshot it slices its block
|
||||
* out of, the window under the lookback limit, and the two grounded misses
|
||||
* (bad arguments, an empty window read).
|
||||
*/
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import { DEFAULT_WINDOW } from "@/lib/ai/coach/snapshot-cache";
|
||||
import {
|
||||
clampWindow,
|
||||
type CoachHistoryReach,
|
||||
} from "@/lib/ai/coach/history-reach";
|
||||
import {
|
||||
resolveEmptyRead,
|
||||
type CoachAvailabilitySubject,
|
||||
} from "../availability";
|
||||
import type { CoachToolResult } from "./types";
|
||||
|
||||
export function pickSection(
|
||||
sections: Record<string, unknown>,
|
||||
key: string,
|
||||
): unknown | undefined {
|
||||
const value = sections[key];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool's window under the lookback limit: whatever the call asks for, it
|
||||
* reads no further back than the limit. `undefined` stays undefined (the
|
||||
* builder default, 30 days, is inside every limit but the 7-day one, and the
|
||||
* builder clamps that itself).
|
||||
*/
|
||||
export function limitWindow(
|
||||
window: CoachScopeWindow | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
): CoachScopeWindow | undefined {
|
||||
return window === undefined ? undefined : clampWindow(window, reach);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the scope a tool reads its snapshot under.
|
||||
*
|
||||
* When the route handed us a `sharedScope` (the full-source
|
||||
* inventory build for this turn) AND the tool's effective window matches the
|
||||
* shared window, read under the SHARED scope key so the per-tool read lands the
|
||||
* 60s LRU hit the inventory already primed (the section it needs is present in
|
||||
* the full-source build). Otherwise — no shared scope, or a window override —
|
||||
* fall back to the tight per-source scope (a correct, distinct build).
|
||||
*
|
||||
* The window default chain is unchanged: `args.window` wins, else the
|
||||
* conversation's `fallbackWindow`, else the builder default.
|
||||
*/
|
||||
export function scopeFor(
|
||||
sources: CoachScope["sources"],
|
||||
window: CoachScopeWindow | undefined,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
sharedScope: CoachScope | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
): CoachScope {
|
||||
const effectiveWindow = limitWindow(window ?? fallbackWindow, reach);
|
||||
if (sharedScope && sharedScope.window === effectiveWindow) {
|
||||
// Same window as the inventory's full-source build → reuse its cache entry.
|
||||
return sharedScope;
|
||||
}
|
||||
return {
|
||||
sources,
|
||||
window: effectiveWindow,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot a tool slices its block(s) out of.
|
||||
*
|
||||
* Reads under `scopeFor` first, which lands on the turn's shared full-source
|
||||
* build when the windows match. That build is cut to the prompt budget for a
|
||||
* prompt this read never sends: on an account with many synced series the
|
||||
* budget pass collapses whole blocks to an `{ omitted }` marker, and slicing
|
||||
* the marker out answered `present: true` with no figure in it. So when a
|
||||
* block this read needs was cut there, it is read again from its own sources
|
||||
* at the same window, where it is the block that was asked for.
|
||||
*/
|
||||
export async function readToolSnapshot(
|
||||
userId: string,
|
||||
sources: CoachScope["sources"],
|
||||
sectionKeys: ReadonlyArray<string>,
|
||||
window: CoachScopeWindow | undefined,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
sharedScope: CoachScope | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
) {
|
||||
const scope = scopeFor(sources, window, fallbackWindow, sharedScope, reach);
|
||||
if (scope !== sharedScope) {
|
||||
return buildCoachSnapshot(userId, scope, {
|
||||
reach,
|
||||
condenseRequested: true,
|
||||
});
|
||||
}
|
||||
const snapshot = await buildCoachSnapshot(userId, scope, { reach });
|
||||
const cut = sectionKeys.filter((key) =>
|
||||
snapshot.degradedBlocks?.includes(key),
|
||||
);
|
||||
if (cut.length === 0) return snapshot;
|
||||
annotate({
|
||||
action: { name: "coach.tool.shared_block_cut" },
|
||||
meta: { blocks: cut, window: scope.window ?? DEFAULT_WINDOW },
|
||||
});
|
||||
return buildCoachSnapshot(
|
||||
userId,
|
||||
{ sources, window: scope.window },
|
||||
{ reach, condenseRequested: true },
|
||||
);
|
||||
}
|
||||
|
||||
export function badArgs(name: string, error: z.ZodError): CoachToolResult {
|
||||
annotate({
|
||||
action: { name: "coach.tool.bad_arguments" },
|
||||
meta: { tool: name, reason: "schema", issues: error.issues.length },
|
||||
});
|
||||
return { present: false, reason: "invalid_arguments" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one exit for "the window read produced no section". Never returns a bare
|
||||
* `no_data` without the probe having confirmed the record is empty — see
|
||||
* `availability.ts` for the four states it can resolve to.
|
||||
*/
|
||||
export function emptyRead(
|
||||
userId: string,
|
||||
domain: string,
|
||||
subject: CoachAvailabilitySubject | null,
|
||||
searchedWindow: CoachScopeWindow | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
): Promise<CoachToolResult> {
|
||||
return resolveEmptyRead({ userId, domain, subject, searchedWindow, reach });
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import { readMessageResults } from "@/lib/ai/coach/persistence";
|
||||
import type { CoachResultTable, CoachScope } from "@/lib/ai/coach/types";
|
||||
import { summariseTable } from "@/lib/ai/coach/results/metric-table-tool";
|
||||
import {
|
||||
formatPriorResultRef,
|
||||
resolvePriorResultRef,
|
||||
} from "@/lib/ai/coach/results/refs";
|
||||
import { isCoachDomainWithheld } from "@/lib/ai/coach/results/domain-module";
|
||||
import {
|
||||
COACH_RESULT_COLUMN_KEYS,
|
||||
COACH_RESULT_TITLE_KEYS,
|
||||
COACH_RESULT_UI_KEYS,
|
||||
coachDomainLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import {
|
||||
buildDistributionTable,
|
||||
deriveChartSpec,
|
||||
} from "@/lib/ai/coach/results/chart-spec";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
|
||||
import { showResultArgsSchema, SHOW_RESULT_TOOL_NAME } from "../definitions";
|
||||
import { OUTSIDE_REACH_REASON } from "../availability";
|
||||
import {
|
||||
isCoachScopeSource,
|
||||
scopeAdmits,
|
||||
tableRangeWithinReach,
|
||||
} from "./prior-results";
|
||||
import { badArgs, limitWindow } from "./shared";
|
||||
import type { CoachToolResult, CoachToolTurnContext } from "./types";
|
||||
|
||||
/**
|
||||
* A table shown again, with the chart its `view` asks for. No view: the chart
|
||||
* the server picks for any table. `table`: no chart. `chart`: a day table
|
||||
* becomes how often each range came up (a histogram); any other table, or a
|
||||
* day table with too few values, keeps the chart the server picks. The model
|
||||
* then reads the summary of what is shown.
|
||||
*/
|
||||
function withResultView(
|
||||
shown: CoachResultTable,
|
||||
view: "table" | "chart" | undefined,
|
||||
locale: Locale,
|
||||
): CoachResultTable {
|
||||
// The view is this showing's alone, never the stored copy's.
|
||||
const { view: _stored, ...table } = shown;
|
||||
if (view === "table") {
|
||||
// The table shows first; the chart stays for the toggle and the chip
|
||||
// back to it.
|
||||
const chart = deriveChartSpec(table);
|
||||
return chart
|
||||
? { ...table, chart, chartKind: chart.kind, view: "table" }
|
||||
: { ...table, chart: null, chartKind: null };
|
||||
}
|
||||
if (view === "chart") {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const distribution = buildDistributionTable(table, {
|
||||
localeTag: resolveIntlLocale(locale),
|
||||
title: t(COACH_RESULT_TITLE_KEYS.distribution, {
|
||||
metric: t(coachDomainLabelKey(table.source.domain)),
|
||||
}),
|
||||
range: t(COACH_RESULT_COLUMN_KEYS.range),
|
||||
count: t(COACH_RESULT_COLUMN_KEYS.count),
|
||||
bin: (from, to, unit) =>
|
||||
t(COACH_RESULT_UI_KEYS.histogramBin, { from, to, unit }),
|
||||
});
|
||||
if (distribution) return distribution;
|
||||
}
|
||||
const chart = deriveChartSpec(table);
|
||||
return { ...table, chart, chartKind: chart?.kind ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* `show_result` — an earlier table of THIS conversation, shown again. The
|
||||
* name resolves only against the tables the turn's own conversation holds
|
||||
* (`turn.priorResults`), and the values come through the owner- and
|
||||
* conversation-narrowed read, so a name from anywhere else is an unknown
|
||||
* result, never a lookup.
|
||||
*/
|
||||
export async function showResult(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
sharedScope: CoachScope | undefined,
|
||||
turn: CoachToolTurnContext | undefined,
|
||||
reach: CoachHistoryReach,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = showResultArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs(SHOW_RESULT_TOOL_NAME, parsed.error);
|
||||
const target = turn
|
||||
? resolvePriorResultRef(parsed.data.ref, turn.priorResults)
|
||||
: null;
|
||||
if (!turn || !target) {
|
||||
annotate({
|
||||
action: { name: "coach.result.unknown_ref" },
|
||||
meta: { inTurn: turn !== undefined },
|
||||
});
|
||||
return { present: false, reason: "unknown_result" };
|
||||
}
|
||||
|
||||
// A stored table whose range reaches past the lookback limit set since is
|
||||
// not shown again: the limit covers what the Coach shows as much as what
|
||||
// it reads.
|
||||
const { window: storedWindow, period: storedPeriod } = target.meta.source;
|
||||
if (
|
||||
storedWindow !== undefined &&
|
||||
!tableRangeWithinReach(
|
||||
storedWindow,
|
||||
storedPeriod ?? "current",
|
||||
reach,
|
||||
turn.now,
|
||||
)
|
||||
) {
|
||||
return { present: false, reason: OUTSIDE_REACH_REASON };
|
||||
}
|
||||
|
||||
// The same gate a fresh read passes, before anything is decrypted: a
|
||||
// metric the person has since excluded from the Coach, or left out of
|
||||
// this conversation's scope, is not sent to the model again either.
|
||||
const domain = target.meta.source.domain;
|
||||
if (isCoachScopeSource(domain)) {
|
||||
const gate = await buildCoachSnapshot(
|
||||
userId,
|
||||
sharedScope ?? {
|
||||
sources: [domain],
|
||||
window: limitWindow(target.meta.source.window, reach),
|
||||
},
|
||||
{ reach },
|
||||
);
|
||||
if (!scopeAdmits(gate.sections, domain)) {
|
||||
return { present: false, reason: "unavailable_in_scope" };
|
||||
}
|
||||
}
|
||||
|
||||
const modules = await resolveModuleMap(userId);
|
||||
const entries = await readMessageResults(
|
||||
userId,
|
||||
turn.conversationId,
|
||||
target.messageId,
|
||||
(domain) => isCoachDomainWithheld(domain, modules),
|
||||
);
|
||||
const entry = entries?.find((candidate) => candidate.ref === target.ref);
|
||||
if (!entry) return { present: false, reason: "unknown_result" };
|
||||
if ("withheld" in entry) {
|
||||
return {
|
||||
present: false,
|
||||
reason:
|
||||
entry.withheld === "module_disabled"
|
||||
? "module_disabled"
|
||||
: "result_unavailable",
|
||||
};
|
||||
}
|
||||
const ref = turn.refs.next();
|
||||
if (!ref) return { present: false, reason: "result_limit" };
|
||||
const table = withResultView(
|
||||
{
|
||||
...entry,
|
||||
ref,
|
||||
displayed: false,
|
||||
reusedFrom: { messageId: target.messageId, ref: target.ref },
|
||||
},
|
||||
parsed.data.view,
|
||||
turn.locale,
|
||||
);
|
||||
return {
|
||||
present: true,
|
||||
resultRef: ref,
|
||||
data: {
|
||||
shownAgain: formatPriorResultRef(
|
||||
turn.priorResults.find((p) => p.messageId === target.messageId)
|
||||
?.turnIndex ?? 0,
|
||||
target.ref,
|
||||
),
|
||||
...summariseTable(table),
|
||||
},
|
||||
table,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/** The shapes the Coach tool executor answers with and is handed. */
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { CoachResultTable } from "@/lib/ai/coach/types";
|
||||
import type {
|
||||
PriorResultTurn,
|
||||
ResultRefAllocator,
|
||||
} from "@/lib/ai/coach/results/refs";
|
||||
import type { CoachDomainAvailability } from "../availability";
|
||||
|
||||
/** A read-only structured tool result. Serialised to a `role:"tool"` turn. */
|
||||
export interface CoachToolResult {
|
||||
present: boolean;
|
||||
/** Short machine reason on a `present: false` result (no PII). */
|
||||
reason?: string;
|
||||
/** The domain payload on a `present: true` result (the snapshot section). */
|
||||
data?: unknown;
|
||||
/**
|
||||
* Optional citation-coupled reference grounding for the fetched metric —
|
||||
* published population bands + the user's placement, general guidance only.
|
||||
*/
|
||||
grounding?: string;
|
||||
/** The window an empty read searched, so a miss describes its own scope. */
|
||||
searchedWindow?: string;
|
||||
/**
|
||||
* On a `present: false` result whose reason is `outside_window` /
|
||||
* `unavailable_in_scope`: what the record holds for this domain over its whole
|
||||
* history — count, date range, and a bounded per-series aggregate. These are
|
||||
* server-computed figures the model MAY cite; they ride the authoritative set
|
||||
* for the prose number-verifier exactly like a present result's payload.
|
||||
*/
|
||||
available?: CoachDomainAvailability;
|
||||
/**
|
||||
* The name of the table this call produced (`r1`..`r8`), so the
|
||||
* model can mark the prose that relies on it with `result:rN`. Only in a
|
||||
* chat turn; absent when no table was made.
|
||||
*/
|
||||
resultRef?: string;
|
||||
/**
|
||||
* The full table, for the person. NEVER serialised to the model:
|
||||
* the loop strips it before the tool-result turn, and the model reads the
|
||||
* compact summary in `data` instead. Only in a chat turn.
|
||||
*/
|
||||
table?: CoachResultTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a chat turn hands the executor so a call can produce a
|
||||
* table and name it, and `show_result` can find an earlier one. Absent on
|
||||
* every other caller (MCP), which then gets the plain result.
|
||||
*/
|
||||
export interface CoachToolTurnContext {
|
||||
/** The conversation the turn belongs to, already narrowed to its owner. */
|
||||
conversationId: string;
|
||||
locale: Locale;
|
||||
/** The earlier tables of THIS conversation, as the context named them. */
|
||||
priorResults: ReadonlyArray<PriorResultTurn>;
|
||||
refs: ResultRefAllocator;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/** What the route persists onto provenance: which tools ran, did data exist. */
|
||||
export interface CoachToolTrace {
|
||||
name: string;
|
||||
present: boolean;
|
||||
/**
|
||||
* The call's arguments as their schema validated them; absent
|
||||
* when they did not validate. Turn-internal (steps, method, chips): never
|
||||
* persisted, the provenance keeps `{ name, present }` only.
|
||||
*/
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ vi.mock("@/lib/ai/coach/persistence", () => ({ appendMessage: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/memory/contract", () => ({
|
||||
buildMemoryContextBlock: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/tools/executor", async (importOriginal) => ({
|
||||
vi.mock("@/lib/ai/coach/tools/run/prior-results", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
admittedPriorResults: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
* results (seeded, so a failure reproduces) and checks each step against the
|
||||
* full set of strings the catalog can render.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import {
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
SHOW_RESULT_TOOL_NAME,
|
||||
parseCoachToolArgs,
|
||||
} from "@/lib/ai/coach/tools/definitions";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
|
||||
|
||||
import { MAX_TURN_STEPS, snapshotStep, toStep } from "../steps";
|
||||
|
||||
@@ -745,15 +745,16 @@ describe("snapshotStep", () => {
|
||||
* executor, and the executor turns every failure into a result.
|
||||
*/
|
||||
describe("the tool loop settles every call it starts (structural)", () => {
|
||||
const root = path.resolve(__dirname, "../../../../../..");
|
||||
const loop = readFileSync(
|
||||
path.join(root, "src/lib/ai/coach/tools/loop.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const executor = readFileSync(
|
||||
path.join(root, "src/lib/ai/coach/tools/executor.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const loop = readSource("src/lib/ai/coach/tools/loop.ts", [
|
||||
"onCallStart?.(",
|
||||
"onCallSettled?.(",
|
||||
]);
|
||||
// The entry point and its dispatch live in `executor.ts`; the per-tool
|
||||
// executors it dispatches to are in `tools/run/`.
|
||||
const executor = readSource("src/lib/ai/coach/tools/executor.ts", [
|
||||
"export async function executeCoachTool(",
|
||||
"async function dispatch(",
|
||||
]);
|
||||
|
||||
it("fires onCallStart and onCallSettled exactly once each per call", () => {
|
||||
expect(loop.match(/onCallStart\?\.\(/g)).toHaveLength(1);
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
runCoachToolLoop,
|
||||
type CoachToolTrace,
|
||||
} from "@/lib/ai/coach/tools";
|
||||
import { admittedPriorResults } from "@/lib/ai/coach/tools/executor";
|
||||
import { admittedPriorResults } from "@/lib/ai/coach/tools/run/prior-results";
|
||||
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
|
||||
import {
|
||||
COMPARE_SERIES_TOOL_DEF,
|
||||
|
||||
@@ -43,7 +43,7 @@ import { deriveFollowUps } from "@/lib/ai/coach/follow-ups/derive";
|
||||
import type { ResolvedFollowUp } from "@/lib/ai/coach/follow-ups/resolve";
|
||||
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
import { readCoachReach } from "@/lib/ai/coach/history-reach-read";
|
||||
import { tableRangeWithinReach } from "@/lib/ai/coach/tools/executor";
|
||||
import { tableRangeWithinReach } from "@/lib/ai/coach/tools/run/prior-results";
|
||||
import { createSseStream } from "@/lib/sse/create-stream";
|
||||
|
||||
import {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
coachDomainLabelKey,
|
||||
coachWindowLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
|
||||
import {
|
||||
SHOW_RESULT_TOOL_NAME,
|
||||
isCoachToolName,
|
||||
|
||||
Reference in new issue
Block a user