Parcourir la source

完全修复json解析错误,优化思考链路输出文本

zkn il y a 1 mois
Parent
commit
ff4f43be9c

+ 6 - 0
shudao-chat-py/config/prompt_config.yaml

@@ -65,6 +65,12 @@ prompts:
     encoding: "utf-8"
     variables: []
 
+  thinking_summary:
+    file: "prompts/thinking_summary_prompt.md"
+    description: "思考要点二次总结prompt(方案三)"
+    encoding: "utf-8"
+    variables: ["userMessage", "thinkingText", "finalAnswer", "maxPoints"]
+
 # 默认配置
 defaults:
   encoding: "utf-8"

+ 5 - 3
shudao-chat-py/prompts/guess_questions_template.md

@@ -18,6 +18,8 @@ ${currentContent}
 3. **实用性**:问题应具有实际应用价值,能帮助用户解决实际问题
 4. **多样性**:3个问题应从不同角度切入,避免重复
 5. **简洁性**:每个问题控制在15-30字之间
+6. **禁止占位词**:不得输出 `q1`、`q2`、`q3`、`问题1`、`问题2`、`问题3`、`更多相关问题` 等占位文本
+7. **语言要求**:必须输出真实、完整、自然的中文问句,而不是格式示例或字段名
 
 # 问题类型参考
 
@@ -34,9 +36,9 @@ ${currentContent}
 ```json
 {
   "questions": [
-    "问题1",
-    "问题2",
-    "问题3"
+    "与当前内容相关的真实中文问题1",
+    "与当前内容相关的真实中文问题2",
+    "与当前内容相关的真实中文问题3"
   ]
 }
 ```

+ 45 - 0
shudao-chat-py/prompts/thinking_summary_prompt.md

@@ -0,0 +1,45 @@
+你是“思考过程展示改写器”(面向用户展示)。
+
+目标:
+把“原始思考过程”改写为可展示给用户的中文“思考过程摘要”,要求简练、有重点、像人在解释自己的思路。
+
+输出必须严格遵守:
+1) 只输出 3~{maxPoints} 个自然段,每段 1~2 句。
+2) 第一段必须以:我们需要理解问题:用户问的是“{userMessage}”。 开头(不要改写问题本身)。
+3) 全中文:禁止出现任何英文标题/标签/字段名,例如 Thinking Process / Role / Task / Input / Output / Final Answer 等。
+4) 禁止复述提示词本身:不要写“你是…总结器/改写器”“任务是…”“Analyze the Request”等。
+5) 禁止使用项目符号、编号列表、Markdown 标题、代码块。
+6) 不新增事实、不编造数据;信息不足时用“可能/一般/建议进一步确认/可以补充说明”等表述。
+7) 保留核心结构:信息缺口 → 用户可能关注点 → 我将如何组织回答 → 注意事项/需要追问点。
+8) 严禁照抄示例中的地名/数字/专有名词;示例仅用于模仿写法与结构。
+
+输入:
+用户问题:
+{userMessage}
+
+原始思考过程(很长/夹杂英文/重复/自我对话;不要原样复述):
+{thinkingText}
+
+(可选)最终回答(用于对齐思路,可为空):
+{finalAnswer}
+
+风格示例(仅模仿写法与结构):
+我们需要理解问题:用户问的是“成都骑行体验”,问题比较简短,没有提供具体的背景信息,比如用户是想了解成都整体的骑行环境、推荐路线,还是计划去成都骑行需要攻略。考虑到“骑行体验”可以有很多维度,包括城市通勤、休闲骑行、专业训练、山地越野等,而成都作为平原城市,骑行条件应该不错。
+
+我需要先确认用户最可能关心什么。用户可能是想了解在成都骑行的感受,比如路况、风景、便利性;或者想找具体的骑行路线,比如绕城绿道、锦城湖、三环路、龙泉山等;也可能关心骑行设施,比如共享单车普及度、非机动车道、停车便利性。考虑到问题的开放性,我应该提供一个比较全面的回答,涵盖城市通勤、休闲骑行和进阶骑行等不同场景。
+
+成都以“天府之国”著称,地势平坦,对骑行非常友好。近年来成都建设了环城生态公园,其中的绕城绿道总长超过100公里,是骑行爱好者的热门选择。市区内也有锦江绿道、沙河绿道等。对于想挑战爬坡的用户,龙泉山是不错的选择。另外,成都的共享单车覆盖率很高,市民日常短途骑行很方便。
+
+我打算从几个方面来组织回答:首先概括成都骑行的总体特点,比如地势平坦、绿道系统完善、共享单车普及;然后分类型介绍骑行体验,包括城市通勤、休闲绿道骑行、进阶挑战路线;最后给出一些实用贴士,比如最佳季节、注意事项。如果用户有进一步的需求,比如想知道具体某条路线的详细情况,可以再补充。
+
+注意回答要基于事实,提到具体的路线名称和长度时要准确,比如绕城绿道约100公里,龙泉山爬坡路段等。同时要体现成都特色,比如沿途可以打卡哪些地标或景色。语气上保持友好、实用,给用户提供有价值的信息。
+
+我们需要理解问题:用户问的是“满堂支架施工方案”。这是一个工程技术问题,通常涉及桥梁、建筑等施工中的满堂支架(如扣件式钢管脚手架、碗扣式脚手架等)的搭设方案。需要给出专业、结构化的内容,包括编制依据、材料要求、搭设参数、施工工艺、验算思路(简要)与安全措施等。
+
+用户可能期望一份可直接参考的施工方案模板或要点清单。由于缺少跨度、荷载、地基条件、构件规格等关键参数,我更适合先给出“通用框架 + 关键控制点”,并提示需要补充的信息。
+
+我会按常见施工方案结构来组织回答:工程概况 → 编制依据 → 施工准备 → 支架设计与参数 → 搭设工艺 → 检查验收与监测 → 拆除工艺 → 安全与应急 → 计算书要点。
+
+注意:计算部分不输出过于精确的数值结果,而是说明需要验算的项目与取值原则;同时强调高处作业、荷载控制、变形监测、节点连接等风险点与注意事项。
+
+现在开始输出:只输出“思考过程摘要”正文,不要任何标题、标签或多余说明。

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

@@ -10,9 +10,11 @@ from utils.config import settings
 from utils.logger import logger
 from services.qwen_service import qwen_service
 from utils.prompt_loader import load_prompt
+from utils.thinking_summary import split_thinking_and_answer, summarize_thinking_content
 import time
 import json
 import httpx
+import re
 
 router = APIRouter()
 
@@ -36,6 +38,139 @@ def _build_conversation_title(conversation: AIConversation) -> str:
     return _build_conversation_preview(conversation.content or "", limit=30)
 
 
