fix(exams): fix rich editor crashes and redesign form layout
- Fix groupBlock/questionBlock insertContent error: empty content violates schema "block+", now provide default empty paragraph - Fix buildTiptapDocFromAiResponse: ensure groupBlock/questionBlock always have content (fallback to empty paragraph) - Add wrapInGroup/wrapInQuestion commands to wrap selected text into question/group blocks (vs insert which creates empty blocks) - Update SelectionToolbar: use wrap when text selected, insert when not - Redesign exam-rich-form layout: - Merge basic info into single-row toolbar (title/subject/grade/difficulty/score/duration) - Remove separate "source text" textarea (user pastes directly in editor) - AI auto-mark now reads from editor content via getText() - Editor + preview takes full height (calc(100vh-180px)) - Enhance AI prompt for Chinese exam papers: - Support reading material (阅读理解选段) - Support dotted chars (加点字注音) - Support sub-questions (阅读理解小题) - Better type detection (single/multiple choice, judgment, fill, essay, composite) - Add splitByDottedTexts helper to mark dotted chars in Tiptap doc - Add i18n keys for titlePlaceholder, editorArea description
This commit is contained in:
@@ -916,27 +916,57 @@ export async function autoMarkExamAction(
|
||||
const { sourceText, aiProviderId } = parsed.data
|
||||
|
||||
const systemPrompt = [
|
||||
"你是一个试卷结构解析引擎。",
|
||||
"你是一个试卷结构解析引擎,专门解析中国中小学试卷。",
|
||||
"将给定的试卷文本解析为结构化 JSON,用于在富文本编辑器中渲染为可编辑的题目块。",
|
||||
"识别以下元素:",
|
||||
"1. 大题分组(如\"一、选择题\"\"二、填空题\"),输出到 sections",
|
||||
"2. 每道题目,标注 type(single_choice/multiple_choice/judgment/text/composite)和 score",
|
||||
"3. 选项列表(A. B. C. D. 等),输出到 content.options",
|
||||
"4. 填空位置(如\"_______\"或横线空),在 content.blanks 中标记",
|
||||
"5. 加点字(拼音注音题中加点的字),在 content.dottedTexts 中列出加点的原文片段",
|
||||
"6. 子题(如\"1. 2. 3.\"小题),输出到 content.subQuestions",
|
||||
"输出 JSON,不要输出 markdown 代码块。",
|
||||
"输出 schema:",
|
||||
"",
|
||||
"## 识别规则",
|
||||
"",
|
||||
"1. **大题分组**:识别\"一、选择题\"\"二、填空题\"\"三、阅读理解\"等大题标题,输出到 sections。每个 section 含 title 和 questions。",
|
||||
"2. **题型识别**:",
|
||||
" - 选择题(单选/多选):题干 + 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 字段。",
|
||||
"",
|
||||
"## 输出 JSON schema",
|
||||
"",
|
||||
"```json",
|
||||
"{",
|
||||
' "title": "试卷标题(可选)",',
|
||||
' "title": "试卷标题",',
|
||||
' "sections": [',
|
||||
' { "title": "一、选择题", "questions": [',
|
||||
' { "type": "single_choice", "score": 2, "content": { "text": "题干文本", "options": [{"id":"A","text":"选项A"}], "blanks": [], "dottedTexts": [], "subQuestions": [] } }',
|
||||
" ] }",
|
||||
" {",
|
||||
' "title": "一、选择题",',
|
||||
' "questions": [',
|
||||
" {",
|
||||
' "type": "single_choice",',
|
||||
' "score": 3,',
|
||||
' "content": {',
|
||||
' "text": "下面加点字的注音全部正确的一项是",',
|
||||
' "options": [{"id":"A","text":"解剖(pō) 蹭饭(cènɡ)"},{"id":"B","text":"栖息(qī) 譬如(pì)"}],',
|
||||
' "blanks": [],',
|
||||
' "dottedTexts": ["解","蹭","徉"],',
|
||||
' "subQuestions": []',
|
||||
" }",
|
||||
" }",
|
||||
" ]",
|
||||
" }",
|
||||
" ]",
|
||||
"}",
|
||||
"如果没有 sections,可直接返回 { \"questions\": [...] }",
|
||||
"不要输出 ... 或 [...] 等占位符。",
|
||||
"```",
|
||||
"",
|
||||
"## 注意事项",
|
||||
"- 不要输出 markdown 代码块标记(```),直接输出 JSON。",
|
||||
"- 不要输出 ... 或 [...] 等占位符,必须输出完整数据。",
|
||||
"- 如果没有 sections,可直接返回 { \"questions\": [...] }。",
|
||||
"- 阅读理解的选段文本放到 content.readingMaterial,不要混入题干 text。",
|
||||
"- 作文题的\"不少于300字\"等要求保留在 text 中。",
|
||||
].join("\n")
|
||||
|
||||
const { createAiChatCompletion } = await import("@/shared/lib/ai")
|
||||
@@ -976,6 +1006,52 @@ const extractTitleFromAiResponse = (data: unknown): string => {
|
||||
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 两种形式。
|
||||
@@ -995,10 +1071,48 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
|
||||
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) {
|
||||
inner.push({ type: "paragraph", content: [{ type: "text", text: line }] })
|
||||
// 如果有加点字,在文本中标记对应片段
|
||||
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 }] })
|
||||
}
|
||||
}
|
||||
|
||||
// 选项列表
|
||||
@@ -1023,10 +1137,31 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
|
||||
})
|
||||
}
|
||||
|
||||
// 子题(阅读理解的小题)
|
||||
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 : ""
|
||||
if (subText) {
|
||||
inner.push({
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: subText }],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// questionBlock 要求 content: "block+",无内容时给空段落
|
||||
const innerContent =
|
||||
inner.length > 0
|
||||
? inner
|
||||
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
|
||||
|
||||
return {
|
||||
type: "questionBlock",
|
||||
attrs: { questionId: "", type, score },
|
||||
content: inner,
|
||||
content: innerContent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1037,10 +1172,15 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
|
||||
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({
|
||||
type: "groupBlock",
|
||||
attrs: { title },
|
||||
content: children,
|
||||
content: groupContent,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user