Explorar o código

优化考试工坊生成试卷

FanHong hai 4 días
pai
achega
092c3378aa

+ 1 - 1
shudao-chat-py/.env.example

@@ -51,7 +51,7 @@ SEARCH_API_URL=http://your-search-server:port
 APP_ENV=production
 APP_DEBUG=false
 APP_HOST=0.0.0.0
-APP_PORT=22000
+APP_PORT=22001
 
 # ==================== 日志配置 ====================
 LOG_LEVEL=INFO

+ 2 - 2
shudao-chat-py/build_release.sh

@@ -15,8 +15,8 @@ FRONTEND_DIR="$PARENT_DIR/shudao-main/shudao-vue-frontend"
 BACKEND_DIR="$ROOT_DIR"
 DEPLOY_DIR="/opt/www/shudao-chat-py"
 SERVICE_NAME="shudao-chat-py"
-SERVICE_PORT=22000
-PYTHON_BIN="python3"
+SERVICE_PORT=22001
+PYTHON_BIN="python3.11"
 VENV_DIR="$DEPLOY_DIR/venv"
 
 # 1. 前端构建

+ 1 - 1
shudao-chat-py/build_test.sh

@@ -16,7 +16,7 @@ BACKEND_DIR="$ROOT_DIR"
 DEPLOY_DIR="/opt/www/shudao-chat-py-test"
 SERVICE_NAME="shudao-chat-py-test"
 SERVICE_PORT=22001
-PYTHON_BIN="python3"
+PYTHON_BIN="python3.11"
 VENV_DIR="$DEPLOY_DIR/venv"
 
 # 1. 前端构建

+ 1 - 1
shudao-chat-py/deploy/shudao-chat-py.service

@@ -12,7 +12,7 @@ Environment="PATH=/opt/www/shudao-chat-py/venv/bin"
 # 启动命令
 ExecStart=/opt/www/shudao-chat-py/venv/bin/python -m uvicorn main:app \
     --host 0.0.0.0 \
-    --port 22000 \
+    --port 22001 \
     --workers 4
 
 # 重启策略

+ 2 - 0
shudao-chat-py/requirements.txt

@@ -1,3 +1,5 @@
+# Python 3.11 required
+# Recommended: python3.11 -m venv venv && source venv/bin/activate
 fastapi==0.115.0
 uvicorn[standard]==0.32.0
 sqlalchemy==2.0.36

+ 17 - 5
shudao-chat-py/routers/chat.py

@@ -145,6 +145,7 @@ def _build_history_messages(conv_id: int, limit: int = 10) -> list:
 class SendMessageRequest(BaseModel):
     message: str
     conversation_id: Optional[int] = None
+    ai_conversation_id: Optional[int] = None
     business_type: int = 0  # 0=AI问答, 1=PPT大纲, 2=AI写作, 3=考试工坊
     exam_name: str = ""
     ai_message_id: int = 0
