From ae8b614dd086d37f065a306af0da8d213a0e4b9e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 15 Oct 2026 22:06:05 +0200
Subject: [PATCH 1/9] feat(insights): choose the status regeneration gap by
ingest source
A sync from a watch or an integration now waits the instance's sync gap
(AppSettings.aiSyncRegenGapHours, else AI_SYNC_REGEN_GAP_HOURS, else 6 h)
before it may regenerate a status card again; a hand entry keeps the hour.
Every caller of afterMeasurementMutation and invalidateStatusInsightsForTypes
names its source, so a new ingest path cannot skip the choice.
Refs #1217
---
.env.production.example | 7 +
docker-compose.yml | 5 +
docs/integrations/ai-providers.md | 8 +
scripts/env-manifest.json | 4 +
src/app/api/import/csv/route.ts | 5 +-
src/app/api/import/route.ts | 7 +-
src/app/api/measurements/[id]/route.ts | 20 ++-
src/app/api/measurements/batch/route.ts | 7 +-
.../bulk-delete/__tests__/route.test.ts | 10 +-
src/app/api/measurements/bulk-delete/route.ts | 2 +-
.../by-external-ids/__tests__/route.test.ts | 9 +-
.../api/measurements/by-external-ids/route.ts | 2 +-
.../restore/__tests__/route.test.ts | 10 +-
src/app/api/measurements/restore/route.ts | 2 +-
src/app/api/measurements/route.ts | 18 +-
.../api/mental-health/assessments/route.ts | 16 +-
src/lib/fitbit/sync-core.ts | 1 +
src/lib/fitbit/sync.ts | 4 +-
src/lib/google-health/sync-core.ts | 1 +
src/lib/google-health/sync.ts | 4 +-
.../invalidate-status-insights.test.ts | 164 ++++++++++++++----
src/lib/insights/__tests__/score-row.test.ts | 17 +-
src/lib/insights/score-row.ts | 8 +-
src/lib/insights/status-invalidation.ts | 133 ++++++++++----
src/lib/jobs/apple-health-import-worker.ts | 6 +-
src/lib/jobs/health-connect-import-worker.ts | 6 +-
src/lib/mcp/__tests__/writes.test.ts | 4 +-
src/lib/mcp/writes.ts | 4 +-
.../__tests__/create-from-telegram.test.ts | 2 +-
src/lib/measurements/create-from-telegram.ts | 2 +-
.../repair-apple-health-distance.ts | 2 +-
src/lib/nightscout/sync.ts | 1 +
src/lib/oura/sync.ts | 6 +-
src/lib/polar/sync.ts | 1 +
.../after-measurement-mutation.test.ts | 56 +++---
src/lib/rollups/after-measurement-mutation.ts | 22 ++-
src/lib/whoop/__tests__/sync-body.test.ts | 2 +-
.../__tests__/webhook-notification.test.ts | 2 +-
src/lib/whoop/sync-body.ts | 5 +-
src/lib/whoop/sync-core.ts | 14 +-
src/lib/whoop/webhook-handler.ts | 2 +-
src/lib/withings/sync-activity.ts | 1 +
src/lib/withings/sync-ecg.ts | 1 +
src/lib/withings/sync-sleep.ts | 1 +
src/lib/withings/sync.ts | 1 +
.../sharing-provider-origin.test.ts | 4 +-
.../summaries-pre-window-buckets.test.ts | 8 +-
47 files changed, 452 insertions(+), 165 deletions(-)
diff --git a/.env.production.example b/.env.production.example
index c5f56207c..bc8df0b6f 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -327,6 +327,13 @@ API_TOKEN_HMAC_KEY=""
# docs/integrations/ai-providers.md.
# DOCUMENT_AI_LIMIT_PER_HOUR=""
+# Minimum hours between two regenerations of a status card after a sync
+# (optional). A watch or an integration delivers readings all day, and each
+# delivery would otherwise regenerate the cards it touches every hour. Hand
+# entries keep a one-hour gap. Default 6, whole hours from 1 to 48. The value
+# under Admin > AI wins over this one. See docs/integrations/ai-providers.md.
+# AI_SYNC_REGEN_GAP_HOURS=""
+
# Hourly upload ceiling for each document-upload token (optional). Applies
# only to uploads made with a token minted under Settings > API & Tokens >
# Document tokens: a Paperless workflow or the import script. Every upload
diff --git a/docker-compose.yml b/docker-compose.yml
index eaacd11d4..c8a65b987 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -332,6 +332,11 @@ x-healthlog-env: &healthlog_env
# (the `environment:` block is a whitelist). See
# docs/integrations/ai-providers.md.
DOCUMENT_AI_LIMIT_PER_HOUR: "${DOCUMENT_AI_LIMIT_PER_HOUR:-}"
+ # Minimum hours between two status-card regenerations after a sync
+ # (optional; default 6, whole hours 1-48; hand entries keep one hour). The
+ # value under Admin > AI wins. Listed here so an operator value in .env
+ # reaches the container. See docs/integrations/ai-providers.md.
+ AI_SYNC_REGEN_GAP_HOURS: "${AI_SYNC_REGEN_GAP_HOURS:-}"
# Hourly upload ceiling for each narrow document-upload token (a Paperless
# workflow, the import script; optional; default 120, clamped 1-1000).
# Uploads from the web app and the phone keep their own bucket. Listed here
diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md
index a097541e5..34a6dbda1 100644
--- a/docs/integrations/ai-providers.md
+++ b/docs/integrations/ai-providers.md
@@ -431,6 +431,14 @@ many documents against a generous provider budget; lower it on a
tight one. The 429 names the actual wait (the window is an hour) and
carries the reset instant in `X-RateLimit-Reset`.
+`AI_SYNC_REGEN_GAP_HOURS` (default `6`, whole hours from 1 to 48) is
+the minimum time between two regenerations of a status card after a sync.
+A watch or an integration delivers readings all day, and each delivery
+moves today's figures, so without a gap every touched card regenerated
+about once an hour. A reading entered by hand keeps a one-hour gap, so it
+is still narrated the same day. The value set under Admin > AI wins over
+the environment.
+
`LABS_OCR_LIMIT_PER_HOUR` (default `6`, clamped to 1–1000) does the
same for lab-report scans. The photo or PDF scan and the browser-OCR
text scan share one bucket per user, and a scan that fails before the
diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json
index 08a6796ec..52b63fa38 100644
--- a/scripts/env-manifest.json
+++ b/scripts/env-manifest.json
@@ -142,6 +142,10 @@
"name": "DOCUMENT_AI_LIMIT_PER_HOUR",
"purpose": "Per-user hourly ceiling for the document-AI actions (read/index, extract, suggest, summarise share one bucket). Default 6, clamped to 1-1000."
},
+ {
+ "name": "AI_SYNC_REGEN_GAP_HOURS",
+ "purpose": "Minimum hours between two status-card regenerations after a sync. Default 6, whole hours 1-48; hand entries keep one hour. The admin AI setting wins."
+ },
{
"name": "DOCUMENT_UPLOAD_LIMIT_PER_HOUR",
"purpose": "Hourly upload ceiling per document-upload token (Paperless workflow, import script). Default 120, clamped to 1-1000. Web and phone uploads keep their own bucket."
diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts
index e6990a216..d39ccc3e4 100644
--- a/src/app/api/import/csv/route.ts
+++ b/src/app/api/import/csv/route.ts
@@ -443,7 +443,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
// CSV pays at most ~N (type, day) recomputes, not 10 000 per-row hooks
// — and the status re-warm for the touched types. Best-effort: a
// populator hiccup never fails the importer.
- await afterMeasurementMutation(userId, touchedMeasurements, "import.csv");
+ await afterMeasurementMutation(userId, touchedMeasurements, {
+ source: "manual",
+ label: "import.csv",
+ });
}
await auditLog("import.csv.upload", {
diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts
index e827cc674..b64ff48ce 100644
--- a/src/app/api/import/route.ts
+++ b/src/app/api/import/route.ts
@@ -483,7 +483,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
// (type, day) the import touched (collapsed, so a 10 000-row restore pays
// at most ~N recomputes) and re-warm the touched types' status
// assessments. Best-effort: a populator hiccup never fails the importer.
- await afterMeasurementMutation(userId, touchedMeasurements, "import");
+ await afterMeasurementMutation(userId, touchedMeasurements, {
+ // A file the person chose to import: a deliberate act, narrated like a
+ // hand entry rather than held to the sync gap.
+ source: "manual",
+ label: "import",
+ });
annotate({
meta: {
diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts
index d1a29a746..5b0431332 100644
--- a/src/app/api/measurements/[id]/route.ts
+++ b/src/app/api/measurements/[id]/route.ts
@@ -248,10 +248,14 @@ export const PUT = apiHandler(
// NEW identity ride the list: an edit can move the row across day
// boundaries or re-type it, and the helper collapses identical
// (type, day) pairs so the common in-place edit fires once.
- await afterMeasurementMutation(user.id, [
- { type: measurement.type, measuredAt: measurement.measuredAt },
- { type: existing.type, measuredAt: existing.measuredAt },
- ]);
+ await afterMeasurementMutation(
+ user.id,
+ [
+ { type: measurement.type, measuredAt: measurement.measuredAt },
+ { type: existing.type, measuredAt: existing.measuredAt },
+ ],
+ { source: "manual" },
+ );
return apiSuccess(shapeMeasurementNotes(measurement));
},
@@ -312,9 +316,11 @@ export const DELETE = apiHandler(
// v1.37.19 (C2-F1) — shared post-mutation tail (the recompute drops
// the rollup row when the day's measurement count goes to zero).
- await afterMeasurementMutation(user.id, [
- { type: existing.type, measuredAt: existing.measuredAt },
- ]);
+ await afterMeasurementMutation(
+ user.id,
+ [{ type: existing.type, measuredAt: existing.measuredAt }],
+ { source: "manual" },
+ );
return apiSuccess({ deleted: true });
},
diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts
index 299b0ff1b..43de22c21 100644
--- a/src/app/api/measurements/batch/route.ts
+++ b/src/app/api/measurements/batch/route.ts
@@ -1209,7 +1209,12 @@ async function postBatch(request: NextRequest): Promise {
// v1.37.19 (C2-F1) — shared post-mutation tail for every distinct
// (type, day) the batch touched. Updates participate as well as
// inserts, so this deliberately retains the prepared-row scope.
- await afterMeasurementMutation(user.id, writtenIdentities);
+ // A batch is the phone's sync, held to the sync gap, unless it wrote one
+ // of the person's own hand-entered readings, which is narrated like any
+ // other hand entry.
+ await afterMeasurementMutation(user.id, writtenIdentities, {
+ source: wroteManualRow ? "manual" : "sync",
+ });
// S4 — Apple-Health sleep is the third sleep transport. If any SLEEP_DURATION
// row for last night just landed, kick the debounced morning refresh so the
diff --git a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts
index 32ff2f171..3a3438a12 100644
--- a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts
+++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts
@@ -154,10 +154,12 @@ describe("POST /api/measurements/bulk-delete", () => {
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
// And one status re-warm over the distinct types.
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledTimes(1);
- expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [
- "WEIGHT",
- "PULSE",
- ]);
+ // A delete is the person's own act: narrated like a hand entry.
+ expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith(
+ "user-1",
+ ["WEIGHT", "PULSE"],
+ { source: "manual" },
+ );
});
it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => {
diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts
index 7d341b65c..fd378ea9d 100644
--- a/src/app/api/measurements/bulk-delete/route.ts
+++ b/src/app/api/measurements/bulk-delete/route.ts
@@ -123,7 +123,7 @@ async function postBulkDelete(request: NextRequest): Promise {
type: row.type as MeasurementType,
measuredAt: row.measuredAt,
})),
- "measurements bulk-delete",
+ { source: "manual", label: "measurements bulk-delete" },
);
}
diff --git a/src/app/api/measurements/by-external-ids/__tests__/route.test.ts b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts
index 072608711..afb091c8e 100644
--- a/src/app/api/measurements/by-external-ids/__tests__/route.test.ts
+++ b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts
@@ -118,9 +118,12 @@ describe("DELETE /api/measurements/by-external-ids", () => {
// The deleted row's type is re-warmed; the zero-row case below is the
// twin that must not reach it.
- expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [
- "WEIGHT",
- ]);
+ // The phone's HealthKit deletions arrive with its sync: the sync gap.
+ expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith(
+ "user-1",
+ ["WEIGHT"],
+ { source: "sync" },
+ );
});
it("is a no-op for a foreign-origin externalId that collides on a non-app-minted row", async () => {
diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts
index 387db7ed4..3a1caf88d 100644
--- a/src/app/api/measurements/by-external-ids/route.ts
+++ b/src/app/api/measurements/by-external-ids/route.ts
@@ -169,7 +169,7 @@ async function deleteByExternalIds(request: NextRequest): Promise {
// v1.37.19 (C2-F1) — shared post-mutation tail (identities collected
// BEFORE the destructive statement, while readable).
- await afterMeasurementMutation(user.id, affectedRows);
+ await afterMeasurementMutation(user.id, affectedRows, { source: "sync" });
}
return apiSuccess({ deletedCount: result.count });
diff --git a/src/app/api/measurements/restore/__tests__/route.test.ts b/src/app/api/measurements/restore/__tests__/route.test.ts
index dcd8a0f15..8ef10c8c3 100644
--- a/src/app/api/measurements/restore/__tests__/route.test.ts
+++ b/src/app/api/measurements/restore/__tests__/route.test.ts
@@ -149,10 +149,12 @@ describe("POST /api/measurements/restore", () => {
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
// And one status re-warm over the distinct types.
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledTimes(1);
- expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [
- "WEIGHT",
- "PULSE",
- ]);
+ // A restore is the person's own act: narrated like a hand entry.
+ expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith(
+ "user-1",
+ ["WEIGHT", "PULSE"],
+ { source: "manual" },
+ );
});
it("is a no-op (restored: 0) when no id is owned + tombstoned — no rollup, no invalidate", async () => {
diff --git a/src/app/api/measurements/restore/route.ts b/src/app/api/measurements/restore/route.ts
index cbef0acdf..be8a4321a 100644
--- a/src/app/api/measurements/restore/route.ts
+++ b/src/app/api/measurements/restore/route.ts
@@ -122,7 +122,7 @@ async function postRestore(request: NextRequest): Promise {
type: row.type as MeasurementType,
measuredAt: row.measuredAt,
})),
- "measurements restore",
+ { source: "manual", label: "measurements restore" },
);
}
diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts
index 278ddb899..fa46fa1f7 100644
--- a/src/app/api/measurements/route.ts
+++ b/src/app/api/measurements/route.ts
@@ -963,6 +963,7 @@ async function postMeasurement(request: NextRequest) {
await afterMeasurementMutation(
user.id,
results.map((r) => ({ type: r.type, measuredAt: r.measuredAt })),
+ { source: "manual" },
);
return apiSuccess(shapedResults, 201);
@@ -1116,9 +1117,11 @@ async function postMeasurement(request: NextRequest) {
// pg-boss; WEEK / MONTH / YEAR recomputes are enqueued under the
// hood. Best-effort — a populator hiccup never fails the user's
// write.
- await afterMeasurementMutation(user.id, [
- { type: measurement.type, measuredAt: measurement.measuredAt },
- ]);
+ await afterMeasurementMutation(
+ user.id,
+ [{ type: measurement.type, measuredAt: measurement.measuredAt }],
+ { source: "manual" },
+ );
return apiSuccess(shapeMeasurementNotes(measurement), 201);
}
@@ -1259,10 +1262,11 @@ async function postNamedSleepStage(
).catch(() => {});
void maybeEnqueueMorningRefresh(userId, [segment.end]).catch(() => {});
}
- await afterMeasurementMutation(userId, [
- { type, measuredAt: segment.end },
- ...(verdict.dirtyIdentities ?? []),
- ]);
+ await afterMeasurementMutation(
+ userId,
+ [{ type, measuredAt: segment.end }, ...(verdict.dirtyIdentities ?? [])],
+ { source: "manual" },
+ );
}
return namedSleepStageResponse(verdict.row.id, status, {
diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts
index 74c81f181..c1ad8cc72 100644
--- a/src/app/api/mental-health/assessments/route.ts
+++ b/src/app/api/mental-health/assessments/route.ts
@@ -374,12 +374,16 @@ async function postAssessment(request: NextRequest): Promise {
// route it through the shared post-mutation tail so the (type, day)
// rollup bucket and the cached status assessment converge without
// waiting for a nightly discovery pass.
- await afterMeasurementMutation(user.id, [
- {
- type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType,
- measuredAt: when,
- },
- ]);
+ await afterMeasurementMutation(
+ user.id,
+ [
+ {
+ type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType,
+ measuredAt: when,
+ },
+ ],
+ { source: "manual" },
+ );
// v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on
// PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing
diff --git a/src/lib/fitbit/sync-core.ts b/src/lib/fitbit/sync-core.ts
index d25ac2487..c636b3071 100644
--- a/src/lib/fitbit/sync-core.ts
+++ b/src/lib/fitbit/sync-core.ts
@@ -900,6 +900,7 @@ export async function upsertFitbitMeasurements(
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
+ { source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`fitbit: status-insight invalidate failed for ${userId}: ${err}`,
diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts
index 449efbff2..84f1f84e5 100644
--- a/src/lib/fitbit/sync.ts
+++ b/src/lib/fitbit/sync.ts
@@ -129,7 +129,9 @@ export async function syncUserFitbit(
// the aggregator's `< to` upper bound covers it.
const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000);
await recomputeUserRollups(userId, { types, from, to });
- invalidateStatusInsightsForTypes(userId, types).catch((err) => {
+ invalidateStatusInsightsForTypes(userId, types, {
+ source: "sync",
+ }).catch((err) => {
getEvent()?.addWarning(
`fitbit: status-insight invalidate failed for ${userId}: ${err}`,
);
diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts
index 9bf3bf29c..4940e43ea 100644
--- a/src/lib/google-health/sync-core.ts
+++ b/src/lib/google-health/sync-core.ts
@@ -1606,6 +1606,7 @@ export async function upsertGoogleHealthMeasurements(
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
+ { source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`google-health: status-insight invalidate failed for ${userId}: ${err}`,
diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts
index 85ba41422..0caff675a 100644
--- a/src/lib/google-health/sync.ts
+++ b/src/lib/google-health/sync.ts
@@ -370,7 +370,9 @@ async function runGoogleHealthSyncCycle(
const from = new Date(sorted[0]!);
const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000);
await recomputeUserRollups(userId, { types, from, to });
- invalidateStatusInsightsForTypes(userId, types).catch((err) => {
+ invalidateStatusInsightsForTypes(userId, types, {
+ source: "sync",
+ }).catch((err) => {
getEvent()?.addWarning(
`google-health: status-insight invalidate failed for ${userId}: ${err}`,
);
diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts
index e47f34ee0..08a6a8a51 100644
--- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts
+++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts
@@ -19,6 +19,7 @@ const noteFindMany = vi.fn();
const aiCapabilityForRecord = vi.fn();
const enqueueStatusGeneration = vi.fn();
const userFindUnique = vi.fn();
+const appSettingsFindUnique = vi.fn();
const measurementFindMany = vi.fn();
// v1.42 — type discovery goes through the loose-index-scan helper; the
// rows it answers with are the types themselves.
@@ -40,6 +41,9 @@ vi.mock("@/lib/db", () => ({
findMany: (...a: unknown[]) => noteFindMany(...a),
},
user: { findUnique: (...a: unknown[]) => userFindUnique(...a) },
+ appSettings: {
+ findUnique: (...a: unknown[]) => appSettingsFindUnique(...a),
+ },
measurement: {
findMany: (...a: unknown[]) => measurementFindMany(...a),
},
@@ -85,10 +89,19 @@ beforeEach(() => {
});
enqueueStatusGeneration.mockResolvedValue(undefined);
userFindUnique.mockResolvedValue({ locale: "de" });
+ appSettingsFindUnique.mockResolvedValue({ aiSyncRegenGapHours: null });
measurementFindMany.mockResolvedValue([]);
measurementGroupBy.mockResolvedValue([]);
+ delete process.env.AI_SYNC_REGEN_GAP_HOURS;
});
+/** How far in the past the freshness probe's cutoff sat, in hours. */
+function probedGapHours(before: number): number {
+ const where = noteFindMany.mock.calls[0][0].where;
+ const cutoff = (where.generatedAt.gte as Date).getTime();
+ return Math.round((before - cutoff) / (60 * 60 * 1000));
+}
+
/** Distinct scopes the invalidator enqueued a regenerate for. */
function enqueuedScopes(): string[] {
return [
@@ -102,7 +115,9 @@ function enqueuedScopes(): string[] {
describe("invalidateStatusInsightsForTypes", () => {
it("maps WEIGHT to weight + bmi + general", async () => {
- await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
+ await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
+ source: "manual",
+ });
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
expect(
enqueueStatusGeneration.mock.calls.every(
@@ -112,7 +127,9 @@ describe("invalidateStatusInsightsForTypes", () => {
});
it("never deletes the cached assessment rows (v1.16.8 — hash gate decides)", async () => {
- await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
+ await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
+ source: "manual",
+ });
// The rows stay for stale-while-revalidate AND for the content-hash
// gate the worker's forced regeneration runs; deleting them here would
// force a full LLM regeneration even for a re-synced unchanged batch.
@@ -120,18 +137,20 @@ describe("invalidateStatusInsightsForTypes", () => {
});
it("maps both blood-pressure components to blood-pressure + general", async () => {
- await invalidateStatusInsightsForTypes("u1", [
- "BLOOD_PRESSURE_SYS",
- "BLOOD_PRESSURE_DIA",
- ]);
+ await invalidateStatusInsightsForTypes(
+ "u1",
+ ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
+ { source: "manual" },
+ );
expect(enqueuedScopes()).toEqual(["blood-pressure", "general"]);
});
it("maps PULSE and RESTING_HEART_RATE to pulse + general (+ the resting-HR generic scope)", async () => {
- await invalidateStatusInsightsForTypes("u1", [
- "PULSE",
- "RESTING_HEART_RATE",
- ]);
+ await invalidateStatusInsightsForTypes(
+ "u1",
+ ["PULSE", "RESTING_HEART_RATE"],
+ { source: "manual" },
+ );
// PULSE feeds the specialised pulse + general scopes; RESTING_HEART_RATE
// feeds those too AND carries its own generic assessment card
// (`metric:RESTING_HEART_RATE`), so all three are dirtied.
@@ -146,16 +165,18 @@ describe("invalidateStatusInsightsForTypes", () => {
// BLOOD_GLUCOSE has no specialised assessment, but v1.8.7.1 gives it a
// generic `metric:BLOOD_GLUCOSE` card, so a fresh reading dirties the
// general overview AND that generic scope.
- await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]);
+ await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"], {
+ source: "manual",
+ });
expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]);
});
it("dedupes scopes across a mixed batch", async () => {
- await invalidateStatusInsightsForTypes("u1", [
- "WEIGHT",
- "PULSE",
- "BLOOD_GLUCOSE",
- ]);
+ await invalidateStatusInsightsForTypes(
+ "u1",
+ ["WEIGHT", "PULSE", "BLOOD_GLUCOSE"],
+ { source: "manual" },
+ );
// weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪
// general + metric:BLOOD_GLUCOSE (BLOOD_GLUCOSE)
expect(enqueuedScopes()).toEqual([
@@ -168,14 +189,16 @@ describe("invalidateStatusInsightsForTypes", () => {
});
it("is a no-op for an empty type set (no DB call)", async () => {
- await invalidateStatusInsightsForTypes("u1", []);
+ await invalidateStatusInsightsForTypes("u1", [], { source: "manual" });
expect(noteFindMany).not.toHaveBeenCalled();
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
it("enqueues nothing while statusText is unavailable for the record", async () => {
aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE);
- await invalidateStatusInsightsForTypes("u1", ["WEIGHT", "PULSE"]);
+ await invalidateStatusInsightsForTypes("u1", ["WEIGHT", "PULSE"], {
+ source: "manual",
+ });
expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText");
expect(noteFindMany).not.toHaveBeenCalled();
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
@@ -183,7 +206,9 @@ describe("invalidateStatusInsightsForTypes", () => {
it("enqueues a debounced regenerate for every dirtied scope, the user's locale only", async () => {
userFindUnique.mockResolvedValue({ locale: "de" });
- await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
+ await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
+ source: "manual",
+ });
// weight + bmi + general, each warmed once for the user's resolved locale.
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
expect(enqueueStatusGeneration).toHaveBeenCalledTimes(3);
@@ -197,7 +222,9 @@ describe("invalidateStatusInsightsForTypes", () => {
it("resolves the user's locale (en) for the regenerate, never the unused one", async () => {
userFindUnique.mockResolvedValue({ locale: "en" });
- await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
+ await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
+ source: "manual",
+ });
const locales = new Set(
enqueueStatusGeneration.mock.calls.map(
(c) => (c[0] as { locale: string }).locale,
@@ -210,7 +237,9 @@ describe("invalidateStatusInsightsForTypes", () => {
// A steps sample touches only the general overview, never the weight /
// pulse / bp scopes — the constant Apple-Health sync must not thrash
// every card's cache.
- await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]);
+ await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
+ source: "manual",
+ });
expect(enqueuedScopes()).toEqual(["general", "metric:STEPS"]);
});
@@ -219,14 +248,18 @@ describe("invalidateStatusInsightsForTypes", () => {
// its own generic assessment card (`metric:BLOOD_GLUCOSE`), so the
// freshly-synced metric's card refreshes in the background instead of
// lagging until the nightly warm pass.
- await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]);
+ await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"], {
+ source: "manual",
+ });
expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]);
});
it("maps a remapped MeasurementType to its registry metric id", async () => {
// ACTIVE_ENERGY_BURNED is stored under that DB type but the generic
// metric id (route param + cache scope) is ACTIVE_ENERGY.
- await invalidateStatusInsightsForTypes("u1", ["ACTIVE_ENERGY_BURNED"]);
+ await invalidateStatusInsightsForTypes("u1", ["ACTIVE_ENERGY_BURNED"], {
+ source: "manual",
+ });
expect(enqueuedScopes()).toEqual(["general", "metric:ACTIVE_ENERGY"]);
});
@@ -234,7 +267,9 @@ describe("invalidateStatusInsightsForTypes", () => {
// A weight sample feeds weight + bmi + general; WEIGHT is one of the
// seven specialised metrics and has no generic registry entry, so no
// `metric:` scope is enqueued.
- await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
+ await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
+ source: "manual",
+ });
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
expect(enqueuedScopes().some((s) => s.startsWith("metric:"))).toBe(false);
});
@@ -249,7 +284,9 @@ describe("invalidateStatusInsightsForTypes", () => {
// `general` was regenerated within the gap; a fresh WEIGHT sample
// must not re-enqueue it. weight + bmi are still stale, so they refresh.
noteFindMany.mockResolvedValue([freshRow("general")]);
- await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
+ await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
+ source: "manual",
+ });
expect(enqueuedScopes()).toEqual(["bmi", "weight"]);
});
@@ -261,7 +298,9 @@ describe("invalidateStatusInsightsForTypes", () => {
// this is what lets same-day data be narrated same-day without
// reopening the per-batch regeneration storm.
noteFindMany.mockResolvedValue([]);
- await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
+ await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
+ source: "manual",
+ });
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
});
@@ -271,7 +310,9 @@ describe("invalidateStatusInsightsForTypes", () => {
freshRow("bmi"),
freshRow("general"),
]);
- await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
+ await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
+ source: "manual",
+ });
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
@@ -280,7 +321,9 @@ describe("invalidateStatusInsightsForTypes", () => {
// scope that recently stalled must retry rather than be debounced into
// staying cold. The probe asks the database for noted rows only; with
// none returned, every dirtied scope refreshes.
- await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
+ await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
+ source: "manual",
+ });
const where = noteFindMany.mock.calls[0][0].where;
expect(where.textEncrypted).toEqual({ not: null });
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
@@ -289,7 +332,9 @@ describe("invalidateStatusInsightsForTypes", () => {
it("scopes the freshness probe to the user's resolved locale and a 1 h gap cutoff", async () => {
userFindUnique.mockResolvedValue({ locale: "en" });
const before = Date.now();
- await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
+ await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
+ source: "manual",
+ });
const where = noteFindMany.mock.calls[0][0].where;
expect(where.userId).toBe("u1");
// Only the en notes for the dirtied scopes are probed.
@@ -387,3 +432,64 @@ describe("enqueueStatusRefillForUser", () => {
]);
});
});
+
+// #1217 — the gap is chosen by where the batch came from. A watch drips
+// syncs all day and every drip moves today's partial bucket, so the content
+// hash cannot hold them back; a hand entry still has to be narrated the
+// same day.
+describe("regeneration gap by ingest source", () => {
+ it("holds a sync to six hours by default", async () => {
+ const before = Date.now();
+ await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
+ source: "sync",
+ });
+ expect(probedGapHours(before)).toBe(6);
+ });
+
+ it("keeps a hand entry at one hour", async () => {
+ const before = Date.now();
+ await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], {
+ source: "manual",
+ });
+ expect(probedGapHours(before)).toBe(1);
+ expect(appSettingsFindUnique).not.toHaveBeenCalled();
+ });
+
+ it("takes the sync gap from AI_SYNC_REGEN_GAP_HOURS when the operator set no value", async () => {
+ process.env.AI_SYNC_REGEN_GAP_HOURS = "12";
+ const before = Date.now();
+ await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
+ source: "sync",
+ });
+ expect(probedGapHours(before)).toBe(12);
+ });
+
+ it("prefers the operator's stored gap over the environment", async () => {
+ process.env.AI_SYNC_REGEN_GAP_HOURS = "12";
+ appSettingsFindUnique.mockResolvedValue({ aiSyncRegenGapHours: 3 });
+ const before = Date.now();
+ await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
+ source: "sync",
+ });
+ expect(probedGapHours(before)).toBe(3);
+ });
+
+ it("ignores an out-of-range environment value", async () => {
+ process.env.AI_SYNC_REGEN_GAP_HOURS = "0";
+ const before = Date.now();
+ await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
+ source: "sync",
+ });
+ expect(probedGapHours(before)).toBe(6);
+ });
+
+ it("falls back to the environment when the settings row cannot be read", async () => {
+ process.env.AI_SYNC_REGEN_GAP_HOURS = "24";
+ appSettingsFindUnique.mockRejectedValue(new Error("db down"));
+ const before = Date.now();
+ await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
+ source: "sync",
+ });
+ expect(probedGapHours(before)).toBe(24);
+ });
+});
diff --git a/src/lib/insights/__tests__/score-row.test.ts b/src/lib/insights/__tests__/score-row.test.ts
index b21f2b2c9..68b211f84 100644
--- a/src/lib/insights/__tests__/score-row.test.ts
+++ b/src/lib/insights/__tests__/score-row.test.ts
@@ -131,12 +131,17 @@ describe("upsertScoreRow", () => {
now: new Date("2026-06-02T03:00:00Z"),
});
- expect(afterMeasurementMutation).toHaveBeenCalledWith("u1", [
- {
- type: "STRAIN_SCORE",
- measuredAt: new Date("2026-06-01T12:00:00.000Z"),
- },
- ]);
+ expect(afterMeasurementMutation).toHaveBeenCalledWith(
+ "u1",
+ [
+ {
+ type: "STRAIN_SCORE",
+ measuredAt: new Date("2026-06-01T12:00:00.000Z"),
+ },
+ ],
+ // A computed score is not a hand entry: it waits the sync gap.
+ { source: "sync", label: "score" },
+ );
});
});
diff --git a/src/lib/insights/score-row.ts b/src/lib/insights/score-row.ts
index c82b07487..e49d57a31 100644
--- a/src/lib/insights/score-row.ts
+++ b/src/lib/insights/score-row.ts
@@ -105,9 +105,11 @@ export async function upsertScoreRow(
// it through the shared post-mutation tail so the (type, day) rollup
// bucket and the cached status assessment converge on the same tick that
// wrote the score, instead of waiting for a discovery pass.
- await afterMeasurementMutation(args.userId, [
- { type: args.type, measuredAt },
- ]);
+ await afterMeasurementMutation(
+ args.userId,
+ [{ type: args.type, measuredAt }],
+ { source: "sync", label: "score" },
+ );
}
/** The tally every score pass returns. */
diff --git a/src/lib/insights/status-invalidation.ts b/src/lib/insights/status-invalidation.ts
index b12d59023..aafcc7d47 100644
--- a/src/lib/insights/status-invalidation.ts
+++ b/src/lib/insights/status-invalidation.ts
@@ -34,39 +34,88 @@ import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate";
import type { MeasurementType } from "@/generated/prisma/client";
/**
- * Minimum gap between a scope's last cached (re)generation and the next
- * ingest-driven re-enqueue. A scope whose cached assessment was
- * (re)generated within this window is left intact on a fresh measurement
- * ingest — not re-enqueued.
+ * Where a batch of fresh measurements came from, which decides how soon the
+ * status cards it dirties may regenerate.
*
- * A constantly-syncing client (Apple Health drips batches every few
- * minutes) used to delete + re-enqueue every dirtied scope on every batch,
- * and because each delete dropped the cache row the per-(user,scope,locale)
- * enqueue singleton (120 s) could not coalesce across batches that arrive
- * minutes apart — so STEPS / general / a metric card regenerated several
- * times an hour and the assessment felt "regenerated on every visit". This
- * window coalesces the storm: a genuinely stale scope (no fresh assessment)
- * still refreshes immediately, but a scope refreshed inside the window is
- * skipped, so a fresh assessment survives the day's sync drip.
- *
- * v1.16.8 — first widened from 30 min to 6 h to bound provider spend, but
- * a 6 h wall meant same-day data was not narrated same-day: the nightly
- * warm at 04:30 restarted the window, so a notable 08:00 reading never
- * re-enqueued its scopes and the card showed the pre-reading text until
- * the next nightly tick. The window is now a ONE-HOUR minimum gap, and
- * the budget role the 6 h wall carried moves to the worker's content-hash
- * gate: every ingest that lands past the gap re-enqueues the dirtied
- * scopes, the worker's forced run re-gathers the snapshot, and the gate
- * (`refreshUnchangedStatusInsight`) turns an unchanged snapshot into a
- * timestamp refresh with zero provider cost. So this clock only bounds
- * the SQL-gather frequency — at most one worker run per scope per hour
- * under a constant sync drip — while provider spend tracks actual data
- * change, which is what the gate exists to meter. Comparing the hash
- * inline at invalidation time was rejected: it would run the full
- * per-scope data gather inside the ingest path, which is exactly the
- * work the queue exists to keep off that path.
+ * - `manual`: a reading the person entered or changed by hand (the web
+ * form, the phone's entry sheet, a Telegram reply, an MCP write, a
+ * delete or restore). It should be narrated the same day, so its gap
+ * stays short.
+ * - `sync`: rows a device or an integration delivered on its own schedule
+ * (Apple Health, Health Connect, Withings, Oura, Fitbit, Polar, WHOOP,
+ * Nightscout, Google Health, a file import, a computed score). A watch
+ * drips these all day, and every drip moves today's partial bucket, so
+ * the content-hash gate cannot catch them; only a longer gap does.
*/
-const INGEST_INVALIDATE_MIN_GAP_MS = 60 * 60 * 1000;
+export type StatusIngestSource = "manual" | "sync";
+
+/**
+ * Minimum gap between a scope's last cached (re)generation and the next
+ * ingest-driven re-enqueue, for a hand entry. A scope whose cached
+ * assessment was (re)generated within the gap is left intact and not
+ * re-enqueued.
+ *
+ * History: v1.9.0 introduced the window (30 min) because a constantly
+ * syncing client re-enqueued every dirtied scope on every batch. v1.16.8
+ * widened it to 6 h for every ingest, which was rolled back to 1 h because a
+ * lone 08:00 reading after the 04:30 nightly warm was not narrated the same
+ * day. The gap is now chosen by source: a hand entry keeps the hour, a sync
+ * gets {@link DEFAULT_SYNC_REGEN_GAP_HOURS} (or the operator's value).
+ */
+const MANUAL_INGEST_MIN_GAP_MS = 60 * 60 * 1000;
+
+/**
+ * The sync gap when neither the operator's setting
+ * (`AppSettings.aiSyncRegenGapHours`) nor `AI_SYNC_REGEN_GAP_HOURS` names one.
+ * Six hours turns a watch's ~16 regenerations per scope and day into ~3.
+ */
+export const DEFAULT_SYNC_REGEN_GAP_HOURS = 6;
+
+/** The operator's sync gap bounds, shared with the admin setting. */
+export const SYNC_REGEN_GAP_HOURS_MIN = 1;
+export const SYNC_REGEN_GAP_HOURS_MAX = 48;
+
+/** A gap in hours, or null when the value is not a usable whole hour count. */
+function parseGapHours(value: unknown): number | null {
+ const n =
+ typeof value === "number"
+ ? value
+ : typeof value === "string" && value.trim() !== ""
+ ? Number(value)
+ : NaN;
+ if (!Number.isInteger(n)) return null;
+ if (n < SYNC_REGEN_GAP_HOURS_MIN || n > SYNC_REGEN_GAP_HOURS_MAX) return null;
+ return n;
+}
+
+/**
+ * The instance's sync gap in hours: the operator's admin setting, else
+ * `AI_SYNC_REGEN_GAP_HOURS`, else six. An unreadable settings row falls back
+ * to the environment, never to a shorter gap.
+ */
+export async function loadSyncRegenGapHours(): Promise {
+ let stored: number | null = null;
+ try {
+ const row = await prisma.appSettings.findUnique({
+ where: { id: "singleton" },
+ select: { aiSyncRegenGapHours: true },
+ });
+ stored = parseGapHours(row?.aiSyncRegenGapHours);
+ } catch {
+ stored = null;
+ }
+ return (
+ stored ??
+ parseGapHours(process.env.AI_SYNC_REGEN_GAP_HOURS) ??
+ DEFAULT_SYNC_REGEN_GAP_HOURS
+ );
+}
+
+/** The regeneration gap for one ingest, in milliseconds. */
+async function ingestRegenGapMs(source: StatusIngestSource): Promise {
+ if (source === "manual") return MANUAL_INGEST_MIN_GAP_MS;
+ return (await loadSyncRegenGapHours()) * 60 * 60 * 1000;
+}
/**
* The seven per-metric assessment scopes. Each generator persists its
@@ -130,6 +179,7 @@ function statusScopesForMeasurementType(
export async function invalidateStatusInsightsForTypes(
userId: string,
types: Iterable,
+ options: { source: StatusIngestSource },
): Promise {
// One ordered set of every scope the batch dirties. The seven specialised
// scopes are bare slugs (`weight`, `general`, …); the generic HealthKit
@@ -184,14 +234,20 @@ export async function invalidateStatusInsightsForTypes(
// only the redundant churn is removed. Past the gap the enqueue always
// goes through — the worker's content-hash gate decides whether the
// batch actually changed anything (see the constant's doc).
- const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes);
+ const gapMs = await ingestRegenGapMs(options.source);
+ const freshScopes = await findRecentlyWarmedScopes(
+ userId,
+ locale,
+ scopes,
+ gapMs,
+ );
const staleScopes = Array.from(scopes).filter(
(scope) => !freshScopes.has(scope),
);
if (staleScopes.length === 0) {
annotate({
action: { name: "insights.status.invalidate.debounced" },
- meta: { skipped: scopes.size, refreshed: 0 },
+ meta: { skipped: scopes.size, refreshed: 0, source: options.source },
});
return;
}
@@ -210,7 +266,11 @@ export async function invalidateStatusInsightsForTypes(
annotate({
action: { name: "insights.status.invalidate.debounced" },
- meta: { skipped: freshScopes.size, refreshed: staleScopes.length },
+ meta: {
+ skipped: freshScopes.size,
+ refreshed: staleScopes.length,
+ source: options.source,
+ },
});
}
@@ -268,7 +328,7 @@ export async function enqueueStatusRefillForUser(
/**
* v1.9.0 — return the subset of `scopes` whose cached assessment for
- * `locale` was generated within `INGEST_INVALIDATE_MIN_GAP_MS` and is a
+ * `locale` was generated within `gapMs` and is a
* real (non-stub) assessment. Those scopes are skipped by the ingest
* invalidator so a fresh assessment survives the sync drip.
*
@@ -281,8 +341,9 @@ async function findRecentlyWarmedScopes(
userId: string,
locale: SupportedLocale,
scopes: ReadonlySet,
+ gapMs: number,
): Promise> {
- const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS);
+ const cutoff = new Date(Date.now() - gapMs);
// Match exactly the cache actions for the candidate scopes in this locale.
const candidateActions = Array.from(scopes, (scope) =>
statusCacheAction(scope, locale),
diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts
index ff5c64e2b..f90e09300 100644
--- a/src/lib/jobs/apple-health-import-worker.ts
+++ b/src/lib/jobs/apple-health-import-worker.ts
@@ -605,7 +605,11 @@ export async function handleAppleHealthImport(
if (importedTypes.length > 0) {
// Registered so the test suite can await the detached handle.
trackBackgroundTask(
- invalidateStatusInsightsForTypes(userId, importedTypes).catch((err) => {
+ invalidateStatusInsightsForTypes(userId, importedTypes, {
+ // An export file the person chose to import, narrated like any
+ // other import rather than held to the sync gap.
+ source: "manual",
+ }).catch((err) => {
console.warn(
`[apple-health-import] status-insight invalidate failed for user ${userId}`,
err,
diff --git a/src/lib/jobs/health-connect-import-worker.ts b/src/lib/jobs/health-connect-import-worker.ts
index 58ba0c6db..b4928e022 100644
--- a/src/lib/jobs/health-connect-import-worker.ts
+++ b/src/lib/jobs/health-connect-import-worker.ts
@@ -228,7 +228,11 @@ export async function runHealthConnectImport(
.map(([type]) => type as MeasurementType);
if (touched.length > 0) {
trackBackgroundTask(
- invalidateStatusInsightsForTypes(userId, touched).catch((err) => {
+ invalidateStatusInsightsForTypes(userId, touched, {
+ // An export file the person chose to import, like the Apple Health
+ // import: narrated like a hand entry, not held to the sync gap.
+ source: "manual",
+ }).catch((err) => {
console.warn(
`[health-connect-import] status-insight invalidate failed for user ${userId}`,
err,
diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts
index a762f1f4c..9d5445c9f 100644
--- a/src/lib/mcp/__tests__/writes.test.ts
+++ b/src/lib/mcp/__tests__/writes.test.ts
@@ -478,7 +478,7 @@ describe("MCP writes — post-write tail", () => {
expect(afterMeasurementMutation).toHaveBeenCalledWith(
"u-1",
[expect.objectContaining({ type: "WEIGHT" })],
- "mcp",
+ { source: "manual", label: "mcp" },
);
});
@@ -495,7 +495,7 @@ describe("MCP writes — post-write tail", () => {
expect.objectContaining({ type: "BLOOD_PRESSURE_SYS" }),
expect.objectContaining({ type: "BLOOD_PRESSURE_DIA" }),
],
- "mcp",
+ { source: "manual", label: "mcp" },
);
});
});
diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts
index 49e3365bd..196f0b291 100644
--- a/src/lib/mcp/writes.ts
+++ b/src/lib/mcp/writes.ts
@@ -355,7 +355,7 @@ export async function logMcpMeasurement(input: {
await afterMeasurementMutation(
input.userId,
[{ type: input.type, measuredAt }],
- "mcp",
+ { source: "manual", label: "mcp" },
);
await auditLog("mcp.write.measurement", {
@@ -529,7 +529,7 @@ export async function logMcpBloodPressure(input: {
{ type: "BLOOD_PRESSURE_SYS", measuredAt },
{ type: "BLOOD_PRESSURE_DIA", measuredAt },
],
- "mcp",
+ { source: "manual", label: "mcp" },
);
await auditLog("mcp.write.blood_pressure", {
diff --git a/src/lib/measurements/__tests__/create-from-telegram.test.ts b/src/lib/measurements/__tests__/create-from-telegram.test.ts
index 54fcbd717..b9a98ff09 100644
--- a/src/lib/measurements/__tests__/create-from-telegram.test.ts
+++ b/src/lib/measurements/__tests__/create-from-telegram.test.ts
@@ -96,7 +96,7 @@ describe("logTelegramMeasurement", () => {
expect(afterMeasurementMutation).toHaveBeenCalledWith(
"user-1",
[expect.objectContaining({ type: "WEIGHT" })],
- "telegram-measurement",
+ { source: "manual", label: "telegram-measurement" },
);
});
diff --git a/src/lib/measurements/create-from-telegram.ts b/src/lib/measurements/create-from-telegram.ts
index 9bbe84543..d2f08a71c 100644
--- a/src/lib/measurements/create-from-telegram.ts
+++ b/src/lib/measurements/create-from-telegram.ts
@@ -162,7 +162,7 @@ export async function logTelegramMeasurement(input: {
await afterMeasurementMutation(
input.userId,
[{ type: input.type, measuredAt }],
- "telegram-measurement",
+ { source: "manual", label: "telegram-measurement" },
);
return { status: "ok" };
diff --git a/src/lib/measurements/repair-apple-health-distance.ts b/src/lib/measurements/repair-apple-health-distance.ts
index 9b0178cbf..7a9881da9 100644
--- a/src/lib/measurements/repair-apple-health-distance.ts
+++ b/src/lib/measurements/repair-apple-health-distance.ts
@@ -364,7 +364,7 @@ export async function applyAppleHealthDistanceRepair(
type: REPAIRED_TYPE,
measuredAt: row.measuredAt,
})),
- "repair-apple-health-distance",
+ { source: "sync", label: "repair-apple-health-distance" },
);
await client.auditLog.update({
where: { id: auditId },
diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts
index 3af82a725..c1c0edc92 100644
--- a/src/lib/nightscout/sync.ts
+++ b/src/lib/nightscout/sync.ts
@@ -333,6 +333,7 @@ export async function upsertNightscoutEntries(
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
+ { source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`nightscout: status-insight invalidate failed for ${userId}: ${err}`,
diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts
index 51a785ef3..855646c89 100644
--- a/src/lib/oura/sync.ts
+++ b/src/lib/oura/sync.ts
@@ -573,7 +573,10 @@ export async function retireOuraSleepRecords(
where: { userId, id: { in: rows.map((r) => r.id) }, deletedAt: null },
data: { deletedAt: new Date() },
});
- await afterMeasurementMutation(userId, rows, "oura");
+ await afterMeasurementMutation(userId, rows, {
+ source: "sync",
+ label: "oura",
+ });
invalidateUserMeasurements(userId);
annotate({ meta: { oura_sleep_retired_rows: res.count } });
return res.count;
@@ -808,6 +811,7 @@ export async function upsertOuraMeasurements(
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
+ { source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`oura: status-insight invalidate failed for ${userId}: ${err}`,
diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts
index 23db25dbf..6cd575dd0 100644
--- a/src/lib/polar/sync.ts
+++ b/src/lib/polar/sync.ts
@@ -404,6 +404,7 @@ export async function upsertPolarMeasurements(
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
+ { source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`polar: status-insight invalidate failed for ${userId}: ${err}`,
diff --git a/src/lib/rollups/__tests__/after-measurement-mutation.test.ts b/src/lib/rollups/__tests__/after-measurement-mutation.test.ts
index 6a983e08e..681356457 100644
--- a/src/lib/rollups/__tests__/after-measurement-mutation.test.ts
+++ b/src/lib/rollups/__tests__/after-measurement-mutation.test.ts
@@ -47,20 +47,24 @@ const day = (iso: string) => new Date(iso);
describe("afterMeasurementMutation", () => {
it("recomputes one bucket per distinct (type, day) and invalidates each type once", async () => {
- await afterMeasurementMutation("user-1", [
- {
- type: "WEIGHT" as MeasurementType,
- measuredAt: day("2026-05-10T08:00:00Z"),
- },
- {
- type: "WEIGHT" as MeasurementType,
- measuredAt: day("2026-05-10T21:00:00Z"),
- },
- {
- type: "PULSE" as MeasurementType,
- measuredAt: day("2026-05-10T09:00:00Z"),
- },
- ]);
+ await afterMeasurementMutation(
+ "user-1",
+ [
+ {
+ type: "WEIGHT" as MeasurementType,
+ measuredAt: day("2026-05-10T08:00:00Z"),
+ },
+ {
+ type: "WEIGHT" as MeasurementType,
+ measuredAt: day("2026-05-10T21:00:00Z"),
+ },
+ {
+ type: "PULSE" as MeasurementType,
+ measuredAt: day("2026-05-10T09:00:00Z"),
+ },
+ ],
+ { source: "sync" },
+ );
// Two rows on the same (WEIGHT, day) collapse into one recompute.
expect(mocks.recompute).toHaveBeenCalledTimes(2);
@@ -76,13 +80,15 @@ describe("afterMeasurementMutation", () => {
);
expect(mocks.invalidate).toHaveBeenCalledTimes(1);
- const [userId, types] = mocks.invalidate.mock.calls[0];
+ const [userId, types, options] = mocks.invalidate.mock.calls[0];
expect(userId).toBe("user-1");
expect([...types].sort()).toEqual(["PULSE", "WEIGHT"]);
+ // The caller's source reaches the invalidator, which picks the gap by it.
+ expect(options).toEqual({ source: "sync" });
});
it("is a no-op for an empty row set", async () => {
- await afterMeasurementMutation("user-1", []);
+ await afterMeasurementMutation("user-1", [], { source: "manual" });
expect(mocks.recompute).not.toHaveBeenCalled();
expect(mocks.invalidate).not.toHaveBeenCalled();
});
@@ -99,7 +105,7 @@ describe("afterMeasurementMutation", () => {
measuredAt: day("2026-05-10T08:00:00Z"),
},
],
- "test.surface",
+ { source: "manual", label: "test.surface" },
);
} finally {
warn.mockRestore();
@@ -113,12 +119,16 @@ describe("afterMeasurementMutation", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await expect(
- afterMeasurementMutation("user-1", [
- {
- type: "WEIGHT" as MeasurementType,
- measuredAt: day("2026-05-10T08:00:00Z"),
- },
- ]),
+ afterMeasurementMutation(
+ "user-1",
+ [
+ {
+ type: "WEIGHT" as MeasurementType,
+ measuredAt: day("2026-05-10T08:00:00Z"),
+ },
+ ],
+ { source: "manual" },
+ ),
).resolves.toBeUndefined();
// Let the fire-and-forget rejection settle inside the helper's catch.
await new Promise((resolve) => setImmediate(resolve));
diff --git a/src/lib/rollups/after-measurement-mutation.ts b/src/lib/rollups/after-measurement-mutation.ts
index d4ff65816..f12c17160 100644
--- a/src/lib/rollups/after-measurement-mutation.ts
+++ b/src/lib/rollups/after-measurement-mutation.ts
@@ -22,16 +22,28 @@ import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
-import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation";
+import {
+ invalidateStatusInsightsForTypes,
+ type StatusIngestSource,
+} from "@/lib/insights/status-invalidation";
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import type { MeasurementType } from "@/generated/prisma/client";
export async function afterMeasurementMutation(
userId: string,
rows: Array<{ type: MeasurementType; measuredAt: Date }>,
- /** Log prefix identifying the calling surface in the warn lines. */
- label = "measurements",
+ options: {
+ /**
+ * Where the rows came from: a hand entry (`manual`) or a device or
+ * integration (`sync`). Decides how soon the status cards they dirty may
+ * regenerate (`StatusIngestSource`).
+ */
+ source: StatusIngestSource;
+ /** Log prefix identifying the calling surface in the warn lines. */
+ label?: string;
+ },
): Promise {
+ const label = options.label ?? "measurements";
if (rows.length === 0) return;
// Collapse to the distinct `(type, day)` set BEFORE recomputing so a
@@ -53,7 +65,9 @@ export async function afterMeasurementMutation(
// write on a detached handle, and one that lands while a vitest worker is
// closing its RPC channel fails the whole run. No-op outside test.
trackBackgroundTask(
- invalidateStatusInsightsForTypes(userId, types).catch((err) => {
+ invalidateStatusInsightsForTypes(userId, types, {
+ source: options.source,
+ }).catch((err) => {
console.warn(`[${label}] status-insight invalidate failed`, err);
}),
);
diff --git a/src/lib/whoop/__tests__/sync-body.test.ts b/src/lib/whoop/__tests__/sync-body.test.ts
index 2002fd38b..4237ff0fc 100644
--- a/src/lib/whoop/__tests__/sync-body.test.ts
+++ b/src/lib/whoop/__tests__/sync-body.test.ts
@@ -149,7 +149,7 @@ describe("syncUserBody — profile weight is not ingested (v1.16.11)", () => {
expect(afterMeasurementMutation).toHaveBeenCalledWith(
"user1",
[legacyRow],
- "whoop-body",
+ { source: "sync", label: "whoop-body" },
);
expect(prismaMock.measurement.deleteMany).toHaveBeenCalledWith({
where: {
diff --git a/src/lib/whoop/__tests__/webhook-notification.test.ts b/src/lib/whoop/__tests__/webhook-notification.test.ts
index 2e9db8f19..f118932bb 100644
--- a/src/lib/whoop/__tests__/webhook-notification.test.ts
+++ b/src/lib/whoop/__tests__/webhook-notification.test.ts
@@ -144,7 +144,7 @@ describe("processWhoopNotification — fetch-by-id enqueue", () => {
expect(afterMutation).toHaveBeenCalledWith(
"user-1",
[{ type: "SLEEP_DURATION", measuredAt }],
- "whoop.webhook",
+ { source: "sync", label: "whoop.webhook" },
);
});
});
diff --git a/src/lib/whoop/sync-body.ts b/src/lib/whoop/sync-body.ts
index 2600b2687..5a3ca040a 100644
--- a/src/lib/whoop/sync-body.ts
+++ b/src/lib/whoop/sync-body.ts
@@ -78,7 +78,10 @@ export async function syncUserBody(
});
if (legacy.length > 0) {
await prisma.measurement.deleteMany({ where: legacyWhere });
- await afterMeasurementMutation(userId, legacy, "whoop-body");
+ await afterMeasurementMutation(userId, legacy, {
+ source: "sync",
+ label: "whoop-body",
+ });
}
} catch (err) {
getEvent()?.addWarning(
diff --git a/src/lib/whoop/sync-core.ts b/src/lib/whoop/sync-core.ts
index 464ab6718..bb6da4b42 100644
--- a/src/lib/whoop/sync-core.ts
+++ b/src/lib/whoop/sync-core.ts
@@ -421,13 +421,13 @@ export async function upsertWhoopMeasurements(
const invalidateTouchedTypes = (): void => {
if (touchedTypes.size === 0) return;
- void invalidateStatusInsightsForTypes(userId, [...touchedTypes]).catch(
- (err) => {
- getEvent()?.addWarning(
- `whoop: status-insight invalidate failed for ${userId}: ${err}`,
- );
- },
- );
+ void invalidateStatusInsightsForTypes(userId, [...touchedTypes], {
+ source: "sync",
+ }).catch((err) => {
+ getEvent()?.addWarning(
+ `whoop: status-insight invalidate failed for ${userId}: ${err}`,
+ );
+ });
};
for (
diff --git a/src/lib/whoop/webhook-handler.ts b/src/lib/whoop/webhook-handler.ts
index 55790ef77..6fe2cb8a5 100644
--- a/src/lib/whoop/webhook-handler.ts
+++ b/src/lib/whoop/webhook-handler.ts
@@ -197,7 +197,7 @@ export async function processWhoopNotification(
type: row.type as MeasurementType,
measuredAt: row.measuredAt,
})),
- "whoop.webhook",
+ { source: "sync", label: "whoop.webhook" },
);
if (resource === "workout") {
// The Workout model carries no soft-delete column, so a deleted
diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts
index 49a92cc29..1f2284f06 100644
--- a/src/lib/withings/sync-activity.ts
+++ b/src/lib/withings/sync-activity.ts
@@ -550,6 +550,7 @@ export async function syncUserActivity(
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
+ { source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`withings activity: status-insight invalidate failed for ${userId}: ${err}`,
diff --git a/src/lib/withings/sync-ecg.ts b/src/lib/withings/sync-ecg.ts
index 0e6812e46..4721853fe 100644
--- a/src/lib/withings/sync-ecg.ts
+++ b/src/lib/withings/sync-ecg.ts
@@ -528,6 +528,7 @@ export async function syncUserEcg(
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
+ { source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`withings ecg: status-insight invalidate failed for ${userId}: ${err}`,
diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts
index 1f8719950..252332887 100644
--- a/src/lib/withings/sync-sleep.ts
+++ b/src/lib/withings/sync-sleep.ts
@@ -638,6 +638,7 @@ export async function syncUserSleep(
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
+ { source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`withings sleep: status-insight invalidate failed for ${userId}: ${err}`,
diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts
index 10bdc24b3..af4525e23 100644
--- a/src/lib/withings/sync.ts
+++ b/src/lib/withings/sync.ts
@@ -457,6 +457,7 @@ export async function syncUserMeasurements(
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
+ { source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`withings: status-insight invalidate failed for ${userId}: ${err}`,
diff --git a/tests/integration/sharing-provider-origin.test.ts b/tests/integration/sharing-provider-origin.test.ts
index e764a2002..d433804e9 100644
--- a/tests/integration/sharing-provider-origin.test.ts
+++ b/tests/integration/sharing-provider-origin.test.ts
@@ -126,7 +126,9 @@ async function grantManage(input: {
const enqueueFromSharedMutation: (request: NextRequest) => Promise =
apiHandler(async () => {
const { user } = await requireRecordAuth("manage", "medications");
- await invalidateStatusInsightsForTypes(user.id, ["WEIGHT"]);
+ await invalidateStatusInsightsForTypes(user.id, ["WEIGHT"], {
+ source: "manual",
+ });
return NextResponse.json({ data: { queued: true }, error: null });
});
diff --git a/tests/integration/summaries-pre-window-buckets.test.ts b/tests/integration/summaries-pre-window-buckets.test.ts
index e8c95dea0..7c02ac22b 100644
--- a/tests/integration/summaries-pre-window-buckets.test.ts
+++ b/tests/integration/summaries-pre-window-buckets.test.ts
@@ -116,9 +116,11 @@ describe("all-time summaries with DAY buckets older than the fold window", () =>
await recomputeUserRollups(userId);
// A backdated reading, as a manual entry or a sync of old samples writes
// it: the hook folds that one day, window or not.
- await afterMeasurementMutation(userId, [
- { type: "WEIGHT", measuredAt: old.at },
- ]);
+ await afterMeasurementMutation(
+ userId,
+ [{ type: "WEIGHT", measuredAt: old.at }],
+ { source: "manual" },
+ );
await expectTrueFigures(userId, [64, 80]);
});
From c2245d1d7949173ebe520ffa4c119d6128b276e7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 15 Oct 2026 22:09:26 +0200
Subject: [PATCH 2/9] perf(insights): send snapshots as compact JSON and size
the cap against it
The status cards, the briefing, the derived assessments and the Coach
snapshot drop the two-space indentation from the JSON they hand the model.
The content is unchanged (the hash gate hashes the object, so stored hashes
stay valid). MAX_SNAPSHOT_CHARS is now measured on the compact string: 14 000
for the status cards and the Coach, which every snapshot that fitted the old
24 000 pretty-printed characters still fits, so the model reads the same data
for fewer tokens.
Refs #1217
---
src/app/api/insights/generate/route.ts | 4 +-
src/lib/ai/coach/__tests__/snapshot.test.ts | 199 +-----------------
src/lib/ai/coach/snapshot-prelude.ts | 21 +-
src/lib/ai/coach/snapshot.ts | 9 +-
src/lib/ai/coach/source-snapshot.ts | 4 +-
.../__tests__/compact-snapshot.test.ts | 165 +++++++++++++++
.../comprehensive-generate-force.test.ts | 2 +-
src/lib/insights/biomarker-status.ts | 2 +-
src/lib/insights/blood-pressure-status.ts | 2 +-
src/lib/insights/bmi-status.ts | 2 +-
src/lib/insights/comprehensive-generate.ts | 4 +-
.../insights/derived/derived-assessment-ai.ts | 11 +-
src/lib/insights/general-status.ts | 2 +-
src/lib/insights/graded-series.ts | 24 ++-
.../insights/medication-compliance-status.ts | 2 +-
src/lib/insights/metric-status.ts | 2 +-
src/lib/insights/mood-status.ts | 2 +-
src/lib/insights/pulse-status.ts | 2 +-
src/lib/insights/weight-status.ts | 2 +-
19 files changed, 226 insertions(+), 235 deletions(-)
create mode 100644 src/lib/insights/__tests__/compact-snapshot.test.ts
diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts
index 40a00ebb8..52a89ea4c 100644
--- a/src/app/api/insights/generate/route.ts
+++ b/src/app/api/insights/generate/route.ts
@@ -671,7 +671,9 @@ export const POST = apiHandler((request: NextRequest) =>
const compactFeatures = compactSections(
features as unknown as Record,
);
- const featuresJson = JSON.stringify(compactFeatures, null, 2);
+ // Compact on the wire: indentation is about 40% of a pretty-printed
+ // payload, and the model reads the structure without it (#1217).
+ const featuresJson = JSON.stringify(compactFeatures);
if (payloadDowngraded) {
annotate({
meta: {
diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts
index 7b9dfd64f..447dee86a 100644
--- a/src/lib/ai/coach/__tests__/snapshot.test.ts
+++ b/src/lib/ai/coach/__tests__/snapshot.test.ts
@@ -225,7 +225,10 @@ describe("buildCoachSnapshot", () => {
],
});
- expect(out.snapshotJson).toBe(JSON.stringify(out.sections, null, 2));
+ // The prompt carries the snapshot compact (#1217): the same content,
+ // without the indentation.
+ expect(out.snapshotJson).toBe(JSON.stringify(out.sections));
+ expect(JSON.parse(out.snapshotJson)).toEqual(out.sections);
expect(out).toMatchInlineSnapshot(`
{
"provenance": {
@@ -468,199 +471,7 @@ describe("buildCoachSnapshot", () => {
"unit": "kg",
},
},
- "snapshotJson": "{
- "bloodPressure": {
- "aggregate": {
- "avgSys30": 125,
- "avgDia30": 80,
- "coverage": {
- "count": 4
- }
- },
- "timeline": {
- "recent": [
- {
- "date": "2026-07-10",
- "weekday": "Fri",
- "sys": 128,
- "dia": 82
- }
- ],
- "weeklySys": [
- {
- "weekISO": "2026-W27",
- "mean": 122,
- "count": 1
- }
- ],
- "weeklyDia": [
- {
- "weekISO": "2026-W27",
- "mean": 78,
- "count": 1
- }
- ]
- }
- },
- "weight": {
- "unit": "kg",
- "aggregate": {
- "latest": 80.8,
- "bmi": 24.7,
- "coverage": {
- "count": 2
- },
- "unit": "kg"
- },
- "timeline": {
- "recent": [
- {
- "date": "2026-07-16",
- "weekday": "Thu",
- "value": 80.8
- }
- ],
- "weekly": [
- {
- "weekISO": "2026-W27",
- "mean": 81.4,
- "count": 1
- }
- ]
- }
- },
- "pulse": {
- "aggregate": {
- "avg7": 63,
- "coverage": {
- "count": 2
- }
- },
- "timeline": {
- "recent": [
- {
- "date": "2026-07-17",
- "weekday": "Fri",
- "value": 63
- }
- ],
- "weekly": [
- {
- "weekISO": "2026-W27",
- "mean": 67,
- "count": 1
- }
- ]
- }
- },
- "mood": {
- "aggregate": {
- "avg30": 4,
- "coverage": {
- "count": 2
- }
- },
- "timeline": {
- "recent": [
- {
- "date": "2026-07-18",
- "weekday": "Sat",
- "value": 5
- }
- ],
- "weekly": [
- {
- "weekISO": "2026-W27",
- "mean": 3,
- "count": 1
- }
- ]
- }
- },
- "bodyMassIndex": {
- "unit": "kg/m²",
- "timeline": {
- "recent": [
- {
- "date": "2026-07-08",
- "weekday": "Wed",
- "value": 24.8
- },
- {
- "date": "2026-07-15",
- "weekday": "Wed",
- "value": 24.6
- }
- ],
- "weekly": []
- }
- },
- "steps": {
- "unit": "steps",
- "timeline": {
- "recent": [
- {
- "date": "2026-07-18",
- "weekday": "Sat",
- "value": 8400
- }
- ],
- "weekly": [
- {
- "weekISO": "2026-W27",
- "mean": 7200,
- "count": 1
- }
- ]
- }
- },
- "skinTemperature": {
- "unit": "°C",
- "timeline": {
- "recent": [
- {
- "date": "2026-07-19",
- "weekday": "Sun",
- "value": 33.5
- }
- ],
- "weekly": [
- {
- "weekISO": "2026-W27",
- "mean": 33.2,
- "count": 1
- }
- ]
- }
- },
- "anthropometrics": {
- "heightCm": 181,
- "ageYears": 41,
- "gender": "MALE"
- },
- "dayStrain": {
- "source": "WHOOP-native",
- "scale": "0-21",
- "latest": 13.9,
- "recentMean": 12.6,
- "days": 2,
- "note": "Device-native day strain; prefer over derived.STRAIN_SCORE (computed 0-100 proxy)."
- },
- "scope": {
- "window": "last30days",
- "sources": [
- "bp",
- "weight",
- "pulse",
- "mood",
- "steps",
- "bmi",
- "skin_temp",
- "hrv"
- ],
- "timelineRecentDays": 14
- }
- }",
+ "snapshotJson": "{"bloodPressure":{"aggregate":{"avgSys30":125,"avgDia30":80,"coverage":{"count":4}},"timeline":{"recent":[{"date":"2026-07-10","weekday":"Fri","sys":128,"dia":82}],"weeklySys":[{"weekISO":"2026-W27","mean":122,"count":1}],"weeklyDia":[{"weekISO":"2026-W27","mean":78,"count":1}]}},"weight":{"unit":"kg","aggregate":{"latest":80.8,"bmi":24.7,"coverage":{"count":2},"unit":"kg"},"timeline":{"recent":[{"date":"2026-07-16","weekday":"Thu","value":80.8}],"weekly":[{"weekISO":"2026-W27","mean":81.4,"count":1}]}},"pulse":{"aggregate":{"avg7":63,"coverage":{"count":2}},"timeline":{"recent":[{"date":"2026-07-17","weekday":"Fri","value":63}],"weekly":[{"weekISO":"2026-W27","mean":67,"count":1}]}},"mood":{"aggregate":{"avg30":4,"coverage":{"count":2}},"timeline":{"recent":[{"date":"2026-07-18","weekday":"Sat","value":5}],"weekly":[{"weekISO":"2026-W27","mean":3,"count":1}]}},"bodyMassIndex":{"unit":"kg/m²","timeline":{"recent":[{"date":"2026-07-08","weekday":"Wed","value":24.8},{"date":"2026-07-15","weekday":"Wed","value":24.6}],"weekly":[]}},"steps":{"unit":"steps","timeline":{"recent":[{"date":"2026-07-18","weekday":"Sat","value":8400}],"weekly":[{"weekISO":"2026-W27","mean":7200,"count":1}]}},"skinTemperature":{"unit":"°C","timeline":{"recent":[{"date":"2026-07-19","weekday":"Sun","value":33.5}],"weekly":[{"weekISO":"2026-W27","mean":33.2,"count":1}]}},"anthropometrics":{"heightCm":181,"ageYears":41,"gender":"MALE"},"dayStrain":{"source":"WHOOP-native","scale":"0-21","latest":13.9,"recentMean":12.6,"days":2,"note":"Device-native day strain; prefer over derived.STRAIN_SCORE (computed 0-100 proxy)."},"scope":{"window":"last30days","sources":["bp","weight","pulse","mood","steps","bmi","skin_temp","hrv"],"timelineRecentDays":14}}",
"units": {
"glucoseUnit": "mg/dL",
"system": "metric",
diff --git a/src/lib/ai/coach/snapshot-prelude.ts b/src/lib/ai/coach/snapshot-prelude.ts
index 5acdedc8f..8a6b8ac30 100644
--- a/src/lib/ai/coach/snapshot-prelude.ts
+++ b/src/lib/ai/coach/snapshot-prelude.ts
@@ -36,16 +36,19 @@ import type { CoachScope, CoachScopeSource, CoachScopeWindow } from "./types";
export const DAILY_TIMELINE_DAYS = 14;
/**
- * v1.7.0 — assembled-snapshot soft char cap. After the snapshot is
- * built we measure `JSON.stringify(snapshot).length` as a ~4-chars-per-
- * token proxy and, if it exceeds this cap, progressively degrade the
- * lowest-priority clusters (drop `timeline.recent`, then collapse the
- * weekly buckets) until it fits. ~24 000 chars ≈ ~6 000 tokens, which
- * sits comfortably inside every provider's context alongside the system
- * prompt + history window. The daily token ledger (`budget.ts`) stays
- * the per-day cost backstop; this is the per-prompt shape backstop.
+ * v1.7.0 — assembled-snapshot soft char cap. After the snapshot is built we
+ * measure its compact JSON (`JSON.stringify(snapshot).length`, the form the
+ * prompt and every tool result ship) and, if it exceeds this cap,
+ * progressively degrade the lowest-priority clusters (drop
+ * `timeline.recent`, then collapse the weekly buckets) until it fits. The
+ * daily token ledger (`budget.ts`) stays the per-day cost backstop; this is
+ * the per-prompt shape backstop.
+ *
+ * v1.43 (#1217) — 24 000 pretty-printed characters became 14 000 compact
+ * ones. Compact JSON is ~0.42 to ~0.5 of the pretty form for these blocks, so
+ * everything that fitted before still fits: the same data, fewer tokens.
*/
-export const MAX_SNAPSHOT_CHARS = 24_000;
+export const MAX_SNAPSHOT_CHARS = 14_000;
/**
* v1.18.10 (P-2) — newest-first cap on the single multi-type measurement read
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index 2dd46bbc6..04e37f92b 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -1128,7 +1128,7 @@ async function buildCoachSnapshotImpl(
}
return {
- snapshotJson: JSON.stringify(compactSnapshot, null, 2),
+ snapshotJson: JSON.stringify(compactSnapshot),
// v1.20.0 (F1) — the structured, post-degrade snapshot record keyed by
// domain block (`bloodPressure`, `glucose`, `labs`, …). The coach tool
// executor slices a single domain block out of this so a retrieval tool
@@ -1175,10 +1175,9 @@ export function degradeToBudget(
cluster: CoachDataCluster;
pass: number;
}> = [];
- // Measure against the SAME pretty-printed form the prompt ships
- // (`JSON.stringify(snapshot, null, 2)`), not the compact form —
- // otherwise the cap under-counts by ~2× and the prompt overflows.
- const size = () => JSON.stringify(snapshot, null, 2).length;
+ // Measure against the SAME compact form the prompt ships
+ // (`JSON.stringify(snapshot)`), so the cap counts what the model reads.
+ const size = () => JSON.stringify(snapshot).length;
if (size() <= MAX_SNAPSHOT_CHARS) return degraded;
// Build a degrade order: blocks grouped by cluster, lowest priority
diff --git a/src/lib/ai/coach/source-snapshot.ts b/src/lib/ai/coach/source-snapshot.ts
index 723b94be5..ce9ae0bda 100644
--- a/src/lib/ai/coach/source-snapshot.ts
+++ b/src/lib/ai/coach/source-snapshot.ts
@@ -78,7 +78,7 @@ import type {
} from "./types";
/**
- * Above this pretty-printed size the section goes to the full builder, whose
+ * Above this compact size the section goes to the full builder, whose
* budget pass may trim it (see the module comment). A 30-day section is a few
* thousand characters; a year of blood pressure with its coarse tail is the
* kind of section that crosses it.
@@ -240,7 +240,7 @@ export async function buildCoachSourceSnapshot(
const sections = compactSections(snapshot);
annotateSnapshotFreshness(sections);
- const size = JSON.stringify(sections, null, 2).length;
+ const size = JSON.stringify(sections).length;
if (size > SOURCE_SNAPSHOT_FALLBACK_CHARS) {
annotate({
action: { name: "coach.source_snapshot.full" },
diff --git a/src/lib/insights/__tests__/compact-snapshot.test.ts b/src/lib/insights/__tests__/compact-snapshot.test.ts
new file mode 100644
index 000000000..3dbfd2c86
--- /dev/null
+++ b/src/lib/insights/__tests__/compact-snapshot.test.ts
@@ -0,0 +1,165 @@
+/**
+ * #1217 — the status snapshots travel as compact JSON, and the char cap was
+ * lowered with the switch so the model sees at least the same data for fewer
+ * tokens, never less.
+ *
+ * Two properties pin that:
+ * 1. compact JSON parses back to exactly the object the pretty form did, so
+ * dropping the indentation changes no content;
+ * 2. every snapshot (and every shed stage of one) that fitted the old cap,
+ * 24 000 pretty-printed characters, fits the new compact cap, so the
+ * shedding never starts earlier than it used to.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ MAX_SNAPSHOT_CHARS,
+ buildGradedSeriesFromPoints,
+ degradeStatusSnapshotToBudget,
+} from "@/lib/insights/graded-series";
+
+/** The cap before v1.43, measured on `JSON.stringify(snapshot, null, 2)`. */
+const PRETTY_CAP_BEFORE = 24_000;
+
+const NOW = new Date("2026-10-15T12:00:00Z");
+
+function series(days: number, perDay: number, base: number, seed: number) {
+ const points: Array<{ measuredAt: Date; value: number }> = [];
+ let x = seed;
+ for (let d = 0; d < days; d++) {
+ for (let k = 0; k < perDay; k++) {
+ x = (x * 9301 + 49297) % 233280;
+ points.push({
+ measuredAt: new Date(NOW.getTime() - d * 86_400_000 - k * 3_600_000),
+ value: Math.round((base + (x / 233280) * base * 0.2) * 10) / 10,
+ });
+ }
+ }
+ return buildGradedSeriesFromPoints(points, NOW, "Europe/Berlin");
+}
+
+/** A general-status-shaped snapshot over `metrics` series of `days` days. */
+function snapshot(metrics: number, days: number): Record {
+ const measurementSeries: Record = {};
+ for (let i = 0; i < metrics; i++) {
+ measurementSeries[`METRIC_${i}`] = {
+ unit: i % 2 === 0 ? "bpm" : "kg",
+ summary: { points: days * 2, mean: 61.2, min: 50, max: 80, latest: 63 },
+ series: series(days, 2, 50 + i * 10, i + 1),
+ };
+ }
+ return {
+ locale: "de",
+ generatedForDay: "2026-10-15",
+ interpretationHint:
+ "Use trend direction and deltas. Prioritize the newest data if trends conflict.",
+ dataCoverage: {
+ totalMeasurements: days * 2 * metrics,
+ totalSpanDays: days,
+ newestMeasurementDaysAgo: 0,
+ },
+ measurementSeries,
+ correlations: Array.from({ length: 40 }, (_, i) => ({
+ a: `METRIC_${i % metrics}`,
+ b: `METRIC_${(i + 1) % metrics}`,
+ r: Math.round(((i % 7) / 10 - 0.3) * 100) / 100,
+ n: 30 + i,
+ })),
+ };
+}
+
+const FIXTURES: Array<[number, number]> = [];
+for (const metrics of [1, 2, 3, 4, 6, 8, 10, 16]) {
+ for (const days of [14, 30, 90, 200, 400, 1000]) {
+ FIXTURES.push([metrics, days]);
+ }
+}
+
+/** The shed stages `degradeStatusSnapshotToBudget` walks, in order. */
+function stages(snap: Record): Record[] {
+ const out: Record[] = [structuredClone(snap)];
+ const drop = (node: unknown, field: string): void => {
+ if (Array.isArray(node)) return node.forEach((n) => drop(n, field));
+ if (node && typeof node === "object") {
+ const obj = node as Record;
+ for (const key of Object.keys(obj)) {
+ if (key === field && Array.isArray(obj[key])) delete obj[key];
+ else drop(obj[key], field);
+ }
+ }
+ };
+ const tails = (node: unknown): void => {
+ if (Array.isArray(node)) return node.forEach(tails);
+ if (node && typeof node === "object") {
+ const obj = node as Record;
+ for (const key of Object.keys(obj)) {
+ const value = obj[key];
+ if (Array.isArray(value) && key !== "recent" && value.length > 30) {
+ obj[key] = value.slice(-30);
+ }
+ tails(obj[key]);
+ }
+ }
+ };
+ let current = structuredClone(snap);
+ drop(current, "yearly");
+ out.push(structuredClone(current));
+ drop(current, "weekly");
+ out.push(structuredClone(current));
+ tails(current);
+ out.push(structuredClone(current));
+ current = structuredClone(current);
+ drop(current, "recent");
+ out.push(current);
+ return out;
+}
+
+describe("compact status snapshots (#1217)", () => {
+ it.each(FIXTURES)(
+ "parses back to the same object (%i metrics, %i days)",
+ (metrics, days) => {
+ const snap = snapshot(metrics, days);
+ const compact = JSON.stringify(snap);
+ expect(JSON.parse(compact)).toEqual(
+ JSON.parse(JSON.stringify(snap, null, 2)),
+ );
+ expect(compact.length).toBeLessThan(JSON.stringify(snap, null, 2).length);
+ },
+ );
+
+ it("fits every stage that fitted the old pretty cap under the new compact cap", () => {
+ let checked = 0;
+ for (const [metrics, days] of FIXTURES) {
+ for (const stage of stages(snapshot(metrics, days))) {
+ if (JSON.stringify(stage, null, 2).length > PRETTY_CAP_BEFORE) continue;
+ checked += 1;
+ expect(JSON.stringify(stage).length).toBeLessThanOrEqual(
+ MAX_SNAPSHOT_CHARS,
+ );
+ }
+ }
+ // The property is only worth something if it saw real cases near the cap.
+ expect(checked).toBeGreaterThan(40);
+ });
+
+ it("sheds nothing from a snapshot that fitted before", () => {
+ let fitted = 0;
+ for (const [metrics, days] of FIXTURES) {
+ const snap = snapshot(metrics, days);
+ if (JSON.stringify(snap, null, 2).length > PRETTY_CAP_BEFORE) continue;
+ fitted += 1;
+ const before = structuredClone(snap);
+ expect(degradeStatusSnapshotToBudget(snap)).toEqual([]);
+ expect(snap).toEqual(before);
+ }
+ expect(fitted).toBeGreaterThan(5);
+ });
+
+ it("still sheds a snapshot far over the cap, coarsest-last", () => {
+ const snap = snapshot(16, 1000);
+ const size = JSON.stringify(snap).length;
+ const shed = degradeStatusSnapshotToBudget(snap);
+ expect(shed[0]).toBe("yearly");
+ expect(JSON.stringify(snap).length).toBeLessThan(size);
+ });
+});
diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts
index 5eb22ab81..e9accd527 100644
--- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts
+++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts
@@ -350,7 +350,7 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => {
expect(outcome).toEqual({ status: "generated", providerType: "openai" });
const prompt = JSON.stringify(runRawCompletionWithFallback.mock.calls[0]);
- expect(prompt).toContain('\\"unit\\": \\"lb\\"');
+ expect(prompt).toContain('\\"unit\\":\\"lb\\"');
expect(prompt).toContain("179.5");
expect(prompt).not.toContain("81.4");
diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts
index 20b9e1887..17ca5636f 100644
--- a/src/lib/insights/biomarker-status.ts
+++ b/src/lib/insights/biomarker-status.ts
@@ -315,7 +315,7 @@ export async function generateBiomarkerStatus(args: {
value: r.value,
})),
};
- const snapshotJson = JSON.stringify(snapshot, null, 2);
+ const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts
index 4057590a4..59e2c9c70 100644
--- a/src/lib/insights/blood-pressure-status.ts
+++ b/src/lib/insights/blood-pressure-status.ts
@@ -642,7 +642,7 @@ export async function prepareBloodPressureStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record,
);
- const snapshotJson = JSON.stringify(snapshot, null, 2);
+ const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts
index 9c9808185..7257869dd 100644
--- a/src/lib/insights/bmi-status.ts
+++ b/src/lib/insights/bmi-status.ts
@@ -335,7 +335,7 @@ export async function prepareBmiStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record,
);
- const snapshotJson = JSON.stringify(snapshot, null, 2);
+ const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts
index cd396bfc3..824681e30 100644
--- a/src/lib/insights/comprehensive-generate.ts
+++ b/src/lib/insights/comprehensive-generate.ts
@@ -965,7 +965,9 @@ export async function generateComprehensiveInsight(
const compactFeatures = compactSections(
features as unknown as Record,
);
- const featuresJson = JSON.stringify(compactFeatures, null, 2);
+ // Compact on the wire: indentation is about 40% of a pretty-printed
+ // payload, and the model reads the structure without it (#1217).
+ const featuresJson = JSON.stringify(compactFeatures);
// v1.16.8 — content-hash gate. When the compacted feature snapshot is
// byte-equivalent (modulo clock-relative offsets, see snapshot-hash.ts)
diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts
index 7d1acb045..bf29989e6 100644
--- a/src/lib/insights/derived/derived-assessment-ai.ts
+++ b/src/lib/insights/derived/derived-assessment-ai.ts
@@ -133,11 +133,12 @@ function scoreUserPrompt(
/** v1.30.3 (QA F5) — the user's own IANA tz; was hardcoded "Europe/Berlin". */
tz: string,
): string {
- const snapshot = JSON.stringify(
- { promptVersion: PROMPT_VERSION, generatedForDay: todayKey, band, signal },
- null,
- 2,
- );
+ const snapshot = JSON.stringify({
+ promptVersion: PROMPT_VERSION,
+ generatedForDay: todayKey,
+ band,
+ signal,
+ });
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (${tz})
OPENER HINT: ${openerHint}
diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts
index de8420700..837e0339a 100644
--- a/src/lib/insights/general-status.ts
+++ b/src/lib/insights/general-status.ts
@@ -419,7 +419,7 @@ export async function prepareGeneralStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record,
);
- const snapshotJson = JSON.stringify(snapshot, null, 2);
+ const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts
index ed50332bb..c322381bf 100644
--- a/src/lib/insights/graded-series.ts
+++ b/src/lib/insights/graded-series.ts
@@ -682,17 +682,25 @@ export function convertGradedSeries(
}
/**
- * Assembled-snapshot soft char cap, mirroring the Coach's
- * `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the
- * pretty-printed form). The status snapshots had no cap at all; a
- * many-metric `general-status` could balloon past 100 K tokens.
+ * Assembled-snapshot soft char cap, measured against the COMPACT JSON the
+ * prompt ships (`JSON.stringify(snapshot)`). The status snapshots had no cap
+ * at all once; a many-metric `general-status` could balloon past 100 K
+ * tokens.
+ *
+ * Until v1.43 the cap was 24 000 characters of pretty-printed JSON. The
+ * prompt is compact now, and a graded series loses about 58% of its
+ * characters to indentation (compact is ~0.42 of pretty across one to sixteen
+ * metrics and thirty to a thousand days), so 24 000 pretty characters are
+ * ~10 100 compact ones. 14 000 keeps every snapshot that fitted before
+ * fitting now, with room for the string-heavier blocks whose ratio is higher:
+ * the model sees at least the same data for fewer tokens, never less.
*/
-export const MAX_SNAPSHOT_CHARS = 24_000;
+export const MAX_SNAPSHOT_CHARS = 14_000;
/**
* Walk an arbitrary snapshot object and, while it exceeds
- * `MAX_SNAPSHOT_CHARS` (measured against the pretty-printed form the
- * prompt ships), shed the lowest-signal slices in order:
+ * `MAX_SNAPSHOT_CHARS` (measured against the compact form the prompt
+ * ships), shed the lowest-signal slices in order:
* 1. drop every graded `yearly` array,
* 2. drop every graded `weekly` array,
* 3. truncate any other array to its tail `arrayTailCap` entries
@@ -709,7 +717,7 @@ export function degradeStatusSnapshotToBudget(
arrayTailCap = 30,
): string[] {
const shed: string[] = [];
- const size = () => JSON.stringify(snapshot, null, 2).length;
+ const size = () => JSON.stringify(snapshot).length;
if (size() <= MAX_SNAPSHOT_CHARS) return shed;
const walk = (
diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts
index 299b97a64..7b6846d3f 100644
--- a/src/lib/insights/medication-compliance-status.ts
+++ b/src/lib/insights/medication-compliance-status.ts
@@ -406,7 +406,7 @@ export async function prepareMedicationComplianceStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record,
);
- const snapshotJson = JSON.stringify(snapshot, null, 2);
+ const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts
index 8ee1c927c..26b5748ab 100644
--- a/src/lib/insights/metric-status.ts
+++ b/src/lib/insights/metric-status.ts
@@ -454,7 +454,7 @@ export async function generateMetricStatus(args: {
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record,
);
- const snapshotJson = JSON.stringify(snapshot, null, 2);
+ const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts
index 4c787f040..53b4d06dd 100644
--- a/src/lib/insights/mood-status.ts
+++ b/src/lib/insights/mood-status.ts
@@ -665,7 +665,7 @@ export async function prepareMoodStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record,
);
- const snapshotJson = JSON.stringify(snapshot, null, 2);
+ const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts
index b6143d2e1..1d1901f91 100644
--- a/src/lib/insights/pulse-status.ts
+++ b/src/lib/insights/pulse-status.ts
@@ -392,7 +392,7 @@ export async function preparePulseStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record,
);
- const snapshotJson = JSON.stringify(snapshot, null, 2);
+ const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts
index 7bc94bef0..59d2d58e4 100644
--- a/src/lib/insights/weight-status.ts
+++ b/src/lib/insights/weight-status.ts
@@ -608,7 +608,7 @@ export async function prepareWeightStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record,
);
- const snapshotJson = JSON.stringify(snapshot, null, 2);
+ const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
From e573f28ae89a54d6d9bfa198094a3d2b029a1e2e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 15 Oct 2026 22:14:20 +0200
Subject: [PATCH 3/9] perf(coach): keep the system prompt stable across turns
and rounds
The opener hint, the first-name clause, resolved follow-ups, the table rules
and the final-round line used to be written into the system prompt, so the
cached prefix (tools plus system) was rewritten on every turn and again on
the forced final round. They travel on the user side now: the per-turn lines
in the user message, the table rules as a note after the round that produced
the first table, the final-round reason as the last user line. The system
prompt depends on the locale, the Coach preferences and the about-me text
only.
Refs #1217
---
.../insights/chat/__tests__/dialog-harness.ts | 12 ++
.../chat/__tests__/golden-transcripts.test.ts | 31 +++-
.../__tests__/route-cancelled-turn.test.ts | 1 +
.../__tests__/route-capability-gate.test.ts | 1 +
.../chat/__tests__/route-fence-guard.test.ts | 1 +
.../chat/__tests__/route-followups.test.ts | 36 ++---
.../__tests__/route-guard-activation.test.ts | 1 +
.../chat/__tests__/route-guard-ledger.test.ts | 1 +
.../chat/__tests__/route-guard.test.ts | 1 +
.../chat/__tests__/route-list-query.test.ts | 1 +
.../__tests__/route-snapshot-once.test.ts | 1 +
.../chat/__tests__/route-tool-mode.test.ts | 1 +
.../__tests__/route-workout-scope.test.ts | 1 +
.../ai/coach/__tests__/stable-prefix.test.ts | 108 ++++++++++++++
.../coach/__tests__/tools-loop-budget.test.ts | 19 ++-
src/lib/ai/coach/chat-request-builder.ts | 9 +-
.../__tests__/loop-result-tables.test.ts | 46 ++++--
src/lib/ai/coach/system-prompt.ts | 55 ++++---
src/lib/ai/coach/tools/loop.ts | 41 ++++--
.../__tests__/model-stable-prefix.test.ts | 136 ++++++++++++++++++
src/lib/ai/coach/turn/addenda/index.ts | 13 ++
src/lib/ai/coach/turn/context.ts | 22 ++-
src/lib/ai/coach/turn/model.ts | 75 ++++++----
23 files changed, 512 insertions(+), 101 deletions(-)
create mode 100644 src/lib/ai/coach/__tests__/stable-prefix.test.ts
create mode 100644 src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts
diff --git a/src/app/api/insights/chat/__tests__/dialog-harness.ts b/src/app/api/insights/chat/__tests__/dialog-harness.ts
index c9b2dc079..b1f2cc60c 100644
--- a/src/app/api/insights/chat/__tests__/dialog-harness.ts
+++ b/src/app/api/insights/chat/__tests__/dialog-harness.ts
@@ -68,6 +68,18 @@ export interface ProviderCall {
}
export const providerCalls: ProviderCall[] = [];
+/**
+ * Everything one provider call carried on the user side. Since #1217 a turn's
+ * own lines (a resolved follow-up, an answered clarification, a continuation)
+ * travel here, never in the system prompt, so the cached prefix holds.
+ */
+export function userSide(call: ProviderCall): string {
+ return call.messages
+ .filter((msg) => msg.role === "user")
+ .map((msg) => msg.content)
+ .join("\n\n");
+}
+
/** The last assistant message of the stored conversation. */
export const LAST_ASSISTANT_ID = "m-a1";
export const CONVERSATION_ID = "c-dialog";
diff --git a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts
index 1703970b6..821db4dee 100644
--- a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts
+++ b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts
@@ -113,6 +113,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: h.m.getCoachSystemPrompt,
+ buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: h.m.getSelfContextTextForUser,
@@ -652,21 +653,34 @@ describe("coach chat golden transcripts", () => {
expect(transcript.thrown).toBeNull();
expect(m.runStreamingRawCompletionWithFallback).toHaveBeenCalledTimes(1);
const { params } = m.runStreamingRawCompletionWithFallback.mock
- .calls[0][0] as { params: { system: string } };
- expect(params.system).toContain(CLARIFIED);
+ .calls[0][0] as {
+ params: { system: string; messages: Array<{ content: string }> };
+ };
+ // #1217 — on the user side, never in the cached system prompt.
+ const user = params.messages.map((msg) => msg.content).join("\n");
+ expect(user).toContain(CLARIFIED);
+ expect(params.system).not.toContain(CLARIFIED);
// Server-authored: the request's choice id picks the stored value,
// the label the model once wrote never rides along.
- expect(params.system).not.toContain("Resting pulse");
+ const line = user
+ .split("\n")
+ .find((text) => text.startsWith("CLARIFIED:"));
+ expect(line).toContain(CLARIFIED);
+ expect(line).not.toContain("Resting pulse");
});
it("the tool-mode prompt carries the same line", async () => {
reply("Your resting pulse averaged 62 bpm.");
const transcript = await runTurn(post, body, {});
expect(transcript.thrown).toBeNull();
- const { system } = m.runCoachToolLoop.mock.calls[0][0] as {
+ const { system, messages } = m.runCoachToolLoop.mock.calls[0][0] as {
system: string;
+ messages: Array<{ content: string }>;
};
- expect(system).toContain(CLARIFIED);
+ expect(messages.map((msg) => msg.content).join("\n")).toContain(
+ CLARIFIED,
+ );
+ expect(system).not.toContain(CLARIFIED);
});
it("an unanswered turn sends neither prompt the line", async () => {
@@ -680,8 +694,13 @@ describe("coach chat golden transcripts", () => {
{},
);
const { params } = m.runStreamingRawCompletionWithFallback.mock
- .calls[0][0] as { params: { system: string } };
+ .calls[0][0] as {
+ params: { system: string; messages: Array<{ content: string }> };
+ };
expect(params.system).not.toContain("CLARIFIED:");
+ expect(
+ params.messages.map((msg) => msg.content).join("\n"),
+ ).not.toContain("CLARIFIED:");
});
});
});
diff --git a/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts
index e76d8a5e2..a945487be 100644
--- a/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts
+++ b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts
@@ -118,6 +118,7 @@ vi.mock("@/lib/ai/coach/refusal", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
+ buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
diff --git a/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts b/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts
index ea52a5815..18ca5598c 100644
--- a/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts
+++ b/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts
@@ -116,6 +116,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(),
+ buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(),
diff --git a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts
index 203fde324..f966c86c3 100644
--- a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts
+++ b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts
@@ -103,6 +103,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
+ buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
diff --git a/src/app/api/insights/chat/__tests__/route-followups.test.ts b/src/app/api/insights/chat/__tests__/route-followups.test.ts
index fbfc92f84..b4490a3d5 100644
--- a/src/app/api/insights/chat/__tests__/route-followups.test.ts
+++ b/src/app/api/insights/chat/__tests__/route-followups.test.ts
@@ -265,7 +265,7 @@ describe("a reuse chip", () => {
const run = await tap("f3");
expect(run.status).toBe(200);
expect(annotated("coach.followUp.reuse_unavailable")).toHaveLength(1);
- expect(providerCalls[0].system).toContain(
+ expect(h.userSide(providerCalls[0])).toContain(
"Call show_result with ref m1.r1 and view chart",
);
});
@@ -282,15 +282,16 @@ describe("a model chip", () => {
anchor: { domain: "glucose", window: "allTime" },
});
expect(run.status).toBe(200);
- const system = providerCalls[0].system;
- expect(system).toContain(
+ const user = h.userSide(providerCalls[0]);
+ expect(user).toContain(
"FOLLOW-UP: the person tapped the previous_period chip",
);
- expect(system).toContain(
+ expect(user).toContain(
"get_metric_table metric=bp window=last30days period=previous granularity=week",
);
- expect(system).toContain("m1.r1");
- expect(system).not.toContain("glucose window=allTime");
+ expect(user).toContain("m1.r1");
+ expect(user).not.toContain("glucose window=allTime");
+ expect(providerCalls[0].system).not.toContain("FOLLOW-UP:");
});
it("degrades a chip that is not on the latest reply to a plain message", async () => {
@@ -303,7 +304,7 @@ describe("a model chip", () => {
});
expect(run.status).toBe(200);
expect(providerCalls).toHaveLength(1);
- expect(providerCalls[0].system).not.toContain("FOLLOW-UP:");
+ expect(h.userSide(providerCalls[0])).not.toContain("FOLLOW-UP:");
expect(annotated("coach.followUp.stale")).toEqual([
expect.objectContaining({ meta: { reason: "not_latest" } }),
]);
@@ -313,7 +314,7 @@ describe("a model chip", () => {
conversationWithChips();
world.script = [{ text: "Here is what I see." }];
await tap("f9");
- expect(providerCalls[0].system).not.toContain("FOLLOW-UP:");
+ expect(h.userSide(providerCalls[0])).not.toContain("FOLLOW-UP:");
expect(annotated("coach.followUp.stale")).toEqual([
expect.objectContaining({ meta: { reason: "unknown_chip" } }),
]);
@@ -343,16 +344,17 @@ describe("a model chip", () => {
{ text: "Having looked further, the readings stay level." },
];
await tap("f1");
- const system = providerCalls[0].system;
- expect(system).toContain(
+ const user = h.userSide(providerCalls[0]);
+ expect(user).toContain(
"CONTINUE: the person asked you to keep looking. The unfinished question is their message before that request, in CONVERSATION.",
);
- // The question stays the person's own turn; it never reaches the
- // system role.
- expect(system).not.toContain("How was my blood pressure last month?");
- expect(system).toContain(
+ expect(user).toContain(
"Already fetched: get_metric_table(bp, last30days, week) → m1.r1",
);
+ // The system role carries neither the line nor the question (#1217).
+ const { system } = providerCalls[0];
+ expect(system).not.toContain("CONTINUE:");
+ expect(system).not.toContain("How was my blood pressure last month?");
});
});
@@ -373,7 +375,7 @@ describe("an answered question", () => {
clarification: { messageId: h.LAST_ASSISTANT_ID, choiceId: "c2" },
});
expect(run.status).toBe(200);
- expect(providerCalls[0].system).toContain(
+ expect(h.userSide(providerCalls[0])).toContain(
"CLARIFIED: the person answered your clarifying question by choosing metric=resting_hr.",
);
});
@@ -385,7 +387,7 @@ describe("an answered question", () => {
message: "The one from my watch",
clarification: { messageId: h.LAST_ASSISTANT_ID },
});
- expect(providerCalls[0].system).toContain(
+ expect(h.userSide(providerCalls[0])).toContain(
"CLARIFIED: the person answered your clarifying question in their own words",
);
});
@@ -397,7 +399,7 @@ describe("an answered question", () => {
message: "Resting heart rate",
clarification: { messageId: "m-older", choiceId: "c2" },
});
- expect(providerCalls[0].system).not.toContain("CLARIFIED:");
+ expect(h.userSide(providerCalls[0])).not.toContain("CLARIFIED:");
expect(annotated("coach.clarification.stale")).toHaveLength(1);
});
diff --git a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts
index d63bc8a7f..182861065 100644
--- a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts
+++ b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts
@@ -154,6 +154,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
+ buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
diff --git a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts
index 4f455a742..c692f5205 100644
--- a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts
+++ b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts
@@ -147,6 +147,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
+ buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
diff --git a/src/app/api/insights/chat/__tests__/route-guard.test.ts b/src/app/api/insights/chat/__tests__/route-guard.test.ts
index b47f19163..97d5c793f 100644
--- a/src/app/api/insights/chat/__tests__/route-guard.test.ts
+++ b/src/app/api/insights/chat/__tests__/route-guard.test.ts
@@ -127,6 +127,7 @@ vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal }));
// NB: the outbound guard + numeric verifier are REAL — that is the point.
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
+ buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
diff --git a/src/app/api/insights/chat/__tests__/route-list-query.test.ts b/src/app/api/insights/chat/__tests__/route-list-query.test.ts
index 59beef5cf..7a221034f 100644
--- a/src/app/api/insights/chat/__tests__/route-list-query.test.ts
+++ b/src/app/api/insights/chat/__tests__/route-list-query.test.ts
@@ -109,6 +109,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(),
+ buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(),
diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts
index afe28acfc..713dfcc5c 100644
--- a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts
+++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts
@@ -120,6 +120,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
+ buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts
index 19749f312..fcb3028b6 100644
--- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts
+++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts
@@ -153,6 +153,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
+ buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
diff --git a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts
index 10081bc48..28a98783e 100644
--- a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts
+++ b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts
@@ -193,6 +193,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
+ buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
diff --git a/src/lib/ai/coach/__tests__/stable-prefix.test.ts b/src/lib/ai/coach/__tests__/stable-prefix.test.ts
new file mode 100644
index 000000000..dcc7a7eb3
--- /dev/null
+++ b/src/lib/ai/coach/__tests__/stable-prefix.test.ts
@@ -0,0 +1,108 @@
+/**
+ * #1217 — the Coach's cached prefix (tools plus system prompt) is the same
+ * string on every turn of a conversation. What changes per turn (the opener
+ * hint, the name clause, a resolved follow-up, the table rules once a table
+ * exists) travels in the user message, so a provider's prompt cache holds
+ * from one turn to the next instead of being rewritten every time.
+ */
+import { createHash } from "node:crypto";
+
+import { describe, expect, it } from "vitest";
+
+import { buildCoachToolRequest } from "@/lib/ai/coach/chat-request-builder";
+import {
+ buildCoachTurnNotes,
+ getCoachSystemPrompt,
+} from "@/lib/ai/coach/system-prompt";
+import { buildToolModeAddendum } from "@/lib/ai/coach/tools/system-addendum";
+import {
+ buildDialogAddenda,
+ buildTableRulesAddendum,
+} from "@/lib/ai/coach/turn/addenda";
+import {
+ openerArchetypeHint,
+ shouldUseNameForTurn,
+} from "@/lib/ai/prompts/opener-archetype";
+import type { Locale } from "@/lib/i18n/config";
+
+const sha = (text: string) => createHash("sha256").update(text).digest("hex");
+
+/** One turn's request, assembled the way the turn model assembles it. */
+function turnRequest(locale: Locale, turnIndex: number, hints: string[]) {
+ const systemPrompt = getCoachSystemPrompt(locale, undefined, "I run.");
+ const turnNotes = buildCoachTurnNotes(locale, {
+ firstName: "Alex",
+ mayUseName: shouldUseNameForTurn(`u1:${turnIndex}`),
+ openerHint: openerArchetypeHint(`u1:${turnIndex}`, locale),
+ });
+ const tables = turnIndex > 1 ? [buildTableRulesAddendum(locale)] : [];
+ return buildCoachToolRequest({
+ systemPrompt,
+ toolModeAddendum: `${buildToolModeAddendum(locale)}\n\n${buildDialogAddenda(
+ locale,
+ { tableRules: false },
+ )}`,
+ focusHint: "",
+ workoutEvidence: null,
+ dataInventory: "DATA INVENTORY\n- pulse",
+ guidedBlock: "",
+ transcript: `user: question ${turnIndex}`,
+ languageName: "English",
+ turnNotes: [...tables, turnNotes, ...hints]
+ .filter((block) => block.length > 0)
+ .join("\n\n"),
+ });
+}
+
+describe("stable Coach prefix (#1217)", () => {
+ it.each(["en", "de", "fr", "ko"] as Locale[])(
+ "hashes the system prompt identically on every turn (%s)",
+ (locale) => {
+ const hashes = new Set();
+ for (let turn = 0; turn < 12; turn++) {
+ const request = turnRequest(
+ locale,
+ turn,
+ turn % 3 === 0 ? [`FOLLOW-UP: earlier answer ${turn}`] : [],
+ );
+ hashes.add(sha(request.system));
+ }
+ expect(hashes.size).toBe(1);
+ },
+ );
+
+ it("carries the per-turn lines in the user message instead", () => {
+ const opener = openerArchetypeHint("u1:4", "en");
+ const request = turnRequest("en", 4, ["FOLLOW-UP: the pulse table"]);
+ const user = String(request.messages[0]!.content);
+ expect(user).toContain(opener);
+ expect(user).toContain("FOLLOW-UP: the pulse table");
+ expect(user).toContain(buildTableRulesAddendum("en"));
+ expect(request.system).not.toContain(opener);
+ expect(request.system).not.toContain("FOLLOW-UP");
+ expect(request.system).not.toContain(buildTableRulesAddendum("en"));
+ });
+
+ it("keeps the opener hint and the name out of the system prompt", () => {
+ for (const locale of ["en", "de"] as Locale[]) {
+ const system = getCoachSystemPrompt(locale, undefined, null);
+ expect(system).not.toMatch(
+ /OPENER HINT for this turn|OPENER-HINWEIS für diesen Turn/,
+ );
+ expect(system).not.toContain("Alex");
+ }
+ });
+
+ it("names the turn's opener and, on a gated turn, the first name", () => {
+ const notes = buildCoachTurnNotes("en", {
+ firstName: "Alex",
+ mayUseName: true,
+ openerHint: "Lead with the finding.",
+ });
+ expect(notes).toContain(
+ "OPENER HINT for this turn: Lead with the finding.",
+ );
+ expect(notes).toContain('the user\'s first name is "Alex"');
+ expect(buildCoachTurnNotes("en", {})).toBe("");
+ });
+});
diff --git a/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts b/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts
index d208abff1..c39c6f4b5 100644
--- a/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts
+++ b/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts
@@ -105,11 +105,24 @@ function lastParams() {
const calls = runRawCompletionWithFallback.mock.calls;
return calls[calls.length - 1][0].params as {
system: string;
+ messages: Array<{ role: string; content: unknown }>;
toolChoice?: string;
tools?: unknown[];
};
}
+/**
+ * The final round's reason, as the model reads it: the last user-side line.
+ * #1217 — it no longer rides the system prompt, which stays the cached
+ * prefix every round of the turn shares.
+ */
+function finalRoundLine(): string {
+ const last = lastParams().messages.at(-1);
+ expect(last?.role).toBe("user");
+ expect(lastParams().system).toBe(baseArgs.system);
+ return String(last?.content);
+}
+
beforeEach(() => {
emitSignal.mockReset();
executeCoachTool.mockReset();
@@ -138,9 +151,7 @@ describe("stop reasons", () => {
// The tools stay on the final round: the history holds the calls, and
// Anthropic refuses tool_use/tool_result blocks without `tools` (400).
expect(lastParams().tools).toEqual(COACH_TOOL_DEFS);
- expect(lastParams().system).toMatch(
- /FINAL ROUND: this turn's token budget/,
- );
+ expect(finalRoundLine()).toMatch(/FINAL ROUND: this turn's token budget/);
expect(out.result.content).toBe("Answer from what I have.");
});
@@ -209,7 +220,7 @@ describe("stop reasons", () => {
expect(out.stop).toEqual({ reason: "no_progress", rounds: 3 });
expect(out.forcedFinal).toBe(false);
expectFinalRoundKeepsTools();
- expect(lastParams().system).toMatch(/brought nothing new/);
+ expect(finalRoundLine()).toMatch(/brought nothing new/);
});
});
diff --git a/src/lib/ai/coach/chat-request-builder.ts b/src/lib/ai/coach/chat-request-builder.ts
index 1a892b46e..91ea7e03a 100644
--- a/src/lib/ai/coach/chat-request-builder.ts
+++ b/src/lib/ai/coach/chat-request-builder.ts
@@ -148,6 +148,13 @@ export function buildCoachToolRequest(args: {
guidedBlock: string;
transcript: string;
languageName: string;
+ /**
+ * v1.43 (#1217) — what changes from one turn to the next: the opener hint,
+ * the name clause, a resolved follow-up, the table rules once a table
+ * exists. Carried here, on the user side, so `system` is the same string
+ * on every turn and every round. Empty or absent: nothing is added.
+ */
+ turnNotes?: string;
}): { system: string; messages: AiMessage[] } {
const focusBlock = args.focusHint ? `${args.focusHint}\n\n` : "";
const workoutDataBlock =
@@ -160,7 +167,7 @@ ${fenceHealthData(JSON.stringify({ thisWorkout: args.workoutEvidence }))}
const messages: AiMessage[] = [
{
role: "user",
- content: `${focusBlock}${workoutDataBlock}${args.memoryBlock ? `${args.memoryBlock}\n\n` : ""}${args.dataInventory}${args.priorResults ? `\n\n${args.priorResults}` : ""}${args.guidedBlock}
+ content: `${focusBlock}${workoutDataBlock}${args.memoryBlock ? `${args.memoryBlock}\n\n` : ""}${args.dataInventory}${args.priorResults ? `\n\n${args.priorResults}` : ""}${args.guidedBlock}${args.turnNotes ? `\n\n${args.turnNotes}` : ""}
CONVERSATION
${args.transcript}
diff --git a/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts b/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts
index 780b1281f..1e392f059 100644
--- a/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts
+++ b/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts
@@ -99,7 +99,7 @@ describe("runCoachToolLoop — result tables", () => {
expect(JSON.stringify(out.toolResults)).not.toContain("987654");
});
- it("adds the table rules to the rounds after the first table, not before", async () => {
+ it("adds the table rules to the rounds after the first table, not before, without touching the system prompt", async () => {
executeCoachTool
.mockResolvedValueOnce({ present: true, data: { n: 1 } })
.mockResolvedValueOnce({
@@ -113,16 +113,27 @@ describe("runCoachToolLoop — result tables", () => {
name,
arguments: '{"metric":"pulse"}',
});
- runRawCompletionWithFallback
- .mockResolvedValueOnce(round("", [call("a", "get_metric_series")]))
- .mockResolvedValueOnce(round("", [call("b", "get_metric_table")]))
- .mockResolvedValueOnce(round("answer"));
+ // The loop hands every round the same growing message array, so record
+ // what each round saw at the moment it was called.
+ const seen: Array<{ system: string; messages: unknown[] }> = [];
+ const replies = [
+ round("", [call("a", "get_metric_series")]),
+ round("", [call("b", "get_metric_table")]),
+ round("answer"),
+ ];
+ runRawCompletionWithFallback.mockImplementation(async (arg: unknown) => {
+ const { params } = arg as {
+ params: { system: string; messages: unknown[] };
+ };
+ seen.push({ system: params.system, messages: [...params.messages] });
+ return replies[seen.length - 1];
+ });
await runCoachToolLoop({
userId: "u1",
providers: [],
system: "sys",
- systemOnceTableShown: "sys+tables",
+ tableRulesOnceShown: "TABLE RULES",
messages: [{ role: "user", content: "q" }],
tools: COACH_TOOL_DEFS,
reach: UNBOUNDED_REACH,
@@ -133,9 +144,26 @@ describe("runCoachToolLoop — result tables", () => {
refs: createResultRefAllocator(),
},
});
- const systems = runRawCompletionWithFallback.mock.calls.map(
- ([arg]) => (arg as { params: { system: string } }).params.system,
+ // #1217 — one system prompt for every round, so the cached prefix holds.
+ expect(seen.map((round) => round.system)).toEqual(["sys", "sys", "sys"]);
+ const rulesIn = seen.map((round) =>
+ round.messages.some(
+ (m) =>
+ (m as { role: string; content: unknown }).role === "user" &&
+ (m as { content: unknown }).content === "TABLE RULES",
+ ),
+ );
+ expect(rulesIn).toEqual([false, false, true]);
+ // The rules follow the tool results that produced the table.
+ const last = seen[2]!.messages as Array<{ role: string }>;
+ expect(last.at(-1)).toEqual({ role: "user", content: "TABLE RULES" });
+ expect(last.at(-2)?.role).toBe("tool");
+ // Each round's messages extend the previous round's: a stable prefix.
+ expect(seen[1]!.messages.slice(0, seen[0]!.messages.length)).toEqual(
+ seen[0]!.messages,
+ );
+ expect(seen[2]!.messages.slice(0, seen[1]!.messages.length)).toEqual(
+ seen[1]!.messages,
);
- expect(systems).toEqual(["sys", "sys", "sys+tables"]);
});
});
diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts
index 5ee5600fe..ee5a21713 100644
--- a/src/lib/ai/coach/system-prompt.ts
+++ b/src/lib/ai/coach/system-prompt.ts
@@ -1249,8 +1249,12 @@ const LOCALE_REPLY_FOOTER_FALLBACK: Record<
/**
* v1.22 (W6) — per-turn personalization passed by the chat route. All fields
- * are optional and the addendum is omitted when none apply, so the prompt is
- * byte-identical to the pre-feature one for the coverage test + unnamed users.
+ * are optional and the notes are empty when none apply.
+ *
+ * v1.43 (#1217) — these lines change every turn, so they no longer ride the
+ * system prompt: a system prompt that changed per turn rewrote the provider's
+ * cached prefix (tools plus system, 14 to 19 K tokens) on every turn. They
+ * are composed by {@link buildCoachTurnNotes} and travel in the user message.
*/
export interface CoachPersonalization {
/** The user's first name; null when no display name is set. */
@@ -1261,11 +1265,17 @@ export interface CoachPersonalization {
openerHint?: string | null;
}
+/**
+ * The Coach system prompt. Stable for one person across every turn and every
+ * round of a turn: it depends on the locale, the Coach preferences and the
+ * about-me text only, so the provider's prompt cache holds. Anything that
+ * changes per turn (the opener hint, the name clause, a resolved follow-up)
+ * goes into the user message instead.
+ */
export function getCoachSystemPrompt(
locale: Locale,
prefs: CoachPrefs = DEFAULT_COACH_PREFS,
aboutMe: string | null = null,
- personalization: CoachPersonalization = {},
): string {
let base: string;
if (locale === "de") {
@@ -1313,11 +1323,11 @@ export function getCoachSystemPrompt(
// may only link a slug it lists, so a fabricated /learn URL is impossible.
const withLearn = `${withPrefix}\n\n${learnCatalogPromptBlock()}`;
// v1.22 (W6) — the narrative-quality addendum: the context-layer rule, the
- // single-chart-token exception, the BP usual-range verbatim clause, plus the
- // per-turn opener hint and sparse first-name personalization. Composed after
- // the shared contracts so the chart-token exception can carve out the one
- // place a `metric:` token is permitted.
- const withV122 = `${withLearn}\n\n${buildCoachV122Addendum(locale, personalization)}`;
+ // single-chart-token exception, the BP usual-range verbatim clause. Composed
+ // after the shared contracts so the chart-token exception can carve out the
+ // one place a `metric:` token is permitted. The per-turn opener hint
+ // and name clause are in `buildCoachTurnNotes`.
+ const withV122 = `${withLearn}\n\n${buildCoachV122Addendum(locale)}`;
// v1.15.20 — the user-authored "about me" self-description rides the
// system prompt as a delimited, user-provided block (Settings → AI).
const suffix = aboutMe ? buildAboutMeBlock(locale, aboutMe) : "";
@@ -1338,14 +1348,11 @@ export function getCoachSystemPrompt(
* - BP USUAL RANGE — when the snapshot's blood-pressure block carries a
* `usualRange`, quote THOSE bounds verbatim; never invent a band from window
* means (kills the fabricated "153–150" range).
- * - OPENER HINT — vary the turn's opening so a multi-turn session does not open
- * identically every time.
- * - NAME — sparse, anti-formulaic first-name address on the gated turns only.
+ *
+ * The per-turn opener hint and first-name clause that used to close this
+ * addendum are {@link buildCoachTurnNotes} now.
*/
-function buildCoachV122Addendum(
- locale: Locale,
- personalization: CoachPersonalization,
-): string {
+function buildCoachV122Addendum(locale: Locale): string {
const isDe = instructionLocale(locale) === "de";
const parts: string[] = [];
parts.push(isDe ? "COACH-ZUSATZ (v1.22)" : "COACH ADDENDUM (v1.22)");
@@ -1392,6 +1399,21 @@ function buildCoachV122Addendum(
: `EXPERIMENT REVIEW — when the snapshot carries an "experimentOutcomes" block, read the result back in the user's own numbers, association-only ("looks worth keeping" / "associated with", NEVER "proven" / "it worked" / "caused"). Report a null result honestly ("no measurable change — your call"). If a vital WORSENED during the experiment, do NOT cheerlead it — raise it plainly and route to the doctor / the acute-redflag path. Only ever propose BEHAVIORAL experiments (sleep timing, steps, screen time, meal timing); never a medication / dose / supplement trial — refuse those and defer to the prescriber.`,
);
+ return parts.join("\n\n");
+}
+
+/**
+ * v1.43 (#1217) — the lines that change every Coach turn: the hash-seeded
+ * opener hint and, on the gated turns, the first-name clause. They travel in
+ * the user message so the system prompt stays byte-stable across turns and
+ * the provider's prompt cache holds. Empty when neither applies.
+ */
+export function buildCoachTurnNotes(
+ locale: Locale,
+ personalization: CoachPersonalization,
+): string {
+ const isDe = instructionLocale(locale) === "de";
+ const parts: string[] = [];
if (personalization.openerHint) {
parts.push(
isDe
@@ -1409,7 +1431,8 @@ function buildCoachV122Addendum(
);
}
- return parts.join("\n\n");
+ if (parts.length === 0) return "";
+ return [isDe ? "FÜR DIESEN TURN" : "FOR THIS TURN", ...parts].join("\n\n");
}
/**
diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts
index 2a0c40a82..5fe032389 100644
--- a/src/lib/ai/coach/tools/loop.ts
+++ b/src/lib/ai/coach/tools/loop.ts
@@ -194,10 +194,13 @@ export async function runCoachToolLoop(args: {
providers: ProviderChainResolved[];
system: string;
/**
- * v1.39.4 — the system prompt for the rounds after a call produced a
- * table, when it differs (the table rules ride it).
+ * v1.39.4 — the table rules, for the rounds after a call produced a table.
+ * v1.43 (#1217) — they join the conversation as a user-side note after
+ * that round's tool results instead of switching the system prompt, so the
+ * cached prefix (tools plus system) survives the switch. Absent when the
+ * turn already carries them.
*/
- systemOnceTableShown?: string;
+ tableRulesOnceShown?: string;
/** The conversation messages (history + the new user turn). */
messages: AiMessage[];
tools: AiToolDef[];
@@ -284,7 +287,12 @@ export async function runCoachToolLoop(args: {
const cacheKey = coachPromptCacheKey(turn?.conversationId);
const messages: AiMessage[] = [...args.messages];
- let system = args.system;
+ // v1.43 (#1217) — the system prompt never changes inside a turn: what used
+ // to be swapped or appended into it (the table rules, the final-round line)
+ // goes on the user side, so every round reads the same cached prefix.
+ const system = args.system;
+ let tableRulesPending = args.tableRulesOnceShown;
+ let tableShownThisRound = false;
let totalTokens = 0;
let cachedTokens = 0;
let rounds = 0;
@@ -352,10 +360,15 @@ export async function runCoachToolLoop(args: {
surface: "coach",
ledger,
params: {
- system: stopReason
- ? `${system}\n\n${FINAL_ROUND_LINE[stopReason]}`
- : system,
- messages,
+ system,
+ // The final round says why it is final as the last user-side line,
+ // after everything the earlier rounds cached.
+ messages: stopReason
+ ? [
+ ...messages,
+ { role: "user", content: FINAL_ROUND_LINE[stopReason] },
+ ]
+ : messages,
temperature,
maxTokens,
signal,
@@ -522,9 +535,7 @@ export async function runCoachToolLoop(args: {
// prose against that same summary.
notify(() => onCallSettled?.(call, toolResult, index, round));
const { table, ...forModel } = toolResult;
- if (table && args.systemOnceTableShown) {
- system = args.systemOnceTableShown;
- }
+ if (table) tableShownThisRound = true;
const validArgs =
call.name === COMPARE_SERIES_TOOL_NAME
? compareArgs(call.arguments)
@@ -551,6 +562,14 @@ export async function runCoachToolLoop(args: {
budget.addInput(textTokens(serialised));
messages.push({ role: "tool", toolCallId: call.id, content: serialised });
}
+ // The first table of the turn brings the table rules along, once, right
+ // after the results that produced it.
+ if (tableShownThisRound && tableRulesPending) {
+ messages.push({ role: "user", content: tableRulesPending });
+ budget.addInput(textTokens(tableRulesPending));
+ tableRulesPending = undefined;
+ }
+ tableShownThisRound = false;
budget.addInput(textTokens(result.content ?? ""));
notify(() => onRoundSettled?.(round));
diff --git a/src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts b/src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts
new file mode 100644
index 000000000..1a90f4b14
--- /dev/null
+++ b/src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts
@@ -0,0 +1,136 @@
+/**
+ * #1217 — the turn model sends the same system prompt on every turn. The
+ * per-turn lines (opener hint, name clause, a resolved follow-up) reach the
+ * model on the user side, on the tool path and on the streaming no-tools
+ * path alike, so the provider's cached prefix survives from turn to turn.
+ */
+import { createHash } from "node:crypto";
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const m = vi.hoisted(() => ({
+ loop: vi.fn(),
+ stream: vi.fn(),
+}));
+
+vi.mock("@/lib/db", () => ({ prisma: {} }));
+vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
+vi.mock("@/lib/ai/coach/persistence", () => ({ appendMessage: vi.fn() }));
+vi.mock("@/lib/ai/coach/memory/contract", () => ({
+ buildMemoryContextBlock: vi.fn(async () => null),
+}));
+vi.mock("@/lib/ai/coach/tools/executor", async (importOriginal) => ({
+ ...(await importOriginal
) : null}
+ {usageLevels ? (
+
+ {t("admin.providerHealth.usageLevels", {
+ frugal: groupDigits(usageLevels.frugal),
+ standard: groupDigits(usageLevels.standard),
+ full: groupDigits(usageLevels.full),
+ })}
+
+ ) : null}
{isPending ? (
{t("admin.providerHealth.loading")}
diff --git a/src/components/settings/__tests__/ai-section.test.tsx b/src/components/settings/__tests__/ai-section.test.tsx
index 6c645bde9..d8b62cc74 100644
--- a/src/components/settings/__tests__/ai-section.test.tsx
+++ b/src/components/settings/__tests__/ai-section.test.tsx
@@ -54,6 +54,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
onDeviceAllowed: true,
}),
useAiCapabilityMap: () => null,
+ // v1.43 — no usage level published: the setting stays out of the way.
+ useAiUsage: () => null,
useAiProviderState: () => ({
configured: true,
managedBy: "user",
diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx
index ce6e1f557..7fbfd7891 100644
--- a/src/components/settings/__tests__/sections.test.tsx
+++ b/src/components/settings/__tests__/sections.test.tsx
@@ -54,6 +54,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
onDeviceAllowed: true,
}),
useAiCapabilityMap: () => null,
+ // v1.43 — no usage level published: the setting stays out of the way.
+ useAiUsage: () => null,
useAiProviderState: () => ({
configured: true,
managedBy: "user",
diff --git a/src/components/settings/ai/__tests__/ai-usage-card.test.tsx b/src/components/settings/ai/__tests__/ai-usage-card.test.tsx
new file mode 100644
index 000000000..99cf881fd
--- /dev/null
+++ b/src/components/settings/ai/__tests__/ai-usage-card.test.tsx
@@ -0,0 +1,120 @@
+/**
+ * #1217 — the AI usage setting renders what the server resolved: three
+ * options, the ones above the operator's ceiling disabled with their reason,
+ * each estimate labelled as one, and nothing computed on the client.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import type { AiUsageState } from "@/lib/ai/usage-level";
+
+const state = vi.hoisted(() => ({
+ usage: null as AiUsageState | null,
+ canConfigure: true,
+}));
+
+vi.mock("@/hooks/use-ai-capability", () => ({
+ useAiUsage: () => state.usage,
+ useAiProviderState: () => ({
+ configured: true,
+ managedBy: "user",
+ canConfigure: state.canConfigure,
+ responseTimeoutMs: 60_000,
+ }),
+}));
+
+import { AiUsageCard } from "../ai-usage-card";
+
+function render(usage: AiUsageState | null, canConfigure = true) {
+ state.usage = usage;
+ state.canConfigure = canConfigure;
+ const client = new QueryClient();
+ return renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+}
+
+const CAPPED: AiUsageState = {
+ level: "standard",
+ preference: "full",
+ defaultLevel: "standard",
+ maxLevel: "standard",
+ source: "admin_cap",
+ options: [
+ { level: "frugal", allowed: true, estimatedTokensPerDay: 48_000 },
+ { level: "standard", allowed: true, estimatedTokensPerDay: 100_000 },
+ { level: "full", allowed: false, estimatedTokensPerDay: 400_000 },
+ ],
+ measuredTokensPerDay: 100_000,
+ measuredDays: 7,
+};
+
+describe("AiUsageCard", () => {
+ it("renders nothing on a server that publishes no level", () => {
+ expect(render(null)).toBe("");
+ });
+
+ it("checks the level in effect and disables the ones above the ceiling", () => {
+ const html = render(CAPPED);
+ expect(html.match(/role="radio"/g)).toHaveLength(3);
+ expect(html).toMatch(/aria-checked="true"[^>]*data-level="standard"/);
+ expect(html.match(/aria-checked="true"/g)).toHaveLength(1);
+ expect(html).toContain('data-level="full" data-allowed="false"');
+ expect(html).toContain("Limited by the server operator.");
+ // The capped choice is named and kept.
+ expect(html).toContain('data-slot="ai-usage-capped"');
+ expect(html).toContain("You chose Full");
+ });
+
+ it("shows the server's estimates, labelled, in the reader's number format", () => {
+ const html = render(CAPPED);
+ expect(html).toContain("about 48,000 tokens a day, estimated");
+ expect(html).toContain("about 400,000 tokens a day, estimated");
+ expect(html).toContain(
+ "Measured: 100,000 tokens a day over the last 7 days",
+ );
+ });
+
+ it("says when there is no estimate yet instead of inventing one", () => {
+ const html = render({
+ ...CAPPED,
+ source: "default",
+ preference: null,
+ options: CAPPED.options.map((o) => ({
+ ...o,
+ estimatedTokensPerDay: null,
+ })),
+ measuredTokensPerDay: null,
+ measuredDays: 0,
+ });
+ expect(html).not.toContain("tokens a day, estimated");
+ expect(html).toContain("An estimate per level appears after three days");
+ expect(html).toContain("You follow the server default (Standard).");
+ // No stored choice: nothing to return to.
+ expect(html).not.toContain('data-slot="ai-usage-use-default"');
+ });
+
+ it("offers the way back to the default when a choice is stored", () => {
+ const html = render({
+ ...CAPPED,
+ source: "user",
+ preference: "frugal",
+ level: "frugal",
+ });
+ expect(html).toContain('data-slot="ai-usage-use-default"');
+ expect(html).toContain("Use the server default (Standard)");
+ });
+
+ it("locks the options where the account cannot set its own level", () => {
+ const html = render(CAPPED, false);
+ expect(html).toContain(
+ 'role="radiogroup" aria-label="AI usage" aria-disabled="true"',
+ );
+ });
+});
diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx
index bc2769846..e6e0ebb5b 100644
--- a/src/components/settings/ai/ai-insights-card.tsx
+++ b/src/components/settings/ai/ai-insights-card.tsx
@@ -36,6 +36,7 @@ import {
import { AdminOpenAIProviderForm } from "./admin-openai-provider-form";
import { AiConsentCard } from "./ai-consent-card";
+import { AiUsageCard } from "./ai-usage-card";
import { AnthropicProviderForm } from "./anthropic-provider-form";
import { AutoReadCard } from "./auto-read-card";
import { CentralCodexSwitch } from "./central-codex-switch";
@@ -233,6 +234,10 @@ export function AiInsightsCard({
onSelect={pickProvider}
/>
+ {/* v1.43 (#1217) — how much background AI work the account
+ asks for: one setting, three levels. */}
+
+
{/* v1.22 (#89) — per-user response timeout (mainly for slow
local/self-hosted backends). */}
diff --git a/src/components/settings/ai/ai-usage-card.tsx b/src/components/settings/ai/ai-usage-card.tsx
new file mode 100644
index 000000000..a7817ab78
--- /dev/null
+++ b/src/components/settings/ai/ai-usage-card.tsx
@@ -0,0 +1,233 @@
+"use client";
+
+/* ────────────────────────────────────────────────────────────────
+ * v1.43 (#1217) — AI usage: one setting, three levels.
+ *
+ * The level decides how much background AI work the account asks for: how
+ * often status cards regenerate after a sync, the snapshot size, background
+ * reasoning, the length of the daily briefing, the automatic document read
+ * and the label photo. The Coach is the same at every level.
+ *
+ * Everything shown comes off `ai.usage` on `/api/auth/me`, resolved on the
+ * server: which options the operator allows, the estimate per option (only
+ * ever labelled as one), the measured use. The card computes nothing. A pick
+ * is written at once through `PUT /api/auth/me/ai-usage`; the account payload
+ * is refetched so every surface that reads a capability sees the new level.
+ * ──────────────────────────────────────────────────────────────── */
+
+import { useRef } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Gauge } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { nextChoiceIndex } from "@/components/documents/sources/segmented-choice";
+import { SettingsCard } from "@/components/settings/settings-card";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { useAiProviderState, useAiUsage } from "@/hooks/use-ai-capability";
+import { apiPut } from "@/lib/api/api-fetch";
+import type { AiUsageLevel, AiUsageState } from "@/lib/ai/usage-level";
+import { useTranslations } from "@/lib/i18n/context";
+import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
+import { cn } from "@/lib/utils";
+
+const LEVEL_KEYS = {
+ frugal: {
+ label: "settings.ai.usage.levels.frugal.label",
+ hint: "settings.ai.usage.levels.frugal.hint",
+ },
+ standard: {
+ label: "settings.ai.usage.levels.standard.label",
+ hint: "settings.ai.usage.levels.standard.hint",
+ },
+ full: {
+ label: "settings.ai.usage.levels.full.label",
+ hint: "settings.ai.usage.levels.full.hint",
+ },
+} as const satisfies Record;
+
+export function AiUsageCard() {
+ const { t, locale } = useTranslations();
+ const queryClient = useQueryClient();
+ const usage = useAiUsage();
+ const provider = useAiProviderState();
+ const refs = useRef>([]);
+
+ const save = useMutation({
+ mutationFn: (level) =>
+ apiPut("/api/auth/me/ai-usage", { level }),
+ onSuccess: () => {
+ void invalidateKeys(queryClient, aiInputDependentKeys);
+ },
+ });
+
+ // An older server publishes no level: there is nothing to set.
+ if (!usage) return null;
+
+ // Only the account itself sets its level, and only while the operator's
+ // master switch is on (`canConfigure` is false inside somebody else's
+ // record and while AI is off on the server).
+ const editable = provider.canConfigure && !save.isPending;
+ const tokens = (value: number) => new Intl.NumberFormat(locale).format(value);
+ const levelName = (level: AiUsageLevel) => t(LEVEL_KEYS[level].label);
+ const allowedLevels = usage.options
+ .filter((option) => option.allowed)
+ .map((option) => option.level);
+
+ function pick(level: AiUsageLevel) {
+ if (!editable || level === usage?.level) return;
+ save.mutate(level);
+ }
+
+ function onKeyDown(
+ event: React.KeyboardEvent,
+ level: AiUsageLevel,
+ ) {
+ const from = allowedLevels.indexOf(level);
+ const next = nextChoiceIndex(event.key, from, allowedLevels.length);
+ if (next === null) return;
+ event.preventDefault();
+ const target = allowedLevels[next]!;
+ pick(target);
+ refs.current[usage!.options.findIndex((o) => o.level === target)]?.focus();
+ }
+
+ return (
+
+
+
+
+ {usage.options.map((option, i) => {
+ const checked = option.level === usage.level;
+ const disabled = !option.allowed || !editable;
+ return (
+
+ );
+ })}
+
+
+
+ {usage.measuredTokensPerDay === null || usage.measuredDays < 3
+ ? t("settings.ai.usage.noEstimate")
+ : t("settings.ai.usage.measured", {
+ tokens: tokens(usage.measuredTokensPerDay),
+ days: usage.measuredDays,
+ })}
+
+
+ {usage.source === "admin_cap" && usage.preference ? (
+
+ {t("settings.ai.usage.capped", {
+ choice: levelName(usage.preference),
+ level: levelName(usage.level),
+ })}
+
+ ) : usage.source === "default" ? (
+
+ {t("settings.ai.usage.followsDefault", {
+ level: levelName(usage.defaultLevel),
+ })}
+
+ ) : null}
+
+ {save.isError ? (
+
+ {t("settings.ai.errorGeneric")}
+
+ ) : null}
+
+ {usage.preference !== null ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/settings/ai/auto-read-card.tsx b/src/components/settings/ai/auto-read-card.tsx
index 0df49eaed..e8262693b 100644
--- a/src/components/settings/ai/auto-read-card.tsx
+++ b/src/components/settings/ai/auto-read-card.tsx
@@ -24,7 +24,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Switch } from "@/components/ui/switch";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
-import { useAiCapability } from "@/hooks/use-ai-capability";
+import { useAiCapability, useAiUsage } from "@/hooks/use-ai-capability";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -41,6 +41,9 @@ export function AutoReadCard() {
// already-on preference off stays possible.
const documentAi = useAiCapability("documentAi");
const operatorOff = documentAi.reason === "operator_disabled";
+ // v1.43 (#1217) — on the frugal AI usage level the switch rests: documents
+ // are read only on request. The choice itself is kept.
+ const restingFrugal = useAiUsage()?.level === "frugal";
// Whether the honesty confirm is currently shown (user flipped the switch ON
// but has not yet acknowledged the trade). Off→on reveals it; the write only
@@ -115,6 +118,15 @@ export function AutoReadCard() {
) : null}
+ {restingFrugal && enabled && !operatorOff ? (
+
+ {t("settings.ai.autoRead.restingFrugal")}
+
+ ) : null}
+
{pendingEnable ? (
Date: Thu, 15 Oct 2026 23:05:32 +0200
Subject: [PATCH 8/9] test(integration): pin the AI usage levels end to end
Against real Postgres: ai.usage on /api/auth/me with the measured use and
the estimate, the PUT and its ceiling refusal, an operator lowering the
ceiling over a stored choice, supplementLabel closing on frugal, and the
status invalidator holding a frugal account's sync to one regeneration per
scope a day while a hand entry after six hours goes through. The capability
wiring test now covers supplementLabel.
Refs #1217
---
tests/integration/ai-usage-levels.test.ts | 368 ++++++++++++++++++
.../auth-me-ai-capabilities.test.ts | 18 +-
2 files changed, 382 insertions(+), 4 deletions(-)
create mode 100644 tests/integration/ai-usage-levels.test.ts
diff --git a/tests/integration/ai-usage-levels.test.ts b/tests/integration/ai-usage-levels.test.ts
new file mode 100644
index 000000000..f19361326
--- /dev/null
+++ b/tests/integration/ai-usage-levels.test.ts
@@ -0,0 +1,368 @@
+/**
+ * v1.43 (#1217) — AI usage levels, through the real routes and the real
+ * invalidator against real Postgres.
+ *
+ * The resolution matrix, the estimate and the effect table are unit-tested
+ * (`src/lib/ai/__tests__/usage-level.test.ts`). What only this file proves is
+ * the wiring end to end:
+ *
+ * - `ai.usage` on `GET /api/auth/me` reads the record's stored choice and
+ * the operator's bounds off their rows, and the measured use off
+ * `coach_usage`;
+ * - `PUT /api/auth/me/ai-usage` writes the choice and refuses one above the
+ * ceiling; the admin route lowers the ceiling, which caps a stored choice
+ * at once without erasing it;
+ * - the `supplementLabel` capability closes with `usage_level` on `frugal`;
+ * - the status invalidator holds a `frugal` account's sync ingest to one
+ * regeneration per scope a day, while a hand entry six hours later goes
+ * through, and `standard` holds a sync to the operator's gap.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => {
+ cookieJar.set(name, value);
+ },
+ delete: (name: string) => {
+ cookieJar.delete(name);
+ },
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+// The queue is not under test: record what the invalidator enqueues.
+const enqueued = vi.hoisted(() => [] as Array<{ metric: string }>);
+vi.mock("@/lib/jobs/insight-status-generate-shared", async (orig) => ({
+ ...(await orig()),
+ enqueueStatusGeneration: vi.fn(async (job: { metric: string }) => {
+ enqueued.push(job);
+ }),
+}));
+
+interface UsageState {
+ level: string;
+ preference: string | null;
+ defaultLevel: string;
+ maxLevel: string;
+ source: string;
+ options: Array<{
+ level: string;
+ allowed: boolean;
+ estimatedTokensPerDay: number | null;
+ }>;
+ measuredTokensPerDay: number | null;
+ measuredDays: number;
+}
+
+interface MePayload {
+ ai: {
+ capabilities: Record;
+ usage?: UsageState;
+ };
+}
+
+let counter = 0;
+
+/** A person with their own key, every receipt and the nutrients module on. */
+async function makeUser(overrides: Record = {}) {
+ const suffix = `${counter++}`;
+ const prisma = getPrismaClient();
+ const user = await prisma.user.create({
+ data: {
+ username: `usage-${suffix}`,
+ email: `usage-${suffix}@example.test`,
+ role: "USER",
+ timezone: "UTC",
+ locale: "en",
+ onboardingCompletedAt: new Date(),
+ aiProvider: "ANTHROPIC",
+ aiModel: "claude-sonnet-4-6",
+ // Presence only: the probe never decrypts, so any value will do.
+ aiAnthropicKeyEncrypted: "v1:presence-only",
+ modulePreferencesJson: { nutrients: true },
+ ...overrides,
+ },
+ });
+ await prisma.consentReceipt.create({
+ data: {
+ userId: user.id,
+ kind: "ai_full",
+ artefact: "test",
+ signedAt: new Date(),
+ },
+ });
+ return user;
+}
+
+async function signIn(userId: string) {
+ const session = await getPrismaClient().session.create({
+ data: { userId, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+}
+
+async function readMe(): Promise {
+ const { GET } = await import("@/app/api/auth/me/route");
+ const res = await GET();
+ expect(res.status).toBe(200);
+ return ((await res.json()) as { data: MePayload }).data;
+}
+
+async function putLevel(level: string | null): Promise {
+ const { PUT } = await import("@/app/api/auth/me/ai-usage/route");
+ return (PUT as unknown as (r: Request) => Promise)(
+ new Request("http://localhost/api/auth/me/ai-usage", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ level }),
+ }),
+ );
+}
+
+/** Set the operator's usage bounds through the admin route, as an admin. */
+async function putBounds(aiUsage: Record) {
+ const prisma = getPrismaClient();
+ const admin = await prisma.user.create({
+ data: {
+ username: `admin-${counter++}`,
+ email: `admin-${counter}@example.test`,
+ role: "ADMIN",
+ },
+ });
+ const previous = cookieJar.get("healthlog_session");
+ await signIn(admin.id);
+ const { PUT } =
+ await import("@/app/api/admin/settings/assistant-flags/route");
+ const { NextRequest } = await import("next/server");
+ const res = await PUT(
+ new NextRequest("http://localhost/api/admin/settings/assistant-flags", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ aiUsage }),
+ }),
+ );
+ if (previous) cookieJar.set("healthlog_session", previous);
+ return res;
+}
+
+/** A stored status note for `scope`, written `hoursAgo` hours ago. */
+async function noteWritten(userId: string, scope: string, hoursAgo: number) {
+ await getPrismaClient().insightStatusCache.create({
+ data: {
+ userId,
+ metric: scope,
+ locale: "en",
+ textEncrypted: Buffer.from("ciphertext"),
+ dateKey: new Date().toISOString().slice(0, 10),
+ generatedAt: new Date(Date.now() - hoursAgo * 60 * 60 * 1000),
+ },
+ });
+}
+
+async function invalidate(userId: string, source: "sync" | "manual") {
+ enqueued.length = 0;
+ const { invalidateStatusInsightsForTypes } =
+ await import("@/lib/insights/status-invalidation");
+ await invalidateStatusInsightsForTypes(userId, ["ACTIVITY_STEPS"], {
+ source,
+ });
+ return enqueued.map((job) => job.metric).sort();
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+ enqueued.length = 0;
+ counter = 0;
+});
+
+describe("ai.usage on GET /api/auth/me", () => {
+ it("publishes the instance default with the measured use and the estimate", async () => {
+ const user = await makeUser();
+ const today = new Date();
+ for (let i = 1; i <= 4; i++) {
+ const day = new Date(today.getTime() - i * 86_400_000);
+ await getPrismaClient().coachUsage.create({
+ data: {
+ userId: user.id,
+ dateKey: day.toISOString().slice(0, 10),
+ totalTokens: 100_000,
+ },
+ });
+ }
+ // Today is still running and does not count.
+ await getPrismaClient().coachUsage.create({
+ data: {
+ userId: user.id,
+ dateKey: today.toISOString().slice(0, 10),
+ totalTokens: 9_000_000,
+ },
+ });
+ await signIn(user.id);
+ const { ai } = await readMe();
+ expect(ai.usage).toEqual({
+ level: "standard",
+ preference: null,
+ defaultLevel: "standard",
+ maxLevel: "standard",
+ source: "default",
+ options: [
+ { level: "frugal", allowed: true, estimatedTokensPerDay: 50_000 },
+ { level: "standard", allowed: true, estimatedTokensPerDay: 100_000 },
+ { level: "full", allowed: false, estimatedTokensPerDay: 333_333 },
+ ],
+ measuredTokensPerDay: 100_000,
+ measuredDays: 4,
+ });
+ expect(ai.capabilities.supplementLabel).toMatchObject({
+ available: true,
+ reason: null,
+ });
+ });
+
+ it("writes the choice, closes the label photo on frugal, and refuses above the ceiling", async () => {
+ const user = await makeUser();
+ await signIn(user.id);
+
+ const res = await putLevel("frugal");
+ expect(res.status).toBe(200);
+ expect(
+ (await getPrismaClient().user.findUnique({ where: { id: user.id } }))
+ ?.aiUsageLevel,
+ ).toBe("FRUGAL");
+ const me = await readMe();
+ expect(me.ai.usage).toMatchObject({ level: "frugal", source: "user" });
+ expect(me.ai.capabilities.supplementLabel).toMatchObject({
+ available: false,
+ reason: "usage_level",
+ });
+ // Nothing else is touched by the level.
+ expect(me.ai.capabilities.documentAi.available).toBe(true);
+
+ const above = await putLevel("full");
+ expect(above.status).toBe(422);
+ expect(((await above.json()) as { meta: unknown }).meta).toMatchObject({
+ errorCode: "ai.usage.aboveCeiling",
+ maxLevel: "standard",
+ });
+
+ expect((await putLevel(null)).status).toBe(200);
+ expect((await readMe()).ai.usage).toMatchObject({
+ level: "standard",
+ preference: null,
+ source: "default",
+ });
+ });
+
+ it("caps a stored choice the moment the operator lowers the ceiling, and keeps it", async () => {
+ const user = await makeUser();
+ expect(
+ (
+ await putBounds({
+ maxLevel: "full",
+ defaultLevel: "standard",
+ syncRegenGapHours: null,
+ })
+ ).status,
+ ).toBe(200);
+ await signIn(user.id);
+ expect((await putLevel("full")).status).toBe(200);
+ expect((await readMe()).ai.usage).toMatchObject({
+ level: "full",
+ source: "user",
+ });
+
+ const lowered = await putBounds({
+ maxLevel: "frugal",
+ defaultLevel: "frugal",
+ syncRegenGapHours: null,
+ });
+ expect(lowered.status).toBe(200);
+ const usage = (await readMe()).ai.usage!;
+ expect(usage).toMatchObject({
+ level: "frugal",
+ preference: "full",
+ maxLevel: "frugal",
+ source: "admin_cap",
+ });
+ expect(usage.options.map((o) => o.allowed)).toEqual([true, false, false]);
+
+ const refused = await putBounds({
+ maxLevel: "frugal",
+ defaultLevel: "standard",
+ syncRegenGapHours: null,
+ });
+ expect(refused.status).toBe(422);
+ });
+});
+
+describe("status regeneration gap by level and source", () => {
+ it("frugal: no second generation per scope within 24 h of a sync; a hand entry after 6 h goes through", async () => {
+ const user = await makeUser({ aiUsageLevel: "FRUGAL" });
+ await noteWritten(user.id, "general", 7);
+ await noteWritten(user.id, "metric:STEPS", 23);
+ // A sync 7 h and 23 h after the last generation: still inside the day.
+ expect(await invalidate(user.id, "sync")).toEqual([]);
+ // A hand entry: both scopes were generated more than six hours ago.
+ expect(await invalidate(user.id, "manual")).toEqual([
+ "general",
+ "metric:STEPS",
+ ]);
+ });
+
+ it("frugal: a hand entry within six hours of the last generation waits", async () => {
+ const user = await makeUser({ aiUsageLevel: "FRUGAL" });
+ await noteWritten(user.id, "general", 7);
+ await noteWritten(user.id, "metric:STEPS", 3);
+ expect(await invalidate(user.id, "manual")).toEqual(["general"]);
+ });
+
+ it("frugal: a sync a day after the last generation regenerates", async () => {
+ const user = await makeUser({ aiUsageLevel: "FRUGAL" });
+ await noteWritten(user.id, "general", 25);
+ await noteWritten(user.id, "metric:STEPS", 25);
+ expect(await invalidate(user.id, "sync")).toEqual([
+ "general",
+ "metric:STEPS",
+ ]);
+ });
+
+ it("standard: a sync waits the operator's gap, a hand entry the hour", async () => {
+ const user = await makeUser();
+ await noteWritten(user.id, "general", 3);
+ await noteWritten(user.id, "metric:STEPS", 7);
+ expect(await invalidate(user.id, "sync")).toEqual(["metric:STEPS"]);
+ expect(await invalidate(user.id, "manual")).toEqual([
+ "general",
+ "metric:STEPS",
+ ]);
+
+ await putBounds({
+ maxLevel: "standard",
+ defaultLevel: "standard",
+ syncRegenGapHours: 2,
+ });
+ expect(await invalidate(user.id, "sync")).toEqual([
+ "general",
+ "metric:STEPS",
+ ]);
+ });
+});
diff --git a/tests/integration/auth-me-ai-capabilities.test.ts b/tests/integration/auth-me-ai-capabilities.test.ts
index fdabc33a0..090dbcb14 100644
--- a/tests/integration/auth-me-ai-capabilities.test.ts
+++ b/tests/integration/auth-me-ai-capabilities.test.ts
@@ -56,6 +56,8 @@ const ALL = [
"documentAi",
"labsOcr",
"medicationExtract",
+ // v1.43 — the supplement label photo (needs the nutrients module, on below).
+ "supplementLabel",
] as const;
type Key = (typeof ALL)[number];
@@ -99,6 +101,8 @@ async function makeUser(
aiModel: "claude-sonnet-4-6",
// Presence only: the probe never decrypts, so any value will do.
aiAnthropicKeyEncrypted: "v1:presence-only",
+ // The nutrients module is opt-in; on, so every capability can open.
+ modulePreferencesJson: { nutrients: true },
...overrides,
},
});
@@ -206,7 +210,7 @@ describe("GET /api/auth/me — ai", () => {
await signIn(user.id);
expectOnly(
await readMe(),
- ["documentAi", "labsOcr", "medicationExtract"],
+ ["documentAi", "labsOcr", "medicationExtract", "supplementLabel"],
"operator_disabled",
);
});
@@ -250,7 +254,13 @@ describe("GET /api/auth/me — ai", () => {
await grantConsent(user.id, "ai_insights_only");
expectOnly(
await readMe(),
- ["coach", "documentAi", "labsOcr", "medicationExtract"],
+ [
+ "coach",
+ "documentAi",
+ "labsOcr",
+ "medicationExtract",
+ "supplementLabel",
+ ],
"consent_required",
);
});
@@ -264,7 +274,7 @@ describe("GET /api/auth/me — ai", () => {
it("reads the AI analysis opt-out as the person's own choice", async () => {
const user = await makeUser("insights-off", {
- modulePreferencesJson: { insights: false },
+ modulePreferencesJson: { insights: false, nutrients: true },
});
await grantConsent(user.id, "ai_full");
await signIn(user.id);
@@ -283,7 +293,7 @@ describe("GET /api/auth/me — ai", () => {
it("closes workout notes with the workouts module", async () => {
const user = await makeUser("workouts-off", {
- modulePreferencesJson: { workouts: false },
+ modulePreferencesJson: { workouts: false, nutrients: true },
});
await grantConsent(user.id, "ai_full");
await signIn(user.id);
From d45a0d5d8e8ab2c8041e5dd61fee178f62af596f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 15 Oct 2026 23:06:08 +0200
Subject: [PATCH 9/9] docs(ai): describe the AI usage levels and where the sync
gap is set
Refs #1217
---
.env.production.example | 2 +-
docker-compose.yml | 2 +-
docs/integrations/ai-providers.md | 29 +++++++++++++++++++++++------
3 files changed, 25 insertions(+), 8 deletions(-)
diff --git a/.env.production.example b/.env.production.example
index bc8df0b6f..5ea504a55 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -331,7 +331,7 @@ API_TOKEN_HMAC_KEY=""
# (optional). A watch or an integration delivers readings all day, and each
# delivery would otherwise regenerate the cards it touches every hour. Hand
# entries keep a one-hour gap. Default 6, whole hours from 1 to 48. The value
-# under Admin > AI wins over this one. See docs/integrations/ai-providers.md.
+# under Admin > Coach wins over this one. See docs/integrations/ai-providers.md.
# AI_SYNC_REGEN_GAP_HOURS=""
# Hourly upload ceiling for each document-upload token (optional). Applies
diff --git a/docker-compose.yml b/docker-compose.yml
index c8a65b987..031a8f3ca 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -334,7 +334,7 @@ x-healthlog-env: &healthlog_env
DOCUMENT_AI_LIMIT_PER_HOUR: "${DOCUMENT_AI_LIMIT_PER_HOUR:-}"
# Minimum hours between two status-card regenerations after a sync
# (optional; default 6, whole hours 1-48; hand entries keep one hour). The
- # value under Admin > AI wins. Listed here so an operator value in .env
+ # value under Admin > Coach wins. Listed here so an operator value in .env
# reaches the container. See docs/integrations/ai-providers.md.
AI_SYNC_REGEN_GAP_HOURS: "${AI_SYNC_REGEN_GAP_HOURS:-}"
# Hourly upload ceiling for each narrow document-upload token (a Paperless
diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md
index 34a6dbda1..84e9d20f0 100644
--- a/docs/integrations/ai-providers.md
+++ b/docs/integrations/ai-providers.md
@@ -432,12 +432,29 @@ tight one. The 429 names the actual wait (the window is an hour) and
carries the reset instant in `X-RateLimit-Reset`.
`AI_SYNC_REGEN_GAP_HOURS` (default `6`, whole hours from 1 to 48) is
-the minimum time between two regenerations of a status card after a sync.
-A watch or an integration delivers readings all day, and each delivery
-moves today's figures, so without a gap every touched card regenerated
-about once an hour. A reading entered by hand keeps a one-hour gap, so it
-is still narrated the same day. The value set under Admin > AI wins over
-the environment.
+the minimum time between two regenerations of a status card after a sync
+at the `standard` AI usage level. A watch or an integration delivers
+readings all day, and each delivery moves today's figures, so without a
+gap every touched card regenerated about once an hour. A reading entered
+by hand keeps a one-hour gap, so it is still narrated the same day. The
+value set under Admin > Coach (AI usage levels) wins over the environment.
+
+Each account runs at one of three AI usage levels, chosen under
+Settings > AI within the bounds the operator sets under Admin > Coach (the
+highest level allowed and the default for accounts without a choice):
+
+| | `frugal` | `standard` | `full` |
+| ------------------------------- | ------------------ | -------------------- | -------------------- |
+| Status cards after a sync | at most once a day | the gap above | hourly |
+| Status cards after a hand entry | every 6 hours | hourly | hourly |
+| Snapshot size (compact JSON) | 10 000 characters | 14 000 | 14 000 |
+| Background reasoning | off | job default | job default |
+| Daily briefing | short form | full | full |
+| Automatic document reading | on request only | the account's switch | the account's switch |
+| Supplement label photo | not offered | offered | offered |
+
+The Coach is the same at every level. Settings shows an estimate of
+tokens per day for each level, scaled from the account's last seven days.
`LABS_OCR_LIMIT_PER_HOUR` (default `6`, clamped to 1–1000) does the
same for lab-report scans. The photo or PDF scan and the browser-OCR