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

@@ -7,13 +7,45 @@ import { normalizeStructure } from "@/modules/exams/utils/normalize-structure"
import type { Question } from "@/modules/questions/types"
import type { ExamNode } from "@/modules/exams/components/assembly/selected-question-list"
import { createId } from "@paralleldrive/cuid2"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import {
AiClientProvider,
type AiClientService,
} from "@/modules/ai/context/ai-client-provider"
import {
aiChatAction,
suggestSimilarQuestionsAction,
suggestGradingAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
} from "@/modules/ai/actions"
export const dynamic = "force-dynamic"
/**
* 构建 AI 客户端服务Server Action 引用集合)
*
* 通过 React Context 注入,客户端组件不直接 import actions
* 遵循依赖注入模式,便于测试时替换为 mock。
*/
function createAiClientService(): AiClientService {
return {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
}
}
export default async function BuildExamPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
const { id } = await params
const exam = await getExamById(id)
const ctx = await requirePermission(Permissions.EXAM_READ)
const exam = await getExamById(id, ctx.dataScope)
if (!exam) return notFound()
// Fetch initial questions for the bank (pagination handled by client)
@@ -69,24 +101,28 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
}))
}
const aiClientService = createAiClientService()
return (
<div className="flex h-full flex-col space-y-4 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Build Exam</h1>
<p className="text-muted-foreground">Assemble questions for your exam.</p>
<AiClientProvider service={aiClientService}>
<div className="flex h-full flex-col space-y-4 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Build Exam</h1>
<p className="text-muted-foreground">Assemble questions for your exam.</p>
</div>
<ExamAssembly
examId={exam.id}
title={exam.title}
subject={exam.subject}
grade={exam.grade}
difficulty={exam.difficulty}
totalScore={exam.totalScore}
durationMin={exam.durationMin}
initialSelected={initialSelected}
initialStructure={initialStructure}
questionOptions={questionOptions}
/>
</div>
<ExamAssembly
examId={exam.id}
title={exam.title}
subject={exam.subject}
grade={exam.grade}
difficulty={exam.difficulty}
totalScore={exam.totalScore}
durationMin={exam.durationMin}
initialSelected={initialSelected}
initialStructure={initialStructure}
questionOptions={questionOptions}
/>
</div>
</AiClientProvider>
)
}

View File

@@ -3,15 +3,46 @@ import { notFound } from "next/navigation"
import { getHomeworkSubmissionDetails } from "@/modules/homework/data-access"
import { HomeworkGradingView } from "@/modules/homework/components/homework-grading-view"
import { formatDate } from "@/shared/lib/utils"
import {
AiClientProvider,
type AiClientService,
} from "@/modules/ai/context/ai-client-provider"
import {
suggestGradingAction,
aiChatAction,
suggestSimilarQuestionsAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
} from "@/modules/ai/actions"
export const dynamic = "force-dynamic"
/**
* 构建 AI 客户端服务Server Action 引用集合)
*
* 通过 React Context 注入,客户端组件不直接 import actions
* 遵循依赖注入模式,便于测试时替换为 mock。
*/
function createAiClientService(): AiClientService {
return {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
}
}
export default async function HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }): Promise<JSX.Element> {
const { submissionId } = await params
const submission = await getHomeworkSubmissionDetails(submissionId)
if (!submission) return notFound()
const aiClientService = createAiClientService()
return (
<div className="flex h-full flex-col space-y-4 p-6">
<div className="flex items-center justify-between">
@@ -29,17 +60,19 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params
</div>
</div>
<HomeworkGradingView
submissionId={submission.id}
studentName={submission.studentName}
assignmentTitle={submission.assignmentTitle}
submittedAt={submission.submittedAt}
status={submission.status}
totalScore={submission.totalScore}
answers={submission.answers}
prevSubmissionId={submission.prevSubmissionId}
nextSubmissionId={submission.nextSubmissionId}
/>
<AiClientProvider service={aiClientService}>
<HomeworkGradingView
submissionId={submission.id}
studentName={submission.studentName}
assignmentTitle={submission.assignmentTitle}
submittedAt={submission.submittedAt}
status={submission.status}
totalScore={submission.totalScore}
answers={submission.answers}
prevSubmissionId={submission.prevSubmissionId}
nextSubmissionId={submission.nextSubmissionId}
/>
</AiClientProvider>
</div>
)
}

View File

@@ -1,12 +1,44 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { notFound } from "next/navigation"
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
import { LessonPlanEditor } from "@/modules/lesson-preparation/components/lesson-plan-editor"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { Skeleton } from "@/shared/components/ui/skeleton"
import {
AiClientProvider,
type AiClientService,
} from "@/modules/ai/context/ai-client-provider"
import {
aiChatAction,
suggestSimilarQuestionsAction,
suggestGradingAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
} from "@/modules/ai/actions"
export const dynamic = "force-dynamic"
/**
* 构建 AI 客户端服务Server Action 引用集合)
*
* 通过 React Context 注入,客户端组件不直接 import actions
* 遵循依赖注入模式,便于测试时替换为 mock。
*/
function createAiClientService(): AiClientService {
return {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
}
}
export default async function EditLessonPlanPage({
params,
}: {
@@ -23,16 +55,53 @@ export default async function EditLessonPlanPage({
const classes = teacherClasses.map((c) => ({ id: c.id, name: c.name }))
// 拉取教材/章节标题用于工具栏显示
let textbookTitle: string | undefined
let chapterTitle: string | undefined
if (plan.textbookId) {
const textbook = await getTextbookById(plan.textbookId)
textbookTitle = textbook?.title
if (plan.chapterId) {
const chapters = await getChaptersByTextbookId(plan.textbookId)
const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => {
for (const ch of list) {
if (ch.id === plan.chapterId) return ch
if (ch.children && ch.children.length > 0) {
const found = findChapter(ch.children as typeof chapters)
if (found) return found
}
}
return undefined
}
const chapter = findChapter(chapters)
chapterTitle = chapter?.title
}
}
const aiClientService = createAiClientService()
return (
<div className="h-[calc(100vh-4rem)]">
<LessonPlanEditor
planId={plan.id}
initialTitle={plan.title}
initialDoc={plan.content}
textbookId={plan.textbookId ?? undefined}
chapterId={plan.chapterId ?? undefined}
classes={classes}
/>
</div>
<AiClientProvider service={aiClientService}>
<div className="h-[calc(100vh-4rem)]">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<Skeleton className="h-[80%] w-[80%]" />
</div>
}
>
<LessonPlanEditor
planId={plan.id}
initialTitle={plan.title}
initialDoc={plan.content}
textbookId={plan.textbookId ?? undefined}
chapterId={plan.chapterId ?? undefined}
textbookTitle={textbookTitle}
chapterTitle={chapterTitle}
classes={classes}
/>
</Suspense>
</div>
</AiClientProvider>
)
}