@@ -173,8 +174,10 @@ async def send_deepseek_message(
         if not message:
             return {"statusCode": 400, "msg": "消息不能为空"}
 
+        conversation_id = data.conversation_id or data.ai_conversation_id
+
         # 创建或获取对话
-        if not data.conversation_id:
+        if not conversation_id:
             conversation = AIConversation(
                 user_id=user.user_id,
                 content=message[:100],
@@ -189,7 +192,7 @@ async def send_deepseek_message(
             db.refresh(conversation)
             conv_id = conversation.id
         else:
-            conv_id = data.conversation_id
+            conv_id = conversation_id
             db.query(AIConversation).filter(
                 AIConversation.id == conv_id,
                 AIConversation.user_id == user.user_id,
@@ -290,9 +293,14 @@ async def send_deepseek_message(
             try:
                 system_content = (
                     "你是一个专业的考试题目生成助手,专注于路桥隧轨施工安全领域。\n"
-                    "请根据用户需求生成专业的考试题目,包括单选题、多选题、判断题等。\n"
-                    "每道题目应包含:题目内容、选项(如适用)、正确答案、解析。\n"
-                    "输出格式应为结构化的 JSON。"
+                    "请根据用户需求生成专业的考试题目,包括单选题、多选题、判断题、简答题等。\n"
+                    "输出必须是可直接 JSON.parse 的纯 JSON,不要包含 markdown 代码块、解释文字或额外前后缀。\n"
+                    "JSON 顶层结构必须包含 singleChoice、judge、multiple、short 四个字段。\n"
+                    "singleChoice.questions 和 multiple.questions 中每道题必须包含 text、options、answer、analysis。\n"
+                    "options 必须是数组,元素格式为 {\"key\":\"A\",\"text\":\"具体选项内容\"},禁止输出“选项A”这类占位文本。\n"
+                    "judge.questions 中每道题必须包含 text、answer、analysis。\n"
+                    "short.questions 中每道题必须包含 text、outline,其中 outline 至少包含 keyFactors。\n"
+                    "所有题目内容、选项内容、答案和解析都要结合用户给出的工程类型、题型数量、分值和课件内容具体生成。"
                 )
 
                 messages = [
@@ -319,7 +327,11 @@ async def send_deepseek_message(
             "msg": "success",
             "data": {
                 "conversation_id": conv_id,
+                "ai_conversation_id": conv_id,
                 "response": response_text,
+                "reply": response_text,
+                "content": response_text,
+                "message": response_text,
                 "user_id": user.user_id,
                 "business_type": data.business_type,
             },

+ 104 - 27
shudao-chat-py/routers/exam.py

@@ -1,20 +1,31 @@
 from fastapi import APIRouter, Depends, Request
 from sqlalchemy.orm import Session
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
 from typing import Optional
 from database import get_db
 from models.chat import AIMessage
 from services.qwen_service import qwen_service
-import time
 
 router = APIRouter()
 
 
+class QuestionTypeItem(BaseModel):
+    questionType: str = ""
+    name: str = ""
+    count: int = 0
+    questionCount: int = 0
+    scorePerQuestion: int = 0
+    romanNumeral: str = ""
+
+
 class BuildPromptRequest(BaseModel):
-    exam_type: str
-    topic: str
-    difficulty: str
-    question_count: int
+    mode: str = ""
+    client: str = ""
+    projectType: str = ""
+    examTitle: str = ""
+    totalScore: int = 0
+    questionTypes: list[QuestionTypeItem] = Field(default_factory=list)
+    pptContent: str = ""
 
 
 @router.post("/exam/build_prompt")
@@ -23,12 +34,58 @@ async def build_exam_prompt(
     data: BuildPromptRequest,
     db: Session = Depends(get_db)
 ):
-    """生成考试提示词 - 对齐Go版本函数名"""
+    """根据前端考试工坊参数生成提示词"""
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
-    prompt = f"""请生成{data.question_count}道关于{data.topic}的{data.exam_type},难度为{data.difficulty}。"""
+
+    question_desc = []
+    total_count = 0
+    for item in data.questionTypes:
+        count = item.count or item.questionCount or 0
+        score = item.scorePerQuestion or 0
+        qtype = item.questionType or item.name or "未命名题型"
+        total_count += count
+        question_desc.append(f"{qtype}{count}道,每道{score}分")
+
+    question_text = ";".join(question_desc) if question_desc else "题型未提供"
+    question_schema_lines = []
+    for item in data.questionTypes:
+        count = item.count or item.questionCount or 0
+        score = item.scorePerQuestion or 0
+        qtype = item.questionType or item.name or "未命名题型"
+        if count <= 0:
+            continue
+        question_schema_lines.append(f"- {qtype}: {count}道,每道{score}分")
+
+    question_schema = "\n".join(question_schema_lines) if question_schema_lines 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.totalScore or 0}\n"
+        f"总题量:{total_count}\n"
+        f"题型要求:{question_text}\n"
+        f"课件内容:{data.pptContent or '无'}\n"
+        "请结合课件内容、工程类型和题型要求,生成有具体内容、具体选项、具体答案、具体解析的试卷。\n"
+        "禁止输出“选项A”“题目1”这类占位内容,所有题目必须是可直接展示和作答的真实内容。\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": "具体选项内容"}, {"key": "B", "text": "具体选项内容"}, {"key": "C", "text": "具体选项内容"}, {"key": "D", "text": "具体选项内容"}], "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": "具体选项内容"}, {"key": "B", "text": "具体选项内容"}, {"key": "C", "text": "具体选项内容"}, {"key": "D", "text": "具体选项内容"}], "answers": ["A", "C"], "analysis": "解析内容"}]},\n'
+        '  "short": {"scorePerQuestion": 10, "totalScore": 0, "count": 0, "questions": [{"text": "题目内容", "outline": {"keyFactors": "答题要点", "measures": "参考措施"}}]}\n'
+        "}\n"
+        "请按下面的题型配置生成对应数量的题目,没有的题型 count 返回 0、questions 返回空数组:\n"
+        f"{question_schema}"
+    )
+
     return {
         "statusCode": 200,
         "msg": "success",
@@ -52,7 +109,7 @@ async def build_single_question_prompt(
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
+
     prompt = f"""请生成1道关于{data.topic}的{data.question_type},难度为{data.difficulty}。"""
     return {
         "statusCode": 200,
@@ -76,23 +133,24 @@ async def re_modify_question(
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
+
     # 修改ai_message表中type='ai'的content
     result = db.query(AIMessage).filter(
         AIMessage.ai_conversation_id == data.ai_conversation_id,
         AIMessage.type == 'ai'
     ).update({"content": data.content})
-    
+
     if result == 0:
         return {"statusCode": 404, "msg": "消息不存在"}
-    
+
     db.commit()
     return {"statusCode": 200, "msg": "success"}
 
 
 class ReproduceSingleQuestionRequest(BaseModel):
-    ai_conversation_id: int
-    regenerate_reason: str
+    message: str = ""
+    ai_conversation_id: Optional[int] = None
+    regenerate_reason: str = ""
 
 
 @router.post("/re_produce_single_question")
@@ -105,22 +163,41 @@ async def re_produce_single_question(
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
-    # 获取原消息
-    message = db.query(AIMessage).filter(
-        AIMessage.ai_conversation_id == data.ai_conversation_id,
-        AIMessage.type == 'ai'
-    ).first()
-    
-    if not message:
-        return {"statusCode": 404, "msg": "消息不存在"}
-    
-    new_question = f"重新生成的题目(原因:{data.regenerate_reason})"
+
+    prompt = (data.message or "").strip()
+
+    # 兼容旧版调用:未传 message 时,尝试根据会话和重生成原因构造提示词。
+    if not prompt and data.ai_conversation_id:
+        message = db.query(AIMessage).filter(
+            AIMessage.ai_conversation_id == data.ai_conversation_id,
+            AIMessage.type == 'ai'
+        ).first()
+
+        if not message:
+            return {"statusCode": 404, "msg": "消息不存在"}
+
+        prompt = (message.content or "").strip()
+        if data.regenerate_reason:
+            prompt = f"{prompt}\n\n请根据以下要求重新生成:{data.regenerate_reason}"
+
+    if not prompt:
+        return {"statusCode": 400, "msg": "缺少生成内容"}
+
+    try:
+        new_question = await qwen_service.chat([
+            {"role": "user", "content": prompt}
+        ])
+    except Exception as e:
+        return {"statusCode": 500, "msg": f"AI生成失败: {str(e)}"}
+
     return {
         "statusCode": 200,
         "msg": "success",
         "data": {
             "ai_conversation_id": data.ai_conversation_id,
-            "new_question": new_question
+            "new_question": new_question,
+            "reply": new_question,
+            "content": new_question,
+            "message": new_question
         }
     }

+ 12 - 12
shudao-chat-py/tests/test_report_compat.md

@@ -4,7 +4,7 @@
 测试 shudao-chat-py 中新增的报告兼容接口,这些接口完全对齐 Go 版本的实现。
 
 ## 测试环境
-- 基础URL: `http://127.0.0.1:22000`
+- 基础URL: `http://127.0.0.1:22001`
 - AIChat服务: `http://127.0.0.1:28002`
 
 ## 1. 完整报告生成流程(SSE)
@@ -12,7 +12,7 @@
 ### 1.1 使用外部 Token(代理到 aichat)
 
 ```bash
-curl -X POST "http://127.0.0.1:22000/apiv1/report/complete-flow" \
+curl -X POST "http://127.0.0.1:22001/apiv1/report/complete-flow" \
   -H "Content-Type: application/json" \
   -H "token: YOUR_EXTERNAL_TOKEN" \
   -d '{
@@ -33,7 +33,7 @@ curl -X POST "http://127.0.0.1:22000/apiv1/report/complete-flow" \
 ### 1.2 使用本地 Token(降级到本地流式聊天)
 
 ```bash
-curl -X POST "http://127.0.0.1:22000/apiv1/report/complete-flow" \
+curl -X POST "http://127.0.0.1:22001/apiv1/report/complete-flow" \
   -H "Content-Type: application/json" \
   -H "token: YOUR_LOCAL_TOKEN" \
   -d '{
@@ -54,7 +54,7 @@ curl -X POST "http://127.0.0.1:22000/apiv1/report/complete-flow" \
 ### 1.3 空问题验证
 
 ```bash
-curl -X POST "http://127.0.0.1:22000/apiv1/report/complete-flow" \
+curl -X POST "http://127.0.0.1:22001/apiv1/report/complete-flow" \
   -H "Content-Type: application/json" \
   -H "token: YOUR_TOKEN" \
   -d '{
@@ -76,7 +76,7 @@ data: {"type": "completed"}
 ### 2.1 使用外部 Token(代理到 aichat)
 
 ```bash
-curl -X POST "http://127.0.0.1:22000/apiv1/report/update-ai-message" \
+curl -X POST "http://127.0.0.1:22001/apiv1/report/update-ai-message" \
   -H "Content-Type: application/json" \
   -H "token: YOUR_EXTERNAL_TOKEN" \
   -d '{
@@ -96,7 +96,7 @@ curl -X POST "http://127.0.0.1:22000/apiv1/report/update-ai-message" \
 ### 2.2 使用本地 Token(本地处理)
 
 ```bash
-curl -X POST "http://127.0.0.1:22000/apiv1/report/update-ai-message" \
+curl -X POST "http://127.0.0.1:22001/apiv1/report/update-ai-message" \
   -H "Content-Type: application/json" \
   -H "token: YOUR_LOCAL_TOKEN" \
   -d '{
@@ -112,7 +112,7 @@ curl -X POST "http://127.0.0.1:22000/apiv1/report/update-ai-message" \
 ### 2.3 无效 ID 验证
 
 ```bash
-curl -X POST "http://127.0.0.1:22000/apiv1/report/update-ai-message" \
+curl -X POST "http://127.0.0.1:22001/apiv1/report/update-ai-message" \
   -H "Content-Type: application/json" \
   -H "token: YOUR_TOKEN" \
   -d '{
@@ -134,7 +134,7 @@ curl -X POST "http://127.0.0.1:22000/apiv1/report/update-ai-message" \
 ### 3.1 使用外部 Token(代理到 aichat)
 
 ```bash
-curl -X POST "http://127.0.0.1:22000/apiv1/sse/stop" \
+curl -X POST "http://127.0.0.1:22001/apiv1/sse/stop" \
   -H "Content-Type: application/json" \
   -H "token: YOUR_EXTERNAL_TOKEN" \
   -d '{
@@ -154,7 +154,7 @@ curl -X POST "http://127.0.0.1:22000/apiv1/sse/stop" \
 ### 3.2 使用本地 Token(本地处理)
 
 ```bash
-curl -X POST "http://127.0.0.1:22000/apiv1/sse/stop" \
+curl -X POST "http://127.0.0.1:22001/apiv1/sse/stop" \
   -H "Content-Type: application/json" \
   -H "token: YOUR_LOCAL_TOKEN" \
   -d '{
@@ -226,7 +226,7 @@ print(f"外部 Token: {external_token}")
 
 ```bash
 # 停止 aichat 服务后测试
-curl -X POST "http://127.0.0.1:22000/apiv1/report/complete-flow" \
+curl -X POST "http://127.0.0.1:22001/apiv1/report/complete-flow" \
   -H "Content-Type: application/json" \
   -H "token: YOUR_EXTERNAL_TOKEN" \
   -d '{
@@ -242,7 +242,7 @@ curl -X POST "http://127.0.0.1:22000/apiv1/report/complete-flow" \
 ### 6.2 请求体解析错误
 
 ```bash
-curl -X POST "http://127.0.0.1:22000/apiv1/report/complete-flow" \
+curl -X POST "http://127.0.0.1:22001/apiv1/report/complete-flow" \
   -H "Content-Type: application/json" \
   -H "token: YOUR_TOKEN" \
   -d 'invalid json'
@@ -263,7 +263,7 @@ data: {"type": "completed"}
 # 使用 Apache Bench 进行并发测试
 ab -n 100 -c 10 -p request.json -T application/json \
   -H "token: YOUR_TOKEN" \
-  http://127.0.0.1:22000/apiv1/report/complete-flow
+  http://127.0.0.1:22001/apiv1/report/complete-flow
 ```
 
 ### 7.2 长时间流式响应测试

+ 3 - 3
shudao-chat-py/utils/config.py

@@ -1,5 +1,4 @@
 from pydantic_settings import BaseSettings
-from typing import Optional
 import yaml
 from pathlib import Path
 
@@ -78,7 +77,7 @@ class Settings:
             config_file = current_dir / config_path
         else:
             config_file = Path(config_path)
-        
+
         if config_file.exists():
             with open(config_file, 'r', encoding='utf-8') as f:
                 config_data = yaml.safe_load(f)
@@ -96,7 +95,8 @@ class Settings:
         self.auth = AuthConfig(**config_data.get('auth', {}))
         self.oss = OSSConfig(**config_data.get('oss', {}))
         self.aichat = AIChatConfig(**config_data.get('aichat', {}))
-        self.base_url = config_data.get('base_url', 'https://aqai.shudaodsj.com:22000')
+        self.base_url = config_data.get(
+            'base_url', 'https://aqai.shudaodsj.com:22001')
 
 
 settings = Settings()

+ 122 - 9
shudao-vue-frontend/src/views/ExamWorkshop.vue

@@ -1297,11 +1297,12 @@ const parseAIExamResponse = (aiReply) => {
     const jsonMatch = aiReply.match(/\{[\s\S]*\}/);
     if (jsonMatch) {
       const examData = JSON.parse(jsonMatch[0]);
-      
+      const normalizedExam = normalizeGeneratedExam(examData);
+
       // 确保所有题目都有正确的初始值
-      ensureQuestionInitialValues(examData);
-      
-      return examData;
+      ensureQuestionInitialValues(normalizedExam);
+
+      return normalizedExam;
     } else {
       throw new Error('未找到有效的JSON数据');
     }
@@ -1312,6 +1313,121 @@ const parseAIExamResponse = (aiReply) => {
   }
 };
 
+const getQuestionTypeConfig = (name, fallbackScore = 0) => {
+  const config = questionTypes.value.find(type => type.name === name);
+  return {
+    scorePerQuestion: Number(config?.scorePerQuestion) || fallbackScore,
+    questionCount: Number(config?.questionCount) || 0,
+  };
+};
+
+const normalizeOptions = (options = []) => {
+  if (!Array.isArray(options)) {
+    return [];
+  }
+
+  return options.map((option, index) => {
+    if (typeof option === 'string') {
+      return {
+        key: String.fromCharCode(65 + index),
+        text: option,
+      };
+    }
+
+    return {
+      key: option?.key || String.fromCharCode(65 + index),
+      text: option?.text || option?.content || option?.label || "",
+    };
+  });
+};
+
+const normalizeQuestions = (questions = [], sectionKey) => {
+  if (!Array.isArray(questions)) {
+    return [];
+  }
+
+  return questions.map((question = {}) => {
+    if (sectionKey === 'singleChoice') {
+      return {
+        text: question.text || question.question_text || "",
+        options: normalizeOptions(question.options),
+        selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || "",
+        analysis: question.analysis || question.explanation || "",
+      };
+    }
+
+    if (sectionKey === 'judge') {
+      return {
+        text: question.text || question.question_text || "",
+        selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || "",
+        analysis: question.analysis || question.explanation || "",
+      };
+    }
+
+    if (sectionKey === 'multiple') {
+      const selectedAnswers = question.selectedAnswers || question.correct_answers || question.answers || [];
+      return {
+        text: question.text || question.question_text || "",
+        options: normalizeOptions(question.options),
+        selectedAnswers: Array.isArray(selectedAnswers) ? selectedAnswers : [selectedAnswers].filter(Boolean),
+        analysis: question.analysis || question.explanation || "",
+      };
+    }
+
+    return {
+      text: question.text || question.question_text || "",
+      outline: question.outline || question.answer_outline || { keyFactors: question.answer || "答题要点、关键因素、示例答案" },
+      analysis: question.analysis || question.explanation || "",
+    };
+  });
+};
+
+const normalizeSection = (rawSection, sectionKey, fallbackName, fallbackScore = 0) => {
+  const section = rawSection || {};
+  const config = getQuestionTypeConfig(fallbackName, fallbackScore);
+  const sourceQuestions = Array.isArray(section)
+    ? section
+    : (section.questions || section.items || section.question_list || []);
+  const normalizedQuestions = normalizeQuestions(sourceQuestions, sectionKey);
+  const count = Number(section.count ?? section.question_count ?? normalizedQuestions.length ?? config.questionCount) || 0;
+  const scorePerQuestion = Number(section.scorePerQuestion ?? section.score_per_question ?? config.scorePerQuestion) || 0;
+  const totalScore = Number(section.totalScore ?? section.total_score ?? (scorePerQuestion * count)) || 0;
+
+  return {
+    scorePerQuestion,
+    totalScore,
+    count,
+    questions: normalizedQuestions,
+  };
+};
+
+const normalizeGeneratedExam = (examData = {}) => {
+  const singleSource = examData.singleChoice || examData.questions?.single_choice || examData.single_choice;
+  const judgeSource = examData.judge || examData.questions?.judge;
+  const multipleSource = examData.multiple || examData.questions?.multiple;
+  const shortSource = examData.short || examData.questions?.short;
+
+  const normalizedExam = {
+    title: examData.title || examData.exam_name || examName.value,
+    totalScore: Number(examData.totalScore ?? examData.total_score ?? totalScore.value) || 0,
+    totalQuestions: Number(examData.totalQuestions ?? examData.total_questions) || 0,
+    singleChoice: normalizeSection(singleSource, 'singleChoice', '单选题', 2),
+    judge: normalizeSection(judgeSource, 'judge', '判断题', 2),
+    multiple: normalizeSection(multipleSource, 'multiple', '多选题', 3),
+    short: normalizeSection(shortSource, 'short', '简答题', 10),
+  };
+
+  if (!normalizedExam.totalQuestions) {
+    normalizedExam.totalQuestions =
+      normalizedExam.singleChoice.count +
+      normalizedExam.judge.count +
+      normalizedExam.multiple.count +
+      normalizedExam.short.count;
+  }
+
+  return normalizedExam;
+};
+
 // 确保题目初始值正确
 const ensureQuestionInitialValues = (examData) => {
   // 单选题
@@ -1521,10 +1637,7 @@ const generateDefaultQuestions = (type, count) => {
 
 // 更新当前试卷数据
 const updateCurrentExam = (generatedExam) => {
-  currentExam.value = {
-    ...currentExam.value,
-    ...generatedExam
-  };
+  currentExam.value = generatedExam;
 };
 
 // 返回配置页面
@@ -5605,4 +5718,4 @@ onUnmounted(() => {
   font-size: 16px;
   font-weight: bold;
 }
-</style>
+</style>

+ 109 - 3
shudao-vue-frontend/src/views/mobile/m-ExamWorkshop.vue

@@ -1076,9 +1076,9 @@ const parseAIExamResponse = (aiReply) => {
     const jsonMatch = aiReply.match(/\{[\s\S]*\}/);
     if (jsonMatch) {
       const examData = JSON.parse(jsonMatch[0]);
-      // 确保所有题目都有正确的初始值
-      ensureQuestionInitialValues(examData);
-      return examData;
+      const normalizedExam = normalizeGeneratedExam(examData);
+      ensureQuestionInitialValues(normalizedExam);
+      return normalizedExam;
     } else {
       throw new Error('未找到有效的JSON数据');
     }
@@ -1089,6 +1089,112 @@ const parseAIExamResponse = (aiReply) => {
   }
 };
 
+const getQuestionTypeConfig = (index, fallbackScore = 0) => ({
+  scorePerQuestion: Number(questionTypes.value[index]?.scorePerQuestion) || fallbackScore,
+  questionCount: Number(questionTypes.value[index]?.questionCount) || 0,
+});
+
+const normalizeOptions = (options = []) => {
+  if (!Array.isArray(options)) {
+    return [];
+  }
+
+  return options.map((option, index) => {
+    if (typeof option === 'string') {
+      return {
+        key: String.fromCharCode(65 + index),
+        text: option,
+      };
+    }
+
+    return {
+      key: option?.key || String.fromCharCode(65 + index),
+      text: option?.text || option?.content || option?.label || "",
+    };
+  });
+};
+
+const normalizeQuestions = (questions = [], sectionKey) => {
+  if (!Array.isArray(questions)) {
+    return [];
+  }
+
+  return questions.map((question = {}) => {
+    if (sectionKey === 'singleChoice') {
+      return {
+        text: question.text || question.question_text || "",
+        options: normalizeOptions(question.options),
+        selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || "",
+      };
+    }
+
+    if (sectionKey === 'judge') {
+      return {
+        text: question.text || question.question_text || "",
+        selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || "",
+      };
+    }
+
+    if (sectionKey === 'multiple') {
+      const selectedAnswers = question.selectedAnswers || question.correct_answers || question.answers || [];
+      return {
+        text: question.text || question.question_text || "",
+        options: normalizeOptions(question.options),
+        selectedAnswers: Array.isArray(selectedAnswers) ? selectedAnswers : [selectedAnswers].filter(Boolean),
+      };
+    }
+
+    return {
+      text: question.text || question.question_text || "",
+      outline: question.outline || question.answer_outline || {
+        keyFactors: question.answer || "请参考相关教材和标准规范",
+        measures: "请结合实际工程案例进行解答"
+      },
+    };
+  });
+};
+
+const normalizeSection = (rawSection, sectionKey, index, fallbackScore = 0) => {
+  const section = rawSection || {};
+  const config = getQuestionTypeConfig(index, fallbackScore);
+  const sourceQuestions = Array.isArray(section)
+    ? section
+    : (section.questions || section.items || section.question_list || []);
+  const normalizedQuestions = normalizeQuestions(sourceQuestions, sectionKey);
+  const count = Number(section.count ?? section.question_count ?? normalizedQuestions.length ?? config.questionCount) || 0;
+  const scorePerQuestion = Number(section.scorePerQuestion ?? section.score_per_question ?? config.scorePerQuestion) || 0;
+  const totalScore = Number(section.totalScore ?? section.total_score ?? (scorePerQuestion * count)) || 0;
+
+  return {
+    scorePerQuestion,
+    totalScore,
+    count,
+    questions: normalizedQuestions,
+  };
+};
+
+const normalizeGeneratedExam = (examData = {}) => {
+  const normalizedExam = {
+    title: examData.title || examData.exam_name || examName.value,
+    totalScore: Number(examData.totalScore ?? examData.total_score ?? totalScore.value) || 0,
+    totalQuestions: Number(examData.totalQuestions ?? examData.total_questions) || 0,
+    singleChoice: normalizeSection(examData.singleChoice || examData.questions?.single_choice || examData.single_choice, 'singleChoice', 0, 2),
+    judge: normalizeSection(examData.judge || examData.questions?.judge, 'judge', 1, 2),
+    multiple: normalizeSection(examData.multiple || examData.questions?.multiple, 'multiple', 2, 3),
+    short: normalizeSection(examData.short || examData.questions?.short, 'short', 3, 10),
+  };
+
+  if (!normalizedExam.totalQuestions) {
+    normalizedExam.totalQuestions =
+      normalizedExam.singleChoice.count +
+      normalizedExam.judge.count +
+      normalizedExam.multiple.count +
+      normalizedExam.short.count;
+  }
+
+  return normalizedExam;
+};
+
 // 确保题目初始值正确
 const ensureQuestionInitialValues = (examData) => {
   // 确保单选题有正确的答案格式