3
0

6 Commits 1bb73f5c17 ... c64badce92

Autor SHA1 Nachricht Datum
  FanHong c64badce92 优化考试工坊前端界面 vor 3 Wochen
  FanHong 411840d09b 修复后端JSON提取BUG vor 3 Wochen
  FanHong 4b6ef6a81c Merge remote-tracking branch 'origin/server_test' into dev vor 1 Monat
  FanHong e22ed3ffaf 考试工坊后端未完成 vor 1 Monat
  FanHong 0269ad884e 优化考试工坊 vor 1 Monat
  zkn d6e9fb7870 优化响应速度 vor 1 Monat

+ 9 - 0
shudao-chat-py/main.py

@@ -121,6 +121,15 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
 app.mount("/assets", StaticFiles(directory="assets"), name="assets")
 
 
+@app.on_event("shutdown")
+async def _shutdown_clients():
+    from services.qwen_service import qwen_service
+    from services.deepseek_service import deepseek_service
+
+    await qwen_service.aclose()
+    await deepseek_service.aclose()
+
+
 
 
 @app.get("/", response_class=HTMLResponse)

+ 356 - 29
shudao-chat-py/routers/chat.py

@@ -39,6 +39,306 @@ def _build_conversation_title(conversation: AIConversation) -> str:
     return _build_conversation_preview(conversation.content or "", limit=30)
 
 
+def _extract_json_object_from_index(source: str, start_idx: int) -> str:
+    if start_idx < 0 or start_idx >= len(source) or source[start_idx] != "{":
+        return ""
+
+    depth = 0
+    in_string = False
+    escaped = False
+
+    for idx in range(start_idx, len(source)):
+        ch = source[idx]
+
+        if escaped:
+            escaped = False
+            continue
+
+        if in_string:
+            if ch == "\\":
+                escaped = True
+            elif ch == '"':
+                in_string = False
+            continue
+
+        if ch == '"':
+            in_string = True
+            continue
+
+        if ch == "{":
+            depth += 1
+        elif ch == "}":
+            depth -= 1
+            if depth == 0:
+                return source[start_idx: idx + 1]
+
+    return ""
+
+
+def _extract_balanced_json_objects(text: str) -> list[str]:
+    source = (text or "").strip()
+    if not source:
+        return []
+
+    objects = []
+    seen = set()
+    for idx, ch in enumerate(source):
+        if ch != "{":
+            continue
+        candidate = _extract_json_object_from_index(source, idx)
+        if candidate and candidate not in seen:
+            objects.append(candidate)
+            seen.add(candidate)
+
+    return objects
+
+
+def _extract_trailing_json_candidates(text: str) -> list[str]:
+    source = (text or "").strip()
+    if not source:
+        return []
+
+    candidates = []
+    seen = set()
+    line_start_indexes = [
+        match.start()
+        for match in re.finditer(r"(?m)^[ \t]*\{", source)
+    ]
+
+    for start_idx in reversed(line_start_indexes):
+        candidate = source[start_idx:].strip()
+        if candidate and candidate not in seen:
+            candidates.append(candidate)
+            seen.add(candidate)
+
+    return candidates
+
+
+def _looks_like_exam_payload(payload: object) -> bool:
+    if not isinstance(payload, dict):
+        return False
+
+    questions = payload.get("questions")
+    return any(
+        key in payload
+        for key in (
+            "singleChoice",
+            "single_choice",
+            "单选题",
+            "judge",
+            "判断题",
+            "multiple",
+            "multiple_choice",
+            "multipleChoice",
+            "多选题",
+            "short",
+            "short_answer",
+            "shortAnswer",
+            "简答题",
+        )
+    ) or (
+        isinstance(questions, dict)
+        and any(
+            key in questions
+            for key in (
+                "singleChoice",
+                "single_choice",
+                "单选题",
+                "judge",
+                "判断题",
+                "multiple",
+                "multiple_choice",
+                "multipleChoice",
+                "多选题",
+                "short",
+                "short_answer",
+                "shortAnswer",
+                "简答题",
+            )
+        )
+    )
+
+
+def _score_exam_payload_candidate(payload: object) -> int:
+    if not isinstance(payload, dict):
+        return 0
+
+    score = 0
+    questions = payload.get("questions") if isinstance(
+        payload.get("questions"), dict) else {}
+
+    strong_keys = (
+        "singleChoice",
+        "single_choice",
+        "单选题",
+        "judge",
+        "判断题",
+        "multiple",
+        "multiple_choice",
+        "multipleChoice",
+        "多选题",
+        "short",
+        "short_answer",
+        "shortAnswer",
+        "简答题",
+    )
+    weak_keys = (
+        "title",
+        "exam_name",
+        "examTitle",
+        "试卷标题",
+        "总分",
+        "totalScore",
+        "totalQuestions",
+    )
+
+    score += sum(10 for key in strong_keys if key in payload)
+    score += sum(8 for key in strong_keys if key in questions)
+    score += sum(2 for key in weak_keys if key in payload)
+
+    section_candidates = []
+    for _, value in payload.items():
+        if isinstance(value, dict):
+            section_candidates.append(value)
+    section_candidates.extend(
+        value for value in questions.values() if isinstance(value, dict))
+
+    for section in section_candidates:
+        if "questions" in section and isinstance(section.get("questions"), list):
+            score += 6
+            question_list = section.get("questions") or []
+            if question_list and isinstance(question_list[0], dict):
+                first_question = question_list[0]
+                if any(k in first_question for k in ("text", "question_text", "question", "title", "content", "题干", "题目")):
+                    score += 4
+                if "options" in first_question:
+                    score += 3
+                if any(k in first_question for k in ("answer", "answers", "correct_answer", "correct_answers", "答案", "正确答案")):
+                    score += 3
+                if any(k in first_question for k in ("analysis", "explanation", "解析")):
+                    score += 2
+        if any(k in section for k in ("count", "question_count", "数量")):
+            score += 2
+        if any(k in section for k in ("scorePerQuestion", "score_per_question", "每题分值")):
+            score += 1
+
+    return score
+
+
+def _escape_inner_quotes_in_json(text: str) -> str:
+    chars = []
+    in_string = False
+    escaped = False
+
+    for idx, ch in enumerate(text):
+        if not in_string:
+            chars.append(ch)
+            if ch == '"':
+                in_string = True
+                escaped = False
+            continue
+
+        if escaped:
+            chars.append(ch)
+            escaped = False
+            continue
+
+        if ch == "\\":
+            chars.append(ch)
+            escaped = True
+            continue
+
+        if ch == '"':
+            next_non_space = ""
+            for next_idx in range(idx + 1, len(text)):
+                if not text[next_idx].isspace():
+                    next_non_space = text[next_idx]
+                    break
+
+            if next_non_space in {",", "}", "]", ":"}:
+                chars.append(ch)
+                in_string = False
+            else:
+                chars.append('\\"')
+            continue
+
+        chars.append(ch)
+
+    return "".join(chars)
+
+
+def _try_parse_exam_json(candidate: str) -> Optional[dict]:
+    text = (candidate or "").strip()
+    if not text:
+        return None
+
+    text = (
+        text.replace("\ufeff", "")
+        .replace("```json", "")
+        .replace("```JSON", "")
+        .replace("```", "")
+        .replace("“", '"')
+        .replace("”", '"')
+    ).strip()
+
+    try:
+        parsed = json.loads(text)
+    except Exception:
+        repaired_text = _escape_inner_quotes_in_json(text)
+        repaired_text = re.sub(r",\s*([}\]])", r"\1", repaired_text)
+        try:
+            parsed = json.loads(repaired_text)
+        except Exception:
+            return None
+
+    return parsed if _looks_like_exam_payload(parsed) else None
+
+
+def _sanitize_exam_response(raw_response: str) -> str:
+    """考试工坊只向前端/数据库透传可 JSON.parse 的试卷 JSON。"""
+    raw_text = (raw_response or "").strip()
+    if not raw_text:
+        return ""
+
+    _, answer = split_thinking_and_answer(raw_text)
+    for candidate in (answer, raw_text):
+        parsed = _try_parse_exam_json(candidate)
+        if parsed:
+            return json.dumps(parsed, ensure_ascii=False)
+
+    parsed_candidates = []
+    for candidate in _extract_balanced_json_objects(raw_text):
+        parsed = _try_parse_exam_json(candidate)
+        if parsed:
+            parsed_candidates.append((parsed, candidate))
+
+    for candidate in _extract_trailing_json_candidates(raw_text):
+        parsed = _try_parse_exam_json(candidate)
+        if parsed:
+            parsed_candidates.append((parsed, candidate))
+
+    if parsed_candidates:
+        parsed_candidates.sort(
+            key=lambda item: (
+                _score_exam_payload_candidate(item[0]),
+                len(json.dumps(item[0], ensure_ascii=False)),
+            ),
+            reverse=True,
+        )
+        best_payload, best_raw_candidate = parsed_candidates[0]
+        if _score_exam_payload_candidate(best_payload) > 0:
+            return json.dumps(best_payload, ensure_ascii=False)
+        logger.warning(
+            "[exam] 已提取到JSON对象但试卷特征较弱,选择最大候选兜底: score=%s snippet=%s",
+            _score_exam_payload_candidate(best_payload),
+            (best_raw_candidate or "")[:200],
+        )
+        return json.dumps(best_payload, ensure_ascii=False)
+
+    logger.warning("[exam] 未能从模型响应中提取试卷 JSON,保留原始响应供前端兜底解析")
+    return raw_text
+
+
 def _normalize_related_question(question: str) -> str:
     if not isinstance(question, str):
         return ""
@@ -316,7 +616,8 @@ def _clean_safety_training_topic(message: str) -> str:
 def _parse_json_object(text: str) -> dict:
     if not text:
         return {}
-    cleaned = re.sub(r"```(?:json)?\s*", "", str(text)).replace("```", "").strip()
+    cleaned = re.sub(r"```(?:json)?\s*", "", str(text)
+                     ).replace("```", "").strip()
     match = re.search(r"\{.*\}", cleaned, re.DOTALL)
     if not match:
         return {}
@@ -355,11 +656,13 @@ def _normalize_safety_training_plan(message: str, raw_plan: dict) -> dict:
 
     focus = raw_plan.get("content_focus")
     if isinstance(focus, list):
-        normalized_focus = [str(item).strip() for item in focus if str(item).strip()]
+        normalized_focus = [str(item).strip()
+                            for item in focus if str(item).strip()]
         if normalized_focus:
             plan["content_focus"] = normalized_focus
     elif isinstance(focus, str) and focus.strip():
