refactor(ai): centralise per-surface generation budgets

Replace the scattered `?? 0.3` / `?? 1000` temperature and maxTokens
magic numbers across the status provider, Coach route, comprehensive
generator and period-narrative generator with a single typed
`ai-budgets.ts` map. Each entry documents the output contract that
fixes its token ceiling.

Right-size the per-metric status output from 1000 to 250 tokens: the
card renders one `{ "summary": "..." }` of 2-4 sentences (30-60 words),
so 1000 was an order of magnitude over the real ceiling.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 20:57:37 +02:00
1 parent 3e04035de8
commit 7530324fd1
6 files changed
+136 -8

No files matched your search

+3 -2
View File
@@ -46,6 +46,7 @@ import { resolveProviderChain, resolveProvider } from "@/lib/ai/provider";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import type { CompletionResult } from "@/lib/ai/types";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import {
coachChatRequestSchema,
@@ -439,8 +440,8 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
params: {
systemPrompt,
userPrompt,
temperature: 0.4,
maxTokens: 600,
temperature: AI_BUDGETS.coach.temperature,
maxTokens: AI_BUDGETS.coach.maxTokens,
},
});
result = fallback.result;
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { AI_BUDGETS, REFERENCE_AI_SEED } from "../ai-budgets";
describe("AI_BUDGETS", () => {
it("right-sizes the per-metric status output to its 30-60 word contract", () => {
// The status card renders exactly one `{ "summary": "..." }` of 2-4
// sentences; 1000 was over-generous. 250 caps the ceiling without
// clipping the contract.
expect(AI_BUDGETS.status.maxTokens).toBe(250);
expect(AI_BUDGETS.status.temperature).toBe(0.3);
});
it("keeps the comprehensive briefing budget at 1500 / 0.3", () => {
expect(AI_BUDGETS.comprehensive.maxTokens).toBe(1500);
expect(AI_BUDGETS.comprehensive.temperature).toBe(0.3);
});
it("keeps the narrative budget at 400 / 0.3", () => {
expect(AI_BUDGETS.narrative.maxTokens).toBe(400);
expect(AI_BUDGETS.narrative.temperature).toBe(0.3);
});
it("keeps the coach budget at 600 / 0.4", () => {
expect(AI_BUDGETS.coach.maxTokens).toBe(600);
expect(AI_BUDGETS.coach.temperature).toBe(0.4);
});
it("keeps the worker budgets (summary / facts / self-context)", () => {
expect(AI_BUDGETS.summary).toEqual({ temperature: 0.3, maxTokens: 200 });
expect(AI_BUDGETS.facts).toEqual({ temperature: 0.2, maxTokens: 300 });
expect(AI_BUDGETS.selfContext.temperature).toBe(0.4);
});
it("exposes a stable reference seed constant", () => {
expect(typeof REFERENCE_AI_SEED).toBe("number");
expect(Number.isInteger(REFERENCE_AI_SEED)).toBe(true);
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* Per-surface AI generation budgets — the single source of truth for the
* `temperature` and `maxTokens` each AI surface sends to the provider.
*
* Before this module the values were scattered as `?? 0.3` / `?? 1000`
* magic numbers across the status provider, the Coach route, the
* comprehensive generator and the period-narrative generator. Centralising
* them keeps the cost ceiling auditable in one place and ties every number
* to the output contract it serves (commented per entry below).
*
* `seed` (where present) is a deterministic constant pinned for the
* reference/reproducible surfaces (per the v1.18.7 AI audit MEDIUM-4):
* the status cards and the period narrative are reference assessments, so a
* fixed seed makes a prompt change diff-able against a stable baseline. The
* daily-briefing paragraph is deliberately NOT seeded (it re-rolls for
* phrasing variety — see comprehensive-generate.ts).
*/
export interface AiBudget {
/** Sampling temperature. Low for reference surfaces, slightly higher for chat. */
temperature: number;
/** Output-token ceiling. Sized to the surface's documented output contract. */
maxTokens: number;
}
/**
* Deterministic seed pinned for the reproducible reference surfaces. A stable
* constant (not a per-request value) so two runs of the same prompt+snapshot
* on a seed-aware provider produce byte-identical text — the QA-attribution
* baseline the audit's MEDIUM-4 calls for.
*/
export const REFERENCE_AI_SEED = 1_618_033 as const;
/**
* Surface budgets. Each entry's comment states the output contract that
* fixes its `maxTokens`.
*/
export const AI_BUDGETS = {
/**
* Comprehensive insight + dailyBriefing — the largest structured payload
* (summary + recommendations + dailyBriefing 80-200 words + signals +
* keyFindings + trendAnnotations + storyboardAnnotations). 1500 tokens.
*/
comprehensive: { temperature: 0.3, maxTokens: 1500 },
/**
* Per-metric status assessment cards — output is a single
* `{ "summary": "..." }` of 2-4 sentences / 30-60 words
* (base-system.ts `length` section). ~80 tokens of content + JSON
* envelope; 250 is a comfortable ceiling (was an over-generous 1000).
*/
status: { temperature: 0.3, maxTokens: 250 },
/**
* Period narrative (week/month) — 2-4 short plain-text sentences
* (period-narrative-generate.ts SYSTEM_PROMPT). 400 tokens.
*/
narrative: { temperature: 0.3, maxTokens: 400 },
/**
* Coach SSE chat — a 60-180 word prose reply plus an optional evidence
* sentinel block (system-prompt.ts rule 1). 600 tokens. Slightly higher
* temperature than the reference surfaces for conversational variety.
*/
coach: { temperature: 0.4, maxTokens: 600 },
/**
* Coach rolling-conversation summary worker — a compact summary of the
* elided older turns. 200 tokens.
*/
summary: { temperature: 0.3, maxTokens: 200 },
/**
* Coach durable-fact extraction worker — a short structured fact list.
* 300 tokens. Lowest temperature: extraction must be faithful, not
* creative.
*/
facts: { temperature: 0.2, maxTokens: 300 },
/**
* Coach self-context question generation — a small set of clarifying
* questions. Temperature only; the caller pins its own token const.
*/
selfContext: { temperature: 0.4 },
} as const satisfies Record<string, { temperature: number; maxTokens?: number }>;
+3 -2
View File
@@ -74,6 +74,7 @@ import {
metricStatusScope,
} from "@/lib/insights/metric-status-registry";
import { annotate } from "@/lib/logging/context";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -697,8 +698,8 @@ export async function generateComprehensiveInsight(
params: {
systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics),
userPrompt,
temperature: 0.3,
maxTokens: 1500,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
},
});
result = fallback.result;
@@ -29,6 +29,7 @@ import { prisma as defaultPrisma } from "@/lib/db";
import { encrypt, decrypt } from "@/lib/crypto";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { annotate } from "@/lib/logging/context";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
buildPeriodNarrativeContext,
@@ -261,8 +262,8 @@ export async function generatePeriodNarrative(
consentSurface: "insights",
systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN,
userPrompt: buildNarrativeUserPrompt(context, locale),
temperature: 0.3,
maxTokens: 400,
temperature: AI_BUDGETS.narrative.temperature,
maxTokens: AI_BUDGETS.narrative.maxTokens,
});
// A provider error / timeout is non-fatal — the last good row stays as-is
+3 -2
View File
@@ -10,6 +10,7 @@ import {
type ConsentSurface,
} from "@/lib/ai/consent-guard";
import { annotate } from "@/lib/logging/context";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout";
/**
@@ -161,8 +162,8 @@ export async function runStatusCompletion(
params: {
systemPrompt,
userPrompt,
temperature: args.temperature ?? 0.3,
maxTokens: args.maxTokens ?? 1000,
temperature: args.temperature ?? AI_BUDGETS.status.temperature,
maxTokens: args.maxTokens ?? AI_BUDGETS.status.maxTokens,
},
}),
STATUS_PROVIDER_TIMEOUT_MS,