+def _normalize_related_question(question: str) -> str:
+    if not isinstance(question, str):
+        return ""
+
+    text = question.strip().strip('"').strip("'")
+    text = re.sub(r"^[0-9]+[\.\)\]、]\s*", "", text)
+    text = re.sub(r"^[-*]\s*", "", text)
+    return text.strip()
+
+
+def _is_placeholder_related_question(question: str) -> bool:
+    normalized = _normalize_related_question(question).lower()
+    if not normalized:
+        return True
+
+    placeholder_patterns = (
+        r"^q\s*\d+$",
+        r"^question\s*\d+$",
+        r"^questions?\s*\d+$",
+        r"^问题\s*\d+$",
+        r"^相关问题\s*\d+$",
+        r"^推荐问题\s*\d+$",
+        r"^更多相关问题$",
+        r"^更多问题$",
+    )
+
+    return any(re.fullmatch(pattern, normalized) for pattern in placeholder_patterns)
+
+
+def _contains_chinese(text: str) -> bool:
+    return any("\u4e00" <= char <= "\u9fff" for char in text or "")
+
+
+def _is_invalid_related_question(question: str) -> bool:
+    normalized = _normalize_related_question(question)
+    if (
+        not normalized
+        or len(normalized) < 4
+        or _is_placeholder_related_question(normalized)
+        or not _contains_chinese(normalized)
+    ):
+        return True
+
+    lowered = normalized.lower()
+    blocked_keywords = (
+        "thinking process",
+        "analyze the request",
+        "role:",
+        "**role",
+        "professional question recommendation",
+        "infrastructure construction technology",
+        "output format",
+        "json",
+        "prompt",
+        "system",
+        "assistant",
+        "角色定义",
+        "任务目标",
+        "输入内容",
+        "生成要求",
+        "输出格式",
+        "开始生成",
+    )
+
+    return any(keyword in lowered for keyword in blocked_keywords)
+
+
+def _extract_related_question_topic(content: str) -> str:
+    if not content:
+        return "当前话题"
+
+    text = re.sub(r"<[^>]+>", " ", str(content))
+    text = re.sub(r"\s+", " ", text).strip()
+    text = re.sub(
+        r"^(好的[!!,, ]*|我理解您提出的问题[,, ]*|这个问题[,, ]*|总的来说[::,, ]*)+",
+        "",
+        text,
+    )
+
+    pattern = re.search(
+        r"(?:主要围绕|围绕|关于|针对|聚焦)([^。!?\n,,;;]{4,32})",
+        text,
+    )
+    if pattern:
+        topic = pattern.group(1).strip("“”\"' ::,,")
+        if topic:
+            return topic
+
+    sentence = re.split(r"[。!?\n]", text, maxsplit=1)[0].strip("“”\"' ::,,")
+    if sentence:
+        return sentence[:24]
+
+    return "当前话题"
+
+
+def _build_related_question_fallbacks(content: str) -> list[str]:
+    topic = _extract_related_question_topic(content)
+    return [
+        f"{topic}在现场实施时需要重点关注哪些风险点?",
+        f"{topic}相关的方案编制、审批和验收要求有哪些?",
+        f"针对{topic},日常检查和监测应抓住哪些关键指标?",
+    ]
+
+
+def _finalize_related_questions(questions: list, content: str, limit: int = 3) -> list[str]:
+    cleaned_questions = []
+    seen = set()
+
+    for question in questions or []:
+        normalized = _normalize_related_question(question)
+        lowered = normalized.lower()
+        if (
+            _is_invalid_related_question(normalized)
+            or lowered in seen
+        ):
+            continue
+        cleaned_questions.append(normalized)
+        seen.add(lowered)
+        if len(cleaned_questions) == limit:
+            return cleaned_questions
+
+    for fallback in _build_related_question_fallbacks(content):
+        lowered = fallback.lower()
+        if lowered in seen:
+            continue
+        cleaned_questions.append(fallback)
+        seen.add(lowered)
+        if len(cleaned_questions) == limit:
+            break
+
+    return cleaned_questions[:limit]
+
+
 def _refresh_conversation_snapshot(db: Session, conversation_id: int, user_id: int) -> None:
     latest_message = (
         db.query(AIMessage)
@@ -235,16 +370,35 @@ async def send_deepseek_message(
                 ]
 
                 qwen_response = await qwen_service.chat(messages)
+                raw_thinking, raw_answer = split_thinking_and_answer(qwen_response)
+                answer_source = raw_answer or qwen_response
 
+                # 兼容模型直接返回 JSON 的场景
+                answer_text = answer_source
                 try:
-                    if isinstance(qwen_response, str) and qwen_response.strip().startswith("{"):
-                        response_json = json.loads(qwen_response)
-                        response_text = response_json.get(
-                            "natural_language_answer", qwen_response)
-                    else:
-                        response_text = qwen_response
+                    if isinstance(answer_source, str) and answer_source.strip().startswith("{"):
+                        response_json = json.loads(answer_source)
+                        answer_text = response_json.get(
+                            "natural_language_answer", answer_source
+                        )
                 except Exception:
-                    response_text = qwen_response
+                    answer_text = answer_source
+
+                if raw_thinking:
+                    thinking_summary = await summarize_thinking_content(
+                        user_question=message,
+                        raw_thinking=raw_thinking,
+                        final_answer=answer_text,
+                        chat_service=qwen_service,
+                        context="send_message",
+                    )
+                    response_text = (
+                        f"思考过程:\n{thinking_summary}\n\n回答:\n{answer_text}"
+                        if thinking_summary
+                        else answer_text
+                    )
+                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}")
@@ -266,7 +420,24 @@ async def send_deepseek_message(
                     {"role": "user", "content": system_content},
                 ]
 
-                response_text = await qwen_service.chat(messages)
+                raw_response = await qwen_service.chat(messages)
+                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(
+                        user_question=message,
+                        raw_thinking=raw_thinking,
+                        final_answer=answer_text,
+                        chat_service=qwen_service,
+                        context="ppt_outline",
+                    )
+                    response_text = (
+                        f"思考过程:\n{thinking_summary}\n\n回答:\n{answer_text}"
+                        if thinking_summary
+                        else answer_text
+                    )
+                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}")
@@ -288,7 +459,24 @@ async def send_deepseek_message(
                     {"role": "user", "content": system_content},
                 ]
 
-                response_text = await qwen_service.chat(messages)
+                raw_response = await qwen_service.chat(messages)
+                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(
+                        user_question=message,
+                        raw_thinking=raw_thinking,
+                        final_answer=answer_text,
+                        chat_service=qwen_service,
+                        context="document_writing",
+                    )
+                    response_text = (
+                        f"思考过程:\n{thinking_summary}\n\n回答:\n{answer_text}"
+                        if thinking_summary
+                        else answer_text
+                    )
+                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}")
@@ -595,8 +783,82 @@ async def stream_chat(request: Request, data: StreamChatRequest):
         ]
 
         try:
+            buffer = ""
+            pre_answer = ""
+            thinking_buf = ""
+            in_think = False
+            thinking_done = False
+            max_input_chars = getattr(settings.thinking_summary, "max_input_chars", 1500)
+
             async for chunk in qwen_service.stream_chat(messages):
-                yield f"data: {json.dumps({'content': chunk}, ensure_ascii=False)}\n\n"
+                buffer += chunk
+
+                while buffer:
+                    lower = buffer.lower()
+                    if not thinking_done:
+                        if not in_think:
+                            start_idx = lower.find("<think>")
+                            if start_idx == -1:
+                                yield f"data: {json.dumps({'content': buffer}, ensure_ascii=False)}\n\n"
+                                buffer = ""
+                                break
+
+                            pre_answer += buffer[:start_idx]
+                            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)]
+                            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)]
+
+                        buffer = buffer[end_idx + len("</think>"):]
+                        in_think = False
+                        thinking_done = True
+
+                        thinking_summary = await summarize_thinking_content(
+                            user_question=message,
+                            raw_thinking=thinking_buf,
+                            final_answer="",
+                            chat_service=qwen_service,
+                            context="stream_chat",
+                        )
+                        if thinking_summary:
+                            prefix = f"思考过程:\n{thinking_summary}\n\n回答:\n"
+                            yield f"data: {json.dumps({'content': prefix}, ensure_ascii=False)}\n\n"
+
+                        answer_chunk = (pre_answer + buffer).lstrip()
+                        if answer_chunk:
+                            yield f"data: {json.dumps({'content': answer_chunk}, ensure_ascii=False)}\n\n"
+
+                        pre_answer = ""
+                        buffer = ""
+                        break
+
+                    yield f"data: {json.dumps({'content': buffer}, ensure_ascii=False)}\n\n"
+                    buffer = ""
+
+            # 流结束但未遇到 </think>:仅尝试生成要点(不回退输出 raw thinking)
+            if in_think and not thinking_done and thinking_buf:
+                thinking_summary = await summarize_thinking_content(
+                    user_question=message,
+                    raw_thinking=thinking_buf,
+                    final_answer="",
+                    chat_service=qwen_service,
+                    context="stream_chat_eof",
+                )
+                if thinking_summary:
+                    prefix = f"思考过程:\n{thinking_summary}\n\n回答:\n"
+                    yield f"data: {json.dumps({'content': prefix}, ensure_ascii=False)}\n\n"
+                if pre_answer:
+                    yield f"data: {json.dumps({'content': pre_answer}, ensure_ascii=False)}\n\n"
         except Exception as e:
             logger.error(f"[stream/chat] 流式输出异常: {e}")
             yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
@@ -790,14 +1052,101 @@ 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)
+
+                buffer = ""
+                pre_answer = ""
+                thinking_buf = ""
+                in_think = False
+                thinking_done = False
+
                 async for chunk in qwen_service.stream_chat(messages):
