|
@@ -0,0 +1,351 @@
|
|
|
|
|
+import logging
|
|
|
|
|
+import random
|
|
|
|
|
+import time
|
|
|
|
|
+from typing import Dict, List
|
|
|
|
|
+from hello_agents import SimpleAgent, HelloAgentsLLM, Message
|
|
|
|
|
+
|
|
|
|
|
+from config import get_config
|
|
|
|
|
+from game_logic import GameSession
|
|
|
|
|
+
|
|
|
|
|
+logger = logging.getLogger("game.agent")
|
|
|
|
|
+
|
|
|
|
|
+# 人物候选池,用于随机注入 system prompt,强制 LLM 从不同方向发散
|
|
|
|
|
+_FIGURE_DOMAINS = [
|
|
|
|
|
+ "中国古代帝王(如汉武帝、唐太宗、武则天、康熙等)",
|
|
|
|
|
+ "中国古代文人墨客(如李白、杜甫、苏轼、王羲之等)",
|
|
|
|
|
+ "中国古代军事家(如岳飞、霍去病、戚继光、韩信等)",
|
|
|
|
|
+ "中国神话人物(如女娲、嫦娥、哪吒、二郎神等)",
|
|
|
|
|
+ "西游记人物(如孙悟空、猪八戒、唐僧、沙僧等)",
|
|
|
|
|
+ "三国人物(如诸葛亮、曹操、刘备、关羽、周瑜等)",
|
|
|
|
|
+ "西方历史人物(如拿破仑、凯撒、亚历山大、牛顿等)",
|
|
|
|
|
+ "西方神话人物(如宙斯、雅典娜、赫拉克勒斯、阿喀琉斯等)",
|
|
|
|
|
+ "世界科学家(如爱因斯坦、居里夫人、达芬奇、伽利略等)",
|
|
|
|
|
+ "知名虚构角色(如哈利·波特、福尔摩斯、哆啦A梦、白雪公主等)",
|
|
|
|
|
+ "现代体育明星(如姚明、李娜、迈克尔·乔丹、贝利等)",
|
|
|
|
|
+ "中国近现代人物(如鲁迅、梁启超、郑成功、林则徐等)",
|
|
|
|
|
+ "网络红人与UP主(如李子柒、papi酱、散打哥、罗翔等知名网络人物)",
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _build_random_figure_prompt() -> str:
|
|
|
|
|
+ """Dynamically build a system prompt with a random domain and seed to avoid LLM caching."""
|
|
|
|
|
+ domain = random.choice(_FIGURE_DOMAINS)
|
|
|
|
|
+ seed = random.randint(10000, 99999)
|
|
|
|
|
+ return f"""你是一个随机知名人物生成器。随机种子:{seed}
|
|
|
|
|
+本次请从【{domain}】这个方向随机选择一个人物。
|
|
|
|
|
+要求:
|
|
|
|
|
+1. 必须是大众熟知、有足够信息可供猜测的人物
|
|
|
|
|
+2. 必须是人物(真实或虚构),不要选建筑、动植物、自然景观等物体
|
|
|
|
|
+3. 输出格式严格如下(两行,不要多余内容):
|
|
|
|
|
+名称:<人物名称>
|
|
|
|
|
+简介:<一句话概括其性格特点与主要成就,50字以内>
|
|
|
|
|
+4. 每次必须随机选择,不要总是选同一个"""
|
|
|
|
|
+
|
|
|
|
|
+_HINT_SYSTEM_PROMPT = """你是一位博学的助手。
|
|
|
|
|
+根据提供的搜索资料,生成3条适合猜谜游戏的提示。
|
|
|
|
|
+要求:
|
|
|
|
|
+1. 每条提示单独一行,格式为:提示N:<内容>
|
|
|
|
|
+2. 提示由模糊到具体,第1条最模糊,第3条最具体
|
|
|
|
|
+3. 不能直接说出答案的名称
|
|
|
|
|
+4. 只输出3行提示,不要其他内容"""
|
|
|
|
|
+
|
|
|
|
|
+_SEMANTIC_MATCH_PROMPT = """你是一位知识渊博的助手。请判断以下两个名称是否指代同一个人物或事物。
|
|
|
|
|
+只需回答 "是" 或 "否",不要输出任何其他内容。
|
|
|
|
|
+名称A:{guess}
|
|
|
|
|
+名称B:{actual}"""
|
|
|
|
|
+
|
|
|
|
|
+_ROLEPLAY_SYSTEM_PROMPT = """你正在参与一个猜谜游戏,扮演一个神秘人物(代号:【谜底】)。
|
|
|
|
|
+
|
|
|
|
|
+## 人物背景(仅供你参考,不可直接透露):
|
|
|
|
|
+{bio}
|
|
|
|
|
+
|
|
|
|
|
+## 对话规则:
|
|
|
|
|
+1. 以该人物的第一人称身份回答,语气、措辞符合其性格特点与所处时代/背景
|
|
|
|
|
+2. 用户会通过提问来猜测你的身份,**必须直接针对用户的问题给出明确回应**(如"是的"/"不是"/"确实如此"等),不能回避或答非所问
|
|
|
|
|
+3. 在给出明确回应的基础上,可以用符合人物身份的语气补充一句,增加趣味性
|
|
|
|
|
+4. 每次回答要简短(1-2句话),不要长篇大论
|
|
|
|
|
+5. 回答内容要基于该人物真实的生平、性格、成就,不要编造
|
|
|
|
|
+6. **严禁在任何情况下说出该人物的名称**(包括姓名、字号、封号、外号等一切称谓)
|
|
|
|
|
+7. 如果用户的问题与该人物完全无关,可以用符合人物身份的方式婉转说明"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class HistoricalFigureAgent:
|
|
|
|
|
+ """GuessWhoAmI game Agent wrapper"""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self, game_session: GameSession):
|
|
|
|
|
+ """
|
|
|
|
|
+ Initialize Agent: use LLM to randomly generate a subject (person/object/landmark etc.)
|
|
|
|
|
+ with brief intro, then use TavilySearchTool to pre-generate 3 hints, finally create
|
|
|
|
|
+ role-play Agent.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ game_session: game session object to store current subject info
|
|
|
|
|
+ """
|
|
|
|
|
+ self.game_session = game_session
|
|
|
|
|
+ config = get_config()
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(f"[AGENT] Initializing LLM: model={config.LLM_MODEL_ID} base_url={config.LLM_BASE_URL}")
|
|
|
|
|
+
|
|
|
|
|
+ self._llm = HelloAgentsLLM(
|
|
|
|
|
+ model=config.LLM_MODEL_ID,
|
|
|
|
|
+ api_key=config.LLM_API_KEY,
|
|
|
|
|
+ base_url=config.LLM_BASE_URL,
|
|
|
|
|
+ timeout=config.LLM_TIMEOUT,
|
|
|
|
|
+ provider="modelscope"
|
|
|
|
|
+ )
|
|
|
|
|
+ self._config = config
|
|
|
|
|
+
|
|
|
|
|
+ # Register search tool
|
|
|
|
|
+ self._search_tool = None
|
|
|
|
|
+ if config.TAVILY_API_KEY:
|
|
|
|
|
+ from tools.tavily_search_tool import TavilySearchTool
|
|
|
|
|
+ self._search_tool = TavilySearchTool(api_key=config.TAVILY_API_KEY)
|
|
|
|
|
+ logger.info("[AGENT] TavilySearchTool registered")
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.warning("[AGENT] TAVILY_API_KEY not set, search tool disabled")
|
|
|
|
|
+
|
|
|
|
|
+ # Register Wikipedia image tool (no API key required)
|
|
|
|
|
+ from tools.search_image_tool import SearchImageTool
|
|
|
|
|
+ self._image_tool = SearchImageTool()
|
|
|
|
|
+ logger.info("[AGENT] SearchImageTool (Wikipedia) registered")
|
|
|
|
|
+
|
|
|
|
|
+ # Step 1: LLM generates subject name + brief intro
|
|
|
|
|
+ figure = self._generate_figure()
|
|
|
|
|
+ self.game_session.current_figure = figure
|
|
|
|
|
+ logger.info(f"[AGENT] Subject loaded: {figure}")
|
|
|
|
|
+
|
|
|
|
|
+ # Step 2: pre-generate 3 hints via tavily search
|
|
|
|
|
+ hints = self._generate_hints(figure["name"])
|
|
|
|
|
+ self.game_session.hints = hints
|
|
|
|
|
+ logger.info(f"[AGENT] Hints pre-generated: {hints}")
|
|
|
|
|
+
|
|
|
|
|
+ # Step 3: create role-play Agent
|
|
|
|
|
+ self.agent = self._create_roleplay_agent()
|
|
|
|
|
+
|
|
|
|
|
+ # ── Subject generation ────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ def _generate_figure(self) -> Dict[str, str]:
|
|
|
|
|
+ """Use LLM to randomly generate a subject (person/object/landmark) with brief intro."""
|
|
|
|
|
+ try:
|
|
|
|
|
+ system_prompt = _build_random_figure_prompt()
|
|
|
|
|
+ ts = int(time.time() * 1000)
|
|
|
|
|
+ messages = [
|
|
|
|
|
+ {"role": "system", "content": system_prompt},
|
|
|
|
|
+ {"role": "user", "content": f"请随机给我一个(时间戳:{ts},随机数:{random.randint(1, 9999)})"},
|
|
|
|
|
+ ]
|
|
|
|
|
+ raw = self._llm.invoke(messages).strip()
|
|
|
|
|
+ logger.info(f"[AGENT] LLM generated subject raw: {raw!r}")
|
|
|
|
|
+ return self._parse_figure(raw)
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error(f"[AGENT] Failed to generate subject via LLM: {e}", exc_info=True)
|
|
|
|
|
+ return self._fallback_figure()
|
|
|
|
|
+
|
|
|
|
|
+ def _parse_figure(self, raw: str) -> Dict[str, str]:
|
|
|
|
|
+ """Parse LLM output into {name, bio} dict."""
|
|
|
|
|
+ name = ""
|
|
|
|
|
+ bio = ""
|
|
|
|
|
+ for line in raw.splitlines():
|
|
|
|
|
+ line = line.strip()
|
|
|
|
|
+ if line.startswith("名称:") or line.startswith("名称:") or line.startswith("姓名:") or line.startswith("姓名:"):
|
|
|
|
|
+ name = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
|
|
|
+ elif line.startswith("简介:") or line.startswith("简介:"):
|
|
|
|
|
+ bio = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
|
|
|
+ if not name:
|
|
|
|
|
+ logger.warning("[AGENT] Failed to parse subject name, using fallback")
|
|
|
|
|
+ return self._fallback_figure()
|
|
|
|
|
+ return {"name": name, "bio": bio}
|
|
|
|
|
+
|
|
|
|
|
+ def _fallback_figure(self) -> Dict[str, str]:
|
|
|
|
|
+ """Return a minimal fallback person when LLM fails."""
|
|
|
|
|
+ persons = [
|
|
|
|
|
+ ("孔子", "春秋时期思想家、教育家,儒家学派创始人,性格温和而坚定,一生致力于礼乐仁义"),
|
|
|
|
|
+ ("孙悟空", "《西游记》中的神话英雄,天性顽皮好斗、嫉恶如仇,七十二变,大闹天宫"),
|
|
|
|
|
+ ("武则天", "中国历史上唯一的女皇帝,铁腕治国,心思缜密,开创武周政权"),
|
|
|
|
|
+ ("诸葛亮", "三国时期蜀汉丞相,足智多谋、鞠躬尽瘁,以隆中对和空城计闻名"),
|
|
|
|
|
+ ("哈利·波特", "《哈利·波特》系列中的魔法师主角,勇敢善良,最终击败伏地魔"),
|
|
|
|
|
+ ]
|
|
|
|
|
+ name, bio = random.choice(persons)
|
|
|
|
|
+ return {"name": name, "bio": bio}
|
|
|
|
|
+
|
|
|
|
|
+ # ── Hint generation ───────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ def _generate_hints(self, name: str) -> List[str]:
|
|
|
|
|
+ """Use TavilySearchTool to search subject info, then LLM generates 3 hints."""
|
|
|
|
|
+ if not self._search_tool:
|
|
|
|
|
+ return self._fallback_hints(name)
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ search_results = self._search_tool.run(
|
|
|
|
|
+ {"query": f"{name} 简介 特点 介绍"}
|
|
|
|
|
+ )
|
|
|
|
|
+ logger.info(f"[AGENT] Search results for hints, length: {len(search_results)} chars")
|
|
|
|
|
+
|
|
|
|
|
+ messages = [
|
|
|
|
|
+ {"role": "system", "content": _HINT_SYSTEM_PROMPT},
|
|
|
|
|
+ {"role": "user", "content": f"答案:{name}\n\n搜索资料:\n{search_results}\n\n请生成3条提示:"},
|
|
|
|
|
+ ]
|
|
|
|
|
+ raw = self._llm.invoke(messages).strip()
|
|
|
|
|
+ logger.info(f"[AGENT] LLM hint raw output: {raw!r}")
|
|
|
|
|
+ return self._parse_hints(raw, name)
|
|
|
|
|
+
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error(f"[AGENT] Hint generation failed: {e}", exc_info=True)
|
|
|
|
|
+ return self._fallback_hints(name)
|
|
|
|
|
+
|
|
|
|
|
+ def _parse_hints(self, raw: str, name: str) -> List[str]:
|
|
|
|
|
+ """Parse LLM hint output into a list of 3 hint strings."""
|
|
|
|
|
+ hints = []
|
|
|
|
|
+ for line in raw.splitlines():
|
|
|
|
|
+ line = line.strip()
|
|
|
|
|
+ if not line:
|
|
|
|
|
+ continue
|
|
|
|
|
+ # Remove prefix like "提示1:" / "提示1:" / "1." etc.
|
|
|
|
|
+ import re
|
|
|
|
|
+ cleaned = re.sub(r'^(提示\d[::]\s*|\d+[\.、]\s*)', '', line).strip()
|
|
|
|
|
+ if cleaned:
|
|
|
|
|
+ hints.append(cleaned)
|
|
|
|
|
+ # Ensure exactly 3 hints
|
|
|
|
|
+ if len(hints) >= 3:
|
|
|
|
|
+ return hints[:3]
|
|
|
|
|
+ # Pad with fallback if not enough
|
|
|
|
|
+ fallback = self._fallback_hints(name)
|
|
|
|
|
+ hints.extend(fallback[len(hints):])
|
|
|
|
|
+ return hints[:3]
|
|
|
|
|
+
|
|
|
|
|
+ def _fallback_hints(self, name: str) -> List[str]:
|
|
|
|
|
+ """Return fallback hints when search/LLM fails."""
|
|
|
|
|
+ return [
|
|
|
|
|
+ "这是一个广为人知的事物",
|
|
|
|
|
+ "它在各自的领域中具有重要地位或影响力",
|
|
|
|
|
+ "它的名字在国内外都有很高的知名度",
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ # ── Role-play Agent ───────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ def _create_roleplay_agent(self) -> SimpleAgent:
|
|
|
|
|
+ """Create the role-play SimpleAgent (no tools, conversation only)"""
|
|
|
|
|
+ system_prompt = self._create_system_prompt()
|
|
|
|
|
+ agent = SimpleAgent(
|
|
|
|
|
+ name="guess_who_agent",
|
|
|
|
|
+ llm=self._llm,
|
|
|
|
|
+ system_prompt=system_prompt,
|
|
|
|
|
+ enable_tool_calling=False,
|
|
|
|
|
+ )
|
|
|
|
|
+ subject_name = self.game_session.current_figure.get("name", "未知")
|
|
|
|
|
+ logger.info(f"[AGENT] Role-play agent created | subject={subject_name}")
|
|
|
|
|
+ return agent
|
|
|
|
|
+
|
|
|
|
|
+ def _create_system_prompt(self) -> str:
|
|
|
|
|
+ """Create dynamic system prompt based on current subject"""
|
|
|
|
|
+ figure = self.game_session.current_figure
|
|
|
|
|
+ return _ROLEPLAY_SYSTEM_PROMPT.format(
|
|
|
|
|
+ bio=figure["bio"],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # ── Guess ─────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ def make_guess(self, guess_name: str) -> Dict:
|
|
|
|
|
+ """Process a guess: semantic match via self._llm, then delegate to game_session.
|
|
|
|
|
+ If correct, fetch figure portrait via SearchImageTool (Wikipedia).
|
|
|
|
|
+ """
|
|
|
|
|
+ result = self.game_session.make_guess(
|
|
|
|
|
+ guess_name,
|
|
|
|
|
+ semantic_match_fn=self._semantic_match
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # If guessed correctly, fetch portrait images via Wikipedia
|
|
|
|
|
+ if result.get("correct") and self._image_tool:
|
|
|
|
|
+ figure_name = self.game_session.current_figure.get("name", guess_name)
|
|
|
|
|
+ logger.info(f"[AGENT] Fetching portrait images for {figure_name!r}")
|
|
|
|
|
+ photos = self._image_tool.search_photos(figure_name, per_page=3)
|
|
|
|
|
+ result["portrait_images"] = photos
|
|
|
|
|
+ logger.info(f"[AGENT] Portrait images fetched: {len(photos)} results")
|
|
|
|
|
+
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+ def _semantic_match(self, guess: str, actual: str) -> bool:
|
|
|
|
|
+ """Use LLM to semantically judge whether guess and actual refer to the same subject."""
|
|
|
|
|
+ try:
|
|
|
|
|
+ prompt = _SEMANTIC_MATCH_PROMPT.format(guess=guess.strip(), actual=actual)
|
|
|
|
|
+ result = self._llm.invoke([{"role": "user", "content": prompt}]).strip()
|
|
|
|
|
+ logger.info(f"[AGENT] Semantic match | guess={guess!r} actual={actual!r} llm_answer={result!r}")
|
|
|
|
|
+ return result.startswith("是")
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error(f"[AGENT] Semantic match failed: {e}", exc_info=True)
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ # ── Chat ──────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ def chat(self, user_message: str) -> str:
|
|
|
|
|
+ """
|
|
|
|
|
+ Process user message and return Agent reply
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ user_message: user input message
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ Agent reply content
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ logger.info(f"[AGENT] Calling LLM | user={user_message!r}")
|
|
|
|
|
+ response = self.agent.run(user_message)
|
|
|
|
|
+ logger.info(f"[AGENT] LLM response received | response={response!r}")
|
|
|
|
|
+
|
|
|
|
|
+ # Update game state (increment question count)
|
|
|
|
|
+ self.game_session.ask_question()
|
|
|
|
|
+
|
|
|
|
|
+ return response
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error(f"[AGENT] LLM call failed: {e}", exc_info=True)
|
|
|
|
|
+ return "抱歉,我现在有些恍惚,请再问一次吧。"
|
|
|
|
|
+
|
|
|
|
|
+ def get_conversation_history(self) -> List[Message]:
|
|
|
|
|
+ """Get full conversation history"""
|
|
|
|
|
+ return self.agent.get_history()
|
|
|
|
|
+
|
|
|
|
|
+ def reset_conversation(self):
|
|
|
|
|
+ """Reset conversation history and reload subject"""
|
|
|
|
|
+ self.agent.clear_history()
|
|
|
|
|
+ # Reload a new subject
|
|
|
|
|
+ figure = self._generate_figure()
|
|
|
|
|
+ self.game_session.current_figure = figure
|
|
|
|
|
+ # Re-generate hints
|
|
|
|
|
+ hints = self._generate_hints(figure["name"])
|
|
|
|
|
+ self.game_session.hints = hints
|
|
|
|
|
+ # Rebuild system prompt
|
|
|
|
|
+ system_prompt = self._create_system_prompt()
|
|
|
|
|
+ self.agent.system_prompt = system_prompt
|
|
|
|
|
+ logger.info("[AGENT] Conversation reset and new subject loaded")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Utility functions ─────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def check_guess(guess: str, actual_name: str) -> bool:
|
|
|
|
|
+ """
|
|
|
|
|
+ Check if user guess is correct
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ guess: user guessed name
|
|
|
|
|
+ actual_name: actual subject name
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ bool: whether guess is correct
|
|
|
|
|
+ """
|
|
|
|
|
+ return guess.strip().lower() == actual_name.lower()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def provide_hint(figure: Dict, hints: List[str], hint_index: int = 0) -> str:
|
|
|
|
|
+ """
|
|
|
|
|
+ Provide hint about the subject
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ figure: subject info dict
|
|
|
|
|
+ hints: pre-generated hint list
|
|
|
|
|
+ hint_index: which hint to return (0-based)
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ str: hint message
|
|
|
|
|
+ """
|
|
|
|
|
+ if hints and hint_index < len(hints):
|
|
|
|
|
+ return hints[hint_index]
|
|
|
|
|
+ return "这是一个广为人知的事物"
|