3 Commity b83c37d0b2 ... a02dd52ec2

Autor SHA1 Wiadomość Data
  FanHong a02dd52ec2 Merge branch 'dev' of http://192.168.0.3:3000/SD-SafeAI/shudao-main into dev 1 miesiąc temu
  FanHong 133588b1d2 修改考试工坊 1 miesiąc temu
  FanHong 092c3378aa 优化考试工坊生成试卷 1 miesiąc temu

+ 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

+ 143 - 32
shudao-chat-py/routers/chat.py

@@ -1,5 +1,6 @@
 from fastapi import APIRouter, Depends, Request
 from fastapi.responses import StreamingResponse, JSONResponse
+from sqlalchemy import or_
 from sqlalchemy.orm import Session
 from pydantic import BaseModel
 from typing import Optional
@@ -36,6 +37,28 @@ def _build_conversation_title(conversation: AIConversation) -> str:
     return _build_conversation_preview(conversation.content or "", limit=30)
 
 
+def _is_exam_workshop_conversation(conversation: Optional[AIConversation]) -> bool:
+    if not conversation:
+        return False
+    return conversation.business_type == 3 or bool((conversation.exam_name or "").strip())
+
+
+def _resolve_conversation_metadata(
+    conversation: Optional[AIConversation],
+    requested_business_type: int,
+    requested_exam_name: str,
+) -> tuple[int, str]:
+    requested_exam_name = (requested_exam_name or "").strip()
+
+    if _is_exam_workshop_conversation(conversation):
+        return 3, requested_exam_name or (conversation.exam_name or "").strip()
+
+    if requested_business_type == 3:
+        return 3, requested_exam_name
+
+    return requested_business_type, ""
+
+
 def _refresh_conversation_snapshot(db: Session, conversation_id: int, user_id: int) -> None:
     latest_message = (
         db.query(AIMessage)
@@ -72,7 +95,8 @@ def _refresh_conversation_snapshot(db: Session, conversation_id: int, user_id: i
         if latest_user_message and latest_user_message.content
         else latest_message.content
     )
-    preview_content = _build_conversation_preview(preview_source or "", limit=100)
+    preview_content = _build_conversation_preview(
+        preview_source or "", limit=100)
 
     db.query(AIConversation).filter(
         AIConversation.id == conversation_id,
@@ -145,6 +169,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,13 +198,33 @@ async def send_deepseek_message(
         if not message:
             return {"statusCode": 400, "msg": "消息不能为空"}
 
+        conversation_id = data.conversation_id or data.ai_conversation_id
+
+        existing_conversation = None
+        if conversation_id:
+            existing_conversation = (
+                db.query(AIConversation)
+                .filter(
+                    AIConversation.id == conversation_id,
+                    AIConversation.user_id == user.user_id,
+                    AIConversation.is_deleted == 0,
+                )
+                .first()
+            )
+
+        effective_business_type, effective_exam_name = _resolve_conversation_metadata(
+            existing_conversation,
+            data.business_type,
+            data.exam_name,
+        )
+
         # 创建或获取对话
-        if not data.conversation_id:
+        if not conversation_id:
             conversation = AIConversation(
                 user_id=user.user_id,
                 content=message[:100],
-                business_type=data.business_type,
-                exam_name=data.exam_name if data.business_type == 3 else "",
+                business_type=effective_business_type,
+                exam_name=effective_exam_name,
                 created_at=int(time.time()),
                 updated_at=int(time.time()),
                 is_deleted=0,
@@ -189,15 +234,15 @@ 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,
                 AIConversation.is_deleted == 0,
             ).update({
                 "content": message[:100],
-                "business_type": data.business_type,
-                "exam_name": data.exam_name if data.business_type == 3 else "",
+                "business_type": effective_business_type,
+                "exam_name": effective_exam_name,
                 "updated_at": int(time.time()),
             })
             db.commit()
@@ -211,7 +256,8 @@ async def send_deepseek_message(
                 intent_type = ""
                 if isinstance(intent_result, dict):
                     intent_type = (
-                        intent_result.get("intent_type") or intent_result.get("intent") or ""
+                        intent_result.get("intent_type") or intent_result.get(
+                            "intent") or ""
                     ).lower()
 
                 rag_context = ""
@@ -234,7 +280,8 @@ async def send_deepseek_message(
                 try:
                     if isinstance(qwen_response, str) and qwen_response.strip().startswith("{"):
                         response_json = json.loads(qwen_response)
-                        response_text = response_json.get("natural_language_answer", qwen_response)
+                        response_text = response_json.get(
+                            "natural_language_answer", qwen_response)
                     else:
                         response_text = qwen_response
                 except Exception:
@@ -290,9 +337,16 @@ async def send_deepseek_message(
             try:
                 system_content = (
                     "你是一个专业的考试题目生成助手,专注于路桥隧轨施工安全领域。\n"
-                    "请根据用户需求生成专业的考试题目,包括单选题、多选题、判断题等。\n"
-                    "每道题目应包含:题目内容、选项(如适用)、正确答案、解析。\n"
-                    "输出格式应为结构化的 JSON。"
+                    "请根据用户需求生成专业的考试题目,包括单选题、多选题、判断题、简答题等。\n"
+                    "用户消息中已经包含考试标题、题型要求和出题依据内容,必须以其中的出题依据内容为核心生成题目,不能脱离依据内容自由发挥。\n"
+                    "题干、选项、答案和解析都要与出题依据内容中的知识点、专业术语、操作流程、规范要求或培训主题直接相关。\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 = [
@@ -302,9 +356,41 @@ async def send_deepseek_message(
 
                 response_text = await qwen_service.chat(messages)
 
-                if data.exam_name:
+                now_ts = int(time.time())
+                user_message = AIMessage(
+                    ai_conversation_id=conv_id,
+                    user_id=user.user_id,
+                    type="user",
+                    content=message,
+                    created_at=now_ts,
+                    updated_at=now_ts,
+                    is_deleted=0,
+                )
+                db.add(user_message)
+                db.commit()
+                db.refresh(user_message)
+
+                ai_message = AIMessage(
+                    ai_conversation_id=conv_id,
+                    user_id=user.user_id,
+                    type="ai",
+                    content=response_text,
+                    prev_user_id=user_message.id,
+                    created_at=now_ts,
+                    updated_at=now_ts,
+                    is_deleted=0,
+                )
+                db.add(ai_message)
+                db.commit()
+
+                _refresh_conversation_snapshot(db, conv_id, user.user_id)
+                db.commit()
+
+                if effective_exam_name:
                     db.query(AIConversation).filter(AIConversation.id == conv_id).update(
-                        {"exam_name": data.exam_name, "updated_at": int(time.time())}
+                        {"business_type": 3,
+                            "exam_name": effective_exam_name,
+                            "updated_at": int(time.time())}
                     )
                     db.commit()
             except Exception as e:
@@ -319,9 +405,13 @@ 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,
+                "business_type": effective_business_type,
             },
         }
     except Exception as e:
@@ -381,9 +471,18 @@ async def get_history_record(
     )
 
     if business_type is not None:
-        conversations_query = conversations_query.filter(
-            AIConversation.business_type == business_type
-        )
+        if business_type == 3:
+            conversations_query = conversations_query.filter(
+                or_(
+                    AIConversation.business_type == 3,
+                    AIConversation.exam_name.isnot(None),
+                    AIConversation.exam_name != "",
+                )
+            )
+        else:
+            conversations_query = conversations_query.filter(
+                AIConversation.business_type == business_type
+            )
 
     total = conversations_query.count()
     conversations = (
@@ -457,7 +556,8 @@ async def delete_conversation(
                 AIMessage.ai_conversation_id == ai_message.ai_conversation_id,
             ).update({"is_deleted": 1, "updated_at": now_ts})
 
-        _refresh_conversation_snapshot(db, ai_message.ai_conversation_id, user.user_id)
+        _refresh_conversation_snapshot(
+            db, ai_message.ai_conversation_id, user.user_id)
         db.commit()
         return {"statusCode": 200, "msg": "删除成功"}
 
@@ -520,7 +620,8 @@ async def stream_chat(request: Request, data: StreamChatRequest):
             intent_result = await qwen_service.intent_recognition(message)
             if isinstance(intent_result, dict):
                 intent_type = (
-                    intent_result.get("intent_type") or intent_result.get("intent") or ""
+                    intent_result.get("intent_type") or intent_result.get(
+                        "intent") or ""
                 ).lower()
         except Exception as ie:
             logger.warning(f"[stream/chat] 意图识别异常: {ie}")
@@ -632,7 +733,8 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
                 else:
                     conversation = AIConversation(
                         user_id=user.user_id,
-                        content=_build_conversation_preview(message, limit=100),
+                        content=_build_conversation_preview(
+                            message, limit=100),
                         business_type=data.business_type,
                         exam_name=data.exam_name if data.business_type == 3 else "",
                         created_at=int(time.time()),
@@ -705,9 +807,10 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
                 context_parts.append(f"知识库内容:\n{rag_context}")
             if data.online_search_content:
                 context_parts.append(f"联网搜索结果:\n{data.online_search_content}")
-            
-            context_json = "\n\n".join(context_parts) if context_parts else "暂无相关知识库内容"
-            
+
+            context_json = "\n\n".join(
+                context_parts) if context_parts else "暂无相关知识库内容"
+
             # 使用prompt加载器加载最终回答prompt
             system_content = load_prompt(
                 "final_answer",
@@ -805,7 +908,8 @@ async def guess_you_want(
         try:
             # 尝试从响应中提取 JSON
             import re
-            json_match = re.search(r'\{[^{}]*"questions"[^{}]*\}', response, re.DOTALL)
+            json_match = re.search(
+                r'\{[^{}]*"questions"[^{}]*\}', response, re.DOTALL)
             if json_match:
                 response_json = json.loads(json_match.group())
             else:
@@ -882,10 +986,12 @@ async def online_search(question: str, request: Request, db: Session = Depends(g
         async with httpx.AsyncClient(timeout=30.0) as client:
             resp = await client.post(dify_config.workflow_url, headers=headers, json=payload)
             if resp.status_code != 200:
-                logger.error(f"[online_search] Dify 调用失败: {resp.status_code}, 响应: {resp.text}")
+                logger.error(
+                    f"[online_search] Dify 调用失败: {resp.status_code}, 响应: {resp.text}")
                 return {"statusCode": 500, "msg": f"搜索服务异常: {resp.status_code}"}
             result = resp.json()
-            search_text = result.get("data", {}).get("outputs", {}).get("text", "")
+            search_text = result.get("data", {}).get(
+                "outputs", {}).get("text", "")
 
         return {
             "statusCode": 200,
@@ -916,7 +1022,8 @@ async def save_online_search_result(
 
     try:
         db.query(AIMessage).filter(AIMessage.id == data.ai_message_id).update(
-            {"search_source": data.search_result, "updated_at": int(time.time())}
+            {"search_source": data.search_result,
+                "updated_at": int(time.time())}
         )
         db.commit()
         return {"statusCode": 200, "msg": "保存成功"}
@@ -952,7 +1059,8 @@ async def intent_recognition(
         response_text = ""
         if isinstance(intent_result, dict):
             intent_type = (
-                intent_result.get("intent_type") or intent_result.get("intent") or ""
+                intent_result.get("intent_type") or intent_result.get(
+                    "intent") or ""
             ).lower()
             response_text = intent_result.get("response", "")
 
@@ -1038,10 +1146,13 @@ async def get_user_recommend_question(
 ):
     """获取推荐问题(支持模糊查询)"""
     try:
-        query = db.query(RecommendQuestion).filter(RecommendQuestion.is_deleted == 0)
+        query = db.query(RecommendQuestion).filter(
+            RecommendQuestion.is_deleted == 0)
         if keyword:
-            query = query.filter(RecommendQuestion.question.like(f"%{keyword}%"))
-        questions = query.order_by(RecommendQuestion.id.desc()).limit(limit).all()
+            query = query.filter(
+                RecommendQuestion.question.like(f"%{keyword}%"))
+        questions = query.order_by(
+            RecommendQuestion.id.desc()).limit(limit).all()
 
         return {
             "statusCode": 200,

+ 108 - 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,62 @@ 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"
+        "如果出题依据内容中出现了章节、条款、培训主题或专业术语,题目必须优先考查这些内容,不能偏离到无关知识。\n"
+        "单选题、多选题、判断题和简答题的题干、选项、答案解析都要与出题依据内容直接相关,不能泛泛而谈。\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 +113,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 +137,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 +167,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()

+ 256 - 25
shudao-vue-frontend/src/views/ExamWorkshop.vue

@@ -123,7 +123,25 @@
                             <div class="slider-container">
                                 <span class="slider-label">数量</span>
                                 <input type="range" class="question-slider" v-model.number="type.questionCount" min="0" :max="type.max || 50" :disabled="isGenerating">
-                                <span class="question-count" style="text-align: right; min-width: 40px;">{{ type.questionCount }} 题</span>
+                                <div class="question-count-stepper">
+                                    <span class="question-count">{{ type.questionCount }} 题</span>
+                                    <div class="stepper-buttons">
+                                        <button
+                                            class="stepper-btn stepper-btn-up"
+                                            type="button"
+                                            @click="adjustQuestionCount(type, 1)"
+                                            :disabled="isGenerating || type.questionCount >= 99"
+                                            aria-label="增加题目数量"
+                                        ></button>
+                                        <button
+                                            class="stepper-btn stepper-btn-down"
+                                            type="button"
+                                            @click="adjustQuestionCount(type, -1)"
+                                            :disabled="isGenerating || type.questionCount <= 0"
+                                            aria-label="减少题目数量"
+                                        ></button>
+                                    </div>
+                                </div>
                             </div>
                         </div>
                     </div>
@@ -641,6 +659,12 @@ const selectedFile = ref(null);
 const isUploadingFile = ref(false);
 const fileContent = ref(''); // 存储文件内容
 const pptContentDescription = ref(''); // 存储用户输入的PPT内容描述
+const questionBasis = computed({
+  get: () => pptContentDescription.value,
+  set: (value) => {
+    pptContentDescription.value = value;
+  }
+});
 
 // 文件处理配置
 const fileConfig = reactive({
@@ -670,6 +694,21 @@ const historyData = ref([])
 const historyTotal = ref(0) // 历史记录总数
 
 
+const isExamWorkshopConversation = (conversation = {}) => {
+  const content = String(conversation.content || '')
+  const title = String(conversation.title || '')
+  const examName = String(conversation.exam_name || '')
+
+  return (
+    Number(conversation.business_type) === 3 ||
+    !!examName.trim() ||
+    title.includes('技术考核') ||
+    content.includes('请根据以下要求直接生成一份完整试卷') ||
+    content.includes('"singleChoice"') ||
+    content.includes('"totalQuestions"')
+  )
+}
+
 // 获取历史记录列表
 const getHistoryRecordList = async () => {
   try {
@@ -688,13 +727,34 @@ const getHistoryRecordList = async () => {
     console.log('📋 考试工坊历史记录列表响应:', response)
     
     if (response.statusCode === 200) {
+      const directConversations = Array.isArray(response.data) ? response.data : []
+      let conversations = [...directConversations]
+
+      const fallbackResponse = await apis.getHistoryRecord({
+        ai_conversation_id: 0
+      })
+
+      if (fallbackResponse.statusCode === 200 && Array.isArray(fallbackResponse.data)) {
+        const inferredExamConversations = fallbackResponse.data.filter(isExamWorkshopConversation)
+        const conversationMap = new Map()
+
+        directConversations.concat(inferredExamConversations).forEach((conversation) => {
+          if (!conversation?.id) return
+          conversationMap.set(conversation.id, conversation)
+        })
+
+        conversations = Array.from(conversationMap.values()).sort((a, b) => {
+          return Number(b.updated_at || 0) - Number(a.updated_at || 0)
+        })
+      }
+
       // 设置历史记录总数
-      historyTotal.value = response.total || 0
+      historyTotal.value = conversations.length
       
       // 转换后端数据为前端格式
-      historyData.value = response.data.map(conversation => ({
+      historyData.value = conversations.map(conversation => ({
         id: conversation.id,
-        title: generateConversationTitle(conversation.exam_name),
+        title: generateConversationTitle(conversation.exam_name || conversation.title || conversation.content),
         time: formatTime(conversation.updated_at),
         businessType: conversation.business_type,
         isActive: false,
@@ -961,8 +1021,9 @@ const handleHistoryItem = async (historyItem) => {
       // 解析试卷数据并恢复
       if (latestRecord && latestRecord.content) {
         try {
-          const examData = JSON.parse(latestRecord.content);
+          const examData = extractExamDataFromContent(latestRecord.content);
           restoreExamFromHistory(examData);
+          showExamDetail.value = true;
         } catch (error) {
           console.error('解析试卷数据失败:', error);
           // 如果解析失败,显示默认详情页
@@ -1056,11 +1117,16 @@ const validateQuestionCount = (type) => {
     type.questionCount = 99;
     ElMessage.warning(`${type.name}题目数量不能超过99题`);
   }
-  if (type.questionCount < 1) {
-    type.questionCount = 1;
+  if (type.questionCount < 0) {
+    type.questionCount = 0;
   }
 };
 
+const adjustQuestionCount = (type, delta) => {
+  type.questionCount = Number(type.questionCount || 0) + delta;
+  validateQuestionCount(type);
+};
+
 const generateExam = async () => {
   if (!examName.value.trim()) {
     ElMessage.warning("请输入试卷名称");
@@ -1275,7 +1341,7 @@ const fetchExamPrompt = async (mode = 'ai') => {
     examTitle: examName.value,
     totalScore: totalScore.value,
     questionTypes: normalizedQuestionTypes,
-    pptContent: selectedFile.value?.content || ''
+    pptContent: selectedFile.value?.content || questionBasis.value || ''
   };
 
   try {
@@ -1291,20 +1357,28 @@ const fetchExamPrompt = async (mode = 'ai') => {
 };
 
 // 解析AI回复
+const extractExamDataFromContent = (content) => {
+  if (!content || typeof content !== 'string') {
+    throw new Error('试卷内容为空');
+  }
+
+  const jsonMatch = content.match(/\{[\s\S]*\}/);
+  if (!jsonMatch) {
+    throw new Error('未找到有效的JSON数据');
+  }
+
+  return JSON.parse(jsonMatch[0]);
+};
+
 const parseAIExamResponse = (aiReply) => {
   try {
-    // 尝试提取JSON内容
-    const jsonMatch = aiReply.match(/\{[\s\S]*\}/);
-    if (jsonMatch) {
-      const examData = JSON.parse(jsonMatch[0]);
-      
-      // 确保所有题目都有正确的初始值
-      ensureQuestionInitialValues(examData);
-      
-      return examData;
-    } else {
-      throw new Error('未找到有效的JSON数据');
-    }
+    const examData = extractExamDataFromContent(aiReply);
+    const normalizedExam = normalizeGeneratedExam(examData);
+
+    // 确保所有题目都有正确的初始值
+    ensureQuestionInitialValues(normalizedExam);
+
+    return normalizedExam;
   } catch (error) {
     console.error('解析AI回复失败:', error);
     // 返回默认试卷结构
@@ -1312,6 +1386,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 +1710,7 @@ const generateDefaultQuestions = (type, count) => {
 
 // 更新当前试卷数据
 const updateCurrentExam = (generatedExam) => {
-  currentExam.value = {
-    ...currentExam.value,
-    ...generatedExam
-  };
+  currentExam.value = generatedExam;
 };
 
 // 返回配置页面
@@ -3900,6 +4086,51 @@ onUnmounted(() => {
         text-align: right;
     }
 
+    .question-count-stepper {
+        display: flex;
+        align-items: center;
+        justify-content: flex-end;
+        gap: 8px;
+        min-width: 76px;
+    }
+
+    .stepper-buttons {
+        display: flex;
+        flex-direction: column;
+        gap: 3px;
+    }
+
+    .stepper-btn {
+        width: 10px;
+        height: 7px;
+        padding: 0;
+        border: none;
+        outline: none;
+        appearance: none;
+        -webkit-appearance: none;
+        background: #2563eb;
+        display: block;
+        cursor: pointer;
+        transition: opacity 0.2s ease, transform 0.2s ease;
+    }
+
+    .stepper-btn-up {
+        clip-path: polygon(50% 0, 0 100%, 100% 100%);
+    }
+
+    .stepper-btn-down {
+        clip-path: polygon(0 0, 100% 0, 50% 100%);
+    }
+
+    .stepper-btn:hover:not(:disabled) {
+        transform: scale(1.08);
+    }
+
+    .stepper-btn:disabled {
+        opacity: 0.35;
+        cursor: not-allowed;
+    }
+
     .action-buttons {
         display: flex;
         justify-content: space-between;
@@ -5605,4 +5836,4 @@ onUnmounted(() => {
   font-size: 16px;
   font-weight: bold;
 }
-</style>
+</style>

+ 293 - 46
shudao-vue-frontend/src/views/mobile/m-ExamWorkshop.vue

@@ -135,15 +135,33 @@
                     </div>
                     <div class="config-item">
                       <span>一共</span>
-                      <input
+                      <div class="count-stepper">
+                        <input
                         v-model="type.questionCount"
                         type="number"
                         class="count-input-field"
-                        min="1"
+                        min="0"
                         max="99"
                         @input="validateQuestionCount(type)"
                         :disabled="isGenerating || selectedFile"
-                      />
+                        />
+                        <div class="stepper-buttons">
+                          <button
+                            class="stepper-btn stepper-btn-up"
+                            type="button"
+                            @click="adjustQuestionCount(type, 1)"
+                            :disabled="isGenerating || selectedFile || type.questionCount >= 99"
+                            aria-label="增加题目数量"
+                          ></button>
+                          <button
+                            class="stepper-btn stepper-btn-down"
+                            type="button"
+                            @click="adjustQuestionCount(type, -1)"
+                            :disabled="isGenerating || selectedFile || type.questionCount <= 0"
+                            aria-label="减少题目数量"
+                          ></button>
+                        </div>
+                      </div>
                       <span>题</span>
                     </div>
                   </div>
@@ -710,36 +728,47 @@ const createNewTask = () => {
 
 // 处理历史记录点击
 const handleHistoryItem = async (historyItem) => {
-  if (historyItem.isActive) return
+  if (isGenerating.value || isLoadingHistoryItem.value) return
   
   console.log("点击移动端考试工坊历史记录:", historyItem)
-  
-  // 设置当前点击的历史记录为激活状态
-  historyData.value.forEach((item) => {
-    item.isActive = item.id === historyItem.id
-  })
-  
-  // 关闭历史记录抽屉
-  showHistory.value = false
-  
-  // 解析历史试卷数据
   ai_conversation_id.value = historyItem.id
-  currentTime.value = historyItem.time
-  
-  // 如果有原始数据,尝试解析
-  if (historyItem.rawData && historyItem.rawData.content) {
-    try {
-      const examData = JSON.parse(historyItem.rawData.content)
-      currentExam.value = examData
-      showExamDetail.value = true
-    } catch (error) {
-      console.error('解析历史试卷数据失败:', error)
-      // 如果解析失败,显示默认详情页
-      showExamDetail.value = true
+  isLoadingHistoryItem.value = true
+
+  try {
+    historyData.value.forEach((item) => {
+      item.isActive = item.id === historyItem.id
+    })
+
+    showHistory.value = false
+
+    const response = await apis.getHistoryRecord({
+      ai_conversation_id: historyItem.id,
+      business_type: 3
+    })
+
+    if (response.statusCode === 200 && response.data && response.data.length > 0) {
+      const latestAiRecord = [...response.data].reverse().find(record => record.type === 'ai' && record.content)
+
+      if (latestAiRecord?.content) {
+        try {
+          const examData = extractExamDataFromContent(latestAiRecord.content)
+          restoreExamFromHistory(examData)
+          currentTime.value = historyItem.time
+          showExamDetail.value = true
+          return
+        } catch (error) {
+          console.error('解析移动端历史试卷数据失败:', error)
+        }
+      }
     }
-  } else {
-    // 如果没有内容,显示默认详情页
-    showExamDetail.value = true
+
+    console.error('移动端历史记录缺少可恢复的试卷详情:', response)
+    showToast('该历史记录暂无可恢复的试卷内容')
+  } catch (error) {
+    console.error('获取移动端历史记录详情失败:', error)
+    showToast('获取历史记录详情失败,请重试')
+  } finally {
+    isLoadingHistoryItem.value = false
   }
 }
 
@@ -812,6 +841,21 @@ const formatHistoryTime = (timestamp) => {
 }
 
 // 获取历史记录列表
+const isExamWorkshopConversation = (conversation = {}) => {
+  const content = String(conversation.content || '')
+  const title = String(conversation.title || '')
+  const examName = String(conversation.exam_name || '')
+
+  return (
+    Number(conversation.business_type) === 3 ||
+    !!examName.trim() ||
+    title.includes('技术考核') ||
+    content.includes('请根据以下要求直接生成一份完整试卷') ||
+    content.includes('"singleChoice"') ||
+    content.includes('"totalQuestions"')
+  )
+}
+
 const getHistoryRecordList = async () => {
   try {
     console.log('📋 开始获取移动端考试工坊历史记录列表...')
@@ -829,13 +873,34 @@ const getHistoryRecordList = async () => {
     console.log('📋 移动端历史记录列表响应:', response)
     
     if (response.statusCode === 200) {
+      const directConversations = Array.isArray(response.data) ? response.data : []
+      let conversations = [...directConversations]
+
+      const fallbackResponse = await apis.getHistoryRecord({
+        ai_conversation_id: 0
+      })
+
+      if (fallbackResponse.statusCode === 200 && Array.isArray(fallbackResponse.data)) {
+        const inferredExamConversations = fallbackResponse.data.filter(isExamWorkshopConversation)
+        const conversationMap = new Map()
+
+        directConversations.concat(inferredExamConversations).forEach((conversation) => {
+          if (!conversation?.id) return
+          conversationMap.set(conversation.id, conversation)
+        })
+
+        conversations = Array.from(conversationMap.values()).sort((a, b) => {
+          return Number(b.updated_at || 0) - Number(a.updated_at || 0)
+        })
+      }
+
       // 设置历史记录总数
-      historyTotal.value = response.total || 0
+      historyTotal.value = conversations.length
       
       // 转换后端数据为前端格式
-      historyData.value = response.data.map(conversation => ({
+      historyData.value = conversations.map(conversation => ({
         id: conversation.id,
-        title: generateConversationTitle(conversation.content),
+        title: generateConversationTitle(conversation.exam_name || conversation.title || conversation.content),
         time: formatHistoryTime(conversation.updated_at),
         businessType: conversation.business_type,
         isActive: false,
@@ -915,11 +980,16 @@ const validateQuestionCount = (type) => {
     type.questionCount = 99;
     console.warn(`${type.name}题目数量不能超过99题`);
   }
-  if (type.questionCount < 1) {
-    type.questionCount = 1;
+  if (type.questionCount < 0) {
+    type.questionCount = 0;
   }
 };
 
+const adjustQuestionCount = (type, delta) => {
+  type.questionCount = Number(type.questionCount || 0) + delta;
+  validateQuestionCount(type);
+};
+
 // 清除设置
 const clearSettings = () => {
   // 根据当前选择的工程类型设置试卷名称
@@ -1025,6 +1095,14 @@ const generateExam = async () => {
       // 显示考试详情页
       showExamDetail.value = true;
 
+      // 刷新历史记录,确保新生成的试卷详情可被立即查看
+      await getHistoryRecordList();
+      if (ai_conversation_id.value > 0) {
+        historyData.value.forEach((item) => {
+          item.isActive = item.id === ai_conversation_id.value;
+        });
+      }
+
       console.log('✅ 移动端试卷生成完成!');
 
     } else {
@@ -1070,18 +1148,25 @@ const fetchMobileExamPrompt = async (mode = 'ai') => {
 };
 
 // 解析AI考试回复
+const extractExamDataFromContent = (content) => {
+  if (!content || typeof content !== 'string') {
+    throw new Error('历史内容为空');
+  }
+
+  const directMatch = content.match(/\{[\s\S]*\}/);
+  if (!directMatch) {
+    throw new Error('未找到可解析的试卷JSON');
+  }
+
+  return JSON.parse(directMatch[0]);
+};
+
 const parseAIExamResponse = (aiReply) => {
   try {
-    // 尝试提取JSON内容
-    const jsonMatch = aiReply.match(/\{[\s\S]*\}/);
-    if (jsonMatch) {
-      const examData = JSON.parse(jsonMatch[0]);
-      // 确保所有题目都有正确的初始值
-      ensureQuestionInitialValues(examData);
-      return examData;
-    } else {
-      throw new Error('未找到有效的JSON数据');
-    }
+    const examData = extractExamDataFromContent(aiReply);
+    const normalizedExam = normalizeGeneratedExam(examData);
+    ensureQuestionInitialValues(normalizedExam);
+    return normalizedExam;
   } catch (error) {
     console.error('解析AI回复失败:', error);
     // 返回默认试卷结构
@@ -1089,6 +1174,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) => {
   // 确保单选题有正确的答案格式
@@ -1164,6 +1355,23 @@ const generateDefaultExam = () => {
   }
 };
 
+const restoreExamFromHistory = (examData) => {
+  const exam = examData?.exam || examData || {}
+  const normalizedExam = normalizeGeneratedExam(exam)
+
+  examName.value = normalizedExam.title || examName.value
+  totalScore.value = normalizedExam.totalScore || totalScore.value
+
+  questionTypes.value = [
+    { name: "单选题", scorePerQuestion: normalizedExam.singleChoice.scorePerQuestion, questionCount: normalizedExam.singleChoice.count, romanNumeral: "一" },
+    { name: "判断题", scorePerQuestion: normalizedExam.judge.scorePerQuestion, questionCount: normalizedExam.judge.count, romanNumeral: "二" },
+    { name: "多选题", scorePerQuestion: normalizedExam.multiple.scorePerQuestion, questionCount: normalizedExam.multiple.count, romanNumeral: "三" },
+    { name: "简答题", scorePerQuestion: normalizedExam.short.scorePerQuestion, questionCount: normalizedExam.short.count, romanNumeral: "四" },
+  ]
+
+  currentExam.value = normalizedExam
+}
+
 // 返回配置页面
 const backToConfig = () => {
   showExamDetail.value = false;
@@ -2069,8 +2277,8 @@ onMounted(async () => {
 
 // 监听历史记录抽屉显示状态,显示时加载数据
 watch(showHistory, async (newVal) => {
-  if (newVal && historyData.value.length === 0) {
-    console.log('📋 历史记录抽屉打开,开始加载数据...')
+  if (newVal) {
+    console.log('📋 历史记录抽屉打开,开始刷新数据...')
     await getHistoryRecordList()
   }
 })
@@ -2455,6 +2663,45 @@ watch(showHistory, async (newVal) => {
                     color: #9ca3af;
                   }
                 }
+
+                .count-stepper {
+                  display: flex;
+                  align-items: center;
+                  gap: 6px;
+                }
+
+                .stepper-buttons {
+                  display: flex;
+                  flex-direction: column;
+                  gap: 3px;
+                }
+
+                .stepper-btn {
+                  width: 10px;
+                  height: 7px;
+                  padding: 0;
+                  border: none;
+                  outline: none;
+                  appearance: none;
+                  -webkit-appearance: none;
+                  background: #3e7bfa;
+                  display: block;
+                  cursor: pointer;
+                  transition: opacity 0.2s ease, transform 0.2s ease;
+
+                  &:disabled {
+                    opacity: 0.35;
+                    cursor: not-allowed;
+                  }
+                }
+
+                .stepper-btn-up {
+                  clip-path: polygon(50% 0, 0 100%, 100% 100%);
+                }
+
+                .stepper-btn-down {
+                  clip-path: polygon(0 0, 100% 0, 50% 100%);
+                }
               }
             }
           }