Merge AI client message-array refactor into release/v1.20.0

Move the AI client layer from a single system/user string pair to a
structured system + messages[] shape, with optional tools/toolChoice and
a result carrying toolCalls/finishReason/cachedInputTokens. Per-provider
prompt-caching is wired (Anthropic cache_control, OpenAI/local auto
prefix); tool plumbing is in place for the Coach to use. Behaviour-
preserving: no streaming change, snapshot-once retained.
This commit is contained in:
Marc-André Bombeck committed 2026-06-22 07:17:48 +02:00
commit b07fbcb6fa
31 files changed
+1556 -485

No files matched your search

+9 -6
View File
@@ -6,6 +6,7 @@ import {
AITestConfigError,
type AITestOverride,
} from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
@@ -65,12 +66,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
try {
const result = await provider.generateCompletion({
systemPrompt: "You are a connection-test responder.",
userPrompt: 'Reply with the JSON object {"ok": true} and nothing else.',
temperature: 0,
maxTokens: 32,
});
const result = await provider.generateCompletion(
singleUserTurn({
system: "You are a connection-test responder.",
user: 'Reply with the JSON object {"ok": true} and nothing else.',
temperature: 0,
maxTokens: 32,
}),
);
return apiSuccess({
ok: true,
@@ -164,10 +164,11 @@ function chatReq(body: Record<string, unknown>): Request {
function lastUserPrompt(): string {
const calls = runRawCompletionWithFallback.mock.calls as unknown as Array<
[{ params: { userPrompt: string } }]
[{ params: { messages: Array<{ role: string; content: string }> } }]
>;
const last = calls[calls.length - 1];
return last[0].params.userPrompt;
const userTurn = last[0].params.messages.find((m) => m.role === "user");
return typeof userTurn?.content === "string" ? userTurn.content : "";
}
describe("coach chat — snapshot sent once per conversation (C4)", () => {
+9 -5
View File
@@ -44,7 +44,7 @@ import {
} from "@/lib/ai/provider-runner";
import { resolveProviderChain, resolveProvider } from "@/lib/ai/provider";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import type { CompletionResult } from "@/lib/ai/types";
import { singleUserTurn, type CompletionResult } from "@/lib/ai/types";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
@@ -494,12 +494,16 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
const fallback = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt,
// v1.20.0 — the Coach still builds one assembled user turn (the
// transcript-flattening preserves the once-per-conversation snapshot
// trick + grounding exactly), so it ships as a single user message. The
// stable persona/grounding rides `system` and is now cache-eligible.
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
temperature: AI_BUDGETS.coach.temperature,
maxTokens: AI_BUDGETS.coach.maxTokens,
},
}),
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
+13 -15
View File
@@ -52,7 +52,11 @@ import {
type ComparisonBaseline,
} from "@/lib/dashboard-layout";
import type { MeasurementType } from "@/generated/prisma/client";
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
import {
insightResultSchema,
singleUserTurn,
type InsightResult,
} from "@/lib/ai/types";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import {
AllProvidersFailedError,
@@ -662,7 +666,7 @@ export const POST = apiHandler((request: NextRequest) =>
const fallback = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
params: singleUserTurn({
// The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8
// — emit a top-level `dailyBriefing` block when the snapshot has
// analysable signal — plus the trendAnnotations and
@@ -674,14 +678,11 @@ export const POST = apiHandler((request: NextRequest) =>
// tolerates the strict shape via `insightResultSchema.safeParse`'s
// soft fallback to `parsed` and `passthrough()` on the strict
// schema, so switching the prompt does not break legacy callers.
systemPrompt: buildSystemPromptWithReferences(
locale,
referenceMetrics,
),
userPrompt,
system: buildSystemPromptWithReferences(locale, referenceMetrics),
user: userPrompt,
temperature: 0.3,
maxTokens: 1500,
},
}),
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
@@ -809,15 +810,12 @@ export const POST = apiHandler((request: NextRequest) =>
const retry = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt: buildSystemPromptWithReferences(
locale,
referenceMetrics,
),
userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
params: singleUserTurn({
system: buildSystemPromptWithReferences(locale, referenceMetrics),
user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
temperature: 0.3,
maxTokens: 1500,
},
}),
});
const parsedRetry = JSON.parse(retry.result.content);
const validatedRetry = insightResultSchema.safeParse(parsedRetry);
+5 -4
View File
@@ -57,6 +57,7 @@ import {
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import {
@@ -185,16 +186,16 @@ async function handleExtract(request: NextRequest): Promise<Response> {
completion = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt,
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
// Low temperature because we want a deterministic field
// extractor, not a creative rephrase.
temperature: 0.1,
// The reply body is a single small JSON object; 600 is well
// over the empirical max we have seen on a 2k-char input.
maxTokens: 600,
},
}),
});
} catch (err) {
if (err instanceof AllProvidersFailedError) {