feat(coach): name a read past the lookback limit on the trail

A read whose readings all lie past the lookback limit was reported on its
step as outside_window, and its fetch entry carried no reason, so a client
could not tell an empty window from data the limit keeps out. The step now
carries reason outside_reach and the fetch entry mirrors it. The method
line and the web trail keep the window sentence; the answer names the
limit as before.
This commit is contained in:
Marc-André Bombeck committed 2026-11-05 14:12:09 +01:00
1 parent 2e28d4ed4c
commit f39598f2e7
17 files changed
+212 -33

No files matched your search

@@ -46,6 +46,8 @@ export interface DialogWorld {
resultsWithheld: boolean;
/** The provider's rounds, consumed in order. */
script: ScriptRound[];
/** The stored `User.coachPrefsJson`; null is the defaults. */
coachPrefs: Record<string, unknown> | null;
}
export const world: DialogWorld = {
@@ -56,6 +58,7 @@ export const world: DialogWorld = {
storedProvenance: {},
resultsWithheld: false,
script: [],
coachPrefs: null,
};
/** Everything the scripted provider was sent and answered, in order. */
@@ -412,6 +415,7 @@ export function resetDialog(): void {
world.storedProvenance = {};
world.resultsWithheld = false;
world.script = [];
world.coachPrefs = null;
providerCalls.length = 0;
messageSeq = 0;
for (const fn of Object.values(m)) fn.mockReset();
@@ -465,7 +469,7 @@ export const modules = {
prisma: {
user: {
findUnique: async () => ({
coachPrefsJson: null,
coachPrefsJson: world.coachPrefs,
displayName: null,
aiResponseTimeoutSeconds: null,
}),
@@ -171,6 +171,62 @@ describe("the live trail", () => {
expect(interim).toBeLessThan(firstToken);
});
it("names a read past the lookback limit on its step and its fetch entry", async () => {
world.coachPrefs = { defaultWindow: "last7days" };
world.script = [
{ calls: [{ name: "get_day", args: { date: "2026-06-01" } }] },
{ text: "That day lies beyond the lookback limit you set." },
];
const { status, frames } = await h.postTurn(post, {
message: "What happened on 1 June?",
locale: "en",
});
expect(status).toBe(200);
const step = framesOf<{ step: { id: string; status: string } }>(
frames,
"step",
)
.map((f) => f.step)
.at(-1);
expect(step).toMatchObject({
tool: "get_day",
status: "empty",
reason: "outside_reach",
});
const fetch = framesOf<{ activity: CoachActivity }>(frames, "activity")
.map((f) => f.activity)
.filter((a) => a.phase === "fetch")
.at(-1);
expect(fetch).toMatchObject({
status: "empty",
stepRef: step?.id,
reason: "outside_reach",
});
});
it("names no reason on a fetch inside the limit", async () => {
world.script = [
{
calls: [
{
name: "get_metric_table",
args: { metric: "bp", window: "last30days" },
},
],
},
{ text: "Steady. result:r1" },
];
const { frames } = await h.postTurn(post, {
message: "How was my blood pressure?",
locale: "en",
});
const fetches = framesOf<{ activity: CoachActivity }>(frames, "activity")
.map((f) => f.activity)
.filter((a) => a.phase === "fetch");
expect(fetches.length).toBeGreaterThan(0);
for (const entry of fetches) expect(entry).not.toHaveProperty("reason");
});
it("persists the structure on the provenance and the text in the trail", async () => {
world.script = [
{
@@ -102,6 +102,14 @@ describe("describeStep", () => {
]);
});
it("keeps the window sentence on a read past the lookback limit", () => {
const reach: CoachStep = { ...SLEEP, reason: "outside_reach" };
expect(describeStep(reach, t, tCount).meta).toEqual([
"last 30 days",
"no readings in this window",
]);
});
it("counts metrics on the snapshot step and rows on labs", () => {
const snapshot: CoachStep = {
id: "s1",
+25
View File
@@ -83,6 +83,31 @@ describe("buildMethod", () => {
});
});
it("keeps the window sentence for a read past the lookback limit", () => {
const method = buildMethod({
steps: [
step({
id: "s1",
tool: "get_sleep",
domain: "sleep",
window: "last30days",
status: "empty",
reason: "outside_reach",
}),
],
results: [],
locale: "en",
});
expect(method?.text).toBe(
"Sleep, last 30 days: no readings in this window",
);
expect(method?.entries[0]).toEqual({
domain: "sleep",
window: "last30days",
absent: "outside_window",
});
});
it("says totals for a summed metric and a rate for compliance", () => {
const method = buildMethod({
steps: [
@@ -62,6 +62,30 @@ describe("createActivityRecorder", () => {
});
});
it("names a fetch past the lookback limit on the entry and its metadata", () => {
const { rec, frames } = recorder();
const reach = rec.start({
phase: "fetch",
round: 1,
...fetchActivityLabel("en", { domain: "weight", window: "last90days" }),
stepRef: "s1",
});
rec.finish(reach, "empty", { reason: "outside_reach" });
const plain = rec.start({
phase: "fetch",
round: 1,
...fetchActivityLabel("en", { domain: "pulse", window: "last90days" }),
stepRef: "s2",
});
rec.finish(plain, "empty");
expect(
frames.find((f) => f.id === reach && f.status === "empty"),
).toMatchObject({ reason: "outside_reach" });
expect(frames.at(-1)).not.toHaveProperty("reason");
expect(rec.meta()[0]).toMatchObject({ reason: "outside_reach" });
expect(rec.meta()[1]).not.toHaveProperty("reason");
});
it("drops a title that prescribes a dose and keeps the catalog label", () => {
const { rec, frames } = recorder();
const id = rec.start({
+22
View File
@@ -22,6 +22,7 @@
import type {
CoachActivityMeta,
CoachActivityPhase,
CoachStep,
CoachStepStatus,
CoachStopReason,
CoachTrail,
@@ -67,6 +68,27 @@ export interface ActivityPatch {
labelKey?: string;
label?: string;
count?: number;
/** Why a settled fetch found nothing, when it is a reason the trail names. */
reason?: CoachActivityMeta["reason"];
}
/**
* How a `fetch` entry closes for the step it stands for: the step's status
* (`done` for one that never settled), its count, and `outside_reach` when
* the step says its readings all lie past the lookback limit. Any other
* step reason stays on the step.
*/
export function settledFetch(
status: CoachStepStatus,
step: Pick<CoachStep, "count" | "reason"> | null,
): [Exclude<CoachStepStatus, "running">, ActivityPatch] {
return [
status === "running" ? "done" : status,
{
...(step?.count !== undefined ? { count: step.count } : {}),
...(step?.reason === "outside_reach" ? { reason: step.reason } : {}),
},
];
}
export interface ActivityRecorder {
+1
View File
@@ -86,6 +86,7 @@ export function createActivityRecorder(args: {
if (patch.count !== undefined && Number.isSafeInteger(patch.count)) {
entry.meta.count = Math.max(0, patch.count);
}
if (patch.reason === "outside_reach") entry.meta.reason = patch.reason;
if (patch.title !== undefined) {
const title = screenActivityTitle(patch.title, args.screen);
if (title) entry.title = title;
+2
View File
@@ -65,6 +65,8 @@ export const COACH_STEP_REASON_KEYS: Readonly<Record<CoachStepReason, string>> =
{
no_data: "coach.step.reason.noData",
outside_window: "coach.step.reason.outsideWindow",
// The web trail keeps the window sentence; the answer names the limit.
outside_reach: "coach.step.reason.outsideWindow",
module_disabled: "coach.step.reason.moduleDisabled",
retrieval_failed: "coach.step.reason.retrievalFailed",
invalid_arguments: "coach.step.reason.invalidArguments",
+6 -2
View File
@@ -145,8 +145,12 @@ function collectEntries(
...(granularity ? { granularity } : {}),
};
if (step.status === "empty") {
if (step.reason && ABSENT_REASONS.has(step.reason)) {
add({ ...base, absent: step.reason as Absent });
// Past the lookback limit the method line keeps the window sentence,
// as it always has; the answer names the limit.
const reason =
step.reason === "outside_reach" ? "outside_window" : step.reason;
if (reason && ABSENT_REASONS.has(reason)) {
add({ ...base, absent: reason as Absent });
}
continue;
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Why a Coach step found nothing or failed: the one list behind the
* `CoachStepReason` type and the `step` frame's schema.
*
* `outside_window`: no readings in the window read. `outside_reach`: readings
* exist, all of them older than the lookback limit set for the Coach; the
* answer names the limit, and a method entry says `outside_window` for it.
*
* Client-safe: no server import.
*/
export const COACH_STEP_REASONS = [
"no_data",
"outside_window",
"outside_reach",
"module_disabled",
"retrieval_failed",
"invalid_arguments",
] as const;
+5 -11
View File
@@ -28,6 +28,7 @@ import {
SUGGESTED_ACTION_TYPES,
type CheckupIntervalId,
} from "@/lib/ai/coach/suggest-action";
import { COACH_STEP_REASONS } from "@/lib/ai/coach/step-reasons";
import { COACH_TOOL_NAMES } from "@/lib/ai/coach/tools/definitions";
import {
COACH_MEMORY_CATEGORIES,
@@ -100,15 +101,7 @@ export const coachStepSchema = z
.int()
.optional()
.describe("Readings or rows the server counted. Never a health value."),
reason: z
.enum([
"no_data",
"outside_window",
"module_disabled",
"retrieval_failed",
"invalid_arguments",
])
.optional(),
reason: z.enum(COACH_STEP_REASONS).optional(),
resultRef: resultRefSchema
.optional()
.describe("The table this step produced (`r1`..), when it produced one."),
@@ -123,7 +116,7 @@ export const coachStepSchema = z
.meta({
id: "CoachStep",
description:
"One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done` / `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only: never free text, an analyte name or a value.",
"One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done` / `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only: never free text, an analyte name or a value. `reason` says why a step found nothing or failed: `outside_window` means no readings in the window read, `outside_reach` that readings exist but all lie past the lookback limit set for the Coach (the answer names the limit). A client without a string for a reason shows the step without one.",
});
// ── Results ───────────────────────────────────────────────────────────────
@@ -464,12 +457,13 @@ const coachActivityMetaShape = {
.describe("Readings, lookups or entries the server counted."),
durationMs: z.number().int().optional(),
stop: coachStopReasonSchema.optional().describe("Set on the `stop` entry."),
reason: z.enum(["outside_reach"]).optional(),
};
export const coachActivityMetaSchema = z.object(coachActivityMetaShape).meta({
id: "CoachActivityMeta",
description:
"v1.41 — one entry of a turn's trail as persisted on `metricSource.activity`: phase, status, round, catalog label, counts. The model-written title and text are behind `GET /api/insights/chat/{id}/messages/{messageId}/trail`.",
"v1.41 — one entry of a turn's trail as persisted on `metricSource.activity`: phase, status, round, catalog label, counts. The model-written title and text are behind `GET /api/insights/chat/{id}/messages/{messageId}/trail`. `reason` is set only on a settled `fetch` entry whose readings all lie past the lookback limit set for the Coach (`outside_reach`), mirroring the step it stands for.",
});
export const coachActivitySchema = z
@@ -55,6 +55,7 @@ const STATUSES = new Set(["running", "done", "empty", "failed"]);
const REASONS = new Set([
"no_data",
"outside_window",
"outside_reach",
"module_disabled",
"retrieval_failed",
"invalid_arguments",
@@ -218,6 +219,7 @@ function fuzzResult(r: () => number): CoachToolResult | undefined {
const reasons = [
"no_data",
"outside_window",
"outside_reach",
"unavailable_in_scope",
"no_data_unconfirmed",
"retrieval_failed",
@@ -416,6 +418,10 @@ describe("toStep — mapping", () => {
},
}),
).toMatchObject({ status: "empty", reason: "outside_window", count: 40 });
// Past the lookback limit: its own reason, and no figure from the rows.
const reach = at("outside_reach");
expect(reach).toMatchObject({ status: "empty", reason: "outside_reach" });
expect(reach).not.toHaveProperty("count");
expect(at("retrieval_failed")).toMatchObject({
status: "failed",
reason: "retrieval_failed",
+5 -6
View File
@@ -54,7 +54,10 @@ import type {
CoachStop,
} from "@/lib/ai/coach/types";
import type { AiToolCall } from "@/lib/ai/types";
import type { ActivityRecorder } from "@/lib/ai/coach/activity/contract";
import {
settledFetch,
type ActivityRecorder,
} from "@/lib/ai/coach/activity/contract";
import {
createActivityRecorder,
type TurnActivityRecorder,
@@ -518,11 +521,7 @@ export async function runTurnModel(args: {
steps.record(step);
const entry = fetches.get(index);
const status = step?.status ?? (result.present ? "done" : "empty");
if (entry) {
activity.finish(entry.id, status === "running" ? "done" : status, {
...(step?.count !== undefined ? { count: step.count } : {}),
});
}
if (entry) activity.finish(entry.id, ...settledFetch(status, step));
const reads = roundReads.get(round) ?? [];
reads.push({
count: step?.count ?? 0,
+3 -3
View File
@@ -119,9 +119,9 @@ const MISS: Readonly<
no_data: { status: "empty", reason: "no_data" },
analyte_not_found: { status: "empty", reason: "no_data" },
outside_window: { status: "empty", reason: "outside_window" },
// Beyond the lookback limit: to the person, "no readings in this window"
// is the true sentence; the limit itself is named in the answer.
outside_reach: { status: "empty", reason: "outside_window" },
// Beyond the lookback limit: the readings exist, the limit keeps them
// out. Its own reason, so a trail can say so; the answer names the limit.
outside_reach: { status: "empty", reason: "outside_reach" },
module_disabled: { status: "empty", reason: "module_disabled" },
retrieval_failed: { status: "failed", reason: "retrieval_failed" },
no_data_unconfirmed: { status: "failed", reason: "retrieval_failed" },
+5 -8
View File
@@ -10,6 +10,7 @@ import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
import { COACH_SCOPE_SOURCES } from "./scope-sources";
import type { COACH_STEP_REASONS } from "./step-reasons";
import type { AiUnavailableReason } from "@/lib/ai/capabilities/types";
import type { CoachSuggestedAction } from "./suggest-action";
@@ -339,8 +340,9 @@ export interface CoachActivityMeta {
/** Readings, lookups or remembered entries the server counted. */
count?: number;
durationMs?: number;
/** Set on the `stop` entry. */
/** `stop`: on the stop entry. `reason`: a fetch past the lookback limit. */
stop?: CoachStopReason;
reason?: "outside_reach";
}
/** One trail entry on the wire: the metadata plus the owner-only model text. */
@@ -446,13 +448,8 @@ export type CoachStepDomain =
| "day"
| "snapshot";
/** Why a step or a method entry found nothing. */
export type CoachStepReason =
| "no_data"
| "outside_window"
| "module_disabled"
| "retrieval_failed"
| "invalid_arguments";
/** Why a step or a method entry found nothing (`step-reasons.ts`). */
export type CoachStepReason = (typeof COACH_STEP_REASONS)[number];
export interface CoachStep {
/** `s1`..`s64`, unique within a turn; frames upsert by id. */