mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(coach): keep earlier tables beyond the lookback limit out of the context
The earlier-tables block names each stored table with its window and row count. The list was filtered by module and exclusion only, so a table stored before the limit was set or narrowed still told the model how much history it covered. It now answers to the same range test show_result uses, and the history-reach guard checks both the filter and that the call site passes the limit.
This commit is contained in:
1 parent
0c1dd1e967
commit
1158ae9ffd
4 files changed
+74
-4
No files matched your search
@@ -178,7 +178,11 @@ vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
||||
import {
|
||||
admittedPriorResults,
|
||||
executeCoachTool,
|
||||
} from "@/lib/ai/coach/tools/executor";
|
||||
import { renderPriorResultRefs } from "@/lib/ai/coach/chat-request-builder";
|
||||
import {
|
||||
COACH_TOOL_DEFS,
|
||||
COACH_TOOL_NAMES,
|
||||
@@ -324,6 +328,44 @@ describe("every Coach tool honours the lookback limit", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("earlier tables answer to the lookback limit", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: NOW, toFake: ["Date"] });
|
||||
});
|
||||
|
||||
it("names no earlier table whose range lies beyond the limit", async () => {
|
||||
// The context line carries the table's window and row count; an
|
||||
// all-time table stored before the limit was set must not be listed.
|
||||
const admitted = await admittedPriorResults({
|
||||
userId: "u1",
|
||||
prefs: { excludeMetrics: [] },
|
||||
scope: undefined,
|
||||
reach: REACH,
|
||||
prior: PRIOR,
|
||||
});
|
||||
const refs = admitted.flatMap((turn) => turn.results.map((r) => r.ref));
|
||||
expect(refs).toEqual(["r2"]);
|
||||
const context = renderPriorResultRefs(admitted);
|
||||
expect(context).toContain("last7days");
|
||||
expect(context).not.toContain("allTime");
|
||||
});
|
||||
|
||||
it("drops an earlier year-ago table that does not fit", async () => {
|
||||
const yearAgo = {
|
||||
...storedTable("r3", "last7days"),
|
||||
source: { ...storedTable("r3", "last7days").source, period: "yearAgo" },
|
||||
} as PriorResultTurn["results"][number];
|
||||
const admitted = await admittedPriorResults({
|
||||
userId: "u1",
|
||||
prefs: { excludeMetrics: [] },
|
||||
scope: undefined,
|
||||
reach: REACH,
|
||||
prior: [{ messageId: "m-y", turnIndex: 3, results: [yearAgo] }],
|
||||
});
|
||||
expect(admitted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── The source ─────────────────────────────────────────────────────────
|
||||
|
||||
const ROOT = join(__dirname, "../../../../..");
|
||||
@@ -422,6 +464,14 @@ describe("the source hands the limit on", () => {
|
||||
for (const args of calls) expect(args).not.toMatch(/\breach\b/);
|
||||
});
|
||||
|
||||
it("passes the limit when it lists earlier tables for the model", () => {
|
||||
const calls = walk(COACH_DIR).flatMap((file) =>
|
||||
callArguments(readFileSync(file, "utf8"), "admittedPriorResults"),
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
for (const args of calls) expect(args).toMatch(/\breach\b/);
|
||||
});
|
||||
|
||||
it("passes the limit from the chat loop and nowhere from MCP", () => {
|
||||
const loop = callArguments(
|
||||
readFileSync(join(COACH_DIR, "tools/loop.ts"), "utf8"),
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
type CoachToolTurnContext,
|
||||
} from "@/lib/ai/coach/tools/executor";
|
||||
import { createResultRefAllocator } from "../refs";
|
||||
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
|
||||
import { findUnverifiedCoachNumbers } from "@/lib/ai/coach/coach-prose-grounding";
|
||||
|
||||
const NOW = new Date("2026-09-27T10:00:00Z");
|
||||
@@ -544,6 +545,7 @@ describe("admittedPriorResults", () => {
|
||||
it("drops a metric the person excluded and keeps what no exclusion names", async () => {
|
||||
const out = await admittedPriorResults({
|
||||
userId: "u1",
|
||||
reach: UNBOUNDED_REACH,
|
||||
prefs: { excludeMetrics: ["steps"] },
|
||||
scope: undefined,
|
||||
prior,
|
||||
@@ -556,6 +558,7 @@ describe("admittedPriorResults", () => {
|
||||
it("keeps to the conversation's scope", async () => {
|
||||
const out = await admittedPriorResults({
|
||||
userId: "u1",
|
||||
reach: UNBOUNDED_REACH,
|
||||
prefs: { excludeMetrics: [] },
|
||||
scope: { sources: ["steps"] },
|
||||
prior,
|
||||
@@ -572,6 +575,7 @@ describe("admittedPriorResults", () => {
|
||||
resolveModuleMap.mockResolvedValue({ mood: false });
|
||||
const out = await admittedPriorResults({
|
||||
userId: "u1",
|
||||
reach: UNBOUNDED_REACH,
|
||||
prefs: { excludeMetrics: [] },
|
||||
scope: undefined,
|
||||
prior: [
|
||||
|
||||
@@ -919,11 +919,17 @@ export function scopeAdmits(
|
||||
* (`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 [];
|
||||
@@ -943,7 +949,15 @@ export async function admittedPriorResults(args: {
|
||||
return args.prior
|
||||
.map((turn) => ({
|
||||
...turn,
|
||||
results: turn.results.filter((meta) => admits(meta.source.domain)),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -222,13 +222,15 @@ export async function runTurnModel(args: {
|
||||
effectiveScope,
|
||||
ctx.reach,
|
||||
);
|
||||
// Earlier tables whose metric the person has since excluded are
|
||||
// neither named for the model nor reachable through show_result. The
|
||||
// Earlier tables whose metric the person has since excluded, or whose
|
||||
// range lies beyond the lookback limit, are neither named for the
|
||||
// model nor reachable through show_result. The
|
||||
// scope is the one every tool of the turn reads under.
|
||||
const priorResults = await admittedPriorResults({
|
||||
userId,
|
||||
prefs: ctx.coachPrefs,
|
||||
scope: inventory.probeScope,
|
||||
reach: ctx.reach,
|
||||
prior: args.priorResults ?? [],
|
||||
});
|
||||
const requestWith = (tableRules: boolean) =>
|
||||
|
||||
Reference in new issue
Block a user