Selaa lähdekoodia

优化考试工坊题型分配及试卷名自动生成功能

FanHong 2 viikkoa sitten
vanhempi
sitoutus
4c5964f27f

+ 11 - 2
shudao-chat-py/routers/chat.py

@@ -1147,9 +1147,18 @@ async def send_deepseek_message(
                 _refresh_conversation_snapshot(db, conv_id, user.user_id)
                 db.commit()
 
-                if data.exam_name:
+                generated_title = data.exam_name
+                if not generated_title:
+                    try:
+                        import json
+                        exam_data = json.loads(response_text)
+                        generated_title = exam_data.get("title") or exam_data.get("exam_name") or exam_data.get("examTitle") or exam_data.get("试卷标题") or exam_data.get("标题") or ""
+                    except Exception:
+                        pass
+
+                if generated_title:
                     db.query(AIConversation).filter(AIConversation.id == conv_id).update(
-                        {"exam_name": data.exam_name,
+                        {"exam_name": generated_title,
                             "updated_at": int(time.time())}
                     )
                     db.commit()

+ 22 - 5
shudao-chat-py/routers/exam.py

@@ -27,6 +27,7 @@ class BuildPromptRequest(BaseModel):
     totalScore: int = 0
     questionTypes: list[QuestionTypeItem] = Field(default_factory=list)
     pptContent: str = ""
+    requireBasis: bool = False
 
 
 @router.post("/exam/build_prompt")
@@ -77,12 +78,15 @@ async def build_exam_prompt(
                 f"[exam/build_prompt] pptContent truncated: original_len={len(data.pptContent)} kept_len={len(ppt_content)}"
             )
 
+    basis_field = ', "basis": "<简短的出题依据原文>"' if data.requireBasis else ''
+    basis_instruction = "【出题依据要求】:每道题必须附带一个 'basis' 字段,简短说明出题依据在原文中的原话或出处。\n" if data.requireBasis else ""
+
     prompt = (
         "请根据以下要求直接生成一份完整试卷,并严格返回纯 JSON,不要输出 markdown 代码块、解释说明或额外文字。\n"
         f"生成模式:{data.mode or '未指定'}\n"
         f"客户端:{data.client or '未指定'}\n"
         f"项目类型:{data.projectType or '未指定'}\n"
-        f"考试标题:{data.examTitle or '未命名考试'}\n"
+        f"考试标题:{data.examTitle if data.examTitle else '未提供。请你仔细阅读出题依据内容,高度凝练其核心主题(不要生硬拼凑前缀),生成一个不超过15个字的贴切的试卷名称。特别注意:如果试卷名称中包含公司或组织名称,要么完全省略不写,要么必须使用完整的全称(例如:如果原内容是“蜀道矿业集团”,必须写“蜀道矿业集团”,绝不能擅自简写为“蜀道矿业”)'}\n"
         f"总分:{data.totalScore or 0}\n"
         f"总题量:{total_count}\n"
         f"题型要求:{question_text}\n"
@@ -95,16 +99,29 @@ async def build_exam_prompt(
         "即使出题依据内容较短,也要优先围绕已有内容中的关键词、术语、场景和要求组织出题,不能因为信息少而返回空题目。\n"
         "如果某题型要求生成 3 道题,就必须生成 3 道完整可作答的题目,少于要求数量视为不合格。\n"
         "禁止输出“选项A”“题目1”“桥梁工程相关单选题1”“题目内容”“解析内容”这类占位内容,所有题目必须是可直接展示和作答的真实内容。\n"
+        "【极度重要的多选题防作弊要求】:\n"
+        "近期发现你生成的多选题中,正确答案总是偷懒按顺序排列(比如全都包含A、全都连号如AB、ABC、ABCD)!这在真实考试中是绝对不允许的。\n"
+        "你必须强制打乱正确答案的字母组合,严格遵守以下分布规则:\n"
+        " - 必须有至少 30% 的题目正确答案【完全不包含A】(如 BC, CD, BD, BCD)!\n"
+        " - 必须有至少 30% 的题目正确答案【跳跃分布】(如 AC, AD, BD, ABD, ACD)!\n"
+        " - 包含2个正确选项的题目占比应达到 40%\n"
+        " - 包含3个正确选项的题目占比应达到 40%\n"
+        " - 包含4个正确选项的题目(ABCD)绝对不能超过 20%!\n"
+        "【答案随机性要求】:\n"
+        "1. 单选题:提供4个选项(A/B/C/D),正确答案只能是其中1个,且正确答案必须在A、B、C、D中随机分布,绝不能所有题目的正确答案都相同。\n"
+        "2. 多选题:提供4个选项(A/B/C/D),正确答案的个数在2~4个之间随机,且答案组合必须随机(例如:可以是AB、AC、AD、BC、BD、CD、ABC、BCD、ABCD等),绝不能都从A开始或全都是ABCD。\n"
+        "3. 判断题:正确答案必须在“正确”和“错误”之间随机分布,绝不能所有判断题的答案全都是“正确”或全都是“错误”。\n"
+        f"{basis_instruction}"
         "下面的 JSON 结构示例只用于说明字段格式,示例中的字符串不能原样照抄到最终结果中,最终返回的每个字符串都必须替换成结合出题依据生成的具体内容。\n"
         "JSON 输出结构必须符合以下格式:\n"
         "{\n"
         '  "title": "试卷标题",\n'
         '  "totalScore": 100,\n'
         '  "totalQuestions": 10,\n'
-        '  "singleChoice": {"scorePerQuestion": 2, "totalScore": 20, "count": 10, "questions": [{"text": "<单选题题干>", "options": [{"key": "A", "text": "<选项A具体内容>"}, {"key": "B", "text": "<选项B具体内容>"}, {"key": "C", "text": "<选项C具体内容>"}, {"key": "D", "text": "<选项D具体内容>"}], "answer": "A", "analysis": "<解析内容>"}]},\n'
-        '  "judge": {"scorePerQuestion": 2, "totalScore": 0, "count": 0, "questions": [{"text": "<判断题题干>", "answer": "正确", "analysis": "<解析内容>"}]},\n'
-        '  "multiple": {"scorePerQuestion": 3, "totalScore": 0, "count": 0, "questions": [{"text": "<多选题题干>", "options": [{"key": "A", "text": "<选项A具体内容>"}, {"key": "B", "text": "<选项B具体内容>"}, {"key": "C", "text": "<选项C具体内容>"}, {"key": "D", "text": "<选项D具体内容>"}], "answers": ["A", "C"], "analysis": "<解析内容>"}]},\n'
-        '  "short": {"scorePerQuestion": 10, "totalScore": 0, "count": 0, "questions": [{"text": "<简答题题干>", "outline": {"keyFactors": "<答题要点>", "measures": "<参考措施>"}}]}\n'
+        f'  "singleChoice": {{"scorePerQuestion": 2, "totalScore": 20, "count": 10, "questions": [{{"text": "<单选题题干>", "options": [{{"key": "A", "text": "<选项A具体内容>"}}, {{"key": "B", "text": "<选项B具体内容>"}}, {{"key": "C", "text": "<选项C具体内容>"}}, {{"key": "D", "text": "<选项D具体内容>"}}], "answer": "A", "analysis": "<解析内容>"{basis_field}}}]}},\n'
+        f'  "judge": {{"scorePerQuestion": 2, "totalScore": 0, "count": 0, "questions": [{{"text": "<判断题题干>", "answer": "正确", "analysis": "<解析内容>"{basis_field}}}]}},\n'
+        f'  "multiple": {{"scorePerQuestion": 3, "totalScore": 0, "count": 0, "questions": [{{"text": "<多选题题干>", "options": [{{"key": "A", "text": "<选项A具体内容>"}}, {{"key": "B", "text": "<选项B具体内容>"}}, {{"key": "C", "text": "<选项C具体内容>"}}, {{"key": "D", "text": "<选项D具体内容>"}}], "answers": ["A", "C"], "analysis": "<解析内容>"{basis_field}}}]}},\n'
+        f'  "short": {{"scorePerQuestion": 10, "totalScore": 0, "count": 0, "questions": [{{"text": "<简答题题干>", "outline": {{"keyFactors": "<答题要点>", "measures": "<参考措施>"}}{basis_field}}}]}}\n'
         "}\n"
         "请按下面的题型配置生成对应数量的题目,没有的题型 count 返回 0、questions 返回空数组:\n"
         f"{question_schema}"

+ 60 - 44
shudao-vue-frontend/src/views/ExamWorkshop.vue

@@ -53,7 +53,7 @@
     </div>
 
     <!-- 右侧工作区域 -->
-    <div class="main-work" :style="{ background: showExamDetail ? 'transparent' : '#ebf3ff', position: 'relative' }">
+    <div class="main-work" :style="{ background: showExamDetail ? '#f5f7fa' : '#ebf3ff', position: 'relative' }">
 
       <!-- 头部 -->
       <div class="work-header" v-if="showExamDetail">
@@ -82,7 +82,7 @@
                 
                 <div class="form-group" style="position: relative;">
                     <label class="form-label">试卷名称</label>
-                    <input type="text" class="form-control" v-model="examName" maxlength="32" placeholder="请输入试卷名称..." :disabled="isGenerating">
+                    <input type="text" class="form-control" v-model="examName" maxlength="32" placeholder="请输入试卷名称(未输入将根据出题内容自动生成)..." :disabled="isGenerating">
                     <div class="char-count">{{ examName?.length || 0 }}/32</div>
                 </div>
 
@@ -116,7 +116,13 @@
                 <!-- =============== 题型配置区域 开始 =============== -->
                 <div class="config-section">
                     <div class="config-header">
-                        <h3>题型配置</h3>
+                        <div style="display: flex; align-items: center; gap: 24px;">
+                            <h3>题型配置</h3>
+                            <div class="require-basis-toggle" style="display: flex; align-items: center;">
+                                <input type="checkbox" id="requireBasis" v-model="requireBasis" :disabled="isGenerating" style="margin-right: 8px; cursor: pointer; width: 16px; height: 16px;">
+                                <label for="requireBasis" style="font-size: 14px; color: #666; cursor: pointer; user-select: none;">每题附带出题依据</label>
+                            </div>
+                        </div>
                         <div class="total-score">试卷总分 {{ calculatedTotalScore }}</div>
                     </div>
 
@@ -267,7 +273,7 @@
           <!-- 题型列表 -->
           <div class="question-sections">
             <!-- 单选题 -->
-            <div class="question-section">
+            <div v-if="currentExam.singleChoice && currentExam.singleChoice.count > 0" class="question-section">
               <div class="section-header" @click="isGenerating ? null : toggleSection('single')" :style="{ cursor: isGenerating ? 'not-allowed' : 'pointer' }">
                 <div class="section-title">
                   <span class="section-number">一</span>
@@ -334,12 +340,16 @@
                     <span class="answer-label">正确答案:</span>
                     <span class="answer-value">{{ question.selectedAnswer || '未设置' }}</span>
                   </div>
+                  <div v-if="question.basis" class="basis-section" style="margin-top: 12px; font-size: 14px; color: #666; background: #f8f9fa; padding: 10px 14px; border-radius: 6px; border-left: 3px solid #3e7bfa;">
+                    <span style="font-weight: 600; color: #374151;">出题依据:</span>
+                    <span style="line-height: 1.5;">{{ question.basis }}</span>
+                  </div>
                 </div>
               </div>
             </div>
 
             <!-- 判断题 -->
-            <div class="question-section">
+            <div v-if="currentExam.judge && currentExam.judge.count > 0" class="question-section">
               <div class="section-header" @click="isGenerating ? null : toggleSection('judge')" :style="{ cursor: isGenerating ? 'not-allowed' : 'pointer' }">
                 <div class="section-title">
                   <span class="section-number">二</span>
@@ -405,12 +415,16 @@
                     <span class="answer-label">正确答案:</span>
                     <span class="answer-value">{{ question.selectedAnswer || '未设置' }}</span>
                   </div>
+                  <div v-if="question.basis" class="basis-section" style="margin-top: 12px; font-size: 14px; color: #666; background: #f8f9fa; padding: 10px 14px; border-radius: 6px; border-left: 3px solid #3e7bfa;">
+                    <span style="font-weight: 600; color: #374151;">出题依据:</span>
+                    <span style="line-height: 1.5;">{{ question.basis }}</span>
+                  </div>
                 </div>
               </div>
             </div>
 
             <!-- 多选题 -->
-            <div class="question-section">
+            <div v-if="currentExam.multiple && currentExam.multiple.count > 0" class="question-section">
               <div class="section-header" @click="isGenerating ? null : toggleSection('multiple')" :style="{ cursor: isGenerating ? 'not-allowed' : 'pointer' }">
                 <div class="section-title">
                   <span class="section-number">三</span>
@@ -477,12 +491,16 @@
                     <span class="answer-label">正确答案:</span>
                     <span class="answer-value">{{ (question.selectedAnswers || []).join(', ') || '未设置' }}</span>
                   </div>
+                  <div v-if="question.basis" class="basis-section" style="margin-top: 12px; font-size: 14px; color: #666; background: #f8f9fa; padding: 10px 14px; border-radius: 6px; border-left: 3px solid #3e7bfa;">
+                    <span style="font-weight: 600; color: #374151;">出题依据:</span>
+                    <span style="line-height: 1.5;">{{ question.basis }}</span>
+                  </div>
                 </div>
               </div>
             </div>
 
             <!-- 简答题 -->
-            <div class="question-section">
+            <div v-if="currentExam.short && currentExam.short.count > 0" class="question-section">
               <div class="section-header" @click="isGenerating ? null : toggleSection('short')" :style="{ cursor: isGenerating ? 'not-allowed' : 'pointer' }">
                 <div class="section-title">
                   <span class="section-number">四</span>
@@ -534,6 +552,10 @@
                       </div>
                     </div>
                   </div>
+                  <div v-if="question.basis" class="basis-section" style="margin-top: 12px; font-size: 14px; color: #666; background: #f8f9fa; padding: 10px 14px; border-radius: 6px; border-left: 3px solid #3e7bfa;">
+                    <span style="font-weight: 600; color: #374151;">出题依据:</span>
+                    <span style="line-height: 1.5;">{{ question.basis }}</span>
+                  </div>
                 </div>
               </div>
             </div>
@@ -661,8 +683,9 @@ import attachmentIcon from '@/assets/Chat/9.png'
 // 响应式数据
 const selectedFunction = ref("ai");
 const selectedProjectType = ref("bridge");
-const examName = ref("桥梁工程施工技术考核");
+const examName = ref("");
 const totalScore = ref(100);
+const requireBasis = ref(false);
 const showExamDetail = ref(false);
 const currentTime = ref("");
 
@@ -1038,21 +1061,10 @@ const selectFunction = (functionType) => {
 const selectProjectType = (typeKey) => {
   selectedProjectType.value = typeKey;
   console.log("选择工程类型:", projectTypes[typeKey].name);
-  
-  // 自动更新试卷名称
-  const projectTypeName = projectTypes[typeKey].name;
-  examName.value = `${projectTypeName}工程施工技术考核`;
-  
-  // 同时更新当前试卷的标题
-  if (currentExam.value) {
-    currentExam.value.title = examName.value;
-  }
 };
 
 const clearSettings = () => {
-  // 根据当前选择的工程类型设置试卷名称
-  const projectTypeName = projectTypes[selectedProjectType.value].name;
-  examName.value = `${projectTypeName}工程施工技术考核`;
+  examName.value = "";
   totalScore.value = 100; // 清空时配置总分恢复默认值 100
   // 保留原数组引用,更新每个对象的属性,避免破坏 Vue 3 响应式绑定
   questionTypes.value.forEach(type => {
@@ -1109,16 +1121,6 @@ const adjustQuestionCount = (type, delta) => {
 };
 
 const generateExam = async () => {
-  if (!examName.value.trim()) {
-    ElMessage.warning("请输入试卷名称");
-    return;
-  }
-
-  if (examName.value.trim().length === 0) {
-    ElMessage.warning("试卷名称不能为空");
-    return;
-  }
-
   // 检查总分是否超过限制
   if (totalScore.value > 1000) {
     ElMessage.warning("试卷总分不能超过1000分");
@@ -1325,7 +1327,8 @@ const fetchExamPrompt = async (mode = 'ai') => {
     examTitle: examName.value,
     totalScore: totalScore.value,
     questionTypes: normalizedQuestionTypes,
-    pptContent: finalContentBasis
+    pptContent: finalContentBasis,
+    requireBasis: requireBasis.value
   };
 
   try {
@@ -1549,6 +1552,7 @@ const normalizeQuestions = (questions = [], sectionKey) => {
         options: normalizeOptions(question.options),
         selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || question['正确答案'] || question['答案'] || "",
         analysis: question.analysis || question.explanation || question['解析'] || "",
+        basis: question.basis || question['出题依据'] || "",
       };
     }
 
@@ -1557,6 +1561,7 @@ const normalizeQuestions = (questions = [], sectionKey) => {
         text: question.text || question.question_text || question.question || question.title || question.content || question['题干'] || question['题目'] || "",
         selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || question['正确答案'] || question['答案'] || "",
         analysis: question.analysis || question.explanation || question['解析'] || "",
+        basis: question.basis || question['出题依据'] || "",
       };
     }
 
@@ -1567,6 +1572,7 @@ const normalizeQuestions = (questions = [], sectionKey) => {
         options: normalizeOptions(question.options),
         selectedAnswers: Array.isArray(selectedAnswers) ? selectedAnswers : [selectedAnswers].filter(Boolean),
         analysis: question.analysis || question.explanation || question['解析'] || "",
+        basis: question.basis || question['出题依据'] || "",
       };
     }
 
@@ -1574,6 +1580,7 @@ const normalizeQuestions = (questions = [], sectionKey) => {
       text: question.text || question.question_text || question.question || question.title || question.content || question['题干'] || question['题目'] || "",
       outline: question.outline || question.answer_outline || question['答题要点'] || { keyFactors: question.answer || question['答案'] || "答题要点、关键因素、示例答案" },
       analysis: question.analysis || question.explanation || question['解析'] || "",
+      basis: question.basis || question['出题依据'] || "",
     };
   });
 };
@@ -1834,6 +1841,10 @@ const generateDefaultQuestions = (type, count) => {
 // 更新当前试卷数据
 const updateCurrentExam = (generatedExam) => {
   currentExam.value = generatedExam;
+  // 如果用户没有输入试卷名称,自动使用生成的试卷名称
+  if (!examName.value.trim() && generatedExam.title) {
+    examName.value = generatedExam.title;
+  }
 };
 
 // 返回配置页面
@@ -3804,8 +3815,8 @@ onUnmounted(() => {
 
 /* 工作头部 */
 .work-header {
-  background: transparent;
-  padding: 40px 0px 0px 18px;
+  background: white; /* 恢复工作头部的白色背景 */
+  padding: 40px 0px 20px 18px; /* 增加底部内边距,使其不那么紧凑 */
   
   h2 {
     margin: 0;
@@ -4023,10 +4034,11 @@ onUnmounted(() => {
 
     .config-header {
         display: flex;
-        justify-content: center; /* 改为靠左对齐,而不是两端对齐 */
+        justify-content: space-between; /* 改为两端对齐以实现自适应 */
         align-items: center;
-        gap: 960px; /* 控制“题型配置”和“试卷总分”之间的固定间距 */
-        margin-bottom: 6px;
+        width: 100%;
+        max-width: 1150px; /* 与输入框区域宽度保持一致 */
+        margin: 0 auto 6px auto; /* 居中并保持底部间距 */
     }
 
     .config-header h3 {
@@ -4985,34 +4997,38 @@ onUnmounted(() => {
   }
 
 
-/* 固定布局,支持横向滚动 */
+/* 界面外侧尺寸自适应 */
 .chat-container {
-  min-width: 1428px;
+  min-width: 0;
+  width: 100%;
 }
 
 .work-content {
-  min-width: 1428px;
+  min-width: 0;
+  width: 100%;
   overflow-x: auto;
+  background: white; /* 恢复原有的白色背景 */
   
   &.exam-detail-mode {
-    background: transparent;
+    background: #f5f7fa; /* 详情模式使用浅灰色背景,避免变黑 */
   }
 }
 
 .exam-workshop-card {
-  min-width: 1428px; /* 与AI写作保持一致 */
+  min-width: 0; /* 移除与AI写作保持一致的固定宽度 */
+  width: 100%;
 }
 
 /* 考试详情页样式 */
 .exam-detail-card {
-  // background: white;
+  background: white; /* 恢复被注释掉的白色背景 */
   width: 100%;
   min-height: 800px;
-  // padding: 32px;
+  padding: 32px; /* 恢复被注释掉的内边距 */
   border-radius: 16px;
   // box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
   max-width: 1528px;
-  min-width: 1428px;
+  min-width: 0;
 
   .detail-header {
     display: flex;

+ 7 - 24
shudao-vue-frontend/src/views/mobile/m-ExamWorkshop.vue

@@ -79,7 +79,7 @@
                     <input
                       v-model="examName"
                       type="text"
-                      placeholder="请输入试卷名称"
+                      placeholder="未输入将根据出题内容自动生成"
                       class="config-input"
                       maxlength="32"
                       @input="validateExamName"
@@ -598,7 +598,7 @@ const showDownloadMenu = ref(false) // 控制下载菜单显示状态
 // 试卷配置状态
 const selectedFunction = ref("ai")
 const selectedProjectType = ref("bridge")
-const examName = ref("桥梁工程施工技术考核")
+const examName = ref("")
 const totalScore = ref(100)
 const currentTime = ref("")
 
@@ -887,15 +887,6 @@ const selectFunction = (functionType) => {
 const selectProjectType = (typeKey) => {
   selectedProjectType.value = typeKey;
   console.log("选择工程类型:", projectTypes[typeKey].name);
-  
-  // 自动更新试卷名称
-  const projectTypeName = projectTypes[typeKey].name;
-  examName.value = projectTypeName + '工程施工技术考核';
-  
-  // 同时更新当前试卷的标题
-  if (currentExam.value) {
-    currentExam.value.title = examName.value;
-  }
 };
 
 // 验证试卷名称
@@ -947,9 +938,7 @@ const adjustQuestionCount = (type, delta) => {
 
 // 清除设置
 const clearSettings = () => {
-  // 根据当前选择的工程类型设置试卷名称
-  const projectTypeName = projectTypes[selectedProjectType.value].name;
-  examName.value = projectTypeName + '工程施工技术考核';
+  examName.value = "";
   totalScore.value = 100;
   questionTypes.value = [
     { name: "单选题", scorePerQuestion: 2, questionCount: 8, romanNumeral: "一" },
@@ -962,16 +951,6 @@ const clearSettings = () => {
 
 // 生成试卷
 const generateExam = async () => {
-  if (!examName.value.trim()) {
-    console.warn("请输入试卷名称");
-    return;
-  }
-
-  if (examName.value.trim().length === 0) {
-    console.warn("试卷名称不能为空");
-    return;
-  }
-
   // 检查总分是否超过限制
   if (totalScore.value > 1000) {
     console.warn("试卷总分不能超过1000分");
@@ -1040,6 +1019,10 @@ const generateExam = async () => {
 
       // 更新当前试卷数据
       currentExam.value = generatedExam;
+      // 如果用户没有输入试卷名称,自动使用生成的试卷名称
+      if (!examName.value.trim() && generatedExam.title) {
+        examName.value = generatedExam.title;
+      }
       currentExam.value.title = examName.value;
       currentExam.value.totalScore = totalScore.value;
       currentTime.value = new Date().toLocaleString('zh-CN');