From 7530324fd1a044db3e7a22d5ea5173c307c8906b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 20:57:37 +0200 Subject: [PATCH] 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. --- src/app/api/insights/chat/route.ts | 5 +- src/lib/ai/__tests__/ai-budgets.test.ts | 39 +++++++++ src/lib/ai/ai-budgets.ts | 85 +++++++++++++++++++ src/lib/insights/comprehensive-generate.ts | 5 +- .../narrative/period-narrative-generate.ts | 5 +- src/lib/insights/status-provider.ts | 5 +- 6 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 src/lib/ai/__tests__/ai-budgets.test.ts create mode 100644 src/lib/ai/ai-budgets.ts diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 93311440c..09ecf7d5d 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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; diff --git a/src/lib/ai/__tests__/ai-budgets.test.ts b/src/lib/ai/__tests__/ai-budgets.test.ts new file mode 100644 index 000000000..7741da12f --- /dev/null +++ b/src/lib/ai/__tests__/ai-budgets.test.ts @@ -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); + }); +}); diff --git a/src/lib/ai/ai-budgets.ts b/src/lib/ai/ai-budgets.ts new file mode 100644 index 000000000..ce6c49434 --- /dev/null +++ b/src/lib/ai/ai-budgets.ts @@ -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; diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 139319d65..9d3ce33e8 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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; diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index d383d4cb7..d1c414036 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -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 diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 12a66484a..104704e82 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -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,