-                    escaped_chunk = chunk.replace("\n", "\\n")
-                    full_response += chunk
-                    yield f"data: {escaped_chunk}\n\n"
+                    if not summary_enabled:
+                        escaped_chunk = chunk.replace("\n", "\\n")
+                        full_response += chunk
+                        yield f"data: {escaped_chunk}\n\n"
+                        continue
+
+                    buffer += chunk
+                    while buffer:
+                        lower = buffer.lower()
+                        if not thinking_done:
+                            if not in_think:
+                                start_idx = lower.find("<think>")
+                                if start_idx == -1:
+                                    escaped_text = buffer.replace("\n", "\\n")
+                                    full_response += buffer
+                                    yield f"data: {escaped_text}\n\n"
+                                    buffer = ""
+                                    break
+
+                                pre_answer += buffer[:start_idx]
+                                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)]
+                                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)]
+
+                            buffer = buffer[end_idx + len("</think>") :]
+                            in_think = False
+                            thinking_done = True
+
+                            thinking_summary = await summarize_thinking_content(
+                                user_question=message,
+                                raw_thinking=thinking_buf,
+                                final_answer="",
+                                chat_service=qwen_service,
+                                context="stream_chat_with_db",
+                            )
+                            if thinking_summary:
+                                prefix = f"思考过程:\n{thinking_summary}\n\n回答:\n"
+                                full_response += prefix
+                                yield f"data: {prefix.replace('\n', '\\n')}\n\n"
+
+                            answer_chunk = (pre_answer + buffer).lstrip()
+                            if answer_chunk:
+                                full_response += answer_chunk
+                                yield f"data: {answer_chunk.replace('\n', '\\n')}\n\n"
+
+                            pre_answer = ""
+                            buffer = ""
+                            break
+
+                        escaped_text = buffer.replace("\n", "\\n")
+                        full_response += buffer
+                        yield f"data: {escaped_text}\n\n"
+                        buffer = ""
             except Exception as e:
                 logger.error(f"[stream/chat-with-db] 流式输出异常: {e}")
                 yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
 
+            # 流结束但未遇到 </think>:仅尝试生成要点(不回退输出 raw thinking)
+            if summary_enabled and in_think and not thinking_done and thinking_buf:
+                thinking_summary = await summarize_thinking_content(
+                    user_question=message,
+                    raw_thinking=thinking_buf,
+                    final_answer="",
+                    chat_service=qwen_service,
+                    context="stream_chat_with_db_eof",
+                )
+                if thinking_summary:
+                    prefix = f"思考过程:\n{thinking_summary}\n\n回答:\n"
+                    full_response += prefix
+                    yield f"data: {prefix.replace('\n', '\\n')}\n\n"
+                if pre_answer:
+                    full_response += pre_answer
+                    yield f"data: {pre_answer.replace('\n', '\\n')}\n\n"
+
             # 9. 更新 AI 消息内容
             if full_response:
                 now_ts = int(time.time())
@@ -871,7 +1220,6 @@ async def guess_you_want(
 
         try:
             # 尝试从响应中提取 JSON
-            import re
             json_match = re.search(
                 r'\{[^{}]*"questions"[^{}]*\}', response, re.DOTALL)
             if json_match:
@@ -889,9 +1237,7 @@ async def guess_you_want(
             if not questions:
                 questions = ["该话题的具体应用场景?", "有哪些注意事项?", "相关案例分析?"]
 
-        questions = questions[:3]
-        while len(questions) < 3:
-            questions.append("更多相关问题")
+        questions = _finalize_related_questions(questions, ai_msg.content, limit=3)
 
         guess_json = json.dumps({"questions": questions}, ensure_ascii=False)
 

+ 144 - 0
shudao-chat-py/tests/test_guess_you_want.py

@@ -0,0 +1,144 @@
+import importlib.util
+import json
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+
+CHAT_PATH = Path(__file__).resolve().parents[1] / "routers" / "chat.py"
+spec = importlib.util.spec_from_file_location("chat_under_test", CHAT_PATH)
+chat = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(chat)
+
+
+def contains_chinese(text):
+    return any("\u4e00" <= char <= "\u9fff" for char in text or "")
+
+
+class FakeQuery:
+    def __init__(self, db):
+        self.db = db
+
+    def filter(self, *args, **kwargs):
+        return self
+
+    def first(self):
+        return self.db.ai_message
+
+    def update(self, values):
+        self.db.updated_values = values
+        return 1
+
+
+class FakeDB:
+    def __init__(self, content):
+        self.ai_message = SimpleNamespace(id=10, content=content, is_deleted=0)
+        self.updated_values = None
+        self.commit_called = False
+
+    def query(self, model):
+        return FakeQuery(self)
+
+    def commit(self):
+        self.commit_called = True
+
+
+class GuessYouWantTests(unittest.IsolatedAsyncioTestCase):
+    def _request(self):
+        return SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(user_id=70430)))
+
+    async def test_replaces_q_placeholders_with_real_questions(self):
+        db = FakeDB("满堂支架施工有哪些安全技术措施和验收要求?")
+
+        with patch.object(chat, "load_prompt", return_value="prompt"), patch.object(
+            chat.qwen_service,
+            "chat",
+            AsyncMock(return_value='{"questions":["q1","q2","q3"]}'),
+        ):
+            response = await chat.guess_you_want(
+                self._request(),
+                chat.GuessYouWantRequest(ai_message_id=10),
+                db=db,
+            )
+
+        questions = response["data"]["questions"]
+
+        self.assertEqual(response["statusCode"], 200)
+        self.assertEqual(len(questions), 3)
+        self.assertTrue(all(contains_chinese(question) for question in questions))
+        self.assertFalse(any(question.lower() in {"q1", "q2", "q3"} for question in questions))
+        self.assertTrue(db.commit_called)
+        self.assertEqual(json.loads(db.updated_values["guess_you_want"])["questions"], questions)
+
+    async def test_replaces_problem_number_placeholders_with_real_questions(self):
+        db = FakeDB("桥梁模板支架验收时需要重点检查哪些项目?")
+
+        with patch.object(chat, "load_prompt", return_value="prompt"), patch.object(
+            chat.qwen_service,
+            "chat",
+            AsyncMock(return_value='{"questions":["问题1","问题2","问题3"]}'),
+        ):
+            response = await chat.guess_you_want(
+                self._request(),
+                chat.GuessYouWantRequest(ai_message_id=10),
+                db=db,
+            )
+
+        questions = response["data"]["questions"]
+
+        self.assertEqual(len(questions), 3)
+        self.assertTrue(all(contains_chinese(question) for question in questions))
+        self.assertFalse(any(question in {"问题1", "问题2", "问题3"} for question in questions))
+
+    async def test_replaces_prompt_leakage_with_real_questions(self):
+        db = FakeDB("满堂支架施工方案编制、审批和验收有哪些安全管控要求?")
+        leaked_response = "\n".join([
+            "Thinking Process:",
+            "**Analyze the Request:**",
+            "**Role:** Professional question recommendation assistant focused on infrastructure construction technology (roads, bridges, tunnels, rails).",
+        ])
+
+        with patch.object(chat, "load_prompt", return_value="prompt"), patch.object(
+            chat.qwen_service,
+            "chat",
+            AsyncMock(return_value=leaked_response),
+        ):
+            response = await chat.guess_you_want(
+                self._request(),
+                chat.GuessYouWantRequest(ai_message_id=10),
+                db=db,
+            )
+
+        questions = response["data"]["questions"]
+
+        self.assertEqual(len(questions), 3)
+        self.assertTrue(all(contains_chinese(question) for question in questions))
+        self.assertFalse(any("thinking process" in question.lower() for question in questions))
+        self.assertFalse(any("analyze the request" in question.lower() for question in questions))
+        self.assertFalse(any("professional question recommendation" in question.lower() for question in questions))
+
+    async def test_preserves_valid_generated_questions(self):
+        db = FakeDB("临边防护验收有哪些关键标准?")
+        valid_questions = [
+            "临边防护栏杆的高度和间距要求是什么?",
+            "不同作业高度下的防护措施有何差异?",
+            "现场验收时应重点检查哪些隐患?",
+        ]
+
+        with patch.object(chat, "load_prompt", return_value="prompt"), patch.object(
+            chat.qwen_service,
+            "chat",
+            AsyncMock(return_value=json.dumps({"questions": valid_questions}, ensure_ascii=False)),
+        ):
+            response = await chat.guess_you_want(
+                self._request(),
+                chat.GuessYouWantRequest(ai_message_id=10),
+                db=db,
+            )
+
+        self.assertEqual(response["data"]["questions"], valid_questions)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 10 - 0
shudao-chat-py/utils/config.py

