mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
3e04035de8
commit
7530324fd1
6 files changed
+136
-8
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }>;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user