| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258 |
- import json
- import logging
- import re
- from typing import Any, Dict, Generator, List, Optional
- from hello_agents import ReActAgent, ToolRegistry
- from hello_agents.tools import Tool, ToolParameter, ToolResponse
- from app.agent.agent import create_helloagents_config, create_helloagents_llm, run_simple_agent
- from app.core.config import settings
- from app.agent.stepsearch import StepSearchMCPClient, StepSearchPersonaTool
- from utils import parse_json_from_response
- logger = logging.getLogger(__name__)
- class _StepSearchToolAdapter(Tool):
- """Expose StepSearch MCP search/fetch through the HelloAgents Tool API."""
- def __init__(self, backend: StepSearchPersonaTool):
- super().__init__(
- name="search_persona_sources",
- description="使用 StepSearch MCP 搜索并抓取真实人物资料。",
- )
- self.backend = backend
- def get_parameters(self) -> List[ToolParameter]:
- return [
- ToolParameter(
- name="query",
- type="string",
- description="人物或领域及待核实事实的搜索关键词",
- required=True,
- )
- ]
- def run(self, parameters: Dict[str, Any]) -> ToolResponse:
- query = str(parameters.get("query", "")).strip()
- if not query:
- return ToolResponse.error(code="INVALID_QUERY", message="搜索关键词不能为空")
- try:
- text = self.backend.search(query)
- return ToolResponse.success(text=text, data={"query": query, "provider": "stepsearch"})
- except Exception:
- logger.exception("StepSearch MCP request failed")
- return ToolResponse.error(code="SEARCH_FAILED", message="搜索服务暂时不可用")
- class RealGodAgent:
- """Persona generator implemented with HelloAgents ReActAgent and tools."""
- def __init__(self, max_steps: int = 6):
- self.max_steps = max_steps
- @staticmethod
- def _supports_persona_search() -> bool:
- return "stepfun.com" in settings.final_base_url.lower()
- def _get_persona_count(self, prompt: str) -> int:
- if self._explicit_requested_name(prompt):
- return 1
- messages = [
- {"role": "system", "content": "从用户描述中提取角色数量,只输出 1 到 5 的整数;未指定时输出 1。"},
- {"role": "user", "content": prompt},
- ]
- try:
- content = run_simple_agent(
- "PersonaCountAgent",
- messages[0]["content"],
- messages[1]["content"],
- )
- match = re.search(r"\d+", content)
- return min(max(int(match.group()), 1), 5) if match else 1
- except Exception:
- return 1
- @staticmethod
- def _explicit_requested_name(prompt: str) -> Optional[str]:
- """Extract a directly named person while leaving topic requests flexible."""
- text = prompt.strip().strip("。!?!?.,,")
- for pattern in (
- r"必须生成\s*([^,。;;!?!?]{2,40}?)\s*本人",
- r"(?:请)?(?:创建|生成|塑造|扮演)(?:一位|一个|一名)?\s*真实人物\s*([^,。;;!?!?]{2,40})",
- ):
- named_match = re.search(pattern, text)
- if named_match:
- return named_match.group(1).strip(" 《》\"'“”‘’")
- match = re.fullmatch(
- r"(?:请)?(?:创建|生成|塑造|扮演)(?:一位|一个|一名)?\s*([^,。!?!?]{2,40}?)(?:这个)?(?:角色|人物)?",
- text,
- )
- if not match:
- return None
- candidate = match.group(1).strip(" 《》\"'“”‘’")
- generic_endings = (
- "专家", "学者", "科学家", "工程师", "教授", "医生", "律师", "主持人",
- "角色", "人物", "代表", "顾问", "创业者", "程序员", "设计师", "作家",
- )
- if not candidate or candidate.endswith(generic_endings):
- return None
- return candidate
- @staticmethod
- def _normalize_person_name(value: str) -> str:
- return re.sub(r"[\s·•・.\-_《》'\"“”‘’]", "", value).casefold()
- def _build_agent(self, system_prompt: str) -> ReActAgent:
- registry = ToolRegistry()
- if not self._supports_persona_search():
- raise RuntimeError("MADF requires the StepFun model endpoint and StepSearch MCP tool")
- stepsearch = StepSearchPersonaTool()
- registry.register_tool(_StepSearchToolAdapter(stepsearch))
- return ReActAgent(
- name="RealGodAgent",
- llm=create_helloagents_llm(),
- tool_registry=registry,
- system_prompt=system_prompt,
- config=create_helloagents_config(),
- max_steps=self.max_steps,
- )
- @staticmethod
- def _matches_user_request(prompt: str, persona: Dict[str, Any]) -> bool:
- """Reject a grounded result that researched the wrong named person or topic."""
- explicit_name = RealGodAgent._explicit_requested_name(prompt)
- if explicit_name:
- expected = RealGodAgent._normalize_person_name(explicit_name)
- actual = RealGodAgent._normalize_person_name(str(persona.get("name", "")))
- identity_text = RealGodAgent._normalize_person_name(
- " ".join(
- str(persona.get(field, ""))
- for field in ("name", "title", "bio", "stance", "system_prompt")
- )
- )
- if expected not in actual and actual not in expected and expected not in identity_text:
- return False
- messages = [
- {
- "role": "system",
- "content": (
- "判断候选人物是否满足用户的角色生成需求。重点检查用户点名的人物、职业和主题是否一致。"
- "如果用户说‘创建X’且X本身是明确人物或角色名,候选人物必须就是X,不能创建同一作品或领域的其他人物。"
- "只输出 YES 或 NO;主题型开放请求只要合理匹配就输出 YES。"
- ),
- },
- {
- "role": "user",
- "content": (
- f"用户需求:{prompt}\n"
- f"候选人物:{json.dumps(persona, ensure_ascii=False)}"
- ),
- },
- ]
- try:
- content = run_simple_agent(
- "PersonaAlignmentAgent",
- messages[0]["content"],
- messages[1]["content"],
- ).strip().upper()
- return content.startswith("YES")
- except Exception:
- logger.exception("Persona request-alignment check failed")
- # Provider-side verification failure must not discard an otherwise
- # valid grounded result; generation errors still use the normal path.
- return True
- @staticmethod
- def _parse_persona(agent: ReActAgent, raw: str) -> Optional[Dict[str, Any]]:
- persona = parse_json_from_response(raw)
- if not isinstance(persona, (dict, list)):
- repair = agent.run(
- "上一次输出不是可解析 JSON。请不要解释、不要 Markdown,只返回一个紧凑且完整的合法 JSON 对象;"
- "必须包含 name、title、bio、theories(7 个字符串)、stance、system_prompt。"
- )
- persona = parse_json_from_response(repair)
- if isinstance(persona, list):
- persona = persona[0] if persona else None
- return persona if isinstance(persona, dict) else None
- def _generate_one(
- self,
- prompt: str,
- index: int,
- total: int,
- generated_names: List[str],
- existing_names: List[str],
- ) -> Dict[str, Any]:
- excluded = generated_names + existing_names
- research_instruction = (
- "必须使用注册的 StepSearch 搜索工具核实人物背景,再返回结果。"
- if "stepfun.com" in settings.final_base_url.lower()
- else "必须使用注册的搜索工具核实人物背景,再返回结果。"
- if self._supports_persona_search()
- else "当前模型端点未配置兼容的外部搜索工具;请依据可靠常识生成,并避免无法核实的细节。"
- )
- system_prompt = f"""
- 你是负责创建真实、立体人物角色的研究智能体。{research_instruction}
- 返回一个合法 JSON 对象,不要 Markdown 代码块,不要额外解释。对象必须包含:
- name、title、bio、theories、stance、system_prompt。theories 必须是 7 个字符串的数组;
- bio 与 stance 应具体、有事实依据,system_prompt 使用第一人称并指导角色自然参与讨论。
- 若用户未指定具体人物,应选择符合主题且有公开资料的人物。禁止捏造真实人物经历。
- """.strip()
- agent = self._build_agent(system_prompt)
- task = f"""
- 用户需求:{prompt}
- 当前生成第 {index} 位,共 {total} 位。
- 不得生成这些已有角色:{json.dumps(excluded, ensure_ascii=False)}
- 当用户在同一需求中依次描述了多个角色时,必须严格生成第 {index} 个描述对应的角色,
- 不得用其他序号的角色替代;其职业、立场、风险偏好等关键要求都必须与第 {index} 个描述一致。
- 如果用户明确点名某个人物(例如“创建哈利波特”),必须生成该人物本人,禁止生成同一作品、家族或领域中的原创人物。
- 请生成一个与已有角色不同的角色 JSON。
- """.strip()
- persona = self._parse_persona(agent, agent.run(task))
- alignment_request = (
- f"{prompt}\n当前只校验第 {index} 位(共 {total} 位);"
- f"候选角色不得与这些已生成角色重复:{json.dumps(excluded, ensure_ascii=False)}。"
- )
- if persona and not self._matches_user_request(alignment_request, persona):
- logger.warning(
- "Generated persona %r did not match the user request; retrying once",
- persona.get("name"),
- )
- retry_agent = self._build_agent(system_prompt)
- retry_task = (
- f"{task}\n\n上一次生成了不符合用户需求的人物 {persona.get('name', 'Unknown')},已被拒绝。"
- "必须严格遵循用户点名的人物或主题,重新使用搜索工具核实后生成;不得再次返回被拒绝的人物。"
- )
- persona = self._parse_persona(retry_agent, retry_agent.run(retry_task))
- if persona and not self._matches_user_request(alignment_request, persona):
- raise ValueError("HelloAgents returned a persona unrelated to the user request")
- if not isinstance(persona, dict) or not persona.get("name"):
- raise ValueError("HelloAgents ReActAgent did not return a valid persona JSON object")
- return persona
- def run(
- self,
- prompt: str,
- n: Optional[int] = None,
- generated_names: Optional[List[str]] = None,
- db_existing_names: Optional[List[str]] = None,
- ) -> Generator[Dict[str, Any], None, None]:
- generated_names = generated_names if generated_names is not None else []
- existing_names = db_existing_names or []
- total = min(max(n or self._get_persona_count(prompt), 1), 5)
- yield {"type": "count", "content": total}
- for index in range(1, total + 1):
- yield {"type": "thought_start", "content": f"开始研究并生成第 {index} 位角色(共 {total} 位)"}
- yield {"type": "progress", "current": index, "total": total}
- try:
- persona = self._generate_one(prompt, index, total, generated_names, existing_names)
- except Exception as exc:
- logger.exception("RealGod generation failed")
- yield {"type": "error", "content": "角色生成失败,请稍后重试"}
- continue
- generated_names.append(persona["name"])
- yield {"type": "result", "content": [persona]}
|