Merge branch 'v143/ai' into release/v1.43.0

# Conflicts:
#	docs/api/openapi.yaml
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
#	src/__tests__/contract-stub-inventory.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:12:51 +02:00
commit fd44a8381b
143 files changed
+4806 -628

No files matched your search

@@ -47,6 +47,7 @@ const CONTRACT: Record<AiUnavailableReason, [number, string]> = {
not_permitted_for_record: [403, "ai.record.notPermitted"],
module_disabled: [403, "module.disabled"],
user_disabled: [403, "module.disabled"],
usage_level: [403, "ai.usage.levelTooLow"],
no_provider: [422, "ai.provider.none"],
consent_required: [403, "consent.ai.required"],
};
@@ -75,6 +76,18 @@ describe("the refusal table", () => {
}
});
it("names the level a usage refusal needs (#1217)", () => {
expect(aiRefusal("supplementLabel", "usage_level")).toEqual({
status: 403,
meta: {
errorCode: "ai.usage.levelTooLow",
capability: "supplementLabel",
reason: "usage_level",
requiredLevel: "standard",
},
});
});
it("keeps the code shipped clients know for the Coach", () => {
expect(aiRefusal("coach", "operator_disabled").meta.errorCode).toBe(
"assistant.disabled.coach",
+204
View File
@@ -0,0 +1,204 @@
/**
* #1217 — the AI usage level: resolution, effects and the estimate.
*
* The resolution matrix is exhaustive over every stored choice (three levels
* and none), every default and every ceiling: 4 x 3 x 3 = 36 cases, each
* checked against a rule written out independently of the resolver.
*/
import { describe, expect, it } from "vitest";
import {
AI_USAGE_LEVELS,
AI_USAGE_LEVEL_EFFECTS,
buildAiUsageState,
capUsageLevel,
estimateTokensPerDay,
parseSyncRegenGapHours,
resolveAiUsageLevel,
statusRegenGapHours,
usageLevelFromDb,
usageLevelToDb,
type AiUsageLevel,
} from "@/lib/ai/usage-level";
const RANK: Record<AiUsageLevel, number> = { frugal: 0, standard: 1, full: 2 };
describe("resolveAiUsageLevel — every choice x default x ceiling", () => {
const choices: Array<AiUsageLevel | null> = [null, ...AI_USAGE_LEVELS];
let cases = 0;
for (const preference of choices) {
for (const defaultLevel of AI_USAGE_LEVELS) {
for (const maxLevel of AI_USAGE_LEVELS) {
cases++;
it(`choice ${preference ?? "none"}, default ${defaultLevel}, ceiling ${maxLevel}`, () => {
const got = resolveAiUsageLevel({
preference,
defaultLevel,
maxLevel,
});
if (preference === null) {
// No choice: the default, never above the ceiling.
const expected =
RANK[defaultLevel] > RANK[maxLevel] ? maxLevel : defaultLevel;
expect(got).toEqual({ level: expected, source: "default" });
} else if (RANK[preference] > RANK[maxLevel]) {
// A choice above the ceiling is capped, and says so.
expect(got).toEqual({ level: maxLevel, source: "admin_cap" });
} else {
expect(got).toEqual({ level: preference, source: "user" });
}
// Whatever the inputs, the result never passes the ceiling.
expect(RANK[got.level]).toBeLessThanOrEqual(RANK[maxLevel]);
});
}
}
}
it("covered every combination", () => {
expect(cases).toBe(36);
});
});
describe("what each level does", () => {
it("regenerates status cards after a sync: a day, the operator's gap, the hour", () => {
expect(statusRegenGapHours("frugal", "sync", 6)).toBe(24);
expect(statusRegenGapHours("standard", "sync", 6)).toBe(6);
expect(statusRegenGapHours("standard", "sync", 12)).toBe(12);
expect(statusRegenGapHours("full", "sync", 12)).toBe(1);
});
it("regenerates after a hand entry: six hours on frugal, the hour otherwise", () => {
expect(statusRegenGapHours("frugal", "manual", 6)).toBe(6);
expect(statusRegenGapHours("standard", "manual", 24)).toBe(1);
expect(statusRegenGapHours("full", "manual", 24)).toBe(1);
});
it("caps the status snapshot at 10 000 on frugal and 14 000 above", () => {
expect(
AI_USAGE_LEVELS.map((l) => AI_USAGE_LEVEL_EFFECTS[l].statusSnapshotChars),
).toEqual([10_000, 14_000, 14_000]);
});
it("switches background reasoning and the automatic document read off on frugal only", () => {
for (const level of AI_USAGE_LEVELS) {
const effect = AI_USAGE_LEVEL_EFFECTS[level];
expect(effect.backgroundReasoning, level).toBe(level !== "frugal");
expect(effect.documentsAutoRead, level).toBe(level !== "frugal");
}
});
it("orders the token factors with the levels, full at one", () => {
const factors = AI_USAGE_LEVELS.map(
(l) => AI_USAGE_LEVEL_EFFECTS[l].tokenFactor,
);
expect(factors).toEqual([...factors].sort((a, b) => a - b));
expect(AI_USAGE_LEVEL_EFFECTS.full.tokenFactor).toBe(1);
// Calibrated by the watch-day simulation: standard ~0.31 of full,
// frugal ~0.15, the nightly warm included.
expect(AI_USAGE_LEVEL_EFFECTS.standard.tokenFactor).toBe(0.3);
expect(AI_USAGE_LEVEL_EFFECTS.frugal.tokenFactor).toBe(0.15);
});
});
describe("the estimate", () => {
it("is null without three days of history", () => {
for (const measuredDays of [0, 1, 2]) {
expect(
estimateTokensPerDay({
measuredTokensPerDay: 100_000,
measuredDays,
from: "standard",
target: "full",
}),
).toBeNull();
}
expect(
estimateTokensPerDay({
measuredTokensPerDay: null,
measuredDays: 7,
from: "standard",
target: "full",
}),
).toBeNull();
});
it("scales the measured use by the factor ratio, whole tokens", () => {
const at = (target: AiUsageLevel) =>
estimateTokensPerDay({
measuredTokensPerDay: 100_000,
measuredDays: 5,
from: "standard",
target,
});
expect(at("standard")).toBe(100_000);
expect(at("full")).toBe(333_333);
expect(at("frugal")).toBe(50_000);
});
});
describe("buildAiUsageState", () => {
it("publishes three options in ceiling order with allowance and estimate", () => {
const state = buildAiUsageState({
preference: "full",
defaultLevel: "standard",
maxLevel: "standard",
measuredTokensPerDay: 50_000,
measuredDays: 7,
});
expect(state.level).toBe("standard");
expect(state.source).toBe("admin_cap");
expect(state.preference).toBe("full");
expect(state.options.map((o) => [o.level, o.allowed])).toEqual([
["frugal", true],
["standard", true],
["full", false],
]);
expect(state.options.map((o) => o.estimatedTokensPerDay)).toEqual([
25_000, 50_000, 166_667,
]);
expect(state.measuredTokensPerDay).toBe(50_000);
expect(state.measuredDays).toBe(7);
});
it("publishes no estimate without history", () => {
const state = buildAiUsageState({
preference: null,
defaultLevel: "standard",
maxLevel: "full",
measuredTokensPerDay: null,
measuredDays: 0,
});
expect(state.options.every((o) => o.estimatedTokensPerDay === null)).toBe(
true,
);
expect(state.source).toBe("default");
});
});
describe("spelling and parsing", () => {
it("round-trips the database spelling", () => {
for (const level of AI_USAGE_LEVELS) {
expect(usageLevelFromDb(usageLevelToDb(level))).toBe(level);
}
expect(usageLevelFromDb(null)).toBeNull();
expect(usageLevelFromDb("LAVISH")).toBeNull();
});
it("caps a level at a ceiling", () => {
expect(capUsageLevel("full", "standard")).toBe("standard");
expect(capUsageLevel("frugal", "standard")).toBe("frugal");
});
it.each([
["6", 6],
[12, 12],
["48", 48],
["0", null],
["49", null],
["2.5", null],
["", null],
[undefined, null],
["abc", null],
])("parses the sync gap %s as %s", (raw, expected) => {
expect(parseSyncRegenGapHours(raw)).toBe(expected);
});
});
@@ -58,6 +58,7 @@ function baseline(): AiCapabilityInputs {
},
activeConsentKinds: new Set(),
recordKind: "self",
usageLevel: "full",
};
}
@@ -103,6 +104,11 @@ const PERTURB: Record<Exclude<AiUnavailableReason, "check_failed">, Perturb> = {
inputs.moduleAccess = access;
return true;
},
usage_level: (inputs, key) => {
if (AI_CAPABILITIES[key].minUsageLevel === undefined) return false;
inputs.usageLevel = "frugal";
return true;
},
no_provider: (inputs) => {
inputs.provider = { ...inputs.provider, entries: [], managedBy: null };
return true;
@@ -429,16 +435,18 @@ describe("on-device permission", () => {
not_permitted_for_record: false,
module_disabled: false,
user_disabled: false,
usage_level: false,
no_provider: true,
consent_required: true,
};
for (const reason of PERTURBABLE) {
const inputs = baseline();
PERTURB[reason](inputs, "briefing");
expect(
resolveAiCapability("briefing", inputs).onDeviceAllowed,
reason,
).toBe(expected[reason]);
// `usage_level` only applies to a capability with a minimum level.
const key = reason === "usage_level" ? "supplementLabel" : "briefing";
PERTURB[reason](inputs, key);
expect(resolveAiCapability(key, inputs).onDeviceAllowed, reason).toBe(
expected[reason],
);
}
expect(resolveAiCapability("briefing", null).onDeviceAllowed).toBe(
expected.check_failed,
@@ -486,3 +494,43 @@ describe("the provider block", () => {
});
});
});
describe("the AI usage level (#1217)", () => {
it("closes only the capabilities that need a higher level", () => {
const inputs = baseline();
inputs.usageLevel = "frugal";
const resolved = resolveAiCapabilities(inputs);
for (const key of AI_CAPABILITY_KEYS) {
const needsMore = AI_CAPABILITIES[key].minUsageLevel !== undefined;
expect(resolved[key].reason, key).toBe(needsMore ? "usage_level" : null);
}
});
it("offers the label photo from standard up", () => {
for (const [level, available] of [
["frugal", false],
["standard", true],
["full", true],
] as const) {
const inputs = baseline();
inputs.usageLevel = level;
expect(
resolveAiCapability("supplementLabel", inputs).available,
level,
).toBe(available);
}
});
it("ranks after the person's opt-out and before the provider", () => {
const inputs = baseline();
inputs.usageLevel = "frugal";
inputs.provider = { ...inputs.provider, entries: [], managedBy: null };
expect(resolveAiCapability("supplementLabel", inputs).reason).toBe(
"usage_level",
);
inputs.moduleAccess = { ...inputs.moduleAccess, nutrients: "disabled" };
expect(resolveAiCapability("supplementLabel", inputs).reason).toBe(
"module_disabled",
);
});
});
+35 -11
View File
@@ -9,7 +9,9 @@
* memoised reads the account payload and every module gate use;
* - provider presence: the record's credential row and the operator's
* provider columns (`probeProviderChain`), presence only;
* - consent: the kinds of the record's active receipts, one indexed read.
* - consent: the kinds of the record's active receipts, one indexed read;
* - the record's AI usage level (v1.43): the operator's bounds (memoised)
* and one point read of the record's choice.
*
* Any failure resolves to `null`, which the resolver turns into
* `check_failed` for every capability. Failing closed is safe because no data
@@ -38,6 +40,7 @@ import {
type AiRecordKind,
} from "./resolve";
import type { AiCapabilities } from "./types";
import { loadAiUsageState, loadRecordUsageLevel } from "./usage";
/** Whose record, seen by whom, through which grant. */
export interface AiCapabilityScope {
@@ -89,14 +92,21 @@ export function loadAiCapabilityInputs(
`ai-capability-inputs:${scopeKey(scope)}`,
async () => {
try {
const [switches, modules, availability, provider, activeConsentKinds] =
await Promise.all([
loadAssistantSwitches(),
resolveModuleMap(scope.recordId),
getOperatorModuleAvailability(),
probeProviderChain(scope.recordId, scope.authority),
loadActiveConsentKinds(scope.recordId),
]);
const [
switches,
modules,
availability,
provider,
activeConsentKinds,
usageLevel,
] = await Promise.all([
loadAssistantSwitches(),
resolveModuleMap(scope.recordId),
getOperatorModuleAvailability(),
probeProviderChain(scope.recordId, scope.authority),
loadActiveConsentKinds(scope.recordId),
loadRecordUsageLevel(scope.recordId),
]);
if (switches === null) return null;
const { moduleAccess } = buildModuleDisclosure(
modules,
@@ -110,6 +120,7 @@ export function loadAiCapabilityInputs(
provider,
activeConsentKinds,
recordKind: scope.recordKind,
usageLevel,
};
} catch {
getEvent()?.addWarning(
@@ -122,9 +133,22 @@ export function loadAiCapabilityInputs(
);
}
/** The whole `ai` block for one scope. */
/**
* The whole `ai` block for one scope, the record's AI usage level included
* (v1.43). The measured use and the estimates are published for one's own
* record only: a delegate sees which level a record runs at, not what its
* owner spends. A usage read that fails leaves `usage` out rather than
* failing the block; the capabilities already carry the level they resolved.
*/
export async function loadAiCapabilities(
scope: AiCapabilityScope,
): Promise<AiCapabilities> {
return resolveAiBlock(await loadAiCapabilityInputs(scope));
const [inputs, usage] = await Promise.all([
loadAiCapabilityInputs(scope),
loadAiUsageState(scope.recordId, {
withMeasurement: scope.recordKind === "self",
}).catch(() => undefined),
]);
const block = resolveAiBlock(inputs);
return usage ? { ...block, usage } : block;
}
+16
View File
@@ -8,12 +8,14 @@
*/
import { MODULE_DISABLED_ERROR_CODE } from "@/lib/modules/gate";
import type { ModuleKey } from "@/lib/modules/registry";
import type { AiUsageLevel } from "@/lib/ai/usage-level";
import {
AI_CAPABILITIES,
AI_PROVIDER_NONE_ERROR_CODE,
AI_RECORD_NOT_PERMITTED_ERROR_CODE,
AI_UNAVAILABLE_ERROR_CODE,
AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE,
type AiCapabilityKey,
type AiUnavailableReason,
} from "./types";
@@ -33,6 +35,8 @@ export interface AiRefusalMeta {
capability: AiCapabilityKey;
reason: AiUnavailableReason;
module?: ModuleKey;
/** Beside `ai.usage.levelTooLow`: the lowest level the capability needs. */
requiredLevel?: AiUsageLevel;
}
/** The HTTP shape of one refusal. Pure; exported for the envelope test. */
@@ -72,6 +76,17 @@ export function aiRefusal(
...(module === null ? {} : { module }),
},
};
case "usage_level":
return {
status: 403,
meta: {
...base,
errorCode: AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE,
...(AI_CAPABILITIES[capability].minUsageLevel
? { requiredLevel: AI_CAPABILITIES[capability].minUsageLevel }
: {}),
},
};
case "no_provider":
return {
status: noProvider?.status ?? 422,
@@ -99,6 +114,7 @@ const REFUSAL_MESSAGE: Record<AiUnavailableReason, string> = {
not_permitted_for_record: "AI work is not available for this record",
module_disabled: "This AI feature's module is turned off for this record",
user_disabled: "This AI feature is turned off in your settings",
usage_level: "This AI feature is not available at the current AI usage level",
no_provider: "No AI provider is set up for this feature",
consent_required: "AI consent is required for this feature",
};
+16
View File
@@ -21,6 +21,7 @@ import {
isExternalProvider,
isOperatorHeldProvider,
} from "@/lib/ai/provider-egress";
import { usageLevelAtLeast, type AiUsageLevel } from "@/lib/ai/usage-level";
import type { ModuleKey } from "@/lib/modules/registry";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
@@ -88,6 +89,11 @@ export interface AiCapabilityInputs {
/** Kinds of the record's active (non-revoked) consent receipts. */
activeConsentKinds: ReadonlySet<string>;
recordKind: AiRecordKind;
/**
* v1.43 (#1217) — the record's AI usage level in effect. The loader always
* sets it; absent (a hand-built set of inputs) means no level restriction.
*/
usageLevel?: AiUsageLevel;
}
// Which tags are operator-held and which stays local: `@/lib/ai/provider-egress`,
@@ -271,6 +277,15 @@ function consentReason(
: "consent_required";
}
/** Whether the record's usage level is below what the capability needs. */
function usageReason(
def: AiCapabilityDefinition,
level: AiUsageLevel | undefined,
): AiUnavailableReason | null {
if (def.minUsageLevel === undefined || level === undefined) return null;
return usageLevelAtLeast(level, def.minUsageLevel) ? null : "usage_level";
}
function stateFor(reason: AiUnavailableReason | null): AiCapabilityState {
if (reason === null) {
return { available: true, reason: null, onDeviceAllowed: true };
@@ -314,6 +329,7 @@ export function explainAiCapability(
switchReason,
inputs.providerWorkAdmitted ? null : "not_permitted_for_record",
modules.reason,
usageReason(def, inputs.usageLevel),
providerReason(def, inputs.provider),
consentReason(def, inputs.provider, inputs.activeConsentKinds),
]);
+30
View File
@@ -15,6 +15,7 @@
* contract, the structural guards — can depend on it without pulling a
* database client along.
*/
import type { AiUsageLevel, AiUsageState } from "@/lib/ai/usage-level";
import type { ModuleKey } from "@/lib/modules/registry";
/** Every AI capability, in publication order. */
@@ -29,6 +30,7 @@ export const AI_CAPABILITY_KEYS = [
"documentAi",
"labsOcr",
"medicationExtract",
"supplementLabel",
] as const;
export type AiCapabilityKey = (typeof AI_CAPABILITY_KEYS)[number];
@@ -49,6 +51,9 @@ export type AiCapabilityKey = (typeof AI_CAPABILITY_KEYS)[number];
* record's own switch or by the edge of the active grant.
* - `user_disabled`: the record's own AI opt-out: `disableCoach` for the
* Coach, the `insights` module ("AI analysis") for everything else.
* - `usage_level`: the record's AI usage level (v1.43, #1217) is below the
* level the capability needs (`minUsageLevel`). A choice, like the
* opt-out, so it ranks right after it and before the provider layers.
* - `no_provider`: no configured provider can serve this capability's input.
* - `consent_required`: the provider chain needs a consent receipt and none
* is active.
@@ -59,6 +64,7 @@ export const AI_UNAVAILABLE_REASONS = [
"not_permitted_for_record",
"module_disabled",
"user_disabled",
"usage_level",
"no_provider",
"consent_required",
] as const;
@@ -166,6 +172,11 @@ export interface AiCapabilityDefinition {
consent: { rule: AiConsentRule; kinds: readonly AiConsentKind[] };
modality: AiModality;
providerOrder: AiProviderOrder;
/**
* v1.43 (#1217) — the lowest AI usage level at which the capability is
* offered. Absent: every level.
*/
minUsageLevel?: AiUsageLevel;
}
/**
@@ -289,6 +300,18 @@ export const AI_CAPABILITIES: Readonly<
modality: "text",
providerOrder: "chain",
},
// v1.43 — reading a supplement's label photo into its ingredients. A photo
// is a document, and an extra model run per label, so it is not offered on
// the `frugal` usage level.
supplementLabel: {
key: "supplementLabel",
operatorSwitch: "documentAi",
modules: { mode: "all", keys: ["medications", "nutrients"] },
consent: { rule: "document", kinds: EXTRACTION_KINDS },
modality: "document",
providerOrder: "document",
minUsageLevel: "standard",
},
});
/**
@@ -328,6 +351,11 @@ export interface AiProviderState {
export interface AiCapabilities {
capabilities: Record<AiCapabilityKey, AiCapabilityState>;
provider: AiProviderState;
/**
* v1.43 (#1217) — the record's AI usage level, its bounds and the estimate.
* Absent on servers before v1.43 and on blocks resolved without it.
*/
usage?: AiUsageState;
}
// ── Refusal codes ──────────────────────────────────────────────────────────
@@ -345,3 +373,5 @@ export const AI_RECORD_NOT_PERMITTED_ERROR_CODE = "ai.record.notPermitted";
export const AI_PROVIDER_NONE_ERROR_CODE = "ai.provider.none";
/** The capability's inputs could not be loaded; the answer failed closed. */
export const AI_UNAVAILABLE_ERROR_CODE = "ai.unavailable";
/** v1.43 — the record's AI usage level is below what the capability needs. */
export const AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE = "ai.usage.levelTooLow";
+222
View File
@@ -0,0 +1,222 @@
/**
* v1.43 (#1217) — the reads behind an AI usage level.
*
* The vocabulary, the resolver and the estimate are pure and live in
* `@/lib/ai/usage-level`; this file gathers their inputs:
*
* - the operator's bounds and sync gap: the `AppSettings` singleton, one
* memoised read per request (read afresh inside a background run, so a
* changed ceiling reaches the next account of a nightly pass);
* - the record's stored choice: one point read of `users.ai_usage_level`;
* - the measured use: the record's `coach_usage` rows of the last seven
* full days in its own time zone.
*
* A level is resolved for the RECORD, like every AI capability: a background
* job runs per record, and a delegate or guardian inside somebody else's
* record sees that record's level. Only the account itself can change it
* (`PUT /api/auth/me/ai-usage`); a managed profile has no choice of its own
* and follows the instance default.
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
AI_USAGE_LEVEL_EFFECTS,
AI_USAGE_MEASURE_DAYS,
DEFAULT_AI_USAGE_DEFAULT_LEVEL,
DEFAULT_AI_USAGE_MAX_LEVEL,
DEFAULT_SYNC_REGEN_GAP_HOURS,
buildAiUsageState,
parseSyncRegenGapHours,
resolveAiUsageLevel,
usageLevelFromDb,
type AiUsageLevel,
type AiUsageResolution,
type AiUsageState,
} from "@/lib/ai/usage-level";
/** The operator's side of the usage levels. */
export interface AiUsageSettings {
maxLevel: AiUsageLevel;
defaultLevel: AiUsageLevel;
/** The stored sync gap (1..48), `null` when the operator set none. */
storedSyncRegenGapHours: number | null;
/** The sync gap in effect: the stored one, else the environment, else six. */
syncRegenGapHours: number;
}
/** The environment's sync gap, else six. */
export function envSyncRegenGapHours(): number {
return (
parseSyncRegenGapHours(process.env.AI_SYNC_REGEN_GAP_HOURS) ??
DEFAULT_SYNC_REGEN_GAP_HOURS
);
}
/** Pure: the operator's settings from the raw row (or its absence). */
export function aiUsageSettingsFromRow(
row: {
aiUsageMaxLevel?: string | null;
aiUsageDefaultLevel?: string | null;
aiSyncRegenGapHours?: number | null;
} | null,
): AiUsageSettings {
const stored = parseSyncRegenGapHours(row?.aiSyncRegenGapHours);
return {
maxLevel:
usageLevelFromDb(row?.aiUsageMaxLevel) ?? DEFAULT_AI_USAGE_MAX_LEVEL,
defaultLevel:
usageLevelFromDb(row?.aiUsageDefaultLevel) ??
DEFAULT_AI_USAGE_DEFAULT_LEVEL,
storedSyncRegenGapHours: stored,
syncRegenGapHours: stored ?? envSyncRegenGapHours(),
};
}
/**
* The operator's bounds and sync gap. An unreadable row reads as the
* instance defaults (`standard` / `standard`, the environment's gap): the
* levels bound spend, and the default is what an untouched instance runs at.
*/
export function loadAiUsageSettings(): Promise<AiUsageSettings> {
const read = async (): Promise<AiUsageSettings> => {
try {
const row = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
aiUsageMaxLevel: true,
aiUsageDefaultLevel: true,
aiSyncRegenGapHours: true,
},
});
return aiUsageSettingsFromRow(row);
} catch {
getEvent()?.addWarning(
"Failed to load the AI usage settings; the instance defaults apply",
);
return aiUsageSettingsFromRow(null);
}
};
try {
return memoizePerRequest("ai-usage-settings", read, {
freshInBackground: true,
});
} catch {
return read();
}
}
/** One record's level: its stored choice against the operator's bounds. */
export interface RecordAiUsage extends AiUsageResolution {
preference: AiUsageLevel | null;
settings: AiUsageSettings;
}
/**
* The level in effect for one record. A missing row (an account deleted
* under a running job) reads as no choice, so the default applies.
*/
export function loadRecordAiUsage(recordId: string): Promise<RecordAiUsage> {
const read = async (): Promise<RecordAiUsage> => {
const [settings, row] = await Promise.all([
loadAiUsageSettings(),
prisma.user.findUnique({
where: { id: recordId },
select: { aiUsageLevel: true },
}),
]);
const preference = usageLevelFromDb(row?.aiUsageLevel);
return {
...resolveAiUsageLevel({
preference,
defaultLevel: settings.defaultLevel,
maxLevel: settings.maxLevel,
}),
preference,
settings,
};
};
try {
return memoizePerRequest(`ai-usage:${recordId}`, read, {
freshInBackground: true,
});
} catch {
return read();
}
}
/** Just the effective level, for a job or a gate. */
export async function loadRecordUsageLevel(
recordId: string,
): Promise<AiUsageLevel> {
return (await loadRecordAiUsage(recordId)).level;
}
/**
* The status snapshot's char cap for this record's level. An unreadable level
* reads as the instance default's cap, never as an uncapped snapshot.
*/
export async function statusSnapshotCharsForRecord(
recordId: string,
): Promise<number> {
try {
const level = await loadRecordUsageLevel(recordId);
return AI_USAGE_LEVEL_EFFECTS[level].statusSnapshotChars;
} catch {
return AI_USAGE_LEVEL_EFFECTS[DEFAULT_AI_USAGE_DEFAULT_LEVEL]
.statusSnapshotChars;
}
}
/**
* The record's measured AI use: the mean of its `coach_usage` totals over the
* last seven full days in its own time zone (today is still running and does
* not count). `coach_usage` counts the Coach and the background work
* together. A day without a row had no AI use recorded and is not counted, so
* `days` says how many days stand behind the mean.
*/
export async function loadMeasuredAiUsage(
recordId: string,
now: Date = new Date(),
): Promise<{ tokensPerDay: number | null; days: number }> {
const today = userDayKey(now, await resolveUserTimezone(recordId));
const keys = Array.from({ length: AI_USAGE_MEASURE_DAYS }, (_, i) =>
shiftDateKey(today, -(i + 1)),
);
const rows = await prisma.coachUsage.findMany({
where: { userId: recordId, dateKey: { in: keys } },
select: { totalTokens: true },
});
if (rows.length === 0) return { tokensPerDay: null, days: 0 };
const total = rows.reduce((sum, row) => sum + row.totalTokens, 0);
return { tokensPerDay: Math.round(total / rows.length), days: rows.length };
}
/**
* `ai.usage` for one record. `withMeasurement: false` publishes the levels
* without the measured use or the estimates: a delegate sees which level the
* record runs at, never how much the owner spends.
*/
export async function loadAiUsageState(
recordId: string,
options: { withMeasurement: boolean },
): Promise<AiUsageState> {
const [usage, measured] = await Promise.all([
loadRecordAiUsage(recordId),
options.withMeasurement
? loadMeasuredAiUsage(recordId).catch(() => ({
tokensPerDay: null,
days: 0,
}))
: Promise.resolve({ tokensPerDay: null, days: 0 }),
]);
return buildAiUsageState({
preference: usage.preference,
defaultLevel: usage.settings.defaultLevel,
maxLevel: usage.settings.maxLevel,
measuredTokensPerDay: measured.tokensPerDay,
measuredDays: measured.days,
});
}
+5 -194
View File
@@ -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",
@@ -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<string>();
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("");
});
});
@@ -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/);
});
});
+8 -1
View File
@@ -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}
@@ -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"]);
});
});
+12 -9
View File
@@ -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
+4 -5
View File
@@ -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
+2 -2
View File
@@ -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" },
+39 -16
View File
@@ -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:<TYPE>` 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:<TYPE>` 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");
}
/**
+30 -11
View File
@@ -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));
@@ -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<object>()),
admittedPriorResults: vi.fn(async () => []),
}));
vi.mock("@/lib/ai/coach/tools", async (importOriginal) => ({
...(await importOriginal<object>()),
buildCoachDataInventory: vi.fn(async () => ({
entries: [],
probeScope: undefined,
})),
renderDataInventory: () => "DATA INVENTORY",
runCoachToolLoop: (args: unknown) => m.loop(args),
}));
vi.mock("@/lib/ai/provider-runner", async (importOriginal) => ({
...(await importOriginal<object>()),
runStreamingRawCompletionWithFallback: (args: unknown) => m.stream(args),
}));
import { runTurnModel } from "../model";
import type { TurnContext } from "../context";
const sha = (text: string) => createHash("sha256").update(text).digest("hex");
function ctx(turnNotes: string): TurnContext {
return {
coachPrefs: {},
reach: { kind: "unbounded" },
aiResponseTimeoutMs: 60_000,
effectiveScope: undefined,
snapshot: {
snapshotJson: "{}",
sections: {},
provenance: { windows: [], metrics: [], counts: {} },
},
aboutMe: null,
scheduleDoses: [],
medicationNames: [],
turnContext: {
allTurns: [],
window: [],
transcript: "user: hi",
guidedBlock: "",
historyElided: false,
isFirstTurn: true,
includeFullSnapshot: true,
},
workoutEvidence: null,
systemPrompt: "STABLE SYSTEM",
userPrompt: "SNAPSHOT AND CONVERSATION",
turnNotes,
} as unknown as TurnContext;
}
function run(turnNotes: string, turnHints: string[], toolMode: boolean) {
return runTurnModel({
userId: "u1",
locale: "en",
signal: new AbortController().signal,
conversationId: "c1",
ctx: ctx(turnNotes),
chain: [] as never,
toolMode,
ledger: {
settleRound: vi.fn(async () => {}),
reserveRound: vi.fn(async () => true),
close: vi.fn(async () => {}),
} as never,
emitter: { emit: () => {}, aborted: () => false },
turnHints,
message: "hi",
payer: "user",
conversationWindowSet: false,
}).catch(() => null);
}
beforeEach(() => {
m.loop.mockReset().mockRejectedValue(new Error("stop after capture"));
m.stream.mockReset().mockRejectedValue(new Error("stop after capture"));
});
describe("runTurnModel keeps the system prompt stable (#1217)", () => {
it("on the tool path", async () => {
await run("OPENER HINT for this turn: A", [], true);
await run("OPENER HINT for this turn: B", ["FOLLOW-UP: the table"], true);
const [first, second] = m.loop.mock.calls.map(
([args]) =>
args as { system: string; messages: Array<{ content: string }> },
);
expect(sha(first!.system)).toBe(sha(second!.system));
expect(first!.system).not.toContain("OPENER HINT");
expect(second!.system).not.toContain("FOLLOW-UP");
expect(first!.messages[0]!.content).toContain(
"OPENER HINT for this turn: A",
);
expect(second!.messages[0]!.content).toContain("FOLLOW-UP: the table");
});
it("on the streaming no-tools path", async () => {
await run("OPENER HINT for this turn: A", [], false);
await run("OPENER HINT for this turn: B", ["FOLLOW-UP: the table"], false);
const params = m.stream.mock.calls.map(
([args]) =>
(
args as {
params: { system: string; messages: Array<{ content: string }> };
}
).params,
);
expect(params[0]!.system).toBe("STABLE SYSTEM");
expect(params[1]!.system).toBe("STABLE SYSTEM");
const users = params.map((p) => JSON.stringify(p.messages));
expect(users[0]).toContain("OPENER HINT for this turn: A");
expect(users[1]).toContain("FOLLOW-UP: the table");
});
});
+13
View File
@@ -31,3 +31,16 @@ export function buildDialogAddenda(
.filter((block) => block.length > 0)
.join("\n\n");
}
/**
* v1.43 (#1217) — the table rules alone (how a table was fetched, how to
* follow up on one). They apply once the conversation holds a table, which can
* change mid-turn, so they travel as a user-side note rather than switching the
* system prompt: a switched system prompt rewrites the provider's cached
* prefix on the costliest round of the turn.
*/
export function buildTableRulesAddendum(locale: Locale): string {
return [methodAddendum(locale), followUpsAddendum(locale)]
.filter((block) => block.length > 0)
.join("\n\n");
}
+15 -7
View File
@@ -13,7 +13,10 @@ import type { CoachScope } from "@/lib/ai/coach/types";
import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared";
import { instructionLocale } from "@/lib/ai/prompts/output-language";
import { storeDeterministicFacts } from "@/lib/ai/coach/facts";
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
import {
buildCoachTurnNotes,
getCoachSystemPrompt,
} from "@/lib/ai/coach/system-prompt";
import {
openerArchetypeHint,
shouldUseNameForTurn,
@@ -69,6 +72,11 @@ export interface TurnContext {
workoutEvidence: Record<string, unknown> | null;
systemPrompt: string;
userPrompt: string;
/**
* v1.43 (#1217) — the lines that change every turn (opener hint, name
* clause), for the user message. Empty when none apply.
*/
turnNotes: string;
}
export async function assembleTurnContext(args: {
@@ -150,12 +158,11 @@ export async function assembleTurnContext(args: {
firstName != null && shouldUseNameForTurn(`${userId}:${turnIndex}`),
openerHint: openerArchetypeHint(`${userId}:${turnIndex}`, locale),
};
const baseSystemPrompt = getCoachSystemPrompt(
locale,
coachPrefs,
aboutMe,
coachPersonalization,
);
// v1.43 (#1217) — the system prompt is stable per person; the per-turn
// personalization travels in the user message (`turnNotes`), so the
// provider's cached prefix survives from one turn to the next.
const baseSystemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe);
const turnNotes = buildCoachTurnNotes(locale, coachPersonalization);
const turnContext = buildCoachTurnContext({
priorTurns,
priorSummary,
@@ -240,5 +247,6 @@ export async function assembleTurnContext(args: {
workoutEvidence,
systemPrompt,
userPrompt,
turnNotes,
};
}
+45 -30
View File
@@ -80,7 +80,7 @@ import {
type PriorResultTurn,
} from "@/lib/ai/coach/results/refs";
import { buildDialogAddenda } from "./addenda";
import { buildDialogAddenda, buildTableRulesAddendum } from "./addenda";
import type { TurnLedger } from "./budget";
import type { TurnReasoning } from "./reasoning";
import type { TurnChain } from "./chain";
@@ -228,7 +228,7 @@ export function buildTurnResults(
}
}
/** v1.39.4 — server-authored lines appended to the system prompt. */
/** v1.39.4 — server-authored lines appended to a prompt block. */
function appendBlocks(base: string, blocks: string[]): string {
const extra = blocks.filter((block) => block.length > 0);
return extra.length > 0 ? [base, ...extra].join("\n\n") : base;
@@ -374,28 +374,35 @@ export async function runTurnModel(args: {
reach: ctx.reach,
prior: args.priorResults ?? [],
});
const requestWith = (tableRules: boolean) =>
buildCoachToolRequest({
systemPrompt: ctx.systemPrompt,
toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [
buildDialogAddenda(locale, { tableRules }),
...args.turnHints,
]),
focusHint: renderFocusHint(effectiveScope?.sources),
workoutEvidence,
dataInventory: renderDataInventory(inventory),
priorResults: renderPriorResultRefs(priorResults),
guidedBlock: turnContext.guidedBlock,
transcript: turnContext.transcript,
languageName: LANGUAGE_NAMES[locale],
...(memory ? { memoryBlock: memory.text } : {}),
});
// The recheck and follow-up rules are about tables: they ride the
// prompt once the conversation holds one, or from the round after this
// turn produced its first.
const withTables = requestWith(true);
const toolRequest =
priorResults.length > 0 ? withTables : requestWith(false);
// The recheck and follow-up rules are about tables: they ride the turn
// once the conversation holds one, or from the round after this turn
// produced its first. v1.43 (#1217) — like every other line that can
// change from one turn or round to the next (the opener hint, the name
// clause, a resolved follow-up), they travel on the user side, so the
// system prompt and the tools stay one cached prefix for the whole
// conversation.
const tableRules = buildTableRulesAddendum(locale);
const toolRequest = buildCoachToolRequest({
systemPrompt: ctx.systemPrompt,
toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [
buildDialogAddenda(locale, { tableRules: false }),
]),
focusHint: renderFocusHint(effectiveScope?.sources),
workoutEvidence,
dataInventory: renderDataInventory(inventory),
priorResults: renderPriorResultRefs(priorResults),
guidedBlock: turnContext.guidedBlock,
transcript: turnContext.transcript,
languageName: LANGUAGE_NAMES[locale],
turnNotes: [
...(priorResults.length > 0 ? [tableRules] : []),
ctx.turnNotes,
...args.turnHints,
]
.filter((block) => block.length > 0)
.join("\n\n"),
...(memory ? { memoryBlock: memory.text } : {}),
});
// v1.39.4 — every call this turn settled, by its turn-wide index, for
// the result tables. Only this turn's own calls ever reach them.
const settled: SettledToolCall[] = [];
@@ -420,7 +427,7 @@ export async function runTurnModel(args: {
userId,
providers: chain,
system: toolRequest.system,
systemOnceTableShown: withTables.system,
...(priorResults.length > 0 ? {} : { tableRulesOnceShown: tableRules }),
messages: toolRequest.messages,
tools: COACH_TURN_TOOL_DEFS,
temperature: AI_BUDGETS.coach.temperature,
@@ -631,10 +638,18 @@ export async function runTurnModel(args: {
});
const summaries: string[] = [];
// v1.41 — the memory block rides the user turn here too, ahead of the
// snapshot, fenced like the rest of the person's own context.
const user = memory
? `${memory.text}\n\n${ctx.userPrompt}`
: ctx.userPrompt;
// snapshot, fenced like the rest of the person's own context. v1.43
// (#1217) — so do this turn's own lines (opener hint, name clause, a
// resolved follow-up), which used to ride the system prompt and rewrote
// its cached prefix on every turn.
const user = [
memory?.text,
ctx.turnNotes,
...args.turnHints,
ctx.userPrompt,
]
.filter((block): block is string => Boolean(block))
.join("\n\n");
const fallback = await runStreamingRawCompletionWithFallback({
surface: "coach",
userId,
@@ -644,7 +659,7 @@ export async function runTurnModel(args: {
},
params: {
...singleUserTurn({
system: appendBlocks(ctx.systemPrompt, args.turnHints),
system: ctx.systemPrompt,
user,
temperature: AI_BUDGETS.coach.temperature,
maxTokens: AI_BUDGETS.coach.maxTokens,
@@ -5,8 +5,9 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { row } = vi.hoisted(() => ({
const { row, user } = vi.hoisted(() => ({
row: { current: null as unknown, fail: false },
user: { level: null as string | null },
}));
vi.mock("@/lib/db", () => ({
@@ -17,6 +18,9 @@ vi.mock("@/lib/db", () => ({
return row.current;
}),
},
user: {
findUnique: vi.fn(async () => ({ aiUsageLevel: user.level })),
},
},
}));
vi.mock("@/lib/logging/context", () => ({
@@ -27,11 +31,13 @@ vi.mock("@/lib/logging/context", () => ({
import {
loadReasoningControls,
resolveCoachTurnReasoningLevel,
resolveJobReasoning,
} from "../controls";
beforeEach(() => {
row.current = null;
row.fail = false;
user.level = null;
});
describe("loadReasoningControls", () => {
@@ -88,3 +94,41 @@ describe("resolveCoachTurnReasoningLevel", () => {
);
});
});
// #1217 — background reasoning per AI usage level.
describe("resolveJobReasoning by usage level", () => {
const chain = [{ providerType: "openai" as const }];
it("asks for nothing on frugal", async () => {
user.level = "FRUGAL";
row.current = { aiUsageMaxLevel: "FULL", aiUsageDefaultLevel: "STANDARD" };
expect(
await resolveJobReasoning("daily_briefing", chain, "u1"),
).toBeUndefined();
});
it.each(["STANDARD", "FULL"])(
"keeps the job's level on %s",
async (level) => {
user.level = level;
row.current = {
aiUsageMaxLevel: "FULL",
aiUsageDefaultLevel: "STANDARD",
};
expect(await resolveJobReasoning("daily_briefing", chain, "u1")).toEqual({
effort: "medium",
summaries: false,
});
},
);
it("follows a frugal instance default for an account without a choice", async () => {
row.current = {
aiUsageMaxLevel: "STANDARD",
aiUsageDefaultLevel: "FRUGAL",
};
expect(
await resolveJobReasoning("daily_briefing", chain, "u1"),
).toBeUndefined();
});
});
+29
View File
@@ -13,6 +13,11 @@ import { isOperatorFundedProvider } from "@/lib/ai/coach/budget";
import type { ProviderChainType } from "@/lib/ai/provider-chain";
import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority";
import type { CompletionParams } from "@/lib/ai/types";
import { loadRecordUsageLevel } from "@/lib/ai/capabilities/usage";
import {
AI_USAGE_LEVEL_EFFECTS,
type AiUsageLevel,
} from "@/lib/ai/usage-level";
import {
coachReasoningLevel,
type CoachPrefs,
@@ -167,9 +172,33 @@ export async function loadCoachReasoningState(args: {
export async function resolveJobReasoning(
job: BackgroundReasoningJob,
chain: ReadonlyArray<{ providerType: ProviderChainType }>,
/**
* v1.43 (#1217) — the record the job runs for. On the `frugal` AI usage
* level background jobs do not reason at all; the call goes out as it did
* before reasoning existed. Absent: no level applies.
*/
recordId?: string,
): Promise<CompletionParams["reasoning"]> {
// Reasoning is an improvement to a job, never a precondition for it: any
// failure here sends the call exactly as it went before reasoning existed.
if (recordId !== undefined) {
let level: AiUsageLevel;
try {
level = await loadRecordUsageLevel(recordId);
} catch {
return undefined;
}
if (!AI_USAGE_LEVEL_EFFECTS[level].backgroundReasoning) {
annotate({
meta: {
ai_reasoning_job: job,
ai_reasoning_effort: "off",
ai_reasoning_usage_level: level,
},
});
return undefined;
}
}
let resolved;
try {
resolved = resolveReasoning({
+311
View File
@@ -0,0 +1,311 @@
/**
* v1.43 (#1217) — AI usage in three levels.
*
* One setting per account instead of many knobs. The operator sets the highest
* level an account may choose (`maxLevel`) and the level an account without a
* choice runs at (`defaultLevel`), since the operator pays for the shared key;
* each person picks within that. A level decides how much background model
* work the account asks for. It never decides what data is stored or shown,
* and the interactive Coach is the same at every level (its own budget caps
* apply as before).
*
* This file is the vocabulary: the levels, what each one does, the resolver
* and the estimate. Pure, no I/O, so the account payload, the jobs, the
* routes, the admin readout and the tests all read the same table. The reads
* live in `./capabilities/usage.ts`.
*
* Wire spelling is lower case (`frugal` / `standard` / `full`); the database
* enum (`AiUsageLevel`) is upper case. {@link AI_USAGE_LEVELS} is the ceiling
* order: a level is allowed when it is at or below `maxLevel`.
*/
import {
AI_USAGE_LEVELS,
type AiUsageLevelWire,
} from "@/lib/validations/ai-usage";
export { AI_USAGE_LEVELS };
export type AiUsageLevel = AiUsageLevelWire;
/**
* Where the effective level comes from:
*
* - `user`: the account's own stored choice;
* - `default`: no stored choice, so the operator's default;
* - `admin_cap`: a stored choice above the operator's ceiling, capped at
* read time. The choice itself is kept, so raising the ceiling again
* restores it.
*/
export const AI_USAGE_SOURCES = ["user", "default", "admin_cap"] as const;
export type AiUsageSource = (typeof AI_USAGE_SOURCES)[number];
/** What one level does. Every effect a level has is a row here. */
export interface AiUsageLevelEffect {
/**
* Minimum hours between two status-card regenerations of one scope after a
* sync (a watch, an integration). `"instance"` reads the operator's sync
* gap (`AppSettings.aiSyncRegenGapHours`, else `AI_SYNC_REGEN_GAP_HOURS`,
* else six).
*/
syncRegenGapHours: number | "instance";
/** The same gap after a hand entry. */
manualRegenGapHours: number;
/**
* The status snapshot's char cap, measured on the compact JSON the prompt
* ships (`degradeStatusSnapshotToBudget`). Fourteen thousand holds every
* snapshot that fitted the old 24 000 pretty-printed characters.
*/
statusSnapshotChars: number;
/**
* Whether the background jobs that may reason (the briefing, the period
* narratives, the Coach memory) ask for it. When true the job's own level
* applies (`BACKGROUND_REASONING_LEVEL`), still capped by the operator.
*/
backgroundReasoning: boolean;
/**
* The daily briefing's form. `short`: the lead plus today, at most one
* recommendation; every field of the contract stays present, so a client
* decodes it unchanged.
*/
briefing: "full" | "short";
/**
* Whether the account's own "read documents automatically" setting may
* act. When false, documents are read on request only.
*/
documentsAutoRead: boolean;
/**
* Relative token use, `full` = 1. Scales the measured use of the effective
* level into an estimate for another level. Calibrated with the generation
* simulation of a watch-synced account
* (`src/lib/insights/__tests__/usage-level-simulation.test.ts`): status-card
* generations a day, the nightly warm included, are 141 on `full`, 44 on
* `standard` (0.31) and 21 on `frugal` (0.15, before its shorter briefing
* and switched-off reasoning). The Coach is part of the measured use and
* does not scale, so the result is published and shown as an estimate.
*/
tokenFactor: number;
}
export const AI_USAGE_LEVEL_EFFECTS: Readonly<
Record<AiUsageLevel, Readonly<AiUsageLevelEffect>>
> = Object.freeze({
frugal: {
syncRegenGapHours: 24,
manualRegenGapHours: 6,
statusSnapshotChars: 10_000,
backgroundReasoning: false,
briefing: "short",
documentsAutoRead: false,
tokenFactor: 0.15,
},
standard: {
syncRegenGapHours: "instance",
manualRegenGapHours: 1,
statusSnapshotChars: 14_000,
backgroundReasoning: true,
briefing: "full",
documentsAutoRead: true,
tokenFactor: 0.3,
},
full: {
syncRegenGapHours: 1,
manualRegenGapHours: 1,
statusSnapshotChars: 14_000,
backgroundReasoning: true,
briefing: "full",
documentsAutoRead: true,
tokenFactor: 1,
},
});
/** The instance's own defaults, before an operator changes anything. */
export const DEFAULT_AI_USAGE_MAX_LEVEL: AiUsageLevel = "standard";
export const DEFAULT_AI_USAGE_DEFAULT_LEVEL: AiUsageLevel = "standard";
// ── Order ───────────────────────────────────────────────────────────────────
export function usageLevelRank(level: AiUsageLevel): number {
return AI_USAGE_LEVELS.indexOf(level);
}
/** Whether `level` is at or above `min`. */
export function usageLevelAtLeast(
level: AiUsageLevel,
min: AiUsageLevel,
): boolean {
return usageLevelRank(level) >= usageLevelRank(min);
}
/** `level`, lowered to `max` when it lies above it. */
export function capUsageLevel(
level: AiUsageLevel,
max: AiUsageLevel,
): AiUsageLevel {
return usageLevelRank(level) > usageLevelRank(max) ? max : level;
}
export function isAiUsageLevel(value: unknown): value is AiUsageLevel {
return (
typeof value === "string" &&
(AI_USAGE_LEVELS as readonly string[]).includes(value)
);
}
// ── Database spelling ───────────────────────────────────────────────────────
export type AiUsageLevelDb = "FRUGAL" | "STANDARD" | "FULL";
export function usageLevelFromDb(
value: string | null | undefined,
): AiUsageLevel | null {
if (value == null) return null;
const lower = value.toLowerCase();
return isAiUsageLevel(lower) ? lower : null;
}
export function usageLevelToDb(level: AiUsageLevel): AiUsageLevelDb {
return level.toUpperCase() as AiUsageLevelDb;
}
// ── Resolution ──────────────────────────────────────────────────────────────
export interface AiUsageBounds {
/** The account's stored choice; `null` follows the default. */
preference: AiUsageLevel | null;
defaultLevel: AiUsageLevel;
maxLevel: AiUsageLevel;
}
export interface AiUsageResolution {
level: AiUsageLevel;
source: AiUsageSource;
}
/**
* The level in effect. A stored choice at or below the ceiling wins; above it
* the ceiling applies and the choice is kept (`admin_cap`). Without a choice
* the default applies, held to the ceiling too, so a default stored above
* the ceiling (the admin route refuses one, an old row might hold one) can
* never lift an account over it.
*/
export function resolveAiUsageLevel(bounds: AiUsageBounds): AiUsageResolution {
const { preference, defaultLevel, maxLevel } = bounds;
if (preference === null) {
return { level: capUsageLevel(defaultLevel, maxLevel), source: "default" };
}
if (usageLevelRank(preference) > usageLevelRank(maxLevel)) {
return { level: maxLevel, source: "admin_cap" };
}
return { level: preference, source: "user" };
}
// ── Effects ─────────────────────────────────────────────────────────────────
/** Where a batch of fresh measurements came from (`status-invalidation.ts`). */
export type IngestSource = "manual" | "sync";
/** Default sync gap when neither the operator nor the environment sets one. */
export const DEFAULT_SYNC_REGEN_GAP_HOURS = 6;
export const SYNC_REGEN_GAP_HOURS_MIN = 1;
export const SYNC_REGEN_GAP_HOURS_MAX = 48;
/** A sync gap in whole hours, or `null` when the value is not a usable one. */
export function parseSyncRegenGapHours(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;
}
/**
* Hours a scope waits after its last regeneration before an ingest of this
* source may regenerate it again.
*
* @param instanceSyncGapHours The operator's resolved sync gap, read by
* `standard`.
*/
export function statusRegenGapHours(
level: AiUsageLevel,
source: IngestSource,
instanceSyncGapHours: number,
): number {
const effect = AI_USAGE_LEVEL_EFFECTS[level];
if (source === "manual") return effect.manualRegenGapHours;
return effect.syncRegenGapHours === "instance"
? instanceSyncGapHours
: effect.syncRegenGapHours;
}
// ── Estimate ────────────────────────────────────────────────────────────────
/** Full days behind the measured mean, at most. */
export const AI_USAGE_MEASURE_DAYS = 7;
/** Days of history an estimate needs. */
export const AI_USAGE_ESTIMATE_MIN_DAYS = 3;
/**
* Tokens per day at `target`, scaled from what the account used at `from`.
* `null` without enough history.
*/
export function estimateTokensPerDay(args: {
measuredTokensPerDay: number | null;
measuredDays: number;
from: AiUsageLevel;
target: AiUsageLevel;
}): number | null {
if (args.measuredTokensPerDay === null) return null;
if (args.measuredDays < AI_USAGE_ESTIMATE_MIN_DAYS) return null;
const ratio =
AI_USAGE_LEVEL_EFFECTS[args.target].tokenFactor /
AI_USAGE_LEVEL_EFFECTS[args.from].tokenFactor;
return Math.round(args.measuredTokensPerDay * ratio);
}
/** `ai.usage` on `GET /api/auth/me` (OpenAPI `AiUsageState`). */
export interface AiUsageState {
level: AiUsageLevel;
preference: AiUsageLevel | null;
defaultLevel: AiUsageLevel;
maxLevel: AiUsageLevel;
source: AiUsageSource;
options: Array<{
level: AiUsageLevel;
allowed: boolean;
estimatedTokensPerDay: number | null;
}>;
measuredTokensPerDay: number | null;
measuredDays: number;
}
/** The whole published state, from the bounds and the measured use. */
export function buildAiUsageState(
args: AiUsageBounds & {
measuredTokensPerDay: number | null;
measuredDays: number;
},
): AiUsageState {
const { level, source } = resolveAiUsageLevel(args);
return {
level,
preference: args.preference,
defaultLevel: args.defaultLevel,
maxLevel: args.maxLevel,
source,
options: AI_USAGE_LEVELS.map((option) => ({
level: option,
allowed: usageLevelRank(option) <= usageLevelRank(args.maxLevel),
estimatedTokensPerDay: estimateTokensPerDay({
measuredTokensPerDay: args.measuredTokensPerDay,
measuredDays: args.measuredDays,
from: level,
target: option,
}),
})),
measuredTokensPerDay: args.measuredTokensPerDay,
measuredDays: args.measuredDays,
};
}
@@ -180,6 +180,9 @@ const fakeDb = vi.hoisted(() => {
encounter: { findMany: none },
// The AI capability loader's consent read: no receipts.
consentReceipt: { findMany: none },
// v1.43 — the loader's usage-level read: no stored choice, so the
// instance default applies.
user: { findUnique: async () => ({ aiUsageLevel: null }) },
};
});
@@ -0,0 +1,64 @@
/**
* #1217 — the automatic document read acts only while both the person's
* switch is on and their AI usage level allows it. On `frugal` documents are
* read on request only; the stored switch is left alone.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
user: { documentsAutoAiRead: true, aiUsageLevel: null as string | null },
settings: {
aiUsageMaxLevel: "FULL",
aiUsageDefaultLevel: "STANDARD",
} as Record<string, unknown> | null,
failSettings: false,
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(async () => db.user) },
appSettings: {
findUnique: vi.fn(async () => {
if (db.failSettings) throw new Error("db down");
return db.settings;
}),
},
},
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => undefined,
}));
import { documentAutoReadEnabled } from "../document-settings";
beforeEach(() => {
db.user = { documentsAutoAiRead: true, aiUsageLevel: null };
db.settings = { aiUsageMaxLevel: "FULL", aiUsageDefaultLevel: "STANDARD" };
db.failSettings = false;
});
describe("documentAutoReadEnabled", () => {
it.each([
["FRUGAL", false],
["STANDARD", true],
["FULL", true],
[null, true],
])("level %s with the switch on reads %s", async (level, expected) => {
db.user = { documentsAutoAiRead: true, aiUsageLevel: level };
expect(await documentAutoReadEnabled("u1")).toBe(expected);
});
it("stays off with the switch off, whatever the level", async () => {
db.user = { documentsAutoAiRead: false, aiUsageLevel: "FULL" };
expect(await documentAutoReadEnabled("u1")).toBe(false);
});
it("rests on a frugal instance default", async () => {
db.settings = {
aiUsageMaxLevel: "STANDARD",
aiUsageDefaultLevel: "FRUGAL",
};
expect(await documentAutoReadEnabled("u1")).toBe(false);
});
});
+18 -4
View File
@@ -12,13 +12,21 @@
* - the summary job and its catch-up pass run only while it is ON;
* - the auto-index-on-upload job stays strictly local when it is OFF and uses
* the document-order external pick when it is ON.
*
* v1.43 (#1217) — on the `frugal` AI usage level the setting rests: documents
* are read on request only, whatever the switch says, and the switch acts
* again as soon as the level allows it. The stored choice is never changed.
*/
import { prisma } from "@/lib/db";
import { loadRecordUsageLevel } from "@/lib/ai/capabilities/usage";
import { AI_USAGE_LEVEL_EFFECTS } from "@/lib/ai/usage-level";
/**
* True when the user has opted into automatic AI reading of uploaded documents.
* A missing row (deleted account raced against a job) resolves to `false` —
* fail-closed to the local-first posture, never to external egress.
* True when the user has opted into automatic AI reading of uploaded documents
* and their AI usage level lets the opt-in act. A missing row (deleted account
* raced against a job) resolves to `false` — fail-closed to the local-first
* posture, never to external egress; so does a usage level that cannot be
* read.
*/
export async function documentAutoReadEnabled(
userId: string,
@@ -27,5 +35,11 @@ export async function documentAutoReadEnabled(
where: { id: userId },
select: { documentsAutoAiRead: true },
});
return row?.documentsAutoAiRead ?? false;
if (!(row?.documentsAutoAiRead ?? false)) return false;
try {
const level = await loadRecordUsageLevel(userId);
return AI_USAGE_LEVEL_EFFECTS[level].documentsAutoRead;
} catch {
return false;
}
}
+1
View File
@@ -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}`,
+3 -1
View File
@@ -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}`,
);
+1
View File
@@ -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}`,
+3 -1
View File
@@ -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}`,
);
@@ -0,0 +1,123 @@
/**
* #1217 — the briefing's short form on the `frugal` usage level. The contract
* keeps its shape on every level: `recommendations` stays present (the
* native client decodes it as a required array), only shorter on `frugal`,
* and untouched on `standard` and `full`.
*/
import { describe, expect, it } from "vitest";
import { aiInsightResponseSchema } from "@/lib/ai/schema";
import { AI_USAGE_LEVEL_EFFECTS, AI_USAGE_LEVELS } from "@/lib/ai/usage-level";
import {
SHORT_FORM_LIMITS,
applyBriefingShortForm,
buildBriefingShortFormBlock,
} from "@/lib/insights/briefing-short-form";
function rec(id: string) {
return {
id,
text: `Recommendation ${id}`,
severity: "suggestion",
metricSource: {
type: "bloodPressure",
timeRange: "last30days",
summary: "avg 128/82",
},
rationale: {
dataWindow: "last30days",
comparedTo: "your 90-day median",
deviation: "+4 mmHg",
},
};
}
function finding(n: number) {
return {
tone: "info",
headline: `Finding ${n}`,
detail: "One sentence.",
delta: null,
sourceWindow: "30d",
sourceMetric: "bp",
};
}
const PAYLOAD = {
summary: "A steady day.",
recommendations: [rec("rec-1"), rec("rec-2"), rec("rec-3")],
citations: [
{ type: "bloodPressure", timeRange: "last30days", summary: "avg 128/82" },
],
warnings: [],
dailyBriefing: {
paragraph: "Today reads like a steady day.",
keyFindings: [finding(1), finding(2), finding(3), finding(4)],
},
trendAnnotations: { bp: "Steady over the month." },
storyboardAnnotations: [],
};
describe("briefing form per usage level", () => {
it("keeps the full contract on standard and full, the short form on frugal", () => {
expect(
AI_USAGE_LEVELS.map((level) => AI_USAGE_LEVEL_EFFECTS[level].briefing),
).toEqual(["short", "full", "full"]);
});
it("adds nothing to the prompt for the full form", () => {
expect(buildBriefingShortFormBlock("en", "full")).toBe("");
expect(buildBriefingShortFormBlock("de", "full")).toBe("");
});
it("asks for at most one recommendation on the short form, field kept", () => {
const en = buildBriefingShortFormBlock("en", "short");
expect(en).toContain('"recommendations": at most 1');
expect(en).toContain("every required field");
expect(buildBriefingShortFormBlock("de", "short")).toContain(
'"recommendations": höchstens 1',
);
});
});
describe("applyBriefingShortForm", () => {
it("validates against the briefing schema before and after", () => {
expect(aiInsightResponseSchema.safeParse(PAYLOAD).success).toBe(true);
const short = applyBriefingShortForm(PAYLOAD);
const parsed = aiInsightResponseSchema.safeParse(short);
expect(parsed.success).toBe(true);
});
it("keeps recommendations present, at most one", () => {
const short = applyBriefingShortForm(PAYLOAD);
expect(Array.isArray(short.recommendations)).toBe(true);
expect(short.recommendations).toHaveLength(
SHORT_FORM_LIMITS.recommendations,
);
expect(short.recommendations[0]!.id).toBe("rec-1");
expect(short.dailyBriefing.keyFindings).toHaveLength(
SHORT_FORM_LIMITS.keyFindings,
);
expect(short).not.toHaveProperty("trendAnnotations");
expect(short).not.toHaveProperty("storyboardAnnotations");
// Every required field of the contract is still there.
for (const key of ["summary", "recommendations", "citations", "warnings"]) {
expect(short).toHaveProperty(key);
}
});
it("keeps an empty recommendation list an empty list", () => {
const short = applyBriefingShortForm({ ...PAYLOAD, recommendations: [] });
expect(short.recommendations).toEqual([]);
});
it("leaves the input untouched", () => {
const before = structuredClone(PAYLOAD);
applyBriefingShortForm(PAYLOAD);
expect(PAYLOAD).toEqual(before);
});
it("passes a non-object through", () => {
expect(applyBriefingShortForm(null)).toBeNull();
});
});
@@ -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<string, unknown> {
const measurementSeries: Record<string, unknown> = {};
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<string, unknown>): Record<string, unknown>[] {
const out: Record<string, unknown>[] = [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<string, unknown>;
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<string, unknown>;
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);
});
});
@@ -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");
@@ -495,3 +495,83 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => {
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1);
});
});
// #1217 — the frugal usage level asks for the short briefing and holds its
// counts; the fingerprint carries the form so a level change regenerates.
describe("generateComprehensiveInsight — usage level", () => {
const reply = {
summary: "s",
recommendations: ["a", "b", "c"].map((id) => ({
id,
text: `Keep walking ${id}`,
severity: "info",
})),
citations: [],
warnings: [],
dailyBriefing: { paragraph: "p" },
};
function arrange(aiUsageLevel: string | null) {
resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: {} },
]);
findUnique.mockResolvedValue({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }),
insightsExcludeMetrics: [],
insightsSnapshotHash: FEATURES_HASH,
aiUsageLevel,
});
runRawCompletionWithFallback.mockResolvedValue({
result: {
content: JSON.stringify(reply),
tokensUsed: 10,
providerType: "openai",
model: "m",
},
workingProvider: { providerType: "openai" },
fallbackHops: [],
});
}
function storedPayload() {
const write = userUpdate.mock.calls.find(
(c) =>
(c[0] as { data: Record<string, unknown> }).data.insightsCachedText !==
undefined,
);
const data = (write![0] as { data: Record<string, unknown> }).data;
return {
payload: JSON.parse(data.insightsCachedText as string),
hash: data.insightsSnapshotHash,
};
}
it("asks a frugal account for the short form and keeps one recommendation", async () => {
arrange("FRUGAL");
const outcome = await generateComprehensiveInsight("u1", {
locale: "en",
force: true,
});
expect(outcome.status).toBe("generated");
const prompt = JSON.stringify(runRawCompletionWithFallback.mock.calls[0]);
expect(prompt).toContain("SHORT FORM");
const { payload, hash } = storedPayload();
expect(payload.recommendations.map((r: { id: string }) => r.id)).toEqual([
"a",
]);
expect(hash).not.toBe(FEATURES_HASH);
});
it.each(["STANDARD", "FULL", null])(
"keeps the full contract on %s",
async (level) => {
arrange(level);
await generateComprehensiveInsight("u1", { locale: "en", force: true });
const prompt = JSON.stringify(runRawCompletionWithFallback.mock.calls[0]);
expect(prompt).not.toContain("SHORT FORM");
expect(storedPayload().payload.recommendations).toHaveLength(3);
},
);
});
@@ -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,127 @@ 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);
});
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);
});
});
// #1217 — the gap per AI usage level: frugal holds a sync to a day and a hand
// entry to six hours, standard uses the operator's sync gap and the hour,
// full keeps the hour for both (the behaviour before v1.43).
describe("regeneration gap by usage level", () => {
const cases: Array<
[level: string | null, source: "sync" | "manual", hours: number]
> = [
["FRUGAL", "sync", 24],
["FRUGAL", "manual", 6],
["STANDARD", "sync", 6],
["STANDARD", "manual", 1],
["FULL", "sync", 1],
["FULL", "manual", 1],
// No choice: the instance default (standard).
[null, "sync", 6],
[null, "manual", 1],
];
it.each(cases)(
"%s after a %s ingest waits %i h",
async (level, source, hours) => {
userFindUnique.mockResolvedValue({ locale: "de", aiUsageLevel: level });
appSettingsFindUnique.mockResolvedValue({
aiUsageMaxLevel: "FULL",
aiUsageDefaultLevel: "STANDARD",
aiSyncRegenGapHours: null,
});
const before = Date.now();
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
source,
});
expect(probedGapHours(before)).toBe(hours);
},
);
it("caps a stored full choice at the operator's ceiling", async () => {
userFindUnique.mockResolvedValue({ locale: "de", aiUsageLevel: "FULL" });
appSettingsFindUnique.mockResolvedValue({
aiUsageMaxLevel: "STANDARD",
aiUsageDefaultLevel: "STANDARD",
aiSyncRegenGapHours: 8,
});
const before = Date.now();
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
source: "sync",
});
expect(probedGapHours(before)).toBe(8);
});
it("follows a frugal instance default for an account without a choice", async () => {
userFindUnique.mockResolvedValue({ locale: "de", aiUsageLevel: null });
appSettingsFindUnique.mockResolvedValue({
aiUsageMaxLevel: "STANDARD",
aiUsageDefaultLevel: "FRUGAL",
aiSyncRegenGapHours: null,
});
const before = Date.now();
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], {
source: "sync",
});
expect(probedGapHours(before)).toBe(24);
});
});
+11 -6
View File
@@ -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" },
);
});
});
@@ -0,0 +1,210 @@
/**
* #1217 — what the usage levels do to a watch-synced account, simulated
* deterministically through the real invalidator.
*
* One day of a phone that delivers a watch's samples every 15 minutes from
* 07:00 to 23:00, the morning sleep and recovery figures, two hand-entered
* blood-pressure readings and a hand-entered weight. Every scope the
* invalidator enqueues counts as one generation, the worst case the issue
* describes: today's partial bucket moves on every sync, so the content-hash
* gate never short-circuits a sync-driven run. The 04:30 nightly warm
* regenerates every scope once on every level and is counted apart.
*
* The clock, the cache rows and the queue are in memory; the scope mapping,
* the per-source gap and the level resolution are the production code.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const sim = vi.hoisted(() => ({
notes: new Map<string, number>(),
generations: 0,
level: null as string | null,
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(async () => ({
locale: "en",
aiUsageLevel: sim.level,
})),
},
appSettings: {
findUnique: vi.fn(async () => ({
aiUsageMaxLevel: "FULL",
aiUsageDefaultLevel: "STANDARD",
aiSyncRegenGapHours: null,
})),
},
insightStatusCache: {
findMany: vi.fn(
async (args: {
where: {
OR: Array<{ metric: string; locale: string }>;
generatedAt: { gte: Date };
};
}) =>
args.where.OR.filter(
(key) =>
(sim.notes.get(key.metric) ?? -Infinity) >=
args.where.generatedAt.gte.getTime(),
).map((key) => ({ metric: key.metric, locale: key.locale })),
),
},
},
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
enqueueStatusGeneration: vi.fn(async (job: { metric: string }) => {
sim.generations += 1;
sim.notes.set(job.metric, Date.now());
}),
}));
vi.mock("@/lib/measurements/live-types", () => ({
listLiveMeasurementTypes: vi.fn(async () => []),
}));
import { invalidateStatusInsightsForTypes } from "../status-invalidation";
import type { MeasurementType } from "@/generated/prisma/client";
type Event = {
at: string;
source: "sync" | "manual";
types: MeasurementType[];
};
/** One day of a watch account, minute-exact and the same on every run. */
function day(): Event[] {
const events: Event[] = [];
for (let minutes = 7 * 60; minutes <= 23 * 60; minutes += 15) {
const hh = String(Math.floor(minutes / 60)).padStart(2, "0");
const mm = String(minutes % 60).padStart(2, "0");
const types: MeasurementType[] = [
"ACTIVITY_STEPS",
"ACTIVE_ENERGY_BURNED",
"WALKING_RUNNING_DISTANCE",
"PULSE",
"FLIGHTS_CLIMBED",
];
if (minutes % 60 === 0)
types.push("TIME_IN_DAYLIGHT", "AUDIO_EXPOSURE_ENV");
if (minutes % 120 === 0) {
types.push("HEART_RATE_VARIABILITY", "WALKING_HEART_RATE_AVERAGE");
}
if (minutes === 7 * 60) {
types.push(
"SLEEP_DURATION",
"RESTING_HEART_RATE",
"RESPIRATORY_RATE",
"OXYGEN_SATURATION",
"WRIST_TEMPERATURE",
"VO2_MAX",
);
}
events.push({ at: `${hh}:${mm}`, source: "sync", types });
}
events.push(
{ at: "07:30", source: "manual", types: ["WEIGHT"] },
{
at: "08:00",
source: "manual",
types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
},
{
at: "20:00",
source: "manual",
types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
},
);
return events.sort((a, b) => a.at.localeCompare(b.at));
}
const ALL_SCOPES_AT_WARM = new Set<string>();
async function simulate(level: string | null): Promise<{
warm: number;
ingest: number;
bySource: Record<"sync" | "manual", number>;
}> {
sim.notes.clear();
sim.generations = 0;
sim.level = level;
// The 04:30 warm regenerates every scope the day will touch.
const warmAt = Date.parse("2026-10-15T04:30:00Z");
for (const scope of ALL_SCOPES_AT_WARM) sim.notes.set(scope, warmAt);
const warm = ALL_SCOPES_AT_WARM.size;
const bySource = { sync: 0, manual: 0 };
for (const event of day()) {
vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`));
const before = sim.generations;
await invalidateStatusInsightsForTypes("u1", event.types, {
source: event.source,
});
bySource[event.source] += sim.generations - before;
}
return { warm, ingest: sim.generations, bySource };
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
});
describe("generations per day on a watch-synced account", () => {
it("counts the scopes once to seed the warm", async () => {
// Discover every scope the day dirties: run it once on `full` with no
// warm, which enqueues each scope at least once.
ALL_SCOPES_AT_WARM.clear();
sim.notes.clear();
sim.level = "FULL";
for (const event of day()) {
vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`));
await invalidateStatusInsightsForTypes("u1", event.types, {
source: event.source,
});
}
for (const scope of sim.notes.keys()) ALL_SCOPES_AT_WARM.add(scope);
expect(ALL_SCOPES_AT_WARM.size).toBe(19);
});
it("before v1.43 (every ingest at one hour) versus each level", async () => {
const full = await simulate("FULL"); // the behaviour before v1.43
const standard = await simulate("STANDARD");
const frugal = await simulate("FRUGAL");
const result = { full, standard, frugal };
process.stdout.write(`SIMULATION ${JSON.stringify(result)}\n`);
// Ingest-driven generations a day (the warm adds 19 on every level).
expect(full).toEqual({
warm: 19,
ingest: 122,
bySource: { sync: 118, manual: 4 },
});
expect(standard).toEqual({
warm: 19,
ingest: 25,
bySource: { sync: 20, manual: 5 },
});
expect(frugal).toEqual({
warm: 19,
ingest: 2,
bySource: { sync: 0, manual: 2 },
});
// The contract's aim: standard well below a quarter of the old ingest
// volume, and below a third with the warm counted.
expect(standard.ingest / full.ingest).toBeLessThan(0.25);
expect(
(standard.warm + standard.ingest) / (full.warm + full.ingest),
).toBeLessThan(0.32);
// Hand entries are narrated within the hour on standard, as before; on
// full the sync drip had often refreshed the same scope just before.
expect(standard.bySource.manual).toBeGreaterThanOrEqual(
full.bySource.manual,
);
});
});
+1 -1
View File
@@ -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 },
+3 -1
View File
@@ -42,6 +42,7 @@ import {
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
@@ -641,8 +642,9 @@ export async function prepareBloodPressureStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+3 -1
View File
@@ -19,6 +19,7 @@ import {
scaleGradedSeries,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
@@ -334,8 +335,9 @@ export async function prepareBmiStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+1 -1
View File
@@ -127,7 +127,7 @@ export async function runBriefingCompletion(
// v1.41 — one resolution per call: the operator's switch and cap, the payer
// from the chain's primary. `undefined` is today's wire.
const reasoning = args.reasoningJob
? await resolveJobReasoning(args.reasoningJob, args.chain)
? await resolveJobReasoning(args.reasoningJob, args.chain, args.userId)
: undefined;
const thinkingTokens =
reasoning && reasoning.effort !== "off"
+108
View File
@@ -0,0 +1,108 @@
/**
* v1.43 (#1217) — the daily briefing's short form, for the `frugal` AI usage
* level.
*
* The person chose the level knowingly, so a shorter briefing is fine there:
* the lead and today, at most one recommendation, no trend or storyboard
* annotations. The contract keeps its shape on every level: every required
* field stays present (`recommendations` is decoded as a required array by
* the native client), only shorter. `standard` and `full` keep the full
* contract, recommendations included.
*
* The prompt asks for the short form; {@link applyBriefingShortForm} holds the
* counts afterwards, so a model that writes more than asked cannot widen it.
*/
import { instructionLocale } from "@/lib/ai/prompts/output-language";
import { loadRecordUsageLevel } from "@/lib/ai/capabilities/usage";
import {
AI_USAGE_LEVEL_EFFECTS,
DEFAULT_AI_USAGE_DEFAULT_LEVEL,
} from "@/lib/ai/usage-level";
import type { Locale } from "@/lib/i18n/config";
export type BriefingForm = "full" | "short";
/** At most this many entries of each list on the short form. */
export const SHORT_FORM_LIMITS = Object.freeze({
recommendations: 1,
signalsOfDay: 2,
keyFindings: 2,
});
/**
* The briefing form for one record's level. An unreadable level reads as the
* instance default's form.
*/
export async function briefingFormForRecord(
recordId: string,
): Promise<BriefingForm> {
try {
return AI_USAGE_LEVEL_EFFECTS[await loadRecordUsageLevel(recordId)]
.briefing;
} catch {
return AI_USAGE_LEVEL_EFFECTS[DEFAULT_AI_USAGE_DEFAULT_LEVEL].briefing;
}
}
/**
* The block appended to the briefing's user prompt on the short form. Empty
* for the full form, so a `standard` or `full` prompt is unchanged.
*/
export function buildBriefingShortFormBlock(
locale: Locale,
form: BriefingForm,
): string {
if (form === "full") return "";
if (instructionLocale(locale) === "de") {
return `
KURZFORM (vom Nutzer gewählte sparsame KI-Stufe)
Schreibe dieselbe JSON-Struktur mit allen Pflichtfeldern, nur kürzer:
- "summary": ein bis zwei Sätze.
- "dailyBriefing.paragraph": 40 bis 80 Wörter, ein Absatz: das Urteil des Tages und was es für heute heißt.
- "dailyBriefing.signalsOfDay": höchstens ${SHORT_FORM_LIMITS.signalsOfDay}; "dailyBriefing.keyFindings": höchstens ${SHORT_FORM_LIMITS.keyFindings}.
- "recommendations": höchstens ${SHORT_FORM_LIMITS.recommendations}, die wichtigste; ein leeres Array, wenn nichts ansteht.
- "trendAnnotations" und "storyboardAnnotations" weglassen.
Alle übrigen Regeln gelten unverändert.`;
}
return `
SHORT FORM (the person chose the frugal AI usage level)
Write the same JSON structure with every required field, only shorter:
- "summary": one or two sentences.
- "dailyBriefing.paragraph": 40 to 80 words, one paragraph: the day's verdict and what it means for today.
- "dailyBriefing.signalsOfDay": at most ${SHORT_FORM_LIMITS.signalsOfDay}; "dailyBriefing.keyFindings": at most ${SHORT_FORM_LIMITS.keyFindings}.
- "recommendations": at most ${SHORT_FORM_LIMITS.recommendations}, the most important one; an empty array when nothing is due.
- Leave out "trendAnnotations" and "storyboardAnnotations".
Every other rule still applies.`;
}
/**
* Hold a parsed briefing payload to the short form's counts. Pure: returns a
* copy, every field still present. A payload that is not an object comes
* back as it was.
*/
export function applyBriefingShortForm<T>(payload: T): T {
if (!payload || typeof payload !== "object") return payload;
const out = { ...(payload as Record<string, unknown>) };
if (Array.isArray(out.recommendations)) {
out.recommendations = out.recommendations.slice(
0,
SHORT_FORM_LIMITS.recommendations,
);
}
delete out.trendAnnotations;
delete out.storyboardAnnotations;
const briefing = out.dailyBriefing;
if (briefing && typeof briefing === "object") {
const b = { ...(briefing as Record<string, unknown>) };
if (Array.isArray(b.signalsOfDay)) {
b.signalsOfDay = b.signalsOfDay.slice(0, SHORT_FORM_LIMITS.signalsOfDay);
}
if (Array.isArray(b.keyFindings)) {
b.keyFindings = b.keyFindings.slice(0, SHORT_FORM_LIMITS.keyFindings);
}
out.dailyBriefing = b;
}
return out as T;
}
+19 -1
View File
@@ -104,6 +104,11 @@ import {
stripJsonFences,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import {
applyBriefingShortForm,
briefingFormForRecord,
buildBriefingShortFormBlock,
} from "@/lib/insights/briefing-short-form";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { annotate } from "@/lib/logging/context";
import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets";
@@ -965,7 +970,9 @@ export async function generateComprehensiveInsight(
const compactFeatures = compactSections(
features as unknown as Record<string, unknown>,
);
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)
@@ -1006,11 +1013,16 @@ export async function generateComprehensiveInsight(
const planProgressLines = await buildPlanProgressLines(userId).catch(
() => [] as string[],
);
// v1.43 (#1217) — the short form on the `frugal` usage level. It joins the
// fingerprint only when it applies, so every other account keeps its stored
// hash, and a level change regenerates the briefing once.
const briefingForm = await briefingFormForRecord(userId);
const snapshotHash = hashInsightSnapshot({
features: compactFeatures,
aboutMe: aboutMe ?? null,
comparisonBaseline,
generationLocale: locale,
...(briefingForm === "short" ? { briefingForm } : {}),
// Absent without an active plan, so an account without one keeps its
// stored fingerprint; with one, a plan that moves regenerates the
// briefing the way moved data does.
@@ -1074,6 +1086,7 @@ export async function generateComprehensiveInsight(
locale,
await resolveUserTimezone(userId),
);
userPrompt += buildBriefingShortFormBlock(locale, briefingForm);
const systemPrompt = buildSystemPromptWithReferences(
locale,
referenceMetrics,
@@ -1587,6 +1600,11 @@ export async function generateComprehensiveInsight(
return failBeforeProvider(userId, locale, "aborted");
}
// v1.43 (#1217) — the short form holds its counts whatever the model wrote.
if (briefingForm === "short") {
insights = applyBriefingShortForm(insights);
}
// The payload was built under the privacy and profile scope captured before
// the provider call. Recheck both before the write so an inclusion change
// cannot be overwritten by stale in-flight content.
@@ -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}
+3 -1
View File
@@ -22,6 +22,7 @@ import {
buildGradedSeriesFromPoints,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import {
normalizeLocale,
finalizeStatusSummary,
@@ -418,8 +419,9 @@ export async function prepareGeneralStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+32 -14
View File
@@ -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:
* Walk an arbitrary snapshot object and, while it exceeds the cap
* (`options.maxChars`, else `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
@@ -706,11 +714,21 @@ export const MAX_SNAPSHOT_CHARS = 24_000;
*/
export function degradeStatusSnapshotToBudget(
snapshot: Record<string, unknown>,
arrayTailCap = 30,
options: {
/**
* v1.43 (#1217) — the cap for this record's AI usage level
* (`statusSnapshotChars`, `@/lib/ai/usage-level`). Defaults to
* `MAX_SNAPSHOT_CHARS`.
*/
maxChars?: number;
arrayTailCap?: number;
} = {},
): string[] {
const maxChars = options.maxChars ?? MAX_SNAPSHOT_CHARS;
const arrayTailCap = options.arrayTailCap ?? 30;
const shed: string[] = [];
const size = () => JSON.stringify(snapshot, null, 2).length;
if (size() <= MAX_SNAPSHOT_CHARS) return shed;
const size = () => JSON.stringify(snapshot).length;
if (size() <= maxChars) return shed;
const walk = (
node: unknown,
@@ -738,14 +756,14 @@ export function degradeStatusSnapshotToBudget(
};
dropField("yearly");
if (size() <= MAX_SNAPSHOT_CHARS) {
if (size() <= maxChars) {
shed.push("yearly");
return shed;
}
shed.push("yearly");
dropField("weekly");
if (size() <= MAX_SNAPSHOT_CHARS) {
if (size() <= maxChars) {
shed.push("weekly");
return shed;
}
@@ -761,7 +779,7 @@ export function degradeStatusSnapshotToBudget(
parent[key] = value.slice(-arrayTailCap);
}
});
if (size() <= MAX_SNAPSHOT_CHARS) {
if (size() <= maxChars) {
shed.push("array-tails");
return shed;
}
@@ -28,6 +28,7 @@ import {
} from "@/lib/insights/assessment-context";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series";
import {
@@ -405,8 +406,9 @@ export async function prepareMedicationComplianceStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+3 -1
View File
@@ -62,6 +62,7 @@ import {
degradeStatusSnapshotToBudget,
type GradedSeries,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import {
type SupportedLocale,
normalizeLocale,
@@ -453,8 +454,9 @@ export async function generateMetricStatus(args: {
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(args.userId) },
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+3 -1
View File
@@ -38,6 +38,7 @@ import {
buildGradedSeriesFromPoints,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
@@ -664,8 +665,9 @@ export async function prepareMoodStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+3 -1
View File
@@ -27,6 +27,7 @@ import {
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
@@ -391,8 +392,9 @@ export async function preparePulseStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+5 -3
View File
@@ -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. */
+64 -36
View File
@@ -31,42 +31,52 @@ import {
import { annotate } from "@/lib/logging/context";
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate";
import { loadRecordAiUsage } from "@/lib/ai/capabilities/usage";
import { statusRegenGapHours, type IngestSource } from "@/lib/ai/usage-level";
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, a file they chose to import). It should be narrated
* the same day, so its gap stays short.
* - `sync`: rows a device or an integration delivered on its own schedule
* (the phone's Apple Health sync, Withings, Oura, Fitbit, Polar, WHOOP,
* Nightscout, Google Health, 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 = IngestSource;
/**
* The 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 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. Since v1.43 (#1217) the gap is chosen by source and by the record's AI
* usage level (`statusRegenGapHours` in `@/lib/ai/usage-level`): a hand entry
* keeps the hour (six on `frugal`), a sync waits the operator's sync gap on
* `standard` (default six hours), a day on `frugal`, the hour on `full`.
*/
async function ingestRegenGapMs(
userId: string,
source: StatusIngestSource,
): Promise<{ gapMs: number; level: string }> {
const usage = await loadRecordAiUsage(userId);
const hours = statusRegenGapHours(
usage.level,
source,
usage.settings.syncRegenGapHours,
);
return { gapMs: hours * 60 * 60 * 1000, level: usage.level };
}
/**
* The seven per-metric assessment scopes. Each generator persists its
@@ -130,6 +140,7 @@ function statusScopesForMeasurementType(
export async function invalidateStatusInsightsForTypes(
userId: string,
types: Iterable<MeasurementType>,
options: { source: StatusIngestSource },
): Promise<void> {
// One ordered set of every scope the batch dirties. The seven specialised
// scopes are bare slugs (`weight`, `general`, …); the generic HealthKit
@@ -184,14 +195,25 @@ 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, level } = await ingestRegenGapMs(userId, 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,
usage_level: level,
},
});
return;
}
@@ -210,7 +232,12 @@ 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,
usage_level: level,
},
});
}
@@ -268,7 +295,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 +308,9 @@ async function findRecentlyWarmedScopes(
userId: string,
locale: SupportedLocale,
scopes: ReadonlySet<InsightStatusScope>,
gapMs: number,
): Promise<Set<InsightStatusScope>> {
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),
+1 -1
View File
@@ -216,7 +216,7 @@ export async function runStatusCompletion(
// so a refused call never reads the controls. `undefined` (the operator
// switched reasoning off, or the job resolved off) is today's wire.
const reasoning = args.reasoningJob
? await resolveJobReasoning(args.reasoningJob, chain)
? await resolveJobReasoning(args.reasoningJob, chain, userId)
: undefined;
const thinkingTokens =
reasoning && reasoning.effort !== "off"
+3 -1
View File
@@ -33,6 +33,7 @@ import {
convertGradedSeries,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
@@ -607,8 +608,9 @@ export async function prepareWeightStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(userId) },
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+5 -1
View File
@@ -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,
+5 -1
View File
@@ -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,
+2 -2
View File
@@ -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" },
);
});
});
+2 -2
View File
@@ -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", {
@@ -96,7 +96,7 @@ describe("logTelegramMeasurement", () => {
expect(afterMeasurementMutation).toHaveBeenCalledWith(
"user-1",
[expect.objectContaining({ type: "WEIGHT" })],
"telegram-measurement",
{ source: "manual", label: "telegram-measurement" },
);
});
+1 -1
View File
@@ -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" };
@@ -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 },
+1
View File
@@ -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}`,
+3
View File
@@ -43,7 +43,10 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"ai.provider.none",
"ai.record.notPermitted",
"ai.unavailable",
"ai.usage.aboveCeiling",
"ai.usage.defaultAboveCeiling",
"ai.usage.invalid",
"ai.usage.levelTooLow",
"ai.usage.rateLimited",
],
ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"],
+1 -7
View File
@@ -11,12 +11,7 @@ import { z } from "zod/v4";
import { AI_USAGE_LEVELS, aiUsagePutSchema } from "@/lib/validations/ai-usage";
import {
dataEnvelope,
errorEnvelope,
notImplementedResponse,
stdResponses,
} from "./shared";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
export const aiUsageLevel = z.enum(AI_USAGE_LEVELS).meta({
id: "AiUsageLevel",
@@ -111,7 +106,6 @@ export const aiUsagePaths: NonNullable<ZodOpenApiObject["paths"]> = {
"`ai.usage.invalid` (body) or `ai.usage.aboveCeiling` (`meta.maxLevel` names the ceiling).",
content: { "application/json": { schema: errorEnvelope } },
},
...notImplementedResponse,
},
},
},
+3 -3
View File
@@ -129,7 +129,7 @@ export const moduleAccessMap = z
export const aiUnavailableReason = z.enum(AI_UNAVAILABLE_REASONS).meta({
id: "AiUnavailableReason",
description:
"Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is reported, because it names the layer that would have to change first. `check_failed` — an input could not be read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does not know treats the capability as unavailable.",
"Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is reported, because it names the layer that would have to change first. `check_failed` — an input could not be read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. `usage_level` — the record's AI usage level (`ai.usage.level`, v1.43) is below what the capability needs; today only `supplementLabel` on `frugal`. `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does not know treats the capability as unavailable.",
});
export const aiCapabilityState = z
@@ -141,7 +141,7 @@ export const aiCapabilityState = z
onDeviceAllowed: z
.boolean()
.describe(
"Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, `module_disabled`, `user_disabled` and `check_failed`: the operator's and the person's decisions hold on the device too. True otherwise, since a missing server provider or a missing consent for server egress do not concern a model that runs on the device. Resolved here so the client never decides which reasons apply off the server.",
"Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, `module_disabled`, `user_disabled`, `usage_level` and `check_failed`: the operator's and the person's decisions hold on the device too. True otherwise, since a missing server provider or a missing consent for server egress do not concern a model that runs on the device. Resolved here so the client never decides which reasons apply off the server.",
),
})
.meta({
@@ -164,7 +164,7 @@ export const aiCapabilities = z
.meta({
id: "AiCapabilities",
description:
"Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted into. `periodNarrative` — the model-written half of a period narrative (the deterministic narrative is data). `statusText` — per-metric status notes and the AI override of a derived assessment. `workoutInsights` — the paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule.",
"Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted into. `periodNarrative` — the model-written half of a period narrative (the deterministic narrative is data). `statusText` — per-metric status notes and the AI override of a derived assessment. `workoutInsights` — the paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule. `supplementLabel` (v1.43) — reading a supplement's label photo into its ingredients; unavailable on the `frugal` usage level.",
});
export const aiProviderState = z
+8 -2
View File
@@ -91,13 +91,13 @@ export const errorEnvelope = z
.string()
.optional()
.describe(
"On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, `documentAi`, `labsOcr`, `medicationExtract`).",
"On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, `documentAi`, `labsOcr`, `medicationExtract`, `supplementLabel`).",
),
reason: z
.string()
.optional()
.describe(
"On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it: `operator_disabled` → `assistant.disabled.<switch>` (or `module.disabled` with `module` when the operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`, `module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own typed code where it has one, otherwise `ai.provider.none` (422), `consent_required` → `consent.ai.required`, `check_failed` → `ai.unavailable` (503).",
"On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it: `operator_disabled` → `assistant.disabled.<switch>` (or `module.disabled` with `module` when the operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`, `module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own typed code where it has one, otherwise `ai.provider.none` (422), `usage_level` → `ai.usage.levelTooLow` with `requiredLevel`, `consent_required` → `consent.ai.required`, `check_failed` → `ai.unavailable` (503).",
),
module: z
.string()
@@ -105,6 +105,12 @@ export const errorEnvelope = z
.describe(
"Beside `module.disabled`: the module that is off. On an AI refusal it is the capability's owning module.",
),
requiredLevel: z
.enum(["frugal", "standard", "full"])
.optional()
.describe(
"Beside `ai.usage.levelTooLow` (v1.43): the lowest AI usage level at which the capability is offered.",
),
})
.optional(),
})
+5 -1
View File
@@ -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}`,
+1
View File
@@ -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}`,
@@ -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));
+18 -4
View File
@@ -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<void> {
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);
}),
);
+1 -1
View File
@@ -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: {
@@ -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" },
);
});
});
+4 -1
View File
@@ -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(
+7 -7
View File
@@ -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 (
+1 -1
View File
@@ -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
+1
View File
@@ -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}`,
+1
View File
@@ -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}`,
+1
View File
@@ -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}`,
+1
View File
@@ -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}`,