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) }
}
}