Forráskód Böngészése

考试工坊后端未完成

FanHong 3 hete
szülő
commit
e22ed3ffaf

+ 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)

+ 164 - 24
shudao-chat-py/routers/chat.py

@@ -171,6 +171,124 @@ def _finalize_related_questions(questions: list, content: str, limit: int = 3) -
     return cleaned_questions[:limit]
 
 
+def _extract_json_object_candidates(text: str) -> list[str]:
+    text = text or ""
+    objects = []
+    start = -1
+    depth = 0
+    in_string = False
+    string_quote = '"'
+    escaped = False
+
+    for index, ch in enumerate(text):
+        if escaped:
+            escaped = False
+            continue
+
+        if in_string:
+            if ch == "\\":
+                escaped = True
+                continue
+            if ch == string_quote:
+                in_string = False
+            continue
+
+        if ch in ('"', "'"):
+            in_string = True
+            string_quote = ch
+            continue
+
+        if ch == "{":
+            if depth == 0:
+                start = index
+            depth += 1
+            continue
+
+        if ch == "}" and depth > 0:
+            depth -= 1
+            if depth == 0 and start >= 0:
+                objects.append(text[start:index + 1])
+                start = -1
+
+    return objects
+
+
+def _extract_first_json_object(text: str) -> str:
+    candidates = _extract_json_object_candidates(text)
+    if not candidates:
+        raise ValueError("未找到完整的JSON对象")
+    return candidates[0]
+
+
+def _looks_like_exam_payload(payload) -> bool:
+    if not isinstance(payload, dict):
+        return False
+    questions_root = payload.get("questions") if isinstance(
+        payload.get("questions"), dict) else {}
+    return any([
+        payload.get("singleChoice"),
+        payload.get("single_choice"),
+        payload.get("judge"),
+        payload.get("multiple"),
+        payload.get("multiple_choice"),
+        payload.get("multipleChoice"),
+        payload.get("short"),
+        payload.get("short_answer"),
+        payload.get("shortAnswer"),
+        payload.get("单选题"),
+        payload.get("判断题"),
+        payload.get("多选题"),
+        payload.get("简答题"),
+        questions_root.get("single_choice"),
+        questions_root.get("singleChoice"),
+        questions_root.get("judge"),
+        questions_root.get("multiple"),
+        questions_root.get("multiple_choice"),
+        questions_root.get("multipleChoice"),
+        questions_root.get("short"),
+        questions_root.get("short_answer"),
+        questions_root.get("shortAnswer"),
+        questions_root.get("单选题"),
+        questions_root.get("判断题"),
+        questions_root.get("多选题"),
+        questions_root.get("简答题"),
+    ])
+
+
+def _extract_exam_json_content(response_text: str) -> str:
+    raw_thinking, raw_answer = split_thinking_and_answer(response_text or "")
+    answer_text = (raw_answer or response_text or "").strip()
+    candidates = _extract_json_object_candidates(answer_text)
+    if not candidates:
+        raise ValueError("未找到完整的JSON对象")
+
+    normalized_candidates = [
+        candidate
+        .replace("“", '"')
+        .replace("”", '"')
+        .replace("‘", "'")
+        .replace("’", "'")
+        .replace(",", ",")
+        for candidate in candidates
+    ]
+
+    parsed_candidates = []
+    for candidate in normalized_candidates:
+        try:
+            parsed_candidates.append((candidate, json.loads(candidate)))
+        except Exception:
+            continue
+
+    if not parsed_candidates:
+        return max(normalized_candidates, key=len)
+
+    for candidate, parsed in parsed_candidates:
+        if _looks_like_exam_payload(parsed):
+            return candidate
+
+    return max((candidate for candidate, _ in parsed_candidates), key=len)
+
+
 def _refresh_conversation_snapshot(db: Session, conversation_id: int, user_id: int) -> None:
     latest_message = (
         db.query(AIMessage)
@@ -370,7 +488,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 的场景
@@ -400,8 +519,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:
@@ -421,7 +542,8 @@ async def send_deepseek_message(
                 ]
 
                 raw_response = await qwen_service.chat(messages)
-                raw_thinking, raw_answer = split_thinking_and_answer(raw_response)
+                raw_thinking, raw_answer = split_thinking_and_answer(
+                    raw_response)
                 answer_text = raw_answer or raw_response
                 if raw_thinking:
                     thinking_summary = await summarize_thinking_content(
@@ -439,8 +561,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] 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:
@@ -460,7 +584,8 @@ async def send_deepseek_message(
                 ]
 
                 raw_response = await qwen_service.chat(messages)
-                raw_thinking, raw_answer = split_thinking_and_answer(raw_response)
+                raw_thinking, raw_answer = split_thinking_and_answer(
+                    raw_response)
                 answer_text = raw_answer or raw_response
                 if raw_thinking:
                     thinking_summary = await summarize_thinking_content(
@@ -478,8 +603,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 == 3:
@@ -504,7 +631,8 @@ async def send_deepseek_message(
                     {"role": "user", "content": message},
                 ]
 
-                response_text = await qwen_service.chat(messages)
+                raw_response = await qwen_service.chat(messages)
+                response_text = _extract_exam_json_content(raw_response)
 
                 now_ts = int(time.time())
                 user_message = AIMessage(
@@ -543,8 +671,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:
@@ -788,7 +918,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
@@ -811,13 +942,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
@@ -1033,7 +1166,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 "?????????"
@@ -1052,8 +1186,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 = ""
@@ -1082,22 +1218,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
 
@@ -1117,7 +1255,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 = ""
@@ -1241,7 +1380,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))