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

@@ -34,7 +34,7 @@ export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
}): React.ReactNode {
return (
<AiClientProvider service={aiClientService}>
<SidebarProvider sidebar={<AppSidebar />}>

View File

@@ -6,14 +6,14 @@ import { Permissions } from "@/shared/types/permissions"
export const dynamic = "force-dynamic"
const getStatusFromError = (message: string) => {
const getStatusFromError = (message: string): number => {
if (message === "Invalid payload" || message === "Messages are required") return 400
if (message === "AI API key missing") return 500
if (message === "Empty response") return 502
return 502
}
export async function POST(req: Request) {
export async function POST(req: Request): Promise<NextResponse> {
try {
const ctx = await requirePermission(Permissions.AI_CHAT)
const userId = ctx.userId

View File

@@ -6,15 +6,22 @@ import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guar
import { createAiChatCompletionStream } from "@/shared/lib/ai/client"
import { getAiErrorMessage } from "@/shared/lib/ai"
import { trackEvent } from "@/shared/lib/track-event"
import {
rateLimit,
rateLimitKey,
RATE_LIMIT_RULES,
} from "@/shared/lib/rate-limit"
import { env } from "@/env.mjs"
import { CHAT_SYSTEM_PROMPT } from "@/modules/ai/services/prompt-templates"
import { CHAT_SYSTEM_PROMPT, SOCRATIC_TUTOR_SYSTEM_PROMPT } from "@/modules/ai/services/prompt-templates"
import {
filterUserInput,
filterAiOutput,
checkDailyLimit,
incrementDailyUsage,
tryConsumeDailyQuota,
refundDailyQuota,
validateSocraticOutput,
} from "@/modules/ai/services/content-safety"
import { AiChatInputSchema } from "@/modules/ai/schema"
import type { AiChatMessage } from "@/modules/ai/types"
/**
@@ -23,11 +30,14 @@ import type { AiChatMessage } from "@/modules/ai/types"
* 使用 Server-Sent Events 逐 token 推送 AI 回复,
* 降低用户感知延迟。
*
* 安全:
* 安全策略(与非流式端点一致)
* - requirePermission(AI_CHAT) 权限校验
* - rateLimit 每分钟限流(防高频滥用)
* - Zod 校验输入AiChatInputSchema限制消息数/长度)
* - tryConsumeDailyQuota 原子化每日限额(防 TOCTOU 竞态)
* - 输入/输出内容安全过滤
* - 每日交互限制
* - 学生侧 Socratic 模式
* - 学生侧 Socratic 模式(服务端强制,忽略客户端 systemPrompt
* - 过滤/失败时 refundDailyQuota不惩罚用户
*/
const formatEvent = (data: unknown): string => {
@@ -50,34 +60,45 @@ export async function POST(request: NextRequest): Promise<Response> {
const userRole = session?.user?.role ?? "student"
const isStudent = userRole === "student"
// 2. 每日限制
const limitCheck = checkDailyLimit(ctx.userId, userRole)
if (limitCheck.blocked) {
// 2. Rate limit与非流式端点一致
const limitResult = rateLimit({
key: rateLimitKey("ai-chat-stream", ctx.userId),
...RATE_LIMIT_RULES.AI_CHAT,
})
if (!limitResult.success) {
return new Response(formatError("Rate limit exceeded"), {
status: 429,
headers: { "Content-Type": "text/event-stream" },
})
}
// 3. Zod 校验输入(防止超大 message 导致 token 爆炸)
const rawBody = await request.json().catch(() => null)
const parseResult = AiChatInputSchema.safeParse(rawBody)
if (!parseResult.success) {
return new Response(formatError("Invalid input"), {
status: 400,
headers: { "Content-Type": "text/event-stream" },
})
}
const body = parseResult.data
// 4. 原子化每日限额检查(防 TOCTOU 竞态)
const quotaResult = tryConsumeDailyQuota(ctx.userId, userRole)
if (quotaResult.blocked) {
return new Response(formatError("Daily limit reached"), {
status: 429,
headers: { "Content-Type": "text/event-stream" },
})
}
// 3. 解析请求
const body = (await request.json()) as {
messages?: AiChatMessage[]
providerId?: string
systemPrompt?: string
}
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
return new Response(formatError("Messages are required"), {
status: 400,
headers: { "Content-Type": "text/event-stream" },
})
}
// 4. 输入安全过滤
// 5. 输入安全过滤
for (const msg of body.messages) {
if (msg.role === "user") {
const filterResult = filterUserInput(msg.content, { isStudent })
if (filterResult.blocked) {
// 输入被过滤,回退配额
refundDailyQuota(ctx.userId)
return new Response(formatError("Input blocked by safety filter"), {
status: 400,
headers: { "Content-Type": "text/event-stream" },
@@ -86,24 +107,35 @@ export async function POST(request: NextRequest): Promise<Response> {
}
}
// 5. 构建 system prompt(学生侧 Socratic 模式)
const baseSystemPrompt = body.systemPrompt ?? CHAT_SYSTEM_PROMPT
const studentSystemPrompt = isStudent
? `${baseSystemPrompt}\n\nIMPORTANT: You are in student mode. Use the Socratic method. Do NOT give direct answers. Guide the student to find the answer themselves through questions and hints.`
: baseSystemPrompt
// 6. 构建 system prompt
// 安全:服务端强制使用系统提示词,忽略客户端传入的 systemPrompt
// 防止学生绕过 Socratic 模式获取直接答案。
const baseSystemPrompt = isStudent
? SOCRATIC_TUTOR_SYSTEM_PROMPT
: CHAT_SYSTEM_PROMPT
const messages: AiChatMessage[] = [
{ role: "system", content: studentSystemPrompt },
{ role: "system", content: baseSystemPrompt },
...body.messages,
]
// 6. 流式调用 AI
// 7. 流式调用 AI
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const startTime = Date.now()
let fullContent = ""
let success = true
let errorMessage: string | undefined
let wasFiltered = false
const safeEnqueue = (text: string): void => {
// 防止 controller 已关闭时 enqueue 抛错
try {
controller.enqueue(encoder.encode(text))
} catch {
// controller 已关闭,忽略
}
}
try {
const aiStream = createAiChatCompletionStream({
@@ -119,27 +151,49 @@ export async function POST(request: NextRequest): Promise<Response> {
// 输出安全过滤(逐 chunk 检查关键词)
const outputFilter = filterAiOutput(chunk, { isStudent })
if (outputFilter.blocked) {
controller.enqueue(encoder.encode(formatEvent({
safeEnqueue(formatEvent({
type: "filtered",
message: "Content filtered for safety",
})))
}))
success = false
wasFiltered = true
break
}
controller.enqueue(encoder.encode(formatEvent({ type: "token", content: chunk })))
safeEnqueue(formatEvent({ type: "token", content: chunk }))
}
// 增加每日使用计数
incrementDailyUsage(ctx.userId)
// 仅在成功完成时保留配额计数
// 过滤或失败时回退配额(不惩罚用户)
if (!success || wasFiltered) {
refundDailyQuota(ctx.userId)
}
controller.enqueue(encoder.encode(FORMAT_DONE))
// 学生侧:苏格拉底式辅导输出校验
// 流式完成后检查完整回复是否符合引导式教学原则
if (success && !wasFiltered && isStudent && fullContent) {
const socraticResult = validateSocraticOutput(fullContent)
if (!socraticResult.valid) {
safeEnqueue(formatEvent({
type: "socratic_warning",
message: "AI response did not fully follow Socratic tutoring principles.",
}))
}
}
safeEnqueue(FORMAT_DONE)
} catch (error) {
success = false
errorMessage = error instanceof Error ? error.message : String(error)
controller.enqueue(encoder.encode(formatError(getAiErrorMessage(error))))
// AI 调用失败,回退配额
refundDailyQuota(ctx.userId)
safeEnqueue(formatError(getAiErrorMessage(error)))
} finally {
controller.close()
try {
controller.close()
} catch {
// 已关闭,忽略
}
// 埋点
void trackEvent({
@@ -152,6 +206,7 @@ export async function POST(request: NextRequest): Promise<Response> {
tokenCount: fullContent.length / 4,
errorMessage,
isStudent,
filtered: wasFiltered,
},
}).catch(() => {
// 静默失败