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

@@ -149,11 +149,11 @@ export const requestAiExamStructureDraft = async (input: {
export const validateExamSourceText = async (input: { sourceText: string; aiProviderId?: string }) => {
const text = input.sourceText.trim()
if (!text) {
return { ok: false as const, message: "请先粘贴试卷文本" }
return { ok: false as const, message: "Source text is required" }
}
const userContent = [
"请判断下面文本是否是可读、正常的题目/试卷内容(不是乱码、随机字符或混乱文本)。",
`文本内容:\n${text}`,
"Judge whether the following text is readable and resembles normal exam/question content (not garbled, random characters, or disordered text).",
`Text content:\n${text}`,
].join("\n\n")
try {
const aiResult = await createAiChatCompletion({
@@ -169,12 +169,12 @@ export const validateExamSourceText = async (input: { sourceText: string; aiProv
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
const validated = AiSourceValidationSchema.safeParse(parsed)
if (!validated.success) {
return { ok: false as const, message: "试卷文本校验失败,请重试" }
return { ok: false as const, message: "Source text validation failed, please retry" }
}
if (!validated.data.valid) {
return {
ok: false as const,
message: validated.data.reason?.trim() || "识别为乱码或混乱文本,请粘贴清晰完整的题目内容",
message: validated.data.reason?.trim() || "Text appears garbled or disordered, please paste clear and complete question content",
}
}
return { ok: true as const }
@@ -245,7 +245,8 @@ export const parseQuestionDetail = async (input: {
content: q.content,
} satisfies z.infer<typeof AiQuestionSchema>
}
} catch {
} catch (error) {
console.warn("[parseQuestionDetail] Falling back to text question:", error instanceof Error ? error.message : String(error))
}
return {

View File

@@ -1,10 +1,20 @@
"use client"
import { useState } from "react"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { Card } from "@/shared/components/ui/card"
import { Plus } from "lucide-react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Plus, Sparkles } from "lucide-react"
import type { Question } from "@/modules/questions/types"
import { AiQuestionVariantGenerator } from "@/modules/ai/components/ai-question-variant-generator"
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider"
type QuestionBankListProps = {
questions: Question[]
@@ -13,9 +23,32 @@ type QuestionBankListProps = {
onLoadMore?: () => void
hasMore?: boolean
isLoading?: boolean
/** 学科(用于 AI 题目变体生成) */
subject?: string
}
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading }: QuestionBankListProps) {
/**
* 从题目内容中提取纯文本(用于 AI 变体生成)
*
* 类型收窄:从 unknown 逐步缩小到具体类型,避免使用 as 断言。
*/
function extractQuestionText(content: unknown): string {
if (!content) return ""
if (typeof content === "string") return content
if (typeof content === "object" && content !== null && "text" in content) {
const textValue = (content as Record<string, unknown>).text
if (typeof textValue === "string") return textValue
}
try {
return JSON.stringify(content)
} catch {
return ""
}
}
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading, subject }: QuestionBankListProps) {
const aiClient = useAiClientOptional()
if (questions.length === 0 && !isLoading) {
return (
<div className="text-center py-8 text-muted-foreground">
@@ -42,6 +75,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
return { text: "" }
})()
const typeLabel = typeof q.type === "string" ? q.type.replace("_", " ") : "unknown"
const questionText = extractQuestionText(q.content)
return (
<Card key={q.id} className="p-3 flex gap-3 hover:bg-muted/50 transition-colors">
<div className="flex-1 space-y-2">
@@ -62,9 +96,18 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
{parsedContent.text || "No content preview"}
</p>
</div>
<div className="flex items-center">
<Button
size="sm"
<div className="flex items-center gap-1">
{/* AI 题目变体生成(仅在 AiClientProvider 注入时显示) */}
{aiClient && questionText ? (
<AiVariantDialog
questionText={questionText}
questionType={q.type}
difficulty={q.difficulty}
subject={subject}
/>
) : null}
<Button
size="sm"
variant={added ? "secondary" : "default"}
disabled={added}
onClick={() => onAdd(q)}
@@ -76,13 +119,13 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
</Card>
)
})}
{hasMore && (
<div className="pt-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={onLoadMore}
<Button
variant="ghost"
size="sm"
onClick={onLoadMore}
disabled={isLoading}
className="w-full text-muted-foreground"
>
@@ -90,7 +133,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
</Button>
</div>
)}
{isLoading && questions.length === 0 && (
<div className="space-y-3">
{[1,2,3].map(i => (
@@ -101,3 +144,53 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
</div>
)
}
/**
* AI 题目变体生成对话框
*
* 独立组件,仅在用户点击时挂载,避免不必要的渲染。
*/
function AiVariantDialog({
questionText,
questionType,
difficulty,
subject,
}: {
questionText: string
questionType: string
difficulty: number
subject?: string
}) {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
title="AI variant"
>
<Sparkles className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
AI Variant
</DialogTitle>
</DialogHeader>
<AiQuestionVariantGenerator
originalQuestion={{
text: questionText,
type: questionType,
difficulty,
}}
subject={subject}
/>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import type { Control, UseFormReturn } from "react-hook-form"
import { useTranslations } from "next-intl"
import { Settings } from "lucide-react"
import {
FormField,
@@ -79,14 +80,20 @@ export function ExamAiGenerator({
runningPreviewTaskCount,
queuedPreviewTaskCount,
}: ExamAiGeneratorProps) {
const formatTaskTime = (value: number) => formatDateTime(new Date(value))
const t = useTranslations("ai")
const formatTaskTime = (value: number) => {
if (!Number.isFinite(value)) return ""
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ""
return formatDateTime(date)
}
return (
<Card>
<CardHeader>
<CardTitle>AI Generation</CardTitle>
<CardTitle>{t("exam.generationTitle")}</CardTitle>
<CardDescription>
Paste the exam text and generate a structured preview.
{t("exam.generationDesc")}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
@@ -96,7 +103,7 @@ export function ExamAiGenerator({
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between gap-2">
<FormLabel>AI Provider</FormLabel>
<FormLabel>{t("provider.label")}</FormLabel>
<Dialog
open={providerDialogOpen}
onOpenChange={(open) => {
@@ -109,14 +116,14 @@ export function ExamAiGenerator({
<DialogTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground">
<Settings className="mr-1 h-3.5 w-3.5" />
{t("provider.manage")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[960px]">
<DialogHeader>
<DialogTitle>AI Provider Settings</DialogTitle>
<DialogTitle>{t("provider.manageTitle")}</DialogTitle>
<DialogDescription>
Create a new provider or update existing configuration.
{t("provider.manageDescription")}
</DialogDescription>
</DialogHeader>
<AiProviderSettingsCard
@@ -136,19 +143,19 @@ export function ExamAiGenerator({
<Select value={field.value} onValueChange={field.onChange} disabled={loadingAiProviders}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={loadingAiProviders ? "Loading providers..." : "Select provider"} />
<SelectValue placeholder={loadingAiProviders ? t("provider.loading") : t("provider.placeholder")} />
</SelectTrigger>
</FormControl>
<SelectContent>
{aiProviders.map((item) => (
<SelectItem key={item.id} value={item.id}>
{aiProviderLabels[item.provider] ?? item.provider} · {item.model}{item.isDefault ? " (Default)" : ""}
{aiProviderLabels[item.provider] ?? item.provider} · {item.model}{item.isDefault ? ` (${t("provider.default")})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Select the AI configuration for this generation.
{t("provider.description")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -156,10 +163,10 @@ export function ExamAiGenerator({
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={handleBackgroundPreview}>
{`加入后台队列(运行 ${runningPreviewTaskCount}/3排队 ${queuedPreviewTaskCount}`}
{t("exam.queue")} ({t("exam.queueRunning")} {runningPreviewTaskCount}/3, {t("exam.queueQueued")} {queuedPreviewTaskCount})
</Button>
<Button type="button" variant="outline" size="sm" onClick={handlePreview} disabled={previewLoading || activePreviewTaskCount > 0}>
{previewLoading ? "Generating..." : "立即预览"}
{previewLoading ? t("exam.generating") : t("exam.preview")}
</Button>
</div>
<FormField
@@ -167,16 +174,16 @@ export function ExamAiGenerator({
name="aiSourceText"
render={({ field }) => (
<FormItem>
<FormLabel>Source Exam Text</FormLabel>
<FormLabel>{t("exam.sourceText")}</FormLabel>
<FormControl>
<Textarea
placeholder="Paste the full exam text to parse into questions."
placeholder={t("exam.sourceTextPlaceholder")}
className="min-h-[200px]"
{...field}
/>
</FormControl>
<FormDescription>
AI will extract questions and structure from this text.
{t("exam.sourceTextDesc")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -184,7 +191,7 @@ export function ExamAiGenerator({
/>
{previewTasks.length > 0 ? (
<div className="rounded-md border p-3 space-y-2">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium">{t("exam.backgroundTasks")}</div>
<div className="space-y-2">
{previewTasks.slice(0, 6).map((task) => (
<div key={task.id} className="rounded-md border p-2">
@@ -194,17 +201,17 @@ export function ExamAiGenerator({
</div>
<div className="mt-1 text-xs text-muted-foreground">
{task.status === "queued"
? "排队中"
? t("exam.taskStatus.queued")
: task.status === "running"
? "生成中"
? t("exam.taskStatus.running")
: task.status === "success"
? "已完成"
: `失败${task.message || "生成失败"}`}
? t("exam.taskStatus.success")
: `${t("exam.taskStatus.failed")}${task.message || ""}`}
</div>
{task.status === "success" && task.result ? (
<div className="mt-2 flex justify-end">
<Button type="button" variant="ghost" size="sm" onClick={() => handleOpenPreviewTask(task.id)}>
{t("exam.openPreview")}
</Button>
</div>
) : null}

View File

@@ -11,6 +11,7 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
import type { Question } from "@/modules/questions/types"
import type { QuestionType } from "@/modules/questions/types"
import { updateExamAction } from "@/modules/exams/actions"
import { getQuestionsAction } from "@/modules/questions/actions"
import { StructureEditor } from "./assembly/structure-editor"
@@ -19,6 +20,18 @@ import type { ExamNode } from "./assembly/selected-question-list"
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
import { createId } from "@paralleldrive/cuid2"
const QUESTION_TYPES: readonly QuestionType[] = [
"single_choice",
"multiple_choice",
"text",
"judgment",
"composite",
] as const
function isQuestionType(value: string): value is QuestionType {
return (QUESTION_TYPES as readonly string[]).includes(value)
}
type ExamAssemblyProps = {
examId: string
title: string
@@ -76,15 +89,15 @@ export function ExamAssembly(props: ExamAssemblyProps) {
startBankTransition(async () => {
const nextPage = reset ? 1 : page + 1
try {
const difficultyNum = difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter, 10)
const result = await getQuestionsAction({
q: deferredSearch,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: typeFilter === 'all' ? undefined : typeFilter as any,
difficulty: difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter),
type: typeFilter === 'all' ? undefined : (isQuestionType(typeFilter) ? typeFilter : undefined),
difficulty: difficultyNum === undefined || Number.isNaN(difficultyNum) ? undefined : difficultyNum,
page: nextPage,
pageSize: 20
})
if (result.success && result.data) {
const questionsList = result.data.data
setBankQuestions(prev => {
@@ -97,7 +110,8 @@ export function ExamAssembly(props: ExamAssemblyProps) {
setHasMore(questionsList.length === 20)
setPage(nextPage)
}
} catch {
} catch (error) {
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error))
toast.error("Failed to load questions")
}
})
@@ -127,7 +141,9 @@ export function ExamAssembly(props: ExamAssemblyProps) {
return calc(structure)
}, [structure])
const progress = Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100))
const progress = props.totalScore > 0
? Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100))
: 0
const addedQuestionIds = useMemo(() => {
const ids = new Set<string>()
@@ -255,12 +271,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
formData.set("examId", props.examId)
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
formData.set("structureJson", JSON.stringify(getCleanStructure()))
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success("Exam draft saved")
} else {
toast.error(result.message || "Save failed")
try {
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success("Exam draft saved")
} else {
toast.error(result.message || "Save failed")
}
} catch (error) {
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error))
toast.error("Save failed")
}
}
@@ -269,13 +290,18 @@ export function ExamAssembly(props: ExamAssemblyProps) {
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
formData.set("structureJson", JSON.stringify(getCleanStructure()))
formData.set("status", "published")
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success("Published exam")
router.push("/teacher/exams/all")
} else {
toast.error(result.message || "Publish failed")
try {
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success("Published exam")
router.push("/teacher/exams/all")
} else {
toast.error(result.message || "Publish failed")
}
} catch (error) {
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error))
toast.error("Publish failed")
}
}
@@ -396,13 +422,14 @@ export function ExamAssembly(props: ExamAssemblyProps) {
<ScrollArea className="flex-1 p-0 bg-muted/5">
<div className="p-3">
<QuestionBankList
<QuestionBankList
questions={bankQuestions}
onAdd={handleAdd}
isAdded={(id) => addedQuestionIds.has(id)}
onLoadMore={() => fetchQuestions(false)}
hasMore={hasMore}
isLoading={isBankLoading}
subject={props.subject}
/>
</div>
</ScrollArea>