deep_research.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. from __future__ import annotations
  2. import importlib
  3. import io
  4. import sys
  5. from contextlib import redirect_stdout
  6. from pathlib import Path
  7. from time import perf_counter
  8. from typing import Any
  9. from dotenv import load_dotenv
  10. from backend.agents.base import BaseAgent
  11. from backend.config import ENV_FILE, ROOT_DIR, settings
  12. from backend.events import event_logger
  13. from backend.maintenance import cleanup_deep_research_artifacts
  14. from backend.memory.base import memory_store
  15. from backend.models import AgentRequest, AgentResponse
  16. class DeepResearchAdapter(BaseAgent):
  17. """Expose the built-in DeepResearchAgent as one platform-level agent."""
  18. def run(self, request: AgentRequest) -> AgentResponse:
  19. event_logger.emit("agent_started", agent_id=self.agent_id, task_id=request.task_id)
  20. try:
  21. output, artifacts = self._run_with_artifacts(request)
  22. except Exception as exc:
  23. output = f"deep_research 运行失败:{type(exc).__name__}: {exc}"
  24. artifacts = {"error": str(exc), "error_type": type(exc).__name__}
  25. memory_store.add(self.agent_id, f"input={request.input} output={output}")
  26. event = event_logger.emit(
  27. "agent_completed",
  28. agent_id=self.agent_id,
  29. task_id=request.task_id,
  30. payload={
  31. "output_preview": output[:200],
  32. "artifact_keys": sorted(artifacts.keys()),
  33. },
  34. )
  35. return AgentResponse(
  36. agent_id=self.agent_id,
  37. output=output,
  38. artifacts=artifacts,
  39. events=[event],
  40. )
  41. def _run(self, request: AgentRequest) -> str:
  42. output, _ = self._run_with_artifacts(request)
  43. return output
  44. def _run_with_artifacts(self, request: AgentRequest) -> tuple[str, dict[str, Any]]:
  45. total_started = perf_counter()
  46. stdout_buffer = io.StringIO()
  47. timings: dict[str, float] = {}
  48. cleanup_started = perf_counter()
  49. cleanup_stats = cleanup_deep_research_artifacts()
  50. timings["cleanup_seconds"] = round(perf_counter() - cleanup_started, 3)
  51. if request.context.get("mode") == "group_chat":
  52. return (
  53. "deep_research 是长耗时研究流程。请单独使用 @deep_research 提交明确研究主题。",
  54. {"skipped": True, "reason": "batch_guard", "cleanup": cleanup_stats},
  55. )
  56. deep_research_path = Path(settings.chapter14_backend_path).resolve()
  57. if not deep_research_path.exists():
  58. return (
  59. f"DeepResearch 内置源码路径不存在,无法运行 deep_research:{deep_research_path}",
  60. {
  61. "ready": False,
  62. "deep_research_path": str(deep_research_path),
  63. "cleanup": cleanup_stats,
  64. },
  65. )
  66. if request.context.get("dry_run"):
  67. return (
  68. "deep_research 已接入内置 DeepResearchAgent,真实运行时会执行搜索调研流程。",
  69. {
  70. "ready": True,
  71. "deep_research_path": str(deep_research_path),
  72. "cleanup": cleanup_stats,
  73. },
  74. )
  75. topic_preview = request.input.replace("\n", " ")[:120]
  76. print(f"[deep_research] start task_id={request.task_id or '-'} topic={topic_preview}")
  77. started = perf_counter()
  78. with redirect_stdout(stdout_buffer):
  79. DeepResearchAgent, Configuration = self._load_deep_research_types(deep_research_path)
  80. timings["load_deep_research_seconds"] = round(perf_counter() - started, 3)
  81. print(f"[deep_research] loaded source={deep_research_path}")
  82. started = perf_counter()
  83. with redirect_stdout(stdout_buffer):
  84. config = Configuration.from_env(overrides=self._deep_research_overrides())
  85. agent = DeepResearchAgent(config=config)
  86. timings["agent_init_seconds"] = round(perf_counter() - started, 3)
  87. print(
  88. "[deep_research] initialized "
  89. f"search={config.search_api.value if hasattr(config.search_api, 'value') else config.search_api} "
  90. f"model={config.resolved_model() or '-'}"
  91. )
  92. started = perf_counter()
  93. print("[deep_research] researching...")
  94. with redirect_stdout(stdout_buffer):
  95. result = agent.run(request.input)
  96. timings["agent_run_seconds"] = round(perf_counter() - started, 3)
  97. started = perf_counter()
  98. todo_items = [self._serialize_todo(item) for item in result.todo_items]
  99. report = (result.report_markdown or result.running_summary or "").strip()
  100. completed_items = [
  101. item for item in todo_items if item.get("status") == "completed" and item.get("summary")
  102. ]
  103. skipped_items = [item for item in todo_items if item.get("status") == "skipped"]
  104. failed_items = [item for item in todo_items if item.get("status") == "failed"]
  105. artifacts: dict[str, Any] = {
  106. "report_markdown": report,
  107. "todo_items": todo_items,
  108. "cleanup": cleanup_stats,
  109. }
  110. captured_stdout = stdout_buffer.getvalue().strip()
  111. if captured_stdout:
  112. artifacts["stdout"] = captured_stdout
  113. timings["postprocess_seconds"] = round(perf_counter() - started, 3)
  114. timings["total_seconds"] = round(perf_counter() - total_started, 3)
  115. artifacts["timings"] = timings
  116. if todo_items:
  117. artifacts["todo_count"] = len(todo_items)
  118. artifacts["completed_count"] = len(completed_items)
  119. artifacts["skipped_count"] = len(skipped_items)
  120. artifacts["failed_count"] = len(failed_items)
  121. print(
  122. "[deep_research] research completed "
  123. f"tasks={len(todo_items)} completed={len(completed_items)} "
  124. f"skipped={len(skipped_items)} failed={len(failed_items)}"
  125. )
  126. print(f"[deep_research] report generated chars={len(report)}")
  127. if todo_items and not completed_items and not report:
  128. output = (
  129. "搜索员没有拿到可用的搜索总结,因此未返回正式研究报告。\n"
  130. "可能原因:搜索后端无结果、网络 API 调用失败,或任务执行阶段没有产出摘要。\n"
  131. "请查看后端日志和 data/deep_research/runs 目录下的 task_* 文件。"
  132. )
  133. print(f"[deep_research] failed seconds={timings['total_seconds']}")
  134. return output, artifacts
  135. if todo_items and not completed_items:
  136. artifacts["warning"] = "no_completed_research_tasks"
  137. output = report or "deep_research 已完成,但没有生成报告正文。"
  138. print(f"[deep_research] complete seconds={timings['total_seconds']}")
  139. return output, artifacts
  140. def _load_deep_research_types(self, deep_research_path: Path) -> tuple[type[Any], type[Any]]:
  141. path_text = str(deep_research_path)
  142. if path_text not in sys.path:
  143. sys.path.insert(0, path_text)
  144. agent_module = importlib.import_module("agent")
  145. config_module = importlib.import_module("config")
  146. if ENV_FILE.exists():
  147. load_dotenv(ENV_FILE, override=True)
  148. return agent_module.DeepResearchAgent, config_module.Configuration
  149. def _deep_research_overrides(self) -> dict[str, Any]:
  150. overrides: dict[str, Any] = {
  151. "notes_workspace": self._resolve_workspace(settings.notes_workspace),
  152. "run_workspace": self._resolve_workspace(settings.run_workspace),
  153. }
  154. optional_values = {
  155. "llm_provider": settings.llm_provider,
  156. "llm_model_id": settings.llm_model_id,
  157. "llm_api_key": settings.llm_api_key,
  158. "llm_base_url": settings.llm_base_url,
  159. "llm_timeout": settings.llm_timeout,
  160. "search_api": settings.search_api,
  161. "max_web_research_loops": settings.max_web_research_loops,
  162. "fetch_full_page": settings.fetch_full_page,
  163. "enable_notes": settings.enable_notes,
  164. "persist_runs": settings.persist_runs,
  165. "cleanup_intermediate_files": settings.cleanup_intermediate_files,
  166. }
  167. for key, value in optional_values.items():
  168. if value is not None:
  169. overrides[key] = value
  170. return overrides
  171. @staticmethod
  172. def _resolve_workspace(value: str) -> str:
  173. path = Path(value)
  174. if not path.is_absolute():
  175. path = ROOT_DIR / path
  176. path.mkdir(parents=True, exist_ok=True)
  177. return str(path.resolve())
  178. @staticmethod
  179. def _serialize_todo(item: Any) -> dict[str, Any]:
  180. return {
  181. "id": getattr(item, "id", None),
  182. "title": getattr(item, "title", ""),
  183. "intent": getattr(item, "intent", ""),
  184. "query": getattr(item, "query", ""),
  185. "status": getattr(item, "status", ""),
  186. "summary": getattr(item, "summary", None),
  187. "sources_summary": getattr(item, "sources_summary", None),
  188. "note_id": getattr(item, "note_id", None),
  189. "note_path": getattr(item, "note_path", None),
  190. }