thinking_summary.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  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"(?:^|\n)\s*(识别用户明确查询|问题核心主题|需结合公司主体)", re.MULTILINE),
  154. re.compile(r"我们需要理解问题:"),
  155. re.compile(r"思考要点总结器|思考过程展示改写器"),
  156. re.compile(r"```"),
  157. re.compile(r"#+\s*\S"),
  158. re.compile(r"-\s+|•\s+"),
  159. re.compile(r"\d+\s*[.)、]\s+"),
  160. ]
  161. def _is_summary_acceptable(text: str) -> bool:
  162. summary = (text or "").strip()
  163. if not summary:
  164. return False
  165. if not _REQUIRED_PREFIX_RE.match(summary):
  166. return False
  167. if any(pattern.search(summary) for pattern in _INVALID_SUMMARY_PATTERNS):
  168. return False
  169. return True
  170. def _strip_noise_lines(text: str) -> str:
  171. lines = (text or "").splitlines()
  172. kept: List[str] = []
  173. for raw_line in lines:
  174. line = raw_line.rstrip()
  175. stripped = line.strip()
  176. if not stripped:
  177. kept.append("")
  178. continue
  179. if any(pattern.match(stripped) for pattern in _NOISE_LINE_PATTERNS):
  180. continue
  181. if any(pattern.search(stripped) for pattern in _NOISE_CONTAINS_PATTERNS):
  182. continue
  183. stripped = _LEADING_LABEL_RE.sub("", stripped).strip()
  184. had_list_prefix = bool(_LIST_PREFIX_RE.match(stripped))
  185. stripped = _LIST_PREFIX_RE.sub("", stripped).strip()
  186. if had_list_prefix and stripped and stripped[-1] not in "。!?;!?;":
  187. stripped = stripped.rstrip(",,") + ";"
  188. if stripped:
  189. kept.append(stripped)
  190. cleaned = "\n".join(kept)
  191. cleaned = re.sub(r"\n\s*\n\s*\n+", "\n\n", cleaned).strip()
  192. return cleaned
  193. def _split_paragraphs(text: str) -> List[str]:
  194. chunks = [chunk.strip() for chunk in re.split(r"\n\s*\n+", text or "") if chunk.strip()]
  195. paragraphs: List[str] = []
  196. for chunk in chunks:
  197. paragraph = re.sub(r"\s*\n\s*", "", chunk).strip()
  198. if paragraph:
  199. paragraphs.append(paragraph)
  200. return paragraphs
  201. def normalize_thinking_summary(
  202. text: str,
  203. *,
  204. max_points: int = 5,
  205. max_output_chars: int = 600,
  206. ) -> str:
  207. """将模型输出归一化为中文自然段(最多 max_points 段)。"""
  208. raw_text = (text or "").strip()
  209. if not raw_text:
  210. return ""
  211. cleaned = _strip_noise_lines(raw_text)
  212. if not cleaned:
  213. return ""
  214. paragraphs = _split_paragraphs(cleaned)
  215. if not paragraphs:
  216. return ""
  217. paragraphs = paragraphs[: max(1, int(max_points or 5))]
  218. normalized = "\n\n".join(paragraphs).strip()
  219. if max_output_chars and len(normalized) > int(max_output_chars):
  220. normalized = normalized[: int(max_output_chars)].rstrip()
  221. return normalized
  222. def _build_fallback_summary(
  223. user_question: str,
  224. *,
  225. max_points: int,
  226. max_output_chars: int,
  227. ) -> str:
  228. question = (user_question or "").strip()
  229. if not question:
  230. question = "这个问题"
  231. engineering_keywords = (
  232. "施工",
  233. "支架",
  234. "脚手架",
  235. "架桥机",
  236. "桥梁",
  237. "隧道",
  238. "混凝土",
  239. "钢筋",
  240. "模板",
  241. "验算",
  242. "荷载",
  243. "地基",
  244. "扣件",
  245. "碗扣",
  246. )
  247. is_engineering = any(keyword in question for keyword in engineering_keywords)
  248. domain_hint = "工程施工/技术" if is_engineering else "需要结构化说明"
  249. outline_paragraph = (
  250. "我会按常见施工方案结构来组织回答:工程概况 → 编制依据 → 施工准备 → 设计与参数 → 施工工艺 → 检查验收与监测 → 拆除 → 安全与应急 → 计算要点。"
  251. if is_engineering
  252. else "在信息不充分的情况下,我会先按通用结构组织回答:背景/目标与依据 → 核心要点与步骤 → 注意事项与边界条件 → 需要追问的关键信息。"
  253. )
  254. data_paragraph = (
  255. "如果涉及验算或参数,我不会直接给出精确数值结论(缺少荷载、尺寸、工况等数据),而是说明验算项目、取值原则与需要补充的信息。"
  256. if is_engineering
  257. else "如果涉及具体数据或条件(时间/地区/预算/限制等),我会说明我的假设、可选口径,并提示需要你进一步确认的关键信息。"
  258. )
  259. paragraphs = [
  260. f"用户问的是“{question}”。这更像是一个{domain_hint}类问题,我需要先确认用户希望得到的是方案框架、步骤清单,还是关键控制点与注意事项。",
  261. outline_paragraph,
  262. data_paragraph,
  263. "如果你能补充具体场景(适用对象/工况/阶段/关注重点),我可以把回答细化为可执行的流程清单与检查表。",
  264. ]
  265. paragraphs = paragraphs[: max(1, int(max_points or 3))]
  266. text = "\n\n".join(paragraphs).strip()
  267. if max_output_chars and len(text) > int(max_output_chars):
  268. text = text[: int(max_output_chars)].rstrip()
  269. return text
  270. async def summarize_thinking_content(
  271. *,
  272. user_question: str,
  273. raw_thinking: str,
  274. final_answer: str = "",
  275. chat_service: Any,
  276. context: str = "",
  277. ) -> str:
  278. """
  279. 二次总结原始思考过程,返回可展示的中文“思考过程摘要”(自然段)。
  280. 失败兜底:返回空字符串,避免回退 raw_thinking(防止暴露长推理链路)。
  281. """
  282. cfg = getattr(settings, "thinking_summary", None)
  283. enabled = getattr(cfg, "enabled", True) if cfg else True
  284. if not enabled:
  285. return ""
  286. thinking_text = (raw_thinking or "").strip()
  287. if not thinking_text:
  288. return ""
  289. max_points = int(getattr(cfg, "max_points", 5) if cfg else 5)
  290. max_input_chars = int(getattr(cfg, "max_input_chars", 1500) if cfg else 1500)
  291. max_output_chars = int(getattr(cfg, "max_output_chars", 600) if cfg else 600)
  292. # temperature 预留配置项(当前 qwen_service.chat 未透传该参数)
  293. _ = float(getattr(cfg, "temperature", 0.2) if cfg else 0.2)
  294. if max_input_chars and len(thinking_text) > max_input_chars:
  295. thinking_text = thinking_text[:max_input_chars]
  296. prompt = load_prompt(
  297. "thinking_summary",
  298. userMessage=user_question or "",
  299. thinkingText=thinking_text,
  300. finalAnswer=final_answer or "",
  301. maxPoints=str(max_points),
  302. )
  303. if not (prompt or "").strip():
  304. logger.warning("[thinking_summary] Prompt 为空,已跳过")
  305. return ""
  306. try:
  307. response = await chat_service.chat(
  308. [{"role": "user", "content": prompt}],
  309. )
  310. except Exception as exc:
  311. logger.warning(
  312. f"[thinking_summary] 生成失败({context or 'unknown'}): {type(exc).__name__}",
  313. exc_info=True,
  314. )
  315. return _build_fallback_summary(
  316. user_question=user_question,
  317. max_points=max_points,
  318. max_output_chars=max_output_chars,
  319. )
  320. # 避免总结器自己又输出 <think> 段
  321. _, summary_final = split_thinking_and_answer(response or "")
  322. summary_text = (summary_final or response or "").strip()
  323. normalized = normalize_thinking_summary(
  324. summary_text,
  325. max_points=max_points,
  326. max_output_chars=max_output_chars,
  327. )
  328. if not normalized or not _is_summary_acceptable(normalized):
  329. return _build_fallback_summary(
  330. user_question=user_question,
  331. max_points=max_points,
  332. max_output_chars=max_output_chars,
  333. )
  334. logger.info(
  335. f"[thinking_summary] 生成成功({context or 'unknown'}) | raw_len={len(raw_thinking or '')} | out_len={len(normalized)}"
  336. )
  337. return normalized