fix(ai-usage): never regenerate more often on frugal than on standard

The frugal sync gap is the larger of a day and the operator's sync gap,
so an instance gap above 24 hours no longer leaves frugal ahead of
standard.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 05:38:36 +02:00
1 parent b99b70127d
commit 120b0966b1
5 files changed
+22 -13

No files matched your search

+6 -5
View File
@@ -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:
+3
View File
@@ -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);
+10 -6
View File
@@ -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<AiUsageLevel, Readonly<AiUsageLevelEffect>>
> = 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 ────────────────────────────────────────────────────────────────
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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