diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6c1255829..4432b95d6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -81006,11 +81006,12 @@ components: - standard - full description: "How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at - most every 24 h after a sync and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a - short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic - document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a - hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). - The interactive Coach is the same at every level." + most every 24 h after a sync (or the operator's sync gap when that is longer, so never more often than + `standard`) and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a short daily + briefing (`recommendations` holds at most one entry, every field stays present), no automatic document reading, + no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a hand entry, + background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). The + interactive Coach is the same at every level." CoachReasoningState: type: object properties: diff --git a/src/lib/ai/__tests__/usage-level.test.ts b/src/lib/ai/__tests__/usage-level.test.ts index 7b06d9c0c..bee432add 100644 --- a/src/lib/ai/__tests__/usage-level.test.ts +++ b/src/lib/ai/__tests__/usage-level.test.ts @@ -61,6 +61,9 @@ describe("resolveAiUsageLevel — every choice x default x ceiling", () => { 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); + // Never more often than standard: an operator gap above a day wins. + expect(statusRegenGapHours("frugal", "sync", 48)).toBe(48); + expect(statusRegenGapHours("standard", "sync", 48)).toBe(48); expect(statusRegenGapHours("standard", "sync", 6)).toBe(6); expect(statusRegenGapHours("standard", "sync", 12)).toBe(12); expect(statusRegenGapHours("full", "sync", 12)).toBe(1); diff --git a/src/lib/ai/usage-level.ts b/src/lib/ai/usage-level.ts index e81673b15..cba27e992 100644 --- a/src/lib/ai/usage-level.ts +++ b/src/lib/ai/usage-level.ts @@ -44,9 +44,12 @@ 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). + * else six). `{ instanceAtLeast }` reads the same gap but never goes + * below the floor, so a level meant to regenerate less often than + * `standard` cannot end up regenerating more often on an instance whose + * operator set a gap above the floor. */ - syncRegenGapHours: number | "instance"; + syncRegenGapHours: number | "instance" | { instanceAtLeast: number }; /** The same gap after a hand entry. */ manualRegenGapHours: number; /** @@ -89,7 +92,7 @@ export const AI_USAGE_LEVEL_EFFECTS: Readonly< Record> > = Object.freeze({ frugal: { - syncRegenGapHours: 24, + syncRegenGapHours: { instanceAtLeast: 24 }, manualRegenGapHours: 6, statusSnapshotChars: 10_000, backgroundReasoning: false, @@ -235,9 +238,10 @@ export function statusRegenGapHours( ): number { const effect = AI_USAGE_LEVEL_EFFECTS[level]; if (source === "manual") return effect.manualRegenGapHours; - return effect.syncRegenGapHours === "instance" - ? instanceSyncGapHours - : effect.syncRegenGapHours; + const gap = effect.syncRegenGapHours; + if (gap === "instance") return instanceSyncGapHours; + if (typeof gap === "number") return gap; + return Math.max(gap.instanceAtLeast, instanceSyncGapHours); } // ── Estimate ──────────────────────────────────────────────────────────────── diff --git a/src/lib/insights/status-invalidation.ts b/src/lib/insights/status-invalidation.ts index 033b289fa..d3f818ccf 100644 --- a/src/lib/insights/status-invalidation.ts +++ b/src/lib/insights/status-invalidation.ts @@ -63,7 +63,8 @@ export type StatusIngestSource = IngestSource; * 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`. + * `standard` (default six hours), a day on `frugal` (or the operator's gap + * when it is longer), the hour on `full`. */ async function ingestRegenGapMs( userId: string, diff --git a/src/lib/openapi/routes/ai-usage.ts b/src/lib/openapi/routes/ai-usage.ts index 1711b4267..e70982236 100644 --- a/src/lib/openapi/routes/ai-usage.ts +++ b/src/lib/openapi/routes/ai-usage.ts @@ -16,7 +16,7 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; export const aiUsageLevel = z.enum(AI_USAGE_LEVELS).meta({ id: "AiUsageLevel", description: - "How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at most every 24 h after a sync and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). The interactive Coach is the same at every level.", + "How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at most every 24 h after a sync (or the operator's sync gap when that is longer, so never more often than `standard`) and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). The interactive Coach is the same at every level.", }); export const aiUsageState = z