## 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)
440 lines
14 KiB
TypeScript
440 lines
14 KiB
TypeScript
import "server-only"
|
||
|
||
import { env } from "@/env.mjs"
|
||
import { createAiChatCompletion, getAiErrorMessage } from "@/shared/lib/ai"
|
||
|
||
import {
|
||
GRADING_ASSIST_SYSTEM_PROMPT,
|
||
LESSON_CONTENT_SYSTEM_PROMPT,
|
||
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 {
|
||
GradingSuggestionSchema,
|
||
LessonContentResultSchema,
|
||
QuestionVariantResultSchema,
|
||
SimilarQuestionListSchema,
|
||
WeaknessAnalysisResultSchema,
|
||
ChildSummaryResultSchema,
|
||
StudyPathResultSchema,
|
||
} from "../schema"
|
||
import type {
|
||
AiChatMessage,
|
||
AiChatOptions,
|
||
AiChatResult,
|
||
AiService,
|
||
GradingInput,
|
||
GradingSuggestion,
|
||
LessonContentInput,
|
||
LessonContentResult,
|
||
QuestionVariantInput,
|
||
QuestionVariantResult,
|
||
SimilarQuestionInput,
|
||
SimilarQuestionResult,
|
||
WeaknessAnalysisInput,
|
||
WeaknessAnalysisResult,
|
||
ChildSummaryInput,
|
||
ChildSummaryResult,
|
||
StudyPathInput,
|
||
StudyPathResult,
|
||
} from "../types"
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// JSON 提取工具(从 AI 返回文本中提取 JSON)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const extractBalancedJsonSegment = (value: string): string | null => {
|
||
const startBrace = value.indexOf("{")
|
||
const startBracket = value.indexOf("[")
|
||
const start =
|
||
startBrace === -1
|
||
? startBracket
|
||
: startBracket === -1
|
||
? startBrace
|
||
: Math.min(startBrace, startBracket)
|
||
if (start === -1) return null
|
||
const opening = value[start]
|
||
const closing = opening === "{" ? "}" : "]"
|
||
let depth = 0
|
||
let inString = false
|
||
let escaped = false
|
||
for (let i = start; i < value.length; i += 1) {
|
||
const char = value[i]
|
||
if (inString) {
|
||
if (escaped) {
|
||
escaped = false
|
||
} else if (char === "\\") {
|
||
escaped = true
|
||
} else if (char === '"') {
|
||
inString = false
|
||
}
|
||
continue
|
||
}
|
||
if (char === '"') {
|
||
inString = true
|
||
continue
|
||
}
|
||
if (char === opening) {
|
||
depth += 1
|
||
continue
|
||
}
|
||
if (char === closing) {
|
||
depth -= 1
|
||
if (depth === 0) {
|
||
return value.slice(start, i + 1)
|
||
}
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
const tryParseJson = (value: string): unknown | null => {
|
||
try {
|
||
return JSON.parse(value)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
const extractJson = (raw: string): unknown => {
|
||
const trimmed = raw.trim()
|
||
const candidates: string[] = []
|
||
const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)]
|
||
if (fencedMatches.length > 0) {
|
||
candidates.push(...fencedMatches.map((match) => (match[1] ?? "").trim()))
|
||
}
|
||
candidates.push(trimmed)
|
||
for (const candidate of candidates) {
|
||
const direct = tryParseJson(candidate)
|
||
if (direct !== null) return direct
|
||
const segment = extractBalancedJsonSegment(candidate)
|
||
if (!segment) continue
|
||
const parsed = tryParseJson(segment)
|
||
if (parsed !== null) return parsed
|
||
}
|
||
throw new Error("Invalid AI response: cannot parse JSON")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AiService 实现
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const DEFAULT_MODEL = () => String(env.AI_MODEL ?? "gpt-4o-mini")
|
||
|
||
const buildChatMessages = (
|
||
systemPrompt: string,
|
||
userContent: string
|
||
): AiChatMessage[] => [
|
||
{ role: "system", content: systemPrompt },
|
||
{ role: "user", content: userContent },
|
||
]
|
||
|
||
const callAi = async (
|
||
messages: AiChatMessage[],
|
||
options?: AiChatOptions
|
||
): Promise<{ content: string; model?: string; tokenUsage?: number }> => {
|
||
const result = await createAiChatCompletion({
|
||
messages,
|
||
model: options?.model ?? DEFAULT_MODEL(),
|
||
temperature: options?.temperature ?? 0.3,
|
||
...(typeof options?.maxTokens === "number" ? { maxTokens: options.maxTokens } : {}),
|
||
...(options?.providerId ? { providerId: options.providerId } : {}),
|
||
})
|
||
const tokenUsage =
|
||
result.usage && typeof result.usage === "object" && "total_tokens" in result.usage
|
||
? Number((result.usage as unknown as Record<string, unknown>).total_tokens ?? 0)
|
||
: undefined
|
||
return { content: result.content, tokenUsage }
|
||
}
|
||
|
||
/**
|
||
* 默认 AI 服务实现
|
||
*
|
||
* 封装 shared/lib/ai 的底层 SDK 调用,提供业务语义化接口。
|
||
* 所有业务模块通过此服务调用 AI,不直接 import shared/lib/ai。
|
||
*/
|
||
export class DefaultAiService implements AiService {
|
||
constructor(private readonly userId: string) {}
|
||
|
||
async chat(
|
||
messages: AiChatMessage[],
|
||
options?: AiChatOptions
|
||
): Promise<AiChatResult> {
|
||
return withAiTracking(this.userId, "chat", options?.providerId, async () => {
|
||
const { content, tokenUsage } = await callAi(messages, {
|
||
...options,
|
||
temperature: options?.temperature ?? 0.7,
|
||
})
|
||
return { result: { content, usage: null }, tokenUsage }
|
||
})
|
||
}
|
||
|
||
async suggestSimilarQuestions(
|
||
input: SimilarQuestionInput
|
||
): Promise<SimilarQuestionResult[]> {
|
||
return withAiTracking(this.userId, "similar_question", undefined, async () => {
|
||
const count = input.count ?? 3
|
||
const userLines = [
|
||
`Question Type: ${input.questionType}`,
|
||
input.subject ? `Subject: ${input.subject}` : "",
|
||
input.knowledgePointIds?.length
|
||
? `Knowledge Points: ${input.knowledgePointIds.join(", ")}`
|
||
: "",
|
||
`Generate ${count} similar questions.`,
|
||
`Original Question:\n${input.questionText}`,
|
||
].filter((line) => line.length > 0)
|
||
const { content } = await callAi(
|
||
buildChatMessages(SIMILAR_QUESTION_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||
{ temperature: 0.5, maxTokens: 3000 }
|
||
)
|
||
const parsed = extractJson(content)
|
||
const list =
|
||
parsed && typeof parsed === "object" && "questions" in parsed
|
||
? (parsed as Record<string, unknown>).questions
|
||
: parsed
|
||
const validated = SimilarQuestionListSchema.safeParse(list)
|
||
if (!validated.success) return { result: [] }
|
||
return { result: validated.data }
|
||
})
|
||
}
|
||
|
||
async suggestGrading(input: GradingInput): Promise<GradingSuggestion> {
|
||
return withAiTracking(this.userId, "grading_assist", undefined, async () => {
|
||
const userLines = [
|
||
`Question Type: ${input.questionType}`,
|
||
`Max Score: ${input.maxScore}`,
|
||
input.subject ? `Subject: ${input.subject}` : "",
|
||
`Question:\n${input.questionText}`,
|
||
`Student Answer:\n${input.studentAnswer}`,
|
||
input.correctAnswer ? `Correct Answer:\n${input.correctAnswer}` : "",
|
||
].filter((line) => line.length > 0)
|
||
const { content } = await callAi(
|
||
buildChatMessages(GRADING_ASSIST_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||
{ temperature: 0.2, maxTokens: 1000 }
|
||
)
|
||
const parsed = extractJson(content)
|
||
const validated = GradingSuggestionSchema.safeParse(parsed)
|
||
if (!validated.success) {
|
||
return {
|
||
result: {
|
||
suggestedScore: 0,
|
||
confidence: 0,
|
||
feedback: "AI grading unavailable",
|
||
reasoning: "AI response format invalid",
|
||
},
|
||
}
|
||
}
|
||
const data = validated.data
|
||
return {
|
||
result: {
|
||
suggestedScore: Math.min(Math.max(data.suggestedScore, 0), input.maxScore),
|
||
confidence: data.confidence,
|
||
feedback: data.feedback,
|
||
reasoning: data.reasoning,
|
||
},
|
||
}
|
||
})
|
||
}
|
||
|
||
async generateLessonContent(
|
||
input: LessonContentInput
|
||
): Promise<LessonContentResult> {
|
||
return withAiTracking(this.userId, "lesson_content", undefined, async () => {
|
||
const userLines = [
|
||
`Topic: ${input.topic}`,
|
||
`Content Type: ${input.contentType}`,
|
||
input.subject ? `Subject: ${input.subject}` : "",
|
||
input.grade ? `Grade: ${input.grade}` : "",
|
||
input.additionalContext ? `Additional Context:\n${input.additionalContext}` : "",
|
||
].filter((line) => line.length > 0)
|
||
const { content } = await callAi(
|
||
buildChatMessages(LESSON_CONTENT_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||
{ temperature: 0.7, maxTokens: 4000 }
|
||
)
|
||
const parsed = extractJson(content)
|
||
const validated = LessonContentResultSchema.safeParse(parsed)
|
||
if (!validated.success) {
|
||
return {
|
||
result: {
|
||
title: input.topic,
|
||
content: content,
|
||
},
|
||
}
|
||
}
|
||
return { result: validated.data }
|
||
})
|
||
}
|
||
|
||
async generateQuestionVariant(
|
||
input: QuestionVariantInput
|
||
): Promise<QuestionVariantResult> {
|
||
return withAiTracking(this.userId, "question_variant", undefined, async () => {
|
||
const userLines = [
|
||
`Variant Type: ${input.variantType}`,
|
||
input.subject ? `Subject: ${input.subject}` : "",
|
||
`Original Question:\n${JSON.stringify(input.originalQuestion, null, 2)}`,
|
||
].filter((line) => line.length > 0)
|
||
const { content } = await callAi(
|
||
buildChatMessages(QUESTION_VARIANT_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||
{ temperature: 0.6, maxTokens: 2000 }
|
||
)
|
||
const parsed = extractJson(content)
|
||
const validated = QuestionVariantResultSchema.safeParse(parsed)
|
||
if (!validated.success) {
|
||
throw new Error("AI question variant format invalid")
|
||
}
|
||
return { result: validated.data }
|
||
})
|
||
}
|
||
|
||
async analyzeWeakness(
|
||
input: WeaknessAnalysisInput
|
||
): Promise<WeaknessAnalysisResult> {
|
||
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
|
||
const userLines = [
|
||
`Student ID: ${input.studentId}`,
|
||
input.subjectId ? `Subject ID: ${input.subjectId}` : "",
|
||
`Error Items (${input.errorItems.length}):`,
|
||
JSON.stringify(
|
||
input.errorItems.map((item) => ({
|
||
questionText: item.questionText,
|
||
questionType: item.questionType,
|
||
errorCount: item.errorCount,
|
||
masteryLevel: item.masteryLevel,
|
||
})),
|
||
null,
|
||
2
|
||
),
|
||
].filter((line) => line.length > 0)
|
||
const { content } = await callAi(
|
||
buildChatMessages(WEAKNESS_ANALYSIS_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||
{ temperature: 0.3, maxTokens: 2000 }
|
||
)
|
||
const parsed = extractJson(content)
|
||
const validated = WeaknessAnalysisResultSchema.safeParse(parsed)
|
||
if (!validated.success) {
|
||
return {
|
||
result: {
|
||
weakAreas: [],
|
||
studyPlan: "Analysis unavailable",
|
||
recommendedResources: [],
|
||
},
|
||
}
|
||
}
|
||
return { result: validated.data }
|
||
})
|
||
}
|
||
|
||
async generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult> {
|
||
return withAiTracking(this.userId, "child_summary", undefined, async () => {
|
||
// PII 最小化:不传学生真实姓名,用 ID 替代(COPPA/FERPA 合规)
|
||
const userLines = [
|
||
`Student ID: ${input.studentId}`,
|
||
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, "study_path", 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)
|
||
|
||
// 知识图谱上下文注入(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 }
|
||
)
|
||
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 }
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建 AI 服务实例
|
||
*
|
||
* 在 Server Action 中调用,传入当前用户 ID。
|
||
* 测试时可替换为 mock 实现。
|
||
*/
|
||
export const createAiService = (userId: string): AiService =>
|
||
new DefaultAiService(userId)
|
||
|
||
/**
|
||
* 安全执行 AI 调用,捕获异常并返回错误消息
|
||
*/
|
||
export const safeAiCall = async <T>(
|
||
fn: () => Promise<T>
|
||
): Promise<{ ok: true; data: T } | { ok: false; message: string }> => {
|
||
try {
|
||
const data = await fn()
|
||
return { ok: true, data }
|
||
} catch (error) {
|
||
return { ok: false, message: getAiErrorMessage(error) }
|
||
}
|
||
}
|