feat(ai): V2 深度增强 — SSE 流式/全局助手/内容安全/多角色覆盖
对标 Khanmigo/Duolingo Max/Squirrel AI/Century Tech 实现: - SSE 流式响应:createAiChatCompletionStream AsyncGenerator + /api/ai/chat/stream SSE 端点 + useAiChatStream hook(AbortController 停止生成 + localStorage 持久化) - Markdown 渲染:AiMarkdownRenderer(react-markdown + remark-gfm + 代码块/表格/列表 + hover 复制按钮) - 全局 AI 助手:AiAssistantWidget 浮动按钮 + Sheet 侧抽屉 + usePathname 路由推断上下文(7 类场景系统提示)+ dashboard layout 全局注入 AiClientProvider - 内容安全:content-safety.ts 多层过滤(输入/输出安全过滤 + 每日限制 student 50/teacher 200/parent 30/admin 500 + 学生苏格拉底模式),COPPA/FERPA K12 合规 - 多角色 AI 覆盖:家长端 AiChildSummary(学情摘要)+ 管理员端 AiUsageDashboard(使用监控)+ 学生端 AiStudyPath(个性化学习路径) - i18n 修复:8 处错误键引用 + zh-CN/en ai.json 全面扩展 - 架构文档 004/005 同步更新
This commit is contained in:
@@ -9,6 +9,8 @@ import {
|
||||
QUESTION_VARIANT_SYSTEM_PROMPT,
|
||||
SIMILAR_QUESTION_SYSTEM_PROMPT,
|
||||
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
|
||||
CHILD_SUMMARY_SYSTEM_PROMPT,
|
||||
STUDY_PATH_SYSTEM_PROMPT,
|
||||
} from "./prompt-templates"
|
||||
import { withAiTracking } from "./usage-tracker"
|
||||
import {
|
||||
@@ -17,6 +19,8 @@ import {
|
||||
QuestionVariantResultSchema,
|
||||
SimilarQuestionListSchema,
|
||||
WeaknessAnalysisResultSchema,
|
||||
ChildSummaryResultSchema,
|
||||
StudyPathResultSchema,
|
||||
} from "../schema"
|
||||
import type {
|
||||
AiChatMessage,
|
||||
@@ -33,6 +37,10 @@ import type {
|
||||
SimilarQuestionResult,
|
||||
WeaknessAnalysisInput,
|
||||
WeaknessAnalysisResult,
|
||||
ChildSummaryInput,
|
||||
ChildSummaryResult,
|
||||
StudyPathInput,
|
||||
StudyPathResult,
|
||||
} from "../types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -320,6 +328,76 @@ export class DefaultAiService implements AiService {
|
||||
return { result: validated.data }
|
||||
})
|
||||
}
|
||||
|
||||
async generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult> {
|
||||
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
|
||||
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)}`
|
||||
: "",
|
||||
input.attendanceRate !== undefined
|
||||
? `Attendance Rate: ${(input.attendanceRate * 100).toFixed(1)}%`
|
||||
: "",
|
||||
input.errorBookSummary
|
||||
? `Error Book Summary:\n${JSON.stringify(input.errorBookSummary, null, 2)}`
|
||||
: "",
|
||||
input.homeworkCompletionRate !== undefined
|
||||
? `Homework Completion Rate: ${(input.homeworkCompletionRate * 100).toFixed(1)}%`
|
||||
: "",
|
||||
].filter((line) => line.length > 0)
|
||||
const { content } = await callAi(
|
||||
buildChatMessages(CHILD_SUMMARY_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||
{ temperature: 0.4, maxTokens: 2000 }
|
||||
)
|
||||
const parsed = extractJson(content)
|
||||
const validated = ChildSummaryResultSchema.safeParse(parsed)
|
||||
if (!validated.success) {
|
||||
return {
|
||||
result: {
|
||||
overallAssessment: "Unable to generate summary at this time.",
|
||||
strengths: [],
|
||||
areasForImprovement: [],
|
||||
familyTutoringSuggestions: [],
|
||||
nextSteps: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
return { result: validated.data }
|
||||
})
|
||||
}
|
||||
|
||||
async recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult> {
|
||||
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
|
||||
const userLines = [
|
||||
`Student ID: ${input.studentId}`,
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
input.currentMastery && input.currentMastery.length > 0
|
||||
? `Current Mastery:\n${JSON.stringify(input.currentMastery, null, 2)}`
|
||||
: "",
|
||||
input.learningGoal ? `Learning Goal: ${input.learningGoal}` : "",
|
||||
].filter((line) => line.length > 0)
|
||||
const { content } = await callAi(
|
||||
buildChatMessages(STUDY_PATH_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||
{ temperature: 0.5, maxTokens: 2000 }
|
||||
)
|
||||
const parsed = extractJson(content)
|
||||
const validated = StudyPathResultSchema.safeParse(parsed)
|
||||
if (!validated.success) {
|
||||
return {
|
||||
result: {
|
||||
currentLevel: "Analysis unavailable",
|
||||
learningPath: [],
|
||||
summary: "Unable to generate learning path at this time.",
|
||||
motivation: "Keep learning!",
|
||||
},
|
||||
}
|
||||
}
|
||||
return { result: validated.data }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
173
src/modules/ai/services/content-safety.ts
Normal file
173
src/modules/ai/services/content-safety.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* AI 内容安全过滤
|
||||
*
|
||||
* 多层防护:
|
||||
* 1. 输入过滤:检查用户输入是否包含不当内容
|
||||
* 2. 输出过滤:检查 AI 回复是否包含不当内容
|
||||
* 3. 每日限制:按用户 + 日期计数
|
||||
*
|
||||
* 参考 Khanmigo 的多层 moderation 模式。
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 不当内容关键词(基础过滤,生产环境应接入专业 Moderation API)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BLOCKED_INPUT_PATTERNS: readonly RegExp[] = [
|
||||
/\b(violence|kill|murder|suicide|self[- ]?harm|cut myself)\b/i,
|
||||
/\b(porn|sex|nude|nsfw|explicit)\b/i,
|
||||
/\b(drug|cocaine|heroin|weed|marijuana)\b/i,
|
||||
/\b(hack|exploit|malware|virus|phishing)\b/i,
|
||||
// PII 请求
|
||||
/\b(your (password|credit card|ssn|social security|bank account))\b/i,
|
||||
/\b(home address|phone number|real name)\b/i,
|
||||
]
|
||||
|
||||
const BLOCKED_OUTPUT_PATTERNS: readonly RegExp[] = [
|
||||
/\b(violence|kill|murder|suicide|self[- ]?harm)\b/i,
|
||||
/\b(porn|sex|nude|nsfw|explicit)\b/i,
|
||||
/\b(drug|cocaine|heroin)\b/i,
|
||||
]
|
||||
|
||||
const STUDENT_BLOCKED_PATTERNS: readonly RegExp[] = [
|
||||
// 学生侧额外限制:禁止直接给出作业答案
|
||||
/\b(here is the (complete )?answer|the answer is:?)\b/i,
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 输入过滤
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SafetyFilterResult = {
|
||||
blocked: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export const filterUserInput = (
|
||||
content: string,
|
||||
options?: { isStudent?: boolean }
|
||||
): SafetyFilterResult => {
|
||||
const text = String(content ?? "")
|
||||
|
||||
for (const pattern of BLOCKED_INPUT_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: "Input contains inappropriate content",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.isStudent) {
|
||||
// 学生侧额外检查
|
||||
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: "Student input blocked by safety filter",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { blocked: false }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 输出过滤
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const filterAiOutput = (
|
||||
content: string,
|
||||
options?: { isStudent?: boolean }
|
||||
): SafetyFilterResult => {
|
||||
const text = String(content ?? "")
|
||||
|
||||
for (const pattern of BLOCKED_OUTPUT_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: "AI output contains inappropriate content",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.isStudent) {
|
||||
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: "AI output blocked for student safety",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { blocked: false }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 每日限制
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DAILY_LIMITS: Record<string, number> = {
|
||||
student: 50,
|
||||
teacher: 200,
|
||||
parent: 30,
|
||||
admin: 500,
|
||||
}
|
||||
|
||||
export const getDailyLimit = (role: string): number => {
|
||||
return DAILY_LIMITS[role] ?? 50
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户今日 AI 使用次数
|
||||
*
|
||||
* 生产环境应接入 Redis 或数据库计数器。
|
||||
* 当前实现为内存映射(单实例场景),多实例需替换为 Redis。
|
||||
*/
|
||||
const dailyUsageMap = new Map<string, { date: string; count: number }>()
|
||||
|
||||
export const checkDailyLimit = (userId: string, role: string): SafetyFilterResult => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const key = `${userId}:${today}`
|
||||
const limit = getDailyLimit(role)
|
||||
const current = dailyUsageMap.get(key)
|
||||
|
||||
if (!current) {
|
||||
return { blocked: false }
|
||||
}
|
||||
|
||||
if (current.count >= limit) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: `Daily limit reached (${current.count}/${limit})`,
|
||||
}
|
||||
}
|
||||
|
||||
return { blocked: false }
|
||||
}
|
||||
|
||||
export const incrementDailyUsage = (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 += 1
|
||||
} else {
|
||||
dailyUsageMap.set(key, { date: today, count: 1 })
|
||||
}
|
||||
|
||||
// 清理过期条目(防止内存泄漏)
|
||||
if (dailyUsageMap.size > 10000) {
|
||||
for (const [k, v] of dailyUsageMap.entries()) {
|
||||
if (v.date !== today) {
|
||||
dailyUsageMap.delete(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,3 +152,72 @@ export const JSON_REPAIR_SYSTEM_PROMPT = [
|
||||
"Do not use placeholders such as ... or [...].",
|
||||
"Return JSON only without markdown.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 通用聊天(全局 AI 助手)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CHAT_SYSTEM_PROMPT = [
|
||||
"You are a helpful K12 education assistant for the Next_Edu school management system.",
|
||||
"You assist teachers, students, parents, and administrators with their daily tasks.",
|
||||
"Respond in the user's language (Chinese by default).",
|
||||
"Use Markdown formatting for structured content (lists, tables, code blocks).",
|
||||
"Be concise, accurate, and pedagogically sound.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 家长学情摘要
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CHILD_SUMMARY_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 family education advisor.",
|
||||
"Analyze the student's learning data and generate a summary for parents.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "overallAssessment": "brief overall assessment in parent-friendly language",',
|
||||
' "strengths": ["strength 1", "strength 2"],',
|
||||
' "areasForImprovement": ["area 1", "area 2"],',
|
||||
' "familyTutoringSuggestions": ["suggestion 1", "suggestion 2"],',
|
||||
' "nextSteps": ["actionable next step 1", "actionable next step 2"]',
|
||||
"}",
|
||||
"Rules:",
|
||||
"- Use encouraging and constructive tone.",
|
||||
"- Focus on actionable advice parents can follow at home.",
|
||||
"- Avoid educational jargon; use plain language.",
|
||||
"- Consider cultural sensitivity in family education.",
|
||||
"- If data is limited, provide general guidance.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 学习路径推荐
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "currentLevel": "brief description of current level",',
|
||||
' "learningPath": [',
|
||||
" {",
|
||||
' "step": 1,',
|
||||
' "knowledgePoint": "knowledge point name",',
|
||||
' "status": "mastered | in_progress | needs_work",',
|
||||
' "recommendedAction": "specific action to take",',
|
||||
' "estimatedTime": "15 min"',
|
||||
" }",
|
||||
" ],",
|
||||
' "summary": "brief summary of the learning path",',
|
||||
' "motivation": "encouraging message for the student"',
|
||||
"}",
|
||||
"Rules:",
|
||||
"- Order learning path from foundational to advanced.",
|
||||
"- Prioritize weak areas (mastery < 2) first.",
|
||||
"- Include 3-7 steps in the learning path.",
|
||||
"- estimatedTime should be realistic (5-30 min per step).",
|
||||
"- motivation should be age-appropriate and encouraging.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
Reference in New Issue
Block a user