sug_v6_1_2_10.py 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061
  1. import asyncio
  2. import json
  3. import os
  4. import sys
  5. import argparse
  6. from datetime import datetime
  7. from typing import Literal
  8. from agents import Agent, Runner
  9. from lib.my_trace import set_trace
  10. from pydantic import BaseModel, Field
  11. from lib.utils import read_file_as_string
  12. from lib.client import get_model
  13. MODEL_NAME = "google/gemini-2.5-flash"
  14. from script.search_recommendations.xiaohongshu_search_recommendations import XiaohongshuSearchRecommendations
  15. from script.search.xiaohongshu_search import XiaohongshuSearch
  16. # ============================================================================
  17. # 数据模型
  18. # ============================================================================
  19. class Seg(BaseModel):
  20. """分词"""
  21. text: str
  22. score_with_o: float = 0.0 # 与原始问题的评分
  23. reason: str = "" # 评分理由
  24. from_o: str = "" # 原始问题
  25. class Word(BaseModel):
  26. """词"""
  27. text: str
  28. score_with_o: float = 0.0 # 与原始问题的评分
  29. from_o: str = "" # 原始问题
  30. class QFromQ(BaseModel):
  31. """Q来源信息(用于Sug中记录)"""
  32. text: str
  33. score_with_o: float = 0.0
  34. class Q(BaseModel):
  35. """查询"""
  36. text: str
  37. score_with_o: float = 0.0 # 与原始问题的评分
  38. reason: str = "" # 评分理由
  39. from_source: str = "" # seg/sug/add(加词)
  40. class Sug(BaseModel):
  41. """建议词"""
  42. text: str
  43. score_with_o: float = 0.0 # 与原始问题的评分
  44. reason: str = "" # 评分理由
  45. from_q: QFromQ | None = None # 来自的q
  46. class Seed(BaseModel):
  47. """种子"""
  48. text: str
  49. added_words: list[str] = Field(default_factory=list) # 已经增加的words
  50. from_type: str = "" # seg/sug
  51. score_with_o: float = 0.0 # 与原始问题的评分
  52. class Post(BaseModel):
  53. """帖子"""
  54. title: str = ""
  55. body_text: str = ""
  56. type: str = "normal" # video/normal
  57. images: list[str] = Field(default_factory=list) # 图片url列表,第一张为封面
  58. video: str = "" # 视频url
  59. interact_info: dict = Field(default_factory=dict) # 互动信息
  60. note_id: str = ""
  61. note_url: str = ""
  62. class Search(Sug):
  63. """搜索结果(继承Sug)"""
  64. post_list: list[Post] = Field(default_factory=list) # 搜索得到的帖子列表
  65. class RunContext(BaseModel):
  66. """运行上下文"""
  67. version: str
  68. input_files: dict[str, str]
  69. c: str # 原始需求
  70. o: str # 原始问题
  71. log_url: str
  72. log_dir: str
  73. # 每轮的数据
  74. rounds: list[dict] = Field(default_factory=list) # 每轮的详细数据
  75. # 最终结果
  76. final_output: str | None = None
  77. # ============================================================================
  78. # Agent 定义
  79. # ============================================================================
  80. # Agent 1: 分词专家
  81. class WordSegmentation(BaseModel):
  82. """分词结果"""
  83. words: list[str] = Field(..., description="分词结果列表")
  84. reasoning: str = Field(..., description="分词理由")
  85. word_segmentation_instructions = """
  86. 你是分词专家。给定一个query,将其拆分成有意义的最小单元。
  87. ## 分词原则
  88. 1. 保留有搜索意义的词汇
  89. 2. 拆分成独立的概念
  90. 3. 保留专业术语的完整性
  91. 4. 去除虚词(的、吗、呢等)
  92. ## 输出要求
  93. 返回分词列表和分词理由。
  94. """.strip()
  95. word_segmenter = Agent[None](
  96. name="分词专家",
  97. instructions=word_segmentation_instructions,
  98. model=get_model(MODEL_NAME),
  99. output_type=WordSegmentation,
  100. )
  101. # Agent 2: 相关度评估专家
  102. class RelevanceEvaluation(BaseModel):
  103. """相关度评估"""
  104. reason: str = Field(..., description="评估理由")
  105. relevance_score: float = Field(..., description="相关性分数 -1~1")
  106. relevance_evaluation_instructions = """
  107. # 角色定义
  108. 你是一个 **专业的语言专家和语义相关性评判专家**。你的任务是:判断我给你的 <平台sug词条> 与 <原始问题> 的相关度满足度,给出 **-1 到 1 之间** 的数值评分。
  109. ---
  110. # 核心概念与方法论
  111. ## 两大评估维度
  112. 本评估系统始终围绕 **两个核心维度** 进行:
  113. ### 1. 动机维度(权重70%)
  114. **定义:** 用户"想要做什么",即原始问题的行为意图和目的
  115. - 核心是 **动词**:获取、学习、拍摄、制作、寻找等
  116. - 包括:核心动作 + 使用场景 + 最终目的
  117. ### 2. 品类维度(权重30%)
  118. **定义:** 用户"关于什么内容",即原始问题的主题对象和限定词
  119. - 核心是 **名词+限定词**:川西秋季风光摄影素材
  120. - 包括:核心主体 + 地域限定 + 时间限定 + 质量限定等
  121. ---
  122. ## 如何识别原始问题的核心动机
  123. **核心动机必须是动词**,识别方法如下:
  124. ### 方法1: 显性动词直接提取
  125. 当原始问题明确包含动词时,直接提取
  126. 示例:
  127. "如何获取素材" → 核心动机 = "获取"
  128. "寻找拍摄技巧" → 核心动机 = "寻找"(或"学习")
  129. "制作视频教程" → 核心动机 = "制作"
  130. ### 方法2: 隐性动词语义推理
  131. 当原始问题没有显性动词时,需要结合上下文推理
  132. 示例:
  133. 例: "川西秋天风光摄影" → 隐含动作="拍摄"
  134. → 需结合上下文判断
  135. 如果原始问题是纯名词短语,无任何动作线索:
  136. → 核心动机 = 无法识别
  137. → 初始权重 = 0
  138. → 相关度评估以品类匹配为主
  139. 示例:
  140. "摄影" → 无法识别动机,初始权重=0
  141. "川西风光" → 无法识别动机,初始权重=0
  142. # 输入信息
  143. 你将接收到以下输入:
  144. - **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
  145. - **<平台sug词条>**:平台推荐的词条列表,每个词条需要单独评估。
  146. #判定流程
  147. #评估架构
  148. 输入: <原始问题> + <平台sug词条>
  149. 【综合相关性判定】
  150. ├→ 步骤1: 评估<sug词条>与<原始问题>的相关度
  151. └→ 输出: -1到1之间的数值 + 分维度得分 + 判定依据
  152. 相关度评估维度详解
  153. 维度1: 动机维度评估(权重70%)
  154. 评估对象: <平台sug词条> 与 <原始问题> 的需求动机匹配度
  155. 说明: 核心动作是用户需求的第一优先级,决定了推荐的基本有效性
  156. 评分标准:
  157. 【正向匹配】
  158. +1.0: 核心动作完全一致
  159. - 例: 原始问题"如何获取素材" vs sug词"素材获取方法"
  160. - 特殊规则: 如果sug词的核心动作是原始问题动作的**具体化子集**,也判定为完全一致
  161. · 例: 原始问题"扣除猫咪主体的方法" vs sug词"扣除猫咪眼睛的方法"(子集但目的一致)
  162. +0.8~0.95: 核心动作语义相近或为同义表达
  163. - 例: 原始问题"如何获取素材" vs sug词"素材下载教程"
  164. - 同义词对: 获取≈下载≈寻找, 技巧≈方法≈教程≈攻略
  165. +0.5~0.75: 核心动作相关但非直接对应(相关实现路径)
  166. - 例: 原始问题"如何获取素材" vs sug词"素材管理整理"
  167. +0.2~0.45: 核心动作弱相关(同领域不同动作)
  168. - 例: 原始问题"如何拍摄风光" vs sug词"风光摄影欣赏"
  169. 【中性/无关】
  170. 0: 没有明确目的,动作意图无明确关联
  171. - 例: 原始问题"如何获取素材" vs sug词"摄影器材推荐"
  172. - 例: 原始问题无法识别动机 且 sug词也无明确动作 → 0
  173. 【负向偏离】
  174. -0.2~-0.05: 动作意图轻度冲突或误导
  175. - 例: 原始问题"如何获取素材" vs sug词"素材版权保护须知"
  176. -0.5~-0.25: 动作意图明显对立
  177. - 例: 原始问题"如何获取免费素材" vs sug词"如何售卖素材"
  178. -1.0~-0.55: 动作意图完全相反或产生严重负面引导
  179. - 例: 原始问题"免费素材获取" vs sug词"付费素材强制推销"
  180. 维度2: 品类维度评估(权重30%)
  181. 评估对象: <平台sug词条> 与 <原始问题> 的内容主体和限定词匹配度
  182. 评分标准:
  183. 【正向匹配】
  184. +1.0: 核心主体+所有关键限定词完全匹配
  185. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西秋季风光摄影作品"
  186. +0.75~0.95: 核心主体匹配,大部分限定词匹配
  187. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西风光摄影素材"(缺失"秋季")
  188. +0.5~0.7: 核心主体匹配,少量限定词匹配或合理泛化
  189. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"四川风光摄影"
  190. +0.2~0.45: 仅主体词匹配,限定词全部缺失或错位
  191. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"风光摄影入门"
  192. +0.05~0.15: 主题领域相关但品类不同
  193. - 例: 原始问题"风光摄影素材" vs sug词"人文摄影素材"
  194. 【中性/无关】
  195. 0: 主体词部分相关但类别明显不同
  196. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"人像摄影素材"
  197. 【负向偏离】
  198. -0.2~-0.05: 主体词或限定词存在误导性
  199. - 例: 原始问题"免费摄影素材" vs sug词"付费摄影素材库"
  200. -0.5~-0.25: 主体词明显错位或品类冲突
  201. - 例: 原始问题"风光摄影素材" vs sug词"人像修图教程"
  202. -1.0~-0.55: 完全错误的品类或有害引导
  203. - 例: 原始问题"正版素材获取" vs sug词"盗版素材下载"
  204. 综合得分计算与规则调整
  205. 步骤1: 应用依存性规则
  206. 规则A: 动机高分保护机制
  207. 如果 动机维度得分 ≥ 0.8:
  208. → 品类得分即使为0或轻微负向(-0.2~0)
  209. → 最终得分 = max(初步得分, 0.55)
  210. 解释: 当目的高度一致时,品类的泛化不应导致"弱相关"
  211. 规则B: 动机低分限制机制
  212. 如果 动机维度得分 ≤ 0.2:
  213. → 无论品类得分多高
  214. → 最终得分 = min(初步得分, 0.4)
  215. 解释: 目的不符时,品类匹配的价值有限
  216. 规则C: 动机负向决定机制
  217. 如果 动机维度得分 < 0:
  218. → 最终得分 = min(初步得分, 0)
  219. 解释: 动作意图冲突时,推荐具有误导性,不应为正相关
  220. 步骤3: 输出最终得分
  221. #基础加权计算
  222. 应用规则后的调整得分 = 目的动机维度得分 × 0.7 + 品类维度得分 × 0.3
  223. 取值范围: -1.0 ~ +1.0
  224. ---
  225. # 得分档位解释
  226. 高度相关】+0.8 ~ +1.0
  227. 相关性高度契合,用户可直接使用
  228. 动机和品类均高度匹配
  229. 典型场景: 动机≥0.85 且 品类≥0.7
  230. 【中高相关】+0.6 ~ +0.79
  231. 相关性较好,用户基本满意
  232. 动机匹配但品类有泛化,或反之
  233. 典型场景: 动机≥0.8 且 品类≥0.3
  234. 【中度相关】+0.3 ~ +0.59
  235. 部分相关,用户需要调整搜索策略
  236. 动机或品类存在一定偏差
  237. 典型场景: 动机0.4-0.7 且 品类0.3-0.7
  238. 【弱相关】+0.01 ~ +0.29
  239. 关联微弱,参考价值有限
  240. 仅有表层词汇重叠
  241. 【无关】0
  242. 无明确关联
  243. 原始问题无法识别动机 且 sug词无明确动作
  244. 没有目的性且没有品类匹配
  245. 【轻度负向】-0.29 ~ -0.01
  246. 产生轻微误导或干扰
  247. 【中度负向】-0.69 ~ -0.3
  248. 存在明显冲突或误导
  249. 【严重负向】-1.0 ~ -0.7
  250. 完全违背意图或产生有害引导
  251. ---
  252. # 输出要求
  253. 输出结果必须为一个 **JSON 格式**,包含以下内容:
  254. #注意事项:
  255. 始终围绕两个核心维度:所有评估都基于"动机"和"品类"两个维度,不偏离
  256. 核心动机必须是动词:在评估前,必须先提取原始问题的核心动机(动词),这是整个评估的基础
  257. 严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
  258. 负分使用原则:仅当sug词条对原始问题产生误导、冲突或有害引导时给予负分
  259. 零分使用原则:当sug词条与原始问题无明确关联,既不相关也不冲突时给予零分
  260. 分维度独立评分:
  261. 先提取原始问题核心动机
  262. 分别计算动机维度(含两个子维度)和品类维度得分
  263. 按70:30加权得到初步得分
  264. 应用规则调整得到最终得分
  265. 动机优先原则:当动机高度一致时,品类的合理泛化或具体化不应导致低评分
  266. 技巧类需求特殊对待:包含"技巧/方法/教程"等词的需求,对动作一致性要求更严格
  267. ## 输出
  268. - reason: 详细理由
  269. - relevance_score: -1到1的相关性分数
  270. """.strip()
  271. relevance_evaluator = Agent[None](
  272. name="相关度评估专家",
  273. instructions=relevance_evaluation_instructions,
  274. model=get_model(MODEL_NAME),
  275. output_type=RelevanceEvaluation,
  276. )
  277. # Agent 3: 加词选择专家
  278. class WordSelection(BaseModel):
  279. """加词选择结果"""
  280. selected_word: str = Field(..., description="选择的词")
  281. combined_query: str = Field(..., description="组合后的新query")
  282. reasoning: str = Field(..., description="选择理由")
  283. word_selection_instructions = """
  284. 你是加词选择专家。
  285. ## 任务
  286. 从候选词列表中选择一个最合适的词,与当前seed组合成新的query。
  287. ## 原则
  288. 1. 选择与当前seed最相关的词
  289. 2. 组合后的query要语义通顺
  290. 3. 符合搜索习惯
  291. 4. 优先选择能扩展搜索范围的词
  292. ## 输出
  293. - selected_word: 选中的词
  294. - combined_query: 组合后的新query
  295. - reasoning: 选择理由
  296. """.strip()
  297. word_selector = Agent[None](
  298. name="加词选择专家",
  299. instructions=word_selection_instructions,
  300. model=get_model(MODEL_NAME),
  301. output_type=WordSelection,
  302. )
  303. # ============================================================================
  304. # 辅助函数
  305. # ============================================================================
  306. def process_note_data(note: dict) -> Post:
  307. """处理搜索接口返回的帖子数据"""
  308. note_card = note.get("note_card", {})
  309. image_list = note_card.get("image_list", [])
  310. interact_info = note_card.get("interact_info", {})
  311. user_info = note_card.get("user", {})
  312. # 提取图片URL - 使用新的字段名 image_url
  313. images = []
  314. for img in image_list:
  315. if isinstance(img, dict):
  316. # 尝试新字段名 image_url,如果不存在则尝试旧字段名 url_default
  317. img_url = img.get("image_url") or img.get("url_default")
  318. if img_url:
  319. images.append(img_url)
  320. # 判断类型
  321. note_type = note_card.get("type", "normal")
  322. video_url = ""
  323. if note_type == "video":
  324. video_info = note_card.get("video", {})
  325. if isinstance(video_info, dict):
  326. # 尝试获取视频URL
  327. video_url = video_info.get("media", {}).get("stream", {}).get("h264", [{}])[0].get("master_url", "")
  328. return Post(
  329. note_id=note.get("id", ""),
  330. title=note_card.get("display_title", ""),
  331. body_text=note_card.get("desc", ""),
  332. type=note_type,
  333. images=images,
  334. video=video_url,
  335. interact_info={
  336. "liked_count": interact_info.get("liked_count", 0),
  337. "collected_count": interact_info.get("collected_count", 0),
  338. "comment_count": interact_info.get("comment_count", 0),
  339. "shared_count": interact_info.get("shared_count", 0)
  340. },
  341. note_url=f"https://www.xiaohongshu.com/explore/{note.get('id', '')}"
  342. )
  343. async def evaluate_with_o(text: str, o: str) -> tuple[float, str]:
  344. """评估文本与原始问题o的相关度
  345. Returns:
  346. tuple[float, str]: (相关度分数, 评估理由)
  347. """
  348. eval_input = f"""
  349. <原始问题>
  350. {o}
  351. </原始问题>
  352. <当前文本>
  353. {text}
  354. </当前文本>
  355. 请评估当前文本与原始问题的相关度。
  356. """
  357. result = await Runner.run(relevance_evaluator, eval_input)
  358. evaluation: RelevanceEvaluation = result.final_output
  359. return evaluation.relevance_score, evaluation.reason
  360. # ============================================================================
  361. # 核心流程函数
  362. # ============================================================================
  363. async def initialize(o: str, context: RunContext) -> tuple[list[Seg], list[Word], list[Q], list[Seed]]:
  364. """
  365. 初始化阶段
  366. Returns:
  367. (seg_list, word_list_1, q_list_1, seed_list)
  368. """
  369. print(f"\n{'='*60}")
  370. print(f"初始化阶段")
  371. print(f"{'='*60}")
  372. # 1. 分词:原始问题(o) ->分词-> seg_list
  373. print(f"\n[步骤1] 分词...")
  374. result = await Runner.run(word_segmenter, o)
  375. segmentation: WordSegmentation = result.final_output
  376. seg_list = []
  377. for word in segmentation.words:
  378. seg_list.append(Seg(text=word, from_o=o))
  379. print(f"分词结果: {[s.text for s in seg_list]}")
  380. print(f"分词理由: {segmentation.reasoning}")
  381. # 2. 分词评估:seg_list -> 每个seg与o进行评分(并发)
  382. print(f"\n[步骤2] 评估每个分词与原始问题的相关度...")
  383. async def evaluate_seg(seg: Seg) -> Seg:
  384. seg.score_with_o, seg.reason = await evaluate_with_o(seg.text, o)
  385. return seg
  386. if seg_list:
  387. eval_tasks = [evaluate_seg(seg) for seg in seg_list]
  388. await asyncio.gather(*eval_tasks)
  389. for seg in seg_list:
  390. print(f" {seg.text}: {seg.score_with_o:.2f}")
  391. # 3. 构建word_list_1: seg_list -> word_list_1
  392. print(f"\n[步骤3] 构建word_list_1...")
  393. word_list_1 = []
  394. for seg in seg_list:
  395. word_list_1.append(Word(
  396. text=seg.text,
  397. score_with_o=seg.score_with_o,
  398. from_o=o
  399. ))
  400. print(f"word_list_1: {[w.text for w in word_list_1]}")
  401. # 4. 构建q_list_1:seg_list 作为 q_list_1
  402. print(f"\n[步骤4] 构建q_list_1...")
  403. q_list_1 = []
  404. for seg in seg_list:
  405. q_list_1.append(Q(
  406. text=seg.text,
  407. score_with_o=seg.score_with_o,
  408. reason=seg.reason,
  409. from_source="seg"
  410. ))
  411. print(f"q_list_1: {[q.text for q in q_list_1]}")
  412. # 5. 构建seed_list: seg_list -> seed_list
  413. print(f"\n[步骤5] 构建seed_list...")
  414. seed_list = []
  415. for seg in seg_list:
  416. seed_list.append(Seed(
  417. text=seg.text,
  418. added_words=[],
  419. from_type="seg",
  420. score_with_o=seg.score_with_o
  421. ))
  422. print(f"seed_list: {[s.text for s in seed_list]}")
  423. return seg_list, word_list_1, q_list_1, seed_list
  424. async def run_round(
  425. round_num: int,
  426. q_list: list[Q],
  427. word_list: list[Word],
  428. seed_list: list[Seed],
  429. o: str,
  430. context: RunContext,
  431. xiaohongshu_api: XiaohongshuSearchRecommendations,
  432. xiaohongshu_search: XiaohongshuSearch,
  433. sug_threshold: float = 0.7
  434. ) -> tuple[list[Word], list[Q], list[Seed], list[Search]]:
  435. """
  436. 运行一轮
  437. Args:
  438. round_num: 轮次编号
  439. q_list: 当前轮的q列表
  440. word_list: 当前的word列表
  441. seed_list: 当前的seed列表
  442. o: 原始问题
  443. context: 运行上下文
  444. xiaohongshu_api: 建议词API
  445. xiaohongshu_search: 搜索API
  446. sug_threshold: suggestion的阈值
  447. Returns:
  448. (word_list_next, q_list_next, seed_list_next, search_list)
  449. """
  450. print(f"\n{'='*60}")
  451. print(f"第{round_num}轮")
  452. print(f"{'='*60}")
  453. round_data = {
  454. "round_num": round_num,
  455. "input_q_list": [{"text": q.text, "score": q.score_with_o} for q in q_list],
  456. "input_word_list_size": len(word_list),
  457. "input_seed_list_size": len(seed_list)
  458. }
  459. # 1. 请求sug:q_list -> 每个q请求sug接口 -> sug_list_list
  460. print(f"\n[步骤1] 为每个q请求建议词...")
  461. sug_list_list = [] # list of list
  462. for q in q_list:
  463. print(f"\n 处理q: {q.text}")
  464. suggestions = xiaohongshu_api.get_recommendations(keyword=q.text)
  465. q_sug_list = []
  466. if suggestions:
  467. print(f" 获取到 {len(suggestions)} 个建议词")
  468. for sug_text in suggestions:
  469. sug = Sug(
  470. text=sug_text,
  471. from_q=QFromQ(text=q.text, score_with_o=q.score_with_o)
  472. )
  473. q_sug_list.append(sug)
  474. else:
  475. print(f" 未获取到建议词")
  476. sug_list_list.append(q_sug_list)
  477. # 2. sug评估:sug_list_list -> 每个sug与o进评分(并发)
  478. print(f"\n[步骤2] 评估每个建议词与原始问题的相关度...")
  479. # 2.1 收集所有需要评估的sug,并记录它们所属的q
  480. all_sugs = []
  481. sug_to_q_map = {} # 记录每个sug属于哪个q
  482. for i, q_sug_list in enumerate(sug_list_list):
  483. if q_sug_list:
  484. q_text = q_list[i].text
  485. for sug in q_sug_list:
  486. all_sugs.append(sug)
  487. sug_to_q_map[id(sug)] = q_text
  488. # 2.2 并发评估所有sug
  489. async def evaluate_sug(sug: Sug) -> Sug:
  490. sug.score_with_o, sug.reason = await evaluate_with_o(sug.text, o)
  491. return sug
  492. if all_sugs:
  493. eval_tasks = [evaluate_sug(sug) for sug in all_sugs]
  494. await asyncio.gather(*eval_tasks)
  495. # 2.3 打印结果并组织到sug_details
  496. sug_details = {} # 保存每个Q对应的sug列表
  497. for i, q_sug_list in enumerate(sug_list_list):
  498. if q_sug_list:
  499. q_text = q_list[i].text
  500. print(f"\n 来自q '{q_text}' 的建议词:")
  501. sug_details[q_text] = []
  502. for sug in q_sug_list:
  503. print(f" {sug.text}: {sug.score_with_o:.2f}")
  504. # 保存到sug_details
  505. sug_details[q_text].append({
  506. "text": sug.text,
  507. "score": sug.score_with_o,
  508. "reason": sug.reason
  509. })
  510. # 3. search_list构建
  511. print(f"\n[步骤3] 构建search_list(阈值>{sug_threshold})...")
  512. search_list = []
  513. high_score_sugs = [sug for sug in all_sugs if sug.score_with_o > sug_threshold]
  514. if high_score_sugs:
  515. print(f" 找到 {len(high_score_sugs)} 个高分建议词")
  516. # 并发搜索
  517. async def search_for_sug(sug: Sug) -> Search:
  518. print(f" 搜索: {sug.text}")
  519. try:
  520. search_result = xiaohongshu_search.search(keyword=sug.text)
  521. result_str = search_result.get("result", "{}")
  522. if isinstance(result_str, str):
  523. result_data = json.loads(result_str)
  524. else:
  525. result_data = result_str
  526. notes = result_data.get("data", {}).get("data", [])
  527. post_list = []
  528. for note in notes[:10]: # 只取前10个
  529. post = process_note_data(note)
  530. post_list.append(post)
  531. print(f" → 找到 {len(post_list)} 个帖子")
  532. return Search(
  533. text=sug.text,
  534. score_with_o=sug.score_with_o,
  535. from_q=sug.from_q,
  536. post_list=post_list
  537. )
  538. except Exception as e:
  539. print(f" ✗ 搜索失败: {e}")
  540. return Search(
  541. text=sug.text,
  542. score_with_o=sug.score_with_o,
  543. from_q=sug.from_q,
  544. post_list=[]
  545. )
  546. search_tasks = [search_for_sug(sug) for sug in high_score_sugs]
  547. search_list = await asyncio.gather(*search_tasks)
  548. else:
  549. print(f" 没有高分建议词,search_list为空")
  550. # 4. 构建word_list_next: word_list -> word_list_next(先直接复制)
  551. print(f"\n[步骤4] 构建word_list_next(暂时直接复制)...")
  552. word_list_next = word_list.copy()
  553. # 5. 构建q_list_next
  554. print(f"\n[步骤5] 构建q_list_next...")
  555. q_list_next = []
  556. add_word_details = {} # 保存每个seed对应的组合词列表
  557. # 5.1 对于seed_list中的每个seed,从word_list_next中选一个未加过的词
  558. print(f"\n 5.1 为每个seed加词...")
  559. for seed in seed_list:
  560. print(f"\n 处理seed: {seed.text}")
  561. # 简单过滤:找出不在seed.text中且未被添加过的词
  562. candidate_words = []
  563. for word in word_list_next:
  564. # 检查词是否已在seed中
  565. if word.text in seed.text:
  566. continue
  567. # 检查词是否已被添加过
  568. if word.text in seed.added_words:
  569. continue
  570. candidate_words.append(word)
  571. if not candidate_words:
  572. print(f" 没有可用的候选词")
  573. continue
  574. print(f" 候选词: {[w.text for w in candidate_words]}")
  575. # 使用Agent选择最合适的词
  576. selection_input = f"""
  577. <原始问题>
  578. {o}
  579. </原始问题>
  580. <当前Seed>
  581. {seed.text}
  582. </当前Seed>
  583. <候选词列表>
  584. {', '.join([w.text for w in candidate_words])}
  585. </候选词列表>
  586. 请从候选词中选择一个最合适的词,与当前seed组合成新的query。
  587. """
  588. result = await Runner.run(word_selector, selection_input)
  589. selection: WordSelection = result.final_output
  590. # 验证选择的词是否在候选列表中
  591. if selection.selected_word not in [w.text for w in candidate_words]:
  592. print(f" ✗ Agent选择的词 '{selection.selected_word}' 不在候选列表中,跳过")
  593. continue
  594. print(f" ✓ 选择词: {selection.selected_word}")
  595. print(f" ✓ 新query: {selection.combined_query}")
  596. print(f" 理由: {selection.reasoning}")
  597. # 评估新query
  598. new_q_score, new_q_reason = await evaluate_with_o(selection.combined_query, o)
  599. print(f" 新query评分: {new_q_score:.2f}")
  600. # 创建新的q
  601. new_q = Q(
  602. text=selection.combined_query,
  603. score_with_o=new_q_score,
  604. reason=new_q_reason,
  605. from_source="add"
  606. )
  607. q_list_next.append(new_q)
  608. # 更新seed的added_words
  609. seed.added_words.append(selection.selected_word)
  610. # 保存到add_word_details
  611. if seed.text not in add_word_details:
  612. add_word_details[seed.text] = []
  613. add_word_details[seed.text].append({
  614. "text": selection.combined_query,
  615. "score": new_q_score,
  616. "reason": new_q_reason,
  617. "selected_word": selection.selected_word
  618. })
  619. # 5.2 对于sug_list_list中,每个sug大于来自的query分数,加到q_list_next
  620. print(f"\n 5.2 将高分sug加入q_list_next...")
  621. for sug in all_sugs:
  622. if sug.from_q and sug.score_with_o > sug.from_q.score_with_o:
  623. new_q = Q(
  624. text=sug.text,
  625. score_with_o=sug.score_with_o,
  626. reason=sug.reason,
  627. from_source="sug"
  628. )
  629. q_list_next.append(new_q)
  630. print(f" ✓ {sug.text} (分数: {sug.score_with_o:.2f} > {sug.from_q.score_with_o:.2f})")
  631. # 6. 更新seed_list
  632. print(f"\n[步骤6] 更新seed_list...")
  633. seed_list_next = seed_list.copy() # 保留原有的seed
  634. # 对于sug_list_list中,每个sug分数大于来源query分数的,且没在seed_list中出现过的,加入
  635. existing_seed_texts = {seed.text for seed in seed_list_next}
  636. for sug in all_sugs:
  637. # 新逻辑:sug分数 > 对应query分数
  638. if sug.from_q and sug.score_with_o > sug.from_q.score_with_o and sug.text not in existing_seed_texts:
  639. new_seed = Seed(
  640. text=sug.text,
  641. added_words=[],
  642. from_type="sug",
  643. score_with_o=sug.score_with_o
  644. )
  645. seed_list_next.append(new_seed)
  646. existing_seed_texts.add(sug.text)
  647. print(f" ✓ 新seed: {sug.text} (分数: {sug.score_with_o:.2f} > 来源query: {sug.from_q.score_with_o:.2f})")
  648. # 序列化搜索结果数据(包含帖子详情)
  649. search_results_data = []
  650. for search in search_list:
  651. search_results_data.append({
  652. "text": search.text,
  653. "score_with_o": search.score_with_o,
  654. "post_list": [
  655. {
  656. "note_id": post.note_id,
  657. "note_url": post.note_url,
  658. "title": post.title,
  659. "body_text": post.body_text,
  660. "images": post.images,
  661. "interact_info": post.interact_info
  662. }
  663. for post in search.post_list
  664. ]
  665. })
  666. # 记录本轮数据
  667. round_data.update({
  668. "sug_count": len(all_sugs),
  669. "high_score_sug_count": len(high_score_sugs),
  670. "search_count": len(search_list),
  671. "total_posts": sum(len(s.post_list) for s in search_list),
  672. "q_list_next_size": len(q_list_next),
  673. "seed_list_next_size": len(seed_list_next),
  674. "word_list_next_size": len(word_list_next),
  675. "output_q_list": [{"text": q.text, "score": q.score_with_o, "reason": q.reason, "from": q.from_source} for q in q_list_next],
  676. "seed_list_next": [{"text": seed.text, "from": seed.from_type, "score": seed.score_with_o} for seed in seed_list_next], # 下一轮种子列表
  677. "sug_details": sug_details, # 每个Q对应的sug列表
  678. "add_word_details": add_word_details, # 每个seed对应的组合词列表
  679. "search_results": search_results_data # 搜索结果(包含帖子详情)
  680. })
  681. context.rounds.append(round_data)
  682. print(f"\n本轮总结:")
  683. print(f" 建议词数量: {len(all_sugs)}")
  684. print(f" 高分建议词: {len(high_score_sugs)}")
  685. print(f" 搜索数量: {len(search_list)}")
  686. print(f" 帖子总数: {sum(len(s.post_list) for s in search_list)}")
  687. print(f" 下轮q数量: {len(q_list_next)}")
  688. print(f" seed数量: {len(seed_list_next)}")
  689. return word_list_next, q_list_next, seed_list_next, search_list
  690. async def iterative_loop(
  691. context: RunContext,
  692. max_rounds: int = 2,
  693. sug_threshold: float = 0.7
  694. ):
  695. """主迭代循环"""
  696. print(f"\n{'='*60}")
  697. print(f"开始迭代循环")
  698. print(f"最大轮数: {max_rounds}")
  699. print(f"sug阈值: {sug_threshold}")
  700. print(f"{'='*60}")
  701. # 初始化
  702. seg_list, word_list, q_list, seed_list = await initialize(context.o, context)
  703. # API实例
  704. xiaohongshu_api = XiaohongshuSearchRecommendations()
  705. xiaohongshu_search = XiaohongshuSearch()
  706. # 保存初始化数据
  707. context.rounds.append({
  708. "round_num": 0,
  709. "type": "initialization",
  710. "seg_list": [{"text": s.text, "score": s.score_with_o, "reason": s.reason} for s in seg_list],
  711. "word_list_1": [{"text": w.text, "score": w.score_with_o} for w in word_list],
  712. "q_list_1": [{"text": q.text, "score": q.score_with_o, "reason": q.reason} for q in q_list],
  713. "seed_list": [{"text": s.text, "from_type": s.from_type, "score": s.score_with_o} for s in seed_list]
  714. })
  715. # 收集所有搜索结果
  716. all_search_list = []
  717. # 迭代
  718. round_num = 1
  719. while q_list and round_num <= max_rounds:
  720. word_list, q_list, seed_list, search_list = await run_round(
  721. round_num=round_num,
  722. q_list=q_list,
  723. word_list=word_list,
  724. seed_list=seed_list,
  725. o=context.o,
  726. context=context,
  727. xiaohongshu_api=xiaohongshu_api,
  728. xiaohongshu_search=xiaohongshu_search,
  729. sug_threshold=sug_threshold
  730. )
  731. all_search_list.extend(search_list)
  732. round_num += 1
  733. print(f"\n{'='*60}")
  734. print(f"迭代完成")
  735. print(f" 总轮数: {round_num - 1}")
  736. print(f" 总搜索次数: {len(all_search_list)}")
  737. print(f" 总帖子数: {sum(len(s.post_list) for s in all_search_list)}")
  738. print(f"{'='*60}")
  739. return all_search_list
  740. # ============================================================================
  741. # 主函数
  742. # ============================================================================
  743. async def main(input_dir: str, max_rounds: int = 2, sug_threshold: float = 0.7, visualize: bool = False):
  744. """主函数"""
  745. current_time, log_url = set_trace()
  746. # 读取输入
  747. input_context_file = os.path.join(input_dir, 'context.md')
  748. input_q_file = os.path.join(input_dir, 'q.md')
  749. c = read_file_as_string(input_context_file) # 原始需求
  750. o = read_file_as_string(input_q_file) # 原始问题
  751. # 版本信息
  752. version = os.path.basename(__file__)
  753. version_name = os.path.splitext(version)[0]
  754. # 日志目录
  755. log_dir = os.path.join(input_dir, "output", version_name, current_time)
  756. # 创建运行上下文
  757. run_context = RunContext(
  758. version=version,
  759. input_files={
  760. "input_dir": input_dir,
  761. "context_file": input_context_file,
  762. "q_file": input_q_file,
  763. },
  764. c=c,
  765. o=o,
  766. log_dir=log_dir,
  767. log_url=log_url,
  768. )
  769. # 执行迭代
  770. all_search_list = await iterative_loop(
  771. run_context,
  772. max_rounds=max_rounds,
  773. sug_threshold=sug_threshold
  774. )
  775. # 格式化输出
  776. output = f"原始需求:{run_context.c}\n"
  777. output += f"原始问题:{run_context.o}\n"
  778. output += f"总搜索次数:{len(all_search_list)}\n"
  779. output += f"总帖子数:{sum(len(s.post_list) for s in all_search_list)}\n"
  780. output += "\n" + "="*60 + "\n"
  781. if all_search_list:
  782. output += "【搜索结果】\n\n"
  783. for idx, search in enumerate(all_search_list, 1):
  784. output += f"{idx}. 搜索词: {search.text} (分数: {search.score_with_o:.2f})\n"
  785. output += f" 帖子数: {len(search.post_list)}\n"
  786. if search.post_list:
  787. for post_idx, post in enumerate(search.post_list[:3], 1): # 只显示前3个
  788. output += f" {post_idx}) {post.title}\n"
  789. output += f" URL: {post.note_url}\n"
  790. output += "\n"
  791. else:
  792. output += "未找到搜索结果\n"
  793. run_context.final_output = output
  794. print(f"\n{'='*60}")
  795. print("最终结果")
  796. print(f"{'='*60}")
  797. print(output)
  798. # 保存日志
  799. os.makedirs(run_context.log_dir, exist_ok=True)
  800. context_file_path = os.path.join(run_context.log_dir, "run_context.json")
  801. context_dict = run_context.model_dump()
  802. with open(context_file_path, "w", encoding="utf-8") as f:
  803. json.dump(context_dict, f, ensure_ascii=False, indent=2)
  804. print(f"\nRunContext saved to: {context_file_path}")
  805. # 保存详细的搜索结果
  806. search_results_path = os.path.join(run_context.log_dir, "search_results.json")
  807. search_results_data = [s.model_dump() for s in all_search_list]
  808. with open(search_results_path, "w", encoding="utf-8") as f:
  809. json.dump(search_results_data, f, ensure_ascii=False, indent=2)
  810. print(f"Search results saved to: {search_results_path}")
  811. # 可视化
  812. if visualize:
  813. import subprocess
  814. output_html = os.path.join(run_context.log_dir, "visualization.html")
  815. print(f"\n🎨 生成可视化HTML...")
  816. # 获取绝对路径
  817. abs_context_file = os.path.abspath(context_file_path)
  818. abs_output_html = os.path.abspath(output_html)
  819. # 运行可视化脚本
  820. result = subprocess.run([
  821. "node",
  822. "visualization/sug_v6_1_2_8/index.js",
  823. abs_context_file,
  824. abs_output_html
  825. ])
  826. if result.returncode == 0:
  827. print(f"✅ 可视化已生成: {output_html}")
  828. else:
  829. print(f"❌ 可视化生成失败")
  830. if __name__ == "__main__":
  831. parser = argparse.ArgumentParser(description="搜索query优化工具 - v6.1.2.8 轮次迭代版")
  832. parser.add_argument(
  833. "--input-dir",
  834. type=str,
  835. default="input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?",
  836. help="输入目录路径,默认: input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?"
  837. )
  838. parser.add_argument(
  839. "--max-rounds",
  840. type=int,
  841. default=4,
  842. help="最大轮数,默认: 2"
  843. )
  844. parser.add_argument(
  845. "--sug-threshold",
  846. type=float,
  847. default=0.7,
  848. help="suggestion阈值,默认: 0.7"
  849. )
  850. parser.add_argument(
  851. "--visualize",
  852. action="store_true",
  853. default=True,
  854. help="运行完成后自动生成可视化HTML"
  855. )
  856. args = parser.parse_args()
  857. asyncio.run(main(args.input_dir, max_rounds=args.max_rounds, sug_threshold=args.sug_threshold, visualize=args.visualize))