feat(ai): support a deterministic seed on the reference surfaces

Add an optional `seed` to CompletionParams and thread it onto the
OpenAI and local (Ollama / OpenAI-compatible) request bodies. The field
is dropped when unset, so existing callers are byte-identical.

Pin a stable seed for the status/reference cards (defaulted in the
status provider) and the period narrative, so a prompt change is
diff-able against a fixed baseline rather than masked by low-temperature
non-determinism. Anthropic's Messages API has no seed knob, so its
client ignores the field (documented inline). The seed is annotated on
the narrative wide-event.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 20:58:46 +02:00
1 parent 7530324fd1
commit a460c74e6c
7 files changed
+62 -3

No files matched your search

@@ -39,6 +39,35 @@ describe("OpenAIClient", () => {
expect(body.model).toBe("gpt-4o-mini");
expect(body.messages).toHaveLength(2);
expect(body.response_format).toEqual({ type: "json_object" });
// No seed passed → field omitted from the body entirely.
expect(body).not.toHaveProperty("seed");
});
it("threads a deterministic seed onto the request body when supplied", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '{"summary":"x"}' } }],
usage: { total_tokens: 5 },
}),
});
vi.stubGlobal("fetch", mockFetch);
const client = new OpenAIClient({
apiKey: "sk-test",
model: "gpt-4o-mini",
baseUrl: "https://api.openai.com/v1",
});
await client.generateCompletion({
systemPrompt: "test",
userPrompt: "test",
seed: 1234,
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.seed).toBe(1234);
});
it("throws on non-ok response", async () => {
+3
View File
@@ -45,6 +45,9 @@ export class AnthropicClient implements AIProvider {
"x-api-key": this.config.apiKey,
"anthropic-version": ANTHROPIC_VERSION,
},
// NOTE: Anthropic's Messages API has no `seed` parameter, so
// `params.seed` is intentionally not forwarded here — output on this
// provider is non-deterministic regardless of the pinned seed.
body: JSON.stringify({
model: this.config.model,
max_tokens: params.maxTokens ?? 1000,
+4
View File
@@ -65,6 +65,10 @@ export class LocalOpenAICompatibleClient implements AIProvider {
],
temperature: params.temperature ?? 0.3,
max_tokens: params.maxTokens ?? 1000,
// Deterministic seed for reproducible reference output. Ollama +
// most OpenAI-compatible local servers honour it; servers that
// ignore it simply disregard the field. Omitted when unset.
...(params.seed !== undefined ? { seed: params.seed } : {}),
}),
},
{ timeoutMs: 60_000, requirePublicHost: !allowPrivate },
+4
View File
@@ -48,6 +48,10 @@ export class OpenAIClient implements AIProvider {
temperature: params.temperature ?? 0.3,
max_tokens: params.maxTokens ?? 1000,
response_format: { type: "json_object" },
// Deterministic seed for reproducible reference output; omitted
// (undefined → dropped by JSON.stringify) when the caller does
// not pin one (e.g. the seedless daily-briefing re-roll).
...(params.seed !== undefined ? { seed: params.seed } : {}),
}),
},
// 60 s ceiling so a tar-pit upstream cannot pin a worker
+7
View File
@@ -119,6 +119,13 @@ export interface CompletionParams {
userPrompt: string;
temperature?: number;
maxTokens?: number;
/**
* Optional deterministic seed. Threaded onto the OpenAI and local
* (Ollama / OpenAI-compatible) request bodies for reproducible output on
* the reference surfaces (status cards + period narrative). Anthropic's
* Messages API has no seed knob, so the Anthropic client ignores it.
*/
seed?: number;
}
export interface CompletionResult {
@@ -29,7 +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 { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
buildPeriodNarrativeContext,
@@ -264,6 +264,9 @@ export async function generatePeriodNarrative(
userPrompt: buildNarrativeUserPrompt(context, locale),
temperature: AI_BUDGETS.narrative.temperature,
maxTokens: AI_BUDGETS.narrative.maxTokens,
// v1.18.7 — reference surface: pin a deterministic seed so a prompt
// change is diff-able against a stable baseline (MEDIUM-4).
seed: REFERENCE_AI_SEED,
});
// A provider error / timeout is non-fatal — the last good row stays as-is
@@ -316,7 +319,7 @@ export async function generatePeriodNarrative(
});
annotate({
action: { name: "insights.narrative.generated" },
meta: { period, locale, provider: providerType },
meta: { period, locale, provider: providerType, seed: REFERENCE_AI_SEED },
});
};
+10 -1
View File
@@ -10,7 +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 { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout";
/**
@@ -51,6 +51,12 @@ interface RunStatusCompletionArgs {
userPrompt: string;
temperature?: number;
maxTokens?: number;
/**
* v1.18.7 — optional deterministic seed override. Defaults to
* `REFERENCE_AI_SEED` for every status/reference surface (reproducible
* QA); the period narrative passes the same constant explicitly.
*/
seed?: number;
/**
* v1.12.1 — which AI surface this generation serves, for the consent gate.
* `insights` for the per-metric status cards + period narrative; `coach`
@@ -164,6 +170,9 @@ export async function runStatusCompletion(
userPrompt,
temperature: args.temperature ?? AI_BUDGETS.status.temperature,
maxTokens: args.maxTokens ?? AI_BUDGETS.status.maxTokens,
// v1.18.7 — status/reference output is reproducible: pin the
// deterministic seed unless a caller overrides it.
seed: args.seed ?? REFERENCE_AI_SEED,
},
}),
STATUS_PROVIDER_TIMEOUT_MS,