fix(ai): background generation cannot spend the interactive day

The automatic generators — status and reference cards, the briefing, the
arrival reaction, the nudge, document summaries and the index backfill —
reserved against the same ceiling as the chat. On the operator's account
that meant 150-330 ledger rows a day, 172 metric generations before
06:42Z, and a ceiling already spent when he opened the chat.

Every background surface now reserves through
`resolveDailyCapFor("job", chain)`: half the cost owner's ceiling, all
job surfaces together. The interactive routes keep the whole ceiling, so
a day of background work can no longer take the user's own away.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 10:09:34 +02:00
1 parent 2c46712e13
commit d3196ee69e
38 files changed
+151 -17

No files matched your search

@@ -32,6 +32,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-16"),
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCapFor: vi.fn(() => 100_000),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: { ocrExtractText: { temperature: 0, maxTokens: 2000 } },
@@ -99,6 +100,8 @@ beforeEach(() => {
allowed: true,
reserved: 10,
totalAfter: 10,
owner: "operator",
operatorAfter: 10,
} as Awaited<ReturnType<typeof reserveBudget>>);
mockExtract.mockResolvedValue({
reportDate: "2026-07-10",
@@ -29,6 +29,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 600 }),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 100000),
resolveDailyCapFor: vi.fn(() => 100000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
@@ -45,6 +45,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
@@ -56,6 +56,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 1000),
resolveDailyCapFor: vi.fn(() => 1000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/ai-budgets", () => ({
+7 -2
View File
@@ -32,6 +32,7 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveDailyCapFor,
} from "@/lib/ai/coach/budget";
import { loadDocumentChatText } from "@/lib/documents/content-index";
import { resolveIndexProvider } from "@/lib/documents/index-document";
@@ -276,13 +277,17 @@ export async function maybeAutoStageLabFacts(
}
// A text-structuring pass, not a vision call — reserve the proportionate
// ceiling under the same daily cap the auto-index resolved.
// ceiling under the cost owner the auto-index resolved.
// v1.38.19 (Wave E) — auto-staging runs off the upload, with no one waiting
// on it: a background surface, so half the day's ceiling.
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.ocrExtractText.maxTokens,
dateKey,
provider.dailyCap,
resolveDailyCapFor("job", [
{ providerType: provider.pick.entry.providerType },
]),
provider.costOwner,
);
if (!reservation.allowed) return { staged: false, reason: "budget" };