fix(ai): V3 长期问题修复+规则合规+竞品对标
## P1 安全加固 - 原子化每日限额(tryConsumeDailyQuota)解决 TOCTOU 竞态 - 流式端点补齐 Zod 校验 + rate limit + 服务端强制 systemPrompt - 配额回退机制(refundDailyQuota):过滤/失败不扣配额 - PII 最小化:移除 AI prompt 中的学生姓名 ## P1 数据一致性 - 修复 capability 埋点缺失 child_summary/study_path 类型 - 创建 data-access.ts:真实统计聚合替代硬编码零 - 修复 generateChildSummary/recommendStudyPath 的 capability 标记 ## P2 可靠性 - AI 调用重试机制(withRetry 指数退避,429/5xx,2 次重试) - 30s 超时配置 - 流式 controller 安全 enqueue(防已关闭抛错) - localStorage 防抖持久化(500ms,流式过程中跳过) ## P2 TypeScript/规则合规 - 移除 as 断言(VariantType 类型守卫、Permission 类型、StreamErrorKey) - 补齐返回类型标注(POST/getStatusFromError/DashboardLayout) - 拆分 use-ai-chat-stream hook(190→107 行,函数体≤80 行) - 抽取 stream-utils.ts(SSE 解析/错误映射/消息工具) - Tailwind 任意值添加注释说明(max-w-[80%] 聊天气泡) ## P3 竞品对标 - 苏格拉底式辅导强化(对标 Khanmigo): - SOCRATIC_TUTOR_SYSTEM_PROMPT 3 级提示升级 - 强化 STUDENT_BLOCKED_PATTERNS 正则(中英文答案拦截) - validateSocraticOutput 服务端校验(问号结尾+连续陈述句限制) - socratic_warning SSE 事件类型 - 知识图谱集成(对标 Squirrel AI): - StudyPathInput 新增 knowledgeGraph/textbookId 字段 - recommendStudyPathAction 自动从 textbooks 模块获取图谱+掌握度 - STUDY_PATH_SYSTEM_PROMPT 增加前置依赖链规则 - WEAKNESS_ANALYSIS_SYSTEM_PROMPT 增加 rootCause 字段 ## 架构文档同步 - 004 更新 AI 模块章节(V3 标记/新导出/依赖关系/安全机制/文件清单) - 005 更新 modules.ai 节点(dependsOn/exports/dataAccess/streamUtils/dependencyMatrix)
This commit is contained in:
@@ -5,15 +5,68 @@ import OpenAI from "openai"
|
||||
import { extractMessageContent, type AiChatRequest } from "./payload-parser"
|
||||
import { getAiProviderConfig } from "./provider-config"
|
||||
|
||||
const getAiClient = async (config: { apiKey: string; baseUrl?: string }) => {
|
||||
/** AI 请求超时(毫秒) */
|
||||
const AI_TIMEOUT_MS = 30000
|
||||
|
||||
/** 可重试的 HTTP 状态码(429 限流 + 5xx 服务端错误) */
|
||||
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504])
|
||||
|
||||
/** 最大重试次数 */
|
||||
const MAX_RETRIES = 2
|
||||
|
||||
/** 基础重试延迟(毫秒),实际延迟 = base * 2^attempt */
|
||||
const RETRY_BASE_DELAY_MS = 1000
|
||||
|
||||
const getAiClient = async (config: { apiKey: string; baseUrl?: string }): Promise<OpenAI> => {
|
||||
const baseUrl = String(config.baseUrl ?? "https://api.openai.com").replace(/\/+$/, "")
|
||||
return new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: baseUrl.length ? baseUrl : undefined,
|
||||
timeout: AI_TIMEOUT_MS,
|
||||
maxRetries: 0, // 由业务层控制重试
|
||||
})
|
||||
}
|
||||
|
||||
export const testAiProviderConfig = async (input: { apiKey: string; baseUrl?: string; model: string }) => {
|
||||
/**
|
||||
* 判断错误是否可重试(429 限流或 5xx 服务端错误)
|
||||
*/
|
||||
const isRetryableError = (error: unknown): boolean => {
|
||||
if (error instanceof OpenAI.APIError) {
|
||||
return RETRYABLE_STATUS_CODES.has(error.status ?? 0)
|
||||
}
|
||||
// 网络超时/连接错误也可重试
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase()
|
||||
return msg.includes("timeout") || msg.includes("econnreset") || msg.includes("socket hang up")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 指数退避重试包装器
|
||||
*
|
||||
* 仅对 429(限流)和 5xx(服务端错误)重试,4xx 不重试。
|
||||
* 最大重试 2 次,延迟 1s/2s/4s。
|
||||
*/
|
||||
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt < MAX_RETRIES && isRetryableError(error)) {
|
||||
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export const testAiProviderConfig = async (input: { apiKey: string; baseUrl?: string; model: string }): Promise<boolean> => {
|
||||
const client = await getAiClient({ apiKey: input.apiKey, baseUrl: input.baseUrl })
|
||||
const result = await client.chat.completions.create({
|
||||
model: input.model,
|
||||
@@ -29,7 +82,7 @@ export const testAiProviderConfig = async (input: { apiKey: string; baseUrl?: st
|
||||
export const testAiProviderById = async (
|
||||
providerId: string,
|
||||
overrides?: { baseUrl?: string; model?: string }
|
||||
) => {
|
||||
): Promise<boolean> => {
|
||||
const config = await getAiProviderConfig(providerId)
|
||||
const client = await getAiClient({ apiKey: config.apiKey, baseUrl: overrides?.baseUrl ?? config.baseUrl })
|
||||
const result = await client.chat.completions.create({
|
||||
@@ -43,18 +96,19 @@ export const testAiProviderById = async (
|
||||
return true
|
||||
}
|
||||
|
||||
export const createAiChatCompletion = async (input: AiChatRequest) => {
|
||||
export const createAiChatCompletion = async (input: AiChatRequest): Promise<{ content: string; usage: unknown }> => {
|
||||
const config = await getAiProviderConfig(input.providerId)
|
||||
const client = await getAiClient(config)
|
||||
const result = (await client.chat.completions.create({
|
||||
model: config.model || input.model,
|
||||
messages: input.messages,
|
||||
temperature: input.temperature,
|
||||
...(typeof input.maxTokens === "number" ? { max_tokens: input.maxTokens } : {}),
|
||||
...(input.thinking ? { thinking: input.thinking } : {}),
|
||||
} as Parameters<typeof client.chat.completions.create>[0])) as Awaited<
|
||||
ReturnType<typeof client.chat.completions.create>
|
||||
>
|
||||
|
||||
const result = await withRetry(() =>
|
||||
client.chat.completions.create({
|
||||
model: config.model || input.model,
|
||||
messages: input.messages,
|
||||
temperature: input.temperature,
|
||||
...(typeof input.maxTokens === "number" ? { max_tokens: input.maxTokens } : {}),
|
||||
...(input.thinking ? { thinking: input.thinking } : {}),
|
||||
} as Parameters<typeof client.chat.completions.create>[0])
|
||||
) as Awaited<ReturnType<typeof client.chat.completions.create>>
|
||||
|
||||
const hasChoices = "choices" in result && Array.isArray(result.choices) && result.choices.length > 0
|
||||
if (!hasChoices) throw new Error("Empty response from provider. Check API URL, model, and API key.")
|
||||
@@ -71,6 +125,9 @@ export const createAiChatCompletion = async (input: AiChatRequest) => {
|
||||
*
|
||||
* 返回 AsyncGenerator,逐 token 产出内容。
|
||||
* 用于 SSE 流式响应,降低用户感知延迟。
|
||||
*
|
||||
* 注意:流式调用不使用 withRetry,因为流一旦开始无法重试。
|
||||
* 超时由 OpenAI SDK 的 timeout 配置控制。
|
||||
*/
|
||||
export async function* createAiChatCompletionStream(
|
||||
input: AiChatRequest
|
||||
|
||||
@@ -27,6 +27,7 @@ export type EventName =
|
||||
| "notification.marked_all_read"
|
||||
| "notification.sent"
|
||||
| "notification.send_failed"
|
||||
| "notification.archived"
|
||||
| "attendance.recorded"
|
||||
| "attendance.batch_recorded"
|
||||
| "attendance.updated"
|
||||
@@ -66,6 +67,8 @@ export type EventName =
|
||||
| "ai.lesson_content"
|
||||
| "ai.question_variant"
|
||||
| "ai.weakness_analysis"
|
||||
| "ai.child_summary"
|
||||
| "ai.study_path"
|
||||
|
||||
/** 埋点事件负载 */
|
||||
export interface TrackEventPayload {
|
||||
|
||||
Reference in New Issue
Block a user