mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
refactor(ai): take a structured message array instead of a string pair
Replace the CompletionParams { systemPrompt, userPrompt } string pair with a
structured { system, messages[] } request across all five AI clients. One
refactor unlocks three things: provider prompt-caching (Anthropic cache_control
on the stable system prefix, OpenAI/Codex automatic prefix cache, with
cachedInputTokens surfaced for observability), the tool-calling wire surface,
and clean multi-turn.
Each client maps the new shape to its own wire and degrades unsupported
features silently: the local OpenAI-compatible server never receives a tools
field (most reject unknown keys), and prompt-caching is best-effort everywhere.
CompletionResult gains toolCalls, finishReason and cachedInputTokens; the tool
types and per-provider mapping ship now, the tool-use loop does not.
Vision inputs fold into the user turn's content parts via singleUserTurn, with
each provider's pre-existing block ordering preserved. The corrective-retry
paths append to the last user message rather than a flat string. The Coach
keeps assembling one user turn so its snapshot-once and grounding behaviour is
unchanged. No wire schema visible to API consumers changed.
Behaviour is unchanged across every surface; the full suite stays green.
This commit is contained in:
1 parent
461190e63e
commit
4e777a7e6a
31 files changed
+1556
-485
No files matched your search
@@ -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)", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user