"use server" import { revalidatePath } from "next/cache" import type { ActionState } from "@/shared/types/action-state" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { z } from "zod" import { createId } from "@paralleldrive/cuid2" import { handleActionError, safeJsonParse, } from "@/shared/lib/action-utils" import { trackExamEvent } from "@/shared/lib/track-event" import { buildExamDescription, deleteExamById, duplicateExam, getExamCreatorId, getExamGrades, getExamPreview, getExamSubjects, getExamsByGradeId, persistAiGeneratedExamDraft, persistExamDraft, resolveSubjectGradeNames, updateExamWithQuestions, type ExamModeConfig, } from "./data-access" import { AiGeneratedStructureSchema, AiInsertQuestionSchema, AiQuestionSchema, generateAiCreateDraftFromSource, generateAiPreviewData, regenerateAiQuestionByInstruction, } from "./ai-pipeline" import type { AiGeneratedQuestion, AiGeneratedStructureNode, AiPreviewData, AiRewriteQuestionData, } from "./ai-pipeline" import type { GradeExamsResult } from "./types" export type { AiPreviewData, AiRewriteQuestionData } from "./ai-pipeline" const ExamCreateSchema = z.object({ title: z.string().min(1), subject: z.string().min(1), grade: z.string().min(1), difficulty: z.coerce.number().int().min(1).max(5), totalScore: z.coerce.number().int().min(1), durationMin: z.coerce.number().int().min(1), scheduledAt: z.string().optional().nullable(), questions: z .array( z.object({ id: z.string(), score: z.coerce.number().int().min(0), }) ) .optional(), }) const getStringValue = (formData: FormData, key: string) => { const value = formData.get(key) return typeof value === "string" ? value : undefined } const getBoolValue = (formData: FormData, key: string, fallback = false): boolean => { const value = formData.get(key) if (typeof value !== "string") return fallback return value === "true" } const parseExamModeConfig = (formData: FormData): ExamModeConfig => { const rawMode = getStringValue(formData, "examMode") const examMode: ExamModeConfig["examMode"] = rawMode === "timed" || rawMode === "proctored" ? rawMode : "homework" const rawDuration = getStringValue(formData, "durationMinutes") const durationMinutes = rawDuration && Number.isFinite(Number(rawDuration)) ? Number(rawDuration) : null const rawGrace = getStringValue(formData, "lateStartGraceMinutes") ?? "0" const parsedGrace = Number(rawGrace) const lateStartGraceMinutes = Number.isFinite(parsedGrace) ? parsedGrace : 0 return { examMode, durationMinutes, shuffleQuestions: getBoolValue(formData, "shuffleQuestions", false), allowLateStart: getBoolValue(formData, "allowLateStart", false), lateStartGraceMinutes, antiCheatEnabled: getBoolValue(formData, "antiCheatEnabled", false), } } const failState = (message: string, errors?: Record): ActionState => ({ success: false, message, errors, }) const successState = (data: T, message?: string): ActionState => ({ success: true, message, data, }) const invalidFormState = ( error: z.ZodError, options?: { fallbackMessage?: string; useFirstMessage?: boolean } ): ActionState => { const errors = error.flatten().fieldErrors const fallbackMessage = options?.fallbackMessage ?? "Invalid form data" const useFirstMessage = options?.useFirstMessage ?? true const messages = Object.values(errors).flatMap((items) => items ?? []) const firstMessage = messages.find((msg): msg is string => typeof msg === "string" && msg.length > 0) return failState(useFirstMessage ? (firstMessage ?? fallbackMessage) : fallbackMessage, errors) } const prepareExamCreateContext = async (input: { subject: string grade: string difficulty: number totalScore: number durationMin: number scheduledAt?: string | null }) => { const examId = createId() const scheduled = input.scheduledAt || undefined const resolvedNames = await resolveSubjectGradeNames({ subjectId: input.subject, gradeId: input.grade, }) const subjectName = resolvedNames.subjectName ?? input.subject const gradeName = resolvedNames.gradeName ?? input.grade const buildDescription = (options?: { questionCount?: number }) => buildExamDescription({ subject: subjectName, grade: gradeName, difficulty: input.difficulty, totalScore: input.totalScore, durationMin: input.durationMin, scheduledAt: scheduled, questionCount: options?.questionCount, }) return { examId, scheduled, subjectName, gradeName, buildDescription } } const loadAiDraftQuestionsAndStructure = async (input: { rawAiQuestions: string | null rawStructure: string | null title: string subject: string grade: string difficulty: number totalScore: number durationMin: number aiSourceText?: string aiQuestionCount?: number aiProviderId?: string }): Promise< | { ok: true; generated: AiGeneratedQuestion[]; structure: AiGeneratedStructureNode[] } | { ok: false; message: string } > => { if (input.rawAiQuestions) { let parsedQuestions: unknown = null try { parsedQuestions = JSON.parse(input.rawAiQuestions) } catch { return { ok: false, message: "Invalid AI preview payload" } } const validated = z.array(AiInsertQuestionSchema).safeParse(parsedQuestions) if (!validated.success || validated.data.length === 0) { return { ok: false, message: "Invalid AI preview payload" } } const generated: AiGeneratedQuestion[] = validated.data.map((q) => ({ id: q.id, type: q.type, difficulty: q.difficulty, score: q.score, content: { text: q.content.text, ...(q.content.options ? { options: q.content.options.map((opt) => ({ id: opt.id, text: opt.text, isCorrect: opt.isCorrect ?? false, })), } : {}), ...(q.content.subQuestions ? { subQuestions: q.content.subQuestions } : {}), }, })) let structure: AiGeneratedStructureNode[] = [] if (input.rawStructure) { try { const parsedStructure = JSON.parse(input.rawStructure) const validatedStructure = AiGeneratedStructureSchema.safeParse(parsedStructure) if (validatedStructure.success) { structure = validatedStructure.data } else { return { ok: false, message: "Invalid preview structure" } } } catch { return { ok: false, message: "Invalid preview structure" } } } if (structure.length === 0) { structure = generated.map((q) => ({ id: createId(), type: "question", questionId: q.id, score: q.score, })) } return { ok: true, generated, structure } } const sourceText = input.aiSourceText?.trim() if (!sourceText) { return { ok: false, message: "Please analyze and preview before creating" } } const aiDraft = await generateAiCreateDraftFromSource({ title: input.title, subject: input.subject, grade: input.grade, difficulty: input.difficulty, totalScore: input.totalScore, durationMin: input.durationMin, questionCount: input.aiQuestionCount, sourceText, aiProviderId: input.aiProviderId, }) if (!aiDraft.ok) { return { ok: false, message: aiDraft.message } } return { ok: true, generated: aiDraft.generated, structure: aiDraft.structure } } const prepareAiPreviewRequest = async (input: { title?: string subject?: string grade?: string difficulty?: number totalScore?: number durationMin?: number aiSourceText: string aiQuestionCount?: number aiProviderId?: string }) => { const resolvedNames = await resolveSubjectGradeNames({ subjectId: input.subject, gradeId: input.grade, }) const title = input.title && input.title.trim().length > 0 ? input.title : "AI Exam" const subjectName = input.subject ? resolvedNames.subjectName ?? input.subject : undefined const gradeName = input.grade ? resolvedNames.gradeName ?? input.grade : undefined return { title, subject: subjectName, grade: gradeName, difficulty: input.difficulty ?? 3, totalScore: input.totalScore ?? 100, durationMin: input.durationMin ?? 90, questionCount: input.aiQuestionCount, sourceText: input.aiSourceText, aiProviderId: input.aiProviderId, } } const parseRegenerateAiQuestionInput = ( formData: FormData ): | { ok: true instruction: string aiProviderId?: string sourceText?: string originalQuestion: z.infer } | { ok: false; state: ActionState } => { const instruction = getStringValue(formData, "instruction")?.trim() const aiProviderId = getStringValue(formData, "aiProviderId")?.trim() const sourceText = getStringValue(formData, "sourceText")?.trim() const questionJson = getStringValue(formData, "questionJson") if (!instruction) { return { ok: false, state: failState("Please enter rewrite instruction") } } if (!questionJson) { return { ok: false, state: failState("No selected question data") } } try { const parsedQuestion = JSON.parse(questionJson) as unknown const validatedQuestion = AiQuestionSchema.safeParse(parsedQuestion) if (!validatedQuestion.success) { return { ok: false, state: failState("Selected question format invalid") } } return { ok: true, instruction, aiProviderId, sourceText, originalQuestion: validatedQuestion.data, } } catch { return { ok: false, state: failState("Selected question format invalid") } } } export async function createExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const ctx = await requirePermission(Permissions.EXAM_CREATE) const rawQuestionsValue = formData.get("questionsJson") const rawQuestions = typeof rawQuestionsValue === "string" ? rawQuestionsValue : null const parsed = ExamCreateSchema.safeParse({ title: getStringValue(formData, "title"), subject: getStringValue(formData, "subject"), grade: getStringValue(formData, "grade"), difficulty: getStringValue(formData, "difficulty"), totalScore: getStringValue(formData, "totalScore"), durationMin: getStringValue(formData, "durationMin"), scheduledAt: getStringValue(formData, "scheduledAt") ?? null, questions: rawQuestions ? safeJsonParse(rawQuestions, "题目数据格式无效") : [], }) if (!parsed.success) { return invalidFormState(parsed.error, { useFirstMessage: false }) } const input = parsed.data const context = await prepareExamCreateContext({ subject: input.subject, grade: input.grade, difficulty: input.difficulty, totalScore: input.totalScore, durationMin: input.durationMin, scheduledAt: input.scheduledAt, }) const description = context.buildDescription() try { await persistExamDraft({ examId: context.examId, title: input.title, creatorId: ctx.userId, subjectId: input.subject, gradeId: input.grade, scheduledAt: context.scheduled, description, examModeConfig: parseExamModeConfig(formData), }) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to create exam") } revalidatePath("/teacher/exams/all") return successState(context.examId, "Exam created successfully.") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } const AiExamCreateSchema = ExamCreateSchema.extend({ aiSourceText: z.string().optional(), aiQuestionCount: z.coerce.number().int().min(1).max(200).optional(), aiProviderId: z.string().min(1).optional(), }) const AiExamPreviewSchema = z.object({ title: z.string().optional(), subject: z.string().optional(), grade: z.string().optional(), difficulty: z.coerce.number().int().min(1).max(5).optional(), totalScore: z.coerce.number().int().min(1).optional(), durationMin: z.coerce.number().int().min(1).optional(), aiSourceText: z.string().min(1), aiQuestionCount: z.coerce.number().int().min(1).max(200).optional(), aiProviderId: z.string().min(1).optional(), }) export async function createAiExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const ctx = await requirePermission(Permissions.EXAM_AI_GENERATE) const rawQuestionsValue = formData.get("questionsJson") const rawQuestions = typeof rawQuestionsValue === "string" ? rawQuestionsValue : null const rawAiQuestionsValue = formData.get("aiQuestionsJson") const rawAiQuestions = typeof rawAiQuestionsValue === "string" ? rawAiQuestionsValue : null const rawStructureValue = formData.get("structureJson") const rawStructure = typeof rawStructureValue === "string" ? rawStructureValue : null const aiSourceTextRaw = formData.get("aiSourceText") const aiQuestionCountRaw = formData.get("aiQuestionCount") const aiProviderIdRaw = formData.get("aiProviderId") const parsed = AiExamCreateSchema.safeParse({ title: getStringValue(formData, "title"), subject: getStringValue(formData, "subject"), grade: getStringValue(formData, "grade"), difficulty: getStringValue(formData, "difficulty"), totalScore: getStringValue(formData, "totalScore"), durationMin: getStringValue(formData, "durationMin"), scheduledAt: getStringValue(formData, "scheduledAt") ?? null, questions: rawQuestions ? safeJsonParse(rawQuestions, "题目数据格式无效") : [], aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined, aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0 ? aiQuestionCountRaw : undefined, aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0 ? aiProviderIdRaw : undefined, }) if (!parsed.success) { return invalidFormState(parsed.error) } const input = parsed.data if (!rawAiQuestions && !input.aiSourceText) { return failState("Please analyze and preview before creating") } const context = await prepareExamCreateContext({ subject: input.subject, grade: input.grade, difficulty: input.difficulty, totalScore: input.totalScore, durationMin: input.durationMin, scheduledAt: input.scheduledAt, }) const aiDraftResult = await loadAiDraftQuestionsAndStructure({ rawAiQuestions, rawStructure, title: input.title, subject: context.subjectName, grade: context.gradeName, difficulty: input.difficulty, totalScore: input.totalScore, durationMin: input.durationMin, aiSourceText: input.aiSourceText, aiQuestionCount: input.aiQuestionCount, aiProviderId: input.aiProviderId, }) if (!aiDraftResult.ok) { return failState(aiDraftResult.message) } const { generated, structure } = aiDraftResult const questionCount = generated.length const description = context.buildDescription({ questionCount }) try { await persistAiGeneratedExamDraft({ examId: context.examId, title: input.title, creatorId: ctx.userId, subjectId: input.subject, gradeId: input.grade, scheduledAt: context.scheduled, description, structure, generated, examModeConfig: parseExamModeConfig(formData), }) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to create exam") } revalidatePath("/teacher/exams/all") // V3-4: 埋点监控(AI 生成考试) await trackExamEvent("exam.ai_generated", { userId: ctx.userId, targetId: context.examId, properties: { aiSourceText: input.aiSourceText?.length ?? 0, aiQuestionCount: input.aiQuestionCount, }, }) return successState(context.examId, "Exam created successfully.") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } export async function previewAiExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { await requirePermission(Permissions.EXAM_AI_GENERATE) const aiSourceTextRaw = formData.get("aiSourceText") const aiQuestionCountRaw = formData.get("aiQuestionCount") const aiProviderIdRaw = formData.get("aiProviderId") const sourceText = typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : "" if (!sourceText) { return failState("Please paste the full exam text first", { aiSourceText: ["Please paste the full exam text first"], }) } const parsed = AiExamPreviewSchema.safeParse({ title: getStringValue(formData, "title"), subject: getStringValue(formData, "subject"), grade: getStringValue(formData, "grade"), difficulty: getStringValue(formData, "difficulty"), totalScore: getStringValue(formData, "totalScore"), durationMin: getStringValue(formData, "durationMin"), aiSourceText: sourceText, aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0 ? aiQuestionCountRaw : undefined, aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0 ? aiProviderIdRaw : undefined, }) if (!parsed.success) { return invalidFormState(parsed.error) } const input = parsed.data const previewRequest = await prepareAiPreviewRequest(input) const aiDraft = await generateAiPreviewData(previewRequest) if (!aiDraft.ok) { return failState(aiDraft.message) } return successState({ ...aiDraft.data, rawOutput: aiDraft.rawOutput }) } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } export async function regenerateAiQuestionAction( prevState: ActionState | null, formData: FormData ): Promise> { try { await requirePermission(Permissions.EXAM_AI_GENERATE) const parsedInput = parseRegenerateAiQuestionInput(formData) if (!parsedInput.ok) { return parsedInput.state } const { instruction, aiProviderId, sourceText, originalQuestion } = parsedInput const originalDifficulty = originalQuestion.difficulty ?? 3 const originalScore = originalQuestion.score ?? 0 try { const result = await regenerateAiQuestionByInstruction({ instruction, originalQuestion, sourceText, aiProviderId, }) if (!result.ok) { return failState(result.message) } return successState({ type: result.data.type, difficulty: result.data.difficulty ?? originalDifficulty, score: result.data.score ?? originalScore, content: result.data.content, }) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("AI question format invalid") } } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } const ExamUpdateSchema = z.object({ examId: z.string().min(1), questions: z .array( z.object({ id: z.string(), score: z.coerce.number().int().min(0), }) ) .optional(), structure: z.unknown().optional(), status: z.enum(["draft", "published", "archived"]).optional(), }) export async function updateExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const ctx = await requirePermission(Permissions.EXAM_UPDATE) const rawQuestions = formData.get("questionsJson") const rawStructure = formData.get("structureJson") const rawQuestionsStr = typeof rawQuestions === "string" ? rawQuestions : null const rawStructureStr = typeof rawStructure === "string" ? rawStructure : null const parsed = ExamUpdateSchema.safeParse({ examId: formData.get("examId"), questions: rawQuestionsStr ? safeJsonParse(rawQuestionsStr, "题目数据格式无效") : undefined, structure: rawStructureStr ? safeJsonParse(rawStructureStr, "试卷结构数据格式无效") : undefined, status: formData.get("status") ?? undefined, }) if (!parsed.success) { return invalidFormState(parsed.error, { fallbackMessage: "Invalid update data", useFirstMessage: false, }) } const { examId, questions, structure, status } = parsed.data // Ownership check: non-admin users can only update their own exams if (ctx.dataScope.type !== "all") { const creatorId = await getExamCreatorId(examId) if (!creatorId || creatorId !== ctx.userId) { return failState("You can only update exams you created") } } try { await updateExamWithQuestions(examId, { questions: questions ?? undefined, structure, status, }) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to update exam") } revalidatePath("/teacher/exams/all") // V3-4: 埋点监控 await trackExamEvent("exam.updated", { userId: ctx.userId, targetId: examId, properties: { hasQuestions: !!questions, hasStructure: !!structure, status }, }) return successState(examId, "Exam updated") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } const ExamDeleteSchema = z.object({ examId: z.string().min(1), }) export async function deleteExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const ctx = await requirePermission(Permissions.EXAM_DELETE) const parsed = ExamDeleteSchema.safeParse({ examId: formData.get("examId"), }) if (!parsed.success) { return invalidFormState(parsed.error, { fallbackMessage: "Invalid delete data", useFirstMessage: false, }) } const { examId } = parsed.data // Ownership check: non-admin users can only delete their own exams if (ctx.dataScope.type !== "all") { const creatorId = await getExamCreatorId(examId) if (!creatorId || creatorId !== ctx.userId) { return failState("You can only delete exams you created") } } try { await deleteExamById(examId) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to delete exam") } revalidatePath("/teacher/exams/all") // V3-4: 埋点监控 await trackExamEvent("exam.deleted", { userId: ctx.userId, targetId: examId, }) return successState(examId, "Exam deleted") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } const ExamDuplicateSchema = z.object({ examId: z.string().min(1), }) export async function duplicateExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const ctx = await requirePermission(Permissions.EXAM_DUPLICATE) const parsed = ExamDuplicateSchema.safeParse({ examId: formData.get("examId"), }) if (!parsed.success) { return invalidFormState(parsed.error, { fallbackMessage: "Invalid duplicate data", useFirstMessage: false, }) } const { examId } = parsed.data let newExamId: string try { const duplicatedId = await duplicateExam(examId, ctx.userId) if (!duplicatedId) { return failState("Exam not found") } newExamId = duplicatedId } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to duplicate exam") } revalidatePath("/teacher/exams/all") // V3-4: 埋点监控 await trackExamEvent("exam.duplicated", { userId: ctx.userId, targetId: newExamId, properties: { sourceExamId: examId }, }) return successState(newExamId, "Exam duplicated") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } export async function getExamPreviewAction( examId: string ): Promise }>> { try { await requirePermission(Permissions.EXAM_READ) try { const exam = await getExamPreview(examId) if (!exam) { return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found") } return successState({ structure: exam.structure, questions: exam.questions, }) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview") } } catch (error) { if (error instanceof PermissionDeniedError) { return failState<{ structure: unknown; questions: Array<{ id: string }> }>(error.message) } return handleActionError(error) } } export async function getSubjectsAction(): Promise> { try { await requirePermission(Permissions.EXAM_READ) try { const allSubjects = await getExamSubjects() return successState(allSubjects) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState<{ id: string; name: string }[]>("Failed to load subjects") } } catch (error) { if (error instanceof PermissionDeniedError) { return failState<{ id: string; name: string }[]>(error.message) } return handleActionError(error) } } export async function getGradesAction(): Promise> { try { await requirePermission(Permissions.EXAM_READ) try { const allGrades = await getExamGrades() return successState(allGrades) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState<{ id: string; name: string }[]>("Failed to load grades") } } catch (error) { if (error instanceof PermissionDeniedError) { return failState<{ id: string; name: string }[]>(error.message) } return handleActionError(error) } } /** * 年级仪表盘 - 维度3:获取年级下所有考试 + 提交统计。 */ export async function getExamsByGradeIdAction( gradeId: string ): Promise> { try { const ctx = await requirePermission(Permissions.EXAM_READ) if (!gradeId || gradeId.trim().length === 0) { return failState("Invalid grade id") } const result = await getExamsByGradeId({ gradeId, scope: ctx.dataScope, }) return successState(result) } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } // --------------------------------------------------------------------------- // 富文本编辑器:AI 自动标记 + 保存草稿 // --------------------------------------------------------------------------- const AutoMarkSchema = z.object({ sourceText: z.string().min(1, "试卷文本不能为空"), aiProviderId: z.string().optional(), }) export interface AutoMarkResult { /** Tiptap JSONContent 文档,可直接载入编辑器 */ doc: unknown /** 解析出的标题(如有) */ title: string } /** * AI 自动标记 —— 将粘贴的试卷文本交给 AI,返回带题目块/分组/填空/加点字标记的 Tiptap JSONContent 文档。 * 教师可在编辑器中基于此结果继续微调。 */ export async function autoMarkExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { await requirePermission(Permissions.EXAM_AI_GENERATE) const parsed = AutoMarkSchema.safeParse({ sourceText: getStringValue(formData, "sourceText"), aiProviderId: getStringValue(formData, "aiProviderId") || undefined, }) if (!parsed.success) { return invalidFormState(parsed.error, { useFirstMessage: true }) } const { sourceText, aiProviderId } = parsed.data const systemPrompt = [ "你是一个试卷结构解析引擎,专门解析中国中小学试卷。", "将给定的试卷文本解析为结构化 JSON,用于在富文本编辑器中渲染为可编辑的题目块。", "", "## 识别规则", "", "1. **分卷**:识别\"第Ⅰ卷\"\"第Ⅱ卷\"\"第一部分\"等顶层分卷标记,输出到 volumes。每个 volume 含 title、instruction(可选)和 groups。", "2. **大题分组**:识别\"一、选择题\"\"二、填空题\"\"三、阅读理解\"等大题标题,输出到 groups(若在 volume 内则归入对应 volume,否则归入顶层 groups)。每个 group 含 title、instruction(如\"每小题3分,共24分\")和 questions。", "3. **题型识别**:", " - 选择题(单选/多选):题干 + A/B/C/D 选项 → type: \"single_choice\" 或 \"multiple_choice\"", " - 判断题:题干 + 对/错 → type: \"judgment\"", " - 填空题:题干含横线空(_______或( )) → type: \"text\",在 blanks 中标记空数", " - 简答/作文题:题干 + \"不少于X字\" → type: \"text\"", " - 阅读理解(含选段+多小题)→ type: \"composite\",subQuestions 存小题", "4. **分值**:从\"每小题3分\"\"共24分\"\"(12分)\"等提取,分摊到每题。", "5. **选项**:A. B. C. D. 格式,输出到 content.options,每项含 id 和 text。", "6. **填空**:横线\"_______\"或括号\"( )\"位置,在 content.blanks 中标记(数组长度=空数)。", "7. **加点字**:拼音注音题中加点的字(如\"解\"剖),在 content.dottedTexts 中列出原文片段。", "8. **子题**:阅读理解下的\"1. 2. 3.\"小题,输出到 content.subQuestions,每项含 text 和 score。", "9. **阅读材料**:阅读理解的文章选段,放到 content.readingMaterial 字段。", "10. **说明文字**:\"每小题3分,共24分\"\"选择正确答案的番号填涂在答题卡上\"等放到 group.instruction,不要混入题干。", "", "## 输出 JSON schema", "", "```json", "{", ' "title": "试卷标题",', ' "volumes": [', " {", ' "title": "第Ⅰ卷 选择题",', ' "groups": [', " {", ' "title": "一、选择正确答案的番号填涂在答题卡上",', ' "instruction": "每小题3分,共24分",', ' "questions": [', " {", ' "type": "single_choice",', ' "score": 3,', ' "content": {', ' "text": "下面加点字的注音全部正确的一项是",', ' "options": [{"id":"A","text":"解剖(pō) 蹭饭(cènɡ)"},{"id":"B","text":"栖息(qī) 譬如(pì)"}],', ' "blanks": [],', ' "dottedTexts": ["解","蹭","徉"],', ' "subQuestions": []', " }", " }", " ]", " }", " ]", " }", " ],", ' "groups": []', "}", "```", "", "## 注意事项", "- 不要输出 markdown 代码块标记(```),直接输出 JSON。", "- 不要输出 ... 或 [...] 等占位符,必须输出完整数据。", "- 如果没有 volumes,可直接返回 { \"groups\": [...] } 或 { \"questions\": [...] }。", "- 阅读理解的选段文本放到 content.readingMaterial,不要混入题干 text。", "- 作文题的\"不少于300字\"等要求保留在 text 中。", "- \"共24分\"等总分信息不要写进 title,放到 instruction;实际总分由子题自动累加。", ].join("\n") const { createAiChatCompletion } = await import("@/shared/lib/ai") const { env } = await import("@/env.mjs") const { parseAiResponse } = await import("./ai-pipeline/parse") const aiResult = await createAiChatCompletion({ model: String(env.AI_MODEL ?? "gpt-4o-mini"), providerId: aiProviderId, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: sourceText }, ], temperature: 0, maxTokens: 8000, }) const parsedJson = await parseAiResponse(aiResult.content, aiProviderId) const doc = buildTiptapDocFromAiResponse(parsedJson) const title = extractTitleFromAiResponse(parsedJson) return successState({ doc, title }, "AI 自动标记完成") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } console.error("[autoMarkExamAction]", error instanceof Error ? error.message : String(error)) return handleActionError(error) } } const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null const extractTitleFromAiResponse = (data: unknown): string => { if (!isRecord(data)) return "" return typeof data.title === "string" ? data.title : "" } /** * 将文本按加点字片段切分,返回带 dotted 标记的片段数组。 * 用于在 Tiptap 文档中标记加点字(下加点)。 */ const splitByDottedTexts = ( text: string, dottedTexts: string[] ): Array<{ text: string; dotted: boolean }> => { if (dottedTexts.length === 0) return [{ text, dotted: false }] const result: Array<{ text: string; dotted: boolean }> = [] let remaining = text while (remaining.length > 0) { // 找到最早出现的加点字 let earliestIdx = -1 let earliestText = "" for (const dt of dottedTexts) { if (!dt) continue const idx = remaining.indexOf(dt) if (idx >= 0 && (earliestIdx === -1 || idx < earliestIdx)) { earliestIdx = idx earliestText = dt } } if (earliestIdx === -1) { // 没有更多加点字,剩余文本作为普通片段 if (remaining.length > 0) { result.push({ text: remaining, dotted: false }) } break } // 加点字之前的普通文本 if (earliestIdx > 0) { result.push({ text: remaining.slice(0, earliestIdx), dotted: false }) } // 加点字片段 result.push({ text: earliestText, dotted: true }) remaining = remaining.slice(earliestIdx + earliestText.length) } return result } /** * 将 AI 返回的结构化 JSON 转换为 Tiptap JSONContent 文档。 * 支持 sections(分组)和顶层 questions 两种形式。 */ const buildTiptapDocFromAiResponse = (data: unknown): unknown => { if (!isRecord(data)) return { type: "doc", content: [] } const content: unknown[] = [] const topQuestions = Array.isArray(data.questions) ? data.questions : [] const buildQuestionBlock = (q: unknown): unknown | null => { if (!isRecord(q)) return null const type = typeof q.type === "string" ? q.type : "text" const score = typeof q.score === "number" ? q.score : 0 const contentNode = isRecord(q.content) ? q.content : {} const text = typeof contentNode.text === "string" ? contentNode.text : "" const inner: unknown[] = [] // 阅读材料(阅读理解题型的选段) const readingMaterial = typeof contentNode.readingMaterial === "string" ? contentNode.readingMaterial : "" if (readingMaterial) { const materialLines = readingMaterial .split("\n") .filter((l) => l.trim().length > 0) for (const line of materialLines) { inner.push({ type: "paragraph", content: [{ type: "text", text: line, marks: [{ type: "italic" }] }], }) } } // 题干段落(按行拆分),处理加点字标记 const dottedTexts = Array.isArray(contentNode.dottedTexts) ? (contentNode.dottedTexts as unknown[]) .filter((s): s is string => typeof s === "string") : [] const lines = text.split("\n").filter((l) => l.trim().length > 0) for (const line of lines) { // 如果有加点字,在文本中标记对应片段 if (dottedTexts.length > 0) { const segments = splitByDottedTexts(line, dottedTexts) const textNodes = segments.map((seg) => seg.dotted ? { type: "text", text: seg.text, marks: [{ type: "dotted" }], } : { type: "text", text: seg.text } ) inner.push({ type: "paragraph", content: textNodes }) } else { inner.push({ type: "paragraph", content: [{ type: "text", text: line }] }) } } // 选项列表 const options = Array.isArray(contentNode.options) ? contentNode.options : [] if (options.length > 0) { inner.push({ type: "orderedList", content: options.map((opt) => { const o = isRecord(opt) ? opt : {} const id = typeof o.id === "string" ? o.id : "" const optText = typeof o.text === "string" ? o.text : "" return { type: "listItem", content: [ { type: "paragraph", content: [{ type: "text", text: `${id}. ${optText}` }], }, ], } }), }) } // 子题(阅读理解的小题)—— 生成为嵌套的 questionBlock,便于编辑器层级展示与解析 const subQuestions = Array.isArray(contentNode.subQuestions) ? contentNode.subQuestions : [] for (const sub of subQuestions) { if (!isRecord(sub)) continue const subText = typeof sub.text === "string" ? sub.text : "" const subScore = typeof sub.score === "number" ? sub.score : 0 if (subText) { // 子题文本按行拆分为段落 const subLines = subText.split("\n").filter((l) => l.trim().length > 0) const subInner = subLines.length > 0 ? subLines.map((line) => ({ type: "paragraph", content: [{ type: "text", text: line }], })) : [{ type: "paragraph", content: [{ type: "text", text: " " }] }] inner.push({ type: "questionBlock", attrs: { questionId: "", type: "text", score: subScore }, content: subInner, }) } } // questionBlock 要求 content: "block+",无内容时给空段落 const innerContent = inner.length > 0 ? inner : [{ type: "paragraph", content: [{ type: "text", text: " " }] }] return { type: "questionBlock", attrs: { questionId: "", type, score }, content: innerContent, } } // 构建 groupBlock(大题分组),含 instruction const buildGroupBlock = (g: unknown): unknown | null => { if (!isRecord(g)) return null const title = typeof g.title === "string" ? g.title : "" const instruction = typeof g.instruction === "string" ? g.instruction : "" const questions = Array.isArray(g.questions) ? g.questions : [] const children = questions .map(buildQuestionBlock) .filter((b): b is Record => b !== null) const groupContent = children.length > 0 ? children : [{ type: "paragraph", content: [{ type: "text", text: " " }] }] return { type: "groupBlock", attrs: { title, instruction }, content: groupContent, } } // 构建 sectionBlock(分卷),内含多个 groupBlock const volumes = Array.isArray(data.volumes) ? data.volumes : [] const topGroups = Array.isArray(data.groups) ? data.groups : [] for (const volume of volumes) { if (!isRecord(volume)) continue const title = typeof volume.title === "string" ? volume.title : "" const innerGroups = Array.isArray(volume.groups) ? volume.groups : [] const groupBlocks = innerGroups .map(buildGroupBlock) .filter((b): b is Record => b !== null) const sectionContent = groupBlocks.length > 0 ? groupBlocks : [{ type: "paragraph", content: [{ type: "text", text: " " }] }] content.push({ type: "sectionBlock", attrs: { title, level: 1 }, content: sectionContent, }) } // 顶层 groups(无分卷时) for (const g of topGroups) { const block = buildGroupBlock(g) if (block) content.push(block) } // 顶层 questions(无分卷无大题时) if (volumes.length === 0 && topGroups.length === 0) { for (const q of topQuestions) { const block = buildQuestionBlock(q) if (block) content.push(block) } } return { type: "doc", content } } const RichExamCreateSchema = z.object({ title: z.string().min(1, "标题不能为空"), subject: z.string().min(1), grade: z.string().min(1), difficulty: z.coerce.number().int().min(1).max(5), totalScore: z.coerce.number().int().min(1), durationMin: z.coerce.number().int().min(1), scheduledAt: z.string().optional().nullable(), /** Tiptap JSONContent 文档(JSON 字符串) */ editorDoc: z.string().min(1, "试卷内容不能为空"), }) /** * 从富文本编辑器保存试卷草稿。 * 将 Tiptap JSONContent 转换为 questions + structure 后持久化。 */ export async function createExamFromRichEditorAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const ctx = await requirePermission(Permissions.EXAM_CREATE) const parsed = RichExamCreateSchema.safeParse({ title: getStringValue(formData, "title"), subject: getStringValue(formData, "subject"), grade: getStringValue(formData, "grade"), difficulty: getStringValue(formData, "difficulty"), totalScore: getStringValue(formData, "totalScore"), durationMin: getStringValue(formData, "durationMin"), scheduledAt: getStringValue(formData, "scheduledAt") ?? null, editorDoc: getStringValue(formData, "editorDoc"), }) if (!parsed.success) { return invalidFormState(parsed.error, { useFirstMessage: true }) } const input = parsed.data const editorDoc = safeJsonParse(input.editorDoc, "试卷内容格式无效") if (!editorDoc) { return failState("试卷内容解析失败") } const context = await prepareExamCreateContext({ subject: input.subject, grade: input.grade, difficulty: input.difficulty, totalScore: input.totalScore, durationMin: input.durationMin, scheduledAt: input.scheduledAt, }) // 将 Tiptap doc 转换为 EditorDoc 结构 const { editorDocToStructure } = await import("./editor/editor-to-structure") const structure = editorDocToStructure(editorDoc as never, input.title) // 转换为 AI 生成格式以复用 persistAiGeneratedExamDraft const generated = structure.questions.map((q) => ({ id: q.id, type: q.type as "single_choice" | "multiple_choice" | "text" | "judgment", difficulty: input.difficulty, score: q.score, content: q.content as never, })) const aiStructure = structure.structure.map((node) => { if (node.type === "group") { return { id: node.id, type: "group" as const, title: node.title ?? "", children: (node.children ?? []).map((c) => ({ id: c.id, type: "question" as const, questionId: c.questionId ?? "", score: c.score ?? 0, })), } } return { id: node.id, type: "question" as const, questionId: node.questionId ?? "", score: node.score ?? 0, } }) await persistAiGeneratedExamDraft({ examId: context.examId, title: input.title, creatorId: ctx.userId, subjectId: input.subject, gradeId: input.grade, scheduledAt: context.scheduled, description: context.buildDescription(), structure: aiStructure, generated, examModeConfig: parseExamModeConfig(formData), }) revalidatePath("/teacher/exams/all") return successState(context.examId, "试卷草稿已创建") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } console.error("[createExamFromRichEditorAction]", error instanceof Error ? error.message : String(error)) return handleActionError(error) } }