fix(coach): tell a history outside the window apart from a metric never recorded

A Coach retrieval tool that read the window and found nothing returned
`reason: "no_data"` — the same code for two situations that have nothing in
common: the record holds nothing for that metric, and the record holds a
substantial history for it, all of it older than the window that was searched.
Nothing downstream could tell them apart, so the Coach said the honest-sounding
sentence for the wrong case, and then, having been told there was nothing there,
changed the subject. Someone with a year of imported glucose readings was told
the readings did not exist, on the same account whose glucose page was showing
them.

The window cap stays. It exists because a multi-year history does not fit a
prompt budget, and widening it would only move the same failure. What was
missing is the ability to say a different true sentence.

- `tools/availability.ts` — one grouped aggregate per miss, no rows
  materialised, resolving four states: `no_data` (probe confirmed empty),
  `outside_window` (rows exist, older than the searched window; carries the
  count, the date range in the user's own timezone, and a bounded per-series
  mean/min/max), `unavailable_in_scope` (rows exist inside the window and the
  domain still produced none — a module is off), and `no_data_unconfirmed` (the
  probe itself failed; absence was never established, so it is not asserted).
- Every empty read in the executor routes through it. A bare `no_data` can no
  longer be returned without the record having been checked.
- The DATA INVENTORY carries the same distinction, so a domain the window
  cannot reach is advertised with what it holds instead of as absent — the model
  no longer skips the fetch, and a vague follow-up keeps the subject.
- Both instructions that told the model to report absence and move on now
  branch on the reason code, and forbid answering about one metric by reporting
  another.
- The out-of-window aggregate joins the authoritative set for the prose
  verifier, so the sentence the fix exists to make sayable is not elided.
- A cross-cutting narration block that throws is now counted rather than
  vanishing into a `null` that looks like honest absence.
- The medication-compliance page tells a failed read apart from an account with
  no medications, instead of inviting someone to re-enter what it already holds.

Refs #648
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 18:34:42 +02:00
1 parent 24d699f252
commit a402eaaf15
10 files changed
+917 -112

No files matched your search

