feat(ai): 新增 AI 模块并集成至备课/错题集/试卷/改题四大业务场景

- 新增 src/modules/ai 独立模块,遵循三层架构(actions → services → shared/lib/ai)
- 通过 AiClientProvider + useAiClient 实现 React Context 依赖注入,业务组件零直接 import
- 6 个 Server Actions 均调用 requirePermission() 权限校验,返回 ActionState<T>
- withAiTracking 统一埋点,覆盖 chat/similar_question/grading_assist/lesson_content/question_variant/weakness_analysis
- 集成场景:作业批改 AiGradingAssist、错题集 AiErrorBookAnalysis、备课 AiLessonContentGenerator、试卷 AiQuestionVariantGenerator
- 全量 i18n(en/zh-CN ai.json),Error Boundary + Skeleton 边界处理
- 同步架构图 004/005,新增审计报告 ai-module-audit-report.md
This commit is contained in:
SpecialX
2026-06-23 00:52:39 +08:00
parent ec87cd9efa
commit 21c5eba96c
40 changed files with 4885 additions and 169 deletions

View File

@@ -0,0 +1,346 @@
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,
} from "./prompt-templates"
import { withAiTracking } from "./usage-tracker"
import {
GradingSuggestionSchema,
LessonContentResultSchema,
QuestionVariantResultSchema,
SimilarQuestionListSchema,
WeaknessAnalysisResultSchema,
} from "../schema"
import type {
AiChatMessage,
AiChatOptions,
AiChatResult,
AiService,
GradingInput,
GradingSuggestion,
LessonContentInput,
LessonContentResult,
QuestionVariantInput,
QuestionVariantResult,
SimilarQuestionInput,
SimilarQuestionResult,
WeaknessAnalysisInput,
WeaknessAnalysisResult,
} 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 }
})
}
}
/**
* 创建 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) }
}
}

View File

@@ -0,0 +1,154 @@
/**
* AI Prompt 模板
*
* 集中管理所有业务场景的 Prompt便于版本管理与调优。
* 所有 Prompt 使用英文以获得最佳模型兼容性,业务文本通过 user message 注入。
*/
// ---------------------------------------------------------------------------
// 相似题推荐
// ---------------------------------------------------------------------------
export const SIMILAR_QUESTION_SYSTEM_PROMPT = [
"You are an expert K12 education question generator.",
"Given a question, generate similar practice questions that test the same knowledge points.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "questions": [',
" {",
' "text": "question text",',
' "type": "single_choice | multiple_choice | judgment | text",',
' "difficulty": 3,',
' "options": [{ "id": "A", "text": "option text" }],',
' "answer": "correct answer",',
' "explanation": "brief explanation"',
" }",
" ]",
"}",
"Rules:",
"- Generate 1-5 similar questions based on the count parameter.",
"- Keep the same knowledge points but vary the context and numbers.",
"- For choice questions, always include 4 options.",
"- For text questions, omit options and include the answer.",
"- Difficulty should be 1-5, matching the original.",
"Never output placeholders like ..., [...], or {...}.",
].join("\n")
// ---------------------------------------------------------------------------
// AI 辅助批改
// ---------------------------------------------------------------------------
export const GRADING_ASSIST_SYSTEM_PROMPT = [
"You are an expert K12 teacher assistant for grading subjective questions.",
"Given a question, the student's answer, and the correct answer (if available),",
"evaluate the student's answer and suggest a score with feedback.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "suggestedScore": 4,',
' "confidence": 0.85,',
' "feedback": "constructive feedback in the student\'s language",',
' "reasoning": "why this score was assigned"',
"}",
"Rules:",
"- suggestedScore must be between 0 and maxScore.",
"- confidence is between 0 and 1 (higher means more certain).",
"- feedback should be encouraging and specific.",
"- If the answer is completely wrong, suggestedScore should be 0.",
"- If the answer is partially correct, give partial credit.",
"- Consider alternative correct answers if the question allows.",
"Never output placeholders.",
].join("\n")
// ---------------------------------------------------------------------------
// 备课内容生成
// ---------------------------------------------------------------------------
export const LESSON_CONTENT_SYSTEM_PROMPT = [
"You are an expert K12 instructional designer.",
"Generate teaching content based on the given topic and context.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "title": "content title",',
' "content": "detailed content in markdown format",',
' "metadata": { "duration": "15 min", "materials": ["..."] }',
"}",
"Rules:",
"- Content should be age-appropriate for the specified grade.",
"- For 'activity' type: generate an interactive classroom activity.",
"- For 'assessment' type: generate a formative assessment.",
"- For 'question' type: generate discussion questions.",
"- For 'material' type: generate teaching material outline.",
"- Content should align with the subject curriculum.",
"Never output placeholders.",
].join("\n")
// ---------------------------------------------------------------------------
// 题目变体生成
// ---------------------------------------------------------------------------
export const QUESTION_VARIANT_SYSTEM_PROMPT = [
"You are an expert K12 question variation generator.",
"Given an original question, generate a variant based on the specified type.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "text": "variant question text",',
' "type": "single_choice | multiple_choice | judgment | text",',
' "difficulty": 3,',
' "options": [{ "id": "A", "text": "option", "isCorrect": true }],',
' "answer": "correct answer",',
' "explanation": "brief explanation"',
"}",
"Variant types:",
"- same_knowledge_point: test the same concept with different context.",
"- different_difficulty: make it easier or harder.",
"- different_format: change the question type (e.g., choice to text).",
"Rules:",
"- For choice questions, always include 4 options with exactly one correct.",
"- Difficulty must be 1-5.",
"Never output placeholders.",
].join("\n")
// ---------------------------------------------------------------------------
// 薄弱点分析
// ---------------------------------------------------------------------------
export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
"You are an expert K12 learning analyst.",
"Analyze the student's error patterns and identify weak areas.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "weakAreas": [',
" {",
' "area": "knowledge area name",',
' "severity": "high | medium | low",',
' "suggestion": "specific improvement suggestion"',
" }",
" ],",
' "studyPlan": "personalized study plan summary",',
' "recommendedResources": ["resource 1", "resource 2"]',
"}",
"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.",
"- Suggestions should be actionable and specific.",
"- Study plan should be concise (3-5 sentences).",
"- Recommended resources can be topic names or study strategies.",
"Never output placeholders.",
].join("\n")
// ---------------------------------------------------------------------------
// 通用 JSON 提取提示词(用于修复 AI 返回的无效 JSON
// ---------------------------------------------------------------------------
export const JSON_REPAIR_SYSTEM_PROMPT = [
"You are a JSON repair engine.",
"Fix the provided invalid JSON into valid JSON only.",
"Keep the original structure and values as much as possible.",
"Do not use placeholders such as ... or [...].",
"Return JSON only without markdown.",
].join("\n")

