thinking_summary.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. """
  2. 思考过程二次总结器(方案三)
  3. 将模型输出中的 <think>...</think> / Reasoning 等“思考过程”改写为可展示给用户的中文“思考过程摘要”,
  4. 避免原始长推理链路直接透传到前端/数据库。
  5. """
  6. import logging
  7. import json
  8. import re
  9. from typing import Any, List, Tuple
  10. from utils.config import settings
  11. from utils.prompt_loader import load_prompt
  12. logger = logging.getLogger(__name__)
  13. _NOISE_LINE_PATTERNS = [
  14. re.compile(r"^\s*```"),
  15. re.compile(r"^\s*#+\s*"),
  16. re.compile(r"^\s*(thinking process|reasoning)\s*[::]?\s*$", re.IGNORECASE),
  17. re.compile(r"^\s*analyze the request\s*[::]?\s*$", re.IGNORECASE),
  18. re.compile(r"^\s*(role|task|input|output)\b", re.IGNORECASE),
  19. re.compile(r"^\s*(角色|任务|输入|输出要求|开始输出)\b"),
  20. re.compile(r"^\s*(意图分析|意图识别|思路分析|分析结果|思考过程摘要|思考过程|思考要点)\s*[::]?\s*$"),
  21. ]
  22. _NOISE_CONTAINS_PATTERNS = [
  23. re.compile(r"\bThinking Process\b", re.IGNORECASE),
  24. re.compile(r"\bAnalyze the Request\b", re.IGNORECASE),
  25. re.compile(r"\bRole\b\s*[::]", re.IGNORECASE),
  26. re.compile(r"\bTask\b\s*[::]", re.IGNORECASE),
  27. re.compile(r"\bInput\b\s*[::]", re.IGNORECASE),
  28. re.compile(r"\bOutput\b\s*[::]", re.IGNORECASE),
  29. re.compile(r"\bFinal Answer\b", re.IGNORECASE),
  30. re.compile(r"思考要点总结器|思考过程展示改写器"),
  31. ]
  32. _LEADING_LABEL_RE = re.compile(
  33. r"^\s*(思考过程摘要|思考过程|思考要点|要点|总结|意图分析|意图识别|思路分析|分析结果)\s*[::]\s*"
  34. )
  35. def _try_parse_json_text(text: str) -> bool:
  36. candidate = (text or "").strip()
  37. if not candidate:
  38. return False
  39. if not (candidate.startswith("{") or candidate.startswith("[")):
  40. return False
  41. try:
  42. json.loads(candidate)
  43. return True
  44. except Exception:
  45. return False
  46. def _extract_first_json_object(text: str) -> str:
  47. """提取文本中第一个平衡的大括号 JSON 片段(不保证一定可解析)。"""
  48. source = text or ""
  49. start_idx = source.find("{")
  50. if start_idx == -1:
  51. return ""
  52. depth = 0
  53. in_string = False
  54. escape = False
  55. for idx in range(start_idx, len(source)):
  56. ch = source[idx]
  57. if escape:
  58. escape = False
  59. continue
  60. if ch == "\\":
  61. escape = True
  62. continue
  63. if ch == '"':
  64. in_string = not in_string
  65. continue
  66. if in_string:
  67. continue
  68. if ch == "{":
  69. depth += 1
  70. elif ch == "}":
  71. depth -= 1
  72. if depth == 0:
  73. return source[start_idx: idx + 1]
  74. return source[start_idx:]
  75. def _extract_json_from_model_output(text: str) -> Tuple[str, str]:
  76. """从模型输出中提取 JSON 段,并返回其前面的说明/思考文本。"""
  77. response_text = (text or "").strip()
  78. if not response_text:
  79. return "", ""
  80. code_block = re.search(
  81. r"```(?:json)?\s*\n?(.*?)\n?```",
  82. response_text,
  83. re.DOTALL | re.IGNORECASE,
  84. )
  85. if code_block:
  86. candidate = (code_block.group(1) or "").strip()
  87. if _try_parse_json_text(candidate):
  88. return response_text[: code_block.start()].strip(), candidate
  89. candidate = _extract_first_json_object(response_text).strip()
  90. if candidate and candidate != response_text and _try_parse_json_text(candidate):
  91. start_idx = response_text.find(candidate)
  92. thinking = response_text[:start_idx].strip() if start_idx >= 0 else ""
  93. return thinking, candidate
  94. return "", ""
  95. def split_thinking_and_answer(text: str) -> Tuple[str, str]:
  96. """
  97. 拆分模型输出中的思考过程与正式回答。
  98. - 优先识别 <think>...</think>
  99. - 其次识别以 Reasoning/Thinking Process/思考过程 开头且包含 Final Answer/Answer 标记的格式
  100. - 未匹配到明确分隔符时,不做拆分
  101. """
  102. response_text = (text or "").strip()
  103. if not response_text:
  104. return "", ""
  105. think_match = re.search(
  106. r"<think>\s*(.*?)\s*</think>\s*(.*)",
  107. response_text,
  108. re.DOTALL | re.IGNORECASE,
  109. )
  110. if think_match:
  111. return think_match.group(1).strip(), think_match.group(2).strip()
  112. leading_reasoning = re.match(
  113. r"^\s*(Thinking Process|Reasoning|思考过程|思维链|推理过程)\s*[::]\s*",
  114. response_text,
  115. re.IGNORECASE,
  116. )
  117. if not leading_reasoning:
  118. return "", response_text
  119. final_marker = re.search(
  120. r"\n\s*(Final Answer|Final Response|Answer|最终答案|正式回答|最终回复|输出结果)\s*[::]\s*",
  121. response_text,
  122. re.IGNORECASE,
  123. )
  124. if not final_marker:
  125. extracted_thinking, extracted_json = _extract_json_from_model_output(response_text)
  126. if extracted_json:
  127. extracted_thinking = re.sub(
  128. r"^\s*(Thinking Process|Reasoning|思考过程|思维链|推理过程)\s*[::]\s*",
  129. "",
  130. (extracted_thinking or "").strip(),
  131. flags=re.IGNORECASE,
  132. ).strip()
  133. return extracted_thinking, extracted_json.strip()
  134. return "", response_text
  135. thinking = response_text[: final_marker.start()].strip()
  136. answer = response_text[final_marker.end() :].strip()
  137. thinking = re.sub(
  138. r"^\s*(Thinking Process|Reasoning|思考过程|思维链|推理过程)\s*[::]\s*",
  139. "",
  140. thinking,
  141. flags=re.IGNORECASE,
  142. ).strip()
  143. return thinking, answer
  144. _LIST_PREFIX_RE = re.compile(r"^\s*(?:[-•*]\s+|\d+\s*[.)、]\s+)")
  145. _REQUIRED_PREFIX_RE = re.compile(r"^\s*(?:我们需要理解问题:用户问的是|嗯,用户问的是)")
  146. # 更强的兜底校验:避免回显 prompt 元信息 / 英文标签 / Markdown 列表等
  147. _INVALID_SUMMARY_PATTERNS = [
  148. re.compile(r"\bThinking Process\b", re.IGNORECASE),
  149. re.compile(r"\bAnalyze the Request\b", re.IGNORECASE),
  150. re.compile(r"\bFinal Answer\b", re.IGNORECASE),
  151. re.compile(r"\b(Role|Task|Input|Output)\b", re.IGNORECASE),
  152. re.compile(r"(?:^|\n)\s*(角色|任务|输入|输出要求|输出|开始输出)\s*[::]", re.MULTILINE),
  153. re.compile(r"思考要点总结器|思考过程展示改写器"),
  154. re.compile(r"```"),
  155. re.compile(r"#+\s*\S"),
  156. re.compile(r"-\s+|•\s+"),
  157. re.compile(r"\d+\s*[.)、]\s+"),
  158. ]
  159. def _is_summary_acceptable(text: str) -> bool:
  160. summary = (text or "").strip()
  161. if not summary:
  162. return False
  163. if not _REQUIRED_PREFIX_RE.match(summary):
  164. return False
  165. if any(pattern.search(summary) for pattern in _INVALID_SUMMARY_PATTERNS):
  166. return False
  167. return True
  168. def _strip_noise_lines(text: str) -> str:
  169. lines = (text or "").splitlines()
  170. kept: List[str] = []
  171. for raw_line in lines:
  172. line = raw_line.rstrip()
  173. stripped = line.strip()
  174. if not stripped:
  175. kept.append("")
  176. continue
  177. if any(pattern.match(stripped) for pattern in _NOISE_LINE_PATTERNS):
  178. continue
  179. if any(pattern.search(stripped) for pattern in _NOISE_CONTAINS_PATTERNS):
  180. continue
  181. stripped = _LEADING_LABEL_RE.sub("", stripped).strip()
  182. had_list_prefix = bool(_LIST_PREFIX_RE.match(stripped))
  183. stripped = _LIST_PREFIX_RE.sub("", stripped).strip()
  184. if had_list_prefix and stripped and stripped[-1] not in "。!?;!?;":
  185. stripped = stripped.rstrip(",,") + ";"
  186. if stripped:
  187. kept.append(stripped)
  188. cleaned = "\n".join(kept)
  189. cleaned = re.sub(r"\n\s*\n\s*\n+", "\n\n", cleaned).strip()
  190. return cleaned
  191. def _split_paragraphs(text: str) -> List[str]:
  192. chunks = [chunk.strip() for chunk in re.split(r"\n\s*\n+", text or "") if chunk.strip()]
  193. paragraphs: List[str] = []
  194. for chunk in chunks:
  195. paragraph = re.sub(r"\s*\n\s*", "", chunk).strip()
  196. if paragraph:
  197. paragraphs.append(paragraph)
  198. return paragraphs
  199. def normalize_thinking_summary(
  200. text: str,
  201. *,
  202. max_points: int = 5,
  203. max_output_chars: int = 600,
  204. ) -> str:
  205. """将模型输出归一化为中文自然段(最多 max_points 段)。"""
  206. raw_text = (text or "").strip()
  207. if not raw_text:
  208. return ""
  209. cleaned = _strip_noise_lines(raw_text)
  210. if not cleaned:
  211. return ""
  212. paragraphs = _split_paragraphs(cleaned)
  213. if not paragraphs:
  214. return ""
  215. paragraphs = paragraphs[: max(1, int(max_points or 5))]
  216. normalized = "\n\n".join(paragraphs).strip()
  217. if max_output_chars and len(normalized) > int(max_output_chars):
  218. normalized = normalized[: int(max_output_chars)].rstrip()
  219. return normalized
  220. def _build_fallback_summary(
  221. user_question: str,
  222. *,
  223. max_points: int,
  224. max_output_chars: int,
  225. ) -> str:
  226. question = (user_question or "").strip()
  227. if not question:
  228. question = "这个问题"
  229. engineering_keywords = (
  230. "施工",
  231. "支架",
  232. "脚手架",
  233. "架桥机",
  234. "桥梁",
  235. "隧道",
  236. "混凝土",
  237. "钢筋",
  238. "模板",
  239. "验算",
  240. "荷载",
  241. "地基",
  242. "扣件",
  243. "碗扣",
  244. )
  245. is_engineering = any(keyword in question for keyword in engineering_keywords)
  246. domain_hint = "工程施工/技术" if is_engineering else "需要结构化说明"
  247. outline_paragraph = (
  248. "我会按常见施工方案结构来组织回答:工程概况 → 编制依据 → 施工准备 → 设计与参数 → 施工工艺 → 检查验收与监测 → 拆除 → 安全与应急 → 计算要点。"
  249. if is_engineering
  250. else "在信息不充分的情况下,我会先按通用结构组织回答:背景/目标与依据 → 核心要点与步骤 → 注意事项与边界条件 → 需要追问的关键信息。"
  251. )
  252. data_paragraph = (
  253. "如果涉及验算或参数,我不会直接给出精确数值结论(缺少荷载、尺寸、工况等数据),而是说明验算项目、取值原则与需要补充的信息。"
  254. if is_engineering
  255. else "如果涉及具体数据或条件(时间/地区/预算/限制等),我会说明我的假设、可选口径,并提示需要你进一步确认的关键信息。"
  256. )
  257. paragraphs = [
  258. f"我们需要理解问题:用户问的是“{question}”。这更像是一个{domain_hint}类问题,我需要先确认用户希望得到的是方案框架、步骤清单,还是关键控制点与注意事项。",
  259. outline_paragraph,
  260. data_paragraph,
  261. "如果你能补充具体场景(适用对象/工况/阶段/关注重点),我可以把回答细化为可执行的流程清单与检查表。",
  262. ]
  263. paragraphs = paragraphs[: max(1, int(max_points or 3))]
  264. text = "\n\n".join(paragraphs).strip()
  265. if max_output_chars and len(text) > int(max_output_chars):
  266. text = text[: int(max_output_chars)].rstrip()
  267. return text
  268. async def summarize_thinking_content(
  269. *,
  270. user_question: str,
  271. raw_thinking: str,
  272. final_answer: str = "",
  273. chat_service: Any,
  274. context: str = "",
  275. ) -> str:
  276. """
  277. 二次总结原始思考过程,返回可展示的中文“思考过程摘要”(自然段)。
  278. 失败兜底:返回空字符串,避免回退 raw_thinking(防止暴露长推理链路)。
  279. """
  280. cfg = getattr(settings, "thinking_summary", None)
  281. enabled = getattr(cfg, "enabled", True) if cfg else True
  282. if not enabled:
  283. return ""
  284. thinking_text = (raw_thinking or "").strip()
  285. if not thinking_text:
  286. return ""
  287. max_points = int(getattr(cfg, "max_points", 5) if cfg else 5)
  288. max_input_chars = int(getattr(cfg, "max_input_chars", 1500) if cfg else 1500)
  289. max_output_chars = int(getattr(cfg, "max_output_chars", 600) if cfg else 600)
  290. # temperature 预留配置项(当前 qwen_service.chat 未透传该参数)
  291. _ = float(getattr(cfg, "temperature", 0.2) if cfg else 0.2)
  292. if max_input_chars and len(thinking_text) > max_input_chars:
  293. thinking_text = thinking_text[:max_input_chars]
  294. prompt = load_prompt(
  295. "thinking_summary",
  296. userMessage=user_question or "",
  297. thinkingText=thinking_text,
  298. finalAnswer=final_answer or "",
  299. maxPoints=str(max_points),
  300. )
  301. if not (prompt or "").strip():
  302. logger.warning("[thinking_summary] Prompt 为空,已跳过")
  303. return ""
  304. try:
  305. response = await chat_service.chat(
  306. [{"role": "user", "content": prompt}],
  307. )
  308. except Exception as exc:
  309. logger.warning(
  310. f"[thinking_summary] 生成失败({context or 'unknown'}): {type(exc).__name__}",
  311. exc_info=True,
  312. )
  313. return _build_fallback_summary(
  314. user_question=user_question,
  315. max_points=max_points,
  316. max_output_chars=max_output_chars,
  317. )
  318. # 避免总结器自己又输出 <think> 段
  319. _, summary_final = split_thinking_and_answer(response or "")
  320. summary_text = (summary_final or response or "").strip()
  321. normalized = normalize_thinking_summary(
  322. summary_text,
  323. max_points=max_points,
  324. max_output_chars=max_output_chars,
  325. )
  326. if not normalized or not _is_summary_acceptable(normalized):
  327. return _build_fallback_summary(
  328. user_question=user_question,
  329. max_points=max_points,
  330. max_output_chars=max_output_chars,
  331. )
  332. logger.info(
  333. f"[thinking_summary] 生成成功({context or 'unknown'}) | raw_len={len(raw_thinking or '')} | out_len={len(normalized)}"
  334. )
  335. return normalized