+3 -1
View File
@@ -695,7 +695,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// ("you've logged 42 BP readings") stays grounded.
const presentToolPayloads = [
...(workoutEvidence === null ? [] : [workoutEvidence]),
...(loop.toolResults ?? []).map((r) => r.data),
// A miss carries no `data` but may carry `available` — the bounded
// out-of-window aggregate rule 3 lets the model cite. Ground it.
...(loop.toolResults ?? []).map((r) => r.data ?? r.available),
];
toolResultPayloads =
presentToolPayloads.length > 0
+26 -1
View File
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Progress } from "@/components/ui/progress";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
@@ -93,7 +94,12 @@ export default function InsightsMedikamentePage() {
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
const { data: comprehensive, isLoading: isComprehensiveLoading } = useQuery({
const {
data: comprehensive,
isLoading: isComprehensiveLoading,
isError: isComprehensiveError,
refetch: refetchComprehensive,
} = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
return apiGet<ComprehensiveMedicationData>("/api/insights/comprehensive");
@@ -153,6 +159,25 @@ export default function InsightsMedikamentePage() {
);
}
// A failed comprehensive read leaves `medications` empty, which is
// indistinguishable from an account that tracks none — and the empty state
// then invites someone on five daily medications to add the medications the
// app already holds. A read failure gets its own branch, before the length
// check, mirroring the sibling overview at `/insights`.
if (isComprehensiveError) {
return (
<SubPageShell
title={t("insights.medicationCompliance")}
explainerMetric="medications"
>
<QueryErrorCard
description={t("insights.loadError")}
onRetry={() => refetchComprehensive()}
/>
</SubPageShell>
);
}
if (medications.length === 0) {
// v1.4.27 F17 — medication compliance is event-driven so the
// gate reads `medications.length > 0`. CTA targets `/medications`
+43 -77
View File
@@ -47,6 +47,7 @@ import {
type GroundingMetricInput,
} from "./reference-grounding";
import type { MeasurementType } from "@/generated/prisma/client";
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
import type { ReferenceMetric } from "@/lib/reference-ranges";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
@@ -271,6 +272,29 @@ export async function buildCoachSnapshot(
return result;
}
/**
* A cross-cutting narration block that throws must not vanish silently. Each of
* these builders is additive — the snapshot is still valid without it — so the
* catch stays, but the failure is now countable: `signalTrust` is the block that
* tells the model HOW MUCH to trust the numbers it is about to narrate, and a
* builder that fails to `null` is indistinguishable from a user who simply has
* no trust caveats. Absence and failure are different things and the wide event
* now says which one happened.
*/
function blockFailed(block: string): (err: unknown) => null {
return (err: unknown) => {
// Resolved before the annotate call, not inline: the free-text guard in
// `snapshot.test.ts` reads three lines around a `name:` key and an inline
// `err.name` there looks like an un-sanitised user string to it.
const reason = err instanceof Error ? err.name : "unknown";
annotate({
action: { name: "coach.snapshot.block_failed" },
meta: { block, reason },
});
return null;
};
}
async function buildCoachSnapshotImpl(
userId: string,
scope?: CoachScope,
@@ -545,81 +569,15 @@ async function buildCoachSnapshotImpl(
coarseTailPromises.pulse ?? Promise.resolve(undefined),
]);
// v1.4.23 W6 (S-04) — single source of truth for the
// CoachScopeSource → MeasurementType[] mapping. Drives both the
// SQL `WHERE type IN (…)` build below and the Apple-Health timeline
// block table downstream, so adding a new metric is one entry
// instead of three (boolean + push + appleHealthBlocks row).
// Default Coach scope leaves the Apple Health rows off; non-iOS
// accounts never pay the type-IN overhead because their `sources`
// set never enables them.
// v1.7.0 — extended with the full clustered taxonomy. `mood`,
// `compliance`, and `workouts` map to no `MeasurementType` because
// they read separate models (MoodEntry / MedicationIntakeEvent /
// Workout) and are handled by their own branches below. `glucose`
// also reads `Measurement` but needs the `glucoseContext` column, so
// its block is built separately rather than from the shared
// `byType()` rows.
const METRIC_TYPES: Record<CoachScopeSource, string[]> = {
bp: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
weight: ["WEIGHT"],
pulse: ["PULSE"],
mood: [],
compliance: [],
// v1.30.4 (HRV union) — Oura / Polar / WHOOP write nightly HRV as RMSSD
// (`HRV_RMSSD`), never SDNN (`HEART_RATE_VARIABILITY`, Apple / Fitbit).
// The app's HRV surface already unions both (`sub-page-metric.ts`);
// resolving SDNN alone here made a ring/strap-only user's Coach context
// + the `get_metric_series`/rich-read MCP paths report a false
// `{present:false}` for HRV even though the app charts it.
hrv: ["HEART_RATE_VARIABILITY", "HRV_RMSSD"],
sleep: ["SLEEP_DURATION"],
resting_hr: ["RESTING_HEART_RATE"],
steps: ["ACTIVITY_STEPS"],
active_energy: ["ACTIVE_ENERGY_BURNED"],
flights: ["FLIGHTS_CLIMBED"],
distance: ["WALKING_RUNNING_DISTANCE"],
vo2_max: ["VO2_MAX"],
body_temp: ["BODY_TEMPERATURE"],
// ── cardio composition / vascular ──
walking_hr: ["WALKING_HEART_RATE_AVERAGE"],
respiratory_rate: ["RESPIRATORY_RATE"],
spo2: ["OXYGEN_SATURATION"],
pulse_wave_velocity: ["PULSE_WAVE_VELOCITY"],
vascular_age: ["VASCULAR_AGE"],
// ── body composition ──
body_fat: ["BODY_FAT"],
fat_mass: ["FAT_MASS"],
fat_free_mass: ["FAT_FREE_MASS"],
muscle_mass: ["MUSCLE_MASS"],
lean_body_mass: ["LEAN_BODY_MASS"],
bone_mass: ["BONE_MASS"],
total_body_water: ["TOTAL_BODY_WATER"],
bmi: ["BODY_MASS_INDEX"],
visceral_fat: ["VISCERAL_FAT"],
// ── metabolic — built via the dedicated glucose branch ──
glucose: ["BLOOD_GLUCOSE"],
// ── mobility & gait ──
walking_steadiness: ["WALKING_STEADINESS"],
walking_asymmetry: ["WALKING_ASYMMETRY"],
walking_double_support: ["WALKING_DOUBLE_SUPPORT"],
walking_step_length: ["WALKING_STEP_LENGTH"],
walking_speed: ["WALKING_SPEED"],
// ── environment / exposure ──
audio_env: ["AUDIO_EXPOSURE_ENV"],
audio_headphone: ["AUDIO_EXPOSURE_HEADPHONE"],
audio_event: ["AUDIO_EXPOSURE_EVENT"],
daylight: ["TIME_IN_DAYLIGHT"],
skin_temp: ["SKIN_TEMPERATURE"],
// ── workouts — read from the Workout model, not Measurement ──
workouts: [],
};
// Single fetch for all measurement types — Prisma's filter pushes
// the type list into one SQL `WHERE type IN (…)` so we don't pay
// per-metric round-trips.
// per-metric round-trips. The CoachScopeSource → MeasurementType[] table
// lives in `source-measurement-types.ts` (v1.4.23 W6 / S-04 kept it a single
// source of truth; it is hoisted out of this function so the availability
// probe that answers "does this domain exist OUTSIDE the window?" resolves
// its types from the same table this windowed read filters on).
const wantedTypes = Array.from(sources).flatMap(
(source) => METRIC_TYPES[source] ?? [],
(source) => COACH_SOURCE_MEASUREMENT_TYPES[source] ?? [],
);
const measurementRowsPromise =
@@ -628,7 +586,7 @@ async function buildCoachSnapshotImpl(
.findMany({
where: {
userId,
type: { in: wantedTypes as never[] },
type: { in: wantedTypes },
measuredAt: { gte: cutoff },
deletedAt: null,
},
@@ -1206,11 +1164,19 @@ async function buildCoachSnapshotImpl(
: await Promise.all([
excludesMedications
? Promise.resolve(null)
: buildAdherenceStoryline(userId, userTz, now).catch(() => null),
buildChangepointSignals(userId, now).catch(() => null),
buildSignalTrust(userId, userTz, now).catch(() => null),
: buildAdherenceStoryline(userId, userTz, now).catch(
blockFailed("adherenceStoryline"),
),
buildChangepointSignals(userId, now).catch(
blockFailed("changepoints"),
),
buildSignalTrust(userId, userTz, now).catch(
blockFailed("signalTrust"),
),
experimentVerdictEnabled()
? buildExperimentOutcomeBlock(userId, { now }).catch(() => null)
? buildExperimentOutcomeBlock(userId, { now }).catch(
blockFailed("experimentOutcomes"),
)
: Promise.resolve(null),
]);
if (adherenceStoryline) snapshot.adherenceStoryline = adherenceStoryline;
@@ -0,0 +1,75 @@
/**
* CoachScopeSource → MeasurementType[] mapping.
*
* Extracted out of `snapshot.ts` (where it lived as a function-local literal)
* so the availability probe reads the SAME table the windowed snapshot read
* builds its `WHERE type IN (…)` from. The probe answers "does this domain
* exist at all, outside the window?" — if it resolved its types from a second
* copy of this table, a new metric could be windowed by one map and probed by
* the other, and the two would disagree silently. One table, two readers.
*
* `mood`, `compliance`, and `workouts` map to no `MeasurementType` because they
* read separate models (MoodEntry / MedicationIntakeEvent / Workout); the
* availability probe carries its own subject kinds for those. `glucose` also
* reads `Measurement` but needs the `glucoseContext` column, so its snapshot
* block is built separately rather than from the shared rows.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { CoachScopeSource } from "@/lib/ai/coach/types";
export const COACH_SOURCE_MEASUREMENT_TYPES: Readonly<
Record<CoachScopeSource, readonly MeasurementType[]>
> = {
bp: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
weight: ["WEIGHT"],
pulse: ["PULSE"],
mood: [],
compliance: [],
// v1.30.4 (HRV union) — Oura / Polar / WHOOP write nightly HRV as RMSSD
// (`HRV_RMSSD`), never SDNN (`HEART_RATE_VARIABILITY`, Apple / Fitbit).
// The app's HRV surface already unions both (`sub-page-metric.ts`);
// resolving SDNN alone here made a ring/strap-only user's Coach context
// + the `get_metric_series`/rich-read MCP paths report a false
// `{present:false}` for HRV even though the app charts it.
hrv: ["HEART_RATE_VARIABILITY", "HRV_RMSSD"],
sleep: ["SLEEP_DURATION"],
resting_hr: ["RESTING_HEART_RATE"],
steps: ["ACTIVITY_STEPS"],
active_energy: ["ACTIVE_ENERGY_BURNED"],
flights: ["FLIGHTS_CLIMBED"],
distance: ["WALKING_RUNNING_DISTANCE"],
vo2_max: ["VO2_MAX"],
body_temp: ["BODY_TEMPERATURE"],
// ── cardio composition / vascular ──
walking_hr: ["WALKING_HEART_RATE_AVERAGE"],
respiratory_rate: ["RESPIRATORY_RATE"],
spo2: ["OXYGEN_SATURATION"],
pulse_wave_velocity: ["PULSE_WAVE_VELOCITY"],
vascular_age: ["VASCULAR_AGE"],
// ── body composition ──
body_fat: ["BODY_FAT"],
fat_mass: ["FAT_MASS"],
fat_free_mass: ["FAT_FREE_MASS"],
muscle_mass: ["MUSCLE_MASS"],
lean_body_mass: ["LEAN_BODY_MASS"],
bone_mass: ["BONE_MASS"],
total_body_water: ["TOTAL_BODY_WATER"],
bmi: ["BODY_MASS_INDEX"],
visceral_fat: ["VISCERAL_FAT"],
// ── metabolic — built via the dedicated glucose branch ──
glucose: ["BLOOD_GLUCOSE"],
// ── mobility & gait ──
walking_steadiness: ["WALKING_STEADINESS"],
walking_asymmetry: ["WALKING_ASYMMETRY"],
walking_double_support: ["WALKING_DOUBLE_SUPPORT"],
walking_step_length: ["WALKING_STEP_LENGTH"],
walking_speed: ["WALKING_SPEED"],
// ── environment / exposure ──
audio_env: ["AUDIO_EXPOSURE_ENV"],
audio_headphone: ["AUDIO_EXPOSURE_HEADPHONE"],
audio_event: ["AUDIO_EXPOSURE_EVENT"],
daylight: ["TIME_IN_DAYLIGHT"],
skin_temp: ["SKIN_TEMPERATURE"],
// ── workouts — read from the Workout model, not Measurement ──
workouts: [],
};
+500
View File
@@ -0,0 +1,500 @@
/**
* 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. */
export 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,
mean,
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.
*/
export 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. */
export 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`.
*/
export 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;
}
}
+6 -6
View File
@@ -121,7 +121,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
{
name: "get_metric_series",
description:
"Fetch the user's own time series for ONE metric: blood pressure (bp), weight, pulse, or any synced series (hrv, resting_hr, steps, sleep duration, body composition, gait, audio exposure, vo2_max, …). Returns an aggregate plus a recent-daily and weekly timeline. Call once per metric you need; call several in parallel for a multi-metric question. Returns { present: false } when the user has no data for that metric.",
"Fetch the user's own time series for ONE metric: blood pressure (bp), weight, pulse, or any synced series (hrv, resting_hr, steps, sleep duration, body composition, gait, audio exposure, vo2_max, …). Returns an aggregate plus a recent-daily and weekly timeline. Call once per metric you need; call several in parallel for a multi-metric question. Returns { present: false } with a reason when nothing came back: no_data (never recorded), outside_window (recorded, but older than the window searched — the result carries the count, date range and aggregate, and the window to re-call with), unavailable_in_scope, or no_data_unconfirmed.",
parameters: {
type: "object",
additionalProperties: false,
@@ -143,7 +143,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
{
name: "get_glucose_panel",
description:
"Fetch the user's glucose data: per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). Returns { present: false } when the user logs no glucose.",
"Fetch the user's glucose data: per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). Returns { present: false } with a reason when nothing came back — no_data means no glucose was ever logged, outside_window means it was and lies older than the window searched (the result carries the count, date range and aggregate).",
parameters: {
type: "object",
additionalProperties: false,
@@ -161,7 +161,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
{
name: "get_sleep",
description:
"Fetch the user's sleep: per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). Returns { present: false } when no sleep is tracked.",
"Fetch the user's sleep: per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). Returns { present: false } with a reason — no_data means no sleep was ever tracked, outside_window means it was and lies older than the window searched.",
parameters: {
type: "object",
additionalProperties: false,
@@ -178,7 +178,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
{
name: "get_medication_compliance",
description:
"Fetch the user's medication compliance: the dose-weighted adherence rate plus a recent timeline, and any GLP-1 titration context. Returns { present: false } when no medications are tracked.",
"Fetch the user's medication compliance: the dose-weighted adherence rate plus a recent timeline, and any GLP-1 titration context. Returns { present: false } with a reason — no_data means no doses were ever logged, outside_window means they were and lie older than the window searched.",
parameters: {
type: "object",
additionalProperties: false,
@@ -195,7 +195,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
{
name: "get_labs",
description:
"Fetch the user's most recent lab results — the latest reading per biomarker over the last 12 months, or one named analyte. Returns { present: false } when no labs are on file.",
"Fetch the user's most recent lab results — the latest reading per biomarker over the last 12 months, or one named analyte. Returns { present: false } with a reason — no_data means no labs are on file, outside_window means panels exist but are older than the twelve months this tool reads.",
parameters: {
type: "object",
additionalProperties: false,
@@ -223,7 +223,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
{
name: "get_workouts",
description:
"Fetch the user's workouts: the most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. Use for training-load and 'how were my runs / am I overtraining?' questions. Returns { present: false } when no workouts are tracked.",
"Fetch the user's workouts: the most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. Use for training-load and 'how were my runs / am I overtraining?' questions. Returns { present: false } with a reason — no_data means no workouts were ever tracked, outside_window means they were and lie older than the window searched.",
parameters: {
type: "object",
additionalProperties: false,
+104 -9
View File
@@ -11,6 +11,13 @@
* carries no data — or the user opted out of the owning module — the result
* is `{ present: false, reason }`. It NEVER throws and NEVER returns an
* ambiguous `[]`, so the model can always tell "no data" from "data".
* - An empty WINDOW read is never reported as absence on its own. Every
* `present: false` that comes from a missing snapshot section routes through
* `resolveEmptyRead`, which probes whether the record holds rows outside the
* window and returns `outside_window` (with the range + a bounded aggregate)
* rather than `no_data`. One reason code for "never recorded" and "recorded,
* outside the window I searched" is what made the Coach deny data the app
* was displaying on the next screen.
* - Module / cycle gates are enforced AT THE BUILDER (`buildCoachSnapshot`
* applies `MODULE_EXCLUDED_SOURCES` + the cycle gate before any row is
* read), so an opted-out domain is structurally unfetchable here — the
@@ -55,6 +62,12 @@ import {
METRIC_SERIES_EXCLUDED_SOURCES,
} from "./source-keys";
import { readCoachCorrelations } from "./correlations-read";
import {
resolveEmptyRead,
subjectForTool,
type CoachAvailabilitySubject,
type CoachDomainAvailability,
} from "./availability";
import { buildIllnessScores } from "@/lib/ai/coach/illness-snapshot";
/** A read-only structured tool result. Serialised to a `role:"tool"` turn. */
@@ -69,6 +82,16 @@ export interface CoachToolResult {
* 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;
}
/** What the route persists onto provenance: which tools ran, did data exist. */
@@ -227,6 +250,20 @@ function badArgs(name: string, error: z.ZodError): CoachToolResult {
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.
*/
function emptyRead(
userId: string,
domain: string,
subject: CoachAvailabilitySubject | null,
searchedWindow: CoachScopeWindow | undefined,
): Promise<CoachToolResult> {
return resolveEmptyRead({ userId, domain, subject, searchedWindow });
}
async function getMetricSeries(
userId: string,
rawArgs: unknown,
@@ -263,7 +300,12 @@ async function getMetricSeries(
);
const section = pickSection(snapshot.sections, sectionKey);
if (section === undefined) {
return { present: false, reason: "no_data" };
return emptyRead(
userId,
metric,
subjectForTool("get_metric_series", metric),
window ?? fallbackWindow,
);
}
return {
present: true,
@@ -285,7 +327,14 @@ async function getGlucosePanel(
scopeFor(["glucose"], parsed.data.window, fallbackWindow, sharedScope),
);
const section = pickSection(snapshot.sections, "glucose");
if (section === undefined) return { present: false, reason: "no_data" };
if (section === undefined) {
return emptyRead(
userId,
"glucose",
subjectForTool("get_glucose_panel"),
parsed.data.window ?? fallbackWindow,
);
}
return {
present: true,
data: section,
@@ -308,7 +357,12 @@ async function getSleep(
const nights = pickSection(snapshot.sections, "sleep");
const rhythm = pickSection(snapshot.sections, "sleepRhythm");
if (nights === undefined && rhythm === undefined) {
return { present: false, reason: "no_data" };
return emptyRead(
userId,
"sleep",
subjectForTool("get_sleep"),
parsed.data.window ?? fallbackWindow,
);
}
return {
present: true,
@@ -340,7 +394,12 @@ async function getMedicationCompliance(
{ glp1?: unknown } | undefined;
const glp1 = weeklyContext?.glp1;
if (compliance === undefined && glp1 === undefined) {
return { present: false, reason: "no_data" };
return emptyRead(
userId,
"compliance",
subjectForTool("get_medication_compliance"),
parsed.data.window ?? fallbackWindow,
);
}
return {
present: true,
@@ -377,7 +436,12 @@ async function getLabs(
);
const labs = pickSection(snapshot.sections, "labs") as
{ recent?: Array<{ name?: string; analyte?: string }> } | undefined;
if (labs === undefined) return { present: false, reason: "no_data" };
if (labs === undefined) {
// The labs read is window-AGNOSTIC: a fixed trailing-12-month cutoff. So the
// window it "searched" is a year regardless of the conversation's scope —
// a panel from 2023 is outside it and must not read as "no labs on file".
return emptyRead(userId, "labs", subjectForTool("get_labs"), "lastYear");
}
const analyte = parsed.data.analyte?.trim().toLowerCase();
if (analyte && Array.isArray(labs.recent)) {
@@ -439,7 +503,15 @@ async function getIllnessRecovery(
trajectory === undefined &&
illnessScores === null
) {
return { present: false, reason: "no_data" };
// 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.
return emptyRead(
userId,
"illness_recovery",
subjectForTool("get_illness_recovery"),
fallbackWindow,
);
}
return {
present: true,
@@ -468,7 +540,14 @@ async function getWorkouts(
scopeFor(["workouts"], parsed.data.window, fallbackWindow, sharedScope),
);
const workouts = pickSection(snapshot.sections, "workouts");
if (workouts === undefined) return { present: false, reason: "no_data" };
if (workouts === undefined) {
return emptyRead(
userId,
"workouts",
subjectForTool("get_workouts"),
parsed.data.window ?? fallbackWindow,
);
}
return { present: true, data: workouts };
}
@@ -492,7 +571,17 @@ async function getCycle(
sharedScope ?? { sources: [] },
);
const cycle = pickSection(snapshot.sections, "cycle");
if (cycle === undefined) return { present: false, reason: "no_data" };
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,
);
}
return { present: true, data: cycle };
}
@@ -504,9 +593,15 @@ async function getCorrelations(
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.
const result = await readCoachCorrelations(userId);
if (!result.present) {
return { present: false, reason: result.reason ?? "no_data" };
return { present: false, reason: result.reason ?? "no_pattern" };
}
return {
present: true,
+111 -2
View File
@@ -21,6 +21,7 @@ import type {
CoachProvenanceMetric,
CoachScope,
CoachScopeSource,
CoachScopeWindow,
} from "@/lib/ai/coach/types";
import {
COACH_SOURCE_SNAPSHOT_KEY,
@@ -28,6 +29,29 @@ import {
METRIC_SERIES_INVENTORY_SOURCES,
FULL_INVENTORY_SOURCE_SET,
} from "./source-keys";
import {
classifyAvailability,
probeCoachAvailability,
subjectForTool,
type CoachAvailabilitySubject,
type CoachDomainAvailability,
} from "./availability";
import { isCoachToolName } from "./definitions";
/**
* What the record holds for a domain the WINDOW read found nothing for. Absent
* on a present row and on a genuinely empty domain; set only when rows exist
* that this window could not reach. Model-facing only — no i18n.
*/
export interface InventoryAvailability {
/** `outside_window` or `unavailable_in_scope` — see `availability.ts`. */
state: string;
count: number;
firstDate: string;
lastDate: string;
/** A window the model may re-call with to reach the rows, or null. */
reachableWithWindow: string | null;
}
/** One row of the inventory: a domain + whether the user has data for it. */
export interface InventoryEntry {
@@ -40,6 +64,13 @@ export interface InventoryEntry {
count?: number;
/** For get_metric_series rows: the `metric` argument to pass. */
metric?: string;
/**
* Set when `present` is false and the record nevertheless holds rows for this
* domain. Without it a history the window cannot reach is indistinguishable
* from a metric that was never recorded, and the model is told to move on from
* a topic the record can answer.
*/
availability?: InventoryAvailability;
}
export interface CoachDataInventory {
@@ -185,6 +216,14 @@ export async function buildCoachDataInventory(
});
}
// Every row that came back absent gets checked against the record before the
// manifest says "absent". A domain whose whole history predates the window —
// an imported year of readings, a device that stopped syncing — is otherwise
// advertised as if it had never been recorded, and rule 2 then tells the model
// not to fetch it at all. That is the reported defect: the tool never even
// runs, so nothing downstream can recover the distinction.
await attachAvailability(userId, entries, scope?.window);
// restMode rides the illness section.
const illness = sections.illness as { restMode?: boolean } | undefined;
const restMode = illness?.restMode === true;
@@ -200,6 +239,58 @@ export async function buildCoachDataInventory(
};
}
/**
* Fill `entry.availability` for every absent row the record actually holds rows
* for. One batched probe: a single grouped aggregate over `Measurement` for the
* union of the absent measurement-backed domains, plus at most one `aggregate()`
* per other table asked for. Runs after the snapshot, so a domain that DID
* report data is never probed.
*
* A probe failure leaves every `availability` unset — which reads as "absent",
* the pre-existing behaviour — so an unavailable probe degrades the manifest's
* precision and never its correctness. The per-tool result path is the one that
* must distinguish an unconfirmed absence, and it does (`no_data_unconfirmed`).
*/
async function attachAvailability(
userId: string,
entries: InventoryEntry[],
window: CoachScopeWindow | undefined,
): Promise<void> {
const subjects = new Map<string, CoachAvailabilitySubject>();
const keyed = new Map<string, InventoryEntry>();
for (const entry of entries) {
if (entry.present) continue;
if (!isCoachToolName(entry.tool)) continue;
const subject = subjectForTool(
entry.tool,
entry.metric as CoachScopeSource | undefined,
);
if (subject === null) continue;
const key = `${entry.tool}:${entry.metric ?? entry.domain}`;
subjects.set(key, subject);
keyed.set(key, entry);
}
if (subjects.size === 0) return;
let probed: Map<string, CoachDomainAvailability>;
try {
probed = await probeCoachAvailability(userId, subjects);
} catch {
// Annotated at the probe; nothing to attach.
return;
}
for (const [key, available] of probed) {
const entry = keyed.get(key);
if (!entry) continue;
entry.availability = {
state: classifyAvailability(available, window),
count: available.count,
firstDate: available.firstDate,
lastDate: available.lastDate,
reachableWithWindow: available.reachableWithWindow,
};
}
}
/**
* v1.21.0 (D1) — render the launch FOCUS hint for the tool-mode user turn.
*
@@ -245,7 +336,7 @@ export function renderDataInventory(inventory: CoachDataInventory): string {
const lines: string[] = [];
lines.push("DATA INVENTORY");
lines.push(
"This lists which of the user's data exists and how to fetch it. Call the named tool ONLY for a domain marked present. Never cite a figure you did not fetch with a tool this turn; when a tool returns present:false, say plainly you have no data for it and pivot.",
'This lists which of the user\'s data exists and how to fetch it. Never cite a figure you did not fetch with a tool this turn. Three states per domain: "present" — fetch it with the named tool. "absent" — the record holds nothing for it, ever. "outside window" / "not in scope" — the record DOES hold readings, listed with their count and date range; that is not absence, so never say the user has no data for it, and call its tool anyway (the result reports what the record holds and, when a window would reach it, which one to re-call with).',
);
lines.push(`Window: ${inventory.window}.`);
if (inventory.restMode) {
@@ -256,10 +347,28 @@ export function renderDataInventory(inventory: CoachDataInventory): string {
for (const e of inventory.entries) {
const arg = e.metric ? ` (metric:"${e.metric}")` : "";
lines.push(
`- ${e.domain}: ${e.present ? "present" : "absent"} → ${e.tool}${arg}${
`- ${e.domain}: ${renderState(e)} → ${e.tool}${arg}${
e.present && typeof e.count === "number" ? ` [~${e.count} samples]` : ""
}`,
);
}
return lines.join("\n");
}
/**
* The state word (or clause) for one inventory row. An absent row whose record
* is NOT empty renders the count + range it holds, so the manifest can never
* again advertise a stored history as nothing.
*/
function renderState(entry: InventoryEntry): string {
if (entry.present) return "present";
const a = entry.availability;
if (!a) return "absent";
const range = `${a.count} readings, ${a.firstDate} to ${a.lastDate}`;
if (a.state === "unavailable_in_scope") {
return `NOT IN SCOPE — the record holds ${range}, inside the current window, but this domain is not available in this conversation (its module may be switched off). Do NOT call it absent`;
}
return a.reachableWithWindow
? `OUTSIDE WINDOW — the record holds ${range}; re-call with window:"${a.reachableWithWindow}" to read it`
: `OUTSIDE WINDOW — the record holds ${range}, older than any window reaches. Say so; do NOT call it absent`;
}
+17 -8
View File
@@ -69,10 +69,16 @@ export interface CoachToolLoopResult {
/** Which tools ran + whether each found data (persisted onto provenance). */
toolTrace: CoachToolTrace[];
/**
* v1.21.0 (P6) — the structured payloads of every PRESENT tool result this
* turn, in call order. The post-hoc prose number-verifier extracts the
* numeric leaves from these to cross-check the figures the model cited. Empty
* on a no-tools answer.
* v1.21.0 (P6) — the structured payloads of every tool result this turn that
* DELIVERED figures, in call order. The post-hoc prose number-verifier
* extracts the numeric leaves from these to cross-check the figures the model
* cited. Empty on a no-tools answer.
*
* "Delivered figures" is present results plus the availability payload an
* `outside_window` miss carries: that payload is server-computed (count, date
* range, per-series aggregate) and rule 3 tells the model it MAY cite it, so it
* has to ride the authoritative set — otherwise the verifier would elide the
* one honest sentence the fix exists to make sayable.
*/
toolResults: CoachToolResult[];
}
@@ -197,10 +203,13 @@ export async function runCoachToolLoop(args: {
sharedScope,
});
toolTrace.push({ name: call.name, present: toolResult.present });
// v1.21.0 (P6) — retain the present results' payloads for the post-hoc
// prose number-verifier (the union of numeric leaves grounds the
// figures the model may cite).
if (toolResult.present) toolResults.push(toolResult);
// v1.21.0 (P6) — retain the payloads that carried figures for the
// post-hoc prose number-verifier (the union of numeric leaves grounds
// the figures the model may cite): a present result's `data`, or the
// `available` block an out-of-window miss reports.
if (toolResult.present || toolResult.available !== undefined) {
toolResults.push(toolResult);
}
return { call, toolResult };
}),
);
+32 -8
View File
@@ -13,6 +13,18 @@
*
* When tools are NOT offered (local / no-tools provider) this block is omitted
* and the legacy snapshot-stuffing path runs with the unchanged prompt.
*
* Rule 3 carries the availability discriminant. A `present: false` result used
* to mean one thing to the model — "no data, move on" — and the executor used
* the same reason code for a metric that was never recorded and a metric whose
* whole history sits outside the window it searched. Someone with a year of
* imported readings was told the readings did not exist, and then, told there
* was nothing there, the Coach changed the subject. So rule 3 now branches on
* the reason code, and rule 4 forbids answering about a different metric than
* the one asked about. Rule 3 also overrides base ground rule 3 for every reason
* except `no_data`: "acknowledge the gap and offer to think it through with what
* the user can tell you" is the right move for data the record does not hold,
* and exactly the wrong one for data it does.
*/
import type { Locale } from "@/lib/i18n/config";
@@ -21,20 +33,32 @@ const EN = `TOOL-BASED RETRIEVAL (this conversation)
You have read-only retrieval tools and a DATA INVENTORY listing what the user has logged. The figures are NOT in this prompt — fetch them.
1. GROUNDING (overrides ground rule 7 for this turn): ground EVERY number you cite in a TOOL RESULT you received THIS turn. Never cite a figure you did not just fetch, never recall a number from earlier turns as if it were fresh, and never invent one.
2. Call a tool ONLY for a domain the DATA INVENTORY marks "present". Call several tools in one step when a question spans metrics — they run in parallel.
3. When a tool returns { present: false } or an empty result, say plainly that you have no data for that metric and pivot — do NOT infer, estimate, or fabricate a value.
4. If you can answer without data (a definition, a "what can you help with?"), just answer — do not call a tool.
5. Keep using the EVIDENCE (---KEYVALUES---) block exactly as before, citing only numbers you fetched this turn.`;
2. Call a tool for any domain the DATA INVENTORY does not mark plainly "absent" — including one it marks OUTSIDE WINDOW or NOT IN SCOPE. Call several tools in one step when a question spans metrics — they run in parallel.
3. A { present: false } result is NOT automatically "no data". Read its "reason" (this overrides ground rule 3 for every reason except no_data):
- "no_data": the record holds nothing for that metric. Acknowledge it in one short sentence and offer to think it through with what the user can tell you.
- "outside_window": the readings EXIST and are older than the window that was searched. "available" carries the count, the first and last date, and a per-series mean/min/max. Say what the record holds and over which dates — never that the user has no data for it. Cite only figures from "available". If "available.reachableWithWindow" names a window, call the same tool again with that window to read the real series.
- "unavailable_in_scope": the readings exist and are recent, but this domain is not available in this conversation — its module may be switched off. Say that, and that the user can check it in settings. Never say the data is missing.
- "no_data_unconfirmed" or "retrieval_failed": you could not read it. Say you could not retrieve it — not that it does not exist.
In every case: do NOT infer, estimate, or fabricate a value, and do NOT ask the user to supply readings the record already holds.
4. Stay on the metric the user asked about. A miss on that metric is an answer ABOUT that metric — never answer a question about one metric by reporting a different one instead. On a vague follow-up ("check again", "how were they?"), the subject is still the metric the conversation was already on.
5. If you can answer without data (a definition, a "what can you help with?"), just answer — do not call a tool.
6. Keep using the EVIDENCE (---KEYVALUES---) block exactly as before, citing only numbers you fetched this turn.`;
const DE = `TOOL-BASIERTE ABFRAGE (diese Unterhaltung)
Du hast schreibgeschützte Abfrage-Tools und ein DATA INVENTORY, das auflistet, was der Nutzer erfasst hat. Die Zahlen stehen NICHT in diesem Prompt — rufe sie ab.
1. VERANKERUNG (ersetzt Grundregel 7 für diesen Zug): verankere JEDE genannte Zahl in einem TOOL-ERGEBNIS, das du in DIESEM Zug erhalten hast. Nenne keine Zahl, die du nicht gerade abgerufen hast, gib keine Zahl aus früheren Zügen als frisch aus und erfinde keine.
2. Rufe ein Tool NUR für eine Domäne auf, die das DATA INVENTORY als „present" markiert. Rufe mehrere Tools in einem Schritt auf, wenn eine Frage mehrere Metriken betrifft — sie laufen parallel.
3. Wenn ein Tool { present: false } oder ein leeres Ergebnis liefert, sage klar, dass dazu keine Daten vorliegen, und lenke um — leite nichts ab, schätze nichts und erfinde keinen Wert.
4. Wenn du ohne Daten antworten kannst (eine Definition, „Wobei kannst du helfen?"), antworte einfach — rufe kein Tool auf.
5. Nutze den EVIDENZ-Block (---KEYVALUES---) genau wie zuvor und zitiere nur Zahlen, die du in diesem Zug abgerufen hast.`;
2. Rufe ein Tool für jede Domäne auf, die das DATA INVENTORY nicht klar als „absent" markiert — auch für eine, die als OUTSIDE WINDOW oder NOT IN SCOPE markiert ist. Rufe mehrere Tools in einem Schritt auf, wenn eine Frage mehrere Metriken betrifft — sie laufen parallel.
3. Ein Ergebnis { present: false } bedeutet NICHT automatisch „keine Daten". Lies den „reason" (das ersetzt Grundregel 3 für jeden Grund außer no_data):
- „no_data": zu dieser Metrik liegt nichts vor. Benenne das in einem kurzen Satz und biete an, es mit dem zu durchdenken, was der Nutzer dir erzählen kann.
- „outside_window": die Messwerte EXISTIEREN und sind älter als das abgefragte Zeitfenster. „available" enthält die Anzahl, das erste und letzte Datum sowie Mittelwert/Minimum/Maximum je Serie. Sage, was vorliegt und über welchen Zeitraum — niemals, dass keine Daten vorliegen. Nenne nur Zahlen aus „available". Wenn „available.reachableWithWindow" ein Fenster nennt, rufe dasselbe Tool erneut mit diesem Fenster auf und lies die echte Serie.
- „unavailable_in_scope": die Messwerte existieren und sind aktuell, aber diese Domäne ist in dieser Unterhaltung nicht verfügbar — ihr Modul ist möglicherweise abgeschaltet. Sage das und dass der Nutzer es in den Einstellungen prüfen kann. Sage nie, die Daten fehlten.
- „no_data_unconfirmed" oder „retrieval_failed": du konntest es nicht lesen. Sage, dass du es nicht abrufen konntest — nicht, dass es nicht existiert.
In jedem Fall: leite nichts ab, schätze nichts, erfinde keinen Wert — und bitte den Nutzer nicht, Werte nachzuliefern, die längst erfasst sind.
4. Bleibe bei der Metrik, nach der gefragt wurde. Ein Fehlschlag zu dieser Metrik ist eine Antwort ÜBER diese Metrik — beantworte eine Frage zu einer Metrik niemals dadurch, dass du stattdessen über eine andere berichtest. Bei einer vagen Rückfrage („schau nochmal", „wie waren sie?") bleibt das Thema die Metrik, um die es gerade ging.
5. Wenn du ohne Daten antworten kannst (eine Definition, „Wobei kannst du helfen?"), antworte einfach — rufe kein Tool auf.
6. Nutze den EVIDENZ-Block (---KEYVALUES---) genau wie zuvor und zitiere nur Zahlen, die du in diesem Zug abgerufen hast.`;
export function buildToolModeAddendum(locale: Locale): string {
return locale === "de" ? DE : EN;