View File

@@ -0,0 +1,83 @@
import "server-only"
import { trackEvent, type EventName } from "@/shared/lib/track-event"
export type AiUsageEvent = {
userId: string
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis"
providerId?: string
model?: string
success: boolean
durationMs: number
tokenUsage?: number
errorMessage?: string
}
const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
chat: "ai.chat",
similar_question: "ai.similar_question",
grading_assist: "ai.grading_assist",
lesson_content: "ai.lesson_content",
question_variant: "ai.question_variant",
weakness_analysis: "ai.weakness_analysis",
}
/**
* AI 使用埋点
*
* 记录每次 AI 调用的元数据,用于监控、成本分析与异常排查。
* 非阻塞,失败不影响主流程。
*/
export const trackAiUsage = (event: AiUsageEvent): void => {
const eventName = AI_EVENT_MAP[event.capability]
void trackEvent({
event: eventName,
userId: event.userId,
targetType: event.capability,
properties: {
providerId: event.providerId,
model: event.model,
success: event.success,
durationMs: event.durationMs,
tokenUsage: event.tokenUsage,
errorMessage: event.errorMessage,
},
}).catch(() => {
// 静默失败:埋点不应影响业务流程
})
}
/**
* 测量 AI 调用耗时并自动埋点
*/
export const withAiTracking = async <T>(
userId: string,
capability: AiUsageEvent["capability"],
providerId: string | undefined,
fn: () => Promise<{ result: T; model?: string; tokenUsage?: number }>
): Promise<T> => {
const start = Date.now()
try {
const { result, model, tokenUsage } = await fn()
trackAiUsage({
userId,
capability,
providerId,
model,
success: true,
durationMs: Date.now() - start,
tokenUsage,
})
return result
} catch (error) {
trackAiUsage({
userId,
capability,
providerId,
success: false,
durationMs: Date.now() - start,
errorMessage: error instanceof Error ? error.message : String(error),
})
throw error
}
}