-        plan["content_focus"] = [item.strip() for item in re.split(r"[、,,;\n]", focus) if item.strip()]
+        plan["content_focus"] = [item.strip()
+                                 for item in re.split(r"[、,,;\n]", focus) if item.strip()]
 
     if "培训" not in plan["topic"]:
         plan["topic"] = f"{plan['topic']}安全培训"
@@ -412,7 +715,8 @@ async def _infer_safety_training_plan(message: str) -> dict:
         ])
         return _normalize_safety_training_plan(message, _parse_json_object(response))
     except Exception as e:
-        logger.warning(f"[safety_training] 需求整理失败,使用兜底结构: {type(e).__name__}: {e}")
+        logger.warning(
+            f"[safety_training] 需求整理失败,使用兜底结构: {type(e).__name__}: {e}")
         return _build_fallback_safety_training_plan(message)
 
 
@@ -421,9 +725,11 @@ def _clean_ai_writing_response(content: str) -> str:
     if not text:
         return ""
 
-    text = re.sub(r"```(?:html)?\s*", "", text, flags=re.IGNORECASE).replace("```", "").strip()
+    text = re.sub(r"```(?:html)?\s*", "", text,
+                  flags=re.IGNORECASE).replace("```", "").strip()
 
-    body_match = re.search(r"<body[^>]*>(.*?)</body>", text, re.IGNORECASE | re.DOTALL)
+    body_match = re.search(
+        r"<body[^>]*>(.*?)</body>", text, re.IGNORECASE | re.DOTALL)
     if body_match:
         text = body_match.group(1).strip()
 
@@ -494,7 +800,8 @@ async def _generate_ai_writing_response(message: str) -> str:
 
 async def _generate_ppt_outline_response(message: str) -> str:
     training_plan = await _infer_safety_training_plan(message)
-    generation_message = _build_safety_training_generation_message(message, training_plan)
+    generation_message = _build_safety_training_generation_message(
+        message, training_plan)
     rag_context = await _rag_search(generation_message, top_k=10)
     system_content = load_prompt(
         "ppt_outline",
@@ -676,7 +983,8 @@ async def send_deepseek_message(
                 ]
 
                 qwen_response = await qwen_service.chat(messages)
-                raw_thinking, raw_answer = split_thinking_and_answer(qwen_response)
+                raw_thinking, raw_answer = split_thinking_and_answer(
+                    qwen_response)
                 answer_source = raw_answer or qwen_response
 
                 # 兼容模型直接返回 JSON 的场景
@@ -706,8 +1014,10 @@ async def send_deepseek_message(
                 else:
                     response_text = answer_text
             except Exception as e:
-                error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
-                logger.error(f"[send_deepseek_message] AI问答异常: {type(e).__name__}: {error_detail}")
+                error_detail = str(e).strip() if str(
+                    e).strip() else f"未知错误({type(e).__name__})"
+                logger.error(
+                    f"[send_deepseek_message] AI问答异常: {type(e).__name__}: {error_detail}")
                 response_text = f"处理失败: {error_detail}"
 
         elif data.business_type == 1:
@@ -740,8 +1050,10 @@ async def send_deepseek_message(
                     },
                 }
             except Exception as e:
-                error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
-                logger.error(f"[send_deepseek_message] PPT大纲生成异常: {type(e).__name__}: {error_detail}")
+                error_detail = str(e).strip() if str(
+                    e).strip() else f"未知错误({type(e).__name__})"
+                logger.error(
+                    f"[send_deepseek_message] PPT大纲生成异常: {type(e).__name__}: {error_detail}")
                 response_text = f"处理失败: {error_detail}"
 
         elif data.business_type == 2:
@@ -774,8 +1086,10 @@ async def send_deepseek_message(
                     },
                 }
             except Exception as e:
-                error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
-                logger.error(f"[send_deepseek_message] AI写作异常: {type(e).__name__}: {error_detail}")
+                error_detail = str(e).strip() if str(
+                    e).strip() else f"未知错误({type(e).__name__})"
+                logger.error(
+                    f"[send_deepseek_message] AI写作异常: {type(e).__name__}: {error_detail}")
                 response_text = f"处理失败: {error_detail}"
 
         elif data.business_type == 3:
@@ -800,7 +1114,8 @@ async def send_deepseek_message(
                     {"role": "user", "content": message},
                 ]
 