@@ -68,6 +68,15 @@ class AIChatConfig(BaseSettings):
     timeout: int = 600
 
 
+class ThinkingSummaryConfig(BaseSettings):
+    """思考过程二次总结(方案三)配置"""
+    enabled: bool = True
+    max_points: int = 5
+    max_input_chars: int = 1500
+    max_output_chars: int = 600
+    temperature: float = 0.2
+
+
 class Settings:
     def __init__(self, config_path: str = "config.yaml"):
         # 获取项目根目录
@@ -95,6 +104,7 @@ class Settings:
         self.auth = AuthConfig(**config_data.get('auth', {}))
         self.oss = OSSConfig(**config_data.get('oss', {}))
         self.aichat = AIChatConfig(**config_data.get('aichat', {}))
+        self.thinking_summary = ThinkingSummaryConfig(**config_data.get('thinking_summary', {}))
         self.base_url = config_data.get(
             'base_url', 'https://aqai.shudaodsj.com:22001')
 

+ 7 - 1
shudao-chat-py/utils/prompt_loader.py

@@ -25,6 +25,7 @@ class PromptLoader:
         self.config = {}
         self.cache = {}  # prompt内容缓存
         self.cache_enabled = True
+        self.auto_reload = False
         
         # 加载配置
         self._load_config()
@@ -42,6 +43,7 @@ class PromptLoader:
             # 读取默认配置
             defaults = self.config.get('defaults', {})
             self.cache_enabled = defaults.get('cache_enabled', True)
+            self.auto_reload = defaults.get('auto_reload', False)
             
             logger.info(f"Prompt配置加载成功,共 {len(self.config.get('prompts', {}))} 个模板")
             
@@ -98,7 +100,11 @@ class PromptLoader:
         encoding = prompt_info.get('encoding', 'utf-8')
         
         # 从缓存或文件读取内容
-        if self.cache_enabled and prompt_key in self.cache:
+        if self.auto_reload:
+            content = self._read_prompt_file(file_path, encoding)
+            if self.cache_enabled:
+                self.cache[prompt_key] = content
+        elif self.cache_enabled and prompt_key in self.cache:
             content = self.cache[prompt_key]
         else:
             content = self._read_prompt_file(file_path, encoding)

+ 397 - 0
shudao-chat-py/utils/thinking_summary.py

