fix: bound AI analysis provider latency
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -26,13 +26,22 @@ import type { AuthContext } from "@/modules/core/types";
|
||||
type ServiceResult<T> = { 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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user