1
0

agents.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import logging
  2. import random
  3. import time
  4. from typing import Dict, List
  5. from hello_agents import SimpleAgent, HelloAgentsLLM, Message
  6. from config import get_config
  7. from game_logic import GameSession
  8. logger = logging.getLogger("game.agent")
  9. # 人物候选池,用于随机注入 system prompt,强制 LLM 从不同方向发散
  10. _FIGURE_DOMAINS = [
  11. "中国古代帝王(如汉武帝、唐太宗、武则天、康熙等)",
  12. "中国古代文人墨客(如李白、杜甫、苏轼、王羲之等)",
  13. "中国古代军事家(如岳飞、霍去病、戚继光、韩信等)",
  14. "中国神话人物(如女娲、嫦娥、哪吒、二郎神等)",
  15. "西游记人物(如孙悟空、猪八戒、唐僧、沙僧等)",
  16. "三国人物(如诸葛亮、曹操、刘备、关羽、周瑜等)",
  17. "西方历史人物(如拿破仑、凯撒、亚历山大、牛顿等)",
  18. "西方神话人物(如宙斯、雅典娜、赫拉克勒斯、阿喀琉斯等)",
  19. "世界科学家(如爱因斯坦、居里夫人、达芬奇、伽利略等)",
  20. "知名虚构角色(如哈利·波特、福尔摩斯、哆啦A梦、白雪公主等)",
  21. "现代体育明星(如姚明、李娜、迈克尔·乔丹、贝利等)",
  22. "中国近现代人物(如鲁迅、梁启超、郑成功、林则徐等)",
  23. "网络红人与UP主(如李子柒、papi酱、散打哥、罗翔等知名网络人物)",
  24. ]
  25. def _build_random_figure_prompt() -> str:
  26. """Dynamically build a system prompt with a random domain and seed to avoid LLM caching."""
  27. domain = random.choice(_FIGURE_DOMAINS)
  28. seed = random.randint(10000, 99999)
  29. return f"""你是一个随机知名人物生成器。随机种子:{seed}
  30. 本次请从【{domain}】这个方向随机选择一个人物。
  31. 要求:
  32. 1. 必须是大众熟知、有足够信息可供猜测的人物
  33. 2. 必须是人物(真实或虚构),不要选建筑、动植物、自然景观等物体
  34. 3. 输出格式严格如下(两行,不要多余内容):
  35. 名称:<人物名称>
  36. 简介:<一句话概括其性格特点与主要成就,50字以内>
  37. 4. 每次必须随机选择,不要总是选同一个"""
  38. _HINT_SYSTEM_PROMPT = """你是一位博学的助手。
  39. 根据提供的搜索资料,生成3条适合猜谜游戏的提示。
  40. 要求:
  41. 1. 每条提示单独一行,格式为:提示N:<内容>
  42. 2. 提示由模糊到具体,第1条最模糊,第3条最具体
  43. 3. 不能直接说出答案的名称
  44. 4. 只输出3行提示,不要其他内容"""
  45. _SEMANTIC_MATCH_PROMPT = """你是一位知识渊博的助手。请判断以下两个名称是否指代同一个人物或事物。
  46. 只需回答 "是" 或 "否",不要输出任何其他内容。
  47. 名称A:{guess}
  48. 名称B:{actual}"""
  49. _ROLEPLAY_SYSTEM_PROMPT = """你正在参与一个猜谜游戏,扮演一个神秘人物(代号:【谜底】)。
  50. ## 人物背景(仅供你参考,不可直接透露):
  51. {bio}
  52. ## 对话规则:
  53. 1. 以该人物的第一人称身份回答,语气、措辞符合其性格特点与所处时代/背景
  54. 2. 用户会通过提问来猜测你的身份,**必须直接针对用户的问题给出明确回应**(如"是的"/"不是"/"确实如此"等),不能回避或答非所问
  55. 3. 在给出明确回应的基础上,可以用符合人物身份的语气补充一句,增加趣味性
  56. 4. 每次回答要简短(1-2句话),不要长篇大论
  57. 5. 回答内容要基于该人物真实的生平、性格、成就,不要编造
  58. 6. **严禁在任何情况下说出该人物的名称**(包括姓名、字号、封号、外号等一切称谓)
  59. 7. 如果用户的问题与该人物完全无关,可以用符合人物身份的方式婉转说明"""
  60. class HistoricalFigureAgent:
  61. """GuessWhoAmI game Agent wrapper"""
  62. def __init__(self, game_session: GameSession):
  63. """
  64. Initialize Agent: use LLM to randomly generate a subject (person/object/landmark etc.)
  65. with brief intro, then use TavilySearchTool to pre-generate 3 hints, finally create
  66. role-play Agent.
  67. Args:
  68. game_session: game session object to store current subject info
  69. """
  70. self.game_session = game_session
  71. config = get_config()
  72. logger.info(f"[AGENT] Initializing LLM: model={config.LLM_MODEL_ID} base_url={config.LLM_BASE_URL}")
  73. self._llm = HelloAgentsLLM(
  74. model=config.LLM_MODEL_ID,
  75. api_key=config.LLM_API_KEY,
  76. base_url=config.LLM_BASE_URL,
  77. timeout=config.LLM_TIMEOUT,
  78. provider="modelscope"
  79. )
  80. self._config = config
  81. # Register search tool
  82. self._search_tool = None
  83. if config.TAVILY_API_KEY:
  84. from tools.tavily_search_tool import TavilySearchTool
  85. self._search_tool = TavilySearchTool(api_key=config.TAVILY_API_KEY)
  86. logger.info("[AGENT] TavilySearchTool registered")
  87. else:
  88. logger.warning("[AGENT] TAVILY_API_KEY not set, search tool disabled")
  89. # Register Wikipedia image tool (no API key required)
  90. from tools.search_image_tool import SearchImageTool
  91. self._image_tool = SearchImageTool()
  92. logger.info("[AGENT] SearchImageTool (Wikipedia) registered")
  93. # Step 1: LLM generates subject name + brief intro
  94. figure = self._generate_figure()
  95. self.game_session.current_figure = figure
  96. logger.info(f"[AGENT] Subject loaded: {figure}")
  97. # Step 2: pre-generate 3 hints via tavily search
  98. hints = self._generate_hints(figure["name"])
  99. self.game_session.hints = hints
  100. logger.info(f"[AGENT] Hints pre-generated: {hints}")
  101. # Step 3: create role-play Agent
  102. self.agent = self._create_roleplay_agent()
  103. # ── Subject generation ────────────────────────────────────────────────────
  104. def _generate_figure(self) -> Dict[str, str]:
  105. """Use LLM to randomly generate a subject (person/object/landmark) with brief intro."""
  106. try:
  107. system_prompt = _build_random_figure_prompt()
  108. ts = int(time.time() * 1000)
  109. messages = [
  110. {"role": "system", "content": system_prompt},
  111. {"role": "user", "content": f"请随机给我一个(时间戳:{ts},随机数:{random.randint(1, 9999)})"},
  112. ]
  113. raw = self._llm.invoke(messages).strip()
  114. logger.info(f"[AGENT] LLM generated subject raw: {raw!r}")
  115. return self._parse_figure(raw)
  116. except Exception as e:
  117. logger.error(f"[AGENT] Failed to generate subject via LLM: {e}", exc_info=True)
  118. return self._fallback_figure()
  119. def _parse_figure(self, raw: str) -> Dict[str, str]:
  120. """Parse LLM output into {name, bio} dict."""
  121. name = ""
  122. bio = ""
  123. for line in raw.splitlines():
  124. line = line.strip()
  125. if line.startswith("名称:") or line.startswith("名称:") or line.startswith("姓名:") or line.startswith("姓名:"):
  126. name = line.split(":", 1)[-1].split(":", 1)[-1].strip()
  127. elif line.startswith("简介:") or line.startswith("简介:"):
  128. bio = line.split(":", 1)[-1].split(":", 1)[-1].strip()
  129. if not name:
  130. logger.warning("[AGENT] Failed to parse subject name, using fallback")
  131. return self._fallback_figure()
  132. return {"name": name, "bio": bio}
  133. def _fallback_figure(self) -> Dict[str, str]:
  134. """Return a minimal fallback person when LLM fails."""
  135. persons = [
  136. ("孔子", "春秋时期思想家、教育家,儒家学派创始人,性格温和而坚定,一生致力于礼乐仁义"),
  137. ("孙悟空", "《西游记》中的神话英雄,天性顽皮好斗、嫉恶如仇,七十二变,大闹天宫"),
  138. ("武则天", "中国历史上唯一的女皇帝,铁腕治国,心思缜密,开创武周政权"),
  139. ("诸葛亮", "三国时期蜀汉丞相,足智多谋、鞠躬尽瘁,以隆中对和空城计闻名"),
  140. ("哈利·波特", "《哈利·波特》系列中的魔法师主角,勇敢善良,最终击败伏地魔"),
  141. ]
  142. name, bio = random.choice(persons)
  143. return {"name": name, "bio": bio}
  144. # ── Hint generation ───────────────────────────────────────────────────────
  145. def _generate_hints(self, name: str) -> List[str]:
  146. """Use TavilySearchTool to search subject info, then LLM generates 3 hints."""
  147. if not self._search_tool:
  148. return self._fallback_hints(name)
  149. try:
  150. search_results = self._search_tool.run(
  151. {"query": f"{name} 简介 特点 介绍"}
  152. )
  153. logger.info(f"[AGENT] Search results for hints, length: {len(search_results)} chars")
  154. messages = [
  155. {"role": "system", "content": _HINT_SYSTEM_PROMPT},
  156. {"role": "user", "content": f"答案:{name}\n\n搜索资料:\n{search_results}\n\n请生成3条提示:"},
  157. ]
  158. raw = self._llm.invoke(messages).strip()
  159. logger.info(f"[AGENT] LLM hint raw output: {raw!r}")
  160. return self._parse_hints(raw, name)
  161. except Exception as e:
  162. logger.error(f"[AGENT] Hint generation failed: {e}", exc_info=True)
  163. return self._fallback_hints(name)
  164. def _parse_hints(self, raw: str, name: str) -> List[str]:
  165. """Parse LLM hint output into a list of 3 hint strings."""
  166. hints = []
  167. for line in raw.splitlines():
  168. line = line.strip()
  169. if not line:
  170. continue
  171. # Remove prefix like "提示1:" / "提示1:" / "1." etc.
  172. import re
  173. cleaned = re.sub(r'^(提示\d[::]\s*|\d+[\.、]\s*)', '', line).strip()
  174. if cleaned:
  175. hints.append(cleaned)
  176. # Ensure exactly 3 hints
  177. if len(hints) >= 3:
  178. return hints[:3]
  179. # Pad with fallback if not enough
  180. fallback = self._fallback_hints(name)
  181. hints.extend(fallback[len(hints):])
  182. return hints[:3]
  183. def _fallback_hints(self, name: str) -> List[str]:
  184. """Return fallback hints when search/LLM fails."""
  185. return [
  186. "这是一个广为人知的事物",
  187. "它在各自的领域中具有重要地位或影响力",
  188. "它的名字在国内外都有很高的知名度",
  189. ]
  190. # ── Role-play Agent ───────────────────────────────────────────────────────
  191. def _create_roleplay_agent(self) -> SimpleAgent:
  192. """Create the role-play SimpleAgent (no tools, conversation only)"""
  193. system_prompt = self._create_system_prompt()
  194. agent = SimpleAgent(
  195. name="guess_who_agent",
  196. llm=self._llm,
  197. system_prompt=system_prompt,
  198. enable_tool_calling=False,
  199. )
  200. subject_name = self.game_session.current_figure.get("name", "未知")
  201. logger.info(f"[AGENT] Role-play agent created | subject={subject_name}")
  202. return agent
  203. def _create_system_prompt(self) -> str:
  204. """Create dynamic system prompt based on current subject"""
  205. figure = self.game_session.current_figure
  206. return _ROLEPLAY_SYSTEM_PROMPT.format(
  207. bio=figure["bio"],
  208. )
  209. # ── Guess ─────────────────────────────────────────────────────────────────
  210. def make_guess(self, guess_name: str) -> Dict:
  211. """Process a guess: semantic match via self._llm, then delegate to game_session.
  212. If correct, fetch figure portrait via SearchImageTool (Wikipedia).
  213. """
  214. result = self.game_session.make_guess(
  215. guess_name,
  216. semantic_match_fn=self._semantic_match
  217. )
  218. # If guessed correctly, fetch portrait images via Wikipedia
  219. if result.get("correct") and self._image_tool:
  220. figure_name = self.game_session.current_figure.get("name", guess_name)
  221. logger.info(f"[AGENT] Fetching portrait images for {figure_name!r}")
  222. photos = self._image_tool.search_photos(figure_name, per_page=3)
  223. result["portrait_images"] = photos
  224. logger.info(f"[AGENT] Portrait images fetched: {len(photos)} results")
  225. return result
  226. def _semantic_match(self, guess: str, actual: str) -> bool:
  227. """Use LLM to semantically judge whether guess and actual refer to the same subject."""
  228. try:
  229. prompt = _SEMANTIC_MATCH_PROMPT.format(guess=guess.strip(), actual=actual)
  230. result = self._llm.invoke([{"role": "user", "content": prompt}]).strip()
  231. logger.info(f"[AGENT] Semantic match | guess={guess!r} actual={actual!r} llm_answer={result!r}")
  232. return result.startswith("是")
  233. except Exception as e:
  234. logger.error(f"[AGENT] Semantic match failed: {e}", exc_info=True)
  235. return False
  236. # ── Chat ──────────────────────────────────────────────────────────────────
  237. def chat(self, user_message: str) -> str:
  238. """
  239. Process user message and return Agent reply
  240. Args:
  241. user_message: user input message
  242. Returns:
  243. Agent reply content
  244. """
  245. try:
  246. logger.info(f"[AGENT] Calling LLM | user={user_message!r}")
  247. response = self.agent.run(user_message)
  248. logger.info(f"[AGENT] LLM response received | response={response!r}")
  249. # Update game state (increment question count)
  250. self.game_session.ask_question()
  251. return response
  252. except Exception as e:
  253. logger.error(f"[AGENT] LLM call failed: {e}", exc_info=True)
  254. return "抱歉,我现在有些恍惚,请再问一次吧。"
  255. def get_conversation_history(self) -> List[Message]:
  256. """Get full conversation history"""
  257. return self.agent.get_history()
  258. def reset_conversation(self):
  259. """Reset conversation history and reload subject"""
  260. self.agent.clear_history()
  261. # Reload a new subject
  262. figure = self._generate_figure()
  263. self.game_session.current_figure = figure
  264. # Re-generate hints
  265. hints = self._generate_hints(figure["name"])
  266. self.game_session.hints = hints
  267. # Rebuild system prompt
  268. system_prompt = self._create_system_prompt()
  269. self.agent.system_prompt = system_prompt
  270. logger.info("[AGENT] Conversation reset and new subject loaded")
  271. # ── Utility functions ─────────────────────────────────────────────────────────
  272. def check_guess(guess: str, actual_name: str) -> bool:
  273. """
  274. Check if user guess is correct
  275. Args:
  276. guess: user guessed name
  277. actual_name: actual subject name
  278. Returns:
  279. bool: whether guess is correct
  280. """
  281. return guess.strip().lower() == actual_name.lower()
  282. def provide_hint(figure: Dict, hints: List[str], hint_index: int = 0) -> str:
  283. """
  284. Provide hint about the subject
  285. Args:
  286. figure: subject info dict
  287. hints: pre-generated hint list
  288. hint_index: which hint to return (0-based)
  289. Returns:
  290. str: hint message
  291. """
  292. if hints and hint_index < len(hints):
  293. return hints[hint_index]
  294. return "这是一个广为人知的事物"