mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in new issue
Block a user