diff --git a/src/modules/exams/actions.ts b/src/modules/exams/actions.ts index 019482a..b422e22 100644 --- a/src/modules/exams/actions.ts +++ b/src/modules/exams/actions.ts @@ -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 => 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, }) } diff --git a/src/modules/exams/components/exam-rich-form.tsx b/src/modules/exams/components/exam-rich-form.tsx index cf1a30b..7972e29 100644 --- a/src/modules/exams/components/exam-rich-form.tsx +++ b/src/modules/exams/components/exam-rich-form.tsx @@ -7,9 +7,7 @@ import { toast } from "sonner" import { Sparkles, Save, FileText } from "lucide-react" import { Button } from "@/shared/components/ui/button" -import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Input } from "@/shared/components/ui/input" -import { Label } from "@/shared/components/ui/label" import { Select, SelectContent, @@ -35,11 +33,11 @@ import { } from "../editor" const DIFFICULTY_OPTIONS = [ - { value: "1", label: "Level 1 (Easy)" }, - { value: "2", label: "Level 2" }, - { value: "3", label: "Level 3 (Medium)" }, - { value: "4", label: "Level 4" }, - { value: "5", label: "Level 5 (Hard)" }, + { value: "1", label: "1级 (简单)" }, + { value: "2", label: "2级" }, + { value: "3", label: "3级 (中等)" }, + { value: "4", label: "4级" }, + { value: "5", label: "5级 (困难)" }, ] interface ExamRichFormValues { @@ -74,7 +72,6 @@ export function ExamRichForm() { scheduledAt: "", }) - const [sourceText, setSourceText] = useState("") const [editorDoc, setEditorDoc] = useState(null) useEffect(() => { @@ -109,14 +106,19 @@ export function ExamRichForm() { void fetchMetadata() }, [t]) + /** + * AI 自动标记 —— 从编辑器当前内容获取文本,交给 AI 解析后重新载入。 + * 用户可直接在编辑器中粘贴试卷文本,然后点击此按钮让 AI 自动标记题目结构。 + */ const handleAutoMark = () => { - if (!sourceText.trim()) { - toast.error(t("exam.richEditor.pasteSourceFirst")) + const currentText = editorRef.current?.getText() ?? "" + if (!currentText.trim()) { + toast.error(t("exam.richEditor.emptyEditor")) return } startMarking(async () => { const formData = new FormData() - formData.append("sourceText", sourceText) + formData.append("sourceText", currentText) const result = await autoMarkExamAction(null, formData) if (result.success && result.data) { const doc = result.data.doc as EditorJSONContent @@ -179,189 +181,141 @@ export function ExamRichForm() { : null return ( -
- - - {t("exam.richEditor.basicInfo")} - - -
- - setValues((v) => ({ ...v, title: e.target.value }))} - /> -
-
-
- - -
-
- - -
-
-
-
- - -
-
- - setValues((v) => ({ ...v, totalScore: Number(e.target.value) || 0 }))} - /> -
-
- - setValues((v) => ({ ...v, durationMin: Number(e.target.value) || 0 }))} - /> -
-
-
-
- - - - - {t("exam.richEditor.editorArea")} - - - - -
- -