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

@@ -2056,33 +2056,41 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| **student** | `AiStudyPath` | `student/*` | 学习路径推荐 — V2 新增 | | **student** | `AiStudyPath` | `student/*` | 学习路径推荐 — V2 新增 |
**依赖关系** **依赖关系**
- `modules/ai``shared/lib/ai`AI SDK 封装,含流式 `createAiChatCompletionStream` - `modules/ai``shared/lib/ai`AI SDK 封装,含流式 `createAiChatCompletionStream`V3 增加 `withRetry` 重试机制
- `modules/ai``shared/lib/auth-guard`(权限校验) - `modules/ai``shared/lib/auth-guard`(权限校验)
- `modules/ai``shared/lib/track-event`(使用量埋点) - `modules/ai``shared/lib/track-event`(使用量埋点)
- `modules/ai``shared/types/permissions`(权限常量) - `modules/ai``shared/types/permissions`(权限常量)
- `modules/ai``shared/types/action-state`(返回值类型) - `modules/ai``shared/types/action-state`(返回值类型)
- `modules/ai``modules/textbooks/data-access-graph`(知识图谱+掌握度查询)— V3 新增
- `app/(dashboard)/layout``modules/ai`(全局 Provider + Widget— V2 新增 - `app/(dashboard)/layout``modules/ai`(全局 Provider + Widget— V2 新增
- `app/api/ai/chat/stream``modules/ai/services/content-safety`SSE 端点安全过滤)— V3 加固
- 业务模块 → `modules/ai/context/ai-client-provider`(通过 Context 注入) - 业务模块 → `modules/ai/context/ai-client-provider`(通过 Context 注入)
- 业务模块 → `modules/ai/components/*`(组合 AI 组件) - 业务模块 → `modules/ai/components/*`(组合 AI 组件)
**安全机制V2 新增)** **安全机制V2 新增 / V3 加固**
- 输入过滤:`filterUserInput` 拦截暴力/自残/色情/PII - 输入过滤:`filterUserInput` 拦截暴力/自残/色情/PII
- 输出过滤:`filterAiOutput` 扫描 AI 回复 - 输出过滤:`filterAiOutput` 扫描 AI 回复
- 每日限制:学生 50 次/天,教师 200 次/天,家长 30 次/天 - 每日限制:学生 50 次/天,教师 200 次/天,家长 30 次/天
- 学生 Socratic 模式system prompt 强制不直接给答案 - 学生 Socratic 模式system prompt 强制不直接给答案
- SSE 端点权限校验:`requirePermission(AI_CHAT)` - SSE 端点权限校验:`requirePermission(AI_CHAT)`
- V3 原子配额:`tryConsumeDailyQuota` 解决 TOCTOU 竞态,`refundDailyQuota` 过滤/失败时回退
- V3 苏格拉底校验:`validateSocraticOutput` 检查问号结尾+连续陈述句限制,`SOCRATIC_TUTOR_SYSTEM_PROMPT` 3 级提示升级
- V3 SSE 端点加固Zod 校验 + rate limit + 服务端强制 systemPrompt忽略客户端
- V3 重试机制:`withRetry` 指数退避429/5xx2 次重试1s/2s/4s 延迟)
- V3 知识图谱集成:`recommendStudyPathAction` 自动从 textbooks 模块获取图谱+掌握度注入 prompt
**文件清单** **文件清单**
| 文件 | 行数 | 职责 | | 文件 | 行数 | 职责 |
|------|------|------| |------|------|------|
| `modules/ai/types.ts` | ~270 | 类型定义8 个业务场景类型 + AiService/AiClientService | | `modules/ai/types.ts` | ~290 | 类型定义8 个业务场景类型 + AiService/AiClientServiceV3 新增 knowledgeGraph/textbookId |
| `modules/ai/schema.ts` | ~205 | Zod 验证 schema8 个输入 + 8 个输出) | | `modules/ai/schema.ts` | ~230 | Zod 验证 schema8 个输入 + 8 个输出V3 新增 knowledgeGraph/textbookId |
| `modules/ai/actions.ts` | ~340 | 9 个 Server Actions含权限校验 | | `modules/ai/actions.ts` | ~400 | 9 个 Server Actions含权限校验V3 新增知识图谱获取 |
| `modules/ai/services/ai-service.ts` | ~400 | DefaultAiService 实现8 个方法) | | `modules/ai/data-access.ts` | ~138 | AI 事件存储+统计聚合 — V3 新增 |
| `modules/ai/services/prompt-templates.ts` | ~210 | 8 个系统提示词模板 | | `modules/ai/services/ai-service.ts` | ~430 | DefaultAiService 实现8 个方法V3 新增知识图谱上下文注入) |
| `modules/ai/services/usage-tracker.ts` | ~83 | AI 使用量埋点 | | `modules/ai/services/prompt-templates.ts` | ~250 | 9 个系统提示词模板V3 新增 SOCRATIC_TUTOR_SYSTEM_PROMPT |
| `modules/ai/services/content-safety.ts` | ~130 | 内容安全过滤(输入/输出/每日限制)— V2 新增 | | `modules/ai/services/usage-tracker.ts` | ~90 | AI 使用量埋点V3 新增 child_summary/study_path 能力) |
| `modules/ai/services/content-safety.ts` | ~290 | 内容安全过滤(输入/输出/每日限制/原子配额/苏格拉底校验)— V2 新增 / V3 加固 |
| `modules/ai/context/ai-client-provider.tsx` | ~62 | React Context Provider + Hooks | | `modules/ai/context/ai-client-provider.tsx` | ~62 | React Context Provider + Hooks |
| `modules/ai/components/ai-assistant-widget.tsx` | ~170 | 全局 AI 助手悬浮按钮 — V2 新增 | | `modules/ai/components/ai-assistant-widget.tsx` | ~170 | 全局 AI 助手悬浮按钮 — V2 新增 |
| `modules/ai/components/ai-chat-panel.tsx` | ~305 | AI 对话面板(流式 + Markdown— V2 增强 | | `modules/ai/components/ai-chat-panel.tsx` | ~305 | AI 对话面板(流式 + Markdown— V2 增强 |
@@ -2097,7 +2105,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `modules/ai/components/ai-error-boundary.tsx` | ~88 | AI 错误边界 | | `modules/ai/components/ai-error-boundary.tsx` | ~88 | AI 错误边界 |
| `modules/ai/components/ai-skeleton.tsx` | ~47 | AI 骨架屏 | | `modules/ai/components/ai-skeleton.tsx` | ~47 | AI 骨架屏 |
| `modules/ai/components/ai-provider-selector.tsx` | ~129 | 服务商选择器 | | `modules/ai/components/ai-provider-selector.tsx` | ~129 | 服务商选择器 |
| `modules/ai/hooks/use-ai-chat-stream.ts` | ~170 | 流式 AI 对话 Hook — V2 新增 | | `modules/ai/hooks/use-ai-chat-stream.ts` | ~107 | 流式 AI 对话 HookV3 拆分至 ≤80 行函数体)— V2 新增 / V3 重构 |
| `modules/ai/hooks/stream-utils.ts` | ~120 | SSE 流解析工具consumeSseStream/getStreamErrorKey 等)— V3 新增 |
| `modules/ai/hooks/use-ai-chat.ts` | ~57 | 非流式 AI 对话 Hook | | `modules/ai/hooks/use-ai-chat.ts` | ~57 | 非流式 AI 对话 Hook |
| `modules/ai/hooks/use-ai-suggestion.ts` | ~72 | AI 建议 Hook | | `modules/ai/hooks/use-ai-suggestion.ts` | ~72 | AI 建议 Hook |
| `app/api/ai/chat/stream/route.ts` | ~160 | SSE 流式端点 — V2 新增 | | `app/api/ai/chat/stream/route.ts` | ~160 | SSE 流式端点 — V2 新增 |