@@ -0,0 +1,397 @@
+"""
+思考过程二次总结器(方案三)
+
+将模型输出中的 <think>...</think> / Reasoning 等“思考过程”改写为可展示给用户的中文“思考过程摘要”,
+避免原始长推理链路直接透传到前端/数据库。
+"""
+
+import logging
+import json
+import re
+from typing import Any, List, Tuple
+
+from utils.config import settings
+from utils.prompt_loader import load_prompt
+
+logger = logging.getLogger(__name__)
+
+
+_NOISE_LINE_PATTERNS = [
+    re.compile(r"^\s*```"),
+    re.compile(r"^\s*#+\s*"),
+    re.compile(r"^\s*(thinking process|reasoning)\s*[::]?\s*$", re.IGNORECASE),
+    re.compile(r"^\s*analyze the request\s*[::]?\s*$", re.IGNORECASE),
+    re.compile(r"^\s*(role|task|input|output)\b", re.IGNORECASE),
+    re.compile(r"^\s*(角色|任务|输入|输出要求|开始输出)\b"),
+    re.compile(r"^\s*(意图分析|意图识别|思路分析|分析结果|思考过程摘要|思考过程|思考要点)\s*[::]?\s*$"),
+]
+
+_NOISE_CONTAINS_PATTERNS = [
+    re.compile(r"\bThinking Process\b", re.IGNORECASE),
+    re.compile(r"\bAnalyze the Request\b", re.IGNORECASE),
+    re.compile(r"\bRole\b\s*[::]", re.IGNORECASE),
+    re.compile(r"\bTask\b\s*[::]", re.IGNORECASE),
+    re.compile(r"\bInput\b\s*[::]", re.IGNORECASE),
+    re.compile(r"\bOutput\b\s*[::]", re.IGNORECASE),
+    re.compile(r"\bFinal Answer\b", re.IGNORECASE),
+    re.compile(r"思考要点总结器|思考过程展示改写器"),
+]
+
+_LEADING_LABEL_RE = re.compile(
+    r"^\s*(思考过程摘要|思考过程|思考要点|要点|总结|意图分析|意图识别|思路分析|分析结果)\s*[::]\s*"
+)
+
+
+def _try_parse_json_text(text: str) -> bool:
+    candidate = (text or "").strip()
+    if not candidate:
+        return False
+    if not (candidate.startswith("{") or candidate.startswith("[")):
+        return False
+    try:
+        json.loads(candidate)
+        return True
+    except Exception:
+        return False
+
+
+def _extract_first_json_object(text: str) -> str:
+    """提取文本中第一个平衡的大括号 JSON 片段(不保证一定可解析)。"""
+    source = text or ""
+    start_idx = source.find("{")
+    if start_idx == -1:
+        return ""
+
+    depth = 0
+    in_string = False
+    escape = False
+    for idx in range(start_idx, len(source)):
+        ch = source[idx]
+        if escape:
+            escape = False
+            continue
+        if ch == "\\":
+            escape = True
+            continue
+        if ch == '"':
+            in_string = not in_string
+            continue
+        if in_string:
+            continue
+        if ch == "{":
+            depth += 1
+        elif ch == "}":
+            depth -= 1
+            if depth == 0:
+                return source[start_idx: idx + 1]
+    return source[start_idx:]
+
+
+def _extract_json_from_model_output(text: str) -> Tuple[str, str]:
+    """从模型输出中提取 JSON 段,并返回其前面的说明/思考文本。"""
+    response_text = (text or "").strip()
+    if not response_text:
+        return "", ""
+
+    code_block = re.search(
+        r"```(?:json)?\s*\n?(.*?)\n?```",
+        response_text,
+        re.DOTALL | re.IGNORECASE,
+    )
+    if code_block:
+        candidate = (code_block.group(1) or "").strip()
+        if _try_parse_json_text(candidate):
+            return response_text[: code_block.start()].strip(), candidate
+
+    candidate = _extract_first_json_object(response_text).strip()
+    if candidate and candidate != response_text and _try_parse_json_text(candidate):
+        start_idx = response_text.find(candidate)
+        thinking = response_text[:start_idx].strip() if start_idx >= 0 else ""
+        return thinking, candidate
+
+    return "", ""
+
+
+def split_thinking_and_answer(text: str) -> Tuple[str, str]:
+    """
+    拆分模型输出中的思考过程与正式回答。
+
+    - 优先识别 <think>...</think>
+    - 其次识别以 Reasoning/Thinking Process/思考过程 开头且包含 Final Answer/Answer 标记的格式
+    - 未匹配到明确分隔符时,不做拆分
+    """
+    response_text = (text or "").strip()
+    if not response_text:
+        return "", ""
+
+    think_match = re.search(
+        r"<think>\s*(.*?)\s*</think>\s*(.*)",
+        response_text,
+        re.DOTALL | re.IGNORECASE,
+    )
+    if think_match:
+        return think_match.group(1).strip(), think_match.group(2).strip()
+
+    leading_reasoning = re.match(
+        r"^\s*(Thinking Process|Reasoning|思考过程|思维链|推理过程)\s*[::]\s*",
+        response_text,
+        re.IGNORECASE,
+    )
+    if not leading_reasoning:
+        return "", response_text
+
+    final_marker = re.search(
+        r"\n\s*(Final Answer|Final Response|Answer|最终答案|正式回答|最终回复|输出结果)\s*[::]\s*",
+        response_text,
+        re.IGNORECASE,
+    )
+    if not final_marker:
+        extracted_thinking, extracted_json = _extract_json_from_model_output(response_text)
+        if extracted_json:
+            extracted_thinking = re.sub(
+                r"^\s*(Thinking Process|Reasoning|思考过程|思维链|推理过程)\s*[::]\s*",
+                "",
+                (extracted_thinking or "").strip(),
+                flags=re.IGNORECASE,
+            ).strip()
+            return extracted_thinking, extracted_json.strip()
+        return "", response_text
+
+    thinking = response_text[: final_marker.start()].strip()
+    answer = response_text[final_marker.end() :].strip()
+    thinking = re.sub(
+        r"^\s*(Thinking Process|Reasoning|思考过程|思维链|推理过程)\s*[::]\s*",
+        "",
+        thinking,
+        flags=re.IGNORECASE,
+    ).strip()
+    return thinking, answer
+
+
+_LIST_PREFIX_RE = re.compile(r"^\s*(?:[-•*]\s+|\d+\s*[.)、]\s+)")
+
+_REQUIRED_PREFIX_RE = re.compile(r"^\s*(?:我们需要理解问题:用户问的是|嗯,用户问的是)")
+
+# 更强的兜底校验:避免回显 prompt 元信息 / 英文标签 / Markdown 列表等
+_INVALID_SUMMARY_PATTERNS = [
+    re.compile(r"\bThinking Process\b", re.IGNORECASE),
+    re.compile(r"\bAnalyze the Request\b", re.IGNORECASE),
+    re.compile(r"\bFinal Answer\b", re.IGNORECASE),
+    re.compile(r"\b(Role|Task|Input|Output)\b", re.IGNORECASE),
+    re.compile(r"(?:^|\n)\s*(角色|任务|输入|输出要求|输出|开始输出)\s*[::]", re.MULTILINE),
+    re.compile(r"思考要点总结器|思考过程展示改写器"),
+    re.compile(r"```"),
+    re.compile(r"#+\s*\S"),
+    re.compile(r"-\s+|•\s+"),
+    re.compile(r"\d+\s*[.)、]\s+"),
+]
+
+
+def _is_summary_acceptable(text: str) -> bool:
+    summary = (text or "").strip()
+    if not summary:
+        return False
+    if not _REQUIRED_PREFIX_RE.match(summary):
+        return False
+    if any(pattern.search(summary) for pattern in _INVALID_SUMMARY_PATTERNS):
+        return False
+    return True
+
+
+def _strip_noise_lines(text: str) -> str:
+    lines = (text or "").splitlines()
+    kept: List[str] = []
+    for raw_line in lines:
+        line = raw_line.rstrip()
+        stripped = line.strip()
+        if not stripped:
+            kept.append("")
+            continue
+
+        if any(pattern.match(stripped) for pattern in _NOISE_LINE_PATTERNS):
+            continue
+        if any(pattern.search(stripped) for pattern in _NOISE_CONTAINS_PATTERNS):
+            continue
+
+        stripped = _LEADING_LABEL_RE.sub("", stripped).strip()
+        had_list_prefix = bool(_LIST_PREFIX_RE.match(stripped))
+        stripped = _LIST_PREFIX_RE.sub("", stripped).strip()
+        if had_list_prefix and stripped and stripped[-1] not in "。!?;!?;":
+            stripped = stripped.rstrip(",,") + ";"
+        if stripped:
+            kept.append(stripped)
+
+    cleaned = "\n".join(kept)
+    cleaned = re.sub(r"\n\s*\n\s*\n+", "\n\n", cleaned).strip()
+    return cleaned
+
+
+def _split_paragraphs(text: str) -> List[str]:
+    chunks = [chunk.strip() for chunk in re.split(r"\n\s*\n+", text or "") if chunk.strip()]
+    paragraphs: List[str] = []
+    for chunk in chunks:
+        paragraph = re.sub(r"\s*\n\s*", "", chunk).strip()
+        if paragraph:
+            paragraphs.append(paragraph)
+    return paragraphs
+
+
+def normalize_thinking_summary(
+    text: str,
+    *,
+    max_points: int = 5,
+    max_output_chars: int = 600,
+) -> str:
+    """将模型输出归一化为中文自然段(最多 max_points 段)。"""
+    raw_text = (text or "").strip()
+    if not raw_text:
+        return ""
+
+    cleaned = _strip_noise_lines(raw_text)
+    if not cleaned:
+        return ""
+
+    paragraphs = _split_paragraphs(cleaned)
+    if not paragraphs:
+        return ""
+
+    paragraphs = paragraphs[: max(1, int(max_points or 5))]
+    normalized = "\n\n".join(paragraphs).strip()
+
+    if max_output_chars and len(normalized) > int(max_output_chars):
+        normalized = normalized[: int(max_output_chars)].rstrip()
+
+    return normalized
+
+
+def _build_fallback_summary(
+    user_question: str,
+    *,
+    max_points: int,
+    max_output_chars: int,
+) -> str:
+    question = (user_question or "").strip()
+    if not question:
+        question = "这个问题"
+
+    engineering_keywords = (
+        "施工",
+        "支架",
+        "脚手架",
+        "架桥机",
+        "桥梁",
+        "隧道",
+        "混凝土",
+        "钢筋",
+        "模板",
+        "验算",
+        "荷载",
+        "地基",
+        "扣件",
+        "碗扣",
+    )
+    is_engineering = any(keyword in question for keyword in engineering_keywords)
+    domain_hint = "工程施工/技术" if is_engineering else "需要结构化说明"
+
+    outline_paragraph = (
+        "我会按常见施工方案结构来组织回答:工程概况 → 编制依据 → 施工准备 → 设计与参数 → 施工工艺 → 检查验收与监测 → 拆除 → 安全与应急 → 计算要点。"
+        if is_engineering
+        else "在信息不充分的情况下,我会先按通用结构组织回答:背景/目标与依据 → 核心要点与步骤 → 注意事项与边界条件 → 需要追问的关键信息。"
+    )
+    data_paragraph = (
+        "如果涉及验算或参数,我不会直接给出精确数值结论(缺少荷载、尺寸、工况等数据),而是说明验算项目、取值原则与需要补充的信息。"
+        if is_engineering
+        else "如果涉及具体数据或条件(时间/地区/预算/限制等),我会说明我的假设、可选口径,并提示需要你进一步确认的关键信息。"
+    )
+
+    paragraphs = [
+        f"我们需要理解问题:用户问的是“{question}”。这更像是一个{domain_hint}类问题,我需要先确认用户希望得到的是方案框架、步骤清单,还是关键控制点与注意事项。",
+        outline_paragraph,
+        data_paragraph,
+        "如果你能补充具体场景(适用对象/工况/阶段/关注重点),我可以把回答细化为可执行的流程清单与检查表。",
+    ]
+
+    paragraphs = paragraphs[: max(1, int(max_points or 3))]
+    text = "\n\n".join(paragraphs).strip()
+    if max_output_chars and len(text) > int(max_output_chars):
+        text = text[: int(max_output_chars)].rstrip()
+    return text
+
+
+async def summarize_thinking_content(
+    *,
+    user_question: str,
+    raw_thinking: str,
+    final_answer: str = "",
+    chat_service: Any,
+    context: str = "",
+) -> str:
+    """
+    二次总结原始思考过程,返回可展示的中文“思考过程摘要”(自然段)。
+
+    失败兜底:返回空字符串,避免回退 raw_thinking(防止暴露长推理链路)。
+    """
+    cfg = getattr(settings, "thinking_summary", None)
+    enabled = getattr(cfg, "enabled", True) if cfg else True
+    if not enabled:
+        return ""
+
+    thinking_text = (raw_thinking or "").strip()
+    if not thinking_text:
+        return ""
+
+    max_points = int(getattr(cfg, "max_points", 5) if cfg else 5)
+    max_input_chars = int(getattr(cfg, "max_input_chars", 1500) if cfg else 1500)
+    max_output_chars = int(getattr(cfg, "max_output_chars", 600) if cfg else 600)
+    # temperature 预留配置项(当前 qwen_service.chat 未透传该参数)
+    _ = float(getattr(cfg, "temperature", 0.2) if cfg else 0.2)
+
+    if max_input_chars and len(thinking_text) > max_input_chars:
+        thinking_text = thinking_text[:max_input_chars]
+
+    prompt = load_prompt(
+        "thinking_summary",
+        userMessage=user_question or "",
+        thinkingText=thinking_text,
+        finalAnswer=final_answer or "",
+        maxPoints=str(max_points),
+    )
+    if not (prompt or "").strip():
+        logger.warning("[thinking_summary] Prompt 为空,已跳过")
+        return ""
+
+    try:
+        response = await chat_service.chat(
+            [{"role": "user", "content": prompt}],
+        )
+    except Exception as exc:
+        logger.warning(
+            f"[thinking_summary] 生成失败({context or 'unknown'}): {type(exc).__name__}",
+            exc_info=True,
+        )
+        return _build_fallback_summary(
+            user_question=user_question,
+            max_points=max_points,
+            max_output_chars=max_output_chars,
+        )
+
+    # 避免总结器自己又输出 <think> 段
+    _, summary_final = split_thinking_and_answer(response or "")
+    summary_text = (summary_final or response or "").strip()
+
+    normalized = normalize_thinking_summary(
+        summary_text,
+        max_points=max_points,
+        max_output_chars=max_output_chars,
+    )
+    if not normalized or not _is_summary_acceptable(normalized):
+        return _build_fallback_summary(
+            user_question=user_question,
+            max_points=max_points,
+            max_output_chars=max_output_chars,
+        )
+
+    logger.info(
+        f"[thinking_summary] 生成成功({context or 'unknown'}) | raw_len={len(raw_thinking or '')} | out_len={len(normalized)}"
+    )
+    return normalized

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

