mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
commit
b07fbcb6fa
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