Files
HealthLog/src/lib/ai/coach/tools/availability.ts
T
Marc-André Bombeck 2a9b5ba59d test(coach): prove the availability probe's SQL against Postgres
The unit suite mocks Prisma, so it proves the discriminant and nothing about the
query underneath it — a wrong column would have left every test green and every
account answering no_data_unconfirmed in production. Seeds the reported case and
the four other shapes: an empty record, another account's rows, tombstoned rows,
and a history that mixes units.

Rounds the out-of-window mean to one decimal, like every other mean the snapshot
surfaces, and annotates a failed manifest probe rather than letting it degrade in
silence.
2026-07-26 18:34:42 +02:00

504 lines
18 KiB
TypeScript

/**
* Coach availability probe — the discriminant behind "no data".
*
* A Coach retrieval tool reads the snapshot for a WINDOW. When the window read
* comes back empty there are two entirely different situations, and until this
* module existed both collapsed into one `reason: "no_data"`:
*
* 1. the record holds nothing for this domain, ever;
* 2. the record holds a substantial history for this domain, all of it older
* than the window that was searched.
*
* The second one is not absence. Reported as absence, the Coach tells someone
* their readings do not exist and then — having been told there is nothing
* there — moves on to a metric it can talk about. So every `present: false`
* that comes from an empty window read is checked here first, and the two
* cases reach the model as different reason codes:
*
* `no_data` — the probe confirmed the record is empty for this domain.
* `outside_window` — rows exist; every one of them is older than the window
* that was searched. `available` carries how many, over
* what range, and (for measurement-backed domains) a
* bounded aggregate of them.
* `unavailable_in_scope` — rows exist INSIDE the searched window and the
* domain still produced no block: the owning module is off
* for this account, or the block's own floor was not met.
* Widening the window cannot help, and telling the person
* the data does not exist would be false twice over.
* `no_data_unconfirmed` — the probe itself failed. The window read found
* nothing and we could not establish whether older rows
* exist, so the model is told exactly that rather than
* being handed a confident absence it cannot verify.
*
* Cost. One grouped aggregate over `Measurement` for every measurement-backed
* subject in the batch (indexed by `(userId, type, measuredAt)`, no rows
* materialised in JS), plus at most one `aggregate()` per non-measurement
* subject kind actually asked for. It runs ONLY on the miss path, and only for
* the domains that missed.
*
* Why an aggregate and not a wider window. The window cap exists because a
* multi-year raw history does not fit a prompt budget — 1,597 CGM points cannot
* go to the model. So the out-of-window range comes back as the six scalars SQL
* can compute without materialising a row (count, first, last, mean, min, max
* per series). That is bounded by construction, needs no rollup coverage, and
* is enough for the Coach to answer the question instead of only naming it.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types";
import type { CoachToolName } from "./definitions";
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
import { windowToDays } from "@/lib/ai/coach/snapshot-series";
import { DEFAULT_WINDOW } from "@/lib/ai/coach/snapshot-cache";
/** Which table a domain's existence has to be checked against. */
export type CoachAvailabilitySubject =
| { kind: "measurement"; types: readonly MeasurementType[] }
| { kind: "mood" }
| { kind: "workout" }
| { kind: "intake" }
| { kind: "lab" };
/** Per-series aggregate over the whole recorded history of one series. */
interface CoachAvailabilitySeries {
/** The measurement type, verbatim — unambiguous for the model. */
series: string;
/** The unit every row of this series carries, when they all agree. */
unit: string | null;
count: number;
mean: number;
min: number;
max: number;
}
/**
* What the record holds for a domain, over its whole history. Model-facing
* only: it is serialised into the tool-result turn and the DATA INVENTORY, and
* never rendered to a person, so it carries no i18n strings.
*/
export interface CoachDomainAvailability {
/** Rows the record holds for this domain, over its whole history. */
count: number;
/** `YYYY-MM-DD` of the earliest row, in the user's display timezone. */
firstDate: string;
/** `YYYY-MM-DD` of the latest row, in the user's display timezone. */
lastDate: string;
/**
* Bounded aggregate per series. Present for measurement-backed domains whose
* rows all share one unit; omitted when the history mixes units (a mean
* across mg/dL and mmol/L rows would be a fabricated number) and for the
* domains that carry no single value column (workouts, intakes, labs, mood).
*/
series?: CoachAvailabilitySeries[];
/**
* The widest window that would reach `lastDate`, when one exists — the model
* can re-call the tool with it and get the real series. `null` when the
* history lies beyond every window's reach, which is when the honest sentence
* is the whole answer.
*/
reachableWithWindow: CoachScopeWindow | null;
}
/** The windows a tool call may ask for, widest last. */
const WINDOWS_WIDEST_LAST: readonly CoachScopeWindow[] = [
"last7days",
"last30days",
"last90days",
"lastYear",
"allTime",
];
/**
* The narrowest window whose cutoff still contains `lastDate`, or `null` when
* even the widest one does not reach it. `allTime` is capped at a year like
* `lastYear` (see `windowToDays`), so a history older than that is genuinely
* unreachable by re-calling with a wider window — and saying so is the point.
*/
function reachableWindow(lastAt: Date, now: Date): CoachScopeWindow | null {
const ageDays = (now.getTime() - lastAt.getTime()) / 86_400_000;
for (const window of WINDOWS_WIDEST_LAST) {
if (ageDays <= windowToDays(window)) return window;
}
return null;
}
interface RawBounds {
count: number;
firstAt: Date;
lastAt: Date;
series?: Array<{
series: string;
unit: string | null;
count: number;
mean: number;
min: number;
max: number;
}>;
}
/**
* One grouped aggregate over `Measurement` for every type in `types`, split by
* unit so a mixed-unit history is detectable rather than averaged.
*/
async function readMeasurementBounds(
userId: string,
types: readonly MeasurementType[],
): Promise<Map<MeasurementType, RawBounds>> {
const rows = await prisma.measurement.groupBy({
by: ["type", "unit"],
where: { userId, deletedAt: null, type: { in: [...types] } },
_count: { _all: true },
_min: { measuredAt: true, value: true },
_max: { measuredAt: true, value: true },
_avg: { value: true },
});
const out = new Map<MeasurementType, RawBounds>();
for (const row of rows) {
const firstAt = row._min.measuredAt;
const lastAt = row._max.measuredAt;
const min = row._min.value;
const max = row._max.value;
const mean = row._avg.value;
if (!firstAt || !lastAt) continue;
const existing = out.get(row.type);
const unitEntry =
min !== null && max !== null && mean !== null
? {
series: row.type as string,
unit: row.unit,
count: row._count._all,
// One decimal, like every other mean the snapshot surfaces — a raw
// float would invite the model to quote a precision the reading
// never had.
mean: Math.round(mean * 10) / 10,
min,
max,
}
: null;
if (!existing) {
out.set(row.type, {
count: row._count._all,
firstAt,
lastAt,
...(unitEntry ? { series: [unitEntry] } : {}),
});
continue;
}
// A second unit for the same type: merge the bounds, and drop the value
// aggregate entirely — the two units cannot be folded into one mean.
existing.count += row._count._all;
if (firstAt < existing.firstAt) existing.firstAt = firstAt;
if (lastAt > existing.lastAt) existing.lastAt = lastAt;
delete existing.series;
}
return out;
}
/** One `aggregate()` per non-measurement subject kind. */
async function readTableBounds(
userId: string,
kind: Exclude<CoachAvailabilitySubject["kind"], "measurement">,
): Promise<RawBounds | null> {
if (kind === "mood") {
const row = await prisma.moodEntry.aggregate({
where: { userId, deletedAt: null },
_count: { _all: true },
_min: { moodLoggedAt: true },
_max: { moodLoggedAt: true },
});
return row._min.moodLoggedAt && row._max.moodLoggedAt
? {
count: row._count._all,
firstAt: row._min.moodLoggedAt,
lastAt: row._max.moodLoggedAt,
}
: null;
}
if (kind === "workout") {
// Workout deletes are hard deletes — there is no `deletedAt` to filter.
const row = await prisma.workout.aggregate({
where: { userId },
_count: { _all: true },
_min: { startedAt: true },
_max: { startedAt: true },
});
return row._min.startedAt && row._max.startedAt
? {
count: row._count._all,
firstAt: row._min.startedAt,
lastAt: row._max.startedAt,
}
: null;
}
if (kind === "intake") {
const row = await prisma.medicationIntakeEvent.aggregate({
where: { userId, deletedAt: null },
_count: { _all: true },
_min: { scheduledFor: true },
_max: { scheduledFor: true },
});
return row._min.scheduledFor && row._max.scheduledFor
? {
count: row._count._all,
firstAt: row._min.scheduledFor,
lastAt: row._max.scheduledFor,
}
: null;
}
const row = await prisma.labResult.aggregate({
where: { userId, deletedAt: null },
_count: { _all: true },
_min: { takenAt: true },
_max: { takenAt: true },
});
return row._min.takenAt && row._max.takenAt
? {
count: row._count._all,
firstAt: row._min.takenAt,
lastAt: row._max.takenAt,
}
: null;
}
/** Merge several raw bounds (a domain can span two measurement types). */
function mergeBounds(parts: readonly RawBounds[]): RawBounds | null {
const present = parts.filter((p) => p.count > 0);
if (present.length === 0) return null;
const merged: RawBounds = {
count: present.reduce((sum, p) => sum + p.count, 0),
firstAt: present.reduce(
(min, p) => (p.firstAt < min ? p.firstAt : min),
present[0].firstAt,
),
lastAt: present.reduce(
(max, p) => (p.lastAt > max ? p.lastAt : max),
present[0].lastAt,
),
};
const series = present.flatMap((p) => p.series ?? []);
if (series.length > 0) merged.series = series;
return merged;
}
/**
* Probe several domains in one batch. Returns an entry ONLY for a domain the
* record actually holds rows for — an absent key means "confirmed empty", which
* is exactly the distinction the caller needs.
*
* Throws only if every read fails; the caller treats a throw as
* `no_data_unconfirmed` rather than as absence.
*/
export async function probeCoachAvailability(
userId: string,
subjects: ReadonlyMap<string, CoachAvailabilitySubject>,
options?: { now?: Date },
): Promise<Map<string, CoachDomainAvailability>> {
const out = new Map<string, CoachDomainAvailability>();
if (subjects.size === 0) return out;
const now = options?.now ?? new Date();
const measurementTypes = new Set<MeasurementType>();
const tableKinds = new Set<
Exclude<CoachAvailabilitySubject["kind"], "measurement">
>();
for (const subject of subjects.values()) {
if (subject.kind === "measurement") {
for (const type of subject.types) measurementTypes.add(type);
} else {
tableKinds.add(subject.kind);
}
}
const [tz, measurementBounds, ...tableRows] = await Promise.all([
resolveUserTimezone(userId),
measurementTypes.size > 0
? readMeasurementBounds(userId, [...measurementTypes])
: Promise.resolve(new Map<MeasurementType, RawBounds>()),
...[...tableKinds].map(async (kind) => ({
kind,
bounds: await readTableBounds(userId, kind),
})),
]);
const byKind = new Map(tableRows.map((r) => [r.kind, r.bounds]));
for (const [key, subject] of subjects) {
const raw =
subject.kind === "measurement"
? mergeBounds(
subject.types
.map((type) => measurementBounds.get(type))
.filter((b): b is RawBounds => b !== undefined),
)
: (byKind.get(subject.kind) ?? null);
if (!raw) continue;
out.set(key, {
count: raw.count,
firstDate: userDayKey(raw.firstAt, tz),
lastDate: userDayKey(raw.lastAt, tz),
...(raw.series && raw.series.length > 0 ? { series: raw.series } : {}),
reachableWithWindow: reachableWindow(raw.lastAt, now),
});
}
return out;
}
/**
* The reason codes a `present: false` tool result may carry when it came from
* an empty window read. Frozen so a future reader (a test, a dashboard) can
* assert the three states stay three.
*/
const EMPTY_READ_REASONS = {
/** Probe confirmed: the record holds nothing for this domain, ever. */
none: "no_data",
/** Rows exist, all of them outside the window that was searched. */
outsideWindow: "outside_window",
/** Rows exist inside the searched window; the domain still produced none. */
unavailableInScope: "unavailable_in_scope",
/** The probe failed; absence could not be established either way. */
unconfirmed: "no_data_unconfirmed",
} as const;
/**
* Split a "rows exist" verdict into the two cases that differ in what the model
* should do next.
*
* `reachableWithWindow` is the NARROWEST window that reaches the latest row, so
* when it is no wider than the window already searched the rows were inside that
* window and the miss is not a window artefact at all — re-calling wider would
* change nothing.
*/
export function classifyAvailability(
available: CoachDomainAvailability,
searchedWindow: CoachScopeWindow | undefined,
):
| typeof EMPTY_READ_REASONS.outsideWindow
| typeof EMPTY_READ_REASONS.unavailableInScope {
const reachable = available.reachableWithWindow;
if (reachable === null) return EMPTY_READ_REASONS.outsideWindow;
const searchedDays = windowToDays(searchedWindow ?? DEFAULT_WINDOW);
return windowToDays(reachable) <= searchedDays
? EMPTY_READ_REASONS.unavailableInScope
: EMPTY_READ_REASONS.outsideWindow;
}
/** A `present: false` result with the window/history discriminant resolved. */
interface EmptyReadResult {
present: false;
reason: string;
/** The window the empty read searched, so the miss is self-describing. */
searchedWindow?: string;
available?: CoachDomainAvailability;
}
/**
* Resolve ONE empty window read into the honest reason. Every `present: false`
* that comes from "the snapshot section was absent" routes through here; a
* bare `no_data` is never returned without the probe having confirmed it.
*/
export async function resolveEmptyRead(args: {
userId: string;
/** Stable key for logs — the tool or domain name. */
domain: string;
subject: CoachAvailabilitySubject | null;
searchedWindow: CoachScopeWindow | undefined;
now?: Date;
}): Promise<EmptyReadResult> {
const { userId, domain, subject, searchedWindow } = args;
const searched = searchedWindow ? { searchedWindow } : {};
// A domain with no row-backed subject (illness, cycle, correlations) is not
// window-filtered from a table of rows — its block is computed or gated, so
// an absent block is an honest absence and there is nothing to probe.
if (subject === null) {
return { present: false, reason: EMPTY_READ_REASONS.none, ...searched };
}
let availability: Map<string, CoachDomainAvailability>;
try {
availability = await probeCoachAvailability(
userId,
new Map([[domain, subject]]),
args.now ? { now: args.now } : undefined,
);
} catch (err) {
annotate({
action: { name: "coach.availability.probe_failed" },
meta: {
domain,
reason: err instanceof Error ? err.name : "unknown",
},
});
return {
present: false,
reason: EMPTY_READ_REASONS.unconfirmed,
...searched,
};
}
const available = availability.get(domain);
if (!available) {
return { present: false, reason: EMPTY_READ_REASONS.none, ...searched };
}
const reason = classifyAvailability(available, searchedWindow);
annotate({
action: { name: "coach.availability.resolved" },
meta: {
domain,
reason,
count: available.count,
reachable: available.reachableWithWindow ?? "none",
},
});
return { present: false, reason, ...searched, available };
}
/**
* The subject a `get_metric_series` metric is checked against. `mood` reads
* MoodEntry; every other series-backed source reads `Measurement`.
*/
function subjectForMetricSource(
metric: CoachScopeSource,
): CoachAvailabilitySubject | null {
if (metric === "mood") return { kind: "mood" };
if (metric === "workouts") return { kind: "workout" };
if (metric === "compliance") return { kind: "intake" };
const types = COACH_SOURCE_MEASUREMENT_TYPES[metric];
if (!types || types.length === 0) return null;
return { kind: "measurement", types };
}
/**
* The one table mapping a tool to the rows its existence is checked against.
* Used by BOTH the executor (per miss) and the DATA INVENTORY (per absent
* domain), so the two can never disagree about what a domain is backed by.
*
* The switch is exhaustive over `CoachToolName`: adding a tool without deciding
* what it is probed against fails to compile rather than silently inheriting the
* lossy `no_data`. `null` is a deliberate answer — the three derived domains
* (illness / cycle / correlations) are computed or gated rather than sliced out
* of one table, so there is no row set whose date bounds would mean anything.
*/
export function subjectForTool(
tool: CoachToolName,
metric?: CoachScopeSource,
): CoachAvailabilitySubject | null {
switch (tool) {
case "get_metric_series":
return metric ? subjectForMetricSource(metric) : null;
case "get_glucose_panel":
return subjectForMetricSource("glucose");
case "get_sleep":
return subjectForMetricSource("sleep");
case "get_medication_compliance":
return { kind: "intake" };
case "get_workouts":
return { kind: "workout" };
case "get_labs":
return { kind: "lab" };
case "get_illness_recovery":
case "get_cycle":
case "get_correlations":
return null;
}
}