@@ -28,6 +28,224 @@ export const normalizeReportsForPersistence = (reports) => {
   return hydratePersistedReports(reports)
 }
 
+const extractBalancedJson = (text) => {
+  if (typeof text !== 'string') {
+    return ''
+  }
+
+  const source = text.trim()
+  const startIndex = source.search(/[\[{]/)
+  if (startIndex === -1) {
+    return ''
+  }
+
+  const opening = source[startIndex]
+  const closing = opening === '{' ? '}' : ']'
+  let depth = 0
+  let inString = false
+  let escaped = false
+
+  for (let index = startIndex; index < source.length; index += 1) {
+    const char = source[index]
+
+    if (escaped) {
+      escaped = false
+      continue
+    }
+
+    if (char === '\\') {
+      escaped = true
+      continue
+    }
+
+    if (char === '"') {
+      inString = !inString
+      continue
+    }
+
+    if (inString) {
+      continue
+    }
+
+    if (char === opening) {
+      depth += 1
+      continue
+    }
+
+    if (char === closing) {
+      depth -= 1
+      if (depth === 0) {
+        return source.slice(startIndex, index + 1)
+      }
+    }
+  }
+
+  return ''
+}
+
+const toQuestionArray = (value) => {
+  if (Array.isArray(value)) {
+    return value
+  }
+
+  if (value && typeof value === 'object' && Array.isArray(value.questions)) {
+    return value.questions
+  }
+
+  return []
+}
+
+const normalizeQuestion = (value) => {
+  if (typeof value !== 'string') {
+    return ''
+  }
+
+  return value
+    .trim()
+    .replace(/^[0-9]+[\.\)\]\u3001]\s*/, '')
+    .replace(/^[-*]\s+/, '')
+    .trim()
+}
+
+const isInvalidRelatedQuestion = (value) => {
+  const question = normalizeQuestion(value)
+  if (!question) {
+    return true
+  }
+
+  const lowerQuestion = question.toLowerCase()
+  const placeholderPatterns = [
+    /^q\s*\d+$/,
+    /^question\s*\d+$/,
+    /^questions?\s*\d+$/,
+    /^问题\s*\d+$/,
+    /^相关问题\s*\d+$/,
+    /^推荐问题\s*\d+$/,
+    /^更多相关问题$/,
+    /^更多问题$/
+  ]
+
+  if (placeholderPatterns.some(pattern => pattern.test(lowerQuestion))) {
+    return true
+  }
+
+  const blockedKeywords = [
+    'thinking process',
+    'analyze the request',
+    'role:',
+    '**role',
+    'professional question recommendation',
+    'infrastructure construction technology',
+    'output format',
+    'prompt',
+    'system',
+    'assistant',
+    '角色定义',
+    '任务目标',
+    '输入内容',
+    '生成要求',
+    '输出格式',
+    '开始生成'
+  ]
+
+  return blockedKeywords.some(keyword => lowerQuestion.includes(keyword))
+}
+
+export const extractRelatedQuestions = (source, limit = 3) => {
+  if (!source) {
+    return []
+  }
+
+  let questions = toQuestionArray(source)
+
+  if (!questions.length && typeof source === 'string') {
+    const trimmed = source.trim()
+
+    if (trimmed) {
+      const candidates = [
+        trimmed,
+        trimmed
+          .replace(/^```(?:json)?/i, '')
+          .replace(/```$/i, '')
+          .trim(),
+        extractBalancedJson(trimmed)
+      ].filter(Boolean)
+
+      for (const candidate of candidates) {
+        try {
+          questions = toQuestionArray(JSON.parse(candidate))
+        } catch {
+          questions = []
+        }
+
+        if (questions.length) {
+          break
+        }
+      }
+
+      if (!questions.length) {
+        questions = trimmed.split(/\r?\n/)
+      }
+    }
+  }
+
+  return [...new Set(
+    questions
+      .map(normalizeQuestion)
+      .filter(question => question && !isInvalidRelatedQuestion(question))
+  )].slice(0, limit)
+}
+
+export const splitHtmlIntoTypewriterChunks = (content) => {
+  if (!content) {
+    return []
+  }
+
+  const chunks = []
+  let currentText = ''
+  let currentTag = ''
+  let inTag = false
+
+  for (const char of String(content)) {
+    if (char === '<') {
+      if (currentText) {
+        chunks.push(...currentText.split(''))
+        currentText = ''
+      }
+
+      inTag = true
+      currentTag = '<'
+      continue
+    }
+
+    if (inTag) {
+      currentTag += char
+      if (char === '>') {
+        chunks.push(currentTag)
+        currentTag = ''
+        inTag = false
+      }
+      continue
+    }
+
+    currentText += char
+  }
+
+  if (currentText) {
+    chunks.push(...currentText.split(''))
+  }
+
+  if (currentTag) {
+    chunks.push(currentTag)
+  }
+
+  return chunks
+}
+
+export const shouldClearSummaryForOnlineAnswer = (message) => {
+  return message?.isProfessionalQuestion === false
+}
+
 export const buildPersistedAIMessageContent = (message) => {
   if (!message) {
     return ''

+ 126 - 31
shudao-vue-frontend/src/utils/chatHistoryPersistence.test.js

@@ -3,8 +3,11 @@ import { describe, expect, it } from 'vitest'
 import {
   buildAIMessageUpdatePayload,
   buildPersistedAIMessageContent,
+  extractRelatedQuestions,
   hydratePersistedReports,
-  normalizeReportsForPersistence
+  normalizeReportsForPersistence,
+  shouldClearSummaryForOnlineAnswer,
+  splitHtmlIntoTypewriterChunks
 } from './chatHistoryPersistence'
 
 describe('chatHistoryPersistence', () => {
@@ -20,10 +23,10 @@ describe('chatHistoryPersistence', () => {
           clauses: ''
         },
         _fullContent: {
-          display_name: '桥梁施工规范.pdf',
-          summary: '完整摘要',
-          analysis: '完整分析',
-          clauses: '完整条款'
+          display_name: 'bridge-spec.pdf',
+          summary: 'full summary',
+          analysis: 'full analysis',
+          clauses: 'full clauses'
         }
       }
     ]
@@ -31,10 +34,10 @@ describe('chatHistoryPersistence', () => {
     expect(normalizeReportsForPersistence(reports)).toEqual([
       expect.objectContaining({
         report: {
-          display_name: '桥梁施工规范.pdf',
-          summary: '完整摘要',
-          analysis: '完整分析',
-          clauses: '完整条款'
+          display_name: 'bridge-spec.pdf',
+          summary: 'full summary',
+          analysis: 'full analysis',
+          clauses: 'full clauses'
         }
       })
     ])
@@ -52,16 +55,16 @@ describe('chatHistoryPersistence', () => {
           clauses: ''
         },
         _fullContent: {
-          display_name: '混凝土养护说明.pdf',
-          summary: '已保存的完整摘要',
-          analysis: '已保存的完整分析',
+          display_name: 'concrete-maintenance.pdf',
+          summary: 'saved summary',
+          analysis: 'saved analysis',
           clauses: ''
         }
       },
       {
         type: 'category_title',
-        category: '国家规范',
-        number: '',
+        category: 'National Standards',
+        number: 'I',
         count: 1
       }
     ]
@@ -69,15 +72,15 @@ describe('chatHistoryPersistence', () => {
     expect(hydratePersistedReports(reports)).toEqual([
       expect.objectContaining({
         report: {
-          display_name: '混凝土养护说明.pdf',
-          summary: '已保存的完整摘要',
-          analysis: '已保存的完整分析',
+          display_name: 'concrete-maintenance.pdf',
+          summary: 'saved summary',
+          analysis: 'saved analysis',
           clauses: ''
         }
       }),
       expect.objectContaining({
         type: 'category_title',
-        category: '国家规范'
+        category: 'National Standards'
       })
     ])
   })
@@ -86,7 +89,7 @@ describe('chatHistoryPersistence', () => {
     const content = buildPersistedAIMessageContent({
       reports: [],
       summary: '',
-      _fullSummary: '已识别到专业问题,正在分析相关规范。',
+      _fullSummary: 'The assistant identified a professional question and is analyzing the relevant standards.',
       webSearchRaw: null,
       webSearchSummary: null,
       hasWebSearchResults: false,
@@ -98,22 +101,24 @@ describe('chatHistoryPersistence', () => {
       webSearchRaw: null,
       webSearchSummary: null,
       hasWebSearchResults: false,
-      summary: '已识别到专业问题,正在分析相关规范。'
+      summary: 'The assistant identified a professional question and is analyzing the relevant standards.'
     })
   })
 
   it('persists the full summary instead of the partial typewriter text', () => {
     const content = buildPersistedAIMessageContent({
       reports: [],
-      summary: '好的!我理解您提出的问题,',
-      _fullSummary: '好的!我理解您提出的问题,这个问题主要围绕满堂支架施工的技术与安全管控。',
+      summary: 'Short partial summary',
+      _fullSummary: 'Long complete summary that should be stored instead of the partial typewriter text.',
       webSearchRaw: null,
       webSearchSummary: null,
       hasWebSearchResults: false,
       content: ''
     })
 
-    expect(JSON.parse(content).summary).toBe('好的!我理解您提出的问题,这个问题主要围绕满堂支架施工的技术与安全管控。')
+    expect(JSON.parse(content).summary).toBe(
+      'Long complete summary that should be stored instead of the partial typewriter text.'
+    )
   })
 
   it('keeps thinking content in structured professional payloads', () => {
@@ -121,16 +126,18 @@ describe('chatHistoryPersistence', () => {
       reports: [
         {
           status: 'completed',
-          report: { display_name: 'file.pdf', summary: '概述', analysis: '', clauses: '' }
+          report: { display_name: 'file.pdf', summary: 'overview', analysis: '', clauses: '' }
         }
       ],
       summary: '',
-      _fullSummary: '完整概述',
-      thinkingContent: '检索到规范并完成分析',
+      _fullSummary: 'full overview',
+      thinkingContent: 'found the relevant standards and completed the analysis',
       content: ''
     })
 
-    expect(JSON.parse(content).thinkingContent).toBe('检索到规范并完成分析')
+    expect(JSON.parse(content).thinkingContent).toBe(
+      'found the relevant standards and completed the analysis'
+    )
   })
 
   it('builds an update payload for completed non-typing messages', () => {
@@ -140,7 +147,7 @@ describe('chatHistoryPersistence', () => {
       ai_message_id: 26911,
       reports: [],
       summary: '',
-      _fullSummary: '架桥机施工流程应按设备检查、拼装调试、试吊、架梁和复核验收执行。',
+      _fullSummary: 'The construction flow should follow inspection, assembly, trial hoisting, erection, and acceptance.',
       content: ''
     })
 
@@ -151,7 +158,7 @@ describe('chatHistoryPersistence', () => {
         webSearchRaw: null,
         webSearchSummary: null,
         hasWebSearchResults: false,
-        summary: '架桥机施工流程应按设备检查、拼装调试、试吊、架梁和复核验收执行。'
+        summary: 'The construction flow should follow inspection, assembly, trial hoisting, erection, and acceptance.'
       })
     })
   })
@@ -170,9 +177,97 @@ describe('chatHistoryPersistence', () => {
   it('returns plain text for direct AI answers without structured report data', () => {
     expect(buildPersistedAIMessageContent({
       reports: [],
-      content: '你好,我在。',
+      content: 'Hello, I am here.',
       summary: '',
       _fullSummary: ''
-    })).toBe('你好,我在。')
+    })).toBe('Hello, I am here.')
   })
+
+  it('keeps the intent summary when a professional reply receives an online answer', () => {
+    expect(shouldClearSummaryForOnlineAnswer({
+      isProfessionalQuestion: true,
+      summary: 'The assistant understood the professional scaffold plan question.'
+    })).toBe(false)
+  })
+
+  it('clears the intent summary for non-professional online answers to avoid duplicate text', () => {
+    expect(shouldClearSummaryForOnlineAnswer({
+      isProfessionalQuestion: false,
+      summary: 'Hello, I can help.'
+    })).toBe(true)
+  })
+
+  it('extracts related questions from stored JSON payloads', () => {
+    expect(
+      extractRelatedQuestions('{"questions":["How is it applied on site?","What risks should be checked first?","Which standards are usually cited?"]}')
+    ).toEqual([
+      'How is it applied on site?',
+      'What risks should be checked first?',
+      'Which standards are usually cited?'
+    ])
+  })
+
+  it('extracts related questions from legacy newline text', () => {
+    expect(
+      extractRelatedQuestions('1. How is it applied on site?\n2. What risks should be checked first?\n3. Which standards are usually cited?')
+    ).toEqual([
+      'How is it applied on site?',
+      'What risks should be checked first?',
+      'Which standards are usually cited?'
+    ])
+  })
+
+  it('supports API payloads that already expose a questions array', () => {
+    expect(
+      extractRelatedQuestions({
+        questions: [
+          'How is it applied on site?',
+          'What risks should be checked first?',
+          'Which standards are usually cited?',
+          'This extra question should be trimmed'
+        ]
+      })
+    ).toEqual([
+      'How is it applied on site?',
+      'What risks should be checked first?',
+      'Which standards are usually cited?'
+    ])
+  })
+
+  it('filters prompt leakage and placeholder related questions', () => {
+    expect(
+      extractRelatedQuestions({
+        questions: [
+          'Thinking Process:',
+          '**Analyze the Request:**',
+          '**Role:** Professional question recommendation assistant focused on infrastructure construction technology.',
+          'q1',
+          '问题1'
+        ]
+      })
+    ).toEqual([])
+  })
+
+  it('splits rendered HTML into safe typewriter chunks', () => {
+    expect(
+      splitHtmlIntoTypewriterChunks('<p>Hello <strong>world</strong></p>')
+    ).toEqual([
+      '<p>',
+      'H',
+      'e',
+      'l',
+      'l',
+      'o',
+      ' ',
+      '<strong>',
+      'w',
+      'o',
+      'r',
+      'l',
+      'd',
+      '</strong>',
+      '</p>'
+    ])
+  })
+
 })

+ 35 - 33
shudao-vue-frontend/src/views/Chat.vue

@@ -682,7 +682,14 @@ import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
 // ===== 已删除:getUserId - 不再需要,改用token =====
 // import { getUserId } from '@/utils/userManager.js'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
-import { buildAIMessageUpdatePayload, hydratePersistedReports, normalizeReportsForPersistence } from '@/utils/chatHistoryPersistence.js'
+import {
+  buildAIMessageUpdatePayload,
+  extractRelatedQuestions,
+  hydratePersistedReports,
+  normalizeReportsForPersistence,
+  shouldClearSummaryForOnlineAnswer,
+  splitHtmlIntoTypewriterChunks
+} from '@/utils/chatHistoryPersistence.js'
 import { getToken, getTokenType } from '@/utils/auth.js'
 import { renderMarkdown } from '@/utils/markdown'
 import 'katex/dist/katex.min.css'
@@ -1141,18 +1148,28 @@ const startTypewriterEffect = (message, fullContent, speed = 30) => {
       typewriterIntervals.delete(message.id)
     }
     
+    const chunks = splitHtmlIntoTypewriterChunks(fullContent)
     let currentIndex = 0
     message.displayContent = ''
     message.isTyping = true
     
     const interval = setInterval(() => {
-      if (currentIndex < fullContent.length) {
+      if (currentIndex < chunks.length) {
         // 每次增加的字符数(速度越快,每次增加越多)
         const charsToAdd = Math.max(1, Math.floor(speed / 10))
-        currentIndex = Math.min(currentIndex + charsToAdd, fullContent.length)
+        let charsAdded = 0
+
+        while (currentIndex < chunks.length && charsAdded < charsToAdd) {
+          const chunk = chunks[currentIndex]
+          message.displayContent += chunk
+          currentIndex += 1
+
+          if (!(chunk.startsWith('<') && chunk.endsWith('>'))) {
+            charsAdded += 1
+          }
+        }
         
         // 更新显示内容
-        message.displayContent = fullContent.substring(0, currentIndex)
       } else {
         // 打字完成
         clearInterval(interval)
@@ -1668,14 +1685,11 @@ const getConversationMessages = async (conversationId) => {
           }
           
           // 恢复推荐问题(只处理最后一条AI消息)
-          if (message === lastAIMessage && message.rawData.guess_you_want) {
+          if (message === lastAIMessage && (message.rawData.guess_you_want || message.rawData.relate_question)) {
             try {
-              const questions = message.rawData.guess_you_want.trim()
-                .split('\n')
-                .map(q => q.trim())
-                .filter(q => q.length > 0)
-                .filter((q, index, arr) => arr.indexOf(q) === index)
-                .slice(0, 3)
+              const questions = extractRelatedQuestions(
+                message.rawData.guess_you_want || message.rawData.relate_question
+              )
               
               if (questions.length > 0) {
                 aiRelatedQuestions.value = questions
@@ -2478,6 +2492,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       break
 
     case 'intent':
+      aiMessage.isProfessionalQuestion = data.is_professional_question !== false
       appendThinkingContent(aiMessage, '意图分析', data.thinking_content)
 
       // 检查是否为专业问题
@@ -2521,7 +2536,10 @@ const handleSSEMessage = (data, aiMessageIndex) => {
 
     case 'online_answer': {
       aiMessage.showStats = false
-      aiMessage.summary = ''
+      if (shouldClearSummaryForOnlineAnswer(aiMessage)) {
+        aiMessage.summary = ''
+        aiMessage._fullSummary = ''
+      }
       appendThinkingContent(aiMessage, '正式回答', data.thinking_content)
 
       const finalContent = data.content || ''
@@ -3255,6 +3273,7 @@ const handleReportGeneratorSubmit = async (data) => {
     type: 'ai',
     userQuestion: data.question, // 用户问题
     summary: '',
+    isProfessionalQuestion: null,
     thinkingContent: '',
     showThinking: true,
     totalFiles: 0,
@@ -4368,28 +4387,11 @@ AI回复:${aiReply}
     
     console.log('AI相关推荐问题响应:', response)
     
-    if (response.statusCode === 200 && response.data && response.data.reply) {
+    if (response.statusCode === 200) {
       // 解析大模型返回的推荐问题
-      const replyText = response.data.reply.trim()
-      const questions = replyText.split('\n')
-        .map(q => q.trim())
-        .filter(q => q.length > 0)
-        // 过滤掉包含提示词、标题、说明文字的行
-        .filter(q => {
-          // 过滤包含特定关键词的行
-          const excludeKeywords = ['请生成', '推荐问题', '相关问题', '生成要求', '对话内容', '【', '】', '指令']
-          return !excludeKeywords.some(keyword => q.includes(keyword))
-        })
-        // 过滤掉以"-"或"•"开头的行(通常是说明文字)
-        .filter(q => !q.startsWith('-') && !q.startsWith('•') && !q.startsWith('*'))
-        // 移除可能的编号前缀(1. 2. 3. 或 1、2、3、)
-        .map(q => q.replace(/^[0-9]+[\.\、]\s*/, ''))
-        .filter(q => q.length > 0)
-        // 过滤掉包含冒号的行(通常是标题)
-        .filter(q => !q.includes(':') && !q.includes(':'))
-        .filter((q, index, arr) => arr.indexOf(q) === index) // 去重
-        .slice(0, 3) // 只取前3个
-      
+      const questions = extractRelatedQuestions(
+        response?.data?.questions ?? response?.data?.reply ?? response?.data ?? response
+      )
       if (questions.length > 0) {
         aiRelatedQuestions.value = questions
         relatedQuestionsMessageId.value = messageId

+ 20 - 39
shudao-vue-frontend/src/views/mobile/m-Chat.vue

@@ -470,7 +470,13 @@ import { createSSEConnection, closeSSEConnection } from '@/utils/sse'
 import { getApiPrefix, BACKEND_API_PREFIX } from '@/utils/apiConfig'
 import { renderMarkdown } from '@/utils/markdown'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
-import { buildAIMessageUpdatePayload, hydratePersistedReports, normalizeReportsForPersistence } from '@/utils/chatHistoryPersistence.js'
+import {
+  buildAIMessageUpdatePayload,
+  extractRelatedQuestions,
+  hydratePersistedReports,
+  normalizeReportsForPersistence,
+  shouldClearSummaryForOnlineAnswer
+} from '@/utils/chatHistoryPersistence.js'
 import { getToken, getTokenType, getUserName, getAccountId } from '@/utils/auth.js'
 import { initNativeNavForSubPage } from '@/utils/nativeBridge.js'
 import Vditor from 'vditor'
@@ -1396,35 +1402,9 @@ const getConversationMessages = async (conversationId) => {
         
         // 恢复推荐问题(只处理最后一条AI消息)
           if (message === lastAIMessage) {
-            let questions = []
-          
-            // 尝试从guess_you_want字段恢复(文本格式,换行分隔)
-            if (message.rawData.guess_you_want) {
-          try {
-                questions = message.rawData.guess_you_want.trim()
-                  .split('\n')
-              .map(q => q.trim())
-              .filter(q => q.length > 0)
-                  .filter((q, index, arr) => arr.indexOf(q) === index)
-                  .slice(0, 3)
-              } catch (error) {
-                console.error('解析guess_you_want失败:', error)
-              }
-            }
-            
-            // 如果guess_you_want没有数据,尝试从relate_question恢复(JSON格式)
-            if (questions.length === 0 && message.rawData.relate_question) {
-              try {
-                const relatedQuestions = JSON.parse(message.rawData.relate_question)
-                if (Array.isArray(relatedQuestions) && relatedQuestions.length > 0) {
-                  questions = relatedQuestions.slice(0, 3)
-                }
-              } catch (error) {
-                console.error('解析relate_question失败:', error)
-              }
-            }
-            
-            // 设置推荐问题
+            const questions = extractRelatedQuestions(
+              message.rawData.guess_you_want || message.rawData.relate_question
+            )
             if (questions.length > 0) {
               aiRelatedQuestions.value = questions
               // 使用ai_message_id以确保显示条件匹配
@@ -2510,6 +2490,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
   
   switch (data.type) {
     case 'intent':
+      aiMessage.isProfessionalQuestion = data.is_professional_question !== false
       // 检查是否为专业问题
       if (data.is_professional_question === false) {
         // 非专业问题:立即隐藏状态显示组件
@@ -2551,7 +2532,10 @@ const handleSSEMessage = (data, aiMessageIndex) => {
 
     case 'online_answer': {
       aiMessage.showStats = false
-      aiMessage.summary = ''
+      if (shouldClearSummaryForOnlineAnswer(aiMessage)) {
+        aiMessage.summary = ''
+        aiMessage._fullSummary = ''
+      }
 
       const finalContent = data.content || ''
       aiMessage.content = finalContent
@@ -3255,6 +3239,7 @@ const handleReportGeneratorSubmit = async (data) => {
     type: 'ai',
     userQuestion: data.question, // 用户问题
     summary: '',
+    isProfessionalQuestion: null,
     totalFiles: 0,
     webSearchTotal: 0,
     progress: 0,
@@ -3360,15 +3345,11 @@ AI回复:${aiReply}
     
     console.log('AI相关推荐问题响应:', response)
     
-    if (response.statusCode === 200 && response.data && response.data.reply) {
+    if (response.statusCode === 200) {
       // 解析大模型返回的推荐问题
-      const replyText = response.data.reply.trim()
-      const questions = replyText.split('\n')
-        .map(q => q.trim())
-        .filter(q => q.length > 0)
-        .filter((q, index, arr) => arr.indexOf(q) === index) // 去重
-        .slice(0, 3) // 只取前3个
-      
+      const questions = extractRelatedQuestions(
+        response?.data?.questions ?? response?.data?.reply ?? response?.data ?? response
+      )
       if (questions.length > 0) {
         aiRelatedQuestions.value = questions
         relatedQuestionsMessageId.value = messageId