Files
NextEdu/src/modules/exams/ai-pipeline/auto-mark.ts
SpecialX 12a766d3ee refactor(logging): replace console.* with module loggers in server modules
34 个服务端文件替换 86 处 console.* 调用为 createModuleLogger。模块覆盖: exams/audit/school/files/classes/notifications/grades/auth/homework/announcements/settings/lesson-preparation。shared lib: cache/redis-store, rate-limit/redis-limiter, redis-client, exam-homework-port。API routes: web-vitals, cron/audit-cleanup, proctoring/event。

web-vitals/route.ts 从 edge runtime 改为 nodejs runtime,因 pino 依赖 Node.js stream 内置模块,不兼容 Edge Runtime (V8 Isolate)。
2026-07-07 12:34:57 +08:00

400 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use server"
/**
* AI 自动标记试卷文本 —— Server Action + 纯转换辅助函数。
*
* 从 exams/actions.ts 拆分而来,职责:
* - AutoMarkSchema / AutoMarkResult 类型定义
* - buildTiptapDocFromAiResponse / splitByDottedTexts / extractTitleFromAiResponse 等纯转换函数
* - autoMarkExamAction Server Action
*/
import type { ActionState } from "@/shared/types/action-state"
import { getTranslations } from "next-intl/server"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { z } from "zod"
import { handleActionError } from "@/shared/lib/action-utils"
import { isRecord } from "@/shared/lib/type-guards"
import { createModuleLogger } from "@/shared/lib/logger"
import {
failState,
successState,
invalidFormState,
getStringValue,
} from "../actions-helpers"
const log = createModuleLogger("exams")
// ---------------------------------------------------------------------------
// Schema & 类型
// ---------------------------------------------------------------------------
const AutoMarkSchema = z.object({
sourceText: z.string().min(1, "试卷文本不能为空"),
aiProviderId: z.string().optional(),
})
export interface AutoMarkResult {
/** Tiptap JSONContent 文档,可直接载入编辑器 */
doc: unknown
/** 解析出的标题(如有) */
title: string
}
// ---------------------------------------------------------------------------
// 纯转换辅助函数
// ---------------------------------------------------------------------------
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<string, unknown> => 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<string, unknown> => 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 }
}
// ---------------------------------------------------------------------------
// Server Action
// ---------------------------------------------------------------------------
/**
* AI 自动标记 —— 将粘贴的试卷文本交给 AI,返回带题目块/分组/填空/加点字标记的 Tiptap JSONContent 文档。
* 教师可在编辑器中基于此结果继续微调。
*/
export async function autoMarkExamAction(
prevState: ActionState<AutoMarkResult> | null,
formData: FormData
): Promise<ActionState<AutoMarkResult>> {
const t = await getTranslations("examHomework")
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<AutoMarkResult>(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("./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 }, t("exam.actionMessages.autoMarkCompleted"))
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<AutoMarkResult>(error.message)
}
log.error({ err: error }, "autoMarkExamAction failed")
return handleActionError(error)
}
}