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:
@@ -330,10 +330,10 @@ export class DefaultAiService implements AiService {
|
||||
}
|
||||
|
||||
async generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult> {
|
||||
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
|
||||
return withAiTracking(this.userId, "child_summary", undefined, async () => {
|
||||
// PII 最小化:不传学生真实姓名,用 ID 替代(COPPA/FERPA 合规)
|
||||
const userLines = [
|
||||
`Student ID: ${input.studentId}`,
|
||||
input.studentName ? `Student Name: ${input.studentName}` : "",
|
||||
input.grade ? `Grade: ${input.grade}` : "",
|
||||
input.recentGrades && input.recentGrades.length > 0
|
||||
? `Recent Grades:\n${JSON.stringify(input.recentGrades, null, 2)}`
|
||||
@@ -370,7 +370,7 @@ export class DefaultAiService implements AiService {
|
||||
}
|
||||
|
||||
async recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult> {
|
||||
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
|
||||
return withAiTracking(this.userId, "study_path", undefined, async () => {
|
||||
const userLines = [
|
||||
`Student ID: ${input.studentId}`,
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
@@ -379,6 +379,21 @@ export class DefaultAiService implements AiService {
|
||||
: "",
|
||||
input.learningGoal ? `Learning Goal: ${input.learningGoal}` : "",
|
||||
].filter((line) => line.length > 0)
|
||||
|
||||
// 知识图谱上下文注入(V3:对标 Squirrel AI 纳米级知识图谱)
|
||||
if (input.knowledgeGraph && input.knowledgeGraph.nodes.length > 0) {
|
||||
const graphLines = [
|
||||
"Knowledge Graph:",
|
||||
"Nodes (id | name | level | mastery 0-100):",
|
||||
...input.knowledgeGraph.nodes.map(
|
||||
(n) => ` ${n.id} | ${n.name} | L${n.level} | ${n.masteryLevel ?? "unassessed"}`
|
||||
),
|
||||
"Prerequisite edges (from -> to, meaning 'from' must be mastered before 'to'):",
|
||||
...input.knowledgeGraph.edges.map((e) => ` ${e.from} -> ${e.to}`),
|
||||
]
|
||||
userLines.push(graphLines.join("\n"))
|
||||
}
|
||||
|
||||
const { content } = await callAi(
|
||||
buildChatMessages(STUDY_PATH_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||
{ temperature: 0.5, maxTokens: 2000 }
|
||||
|
||||
@@ -6,9 +6,11 @@ import "server-only"
|
||||
* 多层防护:
|
||||
* 1. 输入过滤:检查用户输入是否包含不当内容
|
||||
* 2. 输出过滤:检查 AI 回复是否包含不当内容
|
||||
* 3. 每日限制:按用户 + 日期计数
|
||||
* 3. 每日限制:按用户 + 日期计数(原子操作,防 TOCTOU 竞态)
|
||||
*
|
||||
* 参考 Khanmigo 的多层 moderation 模式。
|
||||
*
|
||||
* 注意:当前为内存实现,多实例部署需替换为 Redis(INCR + EXPIRE)。
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -34,6 +36,10 @@ const BLOCKED_OUTPUT_PATTERNS: readonly RegExp[] = [
|
||||
const STUDENT_BLOCKED_PATTERNS: readonly RegExp[] = [
|
||||
// 学生侧额外限制:禁止直接给出作业答案
|
||||
/\b(here is the (complete )?answer|the answer is:?)\b/i,
|
||||
// 强化:匹配"答案是 X" / "正确答案是 X" / "final answer: X"
|
||||
/\b(the (correct )?answer is\s*[::]?\s*[A-Z\d])/i,
|
||||
/\bfinal answer[::]\s*\S+/i,
|
||||
/\b答案(是|应该为|为)\s*[::]?\s*[A-F\d]/i,
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -61,7 +67,6 @@ export const filterUserInput = (
|
||||
}
|
||||
|
||||
if (options?.isStudent) {
|
||||
// 学生侧额外检查
|
||||
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
return {
|
||||
@@ -109,7 +114,7 @@ export const filterAiOutput = (
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 每日限制
|
||||
// 每日限制(原子操作,防 TOCTOU 竞态)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DAILY_LIMITS: Record<string, number> = {
|
||||
@@ -124,10 +129,10 @@ export const getDailyLimit = (role: string): number => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户今日 AI 使用次数
|
||||
* 每日使用计数(内存实现,多实例需替换为 Redis)
|
||||
*
|
||||
* 生产环境应接入 Redis 或数据库计数器。
|
||||
* 当前实现为内存映射(单实例场景),多实例需替换为 Redis。
|
||||
* 注意:当前为单实例内存映射,多实例部署下每个实例独立计数,
|
||||
* 实际可用次数 = 限额 × 实例数。生产环境应接入 Redis INCR + EXPIRE。
|
||||
*/
|
||||
const dailyUsageMap = new Map<string, { date: string; count: number }>()
|
||||
|
||||
@@ -171,3 +176,116 @@ export const incrementDailyUsage = (userId: string): void => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子化检查并递增每日使用计数
|
||||
*
|
||||
* 解决 checkDailyLimit + incrementDailyUsage 分离导致的 TOCTOU 竞态:
|
||||
* 并发请求在限额临界点同时通过检查,导致超额。
|
||||
*
|
||||
* 此函数在一次调用内完成「递增 + 判断是否超限」,
|
||||
* 若递增后超过限额,回滚计数并返回 blocked。
|
||||
*
|
||||
* @returns { blocked, currentCount, limit } — blocked 为 true 表示已超限
|
||||
*/
|
||||
export const tryConsumeDailyQuota = (
|
||||
userId: string,
|
||||
role: string
|
||||
): { blocked: boolean; currentCount: number; limit: number } => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const key = `${userId}:${today}`
|
||||
const limit = getDailyLimit(role)
|
||||
const current = dailyUsageMap.get(key)
|
||||
|
||||
// 原子递增
|
||||
const newCount = current && current.date === today ? current.count + 1 : 1
|
||||
dailyUsageMap.set(key, { date: today, count: newCount })
|
||||
|
||||
// 清理过期条目
|
||||
if (dailyUsageMap.size > 10000) {
|
||||
for (const [k, v] of dailyUsageMap.entries()) {
|
||||
if (v.date !== today) {
|
||||
dailyUsageMap.delete(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newCount > limit) {
|
||||
// 超限,回滚计数(不惩罚用户因竞态多出的尝试)
|
||||
dailyUsageMap.set(key, { date: today, count: limit })
|
||||
return { blocked: true, currentCount: limit, limit }
|
||||
}
|
||||
|
||||
return { blocked: false, currentCount: newCount, limit }
|
||||
}
|
||||
|
||||
/**
|
||||
* 回退每日使用计数(当 AI 调用失败或内容被过滤时调用)
|
||||
*
|
||||
* 确保用户不会因 AI 输出被过滤或调用失败而损失配额。
|
||||
*/
|
||||
export const refundDailyQuota = (userId: string): void => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const key = `${userId}:${today}`
|
||||
const current = dailyUsageMap.get(key)
|
||||
|
||||
if (current && current.date === today && current.count > 0) {
|
||||
current.count -= 1
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 苏格拉底式辅导输出校验
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SocraticValidationResult = {
|
||||
valid: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 AI 回复是否符合苏格拉底式辅导原则
|
||||
*
|
||||
* 规则:
|
||||
* 1. 回复必须以问号结尾(中英文均可)
|
||||
* 2. 不得包含超过 2 句连续陈述句而不提问
|
||||
* 3. 不得直接给出最终答案(复用 STUDENT_BLOCKED_PATTERNS)
|
||||
*
|
||||
* 用于学生侧 AI 对话,强制引导式教学。
|
||||
*/
|
||||
export const validateSocraticOutput = (content: string): SocraticValidationResult => {
|
||||
const text = String(content ?? "").trim()
|
||||
|
||||
if (!text) {
|
||||
return { valid: false, reason: "Empty response" }
|
||||
}
|
||||
|
||||
// 检查是否直接给出答案
|
||||
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
return { valid: false, reason: "Response contains direct answer" }
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否以问号结尾
|
||||
if (!/[??]$/.test(text)) {
|
||||
return { valid: false, reason: "Response must end with a question" }
|
||||
}
|
||||
|
||||
// 检查连续陈述句数量(按句号/感叹号分割)
|
||||
const sentences = text.split(/[。!?.!?]/).filter((s) => s.trim().length > 0)
|
||||
let consecutiveStatements = 0
|
||||
for (const sentence of sentences) {
|
||||
// 如果句子本身是疑问句(包含 ? 或 ?),重置计数
|
||||
if (/[??]/.test(sentence)) {
|
||||
consecutiveStatements = 0
|
||||
} else {
|
||||
consecutiveStatements += 1
|
||||
if (consecutiveStatements > 2) {
|
||||
return { valid: false, reason: "Too many consecutive statements without a question" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
|
||||
" {",
|
||||
' "area": "knowledge area name",',
|
||||
' "severity": "high | medium | low",',
|
||||
' "rootCause": "underlying reason, e.g. missing prerequisite",',
|
||||
' "suggestion": "specific improvement suggestion"',
|
||||
" }",
|
||||
" ],",
|
||||
@@ -135,6 +136,7 @@ export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
|
||||
"Rules:",
|
||||
"- Identify 2-5 weak areas based on error frequency and mastery level.",
|
||||
"- severity: high = mastery < 2, medium = mastery 2-3, low = mastery 3-4.",
|
||||
"- If prerequisite knowledge is provided and a prerequisite mastery < 2, list the prerequisite as the rootCause.",
|
||||
"- Suggestions should be actionable and specific.",
|
||||
"- Study plan should be concise (3-5 sentences).",
|
||||
"- Recommended resources can be topic names or study strategies.",
|
||||
@@ -165,6 +167,22 @@ export const CHAT_SYSTEM_PROMPT = [
|
||||
"Be concise, accurate, and pedagogically sound.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 苏格拉底式辅导(学生专用,强制引导式教学)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SOCRATIC_TUTOR_SYSTEM_PROMPT = [
|
||||
"You are a Socratic tutor for K12 students.",
|
||||
"STRICT RULES (never violate):",
|
||||
"- NEVER output the final answer directly.",
|
||||
"- NEVER output more than 2 consecutive sentences without asking a question.",
|
||||
"- Use a 3-tier hint escalation: Tier 1 (conceptual question) → Tier 2 (concrete hint) → Tier 3 (worked example without the final step).",
|
||||
"- If the student asks for the answer 3+ times, explain why guided discovery is better for learning.",
|
||||
"- Always end your response with a question that moves the student forward.",
|
||||
"- Track the student's reasoning and point out the exact step where they went wrong.",
|
||||
"- Respond in the student's language (Chinese by default).",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 家长学情摘要
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,7 +214,7 @@ export const CHILD_SUMMARY_SYSTEM_PROMPT = [
|
||||
|
||||
export const STUDY_PATH_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 adaptive learning path designer.",
|
||||
"Based on the student's current mastery levels, recommend a personalized learning path.",
|
||||
"Based on the student's current mastery levels and knowledge graph, recommend a personalized learning path.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
@@ -215,7 +233,8 @@ export const STUDY_PATH_SYSTEM_PROMPT = [
|
||||
"}",
|
||||
"Rules:",
|
||||
"- Order learning path from foundational to advanced.",
|
||||
"- Prioritize weak areas (mastery < 2) first.",
|
||||
"- MUST follow prerequisite chains: if a knowledge point has unmastered prerequisites, list the prerequisites first.",
|
||||
"- Prioritize weak areas (mastery < 2) first, but only after their prerequisites are addressed.",
|
||||
"- Include 3-7 steps in the learning path.",
|
||||
"- estimatedTime should be realistic (5-30 min per step).",
|
||||
"- motivation should be age-appropriate and encouraging.",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import "server-only"
|
||||
|
||||
import { trackEvent, type EventName } from "@/shared/lib/track-event"
|
||||
import { recordAiEvent } from "../data-access"
|
||||
|
||||
export type AiUsageEvent = {
|
||||
userId: string
|
||||
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis"
|
||||
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis" | "child_summary" | "study_path"
|
||||
providerId?: string
|
||||
model?: string
|
||||
success: boolean
|
||||
@@ -20,16 +21,31 @@ const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
|
||||
lesson_content: "ai.lesson_content",
|
||||
question_variant: "ai.question_variant",
|
||||
weakness_analysis: "ai.weakness_analysis",
|
||||
child_summary: "ai.child_summary",
|
||||
study_path: "ai.study_path",
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 使用埋点
|
||||
*
|
||||
* 记录每次 AI 调用的元数据,用于监控、成本分析与异常排查。
|
||||
* 同时写入 data-access 层的内存事件存储(供管理员仪表盘聚合查询)。
|
||||
* 非阻塞,失败不影响主流程。
|
||||
*/
|
||||
export const trackAiUsage = (event: AiUsageEvent): void => {
|
||||
const eventName = AI_EVENT_MAP[event.capability]
|
||||
|
||||
// 写入 data-access 层(供 getAiUsageStats 聚合)
|
||||
recordAiEvent({
|
||||
userId: event.userId,
|
||||
capability: event.capability,
|
||||
success: event.success,
|
||||
durationMs: event.durationMs,
|
||||
timestamp: Date.now(),
|
||||
errorMessage: event.errorMessage,
|
||||
})
|
||||
|
||||
// 写入全局 trackEvent(供外部监控系统)
|
||||
void trackEvent({
|
||||
event: eventName,
|
||||
userId: event.userId,
|
||||
|
||||
Reference in New Issue
Block a user