search_keyword_agent.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. """
  4. 搜索关键词Agent
  5. 功能: 基于视频分析结果(灵感点、目的点、关键点、选题理解)生成抖音/TikTok组合搜索词列表
  6. 核心任务: 分析视频核心构成,输出用于检索竞品内容的搜索词组合
  7. 特征: 使用"元素+语境"组合法,生成精准搜索词
  8. """
  9. from typing import Any, Dict, List
  10. import json
  11. import re
  12. from src.components.agents.base import BaseLLMAgent
  13. from src.utils.logger import get_logger
  14. logger = get_logger(__name__)
  15. class SearchKeywordAgent(BaseLLMAgent):
  16. """搜索关键词Agent - 生成抖音/TikTok组合搜索词列表"""
  17. def __init__(
  18. self,
  19. name: str = "search_keyword_agent",
  20. description: str = "搜索关键词Agent - 生成组合搜索词列表",
  21. model_provider: str = "google_genai",
  22. temperature: float = 0.7,
  23. max_tokens: int = 8192
  24. ):
  25. """
  26. 初始化搜索关键词Agent
  27. Args:
  28. name: Agent名称
  29. description: Agent描述
  30. model_provider: 模型提供商 ("openai" 或 "google_genai")
  31. temperature: 生成温度,控制创造性
  32. max_tokens: 最大token数
  33. """
  34. system_prompt = self._build_system_prompt()
  35. super().__init__(
  36. name=name,
  37. description=description,
  38. model_provider=model_provider,
  39. system_prompt=system_prompt,
  40. temperature=temperature,
  41. max_tokens=max_tokens
  42. )
  43. def _build_system_prompt(self) -> str:
  44. """构建系统提示词 - 定义角色、能力、输出规范"""
  45. return """# Role
  46. 你是一位精通全网内容分发逻辑与搜索引擎优化(SEO)的数据分析师。
  47. # Task
  48. 我将提供一段关于视频内容的详细结构化数据(JSON格式),包含【灵感点】、【目的点】和【关键点】。
  49. 你的任务是分析该视频的核心构成,并输出一份**抖音/TikTok组合搜索词列表**,用于检索与该视频**题材相同、切入点相似、受众重合**的竞品内容。
  50. # Core Strategy: "元素+语境" 组合法
  51. 为了保证搜索结果的精准度,请严格遵循 **`[核心实体/具象元素]` + `[主题语境/内容属性]`** 的组合公式进行输出。
  52. ### 1. 定义"核心实体" (Left Side)
  53. 请从 JSON 的 `关键点 (Key Points)` 和 `视频信息` 中提取最具体的名词。
  54. * **包括**:具体的人名/地名/产品名、特定的时间节点、核心物体、显性的视觉符号、特定的动作名称。
  55. * **排除**:泛泛的大类词(如"生活"、"快乐"、"健康")。
  56. ### 2. 定义"主题语境" (Right Side)
  57. 请从 JSON 的 `灵感点 (Inspiration)` 和 `目的点 (Purpose)` 中提取修饰性的词汇。
  58. * **包括**:内容的体裁(教程/测评/Vlog)、情感色彩(治愈/吐槽/焦虑)、功能属性(避坑/省钱/变美)、特定的叙事逻辑(反转/揭秘/老话)。
  59. # Execution Rules (执行规则)
  60. 请扫描 JSON 数据,针对不同维度生成搜索词:
  61. 1. **提取"强钩子"组合**:
  62. * 找到视频开头最吸引人的视觉或概念元素(灵感点)。
  63. * *通用公式*:`[核心视觉/事件]` + `[钩子属性(如:真相/后续/反常)]`
  64. 2. **提取"核心内容"组合**:
  65. * 找到视频主要传达的知识、剧情或展示物(关键点)。
  66. * *通用公式*:`[具体事物/动作]` + `[垂直领域词(如:做法/技巧/评价)]`
  67. 3. **提取"受众痛点"组合**:
  68. * 找到视频想要解决的问题或满足的需求(目的点)。
  69. * *通用公式*:`[受众标签/场景]` + `[痛点/解决方案]`
  70. # Output Format
  71. 请输出 10-12 个组合搜索词,按以下格式排列,并简要解释组合逻辑:
  72. * **`关键词` + `语境词`** (例如:*空气炸锅 翻车* / *重阳节 禁忌* / *面试 自我介绍*)
  73. * *逻辑:提取了[X]元素,结合[Y]主题。*
  74. 输出格式为JSON:
  75. ```json
  76. {
  77. "搜索词列表": [
  78. {
  79. "搜索词": "关键词 语境词",
  80. "组合逻辑": "提取了[X]元素,结合[Y]主题。"
  81. }
  82. ]
  83. }
  84. ```"""
  85. def _build_messages(self, state: Dict[str, Any]) -> List[Dict[str, Any]]:
  86. """
  87. 构建LLM消息
  88. Args:
  89. state: 包含视频和其他agent结果的状态字典,需要包含:
  90. - inspiration_points: 灵感点提取结果
  91. - purpose_point: 目的点提取结果
  92. - key_points: 关键点提取结果
  93. - topic_selection_understanding: 选题理解结果
  94. Returns:
  95. 消息列表
  96. """
  97. # 提取输入数据
  98. inspiration_points = state.get("inspiration_points", [])
  99. purpose_point = state.get("purpose_point", {})
  100. key_points = state.get("key_points", {})
  101. topic_selection_understanding = state.get("topic_selection_understanding", {})
  102. # 构建提示词
  103. prompt = self._build_user_prompt(
  104. inspiration_points,
  105. purpose_point,
  106. key_points,
  107. topic_selection_understanding
  108. )
  109. # 构建消息
  110. return [
  111. {"role": "system", "content": self.system_prompt},
  112. {"role": "user", "content": prompt}
  113. ]
  114. def _update_state(self, state: Dict[str, Any], response: Any) -> Dict[str, Any]:
  115. """
  116. 更新状态
  117. Args:
  118. state: 原始状态
  119. response: LLM响应
  120. Returns:
  121. 更新后的状态
  122. """
  123. # 解析响应
  124. result = self._parse_response(response.content)
  125. return {
  126. "search_keywords": result
  127. }
  128. def _build_user_prompt(
  129. self,
  130. inspiration_points: Any,
  131. purpose_point: Dict[str, Any],
  132. key_points: Dict[str, Any],
  133. topic_selection_understanding: Dict[str, Any]
  134. ) -> str:
  135. """构建用户提示 - 提供数据和执行指令"""
  136. # 格式化各维度数据
  137. inspiration_section = self._format_inspiration_points(inspiration_points)
  138. purpose_section = self._format_purpose_point(purpose_point)
  139. key_points_section = self._format_key_points(key_points)
  140. topic_section = self._format_topic_selection_understanding(topic_selection_understanding)
  141. return f"""# Input Data
  142. ## 选题理解结果
  143. {topic_section}
  144. ## 灵感点分析
  145. {inspiration_section}
  146. ## 目的点分析
  147. {purpose_section}
  148. ## 关键点分析
  149. {key_points_section}
  150. ---
  151. # 执行指令
  152. 基于以上数据,输出 10-12 个组合搜索词。
  153. ## 质量要求
  154. - **搜索词数量**: 10-12 个
  155. - **组合公式**: `[核心实体/具象元素]` + `[主题语境/内容属性]`
  156. - **覆盖维度**: 需要覆盖"强钩子"、"核心内容"、"受众痛点"三个维度
  157. - **精准度**: 搜索词应该能够检索到与该视频题材相同、切入点相似、受众重合的竞品内容
  158. - **组合逻辑**: 每个搜索词需要简要说明提取了哪些元素,结合了哪些主题
  159. **重要提醒**:
  160. - 核心实体必须具体,避免泛泛的大类词
  161. - 主题语境要能体现内容的体裁、情感、功能或叙事逻辑
  162. - 搜索词不能出现雷同和近似的词语
  163. - 搜索词应该符合抖音/TikTok用户的搜索习惯"""
  164. def _format_topic_selection_understanding(self, topic_selection_understanding: Dict[str, Any]) -> str:
  165. """格式化选题理解信息"""
  166. if not topic_selection_understanding:
  167. return "**未找到选题理解结果**"
  168. 主题 = topic_selection_understanding.get("主题", "")
  169. 描述 = topic_selection_understanding.get("描述", "")
  170. formatted = ""
  171. if 主题:
  172. formatted += f"**主题**: {主题}\n"
  173. if 描述:
  174. formatted += f"**描述**: {描述}\n"
  175. return formatted if formatted else "**未找到选题理解结果**"
  176. def _format_inspiration_points(self, inspiration_points: Any) -> str:
  177. """格式化灵感点信息"""
  178. # 新结构:inspiration_points 直接是列表
  179. if isinstance(inspiration_points, list):
  180. if not inspiration_points:
  181. return "**未提取到灵感点**"
  182. formatted = f"**灵感点总数**: {len(inspiration_points)}\n\n"
  183. for idx, point in enumerate(inspiration_points, 1):
  184. formatted += f"### 灵感点 {idx}: {point.get('灵感点', 'N/A')}\n"
  185. formatted += f"**分类**: {point.get('分类', '')}\n"
  186. formatted += f"**描述**: {point.get('描述', '')}\n"
  187. if point.get("推理"):
  188. formatted += f"**推理**: {point.get('推理', '')}\n"
  189. formatted += "\n"
  190. return formatted
  191. # 旧结构:{"points": [...]} 或字典格式
  192. if isinstance(inspiration_points, dict):
  193. # 旧结构:points列表
  194. if "points" in inspiration_points:
  195. points = inspiration_points.get("points", [])
  196. if not points:
  197. return "**未提取到灵感点**"
  198. formatted = f"**灵感点总数**: {len(points)}\n\n"
  199. for idx, point in enumerate(points, 1):
  200. formatted += f"### 灵感点 {idx}: {point.get('灵感点表象', 'N/A')}\n"
  201. formatted += f"**本质**: {point.get('灵感点本质', '')}\n"
  202. formatted += f"{point.get('本质详细说明', '')}\n\n"
  203. return formatted
  204. return "**未提取到灵感点**"
  205. def _format_purpose_point(self, purpose_point: Dict[str, Any]) -> str:
  206. """格式化目的点信息"""
  207. if not purpose_point:
  208. return "**未提取到目的点**"
  209. # 新结构:{"perspective": "创作者视角", "purposes": [...], "total_count": 2}
  210. purposes = purpose_point.get("purposes", [])
  211. if not purposes:
  212. return "**未提取到目的点**"
  213. formatted = f"**目的点总数**: {len(purposes)}\n\n"
  214. for idx, purpose in enumerate(purposes, 1):
  215. 维度 = purpose.get("维度", {})
  216. if isinstance(维度, dict):
  217. 维度_str = f"{维度.get('一级分类', '')}/{维度.get('二级分类', '')}"
  218. else:
  219. 维度_str = str(维度)
  220. 目的点 = purpose.get("目的点", "N/A")
  221. 描述 = purpose.get("描述", "")
  222. formatted += f"### 目的点 {idx}: {目的点}\n"
  223. formatted += f"**维度**: {维度_str}\n"
  224. if 描述:
  225. formatted += f"**描述**: {描述}\n"
  226. formatted += "\n"
  227. return formatted
  228. def _format_key_points(self, key_points: Dict[str, Any]) -> str:
  229. """格式化关键点信息 - 支持层级结构"""
  230. if not key_points or "key_points" not in key_points:
  231. return "**未提取到关键点**"
  232. points = key_points.get("key_points", [])
  233. if not points:
  234. return "**未提取到关键点**"
  235. total_count = key_points.get("total_count", len(points))
  236. root_count = key_points.get("root_count", len(points))
  237. hierarchy_enabled = key_points.get("hierarchy_enabled", False)
  238. formatted = f"**关键点总数**: {total_count}\n"
  239. if hierarchy_enabled:
  240. formatted += f"**一级关键点数**: {root_count}\n"
  241. formatted += f"**层级结构**: 已启用\n\n"
  242. else:
  243. formatted += "\n"
  244. # 递归格式化关键点树
  245. def format_point_tree(point: Dict[str, Any], level: int = 1, index: int = 1) -> str:
  246. indent = " " * (level - 1)
  247. result = f"{indent}### 关键点 {index} (层级 {level}): {point.get('关键点', 'N/A')}\n"
  248. result += f"{indent}**维度**: [{point.get('维度大类', '')}] {point.get('维度细分', '')}\n"
  249. result += f"{indent}**描述**: {point.get('描述', '')}\n"
  250. # 递归处理子关键点
  251. children = point.get("children", [])
  252. if children:
  253. result += f"{indent}**子关键点**: {len(children)}个\n\n"
  254. for child_idx, child in enumerate(children, 1):
  255. result += format_point_tree(child, level + 1, child_idx)
  256. else:
  257. result += "\n"
  258. return result
  259. # 格式化所有一级关键点
  260. for idx, point in enumerate(points, 1):
  261. formatted += format_point_tree(point, level=1, index=idx)
  262. return formatted
  263. def _parse_response(self, response: str) -> Dict[str, Any]:
  264. """解析LLM响应"""
  265. try:
  266. # 尝试提取JSON代码块
  267. json_match = re.search(r'```(?:json)?\s*(.*?)\s*```', response, re.DOTALL)
  268. if json_match:
  269. json_str = json_match.group(1)
  270. else:
  271. json_str = response
  272. # 解析JSON
  273. result = json.loads(json_str)
  274. # 确保包含必需字段
  275. if "搜索词列表" not in result:
  276. result["搜索词列表"] = []
  277. # 验证每个搜索词的结构
  278. search_keywords = result.get("搜索词列表", [])
  279. validated_keywords = []
  280. for keyword in search_keywords:
  281. if isinstance(keyword, dict):
  282. # 确保包含必需字段
  283. validated_keyword = {
  284. "搜索词": keyword.get("搜索词", ""),
  285. "组合逻辑": keyword.get("组合逻辑", "")
  286. }
  287. if validated_keyword["搜索词"]: # 只保留有效的搜索词
  288. validated_keywords.append(validated_keyword)
  289. result["搜索词列表"] = validated_keywords
  290. result["总数"] = len(validated_keywords)
  291. return result
  292. except json.JSONDecodeError as e:
  293. logger.error(f"JSON解析失败: {e}")
  294. logger.error(f"响应内容: {response[:500]}")
  295. # JSON解析失败,返回默认结构
  296. return {
  297. "搜索词列表": [],
  298. "总数": 0,
  299. "错误": "解析失败"
  300. }
  301. except Exception as e:
  302. logger.error(f"解析响应时发生错误: {e}", exc_info=True)
  303. return {
  304. "搜索词列表": [],
  305. "总数": 0,
  306. "错误": str(e)
  307. }