View File

@@ -16341,7 +16341,8 @@
}, },
"ai": { "ai": {
"dependsOn": [ "dependsOn": [
"shared" "shared",
"textbooks"
], ],
"uses": { "uses": {
"shared": [ "shared": [
@@ -16353,6 +16354,10 @@
"types.action-state", "types.action-state",
"lib.track-event.trackEvent", "lib.track-event.trackEvent",
"i18n.messages" "i18n.messages"
],
"textbooks": [
"data-access-graph.getKnowledgePointsWithRelations",
"data-access-graph.getStudentKpMastery"
] ]
}, },
"exports": { "exports": {
@@ -16422,11 +16427,27 @@
"filterAiOutput", "filterAiOutput",
"checkDailyLimit", "checkDailyLimit",
"incrementDailyUsage", "incrementDailyUsage",
"getDailyLimit" "getDailyLimit",
"tryConsumeDailyQuota",
"refundDailyQuota",
"validateSocraticOutput"
],
"dataAccess": [
"recordAiEvent",
"getAiUsageStats"
],
"streamUtils": [
"consumeSseStream",
"getStreamErrorKey",
"extractErrorMessage",
"removeTrailingEmptyAssistant",
"appendTokenToLastAssistant"
], ],
"promptTemplates": [ "promptTemplates": [
"CHILD_SUMMARY_SYSTEM_PROMPT", "CHILD_SUMMARY_SYSTEM_PROMPT",
"STUDY_PATH_SYSTEM_PROMPT" "STUDY_PATH_SYSTEM_PROMPT",
"SOCRATIC_TUTOR_SYSTEM_PROMPT",
"CHAT_SYSTEM_PROMPT"
] ]
}, },
"integrations": { "integrations": {
@@ -16841,6 +16862,12 @@
"type": "normal", "type": "normal",
"description": "使用 lib/ai.createAiChatCompletion、auth-guard.requirePermission、types.permissions、types.action-state" "description": "使用 lib/ai.createAiChatCompletion、auth-guard.requirePermission、types.permissions、types.action-state"
}, },
{
"from": "ai",
"to": "textbooks",
"type": "normal",
"description": "V3 新增:使用 data-access-graph.getKnowledgePointsWithRelations/getStudentKpMastery 获取知识图谱与掌握度,注入学习路径推荐"
},
{ {
"from": "homework", "from": "homework",
"to": "ai", "to": "ai",

View File

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

View File

@@ -6,14 +6,14 @@ import { Permissions } from "@/shared/types/permissions"
export const dynamic = "force-dynamic" 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 === "Invalid payload" || message === "Messages are required") return 400
if (message === "AI API key missing") return 500 if (message === "AI API key missing") return 500
if (message === "Empty response") return 502 if (message === "Empty response") return 502
return 502 return 502
} }
export async function POST(req: Request) { export async function POST(req: Request): Promise<NextResponse> {
try { try {
const ctx = await requirePermission(Permissions.AI_CHAT) const ctx = await requirePermission(Permissions.AI_CHAT)
const userId = ctx.userId 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 { createAiChatCompletionStream } from "@/shared/lib/ai/client"
import { getAiErrorMessage } from "@/shared/lib/ai" import { getAiErrorMessage } from "@/shared/lib/ai"
import { trackEvent } from "@/shared/lib/track-event" import { trackEvent } from "@/shared/lib/track-event"
import {
rateLimit,
rateLimitKey,
RATE_LIMIT_RULES,
} from "@/shared/lib/rate-limit"
import { env } from "@/env.mjs" 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 { import {
filterUserInput, filterUserInput,
filterAiOutput, filterAiOutput,
checkDailyLimit, tryConsumeDailyQuota,
incrementDailyUsage, refundDailyQuota,
validateSocraticOutput,
} from "@/modules/ai/services/content-safety" } from "@/modules/ai/services/content-safety"
import { AiChatInputSchema } from "@/modules/ai/schema"
import type { AiChatMessage } from "@/modules/ai/types" import type { AiChatMessage } from "@/modules/ai/types"
/** /**
@@ -23,11 +30,14 @@ import type { AiChatMessage } from "@/modules/ai/types"
* 使用 Server-Sent Events 逐 token 推送 AI 回复, * 使用 Server-Sent Events 逐 token 推送 AI 回复,
* 降低用户感知延迟。 * 降低用户感知延迟。
* *
* 安全: * 安全策略(与非流式端点一致)
* - requirePermission(AI_CHAT) 权限校验 * - requirePermission(AI_CHAT) 权限校验
* - rateLimit 每分钟限流(防高频滥用)
* - Zod 校验输入AiChatInputSchema限制消息数/长度)
* - tryConsumeDailyQuota 原子化每日限额(防 TOCTOU 竞态)
* - 输入/输出内容安全过滤 * - 输入/输出内容安全过滤
* - 每日交互限制 * - 学生侧 Socratic 模式(服务端强制,忽略客户端 systemPrompt
* - 学生侧 Socratic 模式 * - 过滤/失败时 refundDailyQuota不惩罚用户
*/ */
const formatEvent = (data: unknown): string => { const formatEvent = (data: unknown): string => {
@@ -50,34 +60,45 @@ export async function POST(request: NextRequest): Promise<Response> {
const userRole = session?.user?.role ?? "student" const userRole = session?.user?.role ?? "student"
const isStudent = userRole === "student" const isStudent = userRole === "student"
// 2. 每日限制 // 2. Rate limit与非流式端点一致
const limitCheck = checkDailyLimit(ctx.userId, userRole) const limitResult = rateLimit({
if (limitCheck.blocked) { 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"), { return new Response(formatError("Daily limit reached"), {
status: 429, status: 429,
headers: { "Content-Type": "text/event-stream" }, headers: { "Content-Type": "text/event-stream" },
}) })
} }
// 3. 解析请求 // 5. 输入安全过滤
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. 输入安全过滤
for (const msg of body.messages) { for (const msg of body.messages) {
if (msg.role === "user") { if (msg.role === "user") {
const filterResult = filterUserInput(msg.content, { isStudent }) const filterResult = filterUserInput(msg.content, { isStudent })
if (filterResult.blocked) { if (filterResult.blocked) {
// 输入被过滤,回退配额
refundDailyQuota(ctx.userId)
return new Response(formatError("Input blocked by safety filter"), { return new Response(formatError("Input blocked by safety filter"), {
status: 400, status: 400,
headers: { "Content-Type": "text/event-stream" }, headers: { "Content-Type": "text/event-stream" },
@@ -86,24 +107,35 @@ export async function POST(request: NextRequest): Promise<Response> {
} }
} }
// 5. 构建 system prompt(学生侧 Socratic 模式) // 6. 构建 system prompt
const baseSystemPrompt = body.systemPrompt ?? CHAT_SYSTEM_PROMPT // 安全:服务端强制使用系统提示词,忽略客户端传入的 systemPrompt
const studentSystemPrompt = isStudent // 防止学生绕过 Socratic 模式获取直接答案。
? `${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.` const baseSystemPrompt = isStudent
: baseSystemPrompt ? SOCRATIC_TUTOR_SYSTEM_PROMPT
: CHAT_SYSTEM_PROMPT
const messages: AiChatMessage[] = [ const messages: AiChatMessage[] = [
{ role: "system", content: studentSystemPrompt }, { role: "system", content: baseSystemPrompt },
...body.messages, ...body.messages,
] ]
// 6. 流式调用 AI // 7. 流式调用 AI
const stream = new ReadableStream<Uint8Array>({ const stream = new ReadableStream<Uint8Array>({
async start(controller) { async start(controller) {
const startTime = Date.now() const startTime = Date.now()
let fullContent = "" let fullContent = ""
let success = true let success = true
let errorMessage: string | undefined let errorMessage: string | undefined
let wasFiltered = false
const safeEnqueue = (text: string): void => {
// 防止 controller 已关闭时 enqueue 抛错
try {
controller.enqueue(encoder.encode(text))
} catch {
// controller 已关闭,忽略
}
}
try { try {
const aiStream = createAiChatCompletionStream({ const aiStream = createAiChatCompletionStream({
@@ -119,27 +151,49 @@ export async function POST(request: NextRequest): Promise<Response> {
// 输出安全过滤(逐 chunk 检查关键词) // 输出安全过滤(逐 chunk 检查关键词)
const outputFilter = filterAiOutput(chunk, { isStudent }) const outputFilter = filterAiOutput(chunk, { isStudent })
if (outputFilter.blocked) { if (outputFilter.blocked) {
controller.enqueue(encoder.encode(formatEvent({ safeEnqueue(formatEvent({
type: "filtered", type: "filtered",
message: "Content filtered for safety", message: "Content filtered for safety",
}))) }))
success = false success = false
wasFiltered = true
break 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) { } catch (error) {
success = false success = false
errorMessage = error instanceof Error ? error.message : String(error) errorMessage = error instanceof Error ? error.message : String(error)
controller.enqueue(encoder.encode(formatError(getAiErrorMessage(error)))) // AI 调用失败,回退配额
refundDailyQuota(ctx.userId)
safeEnqueue(formatError(getAiErrorMessage(error)))
} finally { } finally {
try {
controller.close() controller.close()
} catch {
// 已关闭,忽略
}
// 埋点 // 埋点
void trackEvent({ void trackEvent({
@@ -152,6 +206,7 @@ export async function POST(request: NextRequest): Promise<Response> {
tokenCount: fullContent.length / 4, tokenCount: fullContent.length / 4,
errorMessage, errorMessage,
isStudent, isStudent,
filtered: wasFiltered,
}, },
}).catch(() => { }).catch(() => {
// 静默失败 // 静默失败

View File

@@ -4,9 +4,11 @@ import { getTranslations } from "next-intl/server"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions" import { Permissions, type Permission } from "@/shared/types/permissions"
import { createAiService, safeAiCall } from "./services/ai-service" import { createAiService, safeAiCall } from "./services/ai-service"
import { getAiUsageStats } from "./data-access"
import { getKnowledgePointsWithRelations, getStudentKpMastery } from "@/modules/textbooks/data-access-graph"
import { import {
AiChatInputSchema, AiChatInputSchema,
GradingInputSchema, GradingInputSchema,
@@ -42,10 +44,10 @@ import type {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const requireAiPermission = async ( const requireAiPermission = async (
...permissions: readonly string[] ...permissions: readonly Permission[]
): Promise<{ userId: string }> => { ): Promise<{ userId: string }> => {
const results = await Promise.all( const results = await Promise.all(
permissions.map((p) => requirePermission(p as never)) permissions.map((p) => requirePermission(p))
) )
return { userId: results[0].userId } return { userId: results[0].userId }
} }
@@ -294,8 +296,56 @@ export async function recommendStudyPathAction(
return { success: false, message: t("error.invalidInput") } return { success: false, message: t("error.invalidInput") }
} }
const serviceInput: StudyPathInput = { ...parsed.data }
// V3知识图谱集成对标 Squirrel AI 纳米级知识图谱)
// 若传入 textbookId 但未传入 knowledgeGraph自动从 textbooks 模块获取
if (parsed.data.textbookId && !parsed.data.knowledgeGraph) {
try {
const [kps, masteryMap] = await Promise.all([
getKnowledgePointsWithRelations(parsed.data.textbookId),
getStudentKpMastery(parsed.data.studentId, parsed.data.textbookId),
])
if (kps.length > 0) {
serviceInput.knowledgeGraph = {
nodes: kps.map((kp) => ({
id: kp.id,
name: kp.name,
level: kp.level,
masteryLevel: masteryMap.get(kp.id)?.masteryLevel,
})),
edges: kps
.flatMap((kp) =>
(kp.prerequisiteIds ?? []).map((prereqId) => ({
from: prereqId,
to: kp.id,
type: "prerequisite" as const,
}))
),
}
// 同步填充 currentMastery若未传入
if (!serviceInput.currentMastery || serviceInput.currentMastery.length === 0) {
serviceInput.currentMastery = kps
.filter((kp) => masteryMap.has(kp.id))
.map((kp) => {
const m = masteryMap.get(kp.id)!
return {
knowledgePoint: kp.name,
masteryLevel: Math.round((m.masteryLevel / 100) * 5),
errorCount: m.totalQuestions - m.correctQuestions,
}
})
}
}
} catch {
// 知识图谱获取失败不阻断主流程,降级为无图谱模式
}
}
const service = createAiService(ctx.userId) const service = createAiService(ctx.userId)
const result = await safeAiCall(() => service.recommendStudyPath(parsed.data)) const result = await safeAiCall(() => service.recommendStudyPath(serviceInput))
if (!result.ok) { if (!result.ok) {
return { success: false, message: result.message } return { success: false, message: result.message }
} }
@@ -317,20 +367,9 @@ export async function getAiUsageStatsAction(): Promise<ActionState<AiUsageStats>
try { try {
await requirePermission(Permissions.AI_CONFIGURE) await requirePermission(Permissions.AI_CONFIGURE)
// 当前从 trackEvent 的内存数据返回统计 // 从 data-access 层获取真实聚合统计
// 生产环境应查询数据库或 Redis 聚合 // 当前为内存事件存储(单实例),生产环境应查询 DB 或 Redis
const stats: AiUsageStats = { const stats = await getAiUsageStats()
totalCalls: 0,
callsToday: 0,
callsThisWeek: 0,
activeUsers: 0,
errorRate: 0,
avgDurationMs: 0,
byCapability: [],
byRole: [],
topUsers: [],
recentActivity: [],
}
return { success: true, data: stats } return { success: true, data: stats }
} catch (error) { } catch (error) {

View File

@@ -72,7 +72,7 @@ export function AiAssistantWidget(): React.ReactNode {
</span> </span>
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent className="w-full sm:max-w-[440px] overflow-y-auto p-0"> <SheetContent className="w-full sm:max-w-md overflow-y-auto p-0">
<SheetHeader className="px-4 py-3 border-b"> <SheetHeader className="px-4 py-3 border-b">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2"> <SheetTitle className="flex items-center gap-2">

View File

@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"
import { Send, Bot, User, Square, Trash2, Sparkles } from "lucide-react" import { Send, Bot, User, Square, Trash2, Sparkles } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea" import { Textarea } from "@/shared/components/ui/textarea"
@@ -31,8 +32,6 @@ type AiChatPanelProps = {
title?: string title?: string
/** 最大消息数 */ /** 最大消息数 */
maxMessages?: number maxMessages?: number
/** 是否启用流式响应(默认 true */
streaming?: boolean
/** 建议提示词列表(空状态展示) */ /** 建议提示词列表(空状态展示) */
suggestedPrompts?: string[] suggestedPrompts?: string[]
} }
@@ -49,7 +48,7 @@ type AiChatPanelProps = {
* - 清除对话按钮 * - 清除对话按钮
* - 建议提示词 * - 建议提示词
* - aria-live 无障碍 * - aria-live 无障碍
* - 对话历史持久化localStorage * - 对话历史持久化localStorage,防抖写入
*/ */
export function AiChatPanel({ export function AiChatPanel({
systemPrompt, systemPrompt,
@@ -57,7 +56,6 @@ export function AiChatPanel({
placeholder, placeholder,
title, title,
maxMessages = 50, maxMessages = 50,
streaming: _streamingEnabled = true,
suggestedPrompts, suggestedPrompts,
}: AiChatPanelProps): React.ReactNode { }: AiChatPanelProps): React.ReactNode {
const t = useTranslations("ai") const t = useTranslations("ai")
@@ -65,33 +63,31 @@ export function AiChatPanel({
const [input, setInput] = useState("") const [input, setInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const storageKey = "ai-chat-history" const storageKey = "ai-chat-history"
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// 从 localStorage 恢复对话历史 // 持久化对话历史(防抖 500ms避免流式过程中频繁写入
useEffect(() => { useEffect(() => {
try { if (messages.length === 0) return
const stored = localStorage.getItem(storageKey) // 流式过程中不写入,流结束后再写
if (stored) { if (streaming) return
const parsed = JSON.parse(stored) as AiChatMessage[]
if (Array.isArray(parsed) && parsed.length > 0) {
// 通过 send 不合适,直接设置 messages 不支持
// 这里只是恢复显示,不重新发送
}
}
} catch {
// 忽略解析错误
}
}, [])
// 持久化对话历史 if (persistTimerRef.current) {
useEffect(() => { clearTimeout(persistTimerRef.current)
}
persistTimerRef.current = setTimeout(() => {
try { try {
if (messages.length > 0) {
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-20))) localStorage.setItem(storageKey, JSON.stringify(messages.slice(-20)))
}
} catch { } catch {
// 忽略写入错误 // 忽略写入错误
} }
}, [messages]) }, 500)
return () => {
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current)
}
}
}, [messages, streaming])
// 自动滚动到底部 // 自动滚动到底部
useEffect(() => { useEffect(() => {
@@ -194,7 +190,7 @@ export function AiChatPanel({
{messages.length > 0 ? ( {messages.length > 0 ? (
<ScrollArea <ScrollArea
className="h-[300px] w-full rounded-md border p-3" className="h-72 w-full rounded-md border p-3"
aria-live="polite" aria-live="polite"
aria-relevant="additions text" aria-relevant="additions text"
> >
@@ -202,7 +198,10 @@ export function AiChatPanel({
{messages.map((message, index) => ( {messages.map((message, index) => (
<div <div
key={index} key={index}
className={`flex gap-2 ${message.role === "user" ? "justify-end" : "justify-start"}`} className={cn(
"flex gap-2",
message.role === "user" ? "justify-end" : "justify-start"
)}
> >
{message.role === "assistant" ? ( {message.role === "assistant" ? (
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" /> <Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
@@ -210,11 +209,13 @@ export function AiChatPanel({
<User className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" /> <User className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
)} )}
<div <div
className={`rounded-md px-3 py-2 text-sm max-w-[80%] ${ className={cn(
// 任意值:聊天气泡需按容器宽度百分比限制,固定 max-w-* 无法适应不同面板宽度
"rounded-md px-3 py-2 text-sm max-w-[80%]",
message.role === "user" message.role === "user"
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-muted" : "bg-muted"
}`} )}
> >
{message.role === "assistant" ? ( {message.role === "assistant" ? (
<AiMarkdownRenderer content={message.content} /> <AiMarkdownRenderer content={message.content} />
@@ -266,7 +267,7 @@ export function AiChatPanel({
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("chat.placeholder")} placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-[60px] resize-none" className="min-h-16 resize-none"
disabled={streaming || messages.length >= maxMessages} disabled={streaming || messages.length >= maxMessages}
aria-label={t("chat.inputLabel")} aria-label={t("chat.inputLabel")}
/> />

View File

@@ -128,7 +128,7 @@ export function AiLessonContentGenerator({
value={additionalContext} value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)} onChange={(e) => setAdditionalContext(e.target.value)}
placeholder={t("lessonPrep.additionalContextPlaceholder")} placeholder={t("lessonPrep.additionalContextPlaceholder")}
className="min-h-[60px] text-sm" className="min-h-16 text-sm"
maxLength={500} maxLength={500}
/> />
</div> </div>

View File

@@ -22,6 +22,12 @@ import type { QuestionVariantResult } from "@/modules/ai/types"
type VariantType = "same_knowledge_point" | "different_difficulty" | "different_format" type VariantType = "same_knowledge_point" | "different_difficulty" | "different_format"
const VARIANT_TYPES: readonly VariantType[] = ["same_knowledge_point", "different_difficulty", "different_format"]
const isVariantType = (value: string): value is VariantType => {
return VARIANT_TYPES.includes(value as VariantType)
}
type AiQuestionVariantGeneratorProps = { type AiQuestionVariantGeneratorProps = {
/** 原始题目 */ /** 原始题目 */
originalQuestion: { originalQuestion: {
@@ -107,7 +113,11 @@ export function AiQuestionVariantGenerator({
</label> </label>
<Select <Select
value={variantType} value={variantType}
onValueChange={(value) => setVariantType(value as VariantType)} onValueChange={(value) => {
if (isVariantType(value)) {
setVariantType(value)
}
}}
> >
<SelectTrigger id="variant-type" className="w-full"> <SelectTrigger id="variant-type" className="w-full">
<SelectValue placeholder={t("exam.generate")} /> <SelectValue placeholder={t("exam.generate")} />

View File

@@ -18,6 +18,8 @@ type AiStudyPathProps = {
subject?: string subject?: string
currentMastery?: StudyPathInput["currentMastery"] currentMastery?: StudyPathInput["currentMastery"]
learningGoal?: string learningGoal?: string
/** 教材 ID传入后自动获取知识图谱注入路径推荐对标 Squirrel AI */
textbookId?: string
onStartLearning?: (step: StudyPathResult["learningPath"][number]) => void onStartLearning?: (step: StudyPathResult["learningPath"][number]) => void
} }
@@ -30,12 +32,16 @@ type AiStudyPathProps = {
* - 分步骤学习路径(含状态、建议、预计时间) * - 分步骤学习路径(含状态、建议、预计时间)
* - 学习总结 * - 学习总结
* - 鼓励语 * - 鼓励语
*
* V3支持 textbookId 参数,自动获取知识图谱与掌握度数据,
* 使 AI 沿前置依赖链推荐学习路径。
*/ */
export function AiStudyPath({ export function AiStudyPath({
studentId, studentId,
subject, subject,
currentMastery, currentMastery,
learningGoal, learningGoal,
textbookId,
onStartLearning, onStartLearning,
}: AiStudyPathProps): React.ReactNode { }: AiStudyPathProps): React.ReactNode {
const t = useTranslations("ai") const t = useTranslations("ai")
@@ -55,6 +61,7 @@ export function AiStudyPath({
subject, subject,
currentMastery, currentMastery,
learningGoal, learningGoal,
textbookId,
}) })
if (result.success && result.data) { if (result.success && result.data) {
setPath(result.data) setPath(result.data)

View File

@@ -0,0 +1,138 @@
import "server-only"
import type { AiUsageStats } from "./types"
/**
* AI 模块数据访问层
*
* 当前为内存实现(单实例),生产环境应替换为数据库或 Redis 查询。
*
* 职责:
* 1. AI 使用统计聚合(供管理员仪表盘)
* 2. AI 使用日志查询
*
* 注意trackEvent 当前仅输出到 console见 shared/lib/track-event.ts
* 生产环境接入真实 sinkPostHog/Sentry/DB 事件表)后,
* 此处的聚合函数需改为查询真实数据源。
*/
// ---------------------------------------------------------------------------
// 内存事件存储(生产环境替换为 DB/Redis
// ---------------------------------------------------------------------------
interface StoredAiEvent {
userId: string
capability: string
success: boolean
durationMs: number
timestamp: number
errorMessage?: string
}
// 单实例内存存储,最多保留最近 10000 条事件
const MAX_EVENTS = 10000
const eventStore: StoredAiEvent[] = []
/**
* 记录 AI 事件到内存存储
*
* 由 usage-tracker.ts 调用(非导出,仅模块内部使用)。
* 生产环境替换为 DB 异步写入。
*/
export function recordAiEvent(event: StoredAiEvent): void {
eventStore.push(event)
// 超出上限时丢弃最旧的事件
if (eventStore.length > MAX_EVENTS) {
eventStore.splice(0, eventStore.length - MAX_EVENTS)
}
}
// ---------------------------------------------------------------------------
// 使用统计聚合
// ---------------------------------------------------------------------------
/**
* 获取 AI 使用统计
*
* 从内存事件存储聚合统计数据。
* 生产环境应改为查询 DB 或 Redis。
*
* @returns AiUsageStats — 聚合后的统计数据
*/
export async function getAiUsageStats(): Promise<AiUsageStats> {
const now = Date.now()
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const todayMs = todayStart.getTime()
const weekMs = now - 7 * 24 * 60 * 60 * 1000
const totalCalls = eventStore.length
const callsToday = eventStore.filter((e) => e.timestamp >= todayMs).length
const callsThisWeek = eventStore.filter((e) => e.timestamp >= weekMs).length
const failedCalls = eventStore.filter((e) => !e.success).length
const errorRate = totalCalls > 0 ? failedCalls / totalCalls : 0
const durations = eventStore.map((e) => e.durationMs)
const avgDurationMs = durations.length > 0
? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length)
: 0
// 活跃用户(今日有调用的去重用户数)
const activeUserIds = new Set(
eventStore
.filter((e) => e.timestamp >= todayMs)
.map((e) => e.userId)
)
// 按 capability 聚合
const capabilityMap = new Map<string, number>()
for (const e of eventStore) {
capabilityMap.set(e.capability, (capabilityMap.get(e.capability) ?? 0) + 1)
}
const byCapability = Array.from(capabilityMap.entries())
.map(([capability, count]) => ({
capability,
count,
percentage: totalCalls > 0 ? Math.round((count / totalCalls) * 100) : 0,
}))
.sort((a, b) => b.count - a.count)
// 按角色聚合(当前无法从事件中获取角色,返回空数组)
// 生产环境若需按角色聚合,需在 StoredAiEvent 中增加 role 字段
const byRole: Array<{ role: string; count: number }> = []
// Top 用户
const userCallMap = new Map<string, number>()
for (const e of eventStore) {
userCallMap.set(e.userId, (userCallMap.get(e.userId) ?? 0) + 1)
}
const topUsers = Array.from(userCallMap.entries())
.map(([userId, count]) => ({ userId, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10)
// 最近活动
const recentActivity = eventStore
.slice(-20)
.reverse()
.map((e) => ({
userId: e.userId,
capability: e.capability,
success: e.success,
durationMs: e.durationMs,
timestamp: new Date(e.timestamp).toISOString(),
}))
return {
totalCalls,
callsToday,
callsThisWeek,
activeUsers: activeUserIds.size,
errorRate: Math.round(errorRate * 100) / 100,
avgDurationMs,
byCapability,
byRole,
topUsers,
recentActivity,
}
}

View File

@@ -0,0 +1,135 @@
/**
* SSE 流式响应解析与处理工具
*
* 从 use-ai-chat-stream hook 中抽取的纯函数,便于单独测试与复用。
*/
import type { AiChatMessage } from "../types"
/** SSE 事件类型 */
export type SseEvent =
| { type: "token"; content: string }
| { type: "error"; message: string }
| { type: "filtered" }
| { type: "socratic_warning"; message: string }
/**
* 从 Response 中读取并解析 SSE 事件流
*
* @param response - fetch 返回的 Response 对象
* @param onEvent - 每个解析出的事件回调
*/
export async function consumeSseStream(
response: Response,
onEvent: (event: SseEvent) => void
): Promise<void> {
const reader = response.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const data = line.slice(6).trim()
if (data === "[DONE]") continue
try {
const parsed = JSON.parse(data) as {
type: "token" | "error" | "filtered" | "socratic_warning"
content?: string
message?: string
}
if (parsed.type === "token" && parsed.content) {
onEvent({ type: "token", content: parsed.content })
} else if (parsed.type === "error") {
onEvent({ type: "error", message: parsed.message ?? "Unknown error" })
} else if (parsed.type === "filtered") {
onEvent({ type: "filtered" })
} else if (parsed.type === "socratic_warning") {
onEvent({ type: "socratic_warning", message: parsed.message ?? "Socratic warning" })
}
} catch {
// 忽略解析错误
}
}
}
}
/** 流式错误对应的 i18n key相对于 "ai" 命名空间) */
export type StreamErrorKey =
| "safety.dailyLimit"
| "error.unauthorized"
| "safety.blocked"
| "error.chatFailed"
/**
* 根据 HTTP 状态码映射错误消息 i18n key
*
* 返回值用于 next-intl 的 t() 调用(相对于 "ai" 命名空间)。
*/
export function getStreamErrorKey(status: number): StreamErrorKey {
if (status === 429) return "safety.dailyLimit"
if (status === 403) return "error.unauthorized"
if (status === 400) return "safety.blocked"
return "error.chatFailed"
}
/**
* 从错误响应体中提取错误消息
*/
export async function extractErrorMessage(
response: Response,
fallback: string
): Promise<string> {
try {
const errorText = await response.text()
const errorData = JSON.parse(errorText) as { message?: string }
return errorData.message ?? fallback
} catch {
return fallback
}
}
/**
* 移除消息列表末尾空的 assistant 消息
*
* 用于流式失败/中止时清理占位消息。
*/
export function removeTrailingEmptyAssistant(
messages: AiChatMessage[]
): AiChatMessage[] {
const filtered = [...messages]
const last = filtered[filtered.length - 1]
if (last && last.role === "assistant" && last.content === "") {
filtered.pop()
}
return filtered
}
/**
* 向消息列表末尾追加 token 到 assistant 消息
*/
export function appendTokenToLastAssistant(
messages: AiChatMessage[],
token: string
): AiChatMessage[] {
const updated = [...messages]
const last = updated[updated.length - 1]
if (last && last.role === "assistant") {
updated[updated.length - 1] = {
...last,
content: last.content + token,
}
}
return updated
}

View File

@@ -3,24 +3,24 @@
import { useState, useCallback, useRef } from "react" import { useState, useCallback, useRef } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import type { AiChatMessage } from "../types" import type { AiChatMessage } from "../types"
import {
consumeSseStream,
getStreamErrorKey,
extractErrorMessage,
removeTrailingEmptyAssistant,
appendTokenToLastAssistant,
} from "./stream-utils"
/** /**
* AI 流式聊天 Hook * AI 流式聊天 Hook
* *
* 通过 SSE 端点消费流式 AI 回复。 * 通过 SSE 端点消费流式 AI 回复。
* 支持: * 支持:逐 token 渲染、停止生成AbortController、错误处理。
* - 逐 token 渲染
* - 停止生成AbortController
* - 错误处理
*/ */
type UseAiChatStreamReturn = {
type StreamState = {
messages: AiChatMessage[] messages: AiChatMessage[]
streaming: boolean streaming: boolean
error: string | null error: string | null
}
type UseAiChatStreamReturn = StreamState & {
send: (messages: AiChatMessage[], options?: { systemPrompt?: string; providerId?: string }) => Promise<void> send: (messages: AiChatMessage[], options?: { systemPrompt?: string; providerId?: string }) => Promise<void>
stop: () => void stop: () => void
clear: () => void clear: () => void
@@ -46,8 +46,6 @@ export function useAiChatStream(): UseAiChatStreamReturn {
if (userMessage && userMessage.role === "user") { if (userMessage && userMessage.role === "user") {
setMessages((prev) => [...prev, userMessage]) setMessages((prev) => [...prev, userMessage])
} }
// 添加空的 assistant 消息,用于流式更新
setMessages((prev) => [...prev, { role: "assistant", content: "" }]) setMessages((prev) => [...prev, { role: "assistant", content: "" }])
const controller = new AbortController() const controller = new AbortController()
@@ -66,119 +64,42 @@ export function useAiChatStream(): UseAiChatStreamReturn {
}) })
if (!response.ok) { if (!response.ok) {
const errorText = await response.text() const fallback = t(getStreamErrorKey(response.status))
let errorMessage = t("error.chatFailed") const errorMessage = await extractErrorMessage(response, fallback)
try {
const errorData = JSON.parse(errorText) as { message?: string }
errorMessage = errorData.message ?? errorMessage
} catch {
// 使用默认错误消息
}
if (response.status === 429) {
errorMessage = t("safety.dailyLimit")
} else if (response.status === 403) {
errorMessage = t("error.unauthorized")
} else if (response.status === 400) {
errorMessage = t("safety.blocked")
}
setError(errorMessage) setError(errorMessage)
// 移除空的 assistant 消息 setMessages((prev) => removeTrailingEmptyAssistant(prev))
setMessages((prev) => {
const filtered = [...prev]
const last = filtered[filtered.length - 1]
if (last && last.role === "assistant" && last.content === "") {
filtered.pop()
}
return filtered
})
return return
} }
const reader = response.body?.getReader() await consumeSseStream(response, (event) => {
if (!reader) { if (event.type === "token") {
setError(t("error.chatFailed")) setMessages((prev) => appendTokenToLastAssistant(prev, event.content))
return } else if (event.type === "error") {
} setError(event.message)
setMessages((prev) => removeTrailingEmptyAssistant(prev))
const decoder = new TextDecoder() } else if (event.type === "filtered") {
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const data = line.slice(6).trim()
if (data === "[DONE]") continue
try {
const parsed = JSON.parse(data) as {
type: "token" | "error" | "filtered"
content?: string
message?: string
}
if (parsed.type === "token" && parsed.content) {
setMessages((prev) => {
const updated = [...prev]
const last = updated[updated.length - 1]
if (last && last.role === "assistant") {
updated[updated.length - 1] = {
...last,
content: last.content + parsed.content,
}
}
return updated
})
} else if (parsed.type === "error") {
setError(parsed.message ?? t("error.chatFailed"))
setMessages((prev) => {
const filtered = [...prev]
const last = filtered[filtered.length - 1]
if (last && last.role === "assistant" && last.content === "") {
filtered.pop()
}
return filtered
})
} else if (parsed.type === "filtered") {
setError(t("safety.contentFiltered")) setError(t("safety.contentFiltered"))
} else if (event.type === "socratic_warning") {
// 苏格拉底式辅导警告:不阻断,仅提示
// 可通过 toast 或 UI 标记展示,此处暂存 error 供组件判断
setError(event.message)
} }
} catch { })
// 忽略解析错误
}
}
}
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === "AbortError") { if (!(err instanceof DOMException && err.name === "AbortError")) {
// 用户主动停止,不显示错误
} else {
setError(err instanceof Error ? err.message : String(err)) setError(err instanceof Error ? err.message : String(err))
} }
} finally { } finally {
setStreaming(false) setStreaming(false)
abortControllerRef.current = null abortControllerRef.current = null
// 清理空的 assistant 消息 setMessages((prev) => removeTrailingEmptyAssistant(prev))
setMessages((prev) => {
const last = prev[prev.length - 1]
if (last && last.role === "assistant" && last.content === "") {
return prev.slice(0, -1)
}
return prev
})
} }
}, },
[streaming, t] [streaming, t]
) )
const stop = useCallback((): void => { const stop = useCallback((): void => {
if (abortControllerRef.current) { abortControllerRef.current?.abort()
abortControllerRef.current.abort()
}
}, []) }, [])
const clear = useCallback((): void => { const clear = useCallback((): void => {

View File

@@ -187,6 +187,28 @@ export const StudyPathInputSchema = z.object({
) )
.optional(), .optional(),
learningGoal: z.string().optional(), learningGoal: z.string().optional(),
/** 教材 ID传入后 action 层自动获取知识图谱注入) */
textbookId: z.string().optional(),
/** 知识图谱(可直接传入,优先于 textbookId 自动获取) */
knowledgeGraph: z
.object({
nodes: z.array(
z.object({
id: z.string().min(1),
name: z.string().min(1),
level: z.number().int().min(0),
masteryLevel: z.number().optional(),
})
),
edges: z.array(
z.object({
from: z.string().min(1),
to: z.string().min(1),
type: z.literal("prerequisite"),
})
),
})
.optional(),
}) })
export const StudyPathResultSchema = z.object({ export const StudyPathResultSchema = z.object({

View File

@@ -330,10 +330,10 @@ export class DefaultAiService implements AiService {
} }
async generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult> { 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 = [ const userLines = [
`Student ID: ${input.studentId}`, `Student ID: ${input.studentId}`,
input.studentName ? `Student Name: ${input.studentName}` : "",
input.grade ? `Grade: ${input.grade}` : "", input.grade ? `Grade: ${input.grade}` : "",
input.recentGrades && input.recentGrades.length > 0 input.recentGrades && input.recentGrades.length > 0
? `Recent Grades:\n${JSON.stringify(input.recentGrades, null, 2)}` ? `Recent Grades:\n${JSON.stringify(input.recentGrades, null, 2)}`
@@ -370,7 +370,7 @@ export class DefaultAiService implements AiService {
} }
async recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult> { async recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult> {
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => { return withAiTracking(this.userId, "study_path", undefined, async () => {
const userLines = [ const userLines = [
`Student ID: ${input.studentId}`, `Student ID: ${input.studentId}`,
input.subject ? `Subject: ${input.subject}` : "", input.subject ? `Subject: ${input.subject}` : "",
@@ -379,6 +379,21 @@ export class DefaultAiService implements AiService {
: "", : "",
input.learningGoal ? `Learning Goal: ${input.learningGoal}` : "", input.learningGoal ? `Learning Goal: ${input.learningGoal}` : "",
].filter((line) => line.length > 0) ].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( const { content } = await callAi(
buildChatMessages(STUDY_PATH_SYSTEM_PROMPT, userLines.join("\n\n")), buildChatMessages(STUDY_PATH_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.5, maxTokens: 2000 } { temperature: 0.5, maxTokens: 2000 }

View File

@@ -6,9 +6,11 @@ import "server-only"
* 多层防护: * 多层防护:
* 1. 输入过滤:检查用户输入是否包含不当内容 * 1. 输入过滤:检查用户输入是否包含不当内容
* 2. 输出过滤:检查 AI 回复是否包含不当内容 * 2. 输出过滤:检查 AI 回复是否包含不当内容
* 3. 每日限制:按用户 + 日期计数 * 3. 每日限制:按用户 + 日期计数(原子操作,防 TOCTOU 竞态)
* *
* 参考 Khanmigo 的多层 moderation 模式。 * 参考 Khanmigo 的多层 moderation 模式。
*
* 注意:当前为内存实现,多实例部署需替换为 RedisINCR + EXPIRE
*/ */
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -34,6 +36,10 @@ const BLOCKED_OUTPUT_PATTERNS: readonly RegExp[] = [
const STUDENT_BLOCKED_PATTERNS: readonly RegExp[] = [ const STUDENT_BLOCKED_PATTERNS: readonly RegExp[] = [
// 学生侧额外限制:禁止直接给出作业答案 // 学生侧额外限制:禁止直接给出作业答案
/\b(here is the (complete )?answer|the answer is:?)\b/i, /\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) { if (options?.isStudent) {
// 学生侧额外检查
for (const pattern of STUDENT_BLOCKED_PATTERNS) { for (const pattern of STUDENT_BLOCKED_PATTERNS) {
if (pattern.test(text)) { if (pattern.test(text)) {
return { return {
@@ -109,7 +114,7 @@ export const filterAiOutput = (
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 每日限制 // 每日限制(原子操作,防 TOCTOU 竞态)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const DAILY_LIMITS: Record<string, number> = { 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 }>() 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 }
}

View File

@@ -126,6 +126,7 @@ export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
" {", " {",
' "area": "knowledge area name",', ' "area": "knowledge area name",',
' "severity": "high | medium | low",', ' "severity": "high | medium | low",',
' "rootCause": "underlying reason, e.g. missing prerequisite",',
' "suggestion": "specific improvement suggestion"', ' "suggestion": "specific improvement suggestion"',
" }", " }",
" ],", " ],",
@@ -135,6 +136,7 @@ export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
"Rules:", "Rules:",
"- Identify 2-5 weak areas based on error frequency and mastery level.", "- Identify 2-5 weak areas based on error frequency and mastery level.",
"- severity: high = mastery < 2, medium = mastery 2-3, low = mastery 3-4.", "- 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.", "- Suggestions should be actionable and specific.",
"- Study plan should be concise (3-5 sentences).", "- Study plan should be concise (3-5 sentences).",
"- Recommended resources can be topic names or study strategies.", "- Recommended resources can be topic names or study strategies.",
@@ -165,6 +167,22 @@ export const CHAT_SYSTEM_PROMPT = [
"Be concise, accurate, and pedagogically sound.", "Be concise, accurate, and pedagogically sound.",
].join("\n") ].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 = [ export const STUDY_PATH_SYSTEM_PROMPT = [
"You are an expert K12 adaptive learning path designer.", "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.", "Return JSON only without markdown.",
"Output schema:", "Output schema:",
"{", "{",
@@ -215,7 +233,8 @@ export const STUDY_PATH_SYSTEM_PROMPT = [
"}", "}",
"Rules:", "Rules:",
"- Order learning path from foundational to advanced.", "- 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.", "- Include 3-7 steps in the learning path.",
"- estimatedTime should be realistic (5-30 min per step).", "- estimatedTime should be realistic (5-30 min per step).",
"- motivation should be age-appropriate and encouraging.", "- motivation should be age-appropriate and encouraging.",

View File

@@ -1,10 +1,11 @@
import "server-only" import "server-only"
import { trackEvent, type EventName } from "@/shared/lib/track-event" import { trackEvent, type EventName } from "@/shared/lib/track-event"
import { recordAiEvent } from "../data-access"
export type AiUsageEvent = { export type AiUsageEvent = {
userId: string 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 providerId?: string
model?: string model?: string
success: boolean success: boolean
@@ -20,16 +21,31 @@ const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
lesson_content: "ai.lesson_content", lesson_content: "ai.lesson_content",
question_variant: "ai.question_variant", question_variant: "ai.question_variant",
weakness_analysis: "ai.weakness_analysis", weakness_analysis: "ai.weakness_analysis",
child_summary: "ai.child_summary",
study_path: "ai.study_path",
} }
/** /**
* AI 使用埋点 * AI 使用埋点
* *
* 记录每次 AI 调用的元数据,用于监控、成本分析与异常排查。 * 记录每次 AI 调用的元数据,用于监控、成本分析与异常排查。
* 同时写入 data-access 层的内存事件存储(供管理员仪表盘聚合查询)。
* 非阻塞,失败不影响主流程。 * 非阻塞,失败不影响主流程。
*/ */
export const trackAiUsage = (event: AiUsageEvent): void => { export const trackAiUsage = (event: AiUsageEvent): void => {
const eventName = AI_EVENT_MAP[event.capability] 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({ void trackEvent({
event: eventName, event: eventName,
userId: event.userId, userId: event.userId,

View File

@@ -168,6 +168,23 @@ export type StudyPathInput = {
errorCount: number errorCount: number
}> }>
learningGoal?: string learningGoal?: string
/** 教材 ID传入后 action 层自动获取知识图谱注入) */
textbookId?: string
/**
* 知识图谱上下文V3 新增,对标 Squirrel AI 纳米级知识图谱)
*
* 由 actions 层从 textbooks/data-access-graph 获取后注入,
* 使 AI 能沿前置依赖链推荐学习路径。
*/
knowledgeGraph?: {
nodes: Array<{
id: string
name: string
level: number
masteryLevel?: number
}>
edges: Array<{ from: string; to: string; type: "prerequisite" }>
}
} }
/** 学习路径推荐结果 */ /** 学习路径推荐结果 */

View File

@@ -5,15 +5,68 @@ import OpenAI from "openai"
import { extractMessageContent, type AiChatRequest } from "./payload-parser" import { extractMessageContent, type AiChatRequest } from "./payload-parser"
import { getAiProviderConfig } from "./provider-config" 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(/\/+$/, "") const baseUrl = String(config.baseUrl ?? "https://api.openai.com").replace(/\/+$/, "")
return new OpenAI({ return new OpenAI({
apiKey: config.apiKey, apiKey: config.apiKey,
baseURL: baseUrl.length ? baseUrl : undefined, 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 client = await getAiClient({ apiKey: input.apiKey, baseUrl: input.baseUrl })
const result = await client.chat.completions.create({ const result = await client.chat.completions.create({
model: input.model, model: input.model,
@@ -29,7 +82,7 @@ export const testAiProviderConfig = async (input: { apiKey: string; baseUrl?: st
export const testAiProviderById = async ( export const testAiProviderById = async (
providerId: string, providerId: string,
overrides?: { baseUrl?: string; model?: string } overrides?: { baseUrl?: string; model?: string }
) => { ): Promise<boolean> => {
const config = await getAiProviderConfig(providerId) const config = await getAiProviderConfig(providerId)
const client = await getAiClient({ apiKey: config.apiKey, baseUrl: overrides?.baseUrl ?? config.baseUrl }) const client = await getAiClient({ apiKey: config.apiKey, baseUrl: overrides?.baseUrl ?? config.baseUrl })
const result = await client.chat.completions.create({ const result = await client.chat.completions.create({
@@ -43,18 +96,19 @@ export const testAiProviderById = async (
return true 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 config = await getAiProviderConfig(input.providerId)
const client = await getAiClient(config) const client = await getAiClient(config)
const result = (await client.chat.completions.create({
const result = await withRetry(() =>
client.chat.completions.create({
model: config.model || input.model, model: config.model || input.model,
messages: input.messages, messages: input.messages,
temperature: input.temperature, temperature: input.temperature,
...(typeof input.maxTokens === "number" ? { max_tokens: input.maxTokens } : {}), ...(typeof input.maxTokens === "number" ? { max_tokens: input.maxTokens } : {}),
...(input.thinking ? { thinking: input.thinking } : {}), ...(input.thinking ? { thinking: input.thinking } : {}),
} as Parameters<typeof client.chat.completions.create>[0])) as Awaited< } as Parameters<typeof client.chat.completions.create>[0])
ReturnType<typeof client.chat.completions.create> ) as Awaited<ReturnType<typeof client.chat.completions.create>>
>
const hasChoices = "choices" in result && Array.isArray(result.choices) && result.choices.length > 0 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.") 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 产出内容。 * 返回 AsyncGenerator逐 token 产出内容。
* 用于 SSE 流式响应,降低用户感知延迟。 * 用于 SSE 流式响应,降低用户感知延迟。
*
* 注意:流式调用不使用 withRetry因为流一旦开始无法重试。
* 超时由 OpenAI SDK 的 timeout 配置控制。
*/ */
export async function* createAiChatCompletionStream( export async function* createAiChatCompletionStream(
input: AiChatRequest input: AiChatRequest

View File

@@ -27,6 +27,7 @@ export type EventName =
| "notification.marked_all_read" | "notification.marked_all_read"
| "notification.sent" | "notification.sent"
| "notification.send_failed" | "notification.send_failed"
| "notification.archived"
| "attendance.recorded" | "attendance.recorded"
| "attendance.batch_recorded" | "attendance.batch_recorded"
| "attendance.updated" | "attendance.updated"
@@ -66,6 +67,8 @@ export type EventName =
| "ai.lesson_content" | "ai.lesson_content"
| "ai.question_variant" | "ai.question_variant"
| "ai.weakness_analysis" | "ai.weakness_analysis"
| "ai.child_summary"
| "ai.study_path"
/** 埋点事件负载 */ /** 埋点事件负载 */
export interface TrackEventPayload { export interface TrackEventPayload {