From a460c74e6c0b215f6e9e15084e46f1c840f60175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 20:58:46 +0200 Subject: [PATCH] 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. --- src/lib/ai/__tests__/openai-client.test.ts | 29 +++++++++++++++++++ src/lib/ai/anthropic-client.ts | 3 ++ src/lib/ai/local-client.ts | 4 +++ src/lib/ai/openai-client.ts | 4 +++ src/lib/ai/types.ts | 7 +++++ .../narrative/period-narrative-generate.ts | 7 +++-- src/lib/insights/status-provider.ts | 11 ++++++- 7 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/lib/ai/__tests__/openai-client.test.ts b/src/lib/ai/__tests__/openai-client.test.ts index 2dfb52125..ac7fa1dd9 100644 --- a/src/lib/ai/__tests__/openai-client.test.ts +++ b/src/lib/ai/__tests__/openai-client.test.ts @@ -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 () => { diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index 4228fd322..b58d8e317 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -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, diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index df03ff991..4e69db65a 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -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 }, diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index a898acaed..7260e6320 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -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 diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index a6dd524d2..54345b244 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -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 { diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index d1c414036..d5be8d839 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -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 }, }); }; diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 104704e82..7be41ada2 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -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,