mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
2e28d4ed4c
commit
f39598f2e7
17 files changed
+212
-33
No files matched your search
@@ -20,6 +20,12 @@
|
||||
it. It used to be skipped. It is summed per day, charted, listed with your
|
||||
measurements, included in backups and exports, and readable over MCP. It
|
||||
feeds no score and no insight. (#1340)
|
||||
- When the Coach looks for readings older than the lookback limit you set
|
||||
for it, its live trail now says so on the wire: the `step` and `activity`
|
||||
frames carry `reason: "outside_reach"` instead of looking like an empty
|
||||
window. The native client can then tell "nothing in this window" from
|
||||
"further back than the Coach may read". The answer still names the limit,
|
||||
and the web trail keeps its wording.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+15
-2
@@ -62336,6 +62336,7 @@ components:
|
||||
enum:
|
||||
- no_data
|
||||
- outside_window
|
||||
- outside_reach
|
||||
- module_disabled
|
||||
- retrieval_failed
|
||||
- invalid_arguments
|
||||
@@ -62355,7 +62356,10 @@ components:
|
||||
additionalProperties: false
|
||||
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."
|
||||
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."
|
||||
CoachStepDomain:
|
||||
type: string
|
||||
enum:
|
||||
@@ -62916,6 +62920,10 @@ components:
|
||||
- time
|
||||
- cap
|
||||
- no_progress
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- outside_reach
|
||||
required:
|
||||
- id
|
||||
- phase
|
||||
@@ -62926,7 +62934,8 @@ components:
|
||||
additionalProperties: false
|
||||
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`."
|
||||
/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."
|
||||
CoachStop:
|
||||
type: object
|
||||
properties:
|
||||
@@ -63536,6 +63545,10 @@ components:
|
||||
- time
|
||||
- cap
|
||||
- no_progress
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- outside_reach
|
||||
title:
|
||||
description: A screened reasoning title or checkpoint sentence.
|
||||
type: string
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user