Make the Coach lookback a limit on every read

The saved Coach window only seeded the snapshot and each tool's default.
A tool could still read back to all time, the availability probe reported
counts and means for history outside the window, and several blocks read
fixed windows of their own (labs 12 months, correlations 180 days, the
coarse tail, the all-time extremes) whatever the setting said.

coachPrefs.defaultWindow is now enforced as a limit through one module,
history-reach.ts: the turn context clamps the client window, the snapshot
clamps its window and gives every fixed-window block either the shorter
window or, when the block's own window cannot fit, leaves it out. Every
retrieval tool clamps its window, an earlier period or a stored table that
reaches past the limit answers outside_reach, and the probe reads only
inside the limit and names older history as outside_reach without any
figure. Follow-up chips that would read past the limit are not offered.
The default (allTime) is no limit and reads as before. MCP clients pass no
limit and keep their own window arguments.

Adds the twelve-month option, rewrites the picker text to state what is
read with the numbers the readers use, shows the same options in the
managed-profile settings, counts cluster members with a singular, and
drops the opacity on switched-off rows that put their text below WCAG AA.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 20:45:21 +02:00
1 parent 3be83eee22
commit 393ac955f6
60 files changed
+2539 -304

No files matched your search

+12
View File
@@ -25929,7 +25929,10 @@ components:
- last7days
- last30days
- last90days
- lastYear
- allTime
description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime`
(the default) is no limit. Formerly only the starting window; `lastYear` came with the limit."
dataClusters:
maxItems: 10
type: array
@@ -32076,7 +32079,10 @@ components:
- last7days
- last30days
- last90days
- lastYear
- allTime
description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime`
(the default) is no limit. Formerly only the starting window; `lastYear` came with the limit."
dataClusters:
maxItems: 10
type: array
@@ -48822,7 +48828,10 @@ components:
- last7days
- last30days
- last90days
- lastYear
- allTime
description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime`
(the default) is no limit. Formerly only the starting window; `lastYear` came with the limit."
dataClusters:
maxItems: 10
type: array
@@ -77933,7 +77942,10 @@ components:
- last7days
- last30days
- last90days
- lastYear
- allTime
description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime`
(the default) is no limit. Formerly only the starting window; `lastYear` came with the limit."
dataClusters:
maxItems: 10
type: array
+17 -7
View File
@@ -2930,7 +2930,6 @@
"lastYear": "letzte 12 Monate",
"allTime": "gesamter Zeitraum (12 Monate im Detail)"
},
"windowLabel": "Zeitraum",
"settingsAriaLabel": "Coach-Einstellungen",
"settingsTitle": "Coach-Einstellungen",
"settingsDescription": "Stell ein, wie der Coach mit dir spricht.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "Daten, die der Coach sieht",
"settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.",
"settingsClusterNoData": "Noch keine Daten",
"sourcesMemberCount": "{count} Werte",
"sourcesMemberCountOne": "1 Wert",
"sourcesMemberCountFew": "{count} Werte",
"sourcesMemberCountOther": "{count} Werte",
"lookback": {
"label": "Wie weit der Coach zurückblickt",
"option": {
"last7days": "{days} Tage",
"last30days": "{days} Tage",
"last90days": "{days} Tage",
"lastYear": "{months} Monate",
"allTime": "Alles"
},
"detailDays": "Der Coach liest nichts, was älter als {days} Tage ist, in keiner Antwort.",
"detailMonths": "Der Coach liest nichts, was älter als {months} Monate ist, in keiner Antwort.",
"detailAll": "Alles, was du erfasst hast: {dailyMonths} Monate tagesgenau, ältere Werte als Monatswerte bis {years} Jahre zurück, Laborwerte der letzten {labMonths} Monate.",
"note": "Dein Profil, aktuelle Medikamente, eine laufende Erkrankung und anstehende Termine bleiben sichtbar; Auswertungen, die mehr Verlauf brauchen, werden gekürzt oder weggelassen."
},
"showConversations": "Unterhaltungen anzeigen",
"hideConversations": "Unterhaltungen ausblenden",
"heroGreeting": "Frage mich etwas zu deinen Daten",
@@ -5090,11 +5105,6 @@
"brief": "Kurz",
"default": "Standard",
"detailed": "Ausführlich",
"defaultWindow": "Standardzeitraum",
"last7days": "Letzte 7 Tage",
"last30days": "Letzte 30 Tage",
"last90days": "Letzte 90 Tage",
"allTime": "Gesamter Zeitraum",
"excludedMetrics": "Ausgeschlossene Messwerte",
"dataClusters": "Datenbereiche",
"customClusters": "Eigene Auswahl von Datenbereichen verwenden",
+17 -7
View File
@@ -2930,7 +2930,6 @@
"lastYear": "last 12 months",
"allTime": "all time (12 months of detail)"
},
"windowLabel": "Window",
"settingsAriaLabel": "Coach settings",
"settingsTitle": "Coach settings",
"settingsDescription": "Tune how the Coach talks with you.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "Data the Coach sees",
"settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.",
"settingsClusterNoData": "No data yet",
"sourcesMemberCount": "{count} metrics",
"sourcesMemberCountOne": "1 metric",
"sourcesMemberCountFew": "{count} metrics",
"sourcesMemberCountOther": "{count} metrics",
"lookback": {
"label": "How far back the Coach can look",
"option": {
"last7days": "{days} days",
"last30days": "{days} days",
"last90days": "{days} days",
"lastYear": "{months} months",
"allTime": "All"
},
"detailDays": "The Coach reads nothing older than {days} days, in any answer.",
"detailMonths": "The Coach reads nothing older than {months} months, in any answer.",
"detailAll": "Everything you recorded: {dailyMonths} months in daily detail, older readings as monthly values up to {years} years back, lab results from the last {labMonths} months.",
"note": "Your profile, current medications, an ongoing illness and upcoming visits stay visible; summaries that need more history are shortened or left out."
},
"showConversations": "Show conversations",
"hideConversations": "Hide conversations",
"heroGreeting": "Ask me anything about your data",
@@ -5090,11 +5105,6 @@
"brief": "Brief",
"default": "Default",
"detailed": "Detailed",
"defaultWindow": "Default window",
"last7days": "Last 7 days",
"last30days": "Last 30 days",
"last90days": "Last 90 days",
"allTime": "All time",
"excludedMetrics": "Excluded metrics",
"dataClusters": "Data clusters",
"customClusters": "Use a custom cluster selection",
+17 -7
View File
@@ -2930,7 +2930,6 @@
"lastYear": "últimos 12 meses",
"allTime": "todo el tiempo (12 meses en detalle)"
},
"windowLabel": "Periodo",
"settingsAriaLabel": "Ajustes del coach",
"settingsTitle": "Ajustes del coach",
"settingsDescription": "Configura cómo te habla el coach.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "Datos que ve el Coach",
"settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.",
"settingsClusterNoData": "Aún no hay datos",
"sourcesMemberCount": "{count} métricas",
"sourcesMemberCountOne": "1 métrica",
"sourcesMemberCountFew": "{count} métricas",
"sourcesMemberCountOther": "{count} métricas",
"lookback": {
"label": "Hasta dónde puede mirar atrás el Coach",
"option": {
"last7days": "{days} días",
"last30days": "{days} días",
"last90days": "{days} días",
"lastYear": "{months} meses",
"allTime": "Todo"
},
"detailDays": "El Coach no lee nada con más de {days} días de antigüedad, en ninguna respuesta.",
"detailMonths": "El Coach no lee nada con más de {months} meses de antigüedad, en ninguna respuesta.",
"detailAll": "Todo lo que has registrado: {dailyMonths} meses al detalle diario, las mediciones más antiguas como valores mensuales hasta {years} años atrás, los análisis de los últimos {labMonths} meses.",
"note": "Tu perfil, tu medicación actual, una enfermedad en curso y las próximas citas siguen visibles; los resúmenes que necesitan más historial se acortan o se omiten."
},
"showConversations": "Mostrar conversaciones",
"hideConversations": "Ocultar conversaciones",
"heroGreeting": "Pregúntame lo que quieras sobre tus datos",
@@ -5090,11 +5105,6 @@
"brief": "Breve",
"default": "Predeterminado",
"detailed": "Detallado",
"defaultWindow": "Periodo predeterminado",
"last7days": "Últimos 7 días",
"last30days": "Últimos 30 días",
"last90days": "Últimos 90 días",
"allTime": "Todo el tiempo",
"excludedMetrics": "Métricas excluidas",
"dataClusters": "Grupos de datos",
"customClusters": "Usar una selección personalizada de grupos",
+17 -7
View File
@@ -2930,7 +2930,6 @@
"lastYear": "12 derniers mois",
"allTime": "toute la période (12 mois en détail)"
},
"windowLabel": "Période",
"settingsAriaLabel": "Paramètres du coach",
"settingsTitle": "Paramètres du coach",
"settingsDescription": "Configure comment le coach te parle.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "Données vues par le Coach",
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
"settingsClusterNoData": "Pas encore de données",
"sourcesMemberCount": "{count} mesures",
"sourcesMemberCountOne": "1 mesure",
"sourcesMemberCountFew": "{count} mesures",
"sourcesMemberCountOther": "{count} mesures",
"lookback": {
"label": "Jusqu’où le Coach peut remonter",
"option": {
"last7days": "{days} jours",
"last30days": "{days} jours",
"last90days": "{days} jours",
"lastYear": "{months} mois",
"allTime": "Tout"
},
"detailDays": "Le Coach ne lit rien de plus ancien que {days} jours, dans aucune réponse.",
"detailMonths": "Le Coach ne lit rien de plus ancien que {months} mois, dans aucune réponse.",
"detailAll": "Tout ce que vous avez enregistré : {dailyMonths} mois au jour près, les mesures plus anciennes en valeurs mensuelles jusqu’à {years} ans en arrière, les analyses des {labMonths} derniers mois.",
"note": "Votre profil, vos médicaments actuels, une maladie en cours et vos prochains rendez-vous restent visibles ; les synthèses qui demandent plus d’historique sont raccourcies ou omises."
},
"showConversations": "Afficher les conversations",
"hideConversations": "Masquer les conversations",
"heroGreeting": "Pose-moi une question sur tes données",
@@ -5090,11 +5105,6 @@
"brief": "Bref",
"default": "Par défaut",
"detailed": "Détaillé",
"defaultWindow": "Période par défaut",
"last7days": "7 derniers jours",
"last30days": "30 derniers jours",
"last90days": "90 derniers jours",
"allTime": "Depuis toujours",
"excludedMetrics": "Mesures exclues",
"dataClusters": "Groupes de données",
"customClusters": "Utiliser une sélection personnalisée de groupes",
+17 -7
View File
@@ -2930,7 +2930,6 @@
"lastYear": "ultimi 12 mesi",
"allTime": "tutto il tempo (12 mesi in dettaglio)"
},
"windowLabel": "Periodo",
"settingsAriaLabel": "Impostazioni del coach",
"settingsTitle": "Impostazioni del coach",
"settingsDescription": "Imposta come il coach ti parla.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "Dati che il Coach vede",
"settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.",
"settingsClusterNoData": "Ancora nessun dato",
"sourcesMemberCount": "{count} metriche",
"sourcesMemberCountOne": "1 metrica",
"sourcesMemberCountFew": "{count} metriche",
"sourcesMemberCountOther": "{count} metriche",
"lookback": {
"label": "Fin dove il Coach può guardare indietro",
"option": {
"last7days": "{days} giorni",
"last30days": "{days} giorni",
"last90days": "{days} giorni",
"lastYear": "{months} mesi",
"allTime": "Tutto"
},
"detailDays": "Il Coach non legge nulla di più vecchio di {days} giorni, in nessuna risposta.",
"detailMonths": "Il Coach non legge nulla di più vecchio di {months} mesi, in nessuna risposta.",
"detailAll": "Tutto ciò che hai registrato: {dailyMonths} mesi giorno per giorno, le misurazioni più vecchie come valori mensili fino a {years} anni fa, gli esami degli ultimi {labMonths} mesi.",
"note": "Il tuo profilo, i farmaci attuali, una malattia in corso e le prossime visite restano visibili; i riepiloghi che richiedono più storico vengono accorciati o omessi."
},
"showConversations": "Mostra conversazioni",
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Chiedimi qualcosa sui tuoi dati",
@@ -5090,11 +5105,6 @@
"brief": "Breve",
"default": "Predefinito",
"detailed": "Dettagliato",
"defaultWindow": "Periodo predefinito",
"last7days": "Ultimi 7 giorni",
"last30days": "Ultimi 30 giorni",
"last90days": "Ultimi 90 giorni",
"allTime": "Tutto il periodo",
"excludedMetrics": "Metriche escluse",
"dataClusters": "Gruppi di dati",
"customClusters": "Usa una selezione personalizzata di gruppi",
+17 -7
View File
@@ -2930,7 +2930,6 @@
"lastYear": "최근 12개월",
"allTime": "전체 기간(상세 12개월)"
},
"windowLabel": "기간",
"settingsAriaLabel": "코치 설정",
"settingsTitle": "코치 설정",
"settingsDescription": "코치가 대화하는 방식을 조절해요.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "코치가 보는 데이터",
"settingsClustersHint": "코치가 읽을 수 있는 데이터 묶음을 고르세요. 데이터가 많을수록 답변이 풍부해지고 프롬프트도 커져요.",
"settingsClusterNoData": "아직 데이터 없음",
"sourcesMemberCount": "지표 {count}개",
"sourcesMemberCountOne": "지표 1개",
"sourcesMemberCountFew": "지표 {count}개",
"sourcesMemberCountOther": "지표 {count}개",
"lookback": {
"label": "코치가 돌아볼 수 있는 기간",
"option": {
"last7days": "{days}일",
"last30days": "{days}일",
"last90days": "{days}일",
"lastYear": "{months}개월",
"allTime": "전체"
},
"detailDays": "코치는 어떤 답변에서도 {days}일보다 오래된 기록은 읽지 않아요.",
"detailMonths": "코치는 어떤 답변에서도 {months}개월보다 오래된 기록은 읽지 않아요.",
"detailAll": "기록한 모든 것: 최근 {dailyMonths}개월은 하루 단위로, 그 이전 측정값은 최대 {years}년 전까지 월별 값으로, 검사 결과는 최근 {labMonths}개월 치를 읽어요.",
"note": "프로필, 현재 복용 중인 약, 진행 중인 질병, 예정된 진료는 계속 보여요. 더 긴 기록이 필요한 요약은 줄이거나 빼요."
},
"showConversations": "대화 보기",
"hideConversations": "대화 숨기기",
"heroGreeting": "내 데이터에 대해 무엇이든 물어보세요",
@@ -5090,11 +5105,6 @@
"brief": "간략",
"default": "기본",
"detailed": "상세",
"defaultWindow": "기본 기간",
"last7days": "최근 7일",
"last30days": "최근 30일",
"last90days": "최근 90일",
"allTime": "전체 기간",
"excludedMetrics": "제외할 지표",
"dataClusters": "데이터 그룹",
"customClusters": "그룹 직접 선택하기",
+17 -7
View File
@@ -2930,7 +2930,6 @@
"lastYear": "ostatnie 12 miesięcy",
"allTime": "cały okres (12 miesięcy szczegółowo)"
},
"windowLabel": "Okres",
"settingsAriaLabel": "Ustawienia coacha",
"settingsTitle": "Ustawienia coacha",
"settingsDescription": "Ustaw, jak coach do ciebie mówi.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "Dane widziane przez Coacha",
"settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.",
"settingsClusterNoData": "Brak danych",
"sourcesMemberCount": "{count} wskaźników",
"sourcesMemberCountOne": "1 wskaźnik",
"sourcesMemberCountFew": "{count} wskaźniki",
"sourcesMemberCountOther": "{count} wskaźników",
"lookback": {
"label": "Jak daleko wstecz może patrzeć Coach",
"option": {
"last7days": "{days} dni",
"last30days": "{days} dni",
"last90days": "{days} dni",
"lastYear": "{months} miesięcy",
"allTime": "Wszystko"
},
"detailDays": "Coach nie czyta niczego starszego niż {days} dni, w żadnej odpowiedzi.",
"detailMonths": "Coach nie czyta niczego starszego niż {months} miesięcy, w żadnej odpowiedzi.",
"detailAll": "Wszystko, co zostało zapisane: {dailyMonths} miesięcy z dokładnością do dnia, starsze pomiary jako wartości miesięczne do {years} lat wstecz, wyniki badań z ostatnich {labMonths} miesięcy.",
"note": "Twój profil, obecne leki, trwająca choroba i nadchodzące wizyty pozostają widoczne; podsumowania wymagające dłuższej historii są skracane lub pomijane."
},
"showConversations": "Pokaż rozmowy",
"hideConversations": "Ukryj rozmowy",
"heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych",
@@ -5090,11 +5105,6 @@
"brief": "Krótka",
"default": "Domyślna",
"detailed": "Szczegółowa",
"defaultWindow": "Domyślny okres",
"last7days": "Ostatnie 7 dni",
"last30days": "Ostatnie 30 dni",
"last90days": "Ostatnie 90 dni",
"allTime": "Cały okres",
"excludedMetrics": "Wykluczone metryki",
"dataClusters": "Grupy danych",
"customClusters": "Użyj własnego wyboru grup",
@@ -124,10 +124,53 @@ describe("<SourcesRail>", () => {
expect(html).toMatch(/data-source="cardio"[^>]*data-active="false"/);
});
it("shows the per-cluster member-count hint", () => {
it("shows the per-cluster member-count hint, singular for one", () => {
const html = render(<SourcesRail />);
// cardio expands to 9 sources, mood to 1 — both surface a count.
expect(html).toContain("9 metrics");
expect(html).toContain("1 metrics");
expect(html).toContain("1 metric<");
expect(html).not.toContain("1 metrics");
});
it("never fades a switched-off row (contrast)", () => {
// `opacity-60` over muted text fell below WCAG AA in both themes. The
// off state is the dashed frame and the switch, the text keeps its
// tokens.
const html = render(<SourcesRail />);
const rows = html.match(/<li[^>]*data-slot="coach-sources-row"[^>]*>/g);
expect(rows?.length).toBe(10);
for (const row of rows ?? []) expect(row).not.toMatch(/opacity/);
expect(html).toMatch(
/data-source="glucose"[^>]*data-active="false"[^>]*class="[^"]*border-dashed/,
);
});
it("names the lookback a limit and states what the default reads", () => {
const html = render(<SourcesRail />);
expect(html).toContain("How far back the Coach can look");
// `allTime` is the default: the sentence carries the numbers the
// readers use (`history-reach.ts`).
expect(html).toContain(
"Everything you recorded: 12 months in daily detail, older readings as monthly values up to 10 years back, lab results from the last 12 months.",
);
expect(html).not.toContain("stay visible");
});
it("states the limit and what stays visible under a bounded lookback", () => {
prefsState.data = { ...DEFAULT_COACH_PREFS, defaultWindow: "last90days" };
const html = render(<SourcesRail />);
expect(html).toContain(
"The Coach reads nothing older than 90 days, in any answer.",
);
expect(html).toContain("stay visible");
expect(html).toContain('aria-describedby="coach-sources-window-detail"');
});
it("offers the twelve-month limit", () => {
prefsState.data = { ...DEFAULT_COACH_PREFS, defaultWindow: "lastYear" };
const html = render(<SourcesRail />);
expect(html).toContain(
"The Coach reads nothing older than 12 months, in any answer.",
);
});
});
@@ -16,6 +16,11 @@ import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import { CLUSTER_SOURCES } from "@/lib/ai/coach/clusters";
import {
COACH_HISTORY_WINDOWS,
HISTORY_REACH_DAYS,
lookbackText,
} from "@/lib/ai/coach/history-reach";
import {
DEFAULT_COACH_CLUSTERS,
coachDataClusterEnum,
@@ -45,12 +50,12 @@ import {
const CLUSTER_OPTIONS: ReadonlyArray<CoachDataCluster> =
coachDataClusterEnum.options;
const WINDOW_OPTIONS: ReadonlyArray<CoachDefaultWindow> = [
"last7days",
"last30days",
"last90days",
"allTime",
];
/**
* The lookback options, narrowest first. The choice is a limit
* the server enforces on every Coach read (`history-reach.ts`), so the text
* under the picker says what is read, with the numbers the readers use.
*/
const WINDOW_OPTIONS = COACH_HISTORY_WINDOWS;
export interface SourcesRailProps {
className?: string;
@@ -67,7 +72,7 @@ export interface SourcesRailProps {
}
export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
const { t } = useTranslations();
const { t, tCount } = useTranslations();
const { data: prefs } = useCoachPrefs();
const save = useSaveCoachPrefs();
@@ -80,6 +85,8 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
prefs?.dataClusters ?? DEFAULT_COACH_CLUSTERS,
);
const activeWindow: CoachDefaultWindow = prefs?.defaultWindow ?? "allTime";
const activeText = lookbackText(activeWindow);
const bounded = HISTORY_REACH_DAYS[activeWindow] !== null;
const [pending, setPending] = useState<CoachDataCluster | "window" | null>(
null,
);
@@ -151,15 +158,15 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
</div>
) : null}
{/* Window selector — persists to `coachPrefs.defaultWindow` so the
chosen timeframe sticks across drawer opens and matches what
the cog sheet shows. */}
{/* Lookback limit — persists to `coachPrefs.defaultWindow`. The line
under the picker states what the Coach reads at this setting, with
the numbers the server readers use. */}
<div data-slot="coach-sources-window" className="flex flex-col gap-1">
<label
htmlFor="coach-sources-window-select"
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
>
{t("insights.coach.windowLabel")}
{t("insights.coach.lookback.label")}
</label>
<Select
value={activeWindow}
@@ -171,17 +178,32 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
data-slot="coach-sources-window-trigger"
size="default"
className="h-9 text-sm"
aria-describedby="coach-sources-window-detail"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w} className="text-sm">
{t(`insights.coach.window.${w}`)}
{t(
`insights.coach.lookback.option.${w}`,
lookbackText(w).optionParams,
)}
</SelectItem>
))}
</SelectContent>
</Select>
<p
id="coach-sources-window-detail"
data-slot="coach-sources-window-detail"
className="text-muted-foreground text-xs leading-snug"
>
{t(
`insights.coach.lookback.${activeText.detailKey}`,
activeText.detailParams,
)}
{bounded ? ` ${t("insights.coach.lookback.note")}` : null}
</p>
</div>
{/* Persisted cluster toggles — the same set the settings cog
@@ -201,11 +223,15 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
data-slot="coach-sources-row"
data-source={cluster}
data-active={checked ? "true" : "false"}
// A switched-off row is told apart by its frame (dashed, no
// fill) and its switch, never by fading the text: opacity over
// muted text fell below WCAG AA in both themes.
className={cn(
"border-border/60 bg-muted/30 flex items-center gap-2.5",
"rounded-lg border px-3",
"flex items-center gap-2.5 rounded-lg border px-3",
"min-h-11 py-2",
!checked && "opacity-60",
checked
? "border-border/60 bg-muted/30"
: "border-border border-dashed bg-transparent",
)}
>
<div className="flex min-w-0 flex-1 flex-col">
@@ -216,9 +242,7 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
{t(`insights.coach.cluster.${cluster}.label`)}
</label>
<span className="text-muted-foreground text-xs leading-snug">
{t("insights.coach.sourcesMemberCount", {
count: memberCount,
})}
{tCount("insights.coach.sourcesMemberCount", memberCount)}
</span>
</div>
{pending === cluster ? (
@@ -34,6 +34,12 @@ import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
COACH_HISTORY_WINDOWS,
HISTORY_REACH_DAYS,
lookbackText,
} from "@/lib/ai/coach/history-reach";
import type { CoachDefaultWindow } from "@/lib/validations/coach-prefs";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Switch } from "@/components/ui/switch";
import { SettingsCardActions } from "./_card-actions";
@@ -776,6 +782,12 @@ function CoachSettingsForm({
const preferences = asRecord(settings.preferences);
const excluded = new Set(asStringArray(preferences.excludeMetrics));
const dataClusters = new Set(asStringArray(preferences.dataClusters));
const savedWindow = asString(preferences.defaultWindow, "allTime");
const [lookback, setLookback] = useState<CoachDefaultWindow>(
(COACH_HISTORY_WINDOWS as readonly string[]).includes(savedWindow)
? (savedWindow as CoachDefaultWindow)
: "allTime",
);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -847,28 +859,44 @@ function CoachSettingsForm({
</option>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-coach-window">
{t("settings.sharedRecord.managedSettings.coach.defaultWindow")}
{/* The same lookback options and wording as the Coach's own picker:
the choice is a limit on every Coach read. */}
<div className="grid gap-1 text-sm">
<label htmlFor="managed-coach-window">
{t("insights.coach.lookback.label")}
</label>
<NativeSelect
defaultValue={asString(preferences.defaultWindow, "allTime")}
value={lookback}
onChange={(event) =>
setLookback(event.currentTarget.value as CoachDefaultWindow)
}
disabled={disabled}
id="managed-coach-window"
name="defaultWindow"
aria-describedby="managed-coach-window-detail"
>
<option value="last7days">
{t("settings.sharedRecord.managedSettings.coach.last7days")}
</option>
<option value="last30days">
{t("settings.sharedRecord.managedSettings.coach.last30days")}
</option>
<option value="last90days">
{t("settings.sharedRecord.managedSettings.coach.last90days")}
</option>
<option value="allTime">
{t("settings.sharedRecord.managedSettings.coach.allTime")}
</option>
{COACH_HISTORY_WINDOWS.map((window) => (
<option key={window} value={window}>
{t(
`insights.coach.lookback.option.${window}`,
lookbackText(window).optionParams,
)}
</option>
))}
</NativeSelect>
</label>
<p
id="managed-coach-window-detail"
className="text-muted-foreground text-xs"
>
{t(
`insights.coach.lookback.${lookbackText(lookback).detailKey}`,
lookbackText(lookback).detailParams,
)}
{HISTORY_REACH_DAYS[lookback] !== null
? ` ${t("insights.coach.lookback.note")}`
: null}
</p>
</div>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
@@ -0,0 +1,417 @@
/**
* The Coach lookback limit holds for every tool.
*
* `coachPrefs.defaultWindow` is a limit: no Coach read may reach
* further back than it, whatever window or period the model asks for. This
* guard runs EVERY tool the model is offered (`COACH_TOOL_DEFS`, `show_result`
* included) through the real executor with a 30-day limit and the widest
* arguments the schema allows (`allTime`, `yearAgo`), once with every snapshot
* section present and once with none (the empty-read path), and records each
* call the executor makes into a reader. A call passes when it carries the
* limit and asks for nothing older than 30 days. A tool passes when it made at
* least one read and every read passed, or when it refused with
* `outside_reach` before reading anything.
*
* A tool added to `COACH_TOOL_DEFS` without a case here fails the first test;
* a case whose tool made no read and did not refuse fails too, so the guard
* cannot go green by having nothing to check.
*
* The second block reads the source: every dispatch branch hands the limit
* on, every Coach `buildCoachSnapshot(` call passes one, and the MCP call
* sites pass none (MCP keeps its own windows). The matchers tolerate any
* whitespace and line breaks, and each asserts a non-zero match count.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
import type {
CoachResultPeriod,
CoachScope,
CoachScopeWindow,
} from "@/lib/ai/coach/types";
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
const NOW = new Date("2026-10-04T12:00:00Z");
const LIMIT_DAYS = 30;
const FLOOR = NOW.getTime() - LIMIT_DAYS * 86_400_000;
const RANK: Record<CoachScopeWindow, number> = {
last7days: 0,
last30days: 1,
last90days: 2,
lastYear: 3,
allTime: 4,
};
interface Read {
reader: string;
ok: boolean;
detail: string;
}
const reads: Read[] = [];
function windowOk(window: CoachScopeWindow | undefined): boolean {
return window === undefined || RANK[window] <= RANK.last30days;
}
function reachOk(reach: CoachHistoryReach | undefined): boolean {
return reach?.days === LIMIT_DAYS;
}
function record(reader: string, ok: boolean, detail: unknown): void {
reads.push({ reader, ok, detail: JSON.stringify(detail) });
}
let sections: Record<string, unknown> = {};
vi.mock("@/lib/ai/coach/snapshot", () => ({
buildCoachSnapshot: async (
_userId: string,
scope?: CoachScope,
options?: { reach?: CoachHistoryReach },
): Promise<CoachSnapshotResult> => {
record(
"buildCoachSnapshot",
reachOk(options?.reach) && windowOk(scope?.window),
{ window: scope?.window, reach: options?.reach },
);
return {
snapshotJson: JSON.stringify(sections),
sections,
provenance: { windows: [], metrics: [] },
referenceGrounding: null,
units: DEFAULT_UNIT_PREFERENCES,
};
},
}));
vi.mock("@/lib/ai/coach/results/metric-table-tool", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/ai/coach/results/metric-table-tool")
>();
return {
...actual,
readMetricTable: async (args: {
window: CoachScopeWindow;
period: CoachResultPeriod;
now?: Date;
}) => {
const range = actual.resolveTableRange({
window: args.window,
period: args.period,
timeZone: "UTC",
now: args.now ?? NOW,
});
record("readMetricTable", range.from.getTime() >= FLOOR - 86_400_000, {
window: args.window,
period: args.period,
from: range.from.toISOString(),
});
return null;
},
};
});
vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
readCoachCorrelations: async (
_userId: string,
_locale: string,
options?: { reach?: CoachHistoryReach },
) => {
record("readCoachCorrelations", reachOk(options?.reach), options);
return { present: false, reason: "no_significant_pattern" };
},
}));
vi.mock("@/lib/ai/coach/illness-snapshot", () => ({
buildIllnessScores: async (
_userId: string,
_now?: Date,
reach?: CoachHistoryReach,
) => {
record("buildIllnessScores", reachOk(reach), reach);
return null;
},
}));
vi.mock("@/lib/ai/coach/tools/availability", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/ai/coach/tools/availability")>();
return {
...actual,
resolveEmptyRead: async (args: {
searchedWindow?: CoachScopeWindow;
reach?: CoachHistoryReach;
}) => {
record(
"resolveEmptyRead",
reachOk(args.reach) && windowOk(args.searchedWindow),
{ window: args.searchedWindow, reach: args.reach },
);
return { present: false, reason: "no_data" };
},
illnessBeyondReach: async (_userId: string, reach: CoachHistoryReach) => {
record("illnessBeyondReach", reachOk(reach), reach);
return false;
},
cycleRecorded: async () => true,
};
});
vi.mock("@/lib/cycle/gate", () => ({
isCycleAvailableForUser: async () => true,
}));
vi.mock("@/lib/i18n/user-locale", () => ({
resolveLocaleForUser: async () => "en",
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: async () => "UTC",
}));
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: async () => ({}),
}));
vi.mock("@/lib/ai/coach/persistence", () => ({
readMessageResults: async () => null,
}));
vi.mock("@/lib/db", () => ({ prisma: {} }));
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
import {
COACH_TOOL_DEFS,
COACH_TOOL_NAMES,
SHOW_RESULT_TOOL_NAME,
} from "@/lib/ai/coach/tools/definitions";
import { createResultRefAllocator } from "@/lib/ai/coach/results/refs";
import type { PriorResultTurn } from "@/lib/ai/coach/results/refs";
const REACH: CoachHistoryReach = { window: "last30days", days: LIMIT_DAYS };
function storedTable(ref: string, window: CoachScopeWindow) {
return {
ref,
source: {
tool: "get_metric_table" as const,
domain: "bp" as const,
window,
period: "current" as const,
},
shape: "timeSeries" as const,
titleKey: "coach.result.title.byDay",
title: "Blood pressure by day",
rowCount: 3,
chartKind: null,
displayed: true,
};
}
const PRIOR: PriorResultTurn[] = [
{
messageId: "m-earlier",
turnIndex: 1,
results: [storedTable("r1", "allTime"), storedTable("r2", "last7days")],
},
];
/**
* One or more argument sets per tool, each the widest the schema allows.
* Every name in `COACH_TOOL_DEFS` must have an entry.
*/
const CASES: Record<string, Array<Record<string, unknown>>> = {
get_metric_series: [
{ metric: "bp", window: "allTime" },
{ metric: "hrv", window: "lastYear" },
],
get_glucose_panel: [{ window: "allTime" }],
get_sleep: [{ window: "allTime" }],
get_medication_compliance: [{ window: "allTime" }],
get_labs: [{}],
get_illness_recovery: [{}],
get_workouts: [{ window: "allTime" }],
get_cycle: [{}],
get_correlations: [{}],
get_metric_table: [
{ metric: "bp", window: "allTime" },
{ metric: "weight", window: "last30days", period: "yearAgo" },
{ metric: "weight", window: "last7days", period: "previous" },
{ metric: "mood", window: "lastYear", granularity: "day" },
],
[SHOW_RESULT_TOOL_NAME]: [{ ref: "m1.r1" }, { ref: "m1.r2" }],
};
const FULL_SECTIONS: Record<string, unknown> = {
bloodPressure: { aggregate: { avgSys30: 128 } },
heartRateVariability: { aggregate: { avg30: 41 } },
glucose: { unit: "mg/dL", byContext: {} },
sleep: { recent: [] },
compliance: { rate: 0.9 },
workouts: { recent: [] },
labs: { recent: [{ name: "LDL", analyte: "ldl" }] },
illness: { restMode: false },
derived: { READINESS: { value: 60 } },
cycle: { phase: "luteal" },
scope: { sources: ["bp", "weight", "mood", "hrv", "sleep"] },
};
async function runCase(
name: string,
args: Record<string, unknown>,
): Promise<{ reason?: string; reads: Read[] }> {
reads.length = 0;
const result = await executeCoachTool({
userId: "u1",
name,
rawArguments: JSON.stringify(args),
fallbackWindow: "allTime",
reach: REACH,
turn: {
conversationId: "c1",
locale: "en",
priorResults: PRIOR,
refs: createResultRefAllocator(),
now: NOW,
},
});
return { reason: result.reason, reads: [...reads] };
}
describe("every Coach tool honours the lookback limit", () => {
beforeEach(() => {
vi.useFakeTimers({ now: NOW, toFake: ["Date"] });
});
it("has a case for every tool the model is offered", () => {
const offered = COACH_TOOL_DEFS.map((def) => def.name).sort();
expect(offered.length).toBeGreaterThan(0);
expect(offered).toEqual(
[...COACH_TOOL_NAMES, SHOW_RESULT_TOOL_NAME].sort(),
);
expect(Object.keys(CASES).sort()).toEqual(offered);
});
for (const [label, present] of [
["with every section present", FULL_SECTIONS],
["with nothing in the window (the empty-read path)", {}],
] as const) {
it(`reads nothing older than the limit, ${label}`, async () => {
sections = present;
let checkedReads = 0;
let refusals = 0;
for (const [name, argSets] of Object.entries(CASES)) {
for (const args of argSets) {
const outcome = await runCase(name, args);
const where = `${name} ${JSON.stringify(args)}`;
const bad = outcome.reads.filter((read) => !read.ok);
expect(bad, `${where} read past the limit`).toEqual([]);
if (outcome.reads.length === 0) {
// Nothing read: only an explicit refusal is an answer.
expect(outcome.reason, `${where} made no read`).toBe(
"outside_reach",
);
refusals += 1;
}
checkedReads += outcome.reads.length;
}
}
expect(checkedReads).toBeGreaterThan(0);
// The refusals are the cycle tool (it predicts from every cycle), the
// stored all-time table, and the year-ago and previous-period tables
// that do not fit into 30 days.
expect(refusals).toBeGreaterThan(0);
});
}
});
// ── The source ─────────────────────────────────────────────────────────
const ROOT = join(__dirname, "../../../../..");
const COACH_DIR = join(ROOT, "src/lib/ai/coach");
const MCP_DIR = join(ROOT, "src/lib/mcp");
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
if (entry === "__tests__" || entry === "eval") continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walk(full));
else if (full.endsWith(".ts")) out.push(full);
}
return out;
}
/** `source` without its comments, so a doc mention is not read as a call. */
function code(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
/** The argument text of every call to `fn(` in `source`, parentheses balanced. */
function callArguments(raw: string, fn: string): string[] {
const source = code(raw);
const out: string[] = [];
const pattern = new RegExp(`\\b${fn}\\s*\\(`, "g");
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
// Skip the declaration itself.
const before = source.slice(Math.max(0, match.index - 20), match.index);
if (/function\s+$/.test(before)) continue;
let depth = 1;
let i = match.index + match[0].length;
const start = i;
while (i < source.length && depth > 0) {
if (source[i] === "(") depth += 1;
else if (source[i] === ")") depth -= 1;
i += 1;
}
out.push(source.slice(start, i - 1));
}
return out;
}
describe("the source hands the limit on", () => {
it("passes the limit in every dispatch branch of the executor", () => {
const source = readFileSync(join(COACH_DIR, "tools/executor.ts"), "utf8");
const dispatch = source.slice(
source.indexOf("async function dispatchRead("),
);
const branches = [
...dispatch.matchAll(
/case\s+"(get_[a-z_]+)"\s*:\s*return\s+[A-Za-z]+\s*\(([\s\S]*?)\)\s*;/g,
),
];
expect(branches.length).toBe(COACH_TOOL_NAMES.length);
for (const [, name, args] of branches) {
expect(args, `${name} does not pass reach`).toMatch(/\breach\b/);
}
});
it("passes the limit to every Coach snapshot build", () => {
const calls = walk(COACH_DIR).flatMap((file) =>
callArguments(readFileSync(file, "utf8"), "buildCoachSnapshot").map(
(args) => ({ file: file.slice(ROOT.length + 1), args }),
),
);
expect(calls.length).toBeGreaterThan(0);
for (const call of calls) {
expect(call.args, `${call.file} builds without a limit`).toMatch(
/\breach\b/,
);
}
});
it("passes the limit from the chat loop and nowhere from MCP", () => {
const loop = callArguments(
readFileSync(join(COACH_DIR, "tools/loop.ts"), "utf8"),
"executeCoachTool",
);
expect(loop.length).toBeGreaterThan(0);
for (const args of loop) expect(args).toMatch(/\breach\b/);
const mcp = walk(MCP_DIR).flatMap((file) =>
callArguments(readFileSync(file, "utf8"), "executeCoachTool"),
);
expect(mcp.length).toBeGreaterThan(0);
for (const args of mcp) expect(args).not.toMatch(/\breach\b/);
});
});
@@ -0,0 +1,229 @@
/**
* The Coach lookback limit, piece by piece: the window arithmetic, the
* numbers the settings text states, the probe's `outside_reach` (which names
* no figure), the rule the model is given for it, and the DATA INVENTORY row.
* The executor-wide guard is `coach-history-reach-guard.test.ts`; the real
* readers against Postgres are `tests/integration/coach-history-reach.test.ts`.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const groupBy = vi.fn();
const findFirst = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
groupBy: (args: unknown) => groupBy(args),
findFirst: (args: unknown) => findFirst(args),
},
},
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: () => Promise.resolve("UTC"),
}));
import {
COACH_ALL_TIME_TABLE_DAYS,
COACH_DAILY_DETAIL_DAYS,
COACH_HISTORY_WINDOWS,
HISTORY_REACH_DAYS,
UNBOUNDED_REACH,
capDays,
clampWindow,
fitsReach,
lookbackText,
reachCacheToken,
reachFromPrefs,
withinReach,
} from "@/lib/ai/coach/history-reach";
import { windowToDays } from "@/lib/ai/coach/snapshot-series";
import { resolveTableRange } from "@/lib/ai/coach/results/metric-table-tool";
import { snapshotCacheKey } from "@/lib/ai/coach/snapshot-cache";
import { coachDefaultWindowEnum } from "@/lib/validations/coach-prefs";
import { buildToolModeAddendum } from "@/lib/ai/coach/tools/system-addendum";
import { resolveEmptyRead } from "@/lib/ai/coach/tools/availability";
import {
renderDataInventory,
type CoachDataInventory,
} from "@/lib/ai/coach/tools/inventory";
const NOW = new Date("2026-10-04T12:00:00Z");
const NINETY = reachFromPrefs({ defaultWindow: "last90days" });
describe("the lookback arithmetic", () => {
it("offers exactly the stored enum, narrowest first, all time last", () => {
expect([...COACH_HISTORY_WINDOWS]).toEqual(coachDefaultWindowEnum.options);
expect(COACH_HISTORY_WINDOWS.at(-1)).toBe("allTime");
expect(reachFromPrefs({ defaultWindow: "allTime" })).toEqual(
UNBOUNDED_REACH,
);
expect(reachFromPrefs(null)).toEqual(UNBOUNDED_REACH);
});
it("clamps a wider window to the limit and leaves a narrower one", () => {
expect(clampWindow("allTime", NINETY)).toBe("last90days");
expect(clampWindow("lastYear", NINETY)).toBe("last90days");
expect(clampWindow("last7days", NINETY)).toBe("last7days");
expect(clampWindow("allTime", UNBOUNDED_REACH)).toBe("allTime");
});
it("shortens a fixed window and drops a block that does not fit", () => {
expect(capDays(180, NINETY)).toBe(90);
expect(capDays(30, NINETY)).toBe(30);
expect(capDays(180, UNBOUNDED_REACH)).toBe(180);
expect(fitsReach(42, NINETY)).toBe(true);
expect(fitsReach(365, NINETY)).toBe(false);
expect(fitsReach(10_000, UNBOUNDED_REACH)).toBe(true);
expect(
withinReach(new Date(NOW.getTime() - 89 * 86_400_000), NINETY, NOW),
).toBe(true);
expect(
withinReach(new Date(NOW.getTime() - 91 * 86_400_000), NINETY, NOW),
).toBe(false);
});
it("keys a limited snapshot apart and leaves an unlimited key as it was", () => {
const scope = { window: "last30days" as const, sources: ["bp" as const] };
expect(snapshotCacheKey("u1", scope)).toBe("u1|last30days|bp");
expect(snapshotCacheKey("u1", scope, UNBOUNDED_REACH)).toBe(
"u1|last30days|bp",
);
expect(snapshotCacheKey("u1", scope, NINETY)).toBe(
"u1|last30days|bp|reach90",
);
expect(reachCacheToken(undefined)).toBe("");
});
});
describe("the numbers the settings text states", () => {
it("are the numbers the readers use", () => {
for (const window of ["last7days", "last30days", "last90days"] as const) {
expect(HISTORY_REACH_DAYS[window]).toBe(windowToDays(window));
}
expect(HISTORY_REACH_DAYS.lastYear).toBe(windowToDays("lastYear"));
// All time reads a year in daily detail ...
expect(windowToDays("allTime")).toBe(COACH_DAILY_DETAIL_DAYS);
// ... and an all-time table reaches back the stated number of years.
const range = resolveTableRange({
window: "allTime",
period: "current",
timeZone: "UTC",
now: NOW,
});
const spanDays =
Math.floor((NOW.getTime() - range.from.getTime()) / 86_400_000) + 1;
expect(spanDays).toBe(COACH_ALL_TIME_TABLE_DAYS);
});
it("phrases each option with those numbers", () => {
expect(lookbackText("last90days")).toEqual({
optionParams: { days: 90 },
detailKey: "detailDays",
detailParams: { days: 90 },
});
expect(lookbackText("lastYear").detailParams).toEqual({ months: 12 });
expect(lookbackText("allTime").detailParams).toEqual({
dailyMonths: 12,
years: 10,
labMonths: 12,
});
});
});
describe("a history beyond the limit", () => {
beforeEach(() => {
groupBy.mockReset();
findFirst.mockReset();
});
it("is reported as outside_reach with no figure from it", async () => {
groupBy.mockResolvedValue([]);
findFirst.mockResolvedValue({ id: "old-row" });
const result = await resolveEmptyRead({
userId: "u1",
domain: "weight",
subject: { kind: "measurement", types: ["WEIGHT"] },
searchedWindow: "last90days",
reach: NINETY,
now: NOW,
});
expect(result).toEqual({
present: false,
reason: "outside_reach",
searchedWindow: "last90days",
});
// The in-limit probe asked only for rows inside the limit; the existence
// check asked only for rows before it.
const groupWhere = groupBy.mock.calls[0][0].where;
expect(groupWhere.measuredAt.gte.getTime()).toBe(
NOW.getTime() - 90 * 86_400_000,
);
const firstWhere = findFirst.mock.calls[0][0].where;
expect(firstWhere.measuredAt.lt.getTime()).toBe(
NOW.getTime() - 90 * 86_400_000,
);
});
it("is plain no_data when nothing older exists either", async () => {
groupBy.mockResolvedValue([]);
findFirst.mockResolvedValue(null);
const result = await resolveEmptyRead({
userId: "u1",
domain: "weight",
subject: { kind: "measurement", types: ["WEIGHT"] },
searchedWindow: "last90days",
reach: NINETY,
now: NOW,
});
expect(result.reason).toBe("no_data");
});
it("is not looked for without a limit (MCP, the default)", async () => {
groupBy.mockResolvedValue([]);
const result = await resolveEmptyRead({
userId: "u1",
domain: "weight",
subject: { kind: "measurement", types: ["WEIGHT"] },
searchedWindow: "last90days",
now: NOW,
});
expect(result.reason).toBe("no_data");
expect(findFirst).not.toHaveBeenCalled();
expect(groupBy.mock.calls[0][0].where.measuredAt).toBeUndefined();
});
});
describe("what the model is told", () => {
it("gives outside_reach its own rule, in English and German", () => {
for (const locale of ["en", "de"] as const) {
const text = buildToolModeAddendum(locale);
expect(text).toMatch(/"outside_reach"|„outside_reach"/);
}
expect(buildToolModeAddendum("en")).toContain("Coach settings");
expect(buildToolModeAddendum("de")).toContain("Coach-Einstellungen");
});
it("marks a domain beyond the limit in the inventory without figures", () => {
const inventory: CoachDataInventory = {
entries: [
{
tool: "get_metric_series",
metric: "weight",
domain: "weight",
present: false,
availability: { state: "outside_reach", reachableWithWindow: null },
},
],
restMode: false,
cycleEnabled: false,
window: "last90days",
lookbackLimit: "last90days",
probeScope: { window: "last90days" },
};
const text = renderDataInventory(inventory);
expect(text).toContain("Lookback limit: last90days.");
const line = text.split("\n").find((l) => l.startsWith("- weight:"));
expect(line).toBe(
'- weight: BEYOND LOOKBACK → get_metric_series (metric:"weight")',
);
});
});
@@ -19,6 +19,7 @@ vi.mock("@/lib/ai/provider-runner", () => ({
import { HARD_CAP, runCoachToolLoop } from "@/lib/ai/coach/tools/loop";
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
type Call = { id: string; name: string; arguments: string };
@@ -44,6 +45,7 @@ const baseArgs = {
system: "sys",
messages: [{ role: "user" as const, content: "q" }],
tools: COACH_TOOL_DEFS,
reach: UNBOUNDED_REACH,
};
beforeEach(() => {
@@ -32,6 +32,7 @@ vi.mock("@/lib/ai/provider-runner", () => ({
import { runCoachToolLoop } from "@/lib/ai/coach/tools/loop";
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
const baseArgs = {
userId: "u1",
@@ -39,6 +40,7 @@ const baseArgs = {
system: "sys",
messages: [{ role: "user" as const, content: "how did I sleep?" }],
tools: COACH_TOOL_DEFS,
reach: UNBOUNDED_REACH,
};
describe("coach tool loop — which results reach the grounding guard", () => {
@@ -26,6 +26,7 @@ import {
MAX_ROUNDS,
} from "@/lib/ai/coach/tools/loop";
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
function completion(opts: {
content: string;
@@ -53,6 +54,7 @@ const baseArgs = {
system: "sys",
messages: [{ role: "user" as const, content: "how is my bp?" }],
tools: COACH_TOOL_DEFS,
reach: UNBOUNDED_REACH,
};
describe("runCoachToolLoop", () => {
@@ -73,11 +73,14 @@ describe("workout evidence builder", () => {
durationSec: 2400,
storedSamples: [{ tSec: 0, mean: 120 }],
});
// No limit passed: the builder hands the unlimited reach on, and
// `buildSportContext` decides whether its 180 days fit.
expect(buildSportContext).toHaveBeenCalledWith(
"u1",
"running",
{ workouts: ["APPLE_HEALTH"] },
"w1",
{ window: "allTime", days: null },
);
expect(buildEvidence).toHaveBeenCalledWith(
expect.objectContaining({
+4 -1
View File
@@ -18,6 +18,7 @@
* Server-only — `readCoachCorrelations` reads `@/lib/db`. Fail-soft: a null
* return (no surviving driver / read hiccup) attaches nothing.
*/
import { UNBOUNDED_REACH, type CoachHistoryReach } from "./history-reach";
import type { Locale } from "@/lib/i18n/config";
import {
readCoachCorrelations,
@@ -53,8 +54,10 @@ export async function buildCorrelationsSnapshotBlock(
* the locale, so it says it.
*/
locale: Locale,
/** The Coach's lookback limit, applied to the scan window. */
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<CorrelationsSnapshotBlock | null> {
const result = await readCoachCorrelations(userId, locale);
const result = await readCoachCorrelations(userId, locale, { reach });
if (!result.present || !result.drivers || result.drivers.length === 0) {
return null;
}
+52 -2
View File
@@ -22,6 +22,12 @@ import {
type BaselineProfile,
} from "@/lib/insights/derived";
import { prisma } from "@/lib/db";
import {
UNBOUNDED_REACH,
capDays,
isBounded,
type CoachHistoryReach,
} from "./history-reach";
/**
* Trailing window the recovery dedup probes for a WHOOP-native row — matches
@@ -30,6 +36,28 @@ import { prisma } from "@/lib/db";
*/
const RECOVERY_DEDUP_WINDOW_DAYS = 14;
/**
* The window each engine reads when called without one (its own
* `DEFAULT_WINDOW_DAYS`). Under a lookback limit each metric is computed over
* the shorter of the two, and an engine that then lacks history reports
* insufficient, which drops the line.
*/
const DERIVED_DEFAULT_WINDOW_DAYS: Readonly<
Partial<Record<DerivedMetricId, number>>
> = {
READINESS: 30,
RECOVERY_SCORE: 14,
STRESS_SCORE: 14,
STRAIN_SCORE: 14,
SLEEP_SCORE: 30,
HRV_BALANCE: 30,
FITNESS_AGE: 180,
VASCULAR_AGE_DELTA: 365,
};
/** The coincident-deviation flag's own default window. */
const COINCIDENT_DEFAULT_WINDOW_DAYS = 30;
/** The high-signal derived metrics worth a Coach prompt slot. The vitals
* baseline is omitted — the per-vital aggregate block already carries those
* numbers; here we add the composites + scores the aggregates can't express. */
@@ -226,8 +254,20 @@ export async function buildDerivedSnapshotBlock(
profile: BaselineProfile,
now: Date,
tz?: string,
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<DerivedSnapshotBlock | null> {
const block: Record<string, DerivedSnapshotEntry> = {};
// Unlimited: no window is passed, so every engine keeps its own default.
const limitedDays = (metric: DerivedMetricId) =>
isBounded(reach)
? {
windowDays: capDays(
DERIVED_DEFAULT_WINDOW_DAYS[metric] ??
COINCIDENT_DEFAULT_WINDOW_DAYS,
reach,
),
}
: {};
// v1.21.0 (C3 / D3) — the coincident-deviation flag, fired-only. Computed
// off the one shared profile alongside the scores; fail-soft to null so a
@@ -238,6 +278,9 @@ export async function buildDerivedSnapshotBlock(
const coincidentPromise = computeCoincidentDeviation(userId, profile, {
now,
...(tz ? { tz } : {}),
...(isBounded(reach)
? { windowDays: capDays(COINCIDENT_DEFAULT_WINDOW_DAYS, reach) }
: {}),
}).catch(() => null);
// The metrics are independent passthrough reads off the one shared profile —
@@ -249,7 +292,13 @@ export async function buildDerivedSnapshotBlock(
try {
return {
metric,
derived: await computeDerivedMetric({ metric, userId, profile, now }),
derived: await computeDerivedMetric({
metric,
userId,
profile,
now,
...limitedDays(metric),
}),
};
} catch {
return { metric, derived: null };
@@ -303,7 +352,8 @@ export async function buildDerivedSnapshotBlock(
deletedAt: null,
measuredAt: {
gte: new Date(
now.getTime() - RECOVERY_DEDUP_WINDOW_DAYS * 24 * 60 * 60 * 1000,
now.getTime() -
capDays(RECOVERY_DEDUP_WINDOW_DAYS, reach) * 24 * 60 * 60 * 1000,
),
lte: now,
},
+3
View File
@@ -25,6 +25,7 @@
* This mirrors the route exactly (`coach-prose-grounding-no-tools.test.ts`).
*/
import type { Locale } from "@/lib/i18n/config";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
import type {
AiMessage,
AiToolCall,
@@ -113,6 +114,8 @@ export async function runRealCase(args: {
// folded into the system prompt by the judge so a no-tools provider still
// has the context. The loop tolerates an empty toolCalls (no-tools path).
tools: [],
// An eval case is a fixed transcript, not a person with a setting.
reach: UNBOUNDED_REACH,
temperature,
maxTokens,
});
@@ -107,6 +107,19 @@ describe("deriveFollowUps — the rules", () => {
});
});
it("offers no chip that would read past the Coach's lookback limit", () => {
const limited = (defaultWindow: "last30days" | "last90days") =>
derive({
history: LONG_HISTORY,
prefs: { ...DEFAULT_COACH_PREFS, defaultWindow },
}).map((c) => c.kind);
// 30 days: the period before needs 60, a year ago 395, the wider window
// is 90 days. None fits.
expect(limited("last30days")).toEqual([]);
// 90 days: the period before (60 days) and the 90-day window fit.
expect(limited("last90days")).toEqual(["previous_period", "widen_window"]);
});
it("offers a year ago and a wider window only when the history reaches back", () => {
expect(derive({ history: LONG_HISTORY }).map((c) => c.kind)).toEqual([
"previous_period",
+36 -1
View File
@@ -56,8 +56,15 @@ import {
buildFollowUp,
isTableMetricDomain,
numberFollowUps,
widerWindow,
} from "./catalog";
import type { FollowUpProposal } from "./parse-sentinel";
import {
exceedsReach,
fitsReach,
reachFromPrefs,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
/** A discovered pair `get_correlations` returned this turn, as scope sources. */
export interface CorrelationPair {
@@ -86,6 +93,30 @@ const WINDOW_DAYS: Partial<Record<CoachScopeWindow, number>> = {
lastYear: 365,
};
/**
* True when answering `chip` stays inside the lookback limit: the period
* before needs twice the window, a year ago a year more than it, a wider
* window must itself fit.
*/
function chipWithinReach(chip: Candidate, reach: CoachHistoryReach): boolean {
const window = chip.anchor?.window;
if (!window) return true;
if (exceedsReach(window, reach)) return false;
const days = WINDOW_DAYS[window];
switch (chip.kind) {
case "previous_period":
return days !== undefined && fitsReach(2 * days, reach);
case "year_ago":
return days !== undefined && fitsReach(days + 365, reach);
case "widen_window": {
const wider = widerWindow(window);
return wider !== null && !exceedsReach(wider, reach);
}
default:
return true;
}
}
/** Whether the pref lets chips be offered. Absent means on. */
export function followUpChipsEnabled(prefs: CoachPrefs): boolean {
return prefs.followUpChips !== false;
@@ -254,10 +285,14 @@ export function deriveFollowUps(args: {
}
}
// Deduplicate by kind and domain, first wins.
// Deduplicate by kind and domain, first wins. A chip that would read past
// the lookback limit is not offered: it could only be answered with
// "that lies beyond your limit".
const reach = reachFromPrefs(args.prefs);
const key = (chip: Candidate) => `${chip.kind}:${chip.anchor?.domain ?? ""}`;
const unique = new Map<string, Candidate>();
for (const chip of candidates) {
if (!chipWithinReach(chip, reach)) continue;
if (!unique.has(key(chip))) unique.set(key(chip), chip);
}
+19 -2
View File
@@ -18,6 +18,12 @@
* Block omitted entirely when the user has no GLP-1 medications, so
* 99% of users never pay the read cost.
*/
import {
UNBOUNDED_REACH,
laterOfFloor,
reachFloor,
type CoachHistoryReach,
} from "./history-reach";
import { prisma } from "@/lib/db";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import { readNote } from "@/lib/crypto/note-cipher";
@@ -218,7 +224,14 @@ export async function buildGlp1SnapshotBlock(
userId: string,
now: Date = new Date(),
tz: string = DEFAULT_TIMEZONE,
/**
* The Coach's lookback limit. The therapy plan (dose, titration, pens) is
* current state and stays; the last injection and the side-effect tags are
* entries and are read from inside the limit only.
*/
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<Glp1SnapshotBlock | null> {
const floor = reachFloor(reach, now);
// Test environments mock parts of prisma (only `measurement` +
// `moodEntry`) and leave `medication` undefined. Treat the absence
// as "no GLP-1 therapy" so the helper silently bows out — matching
@@ -242,7 +255,7 @@ export async function buildGlp1SnapshotBlock(
inventoryItems: { orderBy: { createdAt: "asc" } },
inventoryEvents: { orderBy: { occurredAt: "asc" } },
intakeEvents: {
where: { takenAt: { not: null } },
where: { takenAt: floor ? { not: null, gte: floor } : { not: null } },
orderBy: { takenAt: "desc" },
take: 1,
select: {
@@ -259,7 +272,11 @@ export async function buildGlp1SnapshotBlock(
// the curated GLP-1 tag list. We pull once per user even if there are
// multiple GLP-1 meds (rare) and attribute the same recent symptoms
// to each — the Coach can disambiguate from context.
const moodCutoff = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
const moodCutoff = laterOfFloor(
new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000),
reach,
now,
);
const moods: RawMoodEntry[] = await prisma.moodEntry.findMany({
// v1.7.0 sync — exclude tombstoned rows.
where: { userId, deletedAt: null, moodLoggedAt: { gte: moodCutoff } },
+18
View File
@@ -0,0 +1,18 @@
/**
* The person's Coach lookback limit, read from their saved preferences. Kept
* apart from `history-reach.ts` so that module stays free of database access
* and can be imported by client code (the settings picker).
*/
import { prisma } from "@/lib/db";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
import { reachFromPrefs, type CoachHistoryReach } from "./history-reach";
export async function readCoachReach(
userId: string,
): Promise<CoachHistoryReach> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { coachPrefsJson: true },
});
return reachFromPrefs(parseCoachPrefs(row?.coachPrefsJson));
}
+200
View File
@@ -0,0 +1,200 @@
/**
* How far back the Coach may look: the user's `coachPrefs.defaultWindow`
* read as a LIMIT, not as a starting point.
*
* Before it became a limit, the saved window only seeded the snapshot and the default of
* each tool call; every tool accepted its own `window` up to `allTime`, the
* availability probe reported out-of-window history with counts and means,
* and several snapshot blocks read fixed windows (labs 12 months, glucose
* panel 30 days, correlations 180 days) whatever the setting said. The
* setting is now enforced everywhere the Coach reads: the snapshot, every
* tool, the probe, the inventory and the fixed windows. Each reader takes a
* `CoachHistoryReach` and either clamps its window to it (`clampWindow`,
* `capDays`) or leaves its block out when the block's own fixed window does
* not fit inside it (`fitsReach`). A block is never computed over a silently
* shortened window that would make it disagree with the page that shows it.
*
* What is NOT history and stays visible under any limit: the profile (age,
* sex, height), the current medication plan, an illness episode that is still
* ongoing, upcoming appointments, and what the person told the Coach (facts,
* plans, reminders). The settings text says so.
*
* `allTime` is no limit at all and leaves every reader exactly as it was.
* MCP clients never pass a reach: an MCP token reads with its own window
* arguments, and the Coach setting is a Coach setting.
*/
import type { CoachScopeWindow } from "./types";
import type {
CoachDefaultWindow,
CoachPrefs,
} from "@/lib/validations/coach-prefs";
/** The lookback options, narrowest first. */
export const COACH_HISTORY_WINDOWS: ReadonlyArray<CoachDefaultWindow> = [
"last7days",
"last30days",
"last90days",
"lastYear",
"allTime",
];
/** Days each option reaches back; `null` is no limit. */
export const HISTORY_REACH_DAYS: Readonly<
Record<CoachDefaultWindow, number | null>
> = {
last7days: 7,
last30days: 30,
last90days: 90,
lastYear: 365,
allTime: null,
};
export interface CoachHistoryReach {
/** The option the person chose. */
window: CoachDefaultWindow;
/** Days the Coach may look back, or `null` for no limit. */
days: number | null;
}
/** No limit: the behaviour before the limit existed, and the default. */
export const UNBOUNDED_REACH: CoachHistoryReach = Object.freeze({
window: "allTime",
days: null,
});
export function reachFromPrefs(
prefs: Pick<CoachPrefs, "defaultWindow"> | null | undefined,
): CoachHistoryReach {
const window = prefs?.defaultWindow ?? "allTime";
return { window, days: HISTORY_REACH_DAYS[window] };
}
export function isBounded(reach: CoachHistoryReach): boolean {
return reach.days !== null;
}
const WINDOW_RANK: Readonly<Record<CoachScopeWindow, number>> = {
last7days: 0,
last30days: 1,
last90days: 2,
lastYear: 3,
allTime: 4,
};
/** The narrower of `window` and the reach. */
export function clampWindow(
window: CoachScopeWindow,
reach: CoachHistoryReach,
): CoachScopeWindow {
return WINDOW_RANK[window] <= WINDOW_RANK[reach.window]
? window
: reach.window;
}
/** True when `window` reaches further back than the limit allows. */
export function exceedsReach(
window: CoachScopeWindow,
reach: CoachHistoryReach,
): boolean {
return WINDOW_RANK[window] > WINDOW_RANK[reach.window];
}
/** A fixed window of `days`, shortened to the limit. */
export function capDays(days: number, reach: CoachHistoryReach): number {
return reach.days === null ? days : Math.min(days, reach.days);
}
/**
* True when a block whose own window is `horizonDays` long fits inside the
* limit. A block that does not fit is left out, never computed over fewer
* days than the page that shows it uses.
*/
export function fitsReach(
horizonDays: number,
reach: CoachHistoryReach,
): boolean {
return reach.days === null || horizonDays <= reach.days;
}
/** The earliest instant the Coach may read, or `null` for no limit. */
export function reachFloor(
reach: CoachHistoryReach,
now: Date = new Date(),
): Date | null {
return reach.days === null
? null
: new Date(now.getTime() - reach.days * 86_400_000);
}
/** True when `at` lies inside the limit. */
export function withinReach(
at: Date,
reach: CoachHistoryReach,
now: Date = new Date(),
): boolean {
const floor = reachFloor(reach, now);
return floor === null || at.getTime() >= floor.getTime();
}
/** The later of `cutoff` and the limit's floor. */
export function laterOfFloor(
cutoff: Date,
reach: CoachHistoryReach,
now: Date = new Date(),
): Date {
const floor = reachFloor(reach, now);
return floor !== null && floor.getTime() > cutoff.getTime() ? floor : cutoff;
}
/** A short stable token for cache keys; empty for no limit. */
export function reachCacheToken(reach: CoachHistoryReach | undefined): string {
return reach && reach.days !== null ? `|reach${reach.days}` : "";
}
// ── The numbers the settings text states ────────────────────────────────
//
// The readers below import these, so the sentence the person reads and the
// window the Coach reads come from one place
// (`coach-history-reach.test.ts` pins it).
/** Days of daily detail the snapshot reads at most (`windowToDays`). */
export const COACH_DAILY_DETAIL_DAYS = 365;
/** How far back an all-time table reaches, as monthly values. */
export const COACH_ALL_TIME_TABLE_DAYS = 3_650;
/** Months of lab results the Coach reads (`labs-snapshot.ts`). */
export const COACH_LABS_LOOKBACK_MONTHS = 12;
/** The i18n key and parameters describing one lookback option. */
export function lookbackText(window: CoachDefaultWindow): {
optionParams: Record<string, number>;
detailKey: "detailDays" | "detailMonths" | "detailAll";
detailParams: Record<string, number>;
} {
const days = HISTORY_REACH_DAYS[window];
if (days === null) {
return {
optionParams: {},
detailKey: "detailAll",
detailParams: {
dailyMonths: Math.round(COACH_DAILY_DETAIL_DAYS / 30.4),
years: Math.round(COACH_ALL_TIME_TABLE_DAYS / 365),
labMonths: COACH_LABS_LOOKBACK_MONTHS,
},
};
}
if (days >= 365) {
const months = Math.round(days / 30.4);
return {
optionParams: { months },
detailKey: "detailMonths",
detailParams: { months },
};
}
return {
optionParams: { days },
detailKey: "detailDays",
detailParams: { days },
};
}
+42 -3
View File
@@ -19,6 +19,17 @@
import { prisma } from "@/lib/db";
import { isIllnessEnabled } from "@/lib/illness/gate";
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
import {
BASELINE_WINDOW_DAYS,
PRE_ONSET_LOOKBACK_DAYS,
} from "@/lib/illness/correlation";
import {
UNBOUNDED_REACH,
laterOfFloor,
reachFloor,
withinReach,
type CoachHistoryReach,
} from "./history-reach";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
@@ -131,8 +142,13 @@ const SYMPTOM_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
async function readRecentSymptoms(
userId: string,
now: Date,
reach: CoachHistoryReach,
): Promise<CoachSymptomSummary[]> {
const since = new Date(now.getTime() - SYMPTOM_WINDOW_MS);
const since = laterOfFloor(
new Date(now.getTime() - SYMPTOM_WINDOW_MS),
reach,
now,
);
const grouped = await prisma.symptomEvent.groupBy({
by: ["definitionId"],
where: { userId, occurredAt: { gte: since, lte: now } },
@@ -181,8 +197,15 @@ async function readRecentSymptoms(
export async function buildIllnessSnapshotBlock(
userId: string,
now: Date = new Date(),
/**
* The Coach's lookback limit. An episode that is still ongoing is current
* state and always shown (it carries Rest Mode); resolved episodes and
* symptoms are history and are read from inside the limit only.
*/
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<CoachIllnessBlock | null> {
if (!(await isIllnessEnabled(userId))) return null;
const floor = reachFloor(reach, now);
const [activeRows, resolvedRows, symptoms] = await Promise.all([
prisma.illnessEpisode.findMany({
@@ -203,7 +226,12 @@ export async function buildIllnessSnapshotBlock(
},
}),
prisma.illnessEpisode.findMany({
where: { userId, deletedAt: null, resolvedAt: { not: null } },
where: {
userId,
deletedAt: null,
resolvedAt: { not: null },
...(floor ? { onsetAt: { gte: floor } } : {}),
},
orderBy: { resolvedAt: "desc" },
take: RESOLVED_HISTORY_LIMIT,
select: {
@@ -215,7 +243,7 @@ export async function buildIllnessSnapshotBlock(
laterality: true,
},
}),
readRecentSymptoms(userId, now),
readRecentSymptoms(userId, now, reach),
]);
if (
@@ -342,6 +370,12 @@ function compactFindings(
export async function buildIllnessScores(
userId: string,
now: Date = new Date(),
/**
* The Coach's lookback limit. The scores compare the episode against a
* baseline read before its onset, so they are computed only when that
* baseline lies inside the limit too.
*/
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<CoachIllnessScores | null> {
if (!(await isIllnessEnabled(userId))) return null;
@@ -385,6 +419,11 @@ export async function buildIllnessScores(
// about); fall back to the most-recently-resolved for "last time" questions.
const episode = active ?? resolved;
if (!episode) return null;
const baselineStart = new Date(
episode.onsetAt.getTime() -
(PRE_ONSET_LOOKBACK_DAYS + BASELINE_WINDOW_DAYS) * 86_400_000,
);
if (!withinReach(baselineStart, reach, now)) return null;
const tz = user?.timezone ?? DEFAULT_TIMEZONE;
const derived = await computeEpisodeCorrelation(
+12 -3
View File
@@ -37,13 +37,19 @@
* is always owner-scoped and safe to read), so the block is `null` only when the
* account has no recent readings.
*/
import {
COACH_LABS_LOOKBACK_MONTHS,
UNBOUNDED_REACH,
laterOfFloor,
type CoachHistoryReach,
} from "./history-reach";
import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range";
import { resolveLabFields } from "@/lib/labs/serialise";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import { prisma } from "@/lib/db";
/** Only readings within this many months enter the prompt. */
const LOOKBACK_MONTHS = 12;
const LOOKBACK_MONTHS = COACH_LABS_LOOKBACK_MONTHS;
/** Cap on distinct biomarkers in the block (newest reading first). */
const MAX_BIOMARKERS = 24;
@@ -107,9 +113,12 @@ export interface CoachLabsBlock {
export async function buildLabsSnapshotBlock(
userId: string,
now: Date = new Date(),
/** The Coach's lookback limit: the shorter of it and the 12 months wins. */
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<CoachLabsBlock | null> {
const cutoff = new Date(now);
cutoff.setMonth(cutoff.getMonth() - LOOKBACK_MONTHS);
const monthsBack = new Date(now);
monthsBack.setMonth(monthsBack.getMonth() - LOOKBACK_MONTHS);
const cutoff = laterOfFloor(monthsBack, reach, now);
// Newest reading first; we keep only the first row seen per biomarker so the
// block carries the MOST RECENT value per analyte. Bounded fetch: at most a
+54 -9
View File
@@ -40,6 +40,14 @@ import {
import type { BaselineProfile } from "@/lib/insights/derived";
import type { Locale } from "@/lib/i18n/config";
import { buildCoachFactsBlock } from "./facts";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import {
UNBOUNDED_REACH,
fitsReach,
isBounded,
withinReach,
type CoachHistoryReach,
} from "./history-reach";
import {
buildCoachPlansBlock,
buildCoachRemindersBlock,
@@ -50,6 +58,31 @@ import {
/** The period the rolling profile recalls — month is the high-signal beat. */
const MEMORY_PERIOD: NarrativePeriod = "month";
/**
* How far back the narrative and the band memory reach: the prior month
* against the current one. A shorter lookback limit leaves both out; facts,
* plans and reminders are what the person told the Coach, not readings, and
* stay.
*/
const MEMORY_HISTORY_HORIZON_DAYS = 2 * 30; // two `PERIOD_DAYS.month`
/** True when the history a narrative dated `dateKey` describes lies inside the limit. */
function narrativeWithinReach(
dateKey: string,
reach: CoachHistoryReach,
now: Date,
): boolean {
if (!isBounded(reach)) return true;
// The narrative row's own day key, compared as its date, not a local day.
const end = dayKeyAsUtcMidnight(dateKey).getTime();
if (!Number.isFinite(end)) return false;
return withinReach(
new Date(end - MEMORY_HISTORY_HORIZON_DAYS * 86_400_000),
reach,
now,
);
}
/** Cap the recalled narrative so a verbose row cannot bloat the snapshot. */
const NARRATIVE_HEADLINE_CHARS = 600;
/** Drivers are conservative one-liners; a handful is plenty of recall. */
@@ -145,12 +178,22 @@ export async function buildCoachMemoryBlock(
* pipeline never writes for that user.
*/
locale: Locale,
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<CoachMemoryBlock | null> {
const historyAllowed = fitsReach(MEMORY_HISTORY_HORIZON_DAYS, reach);
let priorNarrative: PriorNarrativeRecall | undefined;
// Sub-source 1: the latest period-narrative headline + driver recall.
// Sub-source 1: the latest period-narrative headline + driver recall. The
// row is whatever was last written, of any age; under a limit it counts
// only when the history it describes lies inside the limit.
try {
const row = await readPeriodNarrative(userId, MEMORY_PERIOD, locale);
if (row && row.text.trim().length > 0) {
const row = historyAllowed
? await readPeriodNarrative(userId, MEMORY_PERIOD, locale)
: null;
if (
row &&
row.text.trim().length > 0 &&
narrativeWithinReach(row.dateKey, reach, now)
) {
const recall = recallNarrative(row);
recall.drivers = recall.drivers.slice(0, MAX_RECALLED_DRIVERS);
priorNarrative = recall;
@@ -165,12 +208,14 @@ export async function buildCoachMemoryBlock(
// parallel aggregation, no recompute beyond the one assembly call.
const trendMemory: Record<string, TrendMemoryEntry> = {};
try {
const ctx = await buildPeriodNarrativeContext(userId, {
period: MEMORY_PERIOD,
now,
locale,
});
if (ctx.status === "ready") {
const ctx = historyAllowed
? await buildPeriodNarrativeContext(userId, {
period: MEMORY_PERIOD,
now,
locale,
})
: null;
if (ctx?.status === "ready") {
for (const transition of ctx.bandTransitions) {
trendMemory[transition.type] = trendFromTransition(transition);
}
@@ -21,6 +21,7 @@ vi.mock("@/lib/ai/provider-runner", () => ({
import { runCoachToolLoop } from "@/lib/ai/coach/tools/loop";
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
import { createResultRefAllocator } from "../refs";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
function round(content: string, toolCalls?: unknown[]) {
return {
@@ -80,6 +81,7 @@ describe("runCoachToolLoop — result tables", () => {
system: "sys",
messages: [{ role: "user", content: "q" }],
tools: COACH_TOOL_DEFS,
reach: UNBOUNDED_REACH,
turn,
onCallSettled: (_call, result) => settled.push(result),
});
@@ -123,6 +125,7 @@ describe("runCoachToolLoop — result tables", () => {
systemOnceTableShown: "sys+tables",
messages: [{ role: "user", content: "q" }],
tools: COACH_TOOL_DEFS,
reach: UNBOUNDED_REACH,
turn: {
conversationId: "c1",
locale: "en",
@@ -29,6 +29,7 @@
* Those summary figures, not the table, are what the reply's numbers are
* checked against.
*/
import { COACH_ALL_TIME_TABLE_DAYS } from "@/lib/ai/coach/history-reach";
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import type { Locale } from "@/lib/i18n/config";
@@ -95,8 +96,8 @@ export const TABLE_SUMMARY_MAX_CHARS = 6_000;
/** At most this many row values ride the summary. */
export const TABLE_SUMMARY_MAX_VALUES = 60;
/** The chart's "All" range: about ten years. */
const ALL_TIME_DAYS = 3_650;
/** The chart's "All" range: about ten years (the settings text states it). */
const ALL_TIME_DAYS = COACH_ALL_TIME_TABLE_DAYS;
const WINDOW_DAYS: Readonly<Record<CoachScopeWindow, number>> = {
last7days: 7,
+5 -1
View File
@@ -50,6 +50,7 @@ import {
type SelfContext,
} from "@/lib/ai/coach/about-me";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import { readCoachReach } from "@/lib/ai/coach/history-reach-read";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import {
@@ -255,7 +256,10 @@ export async function deriveClarifyingQuestions(
// ask about what the user actually tracks. Best-effort: a snapshot
// failure must not cost the AI path, the prompt just stays
// fields-only.
const snapshotJson = await buildCoachSnapshot(userId)
// The person's Coach lookback limit applies here too: the questions are
// asked from what the Coach may see.
const snapshotJson = await readCoachReach(userId)
.then((reach) => buildCoachSnapshot(userId, undefined, { reach }))
.then((s) => s.snapshotJson || null)
.catch(() => null);
const { systemPrompt, userPrompt } = buildPrompts(
@@ -134,6 +134,8 @@ export function buildGlucoseBlock(ctx: Readonly<GlucoseBlockContext>): void {
// rather than asserted as a clinical AGP. The headline mean is converted
// ONCE to the user's display unit; the unit-agnostic fractions / indices
// travel as-is.
// `null` rows: the panel's fixed window reaches past the Coach's lookback
// limit, so the panel is left out rather than computed over fewer days.
const clinicalRaw = computeGlucoseClinicalMetrics(
(glucoseClinicalRows ?? []).map((r) => ({
measuredAt: r.measuredAt,
@@ -194,7 +196,11 @@ export function buildGlucoseBlock(ctx: Readonly<GlucoseBlockContext>): void {
};
// The display unit travels with the block so the prompt renders
// "<value> <unit>" and the EVIDENCE BLOCK tags glucose lines correctly.
snapshot.glucose = { unit: glucoseUnit, byContext: contexts, clinical };
snapshot.glucose = {
unit: glucoseUnit,
byContext: contexts,
...(glucoseClinicalRows !== null ? { clinical } : {}),
};
metrics.add("glucose");
counts.glucose = glucoseRows.length;
registerBlock("glucose", "glucose");
+8 -1
View File
@@ -22,6 +22,7 @@
*/
import type { CoachSnapshotResult } from "./snapshot";
import type { CoachScope, CoachScopeWindow } from "./types";
import { reachCacheToken, type CoachHistoryReach } from "./history-reach";
/** Default window when the caller doesn't pass a scope. */
export const DEFAULT_WINDOW: CoachScopeWindow = "last30days";
@@ -36,6 +37,12 @@ const snapshotCache = new Map<
export function snapshotCacheKey(
userId: string,
scope: CoachScope | undefined,
/**
* The lookback limit the build honoured. Part of the key so a build read
* without a limit (MCP) is never served to a limited Coach turn, nor the
* other way round. No limit adds nothing, so those keys are unchanged.
*/
reach?: CoachHistoryReach,
): string {
// v1.7.0 — when the request pins an explicit source list, key on it.
// Otherwise the source set is derived from the user's saved
@@ -48,7 +55,7 @@ export function snapshotCacheKey(
scope?.sources && scope.sources.length > 0
? Array.from(scope.sources).sort().join(",")
: "clusters";
return `${userId}|${window}|${sourceList}`;
return `${userId}|${window}|${sourceList}${reachCacheToken(reach)}`;
}
export function readSnapshotCache(key: string): CoachSnapshotResult | null {
+38 -16
View File
@@ -18,6 +18,7 @@ import { buildTieredSeries } from "@/lib/rollups/tiered-context";
import type { MeasurementType } from "@/generated/prisma/client";
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
import { DEFAULT_WINDOW } from "./snapshot-cache";
import { COACH_DAILY_DETAIL_DAYS } from "./history-reach";
import type { CoachScope, CoachScopeSource, CoachScopeWindow } from "./types";
const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
@@ -36,12 +37,12 @@ export function windowToDays(window: CoachScopeWindow): number {
// stays bounded, but with explicit semantics — the Coach knows
// the user is asking about a long-horizon view rather than the
// unbounded "all time" backfill.
return 365;
return COACH_DAILY_DETAIL_DAYS;
case "allTime":
// Cap "allTime" at one year for the timeline. The aggregate
// section already cites multi-year ranges via the features
// pipeline; the timeline stays tight to keep token budget sane.
return 365;
return COACH_DAILY_DETAIL_DAYS;
}
}
@@ -251,6 +252,13 @@ export async function buildCoarseTimelineTail(
tz: string,
/** The user's source-priority blob, already loaded by the caller. */
userPriorityJson: unknown,
/**
* The Coach's lookback floor, or null for none. Under a floor only the
* MONTH buckets that start inside it are kept; the YEAR band and the
* anomaly envelope go, because the envelope is scored against the spread
* of every coarse bucket, the older ones included.
*/
floor: Date | null = null,
): Promise<CoarseTimelineTail | undefined> {
// `buildTieredSeries` reads fall back on miss — a coverage miss yields empty
// bands, which collapse to `undefined` here. The try/catch keeps the coarse
@@ -272,25 +280,39 @@ export async function buildCoarseTimelineTail(
} catch {
return undefined;
}
const monthly = series.monthBand.map(
(b) =>
[b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number],
);
const yearly = series.yearBand.map(
(b) =>
[b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number],
);
if (
monthly.length === 0 &&
yearly.length === 0 &&
series.anomalies.length === 0
) {
const monthly = series.monthBand
.filter(
(b) => floor === null || Date.parse(b.bucketStart) >= floor.getTime(),
)
.map(
(b) =>
[b.bucketStart, b.mean, b.min, b.max] as [
string,
number,
number,
number,
],
);
const yearly =
floor === null
? series.yearBand.map(
(b) =>
[b.bucketStart, b.mean, b.min, b.max] as [
string,
number,
number,
number,
],
)
: [];
const anomalies = floor === null ? series.anomalies : [];
if (monthly.length === 0 && yearly.length === 0 && anomalies.length === 0) {
return undefined;
}
return {
monthly,
yearly,
anomalies: series.anomalies.map((a) => ({
anomalies: anomalies.map((a) => ({
band: a.band,
date: a.date,
kind: a.kind,
+128 -59
View File
@@ -101,6 +101,17 @@ import {
buildProfileContextBlocks,
} from "./snapshot-blocks/context-blocks";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import {
UNBOUNDED_REACH,
clampWindow,
fitsReach,
isBounded,
reachFloor,
type CoachHistoryReach,
} from "./history-reach";
import { ADHERENCE_STORYLINE_HORIZON_DAYS } from "@/lib/insights/derived/adherence-storyline";
import { CHANGEPOINT_WINDOW_DAYS } from "@/lib/insights/derived/changepoint";
import { SIGNAL_TRUST_WINDOW_DAYS } from "@/lib/insights/derived/signal-trust";
// Test-only escape hatch — the suites import it from this module.
export { __resetCoachSnapshotCacheForTests } from "./snapshot-cache";
@@ -174,6 +185,13 @@ const SNAPSHOT_MEASUREMENT_ROW_CAP = 6000;
*/
const SLEEP_RHYTHM_WINDOW_DAYS = 42;
/**
* Where the coarse MONTH tail begins: the in-window weekly fold covers the
* first 90 days, the rollup tail what lies beyond. Under a lookback limit of
* 90 days or less the tail has nothing it may add.
*/
const COARSE_TAIL_START_DAYS = 90;
/**
* v1.7.0 — assembled-snapshot soft char cap. After the snapshot is
* built we measure `JSON.stringify(snapshot).length` as a ~4-chars-per-
@@ -233,11 +251,20 @@ const CORE_CLUSTERS: ReadonlySet<CoachDataCluster> = new Set<CoachDataCluster>([
export async function buildCoachSnapshot(
userId: string,
scope?: CoachScope,
options?: {
/**
* How far back the build may read (`history-reach.ts`). Every Coach
* caller passes the person's limit; MCP passes none and reads without
* one, as it always did.
*/
reach?: CoachHistoryReach;
},
): Promise<CoachSnapshotResult> {
const key = snapshotCacheKey(userId, scope);
const reach = options?.reach ?? UNBOUNDED_REACH;
const key = snapshotCacheKey(userId, scope, reach);
const cached = readSnapshotCache(key);
if (cached) return cached;
const result = await buildCoachSnapshotImpl(userId, scope);
const result = await buildCoachSnapshotImpl(userId, scope, reach);
writeSnapshotCache(key, result);
return result;
}
@@ -267,7 +294,8 @@ function blockFailed(block: string): (err: unknown) => null {
async function buildCoachSnapshotImpl(
userId: string,
scope?: CoachScope,
scope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise<CoachSnapshotResult> {
// v1.4.23 H4 — apply per-user `excludeMetrics` BEFORE we read any
// measurement rows so the model never sees data the user opted out
@@ -336,10 +364,14 @@ async function buildCoachSnapshotImpl(
? (prefsRow?.locale as Locale)
: defaultLocale;
const clusterDefault = clusterSourcesFromPrefs(prefs.dataClusters);
const { sources: scopedSources, window } = resolveScope(
const { sources: scopedSources, window: requestedWindow } = resolveScope(
scope,
clusterDefault,
);
// The lookback limit caps the narration window, whatever the caller asked
// for. Every fixed-window block below answers to the same limit.
const window = clampWindow(requestedWindow, reach);
const bounded = isBounded(reach);
const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE;
const units = resolveUnitPreferences({
unitPreference: prefsRow?.unitPreference,
@@ -376,9 +408,16 @@ async function buildCoachSnapshotImpl(
// do not share this read; memoise it per-request keyed on the only varying
// input (windowDays) so the fan-out runs it once instead of up to 6× against
// the shared pool.
// Under a lookback limit the aggregate's "all time" figures, the mood
// history and its sub-blocks are read from inside the limit only.
const featuresFloor = reachFloor(reach);
const featuresPromise = memoizePerRequest(
`coach-features:${userId}:${windowDays}`,
() => extractFeatures(userId, false, { sinceDays: windowDays }),
`coach-features:${userId}:${windowDays}:${reach.days ?? "all"}`,
() =>
extractFeatures(userId, false, {
sinceDays: windowDays,
...(featuresFloor ? { historyFloor: featuresFloor } : {}),
}),
);
// Trim down to the metrics the Coach narrates. extractFeatures
@@ -488,31 +527,39 @@ async function buildCoachSnapshotImpl(
const coarseTailPromises: Partial<
Record<"bp" | "weight" | "pulse", Promise<CoarseTimelineTail | undefined>>
> = {};
if (wantsBp) {
// The coarse tail starts where the in-window weekly fold ends (90 days), so
// a limit of 90 days or less leaves nothing for it to add.
const coarseTailAllowed =
reach.days === null || reach.days > COARSE_TAIL_START_DAYS;
const coarseFloor = reachFloor(reach, now);
if (wantsBp && coarseTailAllowed) {
coarseTailPromises.bp = buildCoarseTimelineTail(
userId,
"BLOOD_PRESSURE_SYS" as MeasurementType,
now,
userTz,
prefsRow?.sourcePriorityJson ?? null,
coarseFloor,
);
}
if (wantsWeight) {
if (wantsWeight && coarseTailAllowed) {
coarseTailPromises.weight = buildCoarseTimelineTail(
userId,
"WEIGHT" as MeasurementType,
now,
userTz,
prefsRow?.sourcePriorityJson ?? null,
coarseFloor,
);
}
if (wantsPulse) {
if (wantsPulse && coarseTailAllowed) {
coarseTailPromises.pulse = buildCoarseTimelineTail(
userId,
"PULSE" as MeasurementType,
now,
userTz,
prefsRow?.sourcePriorityJson ?? null,
coarseFloor,
);
}
const [bpCoarseTail, weightCoarseTail, pulseCoarseTail] = await Promise.all([
@@ -701,26 +748,29 @@ async function buildCoachSnapshotImpl(
const sleepRhythmCutoff = new Date(
now.getTime() - SLEEP_RHYTHM_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
const sleepRhythmRowsPromise = sources.has("sleep")
? prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION" as never,
measuredAt: { gte: sleepRhythmCutoff },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
// source + deviceType feed the canonical writer-dedup so a multi-source
// night is counted ONCE, matching every other sleep surface.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
})
: null;
// A limit shorter than the rhythm's fixed 42 days leaves the block out: a
// debt or chronotype band over fewer nights is not the one the page shows.
const sleepRhythmRowsPromise =
sources.has("sleep") && fitsReach(SLEEP_RHYTHM_WINDOW_DAYS, reach)
? prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION" as never,
measuredAt: { gte: sleepRhythmCutoff },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
// source + deviceType feed the canonical writer-dedup so a multi-source
// night is counted ONCE, matching every other sleep surface.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
})
: null;
const workoutRowsPromise = sources.has("workouts")
? prisma.workout.findMany({
where: { userId, startedAt: { gte: additiveCutoff("workouts") } },
@@ -751,23 +801,26 @@ async function buildCoachSnapshotImpl(
const glucoseClinicalCutoff = new Date(
now.getTime() - GLUCOSE_CLINICAL_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
const glucoseClinicalRowsPromise = sources.has("glucose")
? prisma.measurement.findMany({
where: {
userId,
type: "BLOOD_GLUCOSE" as never,
measuredAt: { gte: glucoseClinicalCutoff },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true },
})
: null;
// Same rule for the fixed 30-day clinical panel: under a shorter limit the
// panel is left out (null rows), never computed over fewer days.
const glucoseClinicalRowsPromise =
sources.has("glucose") && fitsReach(GLUCOSE_CLINICAL_WINDOW_DAYS, reach)
? prisma.measurement.findMany({
where: {
userId,
type: "BLOOD_GLUCOSE" as never,
measuredAt: { gte: glucoseClinicalCutoff },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true },
})
: null;
const glp1BlockPromise = excludesMedications
? null
: buildGlp1SnapshotBlock(userId, now, userTz);
: buildGlp1SnapshotBlock(userId, now, userTz, reach);
const derivedBlockPromise = derivedActive
? buildDerivedSnapshotBlock(userId, derivedProfile, now, userTz)
? buildDerivedSnapshotBlock(userId, derivedProfile, now, userTz, reach)
: null;
// v1.17.0 — WHOOP-native day strain (0–21), distinct from the COMPUTED
// STRAIN_SCORE (0–100) the derived block carries. Gated on the same
@@ -787,7 +840,7 @@ async function buildCoachSnapshotImpl(
})
: null;
const trajectoryBlockPromise = derivedActive
? buildTrajectorySnapshotBlock(userId, derivedProfile, now)
? buildTrajectorySnapshotBlock(userId, derivedProfile, now, reach)
: null;
// RECON1 (D5-5) — discovered cross-metric driver pairs for the no-tools
// snapshot floor. Reuses the SAME gated/ranked output the get_correlations
@@ -797,13 +850,14 @@ async function buildCoachSnapshotImpl(
// instead of getting only the coincident flag. Gated on `derivedActive` (it
// is the recovery / cross-metric layer) and fail-soft to null.
const correlationsBlockPromise = derivedActive
? buildCorrelationsSnapshotBlock(userId, coachLocale)
? buildCorrelationsSnapshotBlock(userId, coachLocale, reach)
: null;
const memoryBlockPromise = buildCoachMemoryBlock(
userId,
derivedProfile,
now,
coachLocale,
reach,
);
// v1.15 — cycle/phase block, gated on the resolved cycle module so a
// non-cycle account issues no query (the helper short-circuits to null
@@ -815,16 +869,20 @@ async function buildCoachSnapshotImpl(
// server-wide kill-switch), so an operator-off instance never injects the
// cycle block into the coach prompt.
const cycleEnabled = await isCycleAvailableForUser(userId);
const cycleBlockPromise = cycleEnabled
? buildCycleSnapshotBlock(userId, prefsRow?.gender, now, userTz)
: null;
// The cycle block predicts from every logged cycle, of any age, so that its
// "day N, period in M days" matches the calendar. Any lookback limit leaves
// it out rather than predicting from a part of the history.
const cycleBlockPromise =
cycleEnabled && !bounded
? buildCycleSnapshotBlock(userId, prefsRow?.gender, now, userTz)
: null;
// v1.18.1 P4 — illness/condition context. Always attempted (the helper is
// module-gated internally and short-circuits to null for a non-illness
// account). It is CONTEXT, not a scope-gated metric: appended like
// anthropometrics/scope below without a `registerBlock` so the budget
// degrader never sheds it — the Coach needs to know about Rest Mode.
const illnessBlockPromise = buildIllnessSnapshotBlock(userId, now);
const illnessBlockPromise = buildIllnessSnapshotBlock(userId, now, reach);
// v1.18.11 (#65) — lab-result context. Like illness it is CONTEXT, not a
// scope-gated metric: attached without a `registerBlock` so the budget
@@ -832,7 +890,7 @@ async function buildCoachSnapshotImpl(
// helper reads owner-scoped rows directly, mirroring `/api/labs`). The block
// carries the most-recent resolved reading per biomarker (last 12 months,
// capped) so the Coach can answer "what was my LDL" without re-deriving.
const labsBlockPromise = buildLabsSnapshotBlock(userId, now);
const labsBlockPromise = buildLabsSnapshotBlock(userId, now, reach);
// v1.38 — doctor-visit context. Like illness/labs it is attempted always (a
// visit is core, never module-gated) and short-circuits to null when there is
@@ -840,7 +898,7 @@ async function buildCoachSnapshotImpl(
// UNLIKE illness/labs it IS registered for degradation below (against the
// lowest-priority cluster), because a visit history is low-frequency context
// the Coach can lose under budget pressure without losing a safety flag.
const visitsBlockPromise = buildVisitsSnapshotBlock(userId, now);
const visitsBlockPromise = buildVisitsSnapshotBlock(userId, now, reach);
const [
moodRows,
@@ -1137,22 +1195,31 @@ async function buildCoachSnapshotImpl(
// bounded to its domain and avoids spurious recovery/vital reads.
const isExplicitlyScoped =
Array.isArray(scope?.sources) && scope.sources.length > 0;
// Each of these reads its own fixed window; one that reaches past the
// lookback limit is left out. The experiment read-back compares against a
// baseline from before the plan began, of no fixed length, so any limit
// leaves it out.
const [adherenceStoryline, changepoints, signalTrust, experimentOutcomes] =
isExplicitlyScoped
? [null, null, null, null]
: await Promise.all([
excludesMedications
excludesMedications ||
!fitsReach(ADHERENCE_STORYLINE_HORIZON_DAYS, reach)
? Promise.resolve(null)
: buildAdherenceStoryline(userId, userTz, now).catch(
blockFailed("adherenceStoryline"),
),
buildChangepointSignals(userId, now).catch(
blockFailed("changepoints"),
),
buildSignalTrust(userId, userTz, now).catch(
blockFailed("signalTrust"),
),
experimentVerdictEnabled()
fitsReach(CHANGEPOINT_WINDOW_DAYS, reach)
? buildChangepointSignals(userId, now).catch(
blockFailed("changepoints"),
)
: Promise.resolve(null),
fitsReach(SIGNAL_TRUST_WINDOW_DAYS, reach)
? buildSignalTrust(userId, userTz, now).catch(
blockFailed("signalTrust"),
)
: Promise.resolve(null),
experimentVerdictEnabled() && !bounded
? buildExperimentOutcomeBlock(userId, { now }).catch(
blockFailed("experimentOutcomes"),
)
@@ -1178,6 +1245,8 @@ async function buildCoachSnapshotImpl(
window,
sources: Array.from(sources),
timelineRecentDays: DAILY_TIMELINE_DAYS,
// Named only under a limit, so an unlimited snapshot stays as it was.
...(bounded ? { lookbackLimit: reach.window } : {}),
};
// v1.4.36 W3 T4 — compactSections drops any zero-row block before
+187 -12
View File
@@ -29,6 +29,15 @@
* 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.
* `outside_reach` — rows exist, every one of them older than the lookback
* limit the person set for the Coach. Nothing about them
* is reported, not a count, a date or a mean: the limit
* says the Coach does not read them, and an aggregate is
* a reading. The model says older readings lie beyond the
* limit and can be opened in the Coach settings.
*
* Under a limit every probe below reads from inside it only, and
* `reachableWithWindow` never names a window wider than the limit.
*
* Cost. One grouped aggregate over `Measurement` for every measurement-backed
* subject in the batch (indexed by `(userId, type, measuredAt)`, no rows
@@ -55,6 +64,12 @@ 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";
import {
UNBOUNDED_REACH,
exceedsReach,
reachFloor,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
/** Which table a domain's existence has to be checked against. */
export type CoachAvailabilitySubject =
@@ -119,9 +134,14 @@ const WINDOWS_WIDEST_LAST: readonly CoachScopeWindow[] = [
* `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 {
function reachableWindow(
lastAt: Date,
now: Date,
reach: CoachHistoryReach,
): CoachScopeWindow | null {
const ageDays = (now.getTime() - lastAt.getTime()) / 86_400_000;
for (const window of WINDOWS_WIDEST_LAST) {
if (exceedsReach(window, reach)) break;
if (ageDays <= windowToDays(window)) return window;
}
return null;
@@ -148,16 +168,22 @@ interface RawBounds {
async function readMeasurementBounds(
userId: string,
types: readonly MeasurementType[],
floor: Date | null,
): Promise<Map<MeasurementType, RawBounds>> {
const rows = await prisma.measurement.groupBy({
by: ["type", "unit"],
where: { userId, deletedAt: null, type: { in: [...types] } },
where: {
userId,
deletedAt: null,
type: { in: [...types] },
...(floor ? { measuredAt: { gte: floor } } : {}),
},
_count: { _all: true },
_min: { measuredAt: true, value: true },
_max: { measuredAt: true, value: true },
_avg: { value: true },
});
const dayMeans = await readHourlyMeanTypeMeans(userId, types);
const dayMeans = await readHourlyMeanTypeMeans(userId, types, floor);
const out = new Map<MeasurementType, RawBounds>();
for (const row of rows) {
const firstAt = row._min.measuredAt;
@@ -212,6 +238,7 @@ async function readMeasurementBounds(
async function readHourlyMeanTypeMeans(
userId: string,
types: readonly MeasurementType[],
floor: Date | null,
): Promise<Map<string, number>> {
const hourly = types.filter((t) => usesHourlyMeanDay(t));
const out = new Map<string, number>();
@@ -228,6 +255,7 @@ async function readHourlyMeanTypeMeans(
AND m."type" IN (${Prisma.join(
hourly.map((t) => Prisma.sql`${t}::measurement_type`),
)})
${floor ? Prisma.sql`AND m."measured_at" >= ${floor}` : Prisma.empty}
)
SELECT
m."type"::text AS type,
@@ -252,10 +280,15 @@ async function readHourlyMeanTypeMeans(
async function readTableBounds(
userId: string,
kind: Exclude<CoachAvailabilitySubject["kind"], "measurement">,
floor: Date | null,
): Promise<RawBounds | null> {
if (kind === "mood") {
const row = await prisma.moodEntry.aggregate({
where: { userId, deletedAt: null },
where: {
userId,
deletedAt: null,
...(floor ? { moodLoggedAt: { gte: floor } } : {}),
},
_count: { _all: true },
_min: { moodLoggedAt: true },
_max: { moodLoggedAt: true },
@@ -271,7 +304,7 @@ async function readTableBounds(
if (kind === "workout") {
// Workout deletes are hard deletes — there is no `deletedAt` to filter.
const row = await prisma.workout.aggregate({
where: { userId },
where: { userId, ...(floor ? { startedAt: { gte: floor } } : {}) },
_count: { _all: true },
_min: { startedAt: true },
_max: { startedAt: true },
@@ -286,7 +319,11 @@ async function readTableBounds(
}
if (kind === "intake") {
const row = await prisma.medicationIntakeEvent.aggregate({
where: { userId, deletedAt: null },
where: {
userId,
deletedAt: null,
...(floor ? { scheduledFor: { gte: floor } } : {}),
},
_count: { _all: true },
_min: { scheduledFor: true },
_max: { scheduledFor: true },
@@ -300,7 +337,11 @@ async function readTableBounds(
: null;
}
const row = await prisma.labResult.aggregate({
where: { userId, deletedAt: null },
where: {
userId,
deletedAt: null,
...(floor ? { takenAt: { gte: floor } } : {}),
},
_count: { _all: true },
_min: { takenAt: true },
_max: { takenAt: true },
@@ -345,11 +386,13 @@ function mergeBounds(parts: readonly RawBounds[]): RawBounds | null {
export async function probeCoachAvailability(
userId: string,
subjects: ReadonlyMap<string, CoachAvailabilitySubject>,
options?: { now?: Date },
options?: { now?: Date; reach?: CoachHistoryReach },
): Promise<Map<string, CoachDomainAvailability>> {
const out = new Map<string, CoachDomainAvailability>();
if (subjects.size === 0) return out;
const now = options?.now ?? new Date();
const reach = options?.reach ?? UNBOUNDED_REACH;
const floor = reachFloor(reach, now);
const measurementTypes = new Set<MeasurementType>();
const tableKinds = new Set<
@@ -366,11 +409,11 @@ export async function probeCoachAvailability(
const [tz, measurementBounds, ...tableRows] = await Promise.all([
resolveUserTimezone(userId),
measurementTypes.size > 0
? readMeasurementBounds(userId, [...measurementTypes])
? readMeasurementBounds(userId, [...measurementTypes], floor)
: Promise.resolve(new Map<MeasurementType, RawBounds>()),
...[...tableKinds].map(async (kind) => ({
kind,
bounds: await readTableBounds(userId, kind),
bounds: await readTableBounds(userId, kind, floor),
})),
]);
const byKind = new Map(tableRows.map((r) => [r.kind, r.bounds]));
@@ -390,12 +433,117 @@ export async function probeCoachAvailability(
firstDate: userDayKey(raw.firstAt, tz),
lastDate: userDayKey(raw.lastAt, tz),
...(raw.series && raw.series.length > 0 ? { series: raw.series } : {}),
reachableWithWindow: reachableWindow(raw.lastAt, now),
reachableWithWindow: reachableWindow(raw.lastAt, now, reach),
});
}
return out;
}
/**
* The subjects among `subjects` that hold rows older than the lookback limit.
* Existence only, one `findFirst` per subject: what lies beyond the limit is
* never counted or aggregated. Empty without a limit.
*/
export async function probeBeyondReach(
userId: string,
subjects: ReadonlyMap<string, CoachAvailabilitySubject>,
reach: CoachHistoryReach,
now: Date = new Date(),
): Promise<Set<string>> {
const floor = reachFloor(reach, now);
const out = new Set<string>();
if (floor === null) return out;
await Promise.all(
[...subjects].map(async ([key, subject]) => {
if (await holdsRowsBefore(userId, subject, floor)) out.add(key);
}),
);
return out;
}
async function holdsRowsBefore(
userId: string,
subject: CoachAvailabilitySubject,
floor: Date,
): Promise<boolean> {
switch (subject.kind) {
case "measurement":
return (
(await prisma.measurement.findFirst({
where: {
userId,
deletedAt: null,
type: { in: [...subject.types] },
measuredAt: { lt: floor },
},
select: { id: true },
})) !== null
);
case "mood":
return (
(await prisma.moodEntry.findFirst({
where: { userId, deletedAt: null, moodLoggedAt: { lt: floor } },
select: { id: true },
})) !== null
);
case "workout":
return (
(await prisma.workout.findFirst({
where: { userId, startedAt: { lt: floor } },
select: { id: true },
})) !== null
);
case "intake":
return (
(await prisma.medicationIntakeEvent.findFirst({
where: { userId, deletedAt: null, scheduledFor: { lt: floor } },
select: { id: true },
})) !== null
);
case "lab":
return (
(await prisma.labResult.findFirst({
where: { userId, deletedAt: null, takenAt: { lt: floor } },
select: { id: true },
})) !== null
);
}
}
/**
* True when the record holds illness episodes that began before the lookback
* floor. The illness domain has no row subject (its block is computed), so a
* limited read that comes back empty asks this to tell "never recorded" from
* "recorded, beyond the limit". Always false without a limit.
*/
export async function illnessBeyondReach(
userId: string,
reach: CoachHistoryReach,
now: Date = new Date(),
): Promise<boolean> {
const floor = reachFloor(reach, now);
if (floor === null) return false;
const row = await prisma.illnessEpisode.findFirst({
where: { userId, deletedAt: null, onsetAt: { lt: floor } },
select: { id: true },
});
return row !== null;
}
/**
* True when the record holds any logged cycle. Under a lookback limit the
* cycle block is left out whole (it predicts from every cycle), so an
* account with cycles is told they lie beyond the limit, and one without is
* told there are none.
*/
export async function cycleRecorded(userId: string): Promise<boolean> {
const row = await prisma.menstrualCycle.findFirst({
where: { userId, deletedAt: null },
select: { id: true },
});
return row !== null;
}
/**
* 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
@@ -410,8 +558,13 @@ const EMPTY_READ_REASONS = {
unavailableInScope: "unavailable_in_scope",
/** The probe failed; absence could not be established either way. */
unconfirmed: "no_data_unconfirmed",
/** Rows exist, all of them older than the person's lookback limit. */
outsideReach: "outside_reach",
} as const;
/** The reason a read gives for data that lies only beyond the lookback limit. */
export const OUTSIDE_REACH_REASON = EMPTY_READ_REASONS.outsideReach;
/**
* Split a "rows exist" verdict into the two cases that differ in what the model
* should do next.
@@ -456,8 +609,11 @@ export async function resolveEmptyRead(args: {
subject: CoachAvailabilitySubject | null;
searchedWindow: CoachScopeWindow | undefined;
now?: Date;
/** The person's lookback limit; absent (MCP) is none. */
reach?: CoachHistoryReach;
}): Promise<EmptyReadResult> {
const { userId, domain, subject, searchedWindow } = args;
const reach = args.reach ?? UNBOUNDED_REACH;
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
@@ -470,8 +626,27 @@ export async function resolveEmptyRead(args: {
availability = await probeCoachAvailability(
userId,
new Map([[domain, subject]]),
args.now ? { now: args.now } : undefined,
{ ...(args.now ? { now: args.now } : {}), reach },
);
if (!availability.has(domain)) {
const beyond = await probeBeyondReach(
userId,
new Map([[domain, subject]]),
reach,
args.now,
);
if (beyond.has(domain)) {
annotate({
action: { name: "coach.availability.resolved" },
meta: { domain, reason: EMPTY_READ_REASONS.outsideReach },
});
return {
present: false,
reason: EMPTY_READ_REASONS.outsideReach,
...searched,
};
}
}
} catch (err) {
annotate({
action: { name: "coach.availability.probe_failed" },
+29 -4
View File
@@ -34,6 +34,12 @@
* interpretation string — so the Coach states the observed linkage without
* inventing a relationship.
*/
import {
UNBOUNDED_REACH,
capDays,
isBounded,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
import { prisma } from "@/lib/db";
import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
import type { Locale } from "@/lib/i18n/config";
@@ -62,6 +68,9 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Trailing window for the discovery scan — mirrors the insight route. */
const WINDOW_DAYS = 180;
/** The coincident-deviation flag's own default window. */
const COINCIDENT_WINDOW_DAYS = 30;
/** One discovered driver pair, descriptive — never causal. */
export interface CoachCorrelationDriver {
behaviour: string;
@@ -180,7 +189,18 @@ export async function readCoachCorrelations(
* German page in English.
*/
locale: Locale,
options?: {
/**
* The Coach's lookback limit: the scan, the early-detection pass and the
* coincident flag read no further back than it. Absent (MCP, the metric
* page strip): the fixed 180-day scan as before.
*/
reach?: CoachHistoryReach;
},
): Promise<CoachCorrelationsResult> {
const reach = options?.reach ?? UNBOUNDED_REACH;
const windowDays = capDays(WINDOW_DAYS, reach);
const earlyDays = capDays(EARLY_WINDOW_DAYS, reach);
// v1.30.22 — the `insights` gate lives HERE, at the read, not at each
// caller. This reader is reached from four places (the Coach
// `get_correlations` tool, the MCP `get_correlation` rich read, the
@@ -211,7 +231,7 @@ export async function readCoachCorrelations(
select: { timezone: true },
});
const tz = userRow?.timezone ?? DEFAULT_TIMEZONE;
const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY);
const since = new Date(Date.now() - windowDays * MS_PER_DAY);
// The channel set is the shared one, so the Coach scans exactly what the
// correlations page scans. The measurement read stays `"raw"` (the route's
@@ -228,7 +248,12 @@ export async function readCoachCorrelations(
// baseline hiccup never sinks the whole correlations read. D2-8: pass the
// user's tz so the "today" grouping matches the user's calendar day, not
// UTC's, before the fired flag is narrated as "out of band TODAY".
computeCoincidentDeviation(userId, profile, { tz }).catch(() => null),
computeCoincidentDeviation(userId, profile, {
tz,
...(isBounded(reach)
? { windowDays: capDays(COINCIDENT_WINDOW_DAYS, reach) }
: {}),
}).catch(() => null),
isSurfaceVisible("correlation:LAB_DRAWS", modules)
? fetchLabDraws(userId, tz, since)
: Promise.resolve([]),
@@ -268,7 +293,7 @@ export async function readCoachCorrelations(
// scan already established, so the Coach never narrates the same pattern as
// both "established" and "emerging".
const recentFromDayKey = tzDayKey(
new Date(Date.now() - EARLY_WINDOW_DAYS * MS_PER_DAY),
new Date(Date.now() - earlyDays * MS_PER_DAY),
tz,
);
const emergingResult = discoverEmergingCorrelations(series, discovery, {
@@ -323,7 +348,7 @@ export async function readCoachCorrelations(
...(labDrivers.length > 0 ? { labDrivers } : {}),
...(coincident ? { coincident } : {}),
pairsTested: discovery.pairsTested,
windowDays: WINDOW_DAYS,
windowDays,
};
} catch {
return { present: false, reason: "retrieval_failed" };
+16 -7
View File
@@ -202,6 +202,15 @@ const WINDOW_ENUM = [
* terse and brand-free; they tell the model WHEN to reach for each tool and
* that an absent domain returns `{ present: false }` rather than an error.
*/
/**
* Every `window` argument says the same thing: it defaults to the
* conversation's window and is cut to the lookback limit the person set. The
* definitions stay the same for every person (the prompt-cache prefix), so
* the limit itself rides the DATA INVENTORY, not this text.
*/
const WINDOW_ARG_DESCRIPTION =
"Analysis window. Defaults to the user's scope window. Never reaches further back than the lookback limit the user set: a wider window is cut to it.";
export const COACH_TOOL_DEFS: AiToolDef[] = [
{
name: "get_metric_series",
@@ -220,7 +229,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
window: {
type: "string",
enum: WINDOW_ENUM,
description: "Analysis window. Defaults to the user's scope window.",
description: WINDOW_ARG_DESCRIPTION,
},
},
},
@@ -238,7 +247,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
type: "string",
enum: WINDOW_ENUM,
description:
"Window for the per-context means. The clinical panel is always the fixed trailing 30 days.",
"Window for the per-context means, cut to the user's lookback limit like every window. The clinical panel is always the fixed trailing 30 days, and is left out when the lookback limit is shorter.",
},
},
},
@@ -255,7 +264,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
window: {
type: "string",
enum: WINDOW_ENUM,
description: "Analysis window. Defaults to the user's scope window.",
description: WINDOW_ARG_DESCRIPTION,
},
},
},
@@ -272,7 +281,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
window: {
type: "string",
enum: WINDOW_ENUM,
description: "Analysis window. Defaults to the user's scope window.",
description: WINDOW_ARG_DESCRIPTION,
},
},
},
@@ -280,7 +289,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 } 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.",
"Fetch the user's most recent lab results — the latest reading per biomarker over the last 12 months (or the user's lookback limit, when shorter), 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, outside_reach means they are older than the user's lookback limit.",
parameters: {
type: "object",
additionalProperties: false,
@@ -317,7 +326,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
window: {
type: "string",
enum: WINDOW_ENUM,
description: "Analysis window. Defaults to the user's scope window.",
description: WINDOW_ARG_DESCRIPTION,
},
},
},
@@ -361,7 +370,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
window: {
type: "string",
enum: WINDOW_ENUM,
description: "Analysis window. Defaults to the user's scope window.",
description: WINDOW_ARG_DESCRIPTION,
},
granularity: {
type: "string",
+228 -32
View File
@@ -51,6 +51,7 @@ import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate";
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
import { readMessageResults } from "@/lib/ai/coach/persistence";
import type {
CoachResultPeriod,
CoachResultTable,
CoachScope,
CoachScopeSource,
@@ -62,6 +63,7 @@ import {
METRIC_TABLE_EXCLUDED_SOURCES,
compareWithCurrent,
readMetricTable,
resolveTableRange,
summariseTable,
} from "@/lib/ai/coach/results/metric-table-tool";
import {
@@ -118,6 +120,20 @@ import {
type CoachDomainAvailability,
} from "./availability";
import { buildIllnessScores } from "@/lib/ai/coach/illness-snapshot";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import {
UNBOUNDED_REACH,
clampWindow,
exceedsReach,
isBounded,
withinReach,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
import {
OUTSIDE_REACH_REASON,
cycleRecorded,
illnessBeyondReach,
} from "./availability";
/** A read-only structured tool result. Serialised to a `role:"tool"` turn. */
export interface CoachToolResult {
@@ -191,6 +207,19 @@ function pickSection(
return value;
}
/**
* A tool's window under the lookback limit: whatever the call asks for, it
* reads no further back than the limit. `undefined` stays undefined (the
* builder default, 30 days, is inside every limit but the 7-day one, and the
* builder clamps that itself).
*/
function limitWindow(
window: CoachScopeWindow | undefined,
reach: CoachHistoryReach,
): CoachScopeWindow | undefined {
return window === undefined ? undefined : clampWindow(window, reach);
}
/**
* Resolve the scope a tool reads its snapshot under.
*
@@ -209,8 +238,9 @@ function scopeFor(
window: CoachScopeWindow | undefined,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): CoachScope {
const effectiveWindow = window ?? fallbackWindow;
const effectiveWindow = limitWindow(window ?? fallbackWindow, reach);
if (sharedScope && sharedScope.window === effectiveWindow) {
// Same window as the inventory's full-source build → reuse its cache entry.
return sharedScope;
@@ -243,9 +273,17 @@ export async function executeCoachTool(args: {
sharedScope?: CoachScope;
/** v1.39.4 — the chat turn, when the call runs inside one. */
turn?: CoachToolTurnContext;
/**
* How far back the call may read: the person's Coach lookback limit. Every
* read below is clamped to it. Absent is no limit, which is what an MCP
* client gets: it reads with its own window arguments, and the Coach
* setting governs the Coach.
*/
reach?: CoachHistoryReach;
}): Promise<CoachToolResult> {
const { userId, name, rawArguments, fallbackWindow, sharedScope, turn } =
args;
const reach = args.reach ?? UNBOUNDED_REACH;
if (!isCoachToolName(name) && name !== SHOW_RESULT_TOOL_NAME) {
annotate({
@@ -269,7 +307,7 @@ export async function executeCoachTool(args: {
try {
const result =
name === SHOW_RESULT_TOOL_NAME
? await showResult(userId, parsedArgs, sharedScope, turn)
? await showResult(userId, parsedArgs, sharedScope, turn, reach)
: await dispatch(
name as CoachToolName,
userId,
@@ -277,6 +315,7 @@ export async function executeCoachTool(args: {
fallbackWindow,
sharedScope,
turn,
reach,
);
annotate({
action: { name: "coach.tool.executed" },
@@ -304,6 +343,7 @@ async function dispatch(
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
turn: CoachToolTurnContext | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const result = await dispatchRead(
name,
@@ -312,6 +352,7 @@ async function dispatch(
fallbackWindow,
sharedScope,
turn,
reach,
);
if (!turn || !result.present || result.table) return result;
return withProjectedTable(
@@ -321,6 +362,7 @@ async function dispatch(
fallbackWindow,
result,
turn,
reach,
);
}
@@ -331,33 +373,56 @@ async function dispatchRead(
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
turn: CoachToolTurnContext | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
// Every branch takes the lookback limit; `coach-history-reach-guard.test.ts`
// fails when one does not.
switch (name) {
case "get_metric_series":
return getMetricSeries(userId, rawArgs, fallbackWindow, sharedScope);
return getMetricSeries(
userId,
rawArgs,
fallbackWindow,
sharedScope,
reach,
);
case "get_glucose_panel":
return getGlucosePanel(userId, rawArgs, fallbackWindow, sharedScope);
return getGlucosePanel(
userId,
rawArgs,
fallbackWindow,
sharedScope,
reach,
);
case "get_sleep":
return getSleep(userId, rawArgs, fallbackWindow, sharedScope);
return getSleep(userId, rawArgs, fallbackWindow, sharedScope, reach);
case "get_medication_compliance":
return getMedicationCompliance(
userId,
rawArgs,
fallbackWindow,
sharedScope,
reach,
);
case "get_labs":
return getLabs(userId, rawArgs, fallbackWindow, sharedScope);
return getLabs(userId, rawArgs, fallbackWindow, sharedScope, reach);
case "get_illness_recovery":
return getIllnessRecovery(userId, fallbackWindow, sharedScope);
return getIllnessRecovery(userId, fallbackWindow, sharedScope, reach);
case "get_workouts":
return getWorkouts(userId, rawArgs, fallbackWindow, sharedScope);
return getWorkouts(userId, rawArgs, fallbackWindow, sharedScope, reach);
case "get_cycle":
return getCycle(userId, rawArgs, sharedScope);
return getCycle(userId, rawArgs, sharedScope, reach);
case "get_correlations":
return getCorrelations(userId, rawArgs);
return getCorrelations(userId, rawArgs, reach);
case "get_metric_table":
return getMetricTable(userId, rawArgs, fallbackWindow, sharedScope, turn);
return getMetricTable(
userId,
rawArgs,
fallbackWindow,
sharedScope,
turn,
reach,
);
}
}
@@ -379,8 +444,9 @@ function emptyRead(
domain: string,
subject: CoachAvailabilitySubject | null,
searchedWindow: CoachScopeWindow | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
return resolveEmptyRead({ userId, domain, subject, searchedWindow });
return resolveEmptyRead({ userId, domain, subject, searchedWindow, reach });
}
async function getMetricSeries(
@@ -388,6 +454,7 @@ async function getMetricSeries(
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getMetricSeriesArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_metric_series", parsed.error);
@@ -415,7 +482,8 @@ async function getMetricSeries(
const snapshot = await buildCoachSnapshot(
userId,
scopeFor([metric], window, fallbackWindow, sharedScope),
scopeFor([metric], window, fallbackWindow, sharedScope, reach),
{ reach },
);
const section = pickSection(snapshot.sections, sectionKey);
if (section === undefined) {
@@ -423,7 +491,8 @@ async function getMetricSeries(
userId,
metric,
subjectForTool("get_metric_series", metric),
window ?? fallbackWindow,
limitWindow(window ?? fallbackWindow, reach),
reach,
);
}
return {
@@ -438,12 +507,20 @@ async function getGlucosePanel(
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getGlucosePanelArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_glucose_panel", parsed.error);
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["glucose"], parsed.data.window, fallbackWindow, sharedScope),
scopeFor(
["glucose"],
parsed.data.window,
fallbackWindow,
sharedScope,
reach,
),
{ reach },
);
const section = pickSection(snapshot.sections, "glucose");
if (section === undefined) {
@@ -451,7 +528,8 @@ async function getGlucosePanel(
userId,
"glucose",
subjectForTool("get_glucose_panel"),
parsed.data.window ?? fallbackWindow,
limitWindow(parsed.data.window ?? fallbackWindow, reach),
reach,
);
}
return {
@@ -466,12 +544,14 @@ async function getSleep(
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getSleepArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_sleep", parsed.error);
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["sleep"], parsed.data.window, fallbackWindow, sharedScope),
scopeFor(["sleep"], parsed.data.window, fallbackWindow, sharedScope, reach),
{ reach },
);
const nights = pickSection(snapshot.sections, "sleep");
const rhythm = pickSection(snapshot.sections, "sleepRhythm");
@@ -480,7 +560,8 @@ async function getSleep(
userId,
"sleep",
subjectForTool("get_sleep"),
parsed.data.window ?? fallbackWindow,
limitWindow(parsed.data.window ?? fallbackWindow, reach),
reach,
);
}
return {
@@ -498,6 +579,7 @@ async function getMedicationCompliance(
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getMedicationComplianceArgsSchema.safeParse(rawArgs);
if (!parsed.success) {
@@ -505,7 +587,14 @@ async function getMedicationCompliance(
}
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["compliance"], parsed.data.window, fallbackWindow, sharedScope),
scopeFor(
["compliance"],
parsed.data.window,
fallbackWindow,
sharedScope,
reach,
),
{ reach },
);
const compliance = pickSection(snapshot.sections, "compliance");
// GLP-1 context rides the `weeklyContext` block.
@@ -517,7 +606,8 @@ async function getMedicationCompliance(
userId,
"compliance",
subjectForTool("get_medication_compliance"),
parsed.data.window ?? fallbackWindow,
limitWindow(parsed.data.window ?? fallbackWindow, reach),
reach,
);
}
return {
@@ -534,6 +624,7 @@ async function getLabs(
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getLabsArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_labs", parsed.error);
@@ -551,7 +642,8 @@ async function getLabs(
// (labs ride unconditionally) when no shared scope or a window mismatch.
const snapshot = await buildCoachSnapshot(
userId,
scopeFor([], fallbackWindow, fallbackWindow, sharedScope),
scopeFor([], fallbackWindow, fallbackWindow, sharedScope, reach),
{ reach },
);
const labs = pickSection(snapshot.sections, "labs") as
{ recent?: Array<{ name?: string; analyte?: string }> } | undefined;
@@ -559,7 +651,14 @@ async function getLabs(
// 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");
// Under a shorter lookback limit the labs read stops at the limit.
return emptyRead(
userId,
"labs",
subjectForTool("get_labs"),
clampWindow("lastYear", reach),
reach,
);
}
const analyte = parsed.data.analyte?.trim().toLowerCase();
@@ -581,6 +680,7 @@ async function getIllnessRecovery(
userId: string,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
// Validate the (empty) args shape for consistency; an empty object always
// passes.
@@ -603,7 +703,9 @@ async function getIllnessRecovery(
fallbackWindow,
fallbackWindow,
sharedScope,
reach,
),
{ reach },
);
const illness = pickSection(snapshot.sections, "illness");
const derived = pickSection(snapshot.sections, "derived");
@@ -614,7 +716,7 @@ async function getIllnessRecovery(
// relevant episode. Read-only, coverage-gated (null when the engine
// withholds), and the SAME engine the card + the red-flag notifier run, so
// the Coach restates the numbers the user sees rather than the composite.
const illnessScores = await buildIllnessScores(userId);
const illnessScores = await buildIllnessScores(userId, undefined, reach);
if (
illness === undefined &&
derived === undefined &&
@@ -624,12 +726,17 @@ async function getIllnessRecovery(
) {
// 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.
// nothing an availability probe could establish. Honest absence, unless
// a lookback limit is what hid the episodes.
if (isBounded(reach) && (await illnessBeyondReach(userId, reach))) {
return { present: false, reason: OUTSIDE_REACH_REASON };
}
return emptyRead(
userId,
"illness_recovery",
subjectForTool("get_illness_recovery"),
fallbackWindow,
limitWindow(fallbackWindow, reach),
reach,
);
}
return {
@@ -649,6 +756,7 @@ async function getWorkouts(
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getWorkoutsArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_workouts", parsed.error);
@@ -656,7 +764,14 @@ async function getWorkouts(
// user has workout rows in the window. Scope the read to that single source.
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["workouts"], parsed.data.window, fallbackWindow, sharedScope),
scopeFor(
["workouts"],
parsed.data.window,
fallbackWindow,
sharedScope,
reach,
),
{ reach },
);
const workouts = pickSection(snapshot.sections, "workouts");
if (workouts === undefined) {
@@ -664,7 +779,8 @@ async function getWorkouts(
userId,
"workouts",
subjectForTool("get_workouts"),
parsed.data.window ?? fallbackWindow,
limitWindow(parsed.data.window ?? fallbackWindow, reach),
reach,
);
}
return { present: true, data: workouts };
@@ -674,9 +790,18 @@ async function getCycle(
userId: string,
rawArgs: unknown,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getCycleArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_cycle", parsed.error);
// The cycle block predicts from every logged cycle, so the snapshot leaves
// it out under any lookback limit. Say why, rather than that it is absent.
if (isBounded(reach)) {
return (await isCycleAvailableForUser(userId)) &&
(await cycleRecorded(userId))
? { present: false, reason: OUTSIDE_REACH_REASON }
: { present: false, reason: "no_data" };
}
// The cycle block is gated INSIDE the builder by `isCycleAvailableForUser`
// (the per-user toggle AND the operator switch), independent of `sources` —
// so a minimal scope still surfaces it when the account tracks cycles, and a
@@ -688,6 +813,7 @@ async function getCycle(
const snapshot = await buildCoachSnapshot(
userId,
sharedScope ?? { sources: [] },
{ reach },
);
const cycle = pickSection(snapshot.sections, "cycle");
if (cycle === undefined) {
@@ -699,6 +825,7 @@ async function getCycle(
"cycle",
subjectForTool("get_cycle"),
sharedScope?.window,
reach,
);
}
return { present: true, data: cycle };
@@ -707,6 +834,7 @@ async function getCycle(
async function getCorrelations(
userId: string,
rawArgs: unknown,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getCorrelationsArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_correlations", parsed.error);
@@ -723,7 +851,8 @@ async function getCorrelations(
// of its own. The notes below are finished sentences; some MCP clients show
// them to the person verbatim rather than letting the model re-word them.
const locale = await resolveLocaleForUser(userId);
const result = await readCoachCorrelations(userId, locale);
// The scan reads 180 days, or the lookback limit when that is shorter.
const result = await readCoachCorrelations(userId, locale, { reach });
if (!result.present) {
return { present: false, reason: result.reason ?? "no_pattern" };
}
@@ -799,28 +928,69 @@ export function isCoachScopeSource(
return Object.hasOwn(COACH_SOURCE_MEASUREMENT_TYPES, domain);
}
/**
* True when a table's whole range lies inside the lookback limit. An earlier
* period needs twice its window (previous) or a year more (a year ago), so a
* range either fits with room to spare or does not fit at all; the UTC day
* edges are close enough.
*/
export function tableRangeWithinReach(
window: CoachScopeWindow,
period: CoachResultPeriod,
reach: CoachHistoryReach,
now: Date = new Date(),
): boolean {
if (!isBounded(reach)) return true;
if (exceedsReach(window, reach)) return false;
const range = resolveTableRange({ window, period, timeZone: "UTC", now });
return withinReach(range.from, reach, now);
}
async function getMetricTable(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
turn: CoachToolTurnContext | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getMetricTableArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_metric_table", parsed.error);
const { metric, granularity } = parsed.data;
const window = parsed.data.window ?? fallbackWindow ?? DEFAULT_WINDOW;
// All time becomes the lookback limit; a narrower window stays.
const window = clampWindow(
parsed.data.window ?? fallbackWindow ?? DEFAULT_WINDOW,
reach,
);
const period =
window === "allTime" ? "current" : (parsed.data.period ?? "current");
const excluded = METRIC_TABLE_EXCLUDED_SOURCES[metric];
if (excluded) return { present: false, reason: excluded };
// An earlier period is read only when all of it lies inside the limit: the
// previous window or the same window a year ago, cut short, would be a
// different comparison under the same name.
if (
period !== "current" &&
!tableRangeWithinReach(window, period, reach, turn?.now)
) {
return {
present: false,
reason: OUTSIDE_REACH_REASON,
searchedWindow: window,
};
}
// Same gate as every other read: a module switched off, or a metric the
// person excluded from the Coach, is not read here either.
const gate = await buildCoachSnapshot(
userId,
sharedScope ?? { sources: [metric], window: fallbackWindow },
sharedScope ?? {
sources: [metric],
window: limitWindow(fallbackWindow, reach),
},
{ reach },
);
if (!scopeAdmits(gate.sections, metric)) {
return {
@@ -849,6 +1019,7 @@ async function getMetricTable(
metric,
subjectForTool("get_metric_table", metric),
window,
reach,
);
}
// An earlier window is read to be compared: read the current one too and
@@ -940,6 +1111,7 @@ async function showResult(
rawArgs: unknown,
sharedScope: CoachScope | undefined,
turn: CoachToolTurnContext | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = showResultArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs(SHOW_RESULT_TOOL_NAME, parsed.error);
@@ -954,6 +1126,22 @@ async function showResult(
return { present: false, reason: "unknown_result" };
}
// A stored table whose range reaches past the lookback limit set since is
// not shown again: the limit covers what the Coach shows as much as what
// it reads.
const { window: storedWindow, period: storedPeriod } = target.meta.source;
if (
storedWindow !== undefined &&
!tableRangeWithinReach(
storedWindow,
storedPeriod ?? "current",
reach,
turn.now,
)
) {
return { present: false, reason: OUTSIDE_REACH_REASON };
}
// The same gate a fresh read passes, before anything is decrypted: a
// metric the person has since excluded from the Coach, or left out of
// this conversation's scope, is not sent to the model again either.
@@ -961,7 +1149,11 @@ async function showResult(
if (isCoachScopeSource(domain)) {
const gate = await buildCoachSnapshot(
userId,
sharedScope ?? { sources: [domain], window: target.meta.source.window },
sharedScope ?? {
sources: [domain],
window: limitWindow(target.meta.source.window, reach),
},
{ reach },
);
if (!scopeAdmits(gate.sections, domain)) {
return { present: false, reason: "unavailable_in_scope" };
@@ -1025,6 +1217,7 @@ async function withProjectedTable(
fallbackWindow: CoachScopeWindow | undefined,
result: CoachToolResult,
turn: CoachToolTurnContext,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const argWindow =
rawArgs !== null &&
@@ -1032,7 +1225,10 @@ async function withProjectedTable(
typeof (rawArgs as { window?: unknown }).window === "string"
? ((rawArgs as { window: CoachScopeWindow }).window as CoachScopeWindow)
: undefined;
const window = argWindow ?? fallbackWindow ?? DEFAULT_WINDOW;
const window = clampWindow(
argWindow ?? fallbackWindow ?? DEFAULT_WINDOW,
reach,
);
try {
let table: CoachResultTable | null = null;
switch (name) {
+46 -8
View File
@@ -31,13 +31,20 @@ import {
FULL_INVENTORY_SOURCE_SET,
} from "./source-keys";
import {
OUTSIDE_REACH_REASON,
classifyAvailability,
probeBeyondReach,
probeCoachAvailability,
subjectForTool,
type CoachAvailabilitySubject,
type CoachDomainAvailability,
} from "./availability";
import { isCoachToolName } from "./definitions";
import {
UNBOUNDED_REACH,
clampWindow,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
/**
* What the record holds for a domain the WINDOW read found nothing for. Absent
@@ -45,11 +52,14 @@ import { isCoachToolName } from "./definitions";
* that this window could not reach. Model-facing only — no i18n.
*/
export interface InventoryAvailability {
/** `outside_window` or `unavailable_in_scope` — see `availability.ts`. */
/**
* `outside_window`, `unavailable_in_scope` or `outside_reach` — see
* `availability.ts`. An `outside_reach` row carries no figures at all.
*/
state: string;
count: number;
firstDate: string;
lastDate: string;
count?: number;
firstDate?: string;
lastDate?: string;
/** A window the model may re-call with to reach the rows, or null. */
reachableWithWindow: string | null;
}
@@ -82,6 +92,8 @@ export interface CoachDataInventory {
cycleEnabled: boolean;
/** The window the inventory was built against. */
window: string;
/** The person's lookback limit, or undefined when there is none. */
lookbackLimit?: string;
/**
* v1.21.0 (D5-1) — the exact full-source scope this inventory's snapshot was
* built against. The route threads it to the tool loop so every per-tool read
@@ -114,6 +126,8 @@ const METRIC_SERIES_DOMAINS: Array<{
export async function buildCoachDataInventory(
userId: string,
scope: CoachScope | undefined,
/** The person's lookback limit; the probe and every row answer to it. */
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<CoachDataInventory> {
// v1.21.0 (C2-2) — probe presence against the FULL source set, not the
// user's narration-cluster default. `effectiveScope` only carried the
@@ -124,10 +138,13 @@ export async function buildCoachDataInventory(
// figure read — it only widens what the inventory advertises.
const probeScope: CoachScope = {
sources: [...FULL_INVENTORY_SOURCE_SET],
window: scope?.window,
window:
scope?.window === undefined
? undefined
: clampWindow(scope.window, reach),
};
const [snapshot, cycleEnabled] = await Promise.all([
buildCoachSnapshot(userId, probeScope),
buildCoachSnapshot(userId, probeScope, { reach }),
isCycleAvailableForUser(userId),
]);
const sections = snapshot.sections;
@@ -223,7 +240,7 @@ export async function buildCoachDataInventory(
// 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);
await attachAvailability(userId, entries, probeScope.window, reach);
// restMode rides the illness section.
const illness = sections.illness as { restMode?: boolean } | undefined;
@@ -236,6 +253,7 @@ export async function buildCoachDataInventory(
restMode,
cycleEnabled,
window: scopeBlock?.window ?? scope?.window ?? "last30days",
...(reach.days !== null ? { lookbackLimit: reach.window } : {}),
probeScope,
};
}
@@ -256,6 +274,7 @@ async function attachAvailability(
userId: string,
entries: InventoryEntry[],
window: CoachScopeWindow | undefined,
reach: CoachHistoryReach,
): Promise<void> {
const subjects = new Map<string, CoachAvailabilitySubject>();
const keyed = new Map<string, InventoryEntry>();
@@ -273,8 +292,13 @@ async function attachAvailability(
}
if (subjects.size === 0) return;
let probed: Map<string, CoachDomainAvailability>;
let beyond: Set<string>;
try {
probed = await probeCoachAvailability(userId, subjects);
probed = await probeCoachAvailability(userId, subjects, { reach });
// Under a limit, a domain with nothing inside it may still hold rows
// older than it. Those are named as beyond the limit, with no figures.
const unprobed = new Map([...subjects].filter(([key]) => !probed.has(key)));
beyond = await probeBeyondReach(userId, unprobed, reach);
} catch (err) {
// The manifest degrades to the pre-fix precision (every unreached domain
// reads "absent"), which is why this is a catch and not a throw — but a
@@ -290,6 +314,14 @@ async function attachAvailability(
action: { name: "coach.inventory.availability" },
meta: { probed: subjects.size, stored: probed.size },
});
for (const key of beyond) {
const entry = keyed.get(key);
if (!entry) continue;
entry.availability = {
state: OUTSIDE_REACH_REASON,
reachableWithWindow: null,
};
}
for (const [key, available] of probed) {
const entry = keyed.get(key);
if (!entry) continue;
@@ -351,6 +383,11 @@ export function renderDataInventory(inventory: CoachDataInventory): string {
'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.lookbackLimit) {
lines.push(
`Lookback limit: ${inventory.lookbackLimit}. The user set how far back you may read; nothing older reaches you, whatever window a tool is called with. "BEYOND LOOKBACK" marks a domain whose readings are all older than that: do not call its tool, say older readings lie beyond the lookback set in the Coach settings, and name no figures.`,
);
}
if (inventory.restMode) {
lines.push(
"The user is currently in REST MODE (recovering from illness) — frame any activity guidance gently.",
@@ -376,6 +413,7 @@ function renderState(entry: InventoryEntry): string {
if (entry.present) return "present";
const a = entry.availability;
if (!a) return "absent";
if (a.state === OUTSIDE_REACH_REASON) return "BEYOND LOOKBACK";
// Terse by design: the preamble states once what these two states mean and
// that neither is absence. Repeating that per row would double a manifest
// that rides every single turn.
+8
View File
@@ -23,6 +23,7 @@
* single completion — the caller falls back to the snapshot path BEFORE
* entering this loop, so this is the safety floor, not the primary route.
*/
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
import { annotate } from "@/lib/logging/context";
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
@@ -124,6 +125,11 @@ export async function runCoachToolLoop(args: {
* under one cache key — one snapshot build per turn instead of N.
*/
sharedScope?: CoachScope;
/**
* The person's Coach lookback limit. Required: every tool call of a chat
* turn is clamped to it, and a caller that forgot it would read past it.
*/
reach: CoachHistoryReach;
ledger?: ProviderHealthLedger;
/** Aborts the per-round provider calls on client disconnect. */
signal?: AbortSignal;
@@ -167,6 +173,7 @@ export async function runCoachToolLoop(args: {
maxTokens,
fallbackWindow,
sharedScope,
reach,
ledger,
signal,
timeoutMs,
@@ -261,6 +268,7 @@ export async function runCoachToolLoop(args: {
rawArguments: call.arguments,
fallbackWindow,
sharedScope,
reach,
...(turn ? { turn } : {}),
});
// v1.39.4 — the settled callback gets the table; the model never
+5 -1
View File
@@ -24,7 +24,9 @@
* 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.
* and exactly the wrong one for data it does. `outside_reach` is data
* the record holds beyond the lookback limit the person set: the model is told
* it exists, gets no figure from it, and points at the setting.
*/
import type { Locale } from "@/lib/i18n/config";
@@ -38,6 +40,7 @@ You have read-only retrieval tools and a DATA INVENTORY listing what the user ha
- "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.
- "outside_reach": the readings exist and all of them are older than the lookback limit the user set for you. You are not given any figure from them, so name none: no count, no date, no value. Say that older readings lie beyond the lookback limit and that the user can widen it in the Coach settings. Never say the data is missing, and do not call the tool again with a wider window — the limit applies to every window.
- "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.
@@ -54,6 +57,7 @@ Du hast schreibgeschützte Abfrage-Tools und ein DATA INVENTORY, das auflistet,
- „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.
- „outside_reach": die Messwerte existieren und sind alle älter als der Rückblick, den der Nutzer für dich eingestellt hat. Du bekommst keine Zahl daraus, also nenne keine: keine Anzahl, kein Datum, keinen Wert. Sage, dass ältere Werte jenseits des eingestellten Rückblicks liegen und der Nutzer ihn in den Coach-Einstellungen erweitern kann. Sage nie, die Daten fehlten, und rufe das Tool nicht mit einem weiteren Fenster erneut auf — die Grenze gilt für jedes Fenster.
- „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.
+17 -1
View File
@@ -21,6 +21,13 @@
* raw rows via the baseline reader.
*/
import {
UNBOUNDED_REACH,
capDays,
isBounded,
type CoachHistoryReach,
} from "./history-reach";
import {
TRAJECTORY_DEFAULT_WINDOW_DAYS,
computeTrajectory,
isDerivedOk,
TRAJECTORY_TYPES,
@@ -88,7 +95,12 @@ export async function buildTrajectorySnapshotBlock(
userId: string,
profile: BaselineProfile,
now: Date,
/** The Coach's lookback limit: the fit reads no further back than it. */
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<Record<string, TrajectorySnapshotEntry> | null> {
const windowOpts = isBounded(reach)
? { windowDays: capDays(TRAJECTORY_DEFAULT_WINDOW_DAYS, reach) }
: {};
const block: Record<string, TrajectorySnapshotEntry> = {};
for (const type of TRAJECTORY_TYPES) {
@@ -96,7 +108,11 @@ export async function buildTrajectorySnapshotBlock(
// must never sink the whole Coach turn — drop it and carry on.
let derived;
try {
derived = await computeTrajectory(userId, profile, { type, now });
derived = await computeTrajectory(userId, profile, {
type,
now,
...windowOpts,
});
} catch {
continue;
}
+25 -10
View File
@@ -37,11 +37,21 @@ import {
parseCoachPrefs,
type CoachPrefs,
} from "@/lib/validations/coach-prefs";
import {
clampWindow,
reachFromPrefs,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
import type { TurnConversation } from "./types";
export interface TurnContext {
coachPrefs: CoachPrefs;
/**
* How far back this turn may read: the saved `defaultWindow` as a limit.
* Every snapshot build and tool call of the turn honours it.
*/
reach: CoachHistoryReach;
/**
* v1.22 (#89) — the upstream timeout for THIS turn's provider call. On the
* streaming (local) path it is the per-idle-gap ceiling; on the buffered
@@ -76,10 +86,8 @@ export async function assembleTurnContext(args: {
// separately so excluded metrics never even leave the DB.
//
// v1.4.25 W5 — `coachPrefs.defaultWindow` is the user's saved
// analysis-window preference. Merge it into the snapshot scope when
// the client didn't supply a per-conversation override; the override
// (header pill / sources rail) always wins. Keep the merge cheap so
// we don't accidentally widen narrow per-call scopes.
// analysis-window preference, merged into the snapshot scope when the
// client didn't supply a per-conversation window.
const prefsRow = await prisma.user.findUnique({
where: { id: userId },
select: {
@@ -98,11 +106,17 @@ export async function assembleTurnContext(args: {
prefsRow?.aiResponseTimeoutSeconds != null
? prefsRow.aiResponseTimeoutSeconds * 1000
: 180_000;
const effectiveScope =
scope?.window === undefined && coachPrefs.defaultWindow
? { ...(scope ?? {}), window: coachPrefs.defaultWindow }
: scope;
const snapshot = await buildCoachSnapshot(userId, effectiveScope);
// The saved window is a limit as well as the default: a
// client-sent window (header pill, deep link) may narrow it, never widen it.
const reach = reachFromPrefs(coachPrefs);
const requestedWindow = scope?.window ?? coachPrefs.defaultWindow;
const effectiveScope: CoachScope = {
...(scope ?? {}),
...(requestedWindow !== undefined
? { window: clampWindow(requestedWindow, reach) }
: {}),
};
const snapshot = await buildCoachSnapshot(userId, effectiveScope, { reach });
// v1.15.20 — the user-authored "about me" self-description (Settings →
// AI) rides the system prompt as a delimited, user-provided context
// block. Fail-open: a missing / undecryptable text yields null and the
@@ -183,7 +197,7 @@ export async function assembleTurnContext(args: {
!workoutId || (await isModuleEnabled(userId, "workouts"));
const workoutEvidence =
isFirstTurn && workoutId && workoutsEnabled
? await buildWorkoutEvidenceSection(userId, workoutId)
? await buildWorkoutEvidenceSection(userId, workoutId, reach)
: null;
if (workoutId) {
annotate({
@@ -210,6 +224,7 @@ export async function assembleTurnContext(args: {
return {
coachPrefs,
reach,
aiResponseTimeoutMs,
effectiveScope,
snapshot,
+7 -1
View File
@@ -217,7 +217,11 @@ export async function runTurnModel(args: {
// model pulls only what it needs via the retrieval tools. The inventory
// build reuses the snapshot we already computed (60s LRU), so the tools
// that fire this turn share its reads.
const inventory = await buildCoachDataInventory(userId, effectiveScope);
const inventory = await buildCoachDataInventory(
userId,
effectiveScope,
ctx.reach,
);
// Earlier tables whose metric the person has since excluded are
// neither named for the model nor reachable through show_result. The
// scope is the one every tool of the turn reads under.
@@ -266,6 +270,8 @@ export async function runTurnModel(args: {
// probe scope is the exact scope the inventory was built against, so the
// per-tool reads land its 60s LRU entry.
sharedScope: inventory.probeScope,
// The lookback limit: every tool call is clamped to it.
reach: ctx.reach,
// v1.20.1 — thread the abort signal so a mid-generation disconnect tears
// down the per-round provider calls instead of paying the full cost.
signal,
+14
View File
@@ -34,6 +34,8 @@ import { buildMethod } from "@/lib/ai/coach/method";
import { deriveFollowUps } from "@/lib/ai/coach/follow-ups/derive";
import type { ResolvedFollowUp } from "@/lib/ai/coach/follow-ups/resolve";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
import { readCoachReach } from "@/lib/ai/coach/history-reach-read";
import { tableRangeWithinReach } from "@/lib/ai/coach/tools/executor";
import { createSseStream } from "@/lib/sse/create-stream";
import { persistUserTurn } from "./conversation";
@@ -60,6 +62,18 @@ async function copyStoredTable(args: {
);
const entry = entries?.find((candidate) => candidate.ref === ref);
if (!entry || "withheld" in entry) return null;
// A table reaching past the lookback limit set since it was made is not
// shown again; the model turn that runs instead says why.
const reach = await readCoachReach(userId);
if (
!tableRangeWithinReach(
entry.source.window,
entry.source.period ?? "current",
reach,
)
) {
return null;
}
if (followUp.kind === "as_chart" && entry.chart === null) return null;
// Only the view changes. "As a table" keeps the chart beside the table, so
// the reply can offer the chart again.
+3
View File
@@ -114,6 +114,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" },
module_disabled: { status: "empty", reason: "module_disabled" },
retrieval_failed: { status: "failed", reason: "retrieval_failed" },
no_data_unconfirmed: { status: "failed", reason: "retrieval_failed" },
+12 -1
View File
@@ -27,6 +27,11 @@
* model). `userId` is narrowed from the authenticated session by the caller and
* feeds the Prisma `where` field-by-field; it is never an input.
*/
import {
UNBOUNDED_REACH,
reachFloor,
type CoachHistoryReach,
} from "./history-reach";
import { prisma } from "@/lib/db";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
@@ -123,7 +128,13 @@ function toEntry(row: EncounterRow): CoachVisitEntry {
export async function buildVisitsSnapshotBlock(
userId: string,
now: Date = new Date(),
/**
* The Coach's lookback limit: upcoming appointments are not history and
* stay; the last visit counts only when it lies inside the limit.
*/
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<CoachVisitsBlock | null> {
const floor = reachFloor(reach, now);
const horizon = new Date(now.getTime() + UPCOMING_HORIZON_DAYS * 86_400_000);
const practitionerInclude = {
practitioner: { select: { name: true, specialty: true } },
@@ -149,7 +160,7 @@ export async function buildVisitsSnapshotBlock(
userId,
deletedAt: null,
status: "DONE",
occurredAt: { lte: now },
occurredAt: floor ? { lte: now, gte: floor } : { lte: now },
},
orderBy: { occurredAt: "desc" },
include: practitionerInclude,
+15 -1
View File
@@ -1,4 +1,9 @@
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import {
UNBOUNDED_REACH,
withinReach,
type CoachHistoryReach,
} from "./history-reach";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
@@ -66,6 +71,7 @@ interface WorkoutEvidenceDependencies {
sportType: string,
sourcePriorityJson: unknown,
excludeWorkoutId?: string,
reach?: CoachHistoryReach,
) => Promise<WorkoutSportContext | null>;
buildEvidence: (input: WorkoutEvidenceInput) => Record<string, unknown>;
onSkipped: () => void;
@@ -76,11 +82,18 @@ export function createWorkoutEvidenceBuilder(
): (
userId: string,
workoutId: string,
/**
* The Coach's lookback limit. A workout older than it is not read, and the
* sport comparison (its own 180 days) is left out under a shorter limit
* (`buildSportContext`).
*/
reach?: CoachHistoryReach,
) => Promise<Record<string, unknown> | null> {
return async (userId, workoutId) => {
return async (userId, workoutId, reach = UNBOUNDED_REACH) => {
try {
const row = await dependencies.findWorkout(userId, workoutId);
if (!row) return null;
if (!withinReach(row.startedAt, reach)) return null;
const profile = await dependencies.findProfile(userId);
const hrSeries = await dependencies.buildHrSeries({
@@ -103,6 +116,7 @@ export function createWorkoutEvidenceBuilder(
row.sportType,
profile?.sourcePriorityJson ?? null,
workoutId,
reach,
);
return dependencies.buildEvidence({
@@ -49,6 +49,12 @@ const ADHERENCE_WINDOW_DAYS = 14;
/** Window for the target-vital before/after read (split in half). */
const VITAL_WINDOW_DAYS = 28;
/** The furthest back the storyline reads: the longer of its two windows. */
export const ADHERENCE_STORYLINE_HORIZON_DAYS = Math.max(
ADHERENCE_WINDOW_DAYS,
VITAL_WINDOW_DAYS,
);
/** Adherence must clear this many days of scheduled doses to be trusted. */
const MIN_ADHERENCE_DAYS = 7;
/** Each side of the vital before/after read needs this many days. */
+1 -1
View File
@@ -177,7 +177,7 @@ const CHANGEPOINT_METRICS: readonly MeasurementType[] = [
];
/** Window the reader looks back over (days). */
const CHANGEPOINT_WINDOW_DAYS = 90;
export const CHANGEPOINT_WINDOW_DAYS = 90;
/** At most this many changepoints in one snapshot (strongest first). */
const MAX_CHANGEPOINTS = 2;
+4 -1
View File
@@ -43,5 +43,8 @@ export { computeCoincidentDeviation } from "./coincident-deviation";
export type { WellnessScoreValue } from "./wellness-scores";
export { computeTrajectory } from "./trajectory";
export {
computeTrajectory,
TRAJECTORY_DEFAULT_WINDOW_DAYS,
} from "./trajectory";
export type { TrajectoryValue } from "./trajectory";
+1 -1
View File
@@ -23,7 +23,7 @@ import {
} from "@/lib/insights/derived/recovery-resolve";
/** How far back the divergence read looks for a comparable two-source night. */
const SIGNAL_TRUST_WINDOW_DAYS = 10;
export const SIGNAL_TRUST_WINDOW_DAYS = 10;
/** The snapshot block shape — small, descriptive, never a number the model recites. */
export interface SignalTrustBlock {
+2
View File
@@ -56,6 +56,8 @@ import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
/** Trailing window the fit reads (days). Matches the baseline engine. */
const DEFAULT_WINDOW_DAYS = 30;
/** The window `computeTrajectory` reads when called without one. */
export const TRAJECTORY_DEFAULT_WINDOW_DAYS = DEFAULT_WINDOW_DAYS;
/** Default short projection horizon (days). Deliberately conservative. */
const DEFAULT_HORIZON_DAYS = 14;
/** Hard ceiling on the horizon — the literature warns off long horizons. */
+9
View File
@@ -59,9 +59,16 @@ interface AllTimeExtremes {
export async function readAllTimeExtremes(
userId: string,
types: readonly MeasurementType[],
/**
* The Coach's lookback floor. Given, "all time" means all the history the
* Coach may read, and nothing older enters the figures.
*/
floor: Date | null = null,
): Promise<Map<MeasurementType, AllTimeExtremes>> {
const out = new Map<MeasurementType, AllTimeExtremes>();
if (types.length === 0) return out;
const since =
floor === null ? Prisma.empty : Prisma.sql`AND m."measured_at" >= ${floor}`;
const priorityJson = await loadUserSourcePriority(userId);
const rank = Prisma.raw(
buildSourceRankCase(priorityJson, 'p."type"', 'p."source"'),
@@ -91,6 +98,7 @@ export async function readAllTimeExtremes(
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."type" IN (${typeList})
${since}
AND ${Prisma.raw(isHourlyMeanTypeSql('m."type"'))}
),
weighted AS (
@@ -116,6 +124,7 @@ export async function readAllTimeExtremes(
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."type" IN (${typeList})
${since}
GROUP BY m."type", m."source", 3
),
canon AS (
+59 -19
View File
@@ -144,10 +144,14 @@ export interface MoodDimensionFeature {
*/
async function buildMoodDimensionFeatures(
userId: string,
floor: Date | null,
): Promise<MoodDimensionFeature[] | undefined> {
const tz = await resolveUserTimezone(userId);
const { t } = getServerTranslator("en");
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const since = laterDate(
new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
floor,
);
const rows = await prisma.moodEntry.findMany({
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "desc" },
@@ -224,7 +228,15 @@ async function buildMoodDimensionFeatures(
* Bounded: the same 90-day window the dimensions use, and the comparison's own
* cap on how many rows survive.
*/
async function buildMoodContextBlock(userId: string): Promise<{
/** The later of `date` and an optional floor. */
function laterDate(date: Date, floor: Date | null): Date {
return floor !== null && floor.getTime() > date.getTime() ? floor : date;
}
async function buildMoodContextBlock(
userId: string,
floor: Date | null,
): Promise<{
context?: {
note: string;
comparisons: Array<{
@@ -237,7 +249,10 @@ async function buildMoodContextBlock(userId: string): Promise<{
}>;
};
}> {
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const since = laterDate(
new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
floor,
);
const rows = await prisma.moodEntry.findMany({
where: {
userId,
@@ -315,11 +330,14 @@ async function buildMoodContextBlock(userId: string): Promise<{
* block-level `asOf`. Absent entirely when nobody has answered a dimension,
* so an account that only ever taps a face carries neither key.
*/
async function buildMoodDimensionBlock(userId: string): Promise<{
async function buildMoodDimensionBlock(
userId: string,
floor: Date | null,
): Promise<{
dimensions?: MoodDimensionFeature[];
dimensionsAsOfNote?: string;
}> {
const dimensions = await buildMoodDimensionFeatures(userId);
const dimensions = await buildMoodDimensionFeatures(userId, floor);
if (!dimensions) return {};
return {
dimensions,
@@ -971,8 +989,18 @@ const FEATURE_MEASUREMENT_ROW_CAP = 6000;
export async function extractFeatures(
userId: string,
includeRaw: boolean,
options: { sinceDays?: number } = {},
options: {
sinceDays?: number;
/**
* The Coach's lookback floor. Given, nothing older than it is read: the
* `allTime*` figures cover the history from the floor on, the mood
* history and its sub-blocks start at it, and the mood forecast, a model
* fitted on the whole history, is left out.
*/
historyFloor?: Date;
} = {},
): Promise<AggregatedFeatures | RawFeatures> {
const historyFloor = options.historyFloor ?? null;
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
@@ -1037,12 +1065,11 @@ export async function extractFeatures(
// materialisation) so the labels stay honest. Unbounded reads (sinceCutoff
// null) already see the whole history and skip the extra query.
const allTimeExtremes = sinceCutoff
? await readAllTimeExtremes(userId, [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
])
? await readAllTimeExtremes(
userId,
["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
historyFloor,
)
: null;
const byType = (type: string) => measurements.filter((m) => m.type === type);
@@ -1407,7 +1434,10 @@ export async function extractFeatures(
void ensureUserMoodRollupsFresh(userId);
// Five-year window mirrors the mood-rollup writer default; covers
// every realistic user history span without an unbounded scan.
const moodSince = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000);
const moodSince = laterDate(
new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000),
historyFloor,
);
const moodRollupDayRows = await readMoodDayRollups(userId, moodSince);
type MoodDayPoint = {
@@ -1429,7 +1459,11 @@ export async function extractFeatures(
// Latest score = newest individual entry (one bounded row).
const latestEntry = await prisma.moodEntry.findFirst({
// v1.7.0 sync — exclude tombstoned rows.
where: { userId, deletedAt: null },
where: {
userId,
deletedAt: null,
...(historyFloor ? { moodLoggedAt: { gte: historyFloor } } : {}),
},
orderBy: { moodLoggedAt: "desc" },
select: { score: true },
});
@@ -1437,7 +1471,10 @@ export async function extractFeatures(
} else {
// Coverage-fallback. Bounded 1-year walk (instead of the legacy
// unbounded `findMany`) so even a cache miss is capped.
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const oneYearAgo = laterDate(
new Date(Date.now() - 365 * 24 * 60 * 60 * 1000),
historyFloor,
);
const moodEntriesRaw = await prisma.moodEntry.findMany({
// v1.7.0 sync — exclude tombstoned rows.
where: { userId, deletedAt: null, moodLoggedAt: { gte: oneYearAgo } },
@@ -1523,9 +1560,9 @@ export async function extractFeatures(
latest: moodLatestScore,
trend30,
totalEntries: moodTotalEntries,
...(await buildMoodDimensionBlock(userId)),
...(await buildMoodContextBlock(userId)),
...(await buildMoodPrognosisBlock(userId)),
...(await buildMoodDimensionBlock(userId, historyFloor)),
...(await buildMoodContextBlock(userId, historyFloor)),
...(historyFloor ? {} : await buildMoodPrognosisBlock(userId)),
coverage: {
count: moodTotalEntries,
spanDays,
@@ -1705,7 +1742,10 @@ export async function extractFeatures(
// findMany loop. Same shape as the v1.3.0 fix to /api/insights/comprehensive
// (the previous N+1 the v3 audit closed). 90 days is the longest window
// calculateCompliance uses below, so we don't need the full intake history.
const ninetyDaysAgo = new Date(Date.now() - 90 * 86_400_000);
const ninetyDaysAgo = laterDate(
new Date(Date.now() - 90 * 86_400_000),
historyFloor,
);
const allEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId,
+5
View File
@@ -131,6 +131,11 @@ export interface McpToolDefinition {
* server-authoritative snapshot builder scoped to the requested domain, and
* returns the grounded `{ present, reason?, data?, grounding? }` result. It
* never throws and never widens scope.
*
* No `reach` is passed, here or at the other MCP call sites: the person's
* Coach lookback limit (`coachPrefs.defaultWindow`) governs what the Coach
* reads, while an MCP client reads with its own window arguments under its
* own token grant, as it did before the limit existed.
*/
async function runCoachTool(
ctx: McpAuthContext,
+16 -18
View File
@@ -8,11 +8,9 @@
* `excludeMetrics` BEFORE landing in the system prompt so the model
* never sees data the user opted out of.
*
* v1.4.25 W5 — extended with `defaultWindow`. The picker lives in the
* settings sheet; the drawer header carries a per-conversation override
* pill so a single chat can narrow the window without flipping the
* global default. The chat route folds the saved preference into the
* snapshot scope when the client didn't supply a window override.
* v1.4.25 W5 — extended with `defaultWindow`, since then made the
* Coach's lookback limit: a per-conversation window can narrow it, never
* widen it (`src/lib/ai/coach/history-reach.ts`).
*/
import { z } from "zod/v4";
@@ -67,20 +65,20 @@ export const coachExcludeMetricEnum = z.enum([
export type CoachExcludeMetric = z.infer<typeof coachExcludeMetricEnum>;
/**
* v1.4.25 W5 — default analysis window the Coach uses when the client
* doesn't supply a per-conversation override. Mirrors
* `CoachScopeWindow` (`src/lib/ai/coach/types.ts`) so the chat route
* can fold the preference into `scope.window` without a translation
* layer. The default stays `allTime` to preserve the v1.4.24 behaviour
* — every legacy row reads as "no opinion" until the user explicitly
* picks a tighter default.
* How far back the Coach may look. This is a LIMIT, enforced
* by every Coach read (`src/lib/ai/coach/history-reach.ts`): the snapshot,
* every retrieval tool, the availability probe and the fixed-window blocks.
* Before, it only seeded the snapshot window and each tool's default, and a
* tool could read further back on its own. Values mirror `CoachScopeWindow`
* (`src/lib/ai/coach/types.ts`) so the chat route folds it into the scope
* without a translation layer. `lastYear` came with the limit. The default
* stays `allTime`, which is no limit, so a legacy row keeps its behaviour.
*/
export const coachDefaultWindowEnum = z.enum([
"last7days",
"last30days",
"last90days",
"allTime",
]);
export const coachDefaultWindowEnum = z
.enum(["last7days", "last30days", "last90days", "lastYear", "allTime"])
.describe(
"How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit.",
);
export type CoachDefaultWindow = z.infer<typeof coachDefaultWindowEnum>;
/**
+11
View File
@@ -12,6 +12,11 @@
* block reads the SAME figures the detail page shows. Two surfaces quoting
* different averages for one sport is the drift this move removes.
*/
import {
UNBOUNDED_REACH,
fitsReach,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
import { prisma } from "@/lib/db";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
@@ -32,7 +37,13 @@ export async function buildSportContext(
sportType: string,
sourcePriorityJson: unknown,
excludeWorkoutId?: string,
/**
* The Coach's lookback limit. The comparison is a fixed 180 days; under a
* shorter limit it is left out rather than drawn from fewer sessions.
*/
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<WorkoutSportContext | null> {
if (!fitsReach(SPORT_CONTEXT_LOOKBACK_DAYS, reach)) return null;
const since = new Date(
Date.now() - SPORT_CONTEXT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
);
@@ -0,0 +1,251 @@
/**
* The Coach lookback limit against real Postgres.
*
* The guard in `coach-history-reach-guard.test.ts` mocks every reader and
* proves the executor hands the limit on. This file proves the readers obey
* it: real builders, real SQL, an account with blood pressure from 200 days
* ago and from the last fortnight, weight and a lab panel only from 200 days
* ago, and a limit of 90 days saved in the account's Coach preferences.
*
* The old readings carry values that never occur in the recent ones (a
* systolic of 187, a weight of 93.7 kg, an LDL of 211), so any leak shows up
* as that number in what the Coach is given. The same calls with no limit
* ("All", the default) still return them, which pins that the default kept
* its behaviour.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
import {
buildCoachDataInventory,
renderDataInventory,
} from "@/lib/ai/coach/tools/inventory";
import { __resetCoachSnapshotCacheForTests } from "@/lib/ai/coach/snapshot";
import { readCoachReach } from "@/lib/ai/coach/history-reach-read";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
import { assembleTurnContext } from "@/lib/ai/coach/turn/context";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(() => null),
}));
const DAY = 86_400_000;
const OLD_SYS = 187;
const OLD_WEIGHT = 93.7;
const OLD_LDL = 211;
let seq = 0;
async function seedAccount(defaultWindow: string | null) {
seq += 1;
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: `history-reach-${seq}`,
email: `history-reach-${seq}@example.test`,
role: "USER",
timezone: "UTC",
createdAt: new Date(Date.now() - 400 * DAY),
...(defaultWindow ? { coachPrefsJson: { defaultWindow } } : {}),
},
});
const now = Date.now();
const bp = (daysAgo: number, sys: number, dia: number) => [
{
userId: user.id,
type: "BLOOD_PRESSURE_SYS" as const,
value: sys,
unit: "mmHg",
measuredAt: new Date(now - daysAgo * DAY),
},
{
userId: user.id,
type: "BLOOD_PRESSURE_DIA" as const,
value: dia,
unit: "mmHg",
measuredAt: new Date(now - daysAgo * DAY),
},
];
await prisma.measurement.createMany({
data: [
...bp(200, OLD_SYS, 109),
...bp(201, OLD_SYS, 109),
...bp(202, OLD_SYS, 109),
...bp(10, 121, 79),
...bp(11, 122, 78),
...bp(12, 123, 77),
...bp(13, 124, 76),
...[200, 201, 202].map((daysAgo) => ({
userId: user.id,
type: "WEIGHT" as const,
value: OLD_WEIGHT,
unit: "kg",
measuredAt: new Date(now - daysAgo * DAY),
})),
],
});
await prisma.labResult.create({
data: {
userId: user.id,
analyte: "LDL",
value: OLD_LDL,
unit: "mg/dL",
takenAt: new Date(now - 200 * DAY),
},
});
return user;
}
function call(
userId: string,
name: string,
args: Record<string, unknown>,
reach: Awaited<ReturnType<typeof readCoachReach>>,
) {
return executeCoachTool({
userId,
name,
rawArguments: JSON.stringify(args),
fallbackWindow: "allTime",
reach,
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
__resetCoachSnapshotCacheForTests();
});
afterEach(() => {
__resetCoachSnapshotCacheForTests();
});
describe("a 90-day lookback limit", () => {
it("reads the saved limit from the account's Coach preferences", async () => {
const user = await seedAccount("last90days");
expect(await readCoachReach(user.id)).toEqual({
window: "last90days",
days: 90,
});
});
it("serves only the recent readings when the model asks for all time", async () => {
const user = await seedAccount("last90days");
const reach = await readCoachReach(user.id);
const result = await call(
user.id,
"get_metric_series",
{ metric: "bp", window: "allTime" },
reach,
);
expect(result.present).toBe(true);
const payload = JSON.stringify(result.data);
expect(payload).toContain("121");
expect(payload).not.toContain(String(OLD_SYS));
});
it("names a history entirely beyond the limit without any figure from it", async () => {
const user = await seedAccount("last90days");
const reach = await readCoachReach(user.id);
const result = await call(
user.id,
"get_metric_series",
{ metric: "weight", window: "allTime" },
reach,
);
expect(result).toMatchObject({ present: false, reason: "outside_reach" });
expect(result.available).toBeUndefined();
expect(JSON.stringify(result)).not.toContain(String(OLD_WEIGHT));
});
it("refuses a year-ago table and an earlier lab panel", async () => {
const user = await seedAccount("last90days");
const reach = await readCoachReach(user.id);
const table = await call(
user.id,
"get_metric_table",
{ metric: "bp", window: "last30days", period: "yearAgo" },
reach,
);
expect(table).toMatchObject({ present: false, reason: "outside_reach" });
const labs = await call(user.id, "get_labs", {}, reach);
expect(labs).toMatchObject({ present: false, reason: "outside_reach" });
expect(JSON.stringify(labs)).not.toContain(String(OLD_LDL));
});
it("marks the old domains beyond the lookback in the inventory, with no figures", async () => {
const user = await seedAccount("last90days");
const reach = await readCoachReach(user.id);
const inventory = await buildCoachDataInventory(
user.id,
{ window: "allTime" },
reach,
);
expect(inventory.window).toBe("last90days");
expect(inventory.lookbackLimit).toBe("last90days");
const weight = inventory.entries.find((e) => e.metric === "weight");
expect(weight?.availability).toEqual({
state: "outside_reach",
reachableWithWindow: null,
});
const text = renderDataInventory(inventory);
expect(text).toContain("BEYOND LOOKBACK");
expect(text).not.toContain(String(OLD_WEIGHT));
expect(text).not.toContain(String(OLD_SYS));
});
it("caps a wider window the client sends with the turn", async () => {
const user = await seedAccount("last90days");
const ctx = await assembleTurnContext({
userId: user.id,
locale: "en",
message: "How was my blood pressure?",
scope: { window: "allTime" },
guidedQuestion: undefined,
workoutId: undefined,
conversation: {
conversationId: "history-reach-conversation",
priorTurns: [],
priorSummary: null,
} as never,
});
expect(ctx.reach.days).toBe(90);
expect(ctx.effectiveScope?.window).toBe("last90days");
expect(ctx.snapshot.snapshotJson).not.toContain(String(OLD_SYS));
expect(ctx.snapshot.snapshotJson).not.toContain(String(OLD_WEIGHT));
expect(ctx.snapshot.snapshotJson).not.toContain(String(OLD_LDL));
});
});
describe("no limit (the default)", () => {
it("still reads the whole history", async () => {
const user = await seedAccount(null);
const reach = await readCoachReach(user.id);
expect(reach).toEqual(UNBOUNDED_REACH);
const bp = await call(
user.id,
"get_metric_series",
{ metric: "bp", window: "allTime" },
reach,
);
expect(bp.present).toBe(true);
expect(JSON.stringify(bp.data)).toContain(String(OLD_SYS));
const labs = await call(user.id, "get_labs", {}, reach);
expect(labs.present).toBe(true);
expect(JSON.stringify(labs.data)).toContain(String(OLD_LDL));
const table = await call(
user.id,
"get_metric_table",
{ metric: "bp", window: "last30days", period: "yearAgo" },
reach,
);
expect(table.reason).not.toBe("outside_reach");
});
});