feat(exams): add section/group structure nodes with auto stats
Distinguish structural levels from questions:
- sectionBlock (new): top-level volume like "第Ⅰ卷 选择题(共24分)"
- groupBlock (enhanced): section like "一、选择题" with instruction field
- questionBlock (composite): reserved for reading comprehension
Key changes:
- New section-block.tsx extension with level attr (1=卷, 2=部分) and
auto-computed question count + total score in NodeView
- group-block.tsx: add instruction field ("每小题3分"), auto stats display
- editor-to-structure.ts: recursive buildStructureNode supports arbitrary
nesting (section > group > question), computeStats accumulates scores
- exam-rich-form.tsx ExamPreview: render section/group/question with
distinct styles and stats badges
- selection-toolbar.tsx: add "分卷" button (Layers icon)
- exam-rich-editor.tsx: register SectionBlock, expose insertSection/
wrapInSection via ref
- actions.ts: AI prompt now outputs volumes[] + groups[] structure with
instruction; buildTiptapDocFromAiResponse generates nested sectionBlock
- i18n: add markSection keys (zh-CN/en)
Structural nodes are NOT questions: their question count and total score
are automatically computed from child questions, not manually set.
This commit is contained in:
@@ -921,52 +921,62 @@ export async function autoMarkExamAction(
|
||||
"",
|
||||
"## 识别规则",
|
||||
"",
|
||||
"1. **大题分组**:识别\"一、选择题\"\"二、填空题\"\"三、阅读理解\"等大题标题,输出到 sections。每个 section 含 title 和 questions。",
|
||||
"2. **题型识别**:",
|
||||
"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 存小题",
|
||||
"3. **分值**:从\"每小题3分\"\"共24分\"\"(12分)\"等提取,分摊到每题。",
|
||||
"4. **选项**:A. B. C. D. 格式,输出到 content.options,每项含 id 和 text。",
|
||||
"5. **填空**:横线\"_______\"或括号\"( )\"位置,在 content.blanks 中标记(数组长度=空数)。",
|
||||
"6. **加点字**:拼音注音题中加点的字(如\"解\"剖),在 content.dottedTexts 中列出原文片段。",
|
||||
"7. **子题**:阅读理解下的\"1. 2. 3.\"小题,输出到 content.subQuestions,每项含 text。",
|
||||
"8. **阅读材料**:阅读理解的文章选段,放到 content.readingMaterial 字段。",
|
||||
"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": "试卷标题",',
|
||||
' "sections": [',
|
||||
' "volumes": [',
|
||||
" {",
|
||||
' "title": "一、选择题",',
|
||||
' "questions": [',
|
||||
' "title": "第Ⅰ卷 选择题",',
|
||||
' "groups": [',
|
||||
" {",
|
||||
' "type": "single_choice",',
|
||||
' "score": 3,',
|
||||
' "content": {',
|
||||
' "text": "下面加点字的注音全部正确的一项是",',
|
||||
' "options": [{"id":"A","text":"解剖(pō) 蹭饭(cènɡ)"},{"id":"B","text":"栖息(qī) 譬如(pì)"}],',
|
||||
' "blanks": [],',
|
||||
' "dottedTexts": ["解","蹭","徉"],',
|
||||
' "subQuestions": []',
|
||||
" }",
|
||||
' "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。",
|
||||
"- 不要输出 ... 或 [...] 等占位符,必须输出完整数据。",
|
||||
"- 如果没有 sections,可直接返回 { \"questions\": [...] }。",
|
||||
"- 如果没有 volumes,可直接返回 { \"groups\": [...] } 或 { \"questions\": [...] }。",
|
||||
"- 阅读理解的选段文本放到 content.readingMaterial,不要混入题干 text。",
|
||||
"- 作文题的\"不少于300字\"等要求保留在 text 中。",
|
||||
"- \"共24分\"等总分信息不要写进 title,放到 instruction;实际总分由子题自动累加。",
|
||||
].join("\n")
|
||||
|
||||
const { createAiChatCompletion } = await import("@/shared/lib/ai")
|
||||
@@ -1060,7 +1070,6 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
|
||||
if (!isRecord(data)) return { type: "doc", content: [] }
|
||||
|
||||
const content: unknown[] = []
|
||||
const sections = Array.isArray(data.sections) ? data.sections : []
|
||||
const topQuestions = Array.isArray(data.questions) ? data.questions : []
|
||||
|
||||
const buildQuestionBlock = (q: unknown): unknown | null => {
|
||||
@@ -1176,26 +1185,56 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const section of sections) {
|
||||
if (!isRecord(section)) continue
|
||||
const title = typeof section.title === "string" ? section.title : ""
|
||||
const questions = Array.isArray(section.questions) ? section.questions : []
|
||||
// 构建 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)
|
||||
// groupBlock 要求 content: "block+",即使无子题也要给一个空段落
|
||||
const groupContent =
|
||||
children.length > 0
|
||||
? children
|
||||
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
|
||||
content.push({
|
||||
return {
|
||||
type: "groupBlock",
|
||||
attrs: { title },
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
// 顶层 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)
|
||||
|
||||
Reference in New Issue
Block a user