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:
SpecialX
2026-06-23 09:39:18 +08:00
parent 036a2f2839
commit 696346dc08
22 changed files with 847 additions and 238 deletions

View File

@@ -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

View File

@@ -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 {