-                response_text = await qwen_service.chat(messages)
+                raw_response_text = await qwen_service.chat(messages)
+                response_text = _sanitize_exam_response(raw_response_text)
 
                 now_ts = int(time.time())
                 user_message = AIMessage(
@@ -839,8 +1154,10 @@ async def send_deepseek_message(
                     )
                     db.commit()
             except Exception as e:
-                error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
-                logger.error(f"[send_deepseek_message] 考试工坊异常: {type(e).__name__}: {error_detail}")
+                error_detail = str(e).strip() if str(
+                    e).strip() else f"未知错误({type(e).__name__})"
+                logger.error(
+                    f"[send_deepseek_message] 考试工坊异常: {type(e).__name__}: {error_detail}")
                 response_text = f"处理失败: {error_detail}"
 
         else:
@@ -1084,7 +1401,8 @@ async def stream_chat(request: Request, data: StreamChatRequest):
             thinking_buf = ""
             in_think = False
             thinking_done = False
-            max_input_chars = getattr(settings.thinking_summary, "max_input_chars", 1500)
+            max_input_chars = getattr(
+                settings.thinking_summary, "max_input_chars", 1500)
 
             async for chunk in qwen_service.stream_chat(messages):
                 buffer += chunk
@@ -1107,13 +1425,15 @@ async def stream_chat(request: Request, data: StreamChatRequest):
                         end_idx = lower.find("</think>")
                         if end_idx == -1:
                             if max_input_chars and len(thinking_buf) < max_input_chars:
-                                thinking_buf += buffer[: max_input_chars - len(thinking_buf)]
+                                thinking_buf += buffer[: max_input_chars -
+                                                       len(thinking_buf)]
                             buffer = ""
                             break
 
                         if max_input_chars and len(thinking_buf) < max_input_chars:
                             thinking_part = buffer[:end_idx]
-                            thinking_buf += thinking_part[: max_input_chars - len(thinking_buf)]
+                            thinking_buf += thinking_part[: max_input_chars - len(
+                                thinking_buf)]
 
                         buffer = buffer[end_idx + len("</think>"):]
                         in_think = False
@@ -1329,7 +1649,8 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
                 if rag_context:
                     context_parts.append(f"??????\n{rag_context}")
                 if data.online_search_content:
-                    context_parts.append(f"???????\n{data.online_search_content}")
+                    context_parts.append(
+                        f"???????\n{data.online_search_content}")
 
                 context_json = "\n\n".join(
                     context_parts) if context_parts else "?????????"
@@ -1348,8 +1669,10 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
             # 8. 流式输出并收集完整回复
             full_response = ""
             try:
-                summary_enabled = getattr(settings.thinking_summary, "enabled", True)
-                max_input_chars = getattr(settings.thinking_summary, "max_input_chars", 1500)
+                summary_enabled = getattr(
+                    settings.thinking_summary, "enabled", True)
+                max_input_chars = getattr(
+                    settings.thinking_summary, "max_input_chars", 1500)
 
                 buffer = ""
                 pre_answer = ""
@@ -1378,22 +1701,24 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
                                     break
 
                                 pre_answer += buffer[:start_idx]
-                                buffer = buffer[start_idx + len("<think>") :]
+                                buffer = buffer[start_idx + len("<think>"):]
                                 in_think = True
                                 continue
 
                             end_idx = lower.find("</think>")
                             if end_idx == -1:
                                 if max_input_chars and len(thinking_buf) < max_input_chars:
-                                    thinking_buf += buffer[: max_input_chars - len(thinking_buf)]
+                                    thinking_buf += buffer[: max_input_chars -
+                                                           len(thinking_buf)]
                                 buffer = ""
                                 break
 
                             if max_input_chars and len(thinking_buf) < max_input_chars:
                                 thinking_part = buffer[:end_idx]
-                                thinking_buf += thinking_part[: max_input_chars - len(thinking_buf)]
+                                thinking_buf += thinking_part[: max_input_chars - len(
+                                    thinking_buf)]
 
-                            buffer = buffer[end_idx + len("</think>") :]
+                            buffer = buffer[end_idx + len("</think>"):]
                             in_think = False
                             thinking_done = True
 
@@ -1413,7 +1738,8 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
                             answer_chunk = (pre_answer + buffer).lstrip()
                             if answer_chunk:
                                 full_response += answer_chunk
-                                escaped_answer = answer_chunk.replace('\n', '\\n')
+                                escaped_answer = answer_chunk.replace(
+                                    '\n', '\\n')
                                 yield f"data: {escaped_answer}\n\n"
 
                             pre_answer = ""
@@ -1537,7 +1863,8 @@ async def guess_you_want(
             if not questions:
                 questions = ["该话题的具体应用场景?", "有哪些注意事项?", "相关案例分析?"]
 
-        questions = _finalize_related_questions(questions, ai_msg.content, limit=3)
+        questions = _finalize_related_questions(
+            questions, ai_msg.content, limit=3)
 
         guess_json = json.dumps({"questions": questions}, ensure_ascii=False)
 

+ 26 - 6
shudao-chat-py/routers/exam.py

@@ -5,6 +5,7 @@ from typing import Optional
 from database import get_db
 from models.chat import AIMessage
 from services.qwen_service import qwen_service
+from utils.logger import logger
 
 router = APIRouter()
 
@@ -61,6 +62,21 @@ async def build_exam_prompt(
     question_schema = "\n".join(
         question_schema_lines) if question_schema_lines else "- 未提供有效题型"
 
+    ppt_content = (data.pptContent or "").strip()
+    if ppt_content:
+        max_chars = 12000
+        if len(ppt_content) > max_chars:
+            head_len = max_chars // 2
+            tail_len = max_chars - head_len
+            ppt_content = (
+                ppt_content[:head_len]
+                + "\n\n(出题依据内容过长,已截断,以下为结尾片段)\n\n"
+                + ppt_content[-tail_len:]
+            )
+            logger.info(
+                f"[exam/build_prompt] pptContent truncated: original_len={len(data.pptContent)} kept_len={len(ppt_content)}"
+            )
+
     prompt = (
         "请根据以下要求直接生成一份完整试卷,并严格返回纯 JSON,不要输出 markdown 代码块、解释说明或额外文字。\n"
         f"生成模式:{data.mode or '未指定'}\n"
@@ -70,21 +86,25 @@ async def build_exam_prompt(
         f"总分:{data.totalScore or 0}\n"
         f"总题量:{total_count}\n"
         f"题型要求:{question_text}\n"
-        f"出题依据内容:{data.pptContent or '无'}\n"
+        f"出题依据内容:{ppt_content or '无'}\n"
         "出题依据内容是本次试卷的核心来源,所有题目必须围绕该内容中的知识点、术语、流程、规范要求和场景展开。\n"
         "如果出题依据内容中出现了章节、条款、培训主题或专业术语,题目必须优先考查这些内容,不能偏离到无关知识。\n"
         "单选题、多选题、判断题和简答题的题干、选项、答案解析都要与出题依据内容直接相关,不能泛泛而谈。\n"
         "请结合出题依据内容、工程类型和题型要求,生成有具体内容、具体选项、具体答案、具体解析的试卷。\n"
-        "禁止输出“选项A”“题目1”这类占位内容,所有题目必须是可直接展示和作答的真实内容。\n"
+        "凡是题型配置中 count 大于 0 的题型,必须返回对应数量的非空题目,不能返回空数组,不能少题。\n"
+        "即使出题依据内容较短,也要优先围绕已有内容中的关键词、术语、场景和要求组织出题,不能因为信息少而返回空题目。\n"
+        "如果某题型要求生成 3 道题,就必须生成 3 道完整可作答的题目,少于要求数量视为不合格。\n"
+        "禁止输出“选项A”“题目1”“桥梁工程相关单选题1”“题目内容”“解析内容”这类占位内容,所有题目必须是可直接展示和作答的真实内容。\n"
+        "下面的 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": "具体选项内容"}, {"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'
+        '  "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'
         "}\n"
         "请按下面的题型配置生成对应数量的题目,没有的题型 count 返回 0、questions 返回空数组:\n"
         f"{question_schema}"

+ 36 - 31
shudao-chat-py/services/deepseek_service.py

@@ -13,6 +13,13 @@ class DeepSeekService:
         self.api_key = settings.deepseek.api_key
         self.api_url = settings.deepseek.api_url
         self.base_url = f"{self.api_url}/v1"
+
+        self._timeout = httpx.Timeout(120.0, connect=10.0)
+        self._limits = httpx.Limits(max_connections=50, max_keepalive_connections=20)
+        self._client = httpx.AsyncClient(timeout=self._timeout, limits=self._limits)
+
+    async def aclose(self) -> None:
+        await self._client.aclose()
     
     async def chat(self, messages: list, model: str = "deepseek-chat") -> str:
         """同步聊天"""
@@ -28,15 +35,14 @@ class DeepSeekService:
         }
         
         try:
-            async with httpx.AsyncClient(timeout=120.0) as client:
-                response = await client.post(
-                    f"{self.base_url}/chat/completions",
-                    headers=headers,
-                    json=data
-                )
-                response.raise_for_status()
-                result = response.json()
-                return result['choices'][0]['message']['content']
+            response = await self._client.post(
+                f"{self.base_url}/chat/completions",
+                headers=headers,
+                json=data,
+            )
+            response.raise_for_status()
+            result = response.json()
+            return result['choices'][0]['message']['content']
         except Exception as e:
             logger.error(f"DeepSeek API 调用失败: {e}")
             raise
@@ -56,28 +62,27 @@ class DeepSeekService:
         }
         
         try:
-            async with httpx.AsyncClient(timeout=120.0) as client:
-                async with client.stream(
-                    "POST",
-                    f"{self.base_url}/chat/completions",
-                    headers=headers,
-                    json=data
-                ) as response:
-                    response.raise_for_status()
-                    async for line in response.aiter_lines():
-                        if line.startswith("data: "):
-                            data_str = line[6:]
-                            if data_str == "[DONE]":
-                                break
-                            try:
-                                data_json = json.loads(data_str)
-                                if 'choices' in data_json and len(data_json['choices']) > 0:
-                                    delta = data_json['choices'][0].get('delta', {})
-                                    content = delta.get('content', '')
-                                    if content:
-                                        yield content
-                            except json.JSONDecodeError:
-                                continue
+            async with self._client.stream(
+                "POST",
+                f"{self.base_url}/chat/completions",
+                headers=headers,
+                json=data,
+            ) as response:
+                response.raise_for_status()
+                async for line in response.aiter_lines():
+                    if line.startswith("data: "):
+                        data_str = line[6:]
+                        if data_str == "[DONE]":
+                            break
+                        try:
+                            data_json = json.loads(data_str)
+                            if 'choices' in data_json and len(data_json['choices']) > 0:
+                                delta = data_json['choices'][0].get('delta', {})
+                                content = delta.get('content', '')
+                                if content:
+                                    yield content
+                        except json.JSONDecodeError:
+                            continue
         except Exception as e:
             logger.error(f"DeepSeek 流式 API 调用失败: {e}")
             raise

+ 75 - 78
shudao-chat-py/services/qwen_service.py

@@ -3,6 +3,7 @@ Qwen AI 服务
 """
 import httpx
 import json
+import time
 from typing import AsyncGenerator
 from utils.config import settings
 from utils.logger import logger
@@ -22,6 +23,13 @@ class QwenService:
         self.intent_api_url = f"{intent_base_url}/v1/chat/completions"
         self.intent_model = settings.intent.model
 
+        self._timeout = httpx.Timeout(120.0, connect=10.0)
+        self._limits = httpx.Limits(max_connections=50, max_keepalive_connections=20)
+        self._client = httpx.AsyncClient(timeout=self._timeout, limits=self._limits)
+
+    async def aclose(self) -> None:
+        await self._client.aclose()
+
     def _should_fallback(self, status_code: int) -> bool:
         return status_code in (429, 500, 502, 503, 504)
 
@@ -119,10 +127,8 @@ class QwenService:
         normalized_target = target_url.rstrip("/")
         is_qwen3_target = normalized_target == self.api_url.rstrip("/")
         
-        # 详细请求日志
-        logger.info(f"[Qwen API] 请求 URL: {target_url}")
-        logger.info(f"[Qwen API] 使用模型: {data['model']}")
-        logger.info(f"[Qwen API] 消息数量: {len(messages)}")
+        start_at = time.monotonic()
+        logger.info(f"[Qwen API] 请求: url={target_url} model={data['model']} messages={len(messages)}")
         
         try:
             # 准备请求头
@@ -140,56 +146,49 @@ class QwenService:
                     headers["Authorization"] = f"Bearer {settings.qwen3.token}"
                     logger.info("[Qwen API] 已添加 Qwen3 API Authorization header")
             
-            async with httpx.AsyncClient(timeout=120.0) as client:
-                response = await client.post(
-                    target_url,
-                    json=data,
-                    headers=headers
-                )
-                
-                logger.info(f"[Qwen API] 响应状态码: {response.status_code}")
-                logger.info(f"[Qwen API] 响应头: {dict(response.headers)}")
-                
-                # 记录响应内容(前500字符用于调试)
-                response_preview = response.text[:500] if response.text else "(空响应)"
-                logger.info(f"[Qwen API] 响应预览: {response_preview}")
-                
-                response.raise_for_status()
-                
-                # 检查响应是否为空
-                if not response.text:
-                    logger.error("[Qwen API] 返回空响应")
-                    return ""
-                
-                # 检查是否是流式响应(以 data: 开头)
-                if response.text.startswith("data:"):
-                    logger.info("[Qwen API] 检测到流式响应,解析 SSE 格式")
-                    # 解析 SSE 格式
-                    content_parts = []
-                    for line in response.text.split('\n'):
-                        if line.startswith("data:"):
-                            data_str = line[5:].strip()
-                            if data_str and data_str != "[DONE]":
-                                try:
-                                    data_json = json.loads(data_str)
-                                    delta_content = data_json.get('choices', [{}])[0].get('delta', {}).get('content', '')
-                                    if delta_content:
-                                        content_parts.append(delta_content)
-                                except json.JSONDecodeError:
-                                    continue
-                    final_content = ''.join(content_parts)
-                    logger.info(f"[Qwen API] SSE 解析完成,内容长度: {len(final_content)}")
-                    return final_content
-                
-                # 普通 JSON 响应
-                try:
-                    result = response.json()
-                    content = result.get('response', result.get('choices', [{}])[0].get('message', {}).get('content', ''))
-                    logger.info(f"[Qwen API] JSON 解析成功,内容长度: {len(content)}")
-                    return content
-                except json.JSONDecodeError as je:
-                    logger.error(f"[Qwen API] 响应不是有效的 JSON: {response.text[:200]}")
-                    raise ValueError(f"无效的 JSON 响应: {str(je)}")
+            response = await self._client.post(
+                target_url,
+                json=data,
+                headers=headers,
+            )
+
+            elapsed_ms = int((time.monotonic() - start_at) * 1000)
+            logger.info(f"[Qwen API] 响应: status={response.status_code} elapsed_ms={elapsed_ms}")
+            logger.debug(f"[Qwen API] 响应头: {dict(response.headers)}")
+            logger.debug(f"[Qwen API] 响应预览: {(response.text[:500] if response.text else '(空响应)')}")
+
+            response.raise_for_status()
+
+            if not response.text:
+                logger.error("[Qwen API] 返回空响应")
+                return ""
+
+            if response.text.startswith("data:"):
+                logger.info("[Qwen API] 检测到流式响应,解析 SSE 格式")
+                content_parts = []
+                for line in response.text.split('\n'):
+                    if line.startswith("data:"):
+                        data_str = line[5:].strip()
+                        if data_str and data_str != "[DONE]":
+                            try:
+                                data_json = json.loads(data_str)
+                                delta_content = data_json.get('choices', [{}])[0].get('delta', {}).get('content', '')
+                                if delta_content:
+                                    content_parts.append(delta_content)
+                            except json.JSONDecodeError:
+                                continue
+                final_content = ''.join(content_parts)
+                logger.info(f"[Qwen API] SSE 解析完成,内容长度: {len(final_content)}")
+                return final_content
+
+            try:
+                result = response.json()
+                content = result.get('response', result.get('choices', [{}])[0].get('message', {}).get('content', ''))
+                logger.info(f"[Qwen API] JSON 解析成功,内容长度: {len(content)}")
+                return content
+            except json.JSONDecodeError as je:
+                logger.error(f"[Qwen API] 响应不是有效的 JSON: {response.text[:200]}")
+                raise ValueError(f"无效的 JSON 响应: {str(je)}")
                 
         except httpx.HTTPStatusError as e:
             logger.error(f"[Qwen API] HTTP 错误 - 状态码: {e.response.status_code}, URL: {target_url}")
@@ -215,30 +214,28 @@ class QwenService:
         }
         
         try:
-            async with httpx.AsyncClient(timeout=120.0) as client:
-                async with client.stream(
-                    "POST",
-                    self.api_url,
-                    json=data
-                ) as response:
-                    response.raise_for_status()
-                    async for line in response.aiter_lines():
-                        if line.startswith("data: "):
-                            data_str = line[6:]
-                            if data_str == "[DONE]":
-                                break
-                            try:
-                                data_json = json.loads(data_str)
-                                # 兼容 OpenAI 格式 choices[0].delta.content
-                                choices = data_json.get('choices', [])
-                                if choices:
-                                    content = choices[0].get('delta', {}).get('content', '') or choices[0].get('message', {}).get('content', '')
-                                else:
-                                    content = data_json.get('content', '')
-                                if content:
-                                    yield content
-                            except json.JSONDecodeError:
-                                continue
+            async with self._client.stream(
+                "POST",
+                self.api_url,
+                json=data,
+            ) as response:
+                response.raise_for_status()
+                async for line in response.aiter_lines():
+                    if line.startswith("data: "):
+                        data_str = line[6:]
+                        if data_str == "[DONE]":
+                            break
+                        try:
+                            data_json = json.loads(data_str)
+                            choices = data_json.get('choices', [])
+                            if choices:
+                                content = choices[0].get('delta', {}).get('content', '') or choices[0].get('message', {}).get('content', '')
+                            else:
+                                content = data_json.get('content', '')
+                            if content:
+                                yield content
+                        except json.JSONDecodeError:
+                            continue
         except httpx.HTTPStatusError as e:
             status_code = e.response.status_code if e.response else 0
             logger.error(f"Qwen stream HTTP error: {status_code}")

+ 26 - 0
shudao-chat-py/test_db_check.py

@@ -0,0 +1,26 @@
+import sys
+import os
+
+# Append the directory to sys.path so we can import modules
+sys.path.append("/Users/fanhong/UGIT/shudao-main/shudao-chat-py")
+
+from database import SessionLocal
+from models.chat import AIConversation, AIMessage
+
+def check_db():
+    db = SessionLocal()
+    try:
+        conv = db.query(AIConversation).filter(AIConversation.id == 11453).first()
+        if conv:
+            print("Conversation:", conv.content)
+        
+        messages = db.query(AIMessage).filter(AIMessage.ai_conversation_id == 11453).order_by(AIMessage.id).all()
+        for msg in messages:
+            print(f"\n--- {msg.type} ---")
+            print(msg.content[:500])
+            print("...")
+    finally:
+        db.close()
+
+if __name__ == "__main__":
+    check_db()

+ 47 - 0
shudao-chat-py/test_exam_payload.py

@@ -0,0 +1,47 @@
+import json
+import requests
+
+url = "http://127.0.0.1:8000/apiv1/exam/build_prompt"
+
+payload = {
+    "mode": "ai",
+    "client": "pc",
+    "projectType": "bridge",
+    "examTitle": "桥梁工程施工技术考核",
+    "totalScore": 100,
+    "questionTypes": [
+        {
+            "name": "单选题",
+            "romanNumeral": "一",
+            "questionCount": 20,
+            "scorePerQuestion": 2
+        },
+        {
+            "name": "判断题",
+            "romanNumeral": "二",
+            "questionCount": 10,
+            "scorePerQuestion": 3
+        },
+        {
+            "name": "多选题",
+            "romanNumeral": "三",
+            "questionCount": 4,
+            "scorePerQuestion": 5
+        },
+        {
+            "name": "简答题",
+            "romanNumeral": "四",
+            "questionCount": 1,
+            "scorePerQuestion": 10
+        }
+    ],
+    "pptContent": "这是桥梁施工的安全规范..."
+}
+
+headers = {
+    "Content-Type": "application/json",
+    "Authorization": "Bearer TEST"
+}
+
+# we can just print the json dumps of payload to see if pydantic parses it
+print(json.dumps(payload, ensure_ascii=False, indent=2))

+ 109 - 0
shudao-chat-py/test_exam_prompt.py

@@ -0,0 +1,109 @@
+import asyncio
+from pydantic import BaseModel, Field
+
+class QuestionTypeItem(BaseModel):
+    questionType: str = ""
+    name: str = ""
+    count: int = 0
+    questionCount: int = 0
+    scorePerQuestion: int = 0
+    romanNumeral: str = ""
+
+class BuildPromptRequest(BaseModel):
+    mode: str = ""
+    client: str = ""
+    projectType: str = ""
+    examTitle: str = ""
+    totalScore: int = 0
+    questionTypes: list[QuestionTypeItem] = Field(default_factory=list)
+    pptContent: str = ""
+
+data = BuildPromptRequest(**{
+  "mode": "ai",
+  "client": "pc",
+  "projectType": "bridge",
+  "examTitle": "桥梁工程施工技术考核",
+  "totalScore": 100,
+  "questionTypes": [
+    {
+      "name": "单选题",
+      "romanNumeral": "一",
+      "questionCount": 20,
+      "scorePerQuestion": 2
+    },
+    {
+      "name": "判断题",
+      "romanNumeral": "二",
+      "questionCount": 10,
+      "scorePerQuestion": 3
+    }
+  ],
+  "pptContent": ""
+})
+
+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 "- 未提供有效题型"
+
+ppt_content = (data.pptContent or "").strip()
+
+basis_requirement = ""
+if ppt_content:
+    basis_requirement = (
+        "出题依据内容是本次试卷的核心来源,所有题目必须围绕该内容中的知识点、术语、流程、规范要求和场景展开。\n"
+        "如果出题依据内容中出现了章节、条款、培训主题或专业术语,题目必须优先考查这些内容,不能偏离到无关知识。\n"
+        "单选题、多选题、判断题和简答题的题干、选项、答案解析都要与出题依据内容直接相关,不能泛泛而谈。\n"
+        "请结合出题依据内容、工程类型和题型要求,生成有具体内容、具体选项、具体答案、具体解析的试卷。\n"
+    )
+else:
+    basis_requirement = (
+        "请结合工程类型、考试标题和题型要求,运用路桥隧轨施工安全等专业知识,生成有具体内容、具体选项、具体答案、具体解析的高质量试卷。\n"
+        "所有题目必须与考试标题紧密相关,不能泛泛而谈。\n"
+    )
+
+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"出题依据内容:{ppt_content or '无'}\n"
+    f"{basis_requirement}"
+    "禁止输出“选项A”“题目1”“桥梁工程相关单选题1”“题目内容”“解析内容”这类占位内容,所有题目必须是可直接展示和作答的真实内容。\n"
+    "下面的 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'
+    "}\n"
+    "请按下面的题型配置生成对应数量的题目,没有的题型 count 返回 0、questions 返回空数组:\n"
+    f"{question_schema}"
+)
+
+print(prompt)

+ 43 - 0
shudao-chat-py/test_placeholder.py

@@ -0,0 +1,43 @@
+import json
+import re
+
+def _looks_like_placeholder_text(text: str) -> bool:
+    normalized = (text or "").strip()
+    if not normalized:
+        return False
+
+    placeholder_patterns = [
+        r"^题目内容$",
+        r"^解析内容$",
+        r"^参考措施$",
+        r"^答题要点$",
+        r"^具体选项内容$",
+        r"^选项[ABCD]$",
+        r"^.+工程相关(?:单选题|多选题|判断题|简答题)\d+$",
+        r"^.+相关(?:单选题|多选题|判断题|简答题)\d+$",
+        r"^第?\d+题$",
+        r"^题目\d+$",
+    ]
+    return any(re.search(pattern, normalized) for pattern in placeholder_patterns)
+
+def _exam_payload_has_placeholders(payload) -> bool:
+    if isinstance(payload, dict):
+        return any(_exam_payload_has_placeholders(value) for value in payload.values())
+    if isinstance(payload, list):
+        return any(_exam_payload_has_placeholders(item) for item in payload)
+    if isinstance(payload, str):
+        if _looks_like_placeholder_text(payload):
+            print(f"Match found for: {payload}")
+            return True
+        return False
+    return False
+
+# we can simulate the generated JSON to see which field triggers the placeholder
+mock_payload = {
+  "title": "隧道工程施工技术考核",
+  "totalScore": 100,
+  "totalQuestions": 10,
+  "singleChoice": {"scorePerQuestion": 2, "totalScore": 20, "count": 10, "questions": [{"text": "这是一个单选题题干", "options": [{"key": "A", "text": "选项A具体内容"}, {"key": "B", "text": "选项B具体内容"}], "answer": "A", "analysis": "解析内容"}]},
+}
+
+print("Has placeholder:", _exam_payload_has_placeholders(mock_payload))

+ 122 - 0
shudao-chat-py/tests/test_exam_response_sanitizer.py

@@ -0,0 +1,122 @@
+import importlib.util
+import json
+import unittest
+from pathlib import Path
+
+
+CHAT_PATH = Path(__file__).resolve().parents[1] / "routers" / "chat.py"
+spec = importlib.util.spec_from_file_location(
+    "chat_under_test_exam", CHAT_PATH)
+chat = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(chat)
+
+
+def exam_payload(title="桩基础施工技术考核"):
+    return {
+        "title": title,
+        "totalScore": 100,
+        "totalQuestions": 1,
+        "singleChoice": {
+            "scorePerQuestion": 2,
+            "totalScore": 2,
+            "count": 1,
+            "questions": [
+                {
+                    "text": "钻孔灌注桩清孔完成后应重点检查哪项指标?",
+                    "options": [
+                        {"key": "A", "text": "孔底沉渣厚度"},
+                        {"key": "B", "text": "施工便道宽度"},
+                        {"key": "C", "text": "钢筋棚颜色"},
+                        {"key": "D", "text": "围挡广告内容"},
+                    ],
+                    "answer": "A",
+                    "analysis": "孔底沉渣厚度直接影响桩端承载力。",
+                }
+            ],
+        },
+        "judge": {"scorePerQuestion": 3, "totalScore": 0, "count": 0, "questions": []},
+        "multiple": {"scorePerQuestion": 5, "totalScore": 0, "count": 0, "questions": []},
+        "short": {"scorePerQuestion": 10, "totalScore": 0, "count": 0, "questions": []},
+    }
+
+
+class ExamResponseSanitizerTests(unittest.TestCase):
+    def test_removes_thinking_process_prefix(self):
+        raw = "Thinking Process:\n\n1. Analyze the Request.\n\n" + \
+            json.dumps(exam_payload(), ensure_ascii=False)
+
+        cleaned = chat._sanitize_exam_response(raw)
+        parsed = json.loads(cleaned)
+
+        self.assertEqual(parsed["title"], "桩基础施工技术考核")
+        self.assertIn("singleChoice", parsed)
+        self.assertNotIn("Thinking Process", cleaned)
+
+    def test_extracts_json_from_markdown_code_block(self):
+        raw = "下面是生成结果:\n```json\n" + \
+            json.dumps(exam_payload("桥梁考试"), ensure_ascii=False) + "\n```"
+
+        cleaned = chat._sanitize_exam_response(raw)
+        parsed = json.loads(cleaned)
+
+        self.assertEqual(parsed["title"], "桥梁考试")
+
+    def test_prefers_exam_payload_over_other_json_noise(self):
+        raw = (
+            "Thinking Process:\n"
+            '{"note":"not exam"}\n'
+            "Final Answer:\n"
+            + json.dumps(exam_payload("最终试卷"), ensure_ascii=False)
+        )
+
+        cleaned = chat._sanitize_exam_response(raw)
+        parsed = json.loads(cleaned)
+
+        self.assertEqual(parsed["title"], "最终试卷")
+        self.assertIn("singleChoice", parsed)
+
+    def test_extracts_exam_payload_when_reasoning_contains_quotes_and_examples(self):
+        raw = (
+            'Thinking Process:\n'
+            'The output must contain "title", "totalScore", "singleChoice".\n'
+            'Use {"key": "A", "text": "..."} as the option shape example.\n'
+            'Section example: {"scorePerQuestion": 2, "totalScore": 20, "count": 10, "questions": [...]}.\n\n'
+            + json.dumps(exam_payload("带说明的最终试卷"), ensure_ascii=False)
+        )
+
+        cleaned = chat._sanitize_exam_response(raw)
+        parsed = json.loads(cleaned)
+
+        self.assertEqual(parsed["title"], "带说明的最终试卷")
+        self.assertIn("singleChoice", parsed)
+        self.assertFalse(cleaned.startswith("Thinking Process"))
+
+    def test_extracts_trailing_exam_json_after_think_suffix(self):
+        raw = (
+            "Thinking Process:\n"
+            'Use {"key": "A", "text": "..."} as example.\n'
+            "</think>\n\n"
+            + json.dumps(exam_payload("尾部试卷"), ensure_ascii=False)
+        )
+
+        cleaned = chat._sanitize_exam_response(raw)
+        parsed = json.loads(cleaned)
+
+        self.assertEqual(parsed["title"], "尾部试卷")
+        self.assertEqual(parsed["totalQuestions"], 1)
+
+    def test_repairs_unescaped_quotes_inside_string_values(self):
+        payload = json.dumps(exam_payload("引号容错"), ensure_ascii=False)
+        payload = payload.replace(
+            "钻孔灌注桩清孔完成后应重点检查哪项指标?", '钻孔灌注桩必须实行"一炮三检"制度吗?')
+        payload = payload.replace("孔底沉渣厚度直接影响桩端承载力。", '"一炮三检"是爆破作业的常见安全检查制度。')
+
+        cleaned = chat._sanitize_exam_response(payload)
+        parsed = json.loads(cleaned)
+
+        self.assertEqual(parsed["title"], "引号容错")
+        self.assertIn('"一炮三检"', parsed["singleChoice"]["questions"][0]["text"])
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 48 - 0
shudao-vue-frontend/src/utils/chatHistoryPersistence.js

@@ -82,6 +82,54 @@ export const normalizeReportsForPersistence = (reports) => {
   return dedupeReportsByFileAndScene(hydratePersistedReports(reports))
 }
 
+export const applyReportChunkToMessage = (message, streamingReports, event) => {
+  if (!message || !Array.isArray(message.reports) || !event) {
+    return null
+  }
+
+  const fileIndex = event.file_index
+  let reportIndex = streamingReports?.get?.(fileIndex)
+  if (reportIndex === undefined) {
+    reportIndex = message.reports.findIndex(report => report?.file_index === fileIndex)
+  }
+
+  const target = message.reports[reportIndex]
+  if (!target || target.type === 'category_title') {
+    return null
+  }
+
+  const partialReport = event.partial_report && typeof event.partial_report === 'object'
+    ? event.partial_report
+    : {}
+  const fields = ['display_name', 'summary', 'analysis', 'clauses']
+
+  target.report = {
+    display_name: target.report?.display_name || '',
+    summary: target.report?.summary || '',
+    analysis: target.report?.analysis || '',
+    clauses: target.report?.clauses || ''
+  }
+  target._fullContent = {
+    display_name: target._fullContent?.display_name || target.report.display_name || '',
+    summary: target._fullContent?.summary || target.report.summary || '',
+    analysis: target._fullContent?.analysis || target.report.analysis || '',
+    clauses: target._fullContent?.clauses || target.report.clauses || ''
+  }
+
+  fields.forEach((field) => {
+    if (typeof partialReport[field] === 'string') {
+      target.report[field] = partialReport[field]
+      target._fullContent[field] = partialReport[field]
+    }
+  })
+
+  target.status = target.status || 'streaming'
+  target._streamingStarted = true
+  target._rawReportChunk = `${target._rawReportChunk || ''}${event.chunk || ''}`
+
+  return target
+}
+
 const extractBalancedJson = (text) => {
   if (typeof text !== 'string') {
     return ''

+ 35 - 0
shudao-vue-frontend/src/utils/chatHistoryPersistence.test.js

@@ -5,6 +5,7 @@ import {
   buildPersistedAIMessageContent,
   dedupeReportsByFileAndScene,
   extractRelatedQuestions,
+  applyReportChunkToMessage,
   hydratePersistedReports,
   normalizeReportsForPersistence,
   shouldClearSummaryForOnlineAnswer,
@@ -12,6 +13,40 @@ import {
 } from './chatHistoryPersistence'
 
 describe('chatHistoryPersistence', () => {
+  it('applies streamed report chunks to an existing placeholder report', () => {
+    const message = {
+      reports: [
+        {
+          file_index: 1,
+          source_file: 'bridge-spec.pdf',
+          status: 'streaming',
+          report: {
+            display_name: '',
+            summary: '',
+            analysis: '',
+            clauses: ''
+          }
+        }
+      ]
+    }
+    const streamingReports = new Map([[1, 0]])
+
+    const target = applyReportChunkToMessage(message, streamingReports, {
+      file_index: 1,
+      chunk: '{"summary":"partial',
+      partial_report: {
+        display_name: 'bridge-spec',
+        summary: 'partial summary'
+      }
+    })
+
+    expect(target).toBe(message.reports[0])
+    expect(target.report.display_name).toBe('bridge-spec')
+    expect(target.report.summary).toBe('partial summary')
+    expect(target._streamingStarted).toBe(true)
+    expect(target._rawReportChunk).toBe('{"summary":"partial')
+  })
+
   it('fills report fields from _fullContent before persistence', () => {
     const reports = [
       {

+ 13 - 7
shudao-vue-frontend/src/views/Chat.vue

@@ -738,6 +738,7 @@ import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
 // import { getUserId } from '@/utils/userManager.js'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
 import {
+  applyReportChunkToMessage,
   buildAIMessageUpdatePayload,
   dedupeReportsByFileAndScene,
   extractRelatedQuestions,
@@ -3080,8 +3081,9 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       break
     
     case 'report_chunk':
-      // 不再处理report_chunk,等待完整的report消息后用打字机效果显示
-      // 这样可以避免与打字机效果冲突
+      if (applyReportChunkToMessage(aiMessage, streamingReports.value, data)) {
+        updateMessageStatus(aiMessage, 'deep_thinking')
+      }
       break
       
     case 'report':
@@ -3105,8 +3107,10 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       
       let targetReport
       if (idx !== undefined) {
+        const existingReport = aiMessage.reports[idx]
+        const hadStreamingContent = Boolean(existingReport?._streamingStarted)
         const displayCategory = reportData.metadata?.primary_category ||
-          aiMessage.reports[idx].metadata?._displayCategory ||
+          existingReport?.metadata?._displayCategory ||
           aiMessage.currentCategory
         const fullSummary = reportData.report?.summary || ''
         const fullAnalysis = reportData.report?.analysis || ''
@@ -3115,12 +3119,13 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         
         // 创建带空内容的报告对象,保留所有原始字段
         aiMessage.reports[idx] = { 
+          ...existingReport,
           ...reportData, // 保留所有原始字段,包括可能的链接字段
           report: {
             display_name: fullDisplayName, // 直接显示
-            summary: '',
-            analysis: '',
-            clauses: ''
+            summary: hadStreamingContent ? fullSummary : '',
+            analysis: hadStreamingContent ? fullAnalysis : '',
+            clauses: hadStreamingContent ? fullClauses : ''
           },
           status: 'completed',
           metadata: {
@@ -3132,7 +3137,8 @@ const handleSSEMessage = (data, aiMessageIndex) => {
             summary: fullSummary,
             analysis: fullAnalysis,
             clauses: fullClauses
-          }
+          },
+          _typewriterCompleted: hadStreamingContent || existingReport?._typewriterCompleted || false
         }
         targetReport = aiMessage.reports[idx]
         streamingReports.value.delete(reportData.file_index)

+ 274 - 127
shudao-vue-frontend/src/views/ExamWorkshop.vue

@@ -72,7 +72,11 @@
                 <!-- 中间主操作区 -->
             <main class="main-content" style="padding-top: 36px; position: relative;">
                 <!-- 返回AI问答按钮 -->
-                <button v-if="!showExamDetail" class="return-ai-btn has-before" @click="handleReturnToAI">
+                <button v-if="!showExamDetail" class="return-ai-btn" @click="handleReturnToAI">
+                  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+                    <circle cx="12" cy="12" r="12" fill="white" stroke="#E5E7EB" stroke-width="1"/>
+                    <path d="M14 7L9 12L14 17" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
+                  </svg>
                   返回AI问答
                 </button>
                 
@@ -86,7 +90,7 @@
                     <label class="form-label">出题依据内容</label>
                     <textarea class="form-control" v-model="questionBasis" placeholder="在此输入知识点、章节或培训内容..." :disabled="isGenerating || uploadedFiles.length > 0"></textarea>
                     
-                    <div class="ppt-upload-section" style="flex-direction: column; align-items: flex-start;" @click="!isGenerating ? triggerFileUpload() : null">
+                    <div class="ppt-upload-section" style="flex-direction: column; align-items: flex-start; justify-content: flex-start;" @click="!isGenerating ? triggerFileUpload() : null">
                         <div style="display: flex; width: 100%; justify-content: space-between; align-items: center;">
                             <div class="ppt-upload-content">
                                 <div class="ppt-upload-icon-wrapper">
@@ -127,7 +131,7 @@
                             </div>
                             <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">
+                                <input type="range" class="question-slider" v-model.number="type.questionCount" :min="type.name === '单选题' ? 1 : 0" :max="type.max || 50" :disabled="isGenerating" @input="validateQuestionCount(type)">
                                 <div class="question-count-stepper">
                                     <span class="question-count">{{ type.questionCount }} 题</span>
                                     <div class="stepper-buttons">
@@ -142,7 +146,7 @@
                                             class="stepper-btn stepper-btn-down"
                                             type="button"
                                             @click="adjustQuestionCount(type, -1)"
-                                            :disabled="isGenerating || type.questionCount <= 0"
+                                            :disabled="isGenerating || (type.name === '单选题' ? type.questionCount <= 1 : type.questionCount <= 0)"
                                             aria-label="减少题目数量"
                                         ></button>
                                     </div>
@@ -195,7 +199,7 @@
                 </div>
 
                 <div class="preview-footer">
-                    <div class="preview-total">
+                    <div class="preview-total" style="font-size: 20px; color: #000000;">
                         <span>配置总分</span>
                         <span class="preview-total-score" style="color: #000000; font-size: 24px;">{{ totalScore }}</span>
                     </div>
@@ -216,15 +220,24 @@
             <div class="header-left">
             </div>
             <div class="header-right" style="display: flex; align-items: center; gap: 12px;">
-              <button class="return-ai-btn has-before" style="position: static;" @click="backToConfig" :disabled="isGenerating">
+              <button class="return-ai-btn" style="position: static;" @click="backToConfig" :disabled="isGenerating">
+                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+                  <circle cx="12" cy="12" r="12" fill="white" stroke="#E5E7EB" stroke-width="1"/>
+                  <path d="M14 7L9 12L14 17" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
+                </svg>
                 返回修改
               </button>
               <!-- <button class="save-btn" @click="saveExam" :disabled="isGenerating">
                 <img :src="saveIcon" alt="保存试卷" class="save-icon" />
               </button> -->
               <div class="download-dropdown" :class="{ 'disabled': isGenerating, 'show': showDownloadMenu }" @click.stop>
-                <button class="download-btn" :disabled="isGenerating" @click="toggleDownloadMenu">
-                  <img :src="downloadIcon" alt="下载Word" class="download-icon" />
+                <button class="download-btn-new" :disabled="isGenerating" @click="toggleDownloadMenu">
+                  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+                    <rect x="1.5" y="1.5" width="21" height="21" rx="5" fill="white" stroke="#E5E7EB" stroke-width="1.5"/>
+                    <path d="M12 15L7.5 10H10.5V5H13.5V10H16.5L12 15Z" fill="currentColor"/>
+                    <path d="M6 17.5H18V19.5H6V17.5Z" fill="currentColor"/>
+                  </svg>
+                  <span class="download-icon-text">下载Word</span>
                 </button>
                 <div class="dropdown-menu">
                   <div class="dropdown-item" @click="exportToWordWithAnswers" :disabled="isGenerating">
@@ -317,6 +330,10 @@
                       </div>
                     </div>
                   </div>
+                  <div class="answer-section">
+                    <span class="answer-label">正确答案:</span>
+                    <span class="answer-value">{{ question.selectedAnswer || '未设置' }}</span>
+                  </div>
                 </div>
               </div>
             </div>
@@ -384,6 +401,10 @@
                       <span class="option-text">错误</span>
                     </div>
                   </div>
+                  <div class="answer-section">
+                    <span class="answer-label">正确答案:</span>
+                    <span class="answer-value">{{ question.selectedAnswer || '未设置' }}</span>
+                  </div>
                 </div>
               </div>
             </div>
@@ -452,6 +473,10 @@
                       </div>
                     </div>
                   </div>
+                  <div class="answer-section">
+                    <span class="answer-label">正确答案:</span>
+                    <span class="answer-value">{{ (question.selectedAnswers || []).join(', ') || '未设置' }}</span>
+                  </div>
                 </div>
               </div>
             </div>
@@ -820,7 +845,7 @@ const projectTypes = {
 
 // 题型配置
 const questionTypes = ref([
-  { name: "单选题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "一" },
+  { name: "单选题", scorePerQuestion: 0, questionCount: 1, romanNumeral: "一" },
   { name: "判断题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "二" },
   { name: "多选题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "三" },
   { name: "简答题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "四" },
@@ -922,7 +947,7 @@ const createNewChat = async () => {
   } else {
     // 如果没有初始配置,使用默认配置
     questionTypes.value = [
-      { name: "单选题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "一" },
+      { name: "单选题", scorePerQuestion: 0, questionCount: 1, romanNumeral: "一" },
       { name: "判断题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "二" },
       { name: "多选题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "三" },
       { name: "简答题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "四" },
@@ -975,10 +1000,12 @@ const handleHistoryItem = async (historyItem) => {
     });
     console.log(response.data)
     if (response.statusCode === 200 && response.data && response.data.length > 0) {
-      const latestRecord = response.data[response.data.length - 1];
+      const latestRecord = [...response.data].reverse().find(record => record.type === 'ai' && record.content)
+        || [...response.data].reverse().find(record => record.content)
+        || response.data[response.data.length - 1];
       console.log('获取到的试卷数据:', latestRecord);
       console.log('试卷数据结构:', JSON.stringify(latestRecord, null, 2));
-      currentTime.value = formatTime(latestRecord.created_at)
+      currentTime.value = latestRecord?.created_at ? formatTime(latestRecord.created_at) : historyItem.time
       if (latestRecord && latestRecord.content) {
         try {
           const examData = extractExamDataFromContent(latestRecord.content);
@@ -986,22 +1013,18 @@ const handleHistoryItem = async (historyItem) => {
           showExamDetail.value = true;
         } catch (error) {
           console.error('解析试卷数据失败:', error);
-          showExamDetail.value = true;
-          currentTime.value = historyItem.time;
+          ElMessage.error('恢复试卷失败,请稍后重试');
         }
       } else {
-        showExamDetail.value = true;
-        currentTime.value = historyItem.time;
+        ElMessage.error('暂无可恢复的试卷内容');
       }
     } else {
       console.error('获取历史记录详情失败:', response);
-      showExamDetail.value = true;
-      currentTime.value = historyItem.time;
+      ElMessage.error('获取历史记录详情失败,请稍后重试');
     }
   } catch (error) {
     console.error('获取历史记录详情失败:', error);
-    showExamDetail.value = true;
-    currentTime.value = historyItem.time;
+    ElMessage.error('获取历史记录详情失败,请稍后重试');
   } finally {
     isLoadingHistoryItem.value = false;
   }
@@ -1034,7 +1057,7 @@ const clearSettings = () => {
   // 保留原数组引用,更新每个对象的属性,避免破坏 Vue 3 响应式绑定
   questionTypes.value.forEach(type => {
     type.scorePerQuestion = 0;
-    type.questionCount = 0;
+    type.questionCount = type.name === '单选题' ? 1 : 0;
   });
   console.log("清除设置");
 };
@@ -1073,7 +1096,9 @@ const validateQuestionCount = (type) => {
     type.questionCount = 99;
     ElMessage.warning(`${type.name}题目数量不能超过99题`);
   }
-  if (type.questionCount < 0) {
+  if (type.name === '单选题' && type.questionCount < 1) {
+    type.questionCount = 1;
+  } else if (type.questionCount < 0) {
     type.questionCount = 0;
   }
 };
@@ -1122,6 +1147,14 @@ const generateExam = async () => {
     return;
   }
 
+  // 检查出题依据内容是否为空
+  const pptContents = uploadedFiles.value.map(file => file.content).join('\n\n');
+  const finalContentBasis = pptContents || questionBasis.value || '';
+  if (!finalContentBasis.trim()) {
+    ElMessage.warning("请输入出题依据内容或上传PPT文件");
+    return;
+  }
+
   console.log("生成试卷:", {
     function: selectedFunction.value,
     projectType: projectTypes[selectedProjectType.value].name,
@@ -1265,10 +1298,8 @@ const generateAIExam = async () => {
     
   } catch (error) {
     console.error('AI生成试卷失败:', error);
-    ElMessage.error('AI生成试卷失败,请稍后重试或检查网络连接');
-    
-    // 失败时显示默认试卷
-    showExamDetail.value = true;
+    ElMessage.error(error?.message || 'AI生成试卷失败,请稍后重试或检查网络连接');
+    showExamDetail.value = false;
   } finally {
     // 重置发送状态
     isGenerating.value = false;
@@ -1315,27 +1346,166 @@ const extractExamDataFromContent = (content) => {
     throw new Error('试卷内容为空');
   }
 
-  const jsonMatch = content.match(/\{[\s\S]*\}/);
-  if (!jsonMatch) {
-    throw new Error('未找到有效的JSON数据');
+  const cleaned = String(content)
+    .replace(/\uFEFF/g, '')
+    .replace(/```(?:json)?/gi, '')
+    .replace(/```/g, '')
+    .trim();
+
+  const extractJsonObjects = (text) => {
+    const objects = [];
+    let start = -1;
+    let depth = 0;
+    let inString = false;
+    let stringQuote = '"';
+    let escaped = false;
+
+    for (let i = 0; i < text.length; i++) {
+      const ch = text[i];
+      if (escaped) {
+        escaped = false;
+        continue;
+      }
+
+      if (inString) {
+        if (ch === '\\') {
+          escaped = true;
+          continue;
+        }
+        if (ch === stringQuote) {
+          inString = false;
+        }
+        continue;
+      }
+
+      if (ch === '"' || ch === "'") {
+        inString = true;
+        stringQuote = ch;
+        continue;
+      }
+
+      if (ch === '{') {
+        if (depth === 0) {
+          start = i;
+        }
+        depth += 1;
+        continue;
+      }
+
+      if (ch === '}' && depth > 0) {
+        depth -= 1;
+        if (depth === 0 && start >= 0) {
+          objects.push(text.slice(start, i + 1));
+          start = -1;
+        }
+      }
+    }
+
+    return objects;
+  };
+
+  const looksLikeExamPayload = (parsed) => {
+    if (!parsed || typeof parsed !== 'object') {
+      return false;
+    }
+    return Boolean(
+      parsed.singleChoice ||
+      parsed.single_choice ||
+      parsed['单选题'] ||
+      parsed.judge ||
+      parsed['判断题'] ||
+      parsed.multiple ||
+      parsed.multiple_choice ||
+      parsed.multipleChoice ||
+      parsed['多选题'] ||
+      parsed.short ||
+      parsed.short_answer ||
+      parsed.shortAnswer ||
+      parsed['简答题'] ||
+      parsed.questions?.single_choice ||
+      parsed.questions?.singleChoice ||
+      parsed.questions?.['单选题'] ||
+      parsed.questions?.judge ||
+      parsed.questions?.['判断题'] ||
+      parsed.questions?.multiple ||
+      parsed.questions?.multiple_choice ||
+      parsed.questions?.multipleChoice ||
+      parsed.questions?.['多选题'] ||
+      parsed.questions?.short ||
+      parsed.questions?.short_answer ||
+      parsed.questions?.shortAnswer ||
+      parsed.questions?.['简答题']
+    );
+  };
+
+  const candidates = extractJsonObjects(cleaned);
+  if (!candidates.length) {
+    throw new Error('未找到完整的JSON对象');
+  }
+
+  const parsedCandidates = [];
+  for (const candidate of candidates) {
+    try {
+      const normalizedJson = candidate
+        .replace(/[“”]/g, '"')
+        .replace(/,\s*([}\]])/g, '$1');
+      parsedCandidates.push(JSON.parse(normalizedJson));
+    } catch (_) {
+      // 跳过无效片段,继续寻找真正的试卷 JSON
+    }
+  }
+
+  const examCandidate = parsedCandidates.find(looksLikeExamPayload);
+  if (examCandidate) {
+    return examCandidate;
   }
 
-  return JSON.parse(jsonMatch[0]);
+  if (parsedCandidates.length > 0) {
+    return parsedCandidates.sort((a, b) => JSON.stringify(b).length - JSON.stringify(a).length)[0];
+  }
+
+  throw new Error('未找到可用的试卷JSON数据');
 };
 
 const parseAIExamResponse = (aiReply) => {
+  const hasPlaceholderContent = (value) => {
+    if (Array.isArray(value)) {
+      return value.some(item => hasPlaceholderContent(item));
+    }
+    if (value && typeof value === 'object') {
+      return Object.values(value).some(item => hasPlaceholderContent(item));
+    }
+    if (typeof value === 'string') {
+      const text = value.trim();
+      return [
+        /^题目内容$/,
+        /^解析内容$/,
+        /^参考措施$/,
+        /^答题要点$/,
+        /^具体选项内容$/,
+        /^选项[ABCD]$/,
+        /^.+工程相关(?:单选题|多选题|判断题|简答题)\d+$/,
+        /^.+相关(?:单选题|多选题|判断题|简答题)\d+$/
+      ].some(pattern => pattern.test(text));
+    }
+    return false;
+  };
+
   try {
     const examData = extractExamDataFromContent(aiReply);
     const normalizedExam = normalizeGeneratedExam(examData);
 
+    if (hasPlaceholderContent(normalizedExam)) {
+      throw new Error('AI返回的是占位题目,不是可用的具体试题');
+    }
+
     // 确保所有题目都有正确的初始值
     ensureQuestionInitialValues(normalizedExam);
 
     return normalizedExam;
   } catch (error) {
     console.error('解析AI回复失败:', error);
-    // 返回默认试卷结构
-    return generateDefaultExam();
+    throw error;
   }
 };
 
@@ -1375,35 +1545,35 @@ const normalizeQuestions = (questions = [], sectionKey) => {
   return questions.map((question = {}) => {
     if (sectionKey === 'singleChoice') {
       return {
-        text: question.text || question.question_text || "",
+        text: question.text || question.question_text || question.question || question.title || question.content || question['题干'] || question['题目'] || "",
         options: normalizeOptions(question.options),
-        selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || "",
-        analysis: question.analysis || question.explanation || "",
+        selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || question['正确答案'] || question['答案'] || "",
+        analysis: question.analysis || question.explanation || question['解析'] || "",
       };
     }
 
     if (sectionKey === 'judge') {
       return {
-        text: question.text || question.question_text || "",
-        selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || "",
-        analysis: question.analysis || question.explanation || "",
+        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['解析'] || "",
       };
     }
 
     if (sectionKey === 'multiple') {
-      const selectedAnswers = question.selectedAnswers || question.correct_answers || question.answers || [];
+      const selectedAnswers = question.selectedAnswers || question.correct_answers || question.answers || question.answer || question['正确答案'] || question['答案'] || [];
       return {
-        text: question.text || question.question_text || "",
+        text: question.text || question.question_text || question.question || question.title || question.content || question['题干'] || question['题目'] || "",
         options: normalizeOptions(question.options),
         selectedAnswers: Array.isArray(selectedAnswers) ? selectedAnswers : [selectedAnswers].filter(Boolean),
-        analysis: question.analysis || question.explanation || "",
+        analysis: question.analysis || question.explanation || question['解析'] || "",
       };
     }
 
     return {
-      text: question.text || question.question_text || "",
-      outline: question.outline || question.answer_outline || { keyFactors: question.answer || "答题要点、关键因素、示例答案" },
-      analysis: question.analysis || question.explanation || "",
+      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['解析'] || "",
     };
   });
 };
@@ -1413,11 +1583,11 @@ const normalizeSection = (rawSection, sectionKey, fallbackName, fallbackScore =
   const config = getQuestionTypeConfig(fallbackName, fallbackScore);
   const sourceQuestions = Array.isArray(section)
     ? section
-    : (section.questions || section.items || section.question_list || []);
+    : (section.questions || section.items || section.question_list || section.questionList || section['题目'] || []);
   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;
+  const count = Number(section.count ?? section.question_count ?? section['数量'] ?? normalizedQuestions.length ?? config.questionCount) || 0;
+  const scorePerQuestion = Number(section.scorePerQuestion ?? section.score_per_question ?? section['每题分值'] ?? config.scorePerQuestion) || 0;
+  const totalScore = Number(section.totalScore ?? section.total_score ?? section['总分'] ?? (scorePerQuestion * count)) || 0;
 
   return {
     scorePerQuestion,
@@ -1428,13 +1598,13 @@ const normalizeSection = (rawSection, sectionKey, fallbackName, fallbackScore =
 };
 
 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 singleSource = examData.singleChoice || examData.questions?.single_choice || examData.questions?.singleChoice || examData.questions?.['单选题'] || examData.single_choice || examData['单选题'];
+  const judgeSource = examData.judge || examData.questions?.judge || examData.questions?.['判断题'] || examData['判断题'];
+  const multipleSource = examData.multiple || examData.multiple_choice || examData.multipleChoice || examData.questions?.multiple || examData.questions?.multiple_choice || examData.questions?.multipleChoice || examData.questions?.['多选题'] || examData['多选题'];
+  const shortSource = examData.short || examData.short_answer || examData.shortAnswer || examData.questions?.short || examData.questions?.short_answer || examData.questions?.shortAnswer || examData.questions?.['简答题'] || examData['简答题'];
 
   const normalizedExam = {
-    title: examData.title || examData.exam_name || examName.value,
+    title: examData.title || examData.exam_name || examData.examTitle || examData['试卷标题'] || examData['标题'] || 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),
@@ -3020,62 +3190,8 @@ const restoreExamFromHistory = (examData) => {
       ];
     }
     
-    // 恢复题目内容
-    if (exam.singleChoice || exam.questions?.single_choice) {
-      const singleChoice = exam.singleChoice || exam.questions.single_choice;
-      const judge = exam.judge || exam.questions.judge;
-      const multiple = exam.multiple || exam.questions.multiple;
-      const short = exam.short || exam.questions.short;
-      
-      console.log('单选题数据:', singleChoice);
-      console.log('判断题数据:', judge);
-      console.log('多选题数据:', multiple);
-      console.log('简答题数据:', short);
-      
-      currentExam.value = {
-        title: examName.value,
-        totalScore: totalScore.value,
-        totalQuestions: exam.totalQuestions || exam.total_questions,
-        singleChoice: {
-          scorePerQuestion: singleChoice.scorePerQuestion || singleChoice.score_per_question,
-          totalScore: singleChoice.totalScore || singleChoice.total_score,
-          count: singleChoice.count,
-          questions: singleChoice.questions.map(q => ({
-            text: q.text || q.question_text,
-            options: q.options || [],
-            selectedAnswer: q.selectedAnswer || ""
-          }))
-        },
-        judge: {
-          scorePerQuestion: judge.scorePerQuestion || judge.score_per_question,
-          totalScore: judge.totalScore || judge.total_score,
-          count: judge.count,
-          questions: judge.questions.map(q => ({
-            text: q.text || q.question_text,
-            selectedAnswer: q.selectedAnswer || q.correct_answer || q.answer || ""
-          }))
-        },
-        multiple: {
-          scorePerQuestion: multiple.scorePerQuestion || multiple.score_per_question,
-          totalScore: multiple.totalScore || multiple.total_score,
-          count: multiple.count,
-          questions: multiple.questions.map(q => ({
-            text: q.text || q.question_text,
-            options: q.options || [],
-            selectedAnswers: q.selectedAnswers || q.correct_answers || q.answers || []
-          }))
-        },
-        short: {
-          scorePerQuestion: short.scorePerQuestion || short.score_per_question,
-          totalScore: short.totalScore || short.total_score,
-          count: short.count,
-          questions: short.questions.map(q => ({
-            text: q.text || q.question_text,
-            outline: q.outline || q.answer_outline || { keyFactors: "答题要点、关键因素、示例答案" }
-          }))
-        }
-      };
-    }
+    currentExam.value = normalizeGeneratedExam(exam);
+    ensureQuestionInitialValues(currentExam.value);
     
     // 恢复用户答案(如果有)
     if (exam.user_answers) {
@@ -3790,7 +3906,7 @@ onUnmounted(() => {
 
     textarea.form-control {
         resize: none;
-        height: 250px;
+        height: 180px;
     }
 
     .char-count {
@@ -3814,6 +3930,9 @@ onUnmounted(() => {
         justify-content: space-between;
         box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); /* 统一阴影 */
         position: relative;
+        height: 140px; /* 固定高度,防止上传文件后撑开高度导致下方模块下移 */
+        box-sizing: border-box;
+        overflow-y: auto; /* 文件过多时内部滚动 */
     }
 
     .ppt-upload-section:hover {
@@ -4956,20 +5075,32 @@ onUnmounted(() => {
           pointer-events: none;
         }
 
-        .download-btn {
-          // padding: 8px;
-          border: none;
-          background: transparent;
+        .download-btn-new {
+          border: 1px solid rgba(0, 0, 0, 0.06);
+          background: white;
+          border-radius: 12px;
+          box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
+          padding: 6px 16px;
+          font-size: 13px;
+          font-weight: 500;
+          color: #28a745;
           cursor: pointer;
-          transition: opacity 0.3s ease;
+          display: flex;
+          align-items: center;
+          gap: 5px;
+          transition: all 0.3s ease;
+          height: 36px;
+          box-sizing: border-box;
 
-          &:hover {
-            opacity: 0.8;
+          &:hover:not(:disabled) {
+            box-shadow: 0 8px 24px rgba(40, 167, 69, 0.12);
+            color: #218838;
+            border-color: rgba(40, 167, 69, 0.2);
           }
-
-          .download-icon {
-            width: 107px;
-            height: 34px;
+          
+          &:disabled {
+            opacity: 0.5;
+            cursor: not-allowed;
           }
         }
 
@@ -5363,6 +5494,28 @@ onUnmounted(() => {
             }
           }
 
+          .answer-section {
+            margin-top: 12px;
+            padding: 10px 12px;
+            background: #f8fafc;
+            border-radius: 6px;
+            display: flex;
+            align-items: center;
+            gap: 8px;
+
+            .answer-label {
+              font-size: 14px;
+              color: #6b7280;
+              font-weight: 600;
+            }
+
+            .answer-value {
+              font-size: 14px;
+              color: #1f2937;
+              font-weight: 600;
+            }
+          }
+
           .answer-box {
             .answer-outline {
               display: flex;
@@ -5793,10 +5946,4 @@ onUnmounted(() => {
   color: #0d6efd;
   border-color: rgba(13, 110, 253, 0.2);
 }
-
-.return-ai-btn.has-before::before {
-  content: '←';
-  font-size: 16px;
-  font-weight: bold;
-}
 </style>

+ 13 - 7
shudao-vue-frontend/src/views/mobile/m-Chat.vue

@@ -483,6 +483,7 @@ import { getApiPrefix, BACKEND_API_PREFIX } from '@/utils/apiConfig'
 import { renderMarkdown } from '@/utils/markdown'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
 import {
+  applyReportChunkToMessage,
   buildAIMessageUpdatePayload,
   dedupeReportsByFileAndScene,
   extractRelatedQuestions,
@@ -2591,8 +2592,9 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       break
     
     case 'report_chunk':
-      // 不再处理report_chunk,等待完整的report消息后用打字机效果显示
-      // 这样可以避免与打字机效果冲突
+      if (applyReportChunkToMessage(aiMessage, streamingReports.value, data)) {
+        updateMessageStatus(aiMessage, 'deep_thinking')
+      }
       break
       
     case 'report':
@@ -2616,8 +2618,10 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       
       let targetReport
       if (idx !== undefined) {
+        const existingReport = aiMessage.reports[idx]
+        const hadStreamingContent = Boolean(existingReport?._streamingStarted)
         const displayCategory = reportData.metadata?.primary_category ||
-          aiMessage.reports[idx].metadata?._displayCategory ||
+          existingReport?.metadata?._displayCategory ||
           aiMessage.currentCategory
         const fullSummary = reportData.report?.summary || ''
         const fullAnalysis = reportData.report?.analysis || ''
@@ -2626,12 +2630,13 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         
         // 创建带空内容的报告对象,保留所有原始字段
         aiMessage.reports[idx] = { 
+          ...existingReport,
           ...reportData, // 保留所有原始字段,包括可能的链接字段
           report: {
             display_name: fullDisplayName, // 直接显示
-            summary: '',
-            analysis: '',
-            clauses: ''
+            summary: hadStreamingContent ? fullSummary : '',
+            analysis: hadStreamingContent ? fullAnalysis : '',
+            clauses: hadStreamingContent ? fullClauses : ''
           },
           status: 'completed',
           metadata: {
@@ -2643,7 +2648,8 @@ const handleSSEMessage = (data, aiMessageIndex) => {
             summary: fullSummary,
             analysis: fullAnalysis,
             clauses: fullClauses
-          }
+          },
+          _typewriterCompleted: hadStreamingContent || existingReport?._typewriterCompleted || false
         }
         targetReport = aiMessage.reports[idx]
         streamingReports.value.delete(reportData.file_index)

+ 133 - 8
shudao-vue-frontend/src/views/mobile/m-ExamWorkshop.vue

@@ -140,7 +140,7 @@
                         v-model="type.questionCount"
                         type="number"
                         class="count-input-field"
-                        min="0"
+                        :min="type.name === '单选题' ? 1 : 0"
                         max="99"
                         @input="validateQuestionCount(type)"
                         :disabled="isGenerating || selectedFile"
@@ -157,7 +157,7 @@
                             class="stepper-btn stepper-btn-down"
                             type="button"
                             @click="adjustQuestionCount(type, -1)"
-                            :disabled="isGenerating || selectedFile || type.questionCount <= 0"
+                            :disabled="isGenerating || selectedFile || (type.name === '单选题' ? type.questionCount <= 1 : type.questionCount <= 0)"
                             aria-label="减少题目数量"
                           ></button>
                         </div>
@@ -933,7 +933,9 @@ const validateQuestionCount = (type) => {
     type.questionCount = 99;
     console.warn(`${type.name}题目数量不能超过99题`);
   }
-  if (type.questionCount < 0) {
+  if (type.name === '单选题' && type.questionCount < 1) {
+    type.questionCount = 1;
+  } else if (type.questionCount < 0) {
     type.questionCount = 0;
   }
 };
@@ -1056,6 +1058,8 @@ const generateExam = async () => {
 
   } catch (error) {
     console.error('生成试卷失败:', error);
+    showToast(error?.message || '生成试卷失败,请重试');
+    showExamDetail.value = false;
   } finally {
     isGenerating.value = false;
   }
@@ -1098,24 +1102,145 @@ const extractExamDataFromContent = (content) => {
     throw new Error('历史内容为空');
   }
 
-  const directMatch = content.match(/\{[\s\S]*\}/);
-  if (!directMatch) {
+  const cleaned = String(content)
+    .replace(/\uFEFF/g, '')
+    .replace(/```(?:json)?/gi, '')
+    .replace(/```/g, '')
+    .trim();
+
+  const extractJsonObjects = (text) => {
+    const objects = [];
+    let start = -1;
+    let depth = 0;
+    let inString = false;
+    let stringQuote = '"';
+    let escaped = false;
+
+    for (let i = 0; i < text.length; i++) {
+      const ch = text[i];
+      if (escaped) {
+        escaped = false;
+        continue;
+      }
+
+      if (inString) {
+        if (ch === '\\') {
+          escaped = true;
+          continue;
+        }
+        if (ch === stringQuote) {
+          inString = false;
+        }
+        continue;
+      }
+
+      if (ch === '"' || ch === "'") {
+        inString = true;
+        stringQuote = ch;
+        continue;
+      }
+
+      if (ch === '{') {
+        if (depth === 0) {
+          start = i;
+        }
+        depth += 1;
+        continue;
+      }
+
+      if (ch === '}' && depth > 0) {
+        depth -= 1;
+        if (depth === 0 && start >= 0) {
+          objects.push(text.slice(start, i + 1));
+          start = -1;
+        }
+      }
+    }
+
+    return objects;
+  };
+
+  const looksLikeExamPayload = (parsed) => {
+    if (!parsed || typeof parsed !== 'object') {
+      return false;
+    }
+    return Boolean(
+      parsed.singleChoice ||
+      parsed.single_choice ||
+      parsed.judge ||
+      parsed.multiple ||
+      parsed.short ||
+      parsed.questions?.single_choice ||
+      parsed.questions?.judge ||
+      parsed.questions?.multiple ||
+      parsed.questions?.short
+    );
+  };
+
+  const candidates = extractJsonObjects(cleaned);
+  if (!candidates.length) {
     throw new Error('未找到可解析的试卷JSON');
   }
 
-  return JSON.parse(directMatch[0]);
+  const parsedCandidates = [];
+  for (const candidate of candidates) {
+    try {
+      const normalizedJson = candidate
+        .replace(/[“”]/g, '"')
+        .replace(/,\s*([}\]])/g, '$1');
+      parsedCandidates.push(JSON.parse(normalizedJson));
+    } catch (_) {
+      // 继续寻找真正的试卷 JSON
+    }
+  }
+
+  const examCandidate = parsedCandidates.find(looksLikeExamPayload);
+  if (examCandidate) {
+    return examCandidate;
+  }
+
+  if (parsedCandidates.length > 0) {
+    return parsedCandidates.sort((a, b) => JSON.stringify(b).length - JSON.stringify(a).length)[0];
+  }
+
+  throw new Error('未找到可用的试卷JSON数据');
 };
 
 const parseAIExamResponse = (aiReply) => {
+  const hasPlaceholderContent = (value) => {
+    if (Array.isArray(value)) {
+      return value.some(item => hasPlaceholderContent(item));
+    }
+    if (value && typeof value === 'object') {
+      return Object.values(value).some(item => hasPlaceholderContent(item));
+    }
+    if (typeof value === 'string') {
+      const text = value.trim();
+      return [
+        /^题目内容$/,
+        /^解析内容$/,
+        /^参考措施$/,
+        /^答题要点$/,
+        /^具体选项内容$/,
+        /^选项[ABCD]$/,
+        /^.+工程相关(?:单选题|多选题|判断题|简答题)\d+$/,
+        /^.+相关(?:单选题|多选题|判断题|简答题)\d+$/
+      ].some(pattern => pattern.test(text));
+    }
+    return false;
+  };
+
   try {
     const examData = extractExamDataFromContent(aiReply);
     const normalizedExam = normalizeGeneratedExam(examData);
+    if (hasPlaceholderContent(normalizedExam)) {
+      throw new Error('AI返回的是占位题目,不是可用的具体试题');
+    }
     ensureQuestionInitialValues(normalizedExam);
     return normalizedExam;
   } catch (error) {
     console.error('解析AI回复失败:', error);
-    // 返回默认试卷结构
-    return generateDefaultExam();
+    throw error;
   }
 };