From 153c501cf7c27e9543f6c80fdcdc37a781928045 Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Thu, 9 Jul 2026 01:11:29 -0700 Subject: [PATCH] fix: bound AI analysis provider latency --- .trellis/spec/backend/ai-sdk-integration.md | 70 +++++++++++++++++++ .../07-08-complete-ops-ai-board/implement.md | 1 + .../tasks/07-08-complete-ops-ai-board/prd.md | 3 +- modules/ai/server/analysis.test.ts | 54 +++++++++++++- modules/ai/server/analysis.ts | 28 +++++++- modules/ai/server/config.ts | 24 +++++++ 6 files changed, 173 insertions(+), 7 deletions(-) diff --git a/.trellis/spec/backend/ai-sdk-integration.md b/.trellis/spec/backend/ai-sdk-integration.md index 0177cfe..9ce3a9f 100644 --- a/.trellis/spec/backend/ai-sdk-integration.md +++ b/.trellis/spec/backend/ai-sdk-integration.md @@ -223,6 +223,76 @@ async function classifyOrder(orderData: OrderData) { | Invalid API key | Missing/wrong credentials | Check environment variables | | Schema validation failed | AI output doesn't match schema | Adjust schema or prompt | + +## Scenario: Elder AI analysis latency controls + +### 1. Scope / Trigger + +- Trigger: backend elder AI analysis calls an OpenAI-compatible chat model through LangChain and returns a fixed API response to the UI. +- Apply this contract whenever changing `modules/ai/server/config.ts`, `modules/ai/server/analysis.ts`, or any elder AI analysis route that can block on a provider call. + +### 2. Signatures + +- Runtime config: `getAiRuntimeConfig() -> { success: true, config } | { success: false, reason }`. +- Config fields: `baseUrl`, `apiKey`, `chatModel`, `requestTimeoutMs`, `maxTokens`, `maxRetries`. +- Generation service: `generateElderAiAnalysis(context, elderId) -> ServiceResult<{ analysis }>`. + +### 3. Contracts + +- Environment keys: + - `AI_API_KEY`: required. + - `AI_BASE_URL`: optional OpenAI-compatible base URL. + - `AI_CHAT_MODEL`: optional model name. + - `AI_REQUEST_TIMEOUT_MS`: optional bounded integer request timeout. + - `AI_MAX_TOKENS`: optional bounded integer generation cap. + - `AI_MAX_RETRIES`: optional bounded integer retry count. +- `ChatOpenAI` construction must pass timeout, token cap, retry count, API key, model, and base URL from the runtime config. +- Provider errors must never persist prompts, resident context, API keys, raw provider messages, or stack traces in `elder_ai_analyses`. + +### 4. Validation & Error Matrix + +- Missing `AI_API_KEY` -> return `AI_API_KEY 未配置`, persist failed history with `errorCategory: "missing_config"` when organization context exists. +- Provider timeout, `AbortError`, or timeout-like provider message -> return `AI 服务响应超时`, persist failed history with `errorCategory: "timeout"`. +- Other provider failure -> return `AI 服务暂时不可用`, persist failed history with `errorCategory: "provider_error"`. +- Invalid model output -> return `AI 返回结构不符合固定分析格式`, persist failed history with `errorCategory: "schema_validation_failed"`. + +### 5. Good/Base/Bad Cases + +- Good: configured runtime uses a short timeout, bounded max tokens, and no hidden retry amplification; timeout failures are visible as sanitized failed history. +- Base: provider is slow or unreachable; user receives the timeout reason instead of waiting for default SDK retries and transport timeouts. +- Bad: service relies on LangChain/OpenAI defaults, causing long waits from high token caps, implicit retries, or unbounded request timeouts. + +### 6. Tests Required + +- Unit: assert `generateElderAiAnalysis` constructs `ChatOpenAI` with runtime-configured `timeout`, `maxTokens`, and `maxRetries`. +- Unit: assert `AbortError` and timeout-like model failures persist `errorCategory: "timeout"`, return `AI 服务响应超时`, and do not persist provider secrets or prompt text. +- Existing failure tests must continue to assert provider errors and schema failures use sanitized persisted history. + +### 7. Wrong vs Correct + +#### Wrong + +```typescript +new ChatOpenAI({ + apiKey, + model, + maxTokens: 1800, +}); +``` + +#### Correct + +```typescript +new ChatOpenAI({ + apiKey: config.apiKey, + model: config.chatModel, + timeout: config.requestTimeoutMs, + maxTokens: config.maxTokens, + maxRetries: config.maxRetries, + configuration: config.baseUrl ? { baseURL: config.baseUrl } : undefined, +}); +``` + ## 6. Prompt Engineering Best Practices ### Use XML Structure for Complex Prompts diff --git a/.trellis/tasks/07-08-complete-ops-ai-board/implement.md b/.trellis/tasks/07-08-complete-ops-ai-board/implement.md index 7086cf2..2a90f13 100644 --- a/.trellis/tasks/07-08-complete-ops-ai-board/implement.md +++ b/.trellis/tasks/07-08-complete-ops-ai-board/implement.md @@ -31,3 +31,4 @@ - [x] Run focused tests covering changed code. - [x] Run `pnpm type-check`. - [x] Smoke-test the dashboard route when feasible via `pnpm build` route compilation. +- [x] Add latency controls for AI provider timeout, token cap, retry count, and sanitized timeout failure history. diff --git a/.trellis/tasks/07-08-complete-ops-ai-board/prd.md b/.trellis/tasks/07-08-complete-ops-ai-board/prd.md index 8dc79a5..4bc0233 100644 --- a/.trellis/tasks/07-08-complete-ops-ai-board/prd.md +++ b/.trellis/tasks/07-08-complete-ops-ai-board/prd.md @@ -23,7 +23,8 @@ Finish the currently visible operations surface by making collaboration modules - [x] AI analysis board items redact result content when `canViewAnalysisScopes` denies one or more stored data scopes. - [x] Focused tests cover idempotent collaboration seeding and AI board redaction behavior. - [x] `pnpm type-check` and focused tests pass. +- [x] Elder AI generation uses bounded provider timeout, token, and retry controls so slow providers fail with sanitized timeout history instead of hanging indefinitely. ## Notes -- Out of scope: background alert-rule execution, family portal/authentication, AI generation changes, and new database schema. +- Out of scope: background alert-rule execution, family portal/authentication, AI recommendation/business-action generation changes, and new database schema. diff --git a/modules/ai/server/analysis.test.ts b/modules/ai/server/analysis.test.ts index 96d1efa..cdf80ea 100644 --- a/modules/ai/server/analysis.test.ts +++ b/modules/ai/server/analysis.test.ts @@ -1,7 +1,6 @@ import { ChatOpenAI } from "@langchain/openai"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { AiRuntimeConfigResult } from "@/modules/ai/server/config"; import { getAiRuntimeConfig } from "@/modules/ai/server/config"; import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context"; import { buildElderAiContext } from "@/modules/ai/server/elder-context"; @@ -95,14 +94,17 @@ type ReturnlessMock = ReturnlessFunction & { type ReturnlessFunction = (...args: unknown[]) => unknown; -const runtimeConfig: AiRuntimeConfigResult = { +const runtimeConfig = { success: true, config: { baseUrl: "https://ai.example.test/v1", apiKey: "test-api-key", chatModel: "gpt-test", + requestTimeoutMs: 12_345, + maxTokens: 901, + maxRetries: 4, }, -}; +} as const; const account: AuthContext["account"] = { id: "account-1", @@ -327,6 +329,52 @@ beforeEach(() => { }); describe("elder AI analysis service", () => { + it("constructs ChatOpenAI with latency controls from AI runtime config", async () => { + const result = await generateElderAiAnalysis(createAuthContext(), "elder-1"); + + expect(result.success).toBe(true); + expect(ChatOpenAI).toHaveBeenCalledWith(expect.objectContaining({ + apiKey: "test-api-key", + model: "gpt-test", + timeout: 12_345, + maxTokens: 901, + maxRetries: 4, + configuration: { baseURL: "https://ai.example.test/v1" }, + })); + }); + + it.each([ + { + name: "AbortError", + error: Object.assign(new Error("aborted request with sk-live-secret and resident prompt"), { name: "AbortError" }), + }, + { + name: "timeout-like provider message", + error: new Error("request timed out after 12345ms with sk-live-secret and resident prompt"), + }, + ])("maps $name model failures to sanitized timeout failed history", async ({ error }) => { + const database = createDatabaseFake(); + useDatabase(database); + chatMocks.invoke.mockRejectedValue(error); + + const result = await generateElderAiAnalysis(createAuthContext(), "elder-1"); + + expect(result).toEqual({ success: false, reason: "AI 服务响应超时", status: 502 }); + expect(database.insertedValues).toEqual([ + expect.objectContaining({ + status: "failed", + dataScopes: ["elder", "health"], + errorCategory: "timeout", + errorReason: "AI 服务响应超时", + }), + ]); + const persistedFailure = JSON.stringify(database.insertedValues[0]); + expect(persistedFailure).not.toContain("sk-live-secret"); + expect(persistedFailure).not.toContain("resident prompt"); + expect(persistedFailure).not.toContain("Night wandering"); + expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ result: "failure", reason: "AI 服务响应超时" })); + }); + it("saves sanitized failed history when AI runtime config is missing", async () => { const database = createDatabaseFake(); useDatabase(database); diff --git a/modules/ai/server/analysis.ts b/modules/ai/server/analysis.ts index 191c45f..517af08 100644 --- a/modules/ai/server/analysis.ts +++ b/modules/ai/server/analysis.ts @@ -26,13 +26,22 @@ import type { AuthContext } from "@/modules/core/types"; type ServiceResult = { success: true; data: T } | { success: false; reason: string; status: number }; type AnalysisRow = typeof elderAiAnalyses.$inferSelect; -function createChatModel(config: { baseUrl: string; apiKey: string; chatModel: string }) { +function createChatModel(config: { + baseUrl: string; + apiKey: string; + chatModel: string; + requestTimeoutMs: number; + maxTokens: number; + maxRetries: number; +}) { const configuration = config.baseUrl ? { baseURL: config.baseUrl } : undefined; return new ChatOpenAI({ apiKey: config.apiKey, model: config.chatModel, - maxTokens: 1800, + maxTokens: config.maxTokens, + maxRetries: config.maxRetries, temperature: 0.2, + timeout: config.requestTimeoutMs, configuration, }); } @@ -42,9 +51,22 @@ function toIsoString(value: Date): string { } function mapErrorCategory(error: unknown): AiErrorCategory { - if (error instanceof Error && error.name.toLowerCase().includes("timeout")) { + if (!(error instanceof Error)) { + return "provider_error"; + } + + const name = error.name.toLowerCase(); + const message = error.message.toLowerCase(); + if ( + name.includes("abort") || + name.includes("timeout") || + message.includes("abort") || + message.includes("timeout") || + message.includes("timed out") + ) { return "timeout"; } + return "provider_error"; } diff --git a/modules/ai/server/config.ts b/modules/ai/server/config.ts index 057cf91..93fb8f7 100644 --- a/modules/ai/server/config.ts +++ b/modules/ai/server/config.ts @@ -3,6 +3,9 @@ export type AiRuntimeConfig = { baseUrl: string; apiKey: string; chatModel: string; + requestTimeoutMs: number; + maxTokens: number; + maxRetries: number; }; export type AiRuntimeConfigResult = @@ -10,11 +13,28 @@ export type AiRuntimeConfigResult = | { success: false; reason: string }; const DEFAULT_CHAT_MODEL = "gpt-4.1-mini"; +const DEFAULT_REQUEST_TIMEOUT_MS = 20_000; +const DEFAULT_MAX_TOKENS = 1_000; +const DEFAULT_MAX_RETRIES = 0; function readOptionalEnv(name: string): string { return process.env[name]?.trim() ?? ""; } +function readBoundedIntegerEnv(name: string, fallback: number, min: number, max: number): number { + const rawValue = readOptionalEnv(name); + if (!rawValue) { + return fallback; + } + + const value = Number(rawValue); + if (!Number.isInteger(value) || value < min || value > max) { + return fallback; + } + + return value; +} + export function getAiRuntimeConfig(): AiRuntimeConfigResult { const apiKey = readOptionalEnv("AI_API_KEY"); if (!apiKey) { @@ -27,6 +47,10 @@ export function getAiRuntimeConfig(): AiRuntimeConfigResult { baseUrl: readOptionalEnv("AI_BASE_URL"), apiKey, chatModel: readOptionalEnv("AI_CHAT_MODEL") || DEFAULT_CHAT_MODEL, + requestTimeoutMs: readBoundedIntegerEnv("AI_REQUEST_TIMEOUT_MS", DEFAULT_REQUEST_TIMEOUT_MS, 1_000, 120_000), + maxTokens: readBoundedIntegerEnv("AI_MAX_TOKENS", DEFAULT_MAX_TOKENS, 256, 2_000), + maxRetries: readBoundedIntegerEnv("AI_MAX_RETRIES", DEFAULT_MAX_RETRIES, 0, 5), }, }; } +