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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user