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)。
400 lines
15 KiB
TypeScript
400 lines
15 KiB
TypeScript
"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)
|
||
}
|
||
}
|