feat(试题生成器): 支持多种答题形式和试题类型

This commit is contained in:
cc 2026-03-26 10:48:27 +08:00
parent f04821c6bf
commit 1d11050178
2 changed files with 219 additions and 34 deletions

View File

@ -56,7 +56,9 @@ export const QUESTION_MAX_TOKENS = 4096;
// 试题生成 System Prompt // 试题生成 System Prompt
export const QUESTION_GENERATOR_PROMPT = `你是一位专业的英语试题出题专家。请根据用户指定的参数生成高质量的英语试题。 export const QUESTION_GENERATOR_PROMPT = `你是一位专业的英语试题出题专家。请根据用户指定的参数生成高质量的英语试题。
输出格式要求 重要必须严格按照用户指定的"答题形式"生成对应格式的试题
## 答题形式为"单选题"时的格式
## 题目 [序号] ## 题目 [序号]
**题目类型**[类型] **题目类型**[类型]
**难度等级**[难度] **难度等级**[难度]
@ -71,15 +73,38 @@ D. [选项D内容]
**解析**[详细解析] **解析**[详细解析]
**知识点**[相关知识点] **知识点**[相关知识点]
## 答题形式为"填空题"时的格式
## 题目 [序号]
**题目类型**[类型]
**难度等级**[难度]
**题目内容**
[题目文本用下划线_____标出需要填空的位置]
**正确答案**[填空答案]
**解析**[详细解析]
**知识点**[相关知识点]
## 答题形式为"写作题"时的格式
## 题目 [序号]
**题目类型**[类型]
**难度等级**[难度]
**题目内容**
[写作题目要求]
**参考范文**
[一篇高质量的范文]
**解析**[写作要点解析包括文章结构关键表达等]
**知识点**[相关知识点]
重要规则 重要规则
1. 所有题型都必须提供4个选项ABCD即使是填空题翻译题或阅读理解题 1. 最高优先级必须严格按照用户指定的答题形式单选题/填空题/写作题生成对应格式的试题
2. 对于填空题选项为不同的填空答案选项 2. 严禁超纲如果用户指定了"教材章节"所有试题内容必须严格限定在该章节的知识范围内绝对禁止出现超出该章节的词汇语法句型等内容
3. 对于翻译题选项为不同的翻译版本 3. 知识点约束如果用户指定了"知识点"试题必须围绕该知识点出题不得偏离
4. 对于阅读理解题选项为对问题的不同回答选项 4. 单选题必须提供4个选项ABCD答案为选项字母
5. 题目质量要高符合英语教学标准 5. 填空题不提供选项_____标出填空位置答案为填空内容
6. 答案准确无误 6. 写作题提供写作要求和参考范文不提供选项
7. 解析清晰易懂有助于学生理解 7. 题目质量要高符合英语教学标准
8. 知识点标注准确便于分类学习`; 8. 答案准确无误
9. 解析清晰易懂有助于学生理解
10. 知识点标注准确便于分类学习`;
// ── 文件上传限制 ── // ── 文件上传限制 ──
export const IMAGE_MAX_SIZE_MB = 10; export const IMAGE_MAX_SIZE_MB = 10;

View File

@ -34,6 +34,8 @@ const selectedKnowledgePoints = ref([]); // 选中的知识点
const customKnowledgePoint = ref(""); // const customKnowledgePoint = ref(""); //
const questionFormat = ref("单项选择"); // const questionFormat = ref("单项选择"); //
const customQuestionFormat = ref(""); // const customQuestionFormat = ref(""); //
const answerFormat = ref("单选题"); //
const examType = ref(""); //
// //
const DIFFICULTY_OPTIONS = [ const DIFFICULTY_OPTIONS = [
@ -77,7 +79,6 @@ const KNOWLEDGE_POINT_OPTIONS = [
// //
const FORMAT_OPTIONS = [ const FORMAT_OPTIONS = [
{ value: "单项选择", label: "单项选择" },
{ value: "完形填空", label: "完形填空" }, { value: "完形填空", label: "完形填空" },
{ value: "句型转换", label: "句型转换" }, { value: "句型转换", label: "句型转换" },
{ value: "词汇运用", label: "词汇运用" }, { value: "词汇运用", label: "词汇运用" },
@ -89,6 +90,20 @@ const FORMAT_OPTIONS = [
{ value: "选词填空", label: "选词填空" }, { value: "选词填空", label: "选词填空" },
]; ];
//
const ANSWER_FORMAT_OPTIONS = [
{ value: "单选题", label: "单选题" },
{ value: "填空题", label: "填空题" },
{ value: "写作题", label: "写作题" },
];
//
const EXAM_TYPE_OPTIONS = [
{ value: "中考", label: "中考" },
{ value: "单元同义句", label: "单元同义句" },
{ value: "高频考点", label: "高频考点" },
];
// //
const toggleKnowledgePoint = (value) => { const toggleKnowledgePoint = (value) => {
const index = selectedKnowledgePoints.value.indexOf(value); const index = selectedKnowledgePoints.value.indexOf(value);
@ -130,7 +145,10 @@ const parsedQuestions = computed(() => {
}); });
function extractField(text, fieldName) { function extractField(text, fieldName) {
const regex = new RegExp(`\\*\\*${fieldName}\\*\\*[:]\\s*([\\s\\S]*?)(?=\\*\\*|$)`, "i"); const regex = new RegExp(
`\\*\\*${fieldName}\\*\\*[:]\\s*([\\s\\S]*?)(?=\\*\\*|$)`,
"i"
);
const match = text.match(regex); const match = text.match(regex);
if (!match) return ""; if (!match) return "";
@ -142,15 +160,17 @@ function extractField(text, fieldName) {
function extractOptions(text) { function extractOptions(text) {
const options = []; const options = [];
// "" // ""
const optionsBlockMatch = text.match(/\*\*选项\*\*[:]\s*([\s\S]*?)(?=\*\*正确答案\*\*|$)/i); const optionsBlockMatch = text.match(
/\*\*选项\*\*[:]\s*([\s\S]*?)(?=\*\*正确答案\*\*|$)/i
);
if (optionsBlockMatch) { if (optionsBlockMatch) {
const optionsBlock = optionsBlockMatch[1]; const optionsBlock = optionsBlockMatch[1];
// //
const lines = optionsBlock.split('\n'); const lines = optionsBlock.split("\n");
for (const line of lines) { for (const line of lines) {
const match = line.match(/^([A-D])[\..。:]\s*(.+)$/i); const match = line.match(/^([A-D])[\..。:]\s*(.+)$/i);
if (match) { if (match) {
@ -161,21 +181,23 @@ function extractOptions(text) {
} }
} }
} }
return options; return options;
} }
// //
const buildRequestBody = () => { const buildRequestBody = () => {
const difficultyLabel = const difficultyLabel =
DIFFICULTY_OPTIONS.find((d) => d.value === difficulty.value)?.label || "中等"; DIFFICULTY_OPTIONS.find((d) => d.value === difficulty.value)?.label ||
"中等";
// + // +
const allKnowledgePoints = [ const allKnowledgePoints = [
...selectedKnowledgePoints.value, ...selectedKnowledgePoints.value,
...(customKnowledgePoint.value ? [customKnowledgePoint.value] : []), ...(customKnowledgePoint.value ? [customKnowledgePoint.value] : []),
]; ];
const knowledgePointStr = allKnowledgePoints.length > 0 ? allKnowledgePoints.join("、") : ""; const knowledgePointStr =
allKnowledgePoints.length > 0 ? allKnowledgePoints.join("、") : "";
// 使使 // 使使
const chapterStr = customTextbookChapter.value || textbookChapter.value || ""; const chapterStr = customTextbookChapter.value || textbookChapter.value || "";
@ -183,16 +205,30 @@ const buildRequestBody = () => {
// 使使 // 使使
const formatStr = customQuestionFormat.value || questionFormat.value || ""; const formatStr = customQuestionFormat.value || questionFormat.value || "";
const userPrompt = `请生成 ${questionCount.value}${difficultyLabel}难度的英语试题。 //
const chapterConstraint = chapterStr
? `\n\n【重要约束】教材章节为"${chapterStr}",所有试题内容必须严格限定在该章节的知识范围内:
- 仅使用该章节已学过的词汇短语和表达方式
- 仅考查该章节涉及的语法点和句型结构
- 绝对禁止出现超出该章节范围的知识点
- 如果试题类型为"中考"则按中考标准出题但仍需体现该章节的核心内容`
: "";
const userPrompt = `请生成 ${
questionCount.value
} 道${difficultyLabel}难度的英语试题
要求 要求
1. 题型${formatStr} 1. 题型${formatStr}
2. 难度等级${difficultyLabel} 2. 答题形式${answerFormat.value}
3. 题目数量${questionCount.value} 3. 难度等级${difficultyLabel}
${chapterStr ? `4. 教材章节:${chapterStr}` : ""} 4. 题目数量${questionCount.value}
${knowledgePointStr ? `5. 知识点:${knowledgePointStr}` : ""} ${examType.value ? `5. 试题类型:${examType.value}` : ""}
${chapterStr ? `6. 教材章节:${chapterStr}` : ""}
${knowledgePointStr ? `7. 知识点:${knowledgePointStr}` : ""}
${chapterConstraint}
请严格按照指定的输出格式生成试题`; 请严格按照指定的输出格式和答题形式生成试题严禁出现超出指定范围的内容`;
return { return {
model: QUESTION_MODEL, model: QUESTION_MODEL,
@ -234,7 +270,11 @@ const filterThinkBlocks = (text) => {
// //
const canGenerate = computed(() => { const canGenerate = computed(() => {
return status.value !== "generating" && questionCount.value >= 1 && questionCount.value <= 20; return (
status.value !== "generating" &&
questionCount.value >= 1 &&
questionCount.value <= 20
);
}); });
const startGeneration = async () => { const startGeneration = async () => {
@ -273,7 +313,9 @@ const startGeneration = async () => {
if (axios.isCancel(err)) return; if (axios.isCancel(err)) return;
console.error(err); console.error(err);
errorMsg.value = errorMsg.value =
err?.response?.data?.error?.message || err.message || "请求失败,请稍后重试"; err?.response?.data?.error?.message ||
err.message ||
"请求失败,请稍后重试";
status.value = "error"; status.value = "error";
}); });
}; };
@ -287,6 +329,8 @@ const resetAll = () => {
const loadExample = () => { const loadExample = () => {
difficulty.value = "medium"; difficulty.value = "medium";
questionCount.value = 3; questionCount.value = 3;
answerFormat.value = "单选题";
examType.value = "";
textbookChapter.value = "Starter Unit2 Keep Tidy"; textbookChapter.value = "Starter Unit2 Keep Tidy";
customTextbookChapter.value = ""; customTextbookChapter.value = "";
selectedKnowledgePoints.value = ["语法", "冠词", "不定冠词"]; selectedKnowledgePoints.value = ["语法", "冠词", "不定冠词"];
@ -397,6 +441,40 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- 试题类型 -->
<div class="config-item">
<label class="config-label">试题类型可选</label>
<div class="exam-type-group">
<button
v-for="opt in EXAM_TYPE_OPTIONS"
:key="opt.value"
class="exam-type-btn"
:class="{ active: examType === opt.value }"
@click="examType = examType === opt.value ? '' : opt.value"
:disabled="status === 'generating'"
>
{{ opt.label }}
</button>
</div>
</div>
<!-- 答题形式 -->
<div class="config-item">
<label class="config-label">答题形式</label>
<div class="answer-format-group">
<button
v-for="opt in ANSWER_FORMAT_OPTIONS"
:key="opt.value"
class="answer-format-btn"
:class="{ active: answerFormat === opt.value }"
@click="answerFormat = opt.value"
:disabled="status === 'generating'"
>
{{ opt.label }}
</button>
</div>
</div>
<!-- 教材章节 --> <!-- 教材章节 -->
<div class="config-item"> <div class="config-item">
<label class="config-label">教材章节可选</label> <label class="config-label">教材章节可选</label>
@ -431,7 +509,9 @@ onUnmounted(() => {
v-for="opt in KNOWLEDGE_POINT_OPTIONS" v-for="opt in KNOWLEDGE_POINT_OPTIONS"
:key="opt.value" :key="opt.value"
class="knowledge-btn" class="knowledge-btn"
:class="{ active: selectedKnowledgePoints.includes(opt.value) }" :class="{
active: selectedKnowledgePoints.includes(opt.value),
}"
@click="toggleKnowledgePoint(opt.value)" @click="toggleKnowledgePoint(opt.value)"
:disabled="status === 'generating'" :disabled="status === 'generating'"
> >
@ -473,7 +553,11 @@ onUnmounted(() => {
</div> </div>
<!-- 示例按钮 --> <!-- 示例按钮 -->
<button class="example-btn" @click="loadExample" :disabled="status === 'generating'"> <button
class="example-btn"
@click="loadExample"
:disabled="status === 'generating'"
>
<svg <svg
width="16" width="16"
height="16" height="16"
@ -484,7 +568,9 @@ onUnmounted(() => {
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
> >
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> <path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" /> <polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" /> <line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" /> <line x1="16" y1="17" x2="8" y2="17" />
@ -515,7 +601,9 @@ onUnmounted(() => {
> >
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /> <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg> </svg>
<span>{{ status === "generating" ? "生成中..." : "开始生成" }}</span> <span>{{
status === "generating" ? "生成中..." : "开始生成"
}}</span>
</button> </button>
<button <button
v-if="status === 'done' || status === 'error'" v-if="status === 'done' || status === 'error'"
@ -564,7 +652,10 @@ onUnmounted(() => {
status === "generating" ? "AI 正在生成..." : "生成完成" status === "generating" ? "AI 正在生成..." : "生成完成"
}}</span> }}</span>
</div> </div>
<span v-if="parsedQuestions.length > 0" class="question-count-badge"> <span
v-if="parsedQuestions.length > 0"
class="question-count-badge"
>
已生成 {{ parsedQuestions.length }} 已生成 {{ parsedQuestions.length }}
</span> </span>
</div> </div>
@ -580,7 +671,9 @@ onUnmounted(() => {
<div class="question-number">题目 {{ q.id }}</div> <div class="question-number">题目 {{ q.id }}</div>
<div class="question-meta"> <div class="question-meta">
<span class="meta-tag type-tag">{{ q.type }}</span> <span class="meta-tag type-tag">{{ q.type }}</span>
<span class="meta-tag difficulty-tag">{{ q.difficulty }}</span> <span class="meta-tag difficulty-tag">{{
q.difficulty
}}</span>
</div> </div>
</div> </div>
@ -588,7 +681,10 @@ onUnmounted(() => {
<p class="content-text">{{ q.content }}</p> <p class="content-text">{{ q.content }}</p>
</div> </div>
<div v-if="q.options && q.options.length > 0" class="options-grid"> <div
v-if="q.options && q.options.length > 0"
class="options-grid"
>
<div <div
v-for="opt in q.options" v-for="opt in q.options"
:key="opt.label" :key="opt.label"
@ -942,6 +1038,70 @@ onUnmounted(() => {
cursor: not-allowed; cursor: not-allowed;
} }
/* Answer Format Selection */
.answer-format-group {
display: flex;
gap: 0.75rem;
}
.answer-format-btn {
flex: 1;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s;
}
.answer-format-btn:hover:not(:disabled) {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.3);
}
.answer-format-btn.active {
background: rgba(16, 185, 129, 0.15);
border-color: #10b981;
color: #10b981;
}
.answer-format-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Exam Type Selection */
.exam-type-group {
display: flex;
gap: 0.75rem;
}
.exam-type-btn {
flex: 1;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s;
}
.exam-type-btn:hover:not(:disabled) {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.3);
}
.exam-type-btn.active {
background: rgba(16, 185, 129, 0.15);
border-color: #10b981;
color: #10b981;
}
.exam-type-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Range Slider */ /* Range Slider */
.range-slider { .range-slider {
width: 100%; width: 100%;