diff --git a/src/lib/ai/__tests__/anthropic-client.test.ts b/src/lib/ai/__tests__/anthropic-client.test.ts index b8aa85683..3db2580da 100644 --- a/src/lib/ai/__tests__/anthropic-client.test.ts +++ b/src/lib/ai/__tests__/anthropic-client.test.ts @@ -53,6 +53,65 @@ describe("AnthropicClient", () => { expect(body.messages[0].content.toLowerCase()).toContain("json"); }); + it("prefills the assistant turn with `{` and re-prepends it for JSON surfaces", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + // Model continues from the prefilled `{`, so its text omits it. + content: [{ type: "text", text: '"summary":"ok"}' }], + usage: { input_tokens: 5, output_tokens: 5 }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const client = new AnthropicClient({ + apiKey: "sk-ant-test", + model: "claude-3-5-sonnet-latest", + }); + + const result = await client.generateCompletion({ + systemPrompt: "s", + userPrompt: "u", + responseFormat: "json", + }); + + // The returned content is the complete object (the `{` re-prepended). + expect(result.content).toBe('{"summary":"ok"}'); + expect(JSON.parse(result.content)).toEqual({ summary: "ok" }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.messages).toHaveLength(2); + expect(body.messages[1]).toEqual({ role: "assistant", content: "{" }); + }); + + it("does NOT prefill for the prose (non-JSON) path", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + content: [{ type: "text", text: "Just some prose." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const client = new AnthropicClient({ + apiKey: "sk-ant-test", + model: "claude-3-5-sonnet-latest", + }); + + const result = await client.generateCompletion({ + systemPrompt: "s", + userPrompt: "u", + }); + + expect(result.content).toBe("Just some prose."); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.messages).toHaveLength(1); + expect(body.messages[0].role).toBe("user"); + }); + it("uses custom baseUrl when provided", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/src/lib/ai/__tests__/local-client.test.ts b/src/lib/ai/__tests__/local-client.test.ts index e0f6f70be..ba6c38ffb 100644 --- a/src/lib/ai/__tests__/local-client.test.ts +++ b/src/lib/ai/__tests__/local-client.test.ts @@ -55,6 +55,37 @@ describe("LocalOpenAICompatibleClient", () => { // CRUCIAL: do not send response_format because many local servers reject it. expect(body).not.toHaveProperty("response_format"); + // No JSON opt-in → no `format` field. + expect(body).not.toHaveProperty("format"); + }); + + it("sends Ollama `format: json` only when the caller opts into JSON", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: '{"summary":"x"}' } }], + usage: { total_tokens: 3 }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const client = new LocalOpenAICompatibleClient({ + apiKey: null, + model: "llama3:8b", + baseUrl: "http://localhost:11434/v1", + }); + + await client.generateCompletion({ + systemPrompt: "s", + userPrompt: "u", + responseFormat: "json", + seed: 99, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.format).toBe("json"); + expect(body.seed).toBe(99); }); it("omits Authorization header when no API key is provided", async () => { diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index b58d8e317..87ac4d5af 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -48,12 +48,26 @@ export class AnthropicClient implements AIProvider { // 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. + // + // v1.18.7 — JSON-reliability prefill. For the structured surfaces + // (`responseFormat: "json"`) we seed the assistant turn with a bare + // `{`. Anthropic continues from that token, so the first emitted + // character is already inside a JSON object — this drops the + // first-pass "prose preamble before the JSON" failure the + // fence-stripping net otherwise has to catch. We re-prepend the `{` + // to the returned text below so the caller sees a complete object. body: JSON.stringify({ model: this.config.model, max_tokens: params.maxTokens ?? 1000, temperature: params.temperature ?? 0.3, system: params.systemPrompt, - messages: [{ role: "user", content: wrapForJson(params.userPrompt) }], + messages: + params.responseFormat === "json" + ? [ + { role: "user", content: wrapForJson(params.userPrompt) }, + { role: "assistant", content: "{" }, + ] + : [{ role: "user", content: wrapForJson(params.userPrompt) }], }), }, // 60 s ceiling — see openai-client.ts for the rationale. @@ -89,12 +103,21 @@ export class AnthropicClient implements AIProvider { }; const textBlock = json.content?.find((c) => c.type === "text"); - const content = textBlock?.text; + const rawText = textBlock?.text; - if (!content) { + if (!rawText) { throw new Error("Anthropic returned empty content"); } + // When we prefilled the assistant turn with `{`, the model continues + // from there and its returned text omits that leading brace — re-prepend + // it so the caller parses a complete object. Guard against a model that + // (rarely) echoes the prefill itself. + const content = + params.responseFormat === "json" && !rawText.trimStart().startsWith("{") + ? `{${rawText}` + : rawText; + const inputTokens = json.usage?.input_tokens ?? 0; const outputTokens = json.usage?.output_tokens ?? 0; const tokensUsed = inputTokens + outputTokens || null; diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index 4e69db65a..905e4e62b 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -69,6 +69,11 @@ export class LocalOpenAICompatibleClient implements AIProvider { // most OpenAI-compatible local servers honour it; servers that // ignore it simply disregard the field. Omitted when unset. ...(params.seed !== undefined ? { seed: params.seed } : {}), + // v1.18.7 — Ollama's native structured-output switch. The JSON + // surfaces opt in via `responseFormat: "json"`; it cuts the + // first-pass JSON-failure rate that the fence-stripping safety net + // otherwise has to catch. The Coach (prose) leaves it unset. + ...(params.responseFormat === "json" ? { format: "json" } : {}), }), }, { timeoutMs: 60_000, requirePublicHost: !allowPrivate }, diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index 54345b244..d5844da2c 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -126,6 +126,17 @@ export interface CompletionParams { * Messages API has no seed knob, so the Anthropic client ignores it. */ seed?: number; + /** + * v1.18.7 — opt the structured (JSON) surfaces into the provider's + * strongest JSON-reliability mode. When `"json"`: + * - local (Ollama / OpenAI-compatible): adds `format: "json"`. + * - Anthropic: prefills the assistant turn with `{` so the first token + * is forced into a JSON object. + * The OpenAI client already pins `response_format: json_object` + * unconditionally and is unaffected. The Coach (prose, NOT JSON) leaves + * this unset, so its streaming path is untouched. + */ + responseFormat?: "json"; } export interface CompletionResult { diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 9d3ce33e8..fab7310de 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -700,6 +700,10 @@ export async function generateComprehensiveInsight( userPrompt, temperature: AI_BUDGETS.comprehensive.temperature, maxTokens: AI_BUDGETS.comprehensive.maxTokens, + // v1.18.7 — structured surface: opt the non-OpenAI chains into their + // strongest JSON mode (Ollama `format`, Anthropic `{` prefill) so a + // first-pass JSON miss is rarer; stripJsonFences stays the net. + responseFormat: "json", }, }); result = fallback.result; diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index d5be8d839..55c63c73c 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -267,6 +267,9 @@ export async function generatePeriodNarrative( // 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, + // The narrative output is PLAIN TEXT (no markdown, no JSON), so suppress + // the JSON-mode opt-in the status cards default into. + responseFormat: "text", }); // 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 7be41ada2..cbe32d023 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -57,6 +57,13 @@ interface RunStatusCompletionArgs { * QA); the period narrative passes the same constant explicitly. */ seed?: number; + /** + * v1.18.7 — output contract of this generation. The per-metric status + * cards return a JSON `{ "summary": ... }` (the default), so they opt the + * non-OpenAI chains into their strongest JSON mode. The period narrative + * returns PLAIN TEXT and passes `"text"` to suppress that. + */ + responseFormat?: "json" | "text"; /** * v1.12.1 — which AI surface this generation serves, for the consent gate. * `insights` for the per-metric status cards + period narrative; `coach` @@ -173,6 +180,9 @@ export async function runStatusCompletion( // v1.18.7 — status/reference output is reproducible: pin the // deterministic seed unless a caller overrides it. seed: args.seed ?? REFERENCE_AI_SEED, + // Status cards are JSON by default; the narrative opts out via + // `"text"`. + responseFormat: args.responseFormat === "text" ? undefined : "json", }, }), STATUS_PROVIDER_TIMEOUT_MS,