real_god.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import json
  2. import logging
  3. import re
  4. from typing import Any, Dict, Generator, List, Optional
  5. from hello_agents import ReActAgent, ToolRegistry
  6. from hello_agents.tools import Tool, ToolParameter, ToolResponse
  7. from app.agent.agent import create_helloagents_config, create_helloagents_llm, run_simple_agent
  8. from app.core.config import settings
  9. from app.agent.stepsearch import StepSearchMCPClient, StepSearchPersonaTool
  10. from utils import parse_json_from_response
  11. logger = logging.getLogger(__name__)
  12. class _StepSearchToolAdapter(Tool):
  13. """Expose StepSearch MCP search/fetch through the HelloAgents Tool API."""
  14. def __init__(self, backend: StepSearchPersonaTool):
  15. super().__init__(
  16. name="search_persona_sources",
  17. description="使用 StepSearch MCP 搜索并抓取真实人物资料。",
  18. )
  19. self.backend = backend
  20. def get_parameters(self) -> List[ToolParameter]:
  21. return [
  22. ToolParameter(
  23. name="query",
  24. type="string",
  25. description="人物或领域及待核实事实的搜索关键词",
  26. required=True,
  27. )
  28. ]
  29. def run(self, parameters: Dict[str, Any]) -> ToolResponse:
  30. query = str(parameters.get("query", "")).strip()
  31. if not query:
  32. return ToolResponse.error(code="INVALID_QUERY", message="搜索关键词不能为空")
  33. try:
  34. text = self.backend.search(query)
  35. return ToolResponse.success(text=text, data={"query": query, "provider": "stepsearch"})
  36. except Exception:
  37. logger.exception("StepSearch MCP request failed")
  38. return ToolResponse.error(code="SEARCH_FAILED", message="搜索服务暂时不可用")
  39. class RealGodAgent:
  40. """Persona generator implemented with HelloAgents ReActAgent and tools."""
  41. def __init__(self, max_steps: int = 6):
  42. self.max_steps = max_steps
  43. @staticmethod
  44. def _supports_persona_search() -> bool:
  45. return "stepfun.com" in settings.final_base_url.lower()
  46. def _get_persona_count(self, prompt: str) -> int:
  47. if self._explicit_requested_name(prompt):
  48. return 1
  49. messages = [
  50. {"role": "system", "content": "从用户描述中提取角色数量,只输出 1 到 5 的整数;未指定时输出 1。"},
  51. {"role": "user", "content": prompt},
  52. ]
  53. try:
  54. content = run_simple_agent(
  55. "PersonaCountAgent",
  56. messages[0]["content"],
  57. messages[1]["content"],
  58. )
  59. match = re.search(r"\d+", content)
  60. return min(max(int(match.group()), 1), 5) if match else 1
  61. except Exception:
  62. return 1
  63. @staticmethod
  64. def _explicit_requested_name(prompt: str) -> Optional[str]:
  65. """Extract a directly named person while leaving topic requests flexible."""
  66. text = prompt.strip().strip("。!?!?.,,")
  67. for pattern in (
  68. r"必须生成\s*([^,。;;!?!?]{2,40}?)\s*本人",
  69. r"(?:请)?(?:创建|生成|塑造|扮演)(?:一位|一个|一名)?\s*真实人物\s*([^,。;;!?!?]{2,40})",
  70. ):
  71. named_match = re.search(pattern, text)
  72. if named_match:
  73. return named_match.group(1).strip(" 《》\"'“”‘’")
  74. match = re.fullmatch(
  75. r"(?:请)?(?:创建|生成|塑造|扮演)(?:一位|一个|一名)?\s*([^,。!?!?]{2,40}?)(?:这个)?(?:角色|人物)?",
  76. text,
  77. )
  78. if not match:
  79. return None
  80. candidate = match.group(1).strip(" 《》\"'“”‘’")
  81. generic_endings = (
  82. "专家", "学者", "科学家", "工程师", "教授", "医生", "律师", "主持人",
  83. "角色", "人物", "代表", "顾问", "创业者", "程序员", "设计师", "作家",
  84. )
  85. if not candidate or candidate.endswith(generic_endings):
  86. return None
  87. return candidate
  88. @staticmethod
  89. def _normalize_person_name(value: str) -> str:
  90. return re.sub(r"[\s·•・.\-_《》'\"“”‘’]", "", value).casefold()
  91. def _build_agent(self, system_prompt: str) -> ReActAgent:
  92. registry = ToolRegistry()
  93. if not self._supports_persona_search():
  94. raise RuntimeError("MADF requires the StepFun model endpoint and StepSearch MCP tool")
  95. stepsearch = StepSearchPersonaTool()
  96. registry.register_tool(_StepSearchToolAdapter(stepsearch))
  97. return ReActAgent(
  98. name="RealGodAgent",
  99. llm=create_helloagents_llm(),
  100. tool_registry=registry,
  101. system_prompt=system_prompt,
  102. config=create_helloagents_config(),
  103. max_steps=self.max_steps,
  104. )
  105. @staticmethod
  106. def _matches_user_request(prompt: str, persona: Dict[str, Any]) -> bool:
  107. """Reject a grounded result that researched the wrong named person or topic."""
  108. explicit_name = RealGodAgent._explicit_requested_name(prompt)
  109. if explicit_name:
  110. expected = RealGodAgent._normalize_person_name(explicit_name)
  111. actual = RealGodAgent._normalize_person_name(str(persona.get("name", "")))
  112. identity_text = RealGodAgent._normalize_person_name(
  113. " ".join(
  114. str(persona.get(field, ""))
  115. for field in ("name", "title", "bio", "stance", "system_prompt")
  116. )
  117. )
  118. if expected not in actual and actual not in expected and expected not in identity_text:
  119. return False
  120. messages = [
  121. {
  122. "role": "system",
  123. "content": (
  124. "判断候选人物是否满足用户的角色生成需求。重点检查用户点名的人物、职业和主题是否一致。"
  125. "如果用户说‘创建X’且X本身是明确人物或角色名,候选人物必须就是X,不能创建同一作品或领域的其他人物。"
  126. "只输出 YES 或 NO;主题型开放请求只要合理匹配就输出 YES。"
  127. ),
  128. },
  129. {
  130. "role": "user",
  131. "content": (
  132. f"用户需求:{prompt}\n"
  133. f"候选人物:{json.dumps(persona, ensure_ascii=False)}"
  134. ),
  135. },
  136. ]
  137. try:
  138. content = run_simple_agent(
  139. "PersonaAlignmentAgent",
  140. messages[0]["content"],
  141. messages[1]["content"],
  142. ).strip().upper()
  143. return content.startswith("YES")
  144. except Exception:
  145. logger.exception("Persona request-alignment check failed")
  146. # Provider-side verification failure must not discard an otherwise
  147. # valid grounded result; generation errors still use the normal path.
  148. return True
  149. @staticmethod
  150. def _parse_persona(agent: ReActAgent, raw: str) -> Optional[Dict[str, Any]]:
  151. persona = parse_json_from_response(raw)
  152. if not isinstance(persona, (dict, list)):
  153. repair = agent.run(
  154. "上一次输出不是可解析 JSON。请不要解释、不要 Markdown,只返回一个紧凑且完整的合法 JSON 对象;"
  155. "必须包含 name、title、bio、theories(7 个字符串)、stance、system_prompt。"
  156. )
  157. persona = parse_json_from_response(repair)
  158. if isinstance(persona, list):
  159. persona = persona[0] if persona else None
  160. return persona if isinstance(persona, dict) else None
  161. def _generate_one(
  162. self,
  163. prompt: str,
  164. index: int,
  165. total: int,
  166. generated_names: List[str],
  167. existing_names: List[str],
  168. ) -> Dict[str, Any]:
  169. excluded = generated_names + existing_names
  170. research_instruction = (
  171. "必须使用注册的 StepSearch 搜索工具核实人物背景,再返回结果。"
  172. if "stepfun.com" in settings.final_base_url.lower()
  173. else "必须使用注册的搜索工具核实人物背景,再返回结果。"
  174. if self._supports_persona_search()
  175. else "当前模型端点未配置兼容的外部搜索工具;请依据可靠常识生成,并避免无法核实的细节。"
  176. )
  177. system_prompt = f"""
  178. 你是负责创建真实、立体人物角色的研究智能体。{research_instruction}
  179. 返回一个合法 JSON 对象,不要 Markdown 代码块,不要额外解释。对象必须包含:
  180. name、title、bio、theories、stance、system_prompt。theories 必须是 7 个字符串的数组;
  181. bio 与 stance 应具体、有事实依据,system_prompt 使用第一人称并指导角色自然参与讨论。
  182. 若用户未指定具体人物,应选择符合主题且有公开资料的人物。禁止捏造真实人物经历。
  183. """.strip()
  184. agent = self._build_agent(system_prompt)
  185. task = f"""
  186. 用户需求:{prompt}
  187. 当前生成第 {index} 位,共 {total} 位。
  188. 不得生成这些已有角色:{json.dumps(excluded, ensure_ascii=False)}
  189. 当用户在同一需求中依次描述了多个角色时,必须严格生成第 {index} 个描述对应的角色,
  190. 不得用其他序号的角色替代;其职业、立场、风险偏好等关键要求都必须与第 {index} 个描述一致。
  191. 如果用户明确点名某个人物(例如“创建哈利波特”),必须生成该人物本人,禁止生成同一作品、家族或领域中的原创人物。
  192. 请生成一个与已有角色不同的角色 JSON。
  193. """.strip()
  194. persona = self._parse_persona(agent, agent.run(task))
  195. alignment_request = (
  196. f"{prompt}\n当前只校验第 {index} 位(共 {total} 位);"
  197. f"候选角色不得与这些已生成角色重复:{json.dumps(excluded, ensure_ascii=False)}。"
  198. )
  199. if persona and not self._matches_user_request(alignment_request, persona):
  200. logger.warning(
  201. "Generated persona %r did not match the user request; retrying once",
  202. persona.get("name"),
  203. )
  204. retry_agent = self._build_agent(system_prompt)
  205. retry_task = (
  206. f"{task}\n\n上一次生成了不符合用户需求的人物 {persona.get('name', 'Unknown')},已被拒绝。"
  207. "必须严格遵循用户点名的人物或主题,重新使用搜索工具核实后生成;不得再次返回被拒绝的人物。"
  208. )
  209. persona = self._parse_persona(retry_agent, retry_agent.run(retry_task))
  210. if persona and not self._matches_user_request(alignment_request, persona):
  211. raise ValueError("HelloAgents returned a persona unrelated to the user request")
  212. if not isinstance(persona, dict) or not persona.get("name"):
  213. raise ValueError("HelloAgents ReActAgent did not return a valid persona JSON object")
  214. return persona
  215. def run(
  216. self,
  217. prompt: str,
  218. n: Optional[int] = None,
  219. generated_names: Optional[List[str]] = None,
  220. db_existing_names: Optional[List[str]] = None,
  221. ) -> Generator[Dict[str, Any], None, None]:
  222. generated_names = generated_names if generated_names is not None else []
  223. existing_names = db_existing_names or []
  224. total = min(max(n or self._get_persona_count(prompt), 1), 5)
  225. yield {"type": "count", "content": total}
  226. for index in range(1, total + 1):
  227. yield {"type": "thought_start", "content": f"开始研究并生成第 {index} 位角色(共 {total} 位)"}
  228. yield {"type": "progress", "current": index, "total": total}
  229. try:
  230. persona = self._generate_one(prompt, index, total, generated_names, existing_names)
  231. except Exception as exc:
  232. logger.exception("RealGod generation failed")
  233. yield {"type": "error", "content": "角色生成失败,请稍后重试"}
  234. continue
  235. generated_names.append(persona["name"])
  236. yield {"type": "result", "content": [persona]}