sug_v6_1_2_121.py 88 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367
  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, ModelSettings
  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. # 得分提升阈值:sug或组合词必须比来源query提升至少此幅度才能进入下一轮
  15. REQUIRED_SCORE_GAIN = 0.05
  16. from script.search_recommendations.xiaohongshu_search_recommendations import XiaohongshuSearchRecommendations
  17. from script.search.xiaohongshu_search import XiaohongshuSearch
  18. # ============================================================================
  19. # 日志工具类
  20. # ============================================================================
  21. class TeeLogger:
  22. """同时输出到控制台和日志文件的工具类"""
  23. def __init__(self, stdout, log_file):
  24. self.stdout = stdout
  25. self.log_file = log_file
  26. def write(self, message):
  27. self.stdout.write(message)
  28. self.log_file.write(message)
  29. self.log_file.flush() # 实时写入,避免丢失日志
  30. def flush(self):
  31. self.stdout.flush()
  32. self.log_file.flush()
  33. # ============================================================================
  34. # 数据模型
  35. # ============================================================================
  36. class Seg(BaseModel):
  37. """分词(旧版)- v120使用"""
  38. text: str
  39. score_with_o: float = 0.0 # 与原始问题的评分
  40. reason: str = "" # 评分理由
  41. from_o: str = "" # 原始问题
  42. # ============================================================================
  43. # 新架构数据模型 (v121)
  44. # ============================================================================
  45. class Segment(BaseModel):
  46. """语义片段(Round 0语义分段结果)"""
  47. text: str # 片段文本
  48. type: str # 语义类型: 疑问标记/核心动作/修饰短语/中心名词/逻辑连接
  49. score_with_o: float = 0.0 # 与原始问题的评分
  50. reason: str = "" # 评分理由
  51. from_o: str = "" # 原始问题
  52. words: list[str] = Field(default_factory=list) # 该片段拆分出的词列表(Round 0拆词结果)
  53. word_scores: dict[str, float] = Field(default_factory=dict) # 词的评分 {word: score}
  54. word_reasons: dict[str, str] = Field(default_factory=dict) # 词的评分理由 {word: reason}
  55. class DomainCombination(BaseModel):
  56. """域组合(Round N的N域组合结果)"""
  57. text: str # 组合后的文本
  58. domains: list[int] = Field(default_factory=list) # 参与组合的域索引列表(对应segments的索引)
  59. type_label: str = "" # 类型标签,如 [疑问标记+核心动作+中心名词]
  60. source_words: list[list[str]] = Field(default_factory=list) # 来源词列表,每个元素是一个域的词列表,如 [["猫咪"], ["梗图"]]
  61. score_with_o: float = 0.0 # 与原始问题的评分
  62. reason: str = "" # 评分理由
  63. from_segments: list[str] = Field(default_factory=list) # 来源segment的文本列表
  64. # ============================================================================
  65. # 旧架构数据模型(保留但不使用)
  66. # ============================================================================
  67. # class Word(BaseModel):
  68. # """词(旧版)- v120使用,v121不再使用"""
  69. # text: str
  70. # score_with_o: float = 0.0 # 与原始问题的评分
  71. # from_o: str = "" # 原始问题
  72. class Word(BaseModel):
  73. """词"""
  74. text: str
  75. score_with_o: float = 0.0 # 与原始问题的评分
  76. from_o: str = "" # 原始问题
  77. class QFromQ(BaseModel):
  78. """Q来源信息(用于Sug中记录)"""
  79. text: str
  80. score_with_o: float = 0.0
  81. class Q(BaseModel):
  82. """查询"""
  83. text: str
  84. score_with_o: float = 0.0 # 与原始问题的评分
  85. reason: str = "" # 评分理由
  86. from_source: str = "" # v120: seg/sug/add; v121新增: segment/domain_comb/sug
  87. type_label: str = "" # v121新增:域类型标签(仅用于domain_comb来源)
  88. domain_index: int = -1 # v121新增:域索引(word来源时有效,-1表示无域)
  89. domain_type: str = "" # v121新增:域类型(word来源时表示所属segment的type,如"中心名词")
  90. class Sug(BaseModel):
  91. """建议词"""
  92. text: str
  93. score_with_o: float = 0.0 # 与原始问题的评分
  94. reason: str = "" # 评分理由
  95. from_q: QFromQ | None = None # 来自的q
  96. class Seed(BaseModel):
  97. """种子(旧版)- v120使用,v121不再使用"""
  98. text: str
  99. added_words: list[str] = Field(default_factory=list) # 已经增加的words
  100. from_type: str = "" # seg/sug/add
  101. score_with_o: float = 0.0 # 与原始问题的评分
  102. class Post(BaseModel):
  103. """帖子"""
  104. title: str = ""
  105. body_text: str = ""
  106. type: str = "normal" # video/normal
  107. images: list[str] = Field(default_factory=list) # 图片url列表,第一张为封面
  108. video: str = "" # 视频url
  109. interact_info: dict = Field(default_factory=dict) # 互动信息
  110. note_id: str = ""
  111. note_url: str = ""
  112. class Search(Sug):
  113. """搜索结果(继承Sug)"""
  114. post_list: list[Post] = Field(default_factory=list) # 搜索得到的帖子列表
  115. class RunContext(BaseModel):
  116. """运行上下文"""
  117. version: str
  118. input_files: dict[str, str]
  119. c: str # 原始需求
  120. o: str # 原始问题
  121. log_url: str
  122. log_dir: str
  123. # v121新增:语义分段结果
  124. segments: list[dict] = Field(default_factory=list) # Round 0的语义分段结果
  125. # 每轮的数据
  126. rounds: list[dict] = Field(default_factory=list) # 每轮的详细数据
  127. # 最终结果
  128. final_output: str | None = None
  129. # 评估缓存:避免重复评估相同文本
  130. evaluation_cache: dict[str, tuple[float, str]] = Field(default_factory=dict)
  131. # key: 文本, value: (score, reason)
  132. # ============================================================================
  133. # Agent 定义
  134. # ============================================================================
  135. # ============================================================================
  136. # v121 新增 Agent
  137. # ============================================================================
  138. # Agent: 语义分段专家 (Prompt1)
  139. class SemanticSegment(BaseModel):
  140. """单个语义片段"""
  141. segment_text: str = Field(..., description="片段文本")
  142. segment_type: str = Field(..., description="语义类型(疑问标记/核心动作/修饰短语/中心名词/逻辑连接)")
  143. reasoning: str = Field(..., description="分段理由")
  144. class SemanticSegmentation(BaseModel):
  145. """语义分段结果"""
  146. segments: list[SemanticSegment] = Field(..., description="语义片段列表")
  147. overall_reasoning: str = Field(..., description="整体分段思路")
  148. semantic_segmentation_instructions = """
  149. 你是语义分段专家。给定一个搜索query,将其拆分成不同语义类型的片段。
  150. ## 语义类型定义
  151. 1. 疑问引导:引导查询意图的元素,如疑问词(原理:表示意图类型,如过程求解或信息查询)。
  152. 2. 核心动作:核心动作或关系谓词,如动词(原理:谓词是语义框架的核心,定义动作或状态)。
  153. 3. 目标对象:动作的目标或实体中心对象,如名词短语(承载谓词的作用对象助词)。
  154. 4. 修饰限定:对目标对象的修饰和限定、对核心动作的限定。
  155. ## 分段原则:严格遵守以下规则
  156. 1. **语义完整性**:每个片段应该是一个完整的语义单元
  157. 2. **类型互斥**:每个片段只能属于一种类型
  158. 3. **保留原文**:片段文本必须保留原query中的字符,不得改写
  159. 4. **顺序保持**:片段顺序应与原query一致
  160. 5. **修饰限定合并规则**
  161. - 定义:在同一个"目标对象"之前的所有"修饰限定"片段,如果它们之间没有插入"疑问引导"、"核心动作"或"目标对象",就必须合并为一个片段
  162. - 判断标准:
  163. * 步骤1:找到"目标对象"在哪里
  164. * 步骤2:向前查看,把所有修饰和限定这个目标对象的词都合并,修辞和限定词包括数量词、地域词、时间词、描述词、程度词、方式词、助词等
  165. ## 输出要求
  166. - segments: 片段列表
  167. - segment_text: 片段文本(必须来自原query)
  168. - segment_type: 语义类型
  169. - reasoning: 为什么这样分段
  170. - overall_reasoning: 整体分段思路
  171. ## JSON输出规范
  172. 1. **格式要求**:必须输出标准JSON格式
  173. 2. **引号规范**:字符串中如需表达引用,使用书名号《》或「」,不要使用英文引号或中文引号""
  174. """.strip()
  175. semantic_segmenter = Agent[None](
  176. name="语义分段专家",
  177. instructions=semantic_segmentation_instructions,
  178. model=get_model(MODEL_NAME),
  179. output_type=SemanticSegmentation,
  180. )
  181. # ============================================================================
  182. # v120 保留 Agent
  183. # ============================================================================
  184. # Agent 1: 分词专家(v121用于Round 0拆词)
  185. class WordSegmentation(BaseModel):
  186. """分词结果"""
  187. words: list[str] = Field(..., description="分词结果列表")
  188. reasoning: str = Field(..., description="分词理由")
  189. word_segmentation_instructions = """
  190. 你是分词专家。给定一个query,将其拆分成有意义的最小单元。
  191. ## 分词原则
  192. 1. 保留有搜索意义的词汇
  193. 2. 拆分成独立的概念
  194. 3. 保留专业术语的完整性
  195. 4. 去除虚词(的、吗、呢等),但保留疑问词(如何、为什么、怎样等)
  196. ## 输出要求
  197. 返回分词列表和分词理由。
  198. """.strip()
  199. word_segmenter = Agent[None](
  200. name="分词专家",
  201. instructions=word_segmentation_instructions,
  202. model=get_model(MODEL_NAME),
  203. output_type=WordSegmentation,
  204. )
  205. # Agent 2: 动机维度评估专家 + 品类维度评估专家(两阶段评估)
  206. # 动机评估的嵌套模型
  207. class CoreMotivationExtraction(BaseModel):
  208. """核心动机提取"""
  209. 简要说明核心动机: str = Field(..., description="核心动机说明")
  210. class MotivationEvaluation(BaseModel):
  211. """动机维度评估"""
  212. 原始问题核心动机提取: CoreMotivationExtraction = Field(..., description="原始问题核心动机提取")
  213. 动机维度得分: float = Field(..., description="动机维度得分 -1~1")
  214. 简要说明动机维度相关度理由: str = Field(..., description="动机维度相关度理由")
  215. class CategoryEvaluation(BaseModel):
  216. """品类维度评估"""
  217. 品类维度得分: float = Field(..., description="品类维度得分 -1~1")
  218. 简要说明品类维度相关度理由: str = Field(..., description="品类维度相关度理由")
  219. # 动机评估 prompt - 第一轮版本(来自 sug_v6_1_2_115.py)
  220. motivation_evaluation_instructions_round1 = """
  221. #角色
  222. 你是一个 **专业的语言专家和语义相关性评判专家**。你的任务是:判断我给你的 <平台sug词条> 与 <原始问题> 的需求动机匹配度,给出 **-1 到 1 之间** 的数值评分。
  223. ---
  224. # 核心概念与方法论
  225. ## 评估维度
  226. 本评估系统围绕 **动机维度** 进行:
  227. ### 1. 动机维度
  228. **定义:** 用户"想要做什么",即原始问题的行为意图和目的
  229. - 核心是 **动词**:获取、学习、拍摄、制作、寻找等
  230. - 包括:核心动作 + 使用场景 + 最终目的
  231. ---
  232. ## 如何识别原始问题的核心动机
  233. **核心动机必须是动词**,识别方法如下:
  234. ### 方法1: 显性动词直接提取
  235. 当原始问题明确包含动词时,直接提取
  236. 示例:
  237. "如何获取素材" → 核心动机 = "获取"
  238. "寻找拍摄技巧" → 核心动机 = "寻找"(或"学习")
  239. "制作视频教程" → 核心动机 = "制作"
  240. ### 方法2: 隐性动词语义推理
  241. 当原始问题没有显性动词时,需要结合上下文推理
  242. 示例:
  243. 例: "川西秋天风光摄影" → 隐含动作="拍摄"
  244. → 需结合上下文判断
  245. 如果原始问题是纯名词短语,无任何动作线索:
  246. → 核心动机 = 无法识别
  247. → 在此情况下,动机维度得分应为 0。
  248. 示例:
  249. "摄影" → 无法识别动机,动机维度得分 = 0
  250. "川西风光" → 无法识别动机,动机维度得分 = 0
  251. ---
  252. # 输入信息
  253. 你将接收到以下输入:
  254. - **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
  255. - **<平台sug词条>**:平台推荐的词条列表,每个词条需要单独评估。
  256. #判定流程
  257. #评估架构
  258. 输入: <原始问题> + <平台sug词条>
  259. 【动机维度相关性判定】
  260. ├→ 步骤1: 评估<sug词条>与<原始问题>的需求动机匹配度
  261. └→ 输出: -1到1之间的数值 + 判定依据
  262. 相关度评估维度详解
  263. 维度1: 动机维度评估
  264. 评估对象: <平台sug词条> 与 <原始问题> 的需求动机匹配度
  265. 说明: 核心动作是用户需求的第一优先级,决定了推荐的基本有效性
  266. 评分标准:
  267. 【正向匹配】
  268. +0.95~1.0: 核心动作完全一致
  269. - 例: 原始问题"如何获取素材" vs sug词"素材获取方法"
  270. - 特殊规则: 如果sug词的核心动作是原始问题动作的**具体化子集**,也判定为完全一致
  271. · 例: 原始问题"扣除猫咪主体的方法" vs sug词"扣除猫咪眼睛的方法"(子集但目的一致)
  272. +0.75~0.95: 核心动作语义相近或为同义表达
  273. - 例: 原始问题"如何获取素材" vs sug词"如何下载素材"
  274. - 同义词对: 获取≈下载≈寻找, 技巧≈方法≈教程≈攻略
  275. +0.5~0.75: 核心动作相关但非直接对应(相关实现路径)
  276. - 例: 原始问题"如何获取素材" vs sug词"素材管理整理"
  277. +0.2~0.45: 核心动作弱相关(同领域不同动作)
  278. - 例: 原始问题"如何拍摄风光" vs sug词"风光摄影欣赏"
  279. 【中性/无关】
  280. 0: 没有明确目的,动作意图无明确关联
  281. - 例: 原始问题"如何获取素材" vs sug词"摄影器材推荐"
  282. - 例: 原始问题无法识别动机 且 sug词也无明确动作 → 0
  283. - 如果原始问题无法识别动机,则动机维度得分为0。
  284. 【负向偏离】
  285. -0.2~-0.05: 动作意图轻度冲突或误导
  286. - 例: 原始问题"如何获取素材" vs sug词"素材版权保护须知"
  287. -0.5~-0.25: 动作意图明显对立
  288. - 例: 原始问题"如何获取免费素材" vs sug词"如何售卖素材"
  289. -1.0~-0.55: 动作意图完全相反或产生严重负面引导
  290. - 例: 原始问题"免费素材获取" vs sug词"付费素材强制推销"
  291. ---
  292. # 输出要求
  293. 输出结果必须为一个 **JSON 格式**,包含以下内容:
  294. ```json
  295. {
  296. "原始问题核心动机提取": {
  297. "简要说明核心动机": ""
  298. },
  299. "动机维度得分": "-1到1之间的小数",
  300. "简要说明动机维度相关度理由": "评估该sug词条与原始问题动机匹配程度的理由"
  301. }
  302. **输出约束(非常重要)**:
  303. 1. **字符串长度限制**:\"简要说明动机维度相关度理由\"字段必须控制在**150字以内**
  304. 2. **JSON格式规范**:必须生成完整的JSON格式,确保字符串用双引号包裹且正确闭合
  305. 3. **引号使用**:字符串中如需表达引用,请使用《》或「」代替单引号或双引号
  306. #注意事项:
  307. 始终围绕动机维度:所有评估都基于"动机"维度,不偏离
  308. 核心动机必须是动词:在评估前,必须先提取原始问题的核心动机(动词),这是整个评估的基础
  309. 严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
  310. 负分使用原则:仅当sug词条对原始问题动机产生误导、冲突或有害引导时给予负分
  311. 零分使用原则:当sug词条与原始问题动机无明确关联,既不相关也不冲突时给予零分,或原始问题无法识别动机时。
  312. """.strip()
  313. # 动机评估 prompt - 后续轮次版本(当前 116 版本)
  314. motivation_evaluation_instructions = """
  315. #角色
  316. 你是一个 **专业的语言专家和语义相关性评判专家**。你的任务是:判断我给你的 <平台sug词条> 与 <原始问题> 的需求动机匹配度,给出 **-1 到 1 之间** 的数值评分。
  317. ---
  318. # 动机评估核心原则(必读)
  319. ### 动机 = 动作 + 对象 + 场景
  320. 评估时必须同时考虑三要素,不能只看动词:
  321. - **动作**:制定、规划、获取、拍摄等
  322. - **对象**:旅行行程 vs 每日计划、风光照片 vs 证件照
  323. - **场景**:旅游 vs 日常、摄影 vs 办公
  324. ### 关键判断:动词相同 ≠ 动机匹配
  325. 错误:只看动词相同就给高分
  326. - "制定旅行行程" vs "制定每日计划" → 给0.95 错误
  327. - "拍摄风光" vs "拍摄证件照" → 给0.95 错误
  328. 正确:检查对象和场景是否匹配
  329. - 对象不同领域 → 降至0.3左右
  330. - 场景不同 → 降至0.3左右
  331. # 核心概念与方法论
  332. ## 评估维度
  333. 本评估系统围绕 **动机维度** 进行:
  334. # 维度独立性警告
  335. 【严格约束】本评估**只评估动机维度**:
  336. **禁止使用"主题相关"作为评分依据**:评分理由中不得出现"主题"、"内容"、"话题"等词
  337. ### 1. 动机维度
  338. **定义:** 用户"想要做什么",即原始问题的行为意图和目的
  339. - 核心是 **动词**:获取、学习、拍摄、制作、寻找等
  340. - 包括:核心动作 + 使用场景 + 最终目的
  341. ---
  342. 如果原始问题是纯名词短语,无任何动作线索:
  343. → 核心动机 = 无法识别
  344. → 在此情况下,动机维度得分应为 0。
  345. 示例:
  346. "摄影" → 无法识别动机,动机维度得分 = 0
  347. "川西风光" → 无法识别动机,动机维度得分 = 0
  348. ---
  349. # 输入信息
  350. 你将接收到以下输入:
  351. - **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
  352. - **<平台sug词条>**:平台推荐的词条列表,每个词条需要单独评估。
  353. #判定流程
  354. #评估架构
  355. 输入: <原始问题> + <平台sug词条>
  356. 【动机维度相关性判定】
  357. ├→ 步骤1: 评估<sug词条>与<原始问题>的需求动机匹配度
  358. └→ 输出: -1到1之间的数值 + 判定依据
  359. 相关度评估维度详解
  360. 维度1: 动机维度评估
  361. 评估对象: <平台sug词条> 与 <原始问题> 的需求动机匹配度
  362. 说明: 核心动作是用户需求的第一优先级,决定了推荐的基本有效性
  363. 评分标准:
  364. 【正向匹配】
  365. +0.95~1.0: 动作+对象+场景完全一致
  366. - 要求:动词、对象、场景都必须匹配,不能只看动词
  367. - "制定旅行行程" vs "制定每日计划"
  368. 虽然动词相同,但对象和场景完全不同,不属于高分
  369. - 特殊规则: 如果sug词的核心动作是原始问题动作在动作+对象+场景一致下的**具体化子集**,也判定为完全一致
  370. +0.75~0.95: 核心动作语义相近或为同义表达
  371. - 例: 原始问题"如何获取素材" vs sug词"如何下载素材"
  372. - 同义词对: 获取≈下载≈寻找, 技巧≈方法≈教程≈攻略
  373. +0.5~0.75: 核心动作相关但非直接对应(相关实现路径)
  374. - 例: 原始问题"如何获取素材" vs sug词"素材管理整理"
  375. +0.25~0.4: 动词相同但对象或场景明显不同(弱相关)
  376. - 判断要点:动词一致,但对象不同领域或场景不同
  377. - 关键:不要因为动词相同就给0.95,必须检查对象!
  378. 【中性/无关】
  379. 0: 没有明确目的,动作意图无明确关联
  380. - 例: 原始问题"如何获取素材" vs sug词"摄影器材推荐"
  381. - 例: 原始问题无法识别动机 且 sug词也无明确动作 → 0
  382. - 如果原始问题无法识别动机,则动机维度得分为0
  383. 特别注意 - 禁止的错误理由:
  384. - 禁止: "虽然没有动作,但主题相关,所以给0.2"
  385. - 禁止:"内容有参考价值,所以给0.15"
  386. - 禁止: "都提到了XX(名词),所以不是完全无关"
  387. - 正确理由:"sug词条无动作意图,与原始问题的'XX'动机完全无关"
  388. 【负向偏离】
  389. -0.2~-0.05: 动作意图轻度冲突或误导
  390. - 例: 原始问题"如何获取素材" vs sug词"素材版权保护须知"
  391. -0.5~-0.25: 动作意图明显对立
  392. - 例: 原始问题"如何获取免费素材" vs sug词"如何售卖素材"
  393. -1.0~-0.55: 动作意图完全相反或产生严重负面引导
  394. - 例: 原始问题"免费素材获取" vs sug词"付费素材强制推销"
  395. ---
  396. # 输出要求
  397. 输出结果必须为一个 **JSON 格式**,包含以下内容:
  398. ```json
  399. {
  400. "原始问题核心动机提取": {
  401. "简要说明核心动机": ""
  402. },
  403. "动机维度得分": "-1到1之间的小数",
  404. "简要说明动机维度相关度理由": "评估该sug词条与原始问题动机匹配程度的理由"
  405. }
  406. **输出约束(非常重要)**:
  407. 1. **字符串长度限制**:\"简要说明动机维度相关度理由\"字段必须控制在**150字以内**
  408. 2. **JSON格式规范**:必须生成完整的JSON格式,确保字符串用双引号包裹且正确闭合
  409. 3. **引号使用**:字符串中如需表达引用,请使用《》或「」代替单引号或双引号
  410. #注意事项:
  411. 始终围绕动机维度:所有评估都基于"动机"维度,不偏离
  412. 核心动机必须是动词:在评估前,必须先提取原始问题的核心动机(动词),这是整个评估的基础
  413. 严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
  414. 负分使用原则:仅当sug词条对原始问题动机产生误导、冲突或有害引导时给予负分
  415. 零分使用原则:当sug词条与原始问题动机无明确关联,既不相关也不冲突时给予零分,或原始问题无法识别动机时。
  416. """.strip()
  417. # 品类评估 prompt
  418. category_evaluation_instructions = """
  419. #角色
  420. 你是一个 **专业的语言专家和语义相关性评判专家**。你的任务是:判断我给你的 <平台sug词条> 与 <原始问题> 的内容主体和限定词匹配度,给出 **-1 到 1 之间** 的数值评分。
  421. ---
  422. # 核心概念与方法论
  423. ## 评估维度
  424. 本评估系统围绕 **品类维度** 进行:
  425. # 维度独立性警告
  426. 【严格约束】本评估**只评估品类维度**,,必须遵守以下规则:
  427. 1. **只看名词和限定词**:评估时只考虑主体、限定词的匹配度
  428. 2. **完全忽略动词**:动作意图、目的等动机信息对本维度评分无影响
  429. ### 品类维度
  430. **定义:** 用户"关于什么内容",即原始问题的主题对象和限定词
  431. - 核心是 **名词+限定词**:川西秋季风光摄影素材
  432. - 包括:核心主体 + 地域限定 + 时间限定 + 质量限定等
  433. ## ⚠️ 品类评估核心原则(必读)
  434. ### 原则1:只看词条表面,禁止联想推演
  435. - 只能基于sug词实际包含的词汇评分
  436. - 禁止推测"可能包含"、"可以理解为"
  437. **错误示例:**
  438. 原始问题:"川西旅行行程" vs sug词:"每日计划"
  439. - 错误 "每日计划可以包含旅行规划,所以有关联" → 这是不允许的联想
  440. - 正确: "sug词只有'每日计划',无'旅行'字眼,品类不匹配" → 正确判断
  441. ### 原则2:通用概念 ≠ 特定概念
  442. - **通用**:计划、方法、技巧、素材(无领域限定)
  443. - **特定**:旅行行程、摄影技巧、烘焙方法(有明确领域)
  444. IF sug词是通用 且 原始问题是特定:
  445. → 品类不匹配 → 评分0.05~0.1
  446. 关键:通用概念不等于特定概念,不能因为"抽象上都是规划"就给分
  447. ---
  448. # 输入信息
  449. 你将接收到以下输入:
  450. - **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
  451. - **<平台sug词条>**:平台推荐的词条列表,每个词条需要单独评估。
  452. #判定流程
  453. #评估架构
  454. 输入: <原始问题> + <平台sug词条>
  455. 【品类维度相关性判定】
  456. ├→ 步骤1: 评估<sug词条>与<原始问题>的内容主体和限定词匹配度
  457. └→ 输出: -1到1之间的数值 + 判定依据
  458. 相关度评估维度详解
  459. 维度2: 品类维度评估
  460. 评估对象: <平台sug词条> 与 <原始问题> 的内容主体和限定词匹配度
  461. 评分标准:
  462. 【正向匹配】
  463. +0.95~1.0: 核心主体+所有关键限定词完全匹配
  464. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西秋季风光摄影作品"
  465. +0.75~0.95: 核心主体匹配,存在限定词匹配
  466. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西风光摄影素材"(缺失"秋季")
  467. +0.5~0.75: 核心主体匹配,无限定词匹配或合理泛化
  468. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"四川风光摄影"
  469. +0.3~0.5: 核心主体匹配,但限定词缺失或存在语义错位
  470. - 特别注意"语义身份"差异,主体词出现但上下文语义不同
  471. - 例:
  472. · "猫咪的XX行为"(猫咪是行为者)
  473. · vs "用猫咪表达XX的梗图"(猫咪是媒介)
  474. · 虽都含"猫咪+XX",但语义角色不同
  475. +0.2~0.3: 主体词不匹配,限定词缺失或错位
  476. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"风光摄影入门"
  477. +0.05~0.2: 主体词过度泛化或仅抽象相似
  478. - 例: sug词是通用概念,原始问题是特定概念
  479. sug词"每日计划"(通用)vs 原始问题 "川西旅行行程"(特定)
  480. → 评分:0.08
  481. 【中性/无关】
  482. 0: 类别明显不同,没有明确目的,无明确关联
  483. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"人像摄影素材"
  484. - 例: 原始问题无法识别动机 且 sug词也无明确动作 → 0
  485. 【负向偏离】
  486. -0.2~-0.05: 主体词或限定词存在误导性
  487. - 例: 原始问题"免费摄影素材" vs sug词"付费摄影素材库"
  488. -0.5~-0.25: 主体词明显错位或品类冲突
  489. - 例: 原始问题"风光摄影素材" vs sug词"人像修图教程"
  490. -1.0~-0.55: 完全错误的品类或有害引导
  491. - 例: 原始问题"正版素材获取" vs sug词"盗版素材下载"
  492. ---
  493. # 输出要求
  494. 输出结果必须为一个 **JSON 格式**,包含以下内容:
  495. ```json
  496. {
  497. "品类维度得分": "-1到1之间的小数",
  498. "简要说明品类维度相关度理由": "评估该sug词条与原始问题品类匹配程度的理由"
  499. }
  500. ---
  501. **输出约束(非常重要)**:
  502. 1. **字符串长度限制**:\"简要说明品类维度相关度理由\"字段必须控制在**150字以内**
  503. 2. **JSON格式规范**:必须生成完整的JSON格式,确保字符串用双引号包裹且正确闭合
  504. 3. **引号使用**:字符串中如需表达引用,请使用《》或「」代替单引号或双引号
  505. ---
  506. #注意事项:
  507. 始终围绕品类维度:所有评估都基于"品类"维度,不偏离
  508. 严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
  509. 负分使用原则:仅当sug词条对原始问题品类产生误导、冲突或有害引导时给予负分
  510. 零分使用原则:当sug词条与原始问题品类无明确关联,既不相关也不冲突时给予零分
  511. """.strip()
  512. # 创建评估 Agent
  513. motivation_evaluator = Agent[None](
  514. name="动机维度评估专家(后续轮次)",
  515. instructions=motivation_evaluation_instructions,
  516. model=get_model(MODEL_NAME),
  517. output_type=MotivationEvaluation)
  518. category_evaluator = Agent[None](
  519. name="品类维度评估专家",
  520. instructions=category_evaluation_instructions,
  521. model=get_model(MODEL_NAME),
  522. output_type=CategoryEvaluation
  523. )
  524. # ============================================================================
  525. # v120 保留但不使用的 Agent(v121不再使用)
  526. # ============================================================================
  527. # # Agent 3: 加词选择专家(旧版 - v120使用,v121不再使用)
  528. # class WordCombination(BaseModel):
  529. # """单个词组合"""
  530. # selected_word: str = Field(..., description="选择的词")
  531. # combined_query: str = Field(..., description="组合后的新query")
  532. # reasoning: str = Field(..., description="选择理由")
  533. # class WordSelectionTop5(BaseModel):
  534. # """加词选择结果(Top 5)"""
  535. # combinations: list[WordCombination] = Field(
  536. # ...,
  537. # description="选择的Top 5组合(不足5个则返回所有)",
  538. # min_items=1,
  539. # max_items=5
  540. # )
  541. # overall_reasoning: str = Field(..., description="整体选择思路")
  542. # word_selection_instructions 已删除 (v121不再使用)
  543. # word_selector = Agent[None](
  544. # name="加词组合专家",
  545. # instructions=word_selection_instructions,
  546. # model=get_model(MODEL_NAME),
  547. # output_type=WordSelectionTop5,
  548. # model_settings=ModelSettings(temperature=0.2),
  549. # )
  550. # ============================================================================
  551. # 辅助函数
  552. # ============================================================================
  553. # ============================================================================
  554. # v121 新增辅助函数
  555. # ============================================================================
  556. def get_ordered_subsets(words: list[str], min_len: int = 1) -> list[list[str]]:
  557. """
  558. 生成words的所有有序子集(可跳过但不可重排)
  559. 使用 itertools.combinations 生成索引组合,保持原始顺序
  560. Args:
  561. words: 词列表
  562. min_len: 子集最小长度
  563. Returns:
  564. 所有可能的有序子集列表
  565. Example:
  566. words = ["川西", "秋季", "风光"]
  567. 结果:
  568. - 长度1: ["川西"], ["秋季"], ["风光"]
  569. - 长度2: ["川西", "秋季"], ["川西", "风光"], ["秋季", "风光"]
  570. - 长度3: ["川西", "秋季", "风光"]
  571. 共 C(3,1) + C(3,2) + C(3,3) = 3 + 3 + 1 = 7种
  572. """
  573. from itertools import combinations
  574. subsets = []
  575. n = len(words)
  576. # 遍历所有可能的长度(从min_len到n)
  577. for r in range(min_len, n + 1):
  578. # 生成长度为r的所有索引组合
  579. for indices in combinations(range(n), r):
  580. # 按照原始顺序提取词
  581. subset = [words[i] for i in indices]
  582. subsets.append(subset)
  583. return subsets
  584. def generate_domain_combinations(segments: list[Segment], n_domains: int) -> list[DomainCombination]:
  585. """
  586. 生成N域组合
  587. 步骤:
  588. 1. 从len(segments)个域中选择n_domains个域(组合,保持顺序)
  589. 2. 对每个选中的域,生成其words的所有有序子集
  590. 3. 计算笛卡尔积,生成所有可能的组合
  591. Args:
  592. segments: 语义片段列表
  593. n_domains: 参与组合的域数量
  594. Returns:
  595. 所有可能的N域组合列表
  596. Example:
  597. 有4个域: [疑问标记, 核心动作, 修饰短语, 中心名词]
  598. n_domains=2时,选择域的方式: C(4,2) = 6种
  599. 假设选中[核心动作, 中心名词]:
  600. - 核心动作的words: ["获取"], 子集: ["获取"]
  601. - 中心名词的words: ["风光", "摄影", "素材"], 子集: 7种
  602. 则该域选择下的组合数: 1 * 7 = 7种
  603. """
  604. from itertools import combinations, product
  605. all_combinations = []
  606. n = len(segments)
  607. # 检查参数有效性
  608. if n_domains > n or n_domains < 1:
  609. return []
  610. # 1. 选择n_domains个域(保持原始顺序)
  611. for domain_indices in combinations(range(n), n_domains):
  612. selected_segments = [segments[i] for i in domain_indices]
  613. # 新增:如果所有域都只有1个词,跳过(单段落单词不组合)
  614. if all(len(seg.words) == 1 for seg in selected_segments):
  615. continue
  616. # 2. 为每个选中的域生成其words的所有有序子集
  617. domain_subsets = []
  618. for seg in selected_segments:
  619. if len(seg.words) == 0:
  620. # 如果某个域没有词,跳过该域组合
  621. domain_subsets = []
  622. break
  623. subsets = get_ordered_subsets(seg.words, min_len=1)
  624. domain_subsets.append(subsets)
  625. # 如果某个域没有词,跳过
  626. if len(domain_subsets) != n_domains:
  627. continue
  628. # 3. 计算笛卡尔积
  629. for word_combination in product(*domain_subsets):
  630. # word_combination 是一个tuple,每个元素是一个词列表
  631. # 例如: (["获取"], ["风光", "摄影"])
  632. # 计算总词数
  633. total_words = sum(len(words) for words in word_combination)
  634. # 如果总词数<=1,跳过(组词必须大于1个词)
  635. if total_words <= 1:
  636. continue
  637. # 将所有词连接成一个字符串
  638. combined_text = "".join(["".join(words) for words in word_combination])
  639. # 生成类型标签
  640. type_labels = [selected_segments[i].type for i in range(n_domains)]
  641. type_label = "[" + "+".join(type_labels) + "]"
  642. # 创建DomainCombination对象
  643. comb = DomainCombination(
  644. text=combined_text,
  645. domains=list(domain_indices),
  646. type_label=type_label,
  647. source_words=[list(words) for words in word_combination], # 保存来源词
  648. from_segments=[seg.text for seg in selected_segments]
  649. )
  650. all_combinations.append(comb)
  651. return all_combinations
  652. def extract_words_from_segments(segments: list[Segment]) -> list[Q]:
  653. """
  654. 从 segments 中提取所有 words,转换为 Q 对象列表
  655. 用于 Round 1 的输入:将 Round 0 的 words 转换为可用于请求SUG的 query 列表
  656. Args:
  657. segments: Round 0 的语义片段列表
  658. Returns:
  659. list[Q]: word 列表,每个 word 作为一个 Q 对象
  660. """
  661. q_list = []
  662. for seg_idx, segment in enumerate(segments):
  663. for word in segment.words:
  664. # 从 segment.word_scores 获取该 word 的评分
  665. word_score = segment.word_scores.get(word, 0.0)
  666. word_reason = segment.word_reasons.get(word, "")
  667. # 创建 Q 对象
  668. q = Q(
  669. text=word,
  670. score_with_o=word_score,
  671. reason=word_reason,
  672. from_source="word", # 标记来源为 word
  673. type_label=f"[{segment.type}]", # 保留域信息
  674. domain_index=seg_idx, # 添加域索引
  675. domain_type=segment.type # 添加域类型(如"中心名词"、"核心动作")
  676. )
  677. q_list.append(q)
  678. return q_list
  679. # ============================================================================
  680. # v120 保留辅助函数
  681. # ============================================================================
  682. def calculate_final_score(motivation_score: float, category_score: float) -> float:
  683. """
  684. 应用依存性规则计算最终得分
  685. 步骤1: 基础加权计算
  686. base_score = motivation_score * 0.7 + category_score * 0.3
  687. 步骤2: 极值保护规则
  688. Args:
  689. motivation_score: 动机维度得分 -1~1
  690. category_score: 品类维度得分 -1~1
  691. Returns:
  692. 最终得分 -1~1
  693. """
  694. # 基础加权得分
  695. base_score = motivation_score * 0.7 + category_score * 0.3
  696. # 规则C: 动机负向决定机制(最高优先级)
  697. if motivation_score < 0:
  698. return 0.0
  699. # 规则A: 动机高分保护机制
  700. if motivation_score >= 0.8:
  701. # 当目的高度一致时,品类的泛化不应导致"弱相关"
  702. return max(base_score, 0.7)
  703. # 规则B: 动机低分限制机制
  704. if motivation_score <= 0.2:
  705. # 目的不符时,品类匹配的价值有限
  706. return min(base_score, 0.5)
  707. # 无规则调整,返回基础得分
  708. return base_score
  709. def clean_json_string(text: str) -> str:
  710. """清理JSON中的非法控制字符(保留 \t \n \r)"""
  711. import re
  712. # 移除除了 \t(09) \n(0A) \r(0D) 之外的所有控制字符
  713. return re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', text)
  714. def process_note_data(note: dict) -> Post:
  715. """处理搜索接口返回的帖子数据"""
  716. note_card = note.get("note_card", {})
  717. image_list = note_card.get("image_list", [])
  718. interact_info = note_card.get("interact_info", {})
  719. user_info = note_card.get("user", {})
  720. # ========== 调试日志 START ==========
  721. note_id = note.get("id", "")
  722. raw_title = note_card.get("display_title") # 不提供默认值
  723. raw_body = note_card.get("desc")
  724. raw_type = note_card.get("type")
  725. # 打印原始值类型和内容
  726. print(f"\n[DEBUG] 处理帖子 {note_id}:")
  727. print(f" raw_title 类型: {type(raw_title).__name__}, 值: {repr(raw_title)}")
  728. print(f" raw_body 类型: {type(raw_body).__name__}, 值: {repr(raw_body)[:100] if raw_body else repr(raw_body)}")
  729. print(f" raw_type 类型: {type(raw_type).__name__}, 值: {repr(raw_type)}")
  730. # 检查是否为 None
  731. if raw_title is None:
  732. print(f" ⚠️ WARNING: display_title 是 None!")
  733. if raw_body is None:
  734. print(f" ⚠️ WARNING: desc 是 None!")
  735. if raw_type is None:
  736. print(f" ⚠️ WARNING: type 是 None!")
  737. # ========== 调试日志 END ==========
  738. # 提取图片URL - 使用新的字段名 image_url
  739. images = []
  740. for img in image_list:
  741. if isinstance(img, dict):
  742. # 尝试新字段名 image_url,如果不存在则尝试旧字段名 url_default
  743. img_url = img.get("image_url") or img.get("url_default")
  744. if img_url:
  745. images.append(img_url)
  746. # 判断类型
  747. note_type = note_card.get("type", "normal")
  748. video_url = ""
  749. if note_type == "video":
  750. video_info = note_card.get("video", {})
  751. if isinstance(video_info, dict):
  752. # 尝试获取视频URL
  753. video_url = video_info.get("media", {}).get("stream", {}).get("h264", [{}])[0].get("master_url", "")
  754. return Post(
  755. note_id=note.get("id") or "",
  756. title=note_card.get("display_title") or "",
  757. body_text=note_card.get("desc") or "",
  758. type=note_type,
  759. images=images,
  760. video=video_url,
  761. interact_info={
  762. "liked_count": interact_info.get("liked_count", 0),
  763. "collected_count": interact_info.get("collected_count", 0),
  764. "comment_count": interact_info.get("comment_count", 0),
  765. "shared_count": interact_info.get("shared_count", 0)
  766. },
  767. note_url=f"https://www.xiaohongshu.com/explore/{note.get('id', '')}"
  768. )
  769. async def evaluate_with_o(text: str, o: str, cache: dict[str, tuple[float, str]] | None = None) -> tuple[float, str]:
  770. """评估文本与原始问题o的相关度
  771. 采用两阶段评估 + 代码计算规则:
  772. 1. 动机维度评估(权重70%)
  773. 2. 品类维度评估(权重30%)
  774. 3. 应用规则A/B/C调整得分
  775. Args:
  776. text: 待评估的文本
  777. o: 原始问题
  778. cache: 评估缓存(可选),用于避免重复评估
  779. Returns:
  780. tuple[float, str]: (最终相关度分数, 综合评估理由)
  781. """
  782. # 检查缓存
  783. if cache is not None and text in cache:
  784. cached_score, cached_reason = cache[text]
  785. print(f" ⚡ 缓存命中: {text} -> {cached_score:.2f}")
  786. return cached_score, cached_reason
  787. # 准备输入
  788. eval_input = f"""
  789. <原始问题>
  790. {o}
  791. </原始问题>
  792. <平台sug词条>
  793. {text}
  794. </平台sug词条>
  795. 请评估平台sug词条与原始问题的匹配度。
  796. """
  797. # 添加重试机制
  798. max_retries = 2
  799. last_error = None
  800. for attempt in range(max_retries):
  801. try:
  802. # 并发调用两个评估器(统一使用标准评估策略)
  803. motivation_task = Runner.run(motivation_evaluator, eval_input)
  804. category_task = Runner.run(category_evaluator, eval_input)
  805. motivation_result, category_result = await asyncio.gather(
  806. motivation_task,
  807. category_task
  808. )
  809. # 获取评估结果
  810. motivation_eval: MotivationEvaluation = motivation_result.final_output
  811. category_eval: CategoryEvaluation = category_result.final_output
  812. # 提取得分
  813. motivation_score = motivation_eval.动机维度得分
  814. category_score = category_eval.品类维度得分
  815. # 计算基础得分
  816. base_score = motivation_score * 0.7 + category_score * 0.3
  817. # 应用规则计算最终得分
  818. final_score = calculate_final_score(motivation_score, category_score)
  819. # 组合评估理由
  820. core_motivation = motivation_eval.原始问题核心动机提取.简要说明核心动机
  821. motivation_reason = motivation_eval.简要说明动机维度相关度理由
  822. category_reason = category_eval.简要说明品类维度相关度理由
  823. combined_reason = (
  824. f"【核心动机】{core_motivation}\n"
  825. f"【动机维度 {motivation_score:.2f}】{motivation_reason}\n"
  826. f"【品类维度 {category_score:.2f}】{category_reason}\n"
  827. f"【基础得分 {base_score:.2f}】= 动机({motivation_score:.2f})*0.7 + 品类({category_score:.2f})*0.3\n"
  828. f"【最终得分 {final_score:.2f}】"
  829. )
  830. # 如果应用了规则,添加规则说明
  831. if final_score != base_score:
  832. if motivation_score < 0:
  833. combined_reason += "(应用规则C:动机负向决定机制)"
  834. elif motivation_score >= 0.8:
  835. combined_reason += "(应用规则A:动机高分保护机制)"
  836. elif motivation_score <= 0.2:
  837. combined_reason += "(应用规则B:动机低分限制机制)"
  838. # 存入缓存
  839. if cache is not None:
  840. cache[text] = (final_score, combined_reason)
  841. return final_score, combined_reason
  842. except Exception as e:
  843. last_error = e
  844. error_msg = str(e)
  845. if attempt < max_retries - 1:
  846. print(f" ⚠️ 评估失败 (尝试 {attempt+1}/{max_retries}): {error_msg[:150]}")
  847. print(f" 正在重试...")
  848. await asyncio.sleep(1) # 等待1秒后重试
  849. else:
  850. print(f" ❌ 评估失败 (已达最大重试次数): {error_msg[:150]}")
  851. # 所有重试失败后,返回默认值
  852. fallback_reason = f"评估失败(重试{max_retries}次): {str(last_error)[:200]}"
  853. print(f" 使用默认值: score=0.0, reason={fallback_reason[:100]}...")
  854. return 0.0, fallback_reason
  855. # ============================================================================
  856. # 核心流程函数
  857. # ============================================================================
  858. async def initialize(o: str, context: RunContext) -> tuple[list[Seg], list[Word], list[Q], list[Seed]]:
  859. """
  860. 初始化阶段
  861. Returns:
  862. (seg_list, word_list_1, q_list_1, seed_list)
  863. """
  864. print(f"\n{'='*60}")
  865. print(f"初始化阶段")
  866. print(f"{'='*60}")
  867. # 1. 分词:原始问题(o) ->分词-> seg_list
  868. print(f"\n[步骤1] 分词...")
  869. result = await Runner.run(word_segmenter, o)
  870. segmentation: WordSegmentation = result.final_output
  871. seg_list = []
  872. for word in segmentation.words:
  873. seg_list.append(Seg(text=word, from_o=o))
  874. print(f"分词结果: {[s.text for s in seg_list]}")
  875. print(f"分词理由: {segmentation.reasoning}")
  876. # 2. 分词评估:seg_list -> 每个seg与o进行评分(使用信号量限制并发数)
  877. print(f"\n[步骤2] 评估每个分词与原始问题的相关度...")
  878. MAX_CONCURRENT_SEG_EVALUATIONS = 10
  879. seg_semaphore = asyncio.Semaphore(MAX_CONCURRENT_SEG_EVALUATIONS)
  880. async def evaluate_seg(seg: Seg) -> Seg:
  881. async with seg_semaphore:
  882. # 初始化阶段的分词评估使用第一轮 prompt (round_num=1)
  883. seg.score_with_o, seg.reason = await evaluate_with_o(seg.text, o, context.evaluation_cache, round_num=1)
  884. return seg
  885. if seg_list:
  886. print(f" 开始评估 {len(seg_list)} 个分词(并发限制: {MAX_CONCURRENT_SEG_EVALUATIONS})...")
  887. eval_tasks = [evaluate_seg(seg) for seg in seg_list]
  888. await asyncio.gather(*eval_tasks)
  889. for seg in seg_list:
  890. print(f" {seg.text}: {seg.score_with_o:.2f}")
  891. # 3. 构建word_list_1: seg_list -> word_list_1(固定词库)
  892. print(f"\n[步骤3] 构建word_list_1(固定词库)...")
  893. word_list_1 = []
  894. for seg in seg_list:
  895. word_list_1.append(Word(
  896. text=seg.text,
  897. score_with_o=seg.score_with_o,
  898. from_o=o
  899. ))
  900. print(f"word_list_1(固定): {[w.text for w in word_list_1]}")
  901. # 4. 构建q_list_1:seg_list 作为 q_list_1
  902. print(f"\n[步骤4] 构建q_list_1...")
  903. q_list_1 = []
  904. for seg in seg_list:
  905. q_list_1.append(Q(
  906. text=seg.text,
  907. score_with_o=seg.score_with_o,
  908. reason=seg.reason,
  909. from_source="seg"
  910. ))
  911. print(f"q_list_1: {[q.text for q in q_list_1]}")
  912. # 5. 构建seed_list: seg_list -> seed_list
  913. print(f"\n[步骤5] 构建seed_list...")
  914. seed_list = []
  915. for seg in seg_list:
  916. seed_list.append(Seed(
  917. text=seg.text,
  918. added_words=[],
  919. from_type="seg",
  920. score_with_o=seg.score_with_o
  921. ))
  922. print(f"seed_list: {[s.text for s in seed_list]}")
  923. return seg_list, word_list_1, q_list_1, seed_list
  924. async def run_round(
  925. round_num: int,
  926. q_list: list[Q],
  927. word_list_1: list[Word],
  928. seed_list: list[Seed],
  929. o: str,
  930. context: RunContext,
  931. xiaohongshu_api: XiaohongshuSearchRecommendations,
  932. xiaohongshu_search: XiaohongshuSearch,
  933. sug_threshold: float = 0.7
  934. ) -> tuple[list[Q], list[Seed], list[Search]]:
  935. """
  936. 运行一轮
  937. Args:
  938. round_num: 轮次编号
  939. q_list: 当前轮的q列表
  940. word_list_1: 固定的词库(第0轮分词结果)
  941. seed_list: 当前的seed列表
  942. o: 原始问题
  943. context: 运行上下文
  944. xiaohongshu_api: 建议词API
  945. xiaohongshu_search: 搜索API
  946. sug_threshold: suggestion的阈值
  947. Returns:
  948. (q_list_next, seed_list_next, search_list)
  949. """
  950. print(f"\n{'='*60}")
  951. print(f"第{round_num}轮")
  952. print(f"{'='*60}")
  953. round_data = {
  954. "round_num": round_num,
  955. "input_q_list": [{"text": q.text, "score": q.score_with_o, "type": "query"} for q in q_list],
  956. "input_word_list_1_size": len(word_list_1),
  957. "input_seed_list_size": len(seed_list)
  958. }
  959. # 1. 请求sug:q_list -> 每个q请求sug接口 -> sug_list_list
  960. print(f"\n[步骤1] 为每个q请求建议词...")
  961. sug_list_list = [] # list of list
  962. for q in q_list:
  963. print(f"\n 处理q: {q.text}")
  964. suggestions = xiaohongshu_api.get_recommendations(keyword=q.text)
  965. q_sug_list = []
  966. if suggestions:
  967. print(f" 获取到 {len(suggestions)} 个建议词")
  968. for sug_text in suggestions:
  969. sug = Sug(
  970. text=sug_text,
  971. from_q=QFromQ(text=q.text, score_with_o=q.score_with_o)
  972. )
  973. q_sug_list.append(sug)
  974. else:
  975. print(f" 未获取到建议词")
  976. sug_list_list.append(q_sug_list)
  977. # 2. sug评估:sug_list_list -> 每个sug与o进行评分(并发)
  978. print(f"\n[步骤2] 评估每个建议词与原始问题的相关度...")
  979. # 2.1 收集所有需要评估的sug,并记录它们所属的q
  980. all_sugs = []
  981. sug_to_q_map = {} # 记录每个sug属于哪个q
  982. for i, q_sug_list in enumerate(sug_list_list):
  983. if q_sug_list:
  984. q_text = q_list[i].text
  985. for sug in q_sug_list:
  986. all_sugs.append(sug)
  987. sug_to_q_map[id(sug)] = q_text
  988. # 2.2 并发评估所有sug(使用信号量限制并发数)
  989. # 每个 evaluate_sug 内部会并发调用 2 个 LLM,所以这里限制为 5,实际并发 LLM 请求为 10
  990. MAX_CONCURRENT_EVALUATIONS = 5
  991. semaphore = asyncio.Semaphore(MAX_CONCURRENT_EVALUATIONS)
  992. async def evaluate_sug(sug: Sug) -> Sug:
  993. async with semaphore: # 限制并发数
  994. # 根据轮次选择 prompt: 第一轮使用 round1 prompt,后续使用标准 prompt
  995. sug.score_with_o, sug.reason = await evaluate_with_o(sug.text, o, context.evaluation_cache, round_num=round_num)
  996. return sug
  997. if all_sugs:
  998. print(f" 开始评估 {len(all_sugs)} 个建议词(并发限制: {MAX_CONCURRENT_EVALUATIONS})...")
  999. eval_tasks = [evaluate_sug(sug) for sug in all_sugs]
  1000. await asyncio.gather(*eval_tasks)
  1001. # 2.3 打印结果并组织到sug_details
  1002. sug_details = {} # 保存每个Q对应的sug列表
  1003. for i, q_sug_list in enumerate(sug_list_list):
  1004. if q_sug_list:
  1005. q_text = q_list[i].text
  1006. print(f"\n 来自q '{q_text}' 的建议词:")
  1007. sug_details[q_text] = []
  1008. for sug in q_sug_list:
  1009. print(f" {sug.text}: {sug.score_with_o:.2f}")
  1010. # 保存到sug_details
  1011. sug_details[q_text].append({
  1012. "text": sug.text,
  1013. "score": sug.score_with_o,
  1014. "reason": sug.reason,
  1015. "type": "sug"
  1016. })
  1017. # 2.4 剪枝判断(已禁用 - 保留所有分支)
  1018. pruned_query_texts = set()
  1019. if False: # 原: if round_num >= 2: # 剪枝功能已禁用,保留代码以便后续调整
  1020. print(f"\n[剪枝判断] 第{round_num}轮开始应用剪枝策略...")
  1021. for i, q in enumerate(q_list):
  1022. q_sug_list = sug_list_list[i]
  1023. if len(q_sug_list) == 0:
  1024. continue # 没有sug则不剪枝
  1025. # 剪枝条件1: 所有sug分数都低于query分数
  1026. all_lower_than_query = all(sug.score_with_o < q.score_with_o for sug in q_sug_list)
  1027. # 剪枝条件2: 所有sug分数都低于0.5
  1028. all_below_threshold = all(sug.score_with_o < 0.5 for sug in q_sug_list)
  1029. if all_lower_than_query and all_below_threshold:
  1030. pruned_query_texts.add(q.text)
  1031. max_sug_score = max(sug.score_with_o for sug in q_sug_list)
  1032. print(f" 🔪 剪枝: {q.text} (query分数:{q.score_with_o:.2f}, sug最高分:{max_sug_score:.2f}, 全部<0.5)")
  1033. if pruned_query_texts:
  1034. print(f" 本轮共剪枝 {len(pruned_query_texts)} 个query")
  1035. else:
  1036. print(f" 本轮无query被剪枝")
  1037. else:
  1038. print(f"\n[剪枝判断] 剪枝功能已禁用,保留所有分支")
  1039. # 3. search_list构建
  1040. print(f"\n[步骤3] 构建search_list(阈值>{sug_threshold})...")
  1041. search_list = []
  1042. high_score_sugs = [sug for sug in all_sugs if sug.score_with_o > sug_threshold]
  1043. if high_score_sugs:
  1044. print(f" 找到 {len(high_score_sugs)} 个高分建议词")
  1045. # 并发搜索
  1046. async def search_for_sug(sug: Sug) -> Search:
  1047. print(f" 搜索: {sug.text}")
  1048. try:
  1049. search_result = xiaohongshu_search.search(keyword=sug.text)
  1050. result_str = search_result.get("result", "{}")
  1051. if isinstance(result_str, str):
  1052. result_data = json.loads(result_str)
  1053. else:
  1054. result_data = result_str
  1055. notes = result_data.get("data", {}).get("data", [])
  1056. post_list = []
  1057. for note in notes[:10]: # 只取前10个
  1058. post = process_note_data(note)
  1059. post_list.append(post)
  1060. print(f" → 找到 {len(post_list)} 个帖子")
  1061. return Search(
  1062. text=sug.text,
  1063. score_with_o=sug.score_with_o,
  1064. from_q=sug.from_q,
  1065. post_list=post_list
  1066. )
  1067. except Exception as e:
  1068. print(f" ✗ 搜索失败: {e}")
  1069. return Search(
  1070. text=sug.text,
  1071. score_with_o=sug.score_with_o,
  1072. from_q=sug.from_q,
  1073. post_list=[]
  1074. )
  1075. search_tasks = [search_for_sug(sug) for sug in high_score_sugs]
  1076. search_list = await asyncio.gather(*search_tasks)
  1077. else:
  1078. print(f" 没有高分建议词,search_list为空")
  1079. # 4. 构建q_list_next
  1080. print(f"\n[步骤4] 构建q_list_next...")
  1081. q_list_next = []
  1082. existing_q_texts = set() # 用于去重
  1083. add_word_details = {} # 保存每个seed对应的组合词列表
  1084. all_seed_combinations = [] # 保存本轮所有seed的组合词(用于后续构建seed_list_next)
  1085. # 4.1 对于seed_list中的每个seed,从word_list_1中选词组合,产生Top 5
  1086. print(f"\n 4.1 为每个seed加词(产生Top 5组合)...")
  1087. for seed in seed_list:
  1088. print(f"\n 处理seed: {seed.text}")
  1089. # 剪枝检查:跳过被剪枝的seed
  1090. if seed.text in pruned_query_texts:
  1091. print(f" ⊗ 跳过被剪枝的seed: {seed.text}")
  1092. continue
  1093. # 从固定词库word_list_1筛选候选词
  1094. candidate_words = []
  1095. for word in word_list_1:
  1096. # 检查词是否已在seed中
  1097. if word.text in seed.text:
  1098. continue
  1099. # 检查词是否已被添加过
  1100. if word.text in seed.added_words:
  1101. continue
  1102. candidate_words.append(word)
  1103. if not candidate_words:
  1104. print(f" 没有可用的候选词")
  1105. continue
  1106. print(f" 候选词数量: {len(candidate_words)}")
  1107. # 调用Agent一次性选择并组合Top 5(添加重试机制)
  1108. candidate_words_text = ', '.join([w.text for w in candidate_words])
  1109. selection_input = f"""
  1110. <原始问题>
  1111. {o}
  1112. </原始问题>
  1113. <当前Seed>
  1114. {seed.text}
  1115. </当前Seed>
  1116. <候选词列表>
  1117. {candidate_words_text}
  1118. </候选词列表>
  1119. 请从候选词列表中选择最多5个最合适的词,分别与当前seed组合成新的query。
  1120. """
  1121. # 重试机制
  1122. max_retries = 2
  1123. selection_result = None
  1124. for attempt in range(max_retries):
  1125. try:
  1126. result = await Runner.run(word_selector, selection_input)
  1127. selection_result = result.final_output
  1128. break # 成功则跳出
  1129. except Exception as e:
  1130. error_msg = str(e)
  1131. if attempt < max_retries - 1:
  1132. print(f" ⚠️ 选词失败 (尝试 {attempt+1}/{max_retries}): {error_msg[:100]}")
  1133. await asyncio.sleep(1)
  1134. else:
  1135. print(f" ❌ 选词失败,跳过该seed: {error_msg[:100]}")
  1136. break
  1137. if selection_result is None:
  1138. print(f" 跳过seed: {seed.text}")
  1139. continue
  1140. print(f" Agent选择了 {len(selection_result.combinations)} 个组合")
  1141. print(f" 整体选择思路: {selection_result.overall_reasoning}")
  1142. # 并发评估所有组合的相关度
  1143. async def evaluate_combination(comb: WordCombination) -> dict:
  1144. combined = comb.combined_query
  1145. # 验证:组合结果必须包含完整的seed和word
  1146. # 检查是否包含seed的所有字符
  1147. seed_chars_in_combined = all(char in combined for char in seed.text)
  1148. # 检查是否包含word的所有字符
  1149. word_chars_in_combined = all(char in combined for char in comb.selected_word)
  1150. if not seed_chars_in_combined or not word_chars_in_combined:
  1151. print(f" ⚠️ 警告:组合不完整")
  1152. print(f" Seed: {seed.text}")
  1153. print(f" Word: {comb.selected_word}")
  1154. print(f" 组合: {combined}")
  1155. print(f" 包含完整seed? {seed_chars_in_combined}")
  1156. print(f" 包含完整word? {word_chars_in_combined}")
  1157. # 返回极低分数,让这个组合不会被选中
  1158. return {
  1159. 'word': comb.selected_word,
  1160. 'query': combined,
  1161. 'score': -1.0, # 极低分数
  1162. 'reason': f"组合不完整:缺少seed或word的部分内容",
  1163. 'reasoning': comb.reasoning
  1164. }
  1165. # 正常评估,根据轮次选择 prompt
  1166. score, reason = await evaluate_with_o(combined, o, context.evaluation_cache, round_num=round_num)
  1167. return {
  1168. 'word': comb.selected_word,
  1169. 'query': combined,
  1170. 'score': score,
  1171. 'reason': reason,
  1172. 'reasoning': comb.reasoning
  1173. }
  1174. eval_tasks = [evaluate_combination(comb) for comb in selection_result.combinations]
  1175. top_5 = await asyncio.gather(*eval_tasks)
  1176. print(f" 评估完成,得到 {len(top_5)} 个组合")
  1177. # 将Top 5全部加入q_list_next(去重检查 + 得分过滤)
  1178. for comb in top_5:
  1179. # 得分过滤:组合词必须比种子提升至少REQUIRED_SCORE_GAIN才能加入下一轮
  1180. if comb['score'] < seed.score_with_o + REQUIRED_SCORE_GAIN:
  1181. print(f" ⊗ 跳过低分: {comb['query']} (分数{comb['score']:.2f} < 种子{seed.score_with_o:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
  1182. continue
  1183. # 去重检查
  1184. if comb['query'] in existing_q_texts:
  1185. print(f" ⊗ 跳过重复: {comb['query']}")
  1186. continue
  1187. print(f" ✓ {comb['query']} (分数: {comb['score']:.2f} > 种子: {seed.score_with_o:.2f})")
  1188. new_q = Q(
  1189. text=comb['query'],
  1190. score_with_o=comb['score'],
  1191. reason=comb['reason'],
  1192. from_source="add"
  1193. )
  1194. q_list_next.append(new_q)
  1195. existing_q_texts.add(comb['query']) # 记录到去重集合
  1196. # 记录已添加的词
  1197. seed.added_words.append(comb['word'])
  1198. # 保存到add_word_details
  1199. add_word_details[seed.text] = [
  1200. {
  1201. "text": comb['query'],
  1202. "score": comb['score'],
  1203. "reason": comb['reason'],
  1204. "selected_word": comb['word'],
  1205. "seed_score": seed.score_with_o, # 添加原始种子的得分
  1206. "type": "add"
  1207. }
  1208. for comb in top_5
  1209. ]
  1210. # 保存到all_seed_combinations(用于构建seed_list_next)
  1211. # 附加seed_score,用于后续过滤
  1212. for comb in top_5:
  1213. comb['seed_score'] = seed.score_with_o
  1214. all_seed_combinations.extend(top_5)
  1215. # 4.2 对于sug_list_list中,每个sug大于来自的query分数,加到q_list_next(去重检查)
  1216. print(f"\n 4.2 将高分sug加入q_list_next...")
  1217. for sug in all_sugs:
  1218. # 剪枝检查:跳过来自被剪枝query的sug
  1219. if sug.from_q and sug.from_q.text in pruned_query_texts:
  1220. print(f" ⊗ 跳过来自被剪枝query的sug: {sug.text} (来源: {sug.from_q.text})")
  1221. continue
  1222. # sug必须比来源query提升至少REQUIRED_SCORE_GAIN才能加入下一轮
  1223. if sug.from_q and sug.score_with_o >= sug.from_q.score_with_o + REQUIRED_SCORE_GAIN:
  1224. # 去重检查
  1225. if sug.text in existing_q_texts:
  1226. print(f" ⊗ 跳过重复: {sug.text}")
  1227. continue
  1228. new_q = Q(
  1229. text=sug.text,
  1230. score_with_o=sug.score_with_o,
  1231. reason=sug.reason,
  1232. from_source="sug"
  1233. )
  1234. q_list_next.append(new_q)
  1235. existing_q_texts.add(sug.text) # 记录到去重集合
  1236. print(f" ✓ {sug.text} (分数: {sug.score_with_o:.2f} >= 来源query: {sug.from_q.score_with_o:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
  1237. # 5. 构建seed_list_next(关键修改:不保留上一轮的seed)
  1238. print(f"\n[步骤5] 构建seed_list_next(不保留上轮seed)...")
  1239. seed_list_next = []
  1240. existing_seed_texts = set()
  1241. # 5.1 加入本轮所有组合词(只加入得分提升的)
  1242. print(f" 5.1 加入本轮所有组合词(得分过滤)...")
  1243. for comb in all_seed_combinations:
  1244. # 得分过滤:组合词必须比种子提升至少REQUIRED_SCORE_GAIN才作为下一轮种子
  1245. seed_score = comb.get('seed_score', 0)
  1246. if comb['score'] < seed_score + REQUIRED_SCORE_GAIN:
  1247. print(f" ⊗ 跳过低分: {comb['query']} (分数{comb['score']:.2f} < 种子{seed_score:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
  1248. continue
  1249. if comb['query'] not in existing_seed_texts:
  1250. new_seed = Seed(
  1251. text=comb['query'],
  1252. added_words=[], # 新seed的added_words清空
  1253. from_type="add",
  1254. score_with_o=comb['score']
  1255. )
  1256. seed_list_next.append(new_seed)
  1257. existing_seed_texts.add(comb['query'])
  1258. print(f" ✓ {comb['query']} (分数: {comb['score']:.2f} >= 种子: {seed_score:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
  1259. # 5.2 加入高分sug
  1260. print(f" 5.2 加入高分sug...")
  1261. for sug in all_sugs:
  1262. # 剪枝检查:跳过来自被剪枝query的sug
  1263. if sug.from_q and sug.from_q.text in pruned_query_texts:
  1264. continue
  1265. # sug必须比来源query提升至少REQUIRED_SCORE_GAIN才作为下一轮种子
  1266. if sug.from_q and sug.score_with_o >= sug.from_q.score_with_o + REQUIRED_SCORE_GAIN and sug.text not in existing_seed_texts:
  1267. new_seed = Seed(
  1268. text=sug.text,
  1269. added_words=[],
  1270. from_type="sug",
  1271. score_with_o=sug.score_with_o
  1272. )
  1273. seed_list_next.append(new_seed)
  1274. existing_seed_texts.add(sug.text)
  1275. print(f" ✓ {sug.text} (分数: {sug.score_with_o:.2f} >= 来源query: {sug.from_q.score_with_o:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
  1276. # 序列化搜索结果数据(包含帖子详情)
  1277. search_results_data = []
  1278. for search in search_list:
  1279. search_results_data.append({
  1280. "text": search.text,
  1281. "score_with_o": search.score_with_o,
  1282. "post_list": [
  1283. {
  1284. "note_id": post.note_id,
  1285. "note_url": post.note_url,
  1286. "title": post.title,
  1287. "body_text": post.body_text,
  1288. "images": post.images,
  1289. "interact_info": post.interact_info
  1290. }
  1291. for post in search.post_list
  1292. ]
  1293. })
  1294. # 记录本轮数据
  1295. round_data.update({
  1296. "sug_count": len(all_sugs),
  1297. "high_score_sug_count": len(high_score_sugs),
  1298. "search_count": len(search_list),
  1299. "total_posts": sum(len(s.post_list) for s in search_list),
  1300. "q_list_next_size": len(q_list_next),
  1301. "seed_list_next_size": len(seed_list_next),
  1302. "total_combinations": len(all_seed_combinations),
  1303. "pruned_query_count": len(pruned_query_texts),
  1304. "pruned_queries": list(pruned_query_texts),
  1305. "output_q_list": [{"text": q.text, "score": q.score_with_o, "reason": q.reason, "from": q.from_source, "type": "query"} for q in q_list_next],
  1306. "seed_list_next": [{"text": seed.text, "from": seed.from_type, "score": seed.score_with_o} for seed in seed_list_next],
  1307. "sug_details": sug_details,
  1308. "add_word_details": add_word_details,
  1309. "search_results": search_results_data
  1310. })
  1311. context.rounds.append(round_data)
  1312. print(f"\n本轮总结:")
  1313. print(f" 建议词数量: {len(all_sugs)}")
  1314. print(f" 高分建议词: {len(high_score_sugs)}")
  1315. print(f" 搜索数量: {len(search_list)}")
  1316. print(f" 帖子总数: {sum(len(s.post_list) for s in search_list)}")
  1317. print(f" 组合词数量: {len(all_seed_combinations)}")
  1318. print(f" 下轮q数量: {len(q_list_next)}")
  1319. print(f" 下轮seed数量: {len(seed_list_next)}")
  1320. return q_list_next, seed_list_next, search_list
  1321. async def iterative_loop(
  1322. context: RunContext,
  1323. max_rounds: int = 2,
  1324. sug_threshold: float = 0.7
  1325. ):
  1326. """主迭代循环"""
  1327. print(f"\n{'='*60}")
  1328. print(f"开始迭代循环")
  1329. print(f"最大轮数: {max_rounds}")
  1330. print(f"sug阈值: {sug_threshold}")
  1331. print(f"{'='*60}")
  1332. # 初始化
  1333. seg_list, word_list_1, q_list, seed_list = await initialize(context.o, context)
  1334. # API实例
  1335. xiaohongshu_api = XiaohongshuSearchRecommendations()
  1336. xiaohongshu_search = XiaohongshuSearch()
  1337. # 保存初始化数据
  1338. context.rounds.append({
  1339. "round_num": 0,
  1340. "type": "initialization",
  1341. "seg_list": [{"text": s.text, "score": s.score_with_o, "reason": s.reason, "type": "seg"} for s in seg_list],
  1342. "word_list_1": [{"text": w.text, "score": w.score_with_o} for w in word_list_1],
  1343. "q_list_1": [{"text": q.text, "score": q.score_with_o, "reason": q.reason, "type": "query"} for q in q_list],
  1344. "seed_list": [{"text": s.text, "from_type": s.from_type, "score": s.score_with_o, "type": "seed"} for s in seed_list]
  1345. })
  1346. # 收集所有搜索结果
  1347. all_search_list = []
  1348. # 迭代
  1349. round_num = 1
  1350. while q_list and round_num <= max_rounds:
  1351. q_list, seed_list, search_list = await run_round(
  1352. round_num=round_num,
  1353. q_list=q_list,
  1354. word_list_1=word_list_1, # 传递固定词库
  1355. seed_list=seed_list,
  1356. o=context.o,
  1357. context=context,
  1358. xiaohongshu_api=xiaohongshu_api,
  1359. xiaohongshu_search=xiaohongshu_search,
  1360. sug_threshold=sug_threshold
  1361. )
  1362. all_search_list.extend(search_list)
  1363. round_num += 1
  1364. print(f"\n{'='*60}")
  1365. print(f"迭代完成")
  1366. print(f" 总轮数: {round_num - 1}")
  1367. print(f" 总搜索次数: {len(all_search_list)}")
  1368. print(f" 总帖子数: {sum(len(s.post_list) for s in all_search_list)}")
  1369. print(f"{'='*60}")
  1370. return all_search_list
  1371. # ============================================================================
  1372. # v121 新架构核心流程函数
  1373. # ============================================================================
  1374. async def initialize_v2(o: str, context: RunContext) -> list[Segment]:
  1375. """
  1376. v121 Round 0 初始化阶段
  1377. 流程:
  1378. 1. 语义分段: 调用 semantic_segmenter 将原始问题拆分成语义片段
  1379. 2. 拆词: 对每个segment调用 word_segmenter 进行拆词
  1380. 3. 评估: 对每个segment和词进行评估
  1381. 4. 不进行组合(Round 0只分段和拆词)
  1382. Returns:
  1383. 语义片段列表 (Segment)
  1384. """
  1385. print(f"\n{'='*60}")
  1386. print(f"Round 0: 初始化阶段(语义分段 + 拆词)")
  1387. print(f"{'='*60}")
  1388. # 1. 语义分段
  1389. print(f"\n[步骤1] 语义分段...")
  1390. result = await Runner.run(semantic_segmenter, o)
  1391. segmentation: SemanticSegmentation = result.final_output
  1392. print(f"语义分段结果: {len(segmentation.segments)} 个片段")
  1393. print(f"整体分段思路: {segmentation.overall_reasoning}")
  1394. segment_list = []
  1395. for seg_item in segmentation.segments:
  1396. segment = Segment(
  1397. text=seg_item.segment_text,
  1398. type=seg_item.segment_type,
  1399. from_o=o
  1400. )
  1401. segment_list.append(segment)
  1402. print(f" - [{segment.type}] {segment.text}")
  1403. # 2. 对每个segment拆词并评估
  1404. print(f"\n[步骤2] 对每个segment拆词并评估...")
  1405. MAX_CONCURRENT_EVALUATIONS = 5
  1406. semaphore = asyncio.Semaphore(MAX_CONCURRENT_EVALUATIONS)
  1407. async def process_segment(segment: Segment) -> Segment:
  1408. """处理单个segment: 拆词 + 评估segment + 评估词"""
  1409. async with semaphore:
  1410. # 2.1 拆词
  1411. word_result = await Runner.run(word_segmenter, segment.text)
  1412. word_segmentation: WordSegmentation = word_result.final_output
  1413. segment.words = word_segmentation.words
  1414. # 2.2 评估segment与原始问题的相关度
  1415. segment.score_with_o, segment.reason = await evaluate_with_o(
  1416. segment.text, o, context.evaluation_cache
  1417. )
  1418. # 2.3 评估每个词与原始问题的相关度
  1419. word_eval_tasks = []
  1420. for word in segment.words:
  1421. async def eval_word(w: str) -> tuple[str, float, str]:
  1422. score, reason = await evaluate_with_o(w, o, context.evaluation_cache)
  1423. return w, score, reason
  1424. word_eval_tasks.append(eval_word(word))
  1425. word_results = await asyncio.gather(*word_eval_tasks)
  1426. for word, score, reason in word_results:
  1427. segment.word_scores[word] = score
  1428. segment.word_reasons[word] = reason
  1429. return segment
  1430. if segment_list:
  1431. print(f" 开始处理 {len(segment_list)} 个segment(并发限制: {MAX_CONCURRENT_EVALUATIONS})...")
  1432. process_tasks = [process_segment(seg) for seg in segment_list]
  1433. await asyncio.gather(*process_tasks)
  1434. # 打印步骤1结果
  1435. print(f"\n[步骤1: 分段及拆词 结果]")
  1436. for segment in segment_list:
  1437. print(f" [{segment.type}] {segment.text} (分数: {segment.score_with_o:.2f})")
  1438. print(f" 拆词: {segment.words}")
  1439. for word in segment.words:
  1440. score = segment.word_scores.get(word, 0.0)
  1441. print(f" - {word}: {score:.2f}")
  1442. # 保存到context(保留旧格式以兼容)
  1443. context.segments = [
  1444. {
  1445. "text": seg.text,
  1446. "type": seg.type,
  1447. "score": seg.score_with_o,
  1448. "reason": seg.reason,
  1449. "words": seg.words,
  1450. "word_scores": seg.word_scores,
  1451. "word_reasons": seg.word_reasons
  1452. }
  1453. for seg in segment_list
  1454. ]
  1455. # 保存 Round 0 到 context.rounds(新格式用于可视化)
  1456. context.rounds.append({
  1457. "round_num": 0,
  1458. "type": "initialization",
  1459. "segments": [
  1460. {
  1461. "text": seg.text,
  1462. "type": seg.type,
  1463. "domain_index": idx,
  1464. "score": seg.score_with_o,
  1465. "reason": seg.reason,
  1466. "words": [
  1467. {
  1468. "text": word,
  1469. "score": seg.word_scores.get(word, 0.0),
  1470. "reason": seg.word_reasons.get(word, "")
  1471. }
  1472. for word in seg.words
  1473. ]
  1474. }
  1475. for idx, seg in enumerate(segment_list)
  1476. ]
  1477. })
  1478. print(f"\n[Round 0 完成]")
  1479. print(f" 分段数: {len(segment_list)}")
  1480. total_words = sum(len(seg.words) for seg in segment_list)
  1481. print(f" 总词数: {total_words}")
  1482. return segment_list
  1483. async def run_round_v2(
  1484. round_num: int,
  1485. query_input: list[Q],
  1486. segments: list[Segment],
  1487. o: str,
  1488. context: RunContext,
  1489. xiaohongshu_api: XiaohongshuSearchRecommendations,
  1490. xiaohongshu_search: XiaohongshuSearch,
  1491. sug_threshold: float = 0.7
  1492. ) -> tuple[list[Q], list[Search]]:
  1493. """
  1494. v121 Round N 执行
  1495. 正确的流程顺序:
  1496. 1. 为 query_input 请求SUG
  1497. 2. 评估SUG
  1498. 3. 高分SUG搜索
  1499. 4. N域组合(从segments生成)
  1500. 5. 评估组合
  1501. 6. 生成 q_list_next(组合 + 高分SUG)
  1502. Args:
  1503. round_num: 轮次编号 (1-4)
  1504. query_input: 本轮的输入query列表(Round 1是words,Round 2+是上轮输出)
  1505. segments: 语义片段列表(用于组合)
  1506. o: 原始问题
  1507. context: 运行上下文
  1508. xiaohongshu_api: 建议词API
  1509. xiaohongshu_search: 搜索API
  1510. sug_threshold: SUG搜索阈值
  1511. Returns:
  1512. (q_list_next, search_list)
  1513. """
  1514. print(f"\n{'='*60}")
  1515. print(f"Round {round_num}: {round_num}域组合")
  1516. print(f"{'='*60}")
  1517. round_data = {
  1518. "round_num": round_num,
  1519. "n_domains": round_num,
  1520. "input_query_count": len(query_input)
  1521. }
  1522. MAX_CONCURRENT_EVALUATIONS = 5
  1523. semaphore = asyncio.Semaphore(MAX_CONCURRENT_EVALUATIONS)
  1524. # 步骤1: 为 query_input 请求SUG
  1525. print(f"\n[步骤1] 为{len(query_input)}个输入query请求SUG...")
  1526. all_sugs = []
  1527. sug_details = {}
  1528. for q in query_input:
  1529. suggestions = xiaohongshu_api.get_recommendations(keyword=q.text)
  1530. if suggestions:
  1531. print(f" {q.text}: 获取到 {len(suggestions)} 个SUG")
  1532. for sug_text in suggestions:
  1533. sug = Sug(
  1534. text=sug_text,
  1535. from_q=QFromQ(text=q.text, score_with_o=q.score_with_o)
  1536. )
  1537. all_sugs.append(sug)
  1538. else:
  1539. print(f" {q.text}: 未获取到SUG")
  1540. print(f" 共获取 {len(all_sugs)} 个SUG")
  1541. # 步骤2: 评估SUG
  1542. if len(all_sugs) > 0:
  1543. print(f"\n[步骤2] 评估{len(all_sugs)}个SUG...")
  1544. async def evaluate_sug(sug: Sug) -> Sug:
  1545. async with semaphore:
  1546. sug.score_with_o, sug.reason = await evaluate_with_o(
  1547. sug.text, o, context.evaluation_cache
  1548. )
  1549. return sug
  1550. eval_tasks = [evaluate_sug(sug) for sug in all_sugs]
  1551. await asyncio.gather(*eval_tasks)
  1552. # 打印结果
  1553. for sug in all_sugs:
  1554. print(f" {sug.text}: {sug.score_with_o:.2f}")
  1555. if sug.from_q:
  1556. if sug.from_q.text not in sug_details:
  1557. sug_details[sug.from_q.text] = []
  1558. sug_details[sug.from_q.text].append({
  1559. "text": sug.text,
  1560. "score": sug.score_with_o,
  1561. "reason": sug.reason,
  1562. "type": "sug"
  1563. })
  1564. # 步骤3: 搜索高分SUG
  1565. print(f"\n[步骤3] 搜索高分SUG(阈值 > {sug_threshold})...")
  1566. high_score_sugs = [sug for sug in all_sugs if sug.score_with_o > sug_threshold]
  1567. print(f" 找到 {len(high_score_sugs)} 个高分SUG")
  1568. search_list = []
  1569. if len(high_score_sugs) > 0:
  1570. async def search_for_sug(sug: Sug) -> Search:
  1571. print(f" 搜索: {sug.text}")
  1572. try:
  1573. search_result = xiaohongshu_search.search(keyword=sug.text)
  1574. result_str = search_result.get("result", "{}")
  1575. if isinstance(result_str, str):
  1576. result_data = json.loads(result_str)
  1577. else:
  1578. result_data = result_str
  1579. notes = result_data.get("data", {}).get("data", [])
  1580. post_list = []
  1581. for note in notes[:10]:
  1582. post = process_note_data(note)
  1583. post_list.append(post)
  1584. print(f" → 找到 {len(post_list)} 个帖子")
  1585. return Search(
  1586. text=sug.text,
  1587. score_with_o=sug.score_with_o,
  1588. from_q=sug.from_q,
  1589. post_list=post_list
  1590. )
  1591. except Exception as e:
  1592. print(f" ✗ 搜索失败: {e}")
  1593. return Search(
  1594. text=sug.text,
  1595. score_with_o=sug.score_with_o,
  1596. from_q=sug.from_q,
  1597. post_list=[]
  1598. )
  1599. search_tasks = [search_for_sug(sug) for sug in high_score_sugs]
  1600. search_list = await asyncio.gather(*search_tasks)
  1601. # 步骤4: 生成N域组合
  1602. print(f"\n[步骤4] 生成{round_num}域组合...")
  1603. domain_combinations = generate_domain_combinations(segments, round_num)
  1604. print(f" 生成了 {len(domain_combinations)} 个组合")
  1605. if len(domain_combinations) == 0:
  1606. print(f" 无法生成{round_num}域组合")
  1607. # 即使无法组合,也返回高分SUG作为下轮输入
  1608. q_list_next = []
  1609. for sug in all_sugs:
  1610. if sug.from_q and sug.score_with_o >= sug.from_q.score_with_o + REQUIRED_SCORE_GAIN:
  1611. q = Q(
  1612. text=sug.text,
  1613. score_with_o=sug.score_with_o,
  1614. reason=sug.reason,
  1615. from_source="sug",
  1616. type_label=""
  1617. )
  1618. q_list_next.append(q)
  1619. round_data.update({
  1620. "domain_combinations_count": 0,
  1621. "sug_count": len(all_sugs),
  1622. "high_score_sug_count": len(high_score_sugs),
  1623. "search_count": len(search_list),
  1624. "sug_details": sug_details,
  1625. "q_list_next_size": len(q_list_next)
  1626. })
  1627. context.rounds.append(round_data)
  1628. return q_list_next, search_list
  1629. # 步骤5: 评估所有组合
  1630. print(f"\n[步骤5] 评估{len(domain_combinations)}个组合...")
  1631. async def evaluate_combination(comb: DomainCombination) -> DomainCombination:
  1632. async with semaphore:
  1633. comb.score_with_o, comb.reason = await evaluate_with_o(
  1634. comb.text, o, context.evaluation_cache
  1635. )
  1636. return comb
  1637. eval_tasks = [evaluate_combination(comb) for comb in domain_combinations]
  1638. await asyncio.gather(*eval_tasks)
  1639. # 排序 - 已注释,保持原始顺序
  1640. # domain_combinations.sort(key=lambda x: x.score_with_o, reverse=True)
  1641. # 打印所有组合(保持原始顺序)
  1642. print(f" 评估完成,共{len(domain_combinations)}个组合:")
  1643. for i, comb in enumerate(domain_combinations, 1):
  1644. print(f" {i}. {comb.text} {comb.type_label} (分数: {comb.score_with_o:.2f})")
  1645. # 步骤6: 构建 q_list_next(组合 + 高分SUG)
  1646. print(f"\n[步骤6] 生成下轮输入...")
  1647. q_list_next = []
  1648. # 6.1 添加高分组合
  1649. high_score_combinations = [comb for comb in domain_combinations if comb.score_with_o > REQUIRED_SCORE_GAIN]
  1650. for comb in high_score_combinations:
  1651. # 生成域字符串,如 "D0,D3"
  1652. domains_str = ','.join([f'D{d}' for d in comb.domains]) if comb.domains else ''
  1653. q = Q(
  1654. text=comb.text,
  1655. score_with_o=comb.score_with_o,
  1656. reason=comb.reason,
  1657. from_source="domain_comb",
  1658. type_label=comb.type_label,
  1659. domain_type=domains_str # 添加域信息
  1660. )
  1661. q_list_next.append(q)
  1662. print(f" 添加 {len(high_score_combinations)} 个高分组合")
  1663. # 6.2 添加高分SUG(满足增益条件)
  1664. high_gain_sugs = []
  1665. for sug in all_sugs:
  1666. if sug.from_q and sug.score_with_o >= sug.from_q.score_with_o + REQUIRED_SCORE_GAIN:
  1667. q = Q(
  1668. text=sug.text,
  1669. score_with_o=sug.score_with_o,
  1670. reason=sug.reason,
  1671. from_source="sug",
  1672. type_label=""
  1673. )
  1674. q_list_next.append(q)
  1675. high_gain_sugs.append(sug)
  1676. print(f" 添加 {len(high_gain_sugs)} 个高增益SUG(增益 > {REQUIRED_SCORE_GAIN})")
  1677. # 保存round数据(包含完整帖子信息)
  1678. search_results_data = []
  1679. for search in search_list:
  1680. search_results_data.append({
  1681. "text": search.text,
  1682. "score_with_o": search.score_with_o,
  1683. "post_list": [
  1684. {
  1685. "note_id": post.note_id,
  1686. "note_url": post.note_url,
  1687. "title": post.title,
  1688. "body_text": post.body_text,
  1689. "images": post.images,
  1690. "interact_info": post.interact_info
  1691. }
  1692. for post in search.post_list
  1693. ]
  1694. })
  1695. round_data.update({
  1696. "input_queries": [{"text": q.text, "score": q.score_with_o, "from_source": q.from_source, "type": "input", "domain_index": q.domain_index, "domain_type": q.domain_type} for q in query_input],
  1697. "domain_combinations_count": len(domain_combinations),
  1698. "domain_combinations": [
  1699. {
  1700. "text": comb.text,
  1701. "type_label": comb.type_label,
  1702. "score": comb.score_with_o,
  1703. "reason": comb.reason,
  1704. "domains": comb.domains,
  1705. "source_words": comb.source_words,
  1706. "from_segments": comb.from_segments
  1707. }
  1708. for comb in domain_combinations
  1709. ],
  1710. "high_score_combinations": [{"text": q.text, "score": q.score_with_o, "type_label": q.type_label, "type": "combination"} for q in q_list_next if q.from_source == "domain_comb"],
  1711. "sug_count": len(all_sugs),
  1712. "sug_details": sug_details,
  1713. "high_score_sug_count": len(high_score_sugs),
  1714. "high_gain_sugs": [{"text": q.text, "score": q.score_with_o, "type": "sug"} for q in q_list_next if q.from_source == "sug"],
  1715. "search_count": len(search_list),
  1716. "search_results": search_results_data,
  1717. "q_list_next_size": len(q_list_next)
  1718. })
  1719. context.rounds.append(round_data)
  1720. print(f"\nRound {round_num} 总结:")
  1721. print(f" 输入Query数: {len(query_input)}")
  1722. print(f" 域组合数: {len(domain_combinations)}")
  1723. print(f" 高分组合: {len(high_score_combinations)}")
  1724. print(f" SUG数: {len(all_sugs)}")
  1725. print(f" 高分SUG数: {len(high_score_sugs)}")
  1726. print(f" 高增益SUG: {len(high_gain_sugs)}")
  1727. print(f" 搜索数: {len(search_list)}")
  1728. print(f" 下轮Query数: {len(q_list_next)}")
  1729. return q_list_next, search_list
  1730. async def iterative_loop_v2(
  1731. context: RunContext,
  1732. max_rounds: int = 4,
  1733. sug_threshold: float = 0.7
  1734. ):
  1735. """v121 主迭代循环"""
  1736. print(f"\n{'='*60}")
  1737. print(f"开始v121迭代循环(语义分段跨域组词版)")
  1738. print(f"最大轮数: {max_rounds}")
  1739. print(f"sug阈值: {sug_threshold}")
  1740. print(f"{'='*60}")
  1741. # Round 0: 初始化(语义分段 + 拆词)
  1742. segments = await initialize_v2(context.o, context)
  1743. # API实例
  1744. xiaohongshu_api = XiaohongshuSearchRecommendations()
  1745. xiaohongshu_search = XiaohongshuSearch()
  1746. # 收集所有搜索结果
  1747. all_search_list = []
  1748. # 准备 Round 1 的输入:从 segments 提取所有 words
  1749. query_input = extract_words_from_segments(segments)
  1750. print(f"\n提取了 {len(query_input)} 个词作为 Round 1 的输入")
  1751. # Round 1-N: 迭代循环
  1752. num_segments = len(segments)
  1753. actual_max_rounds = min(max_rounds, num_segments)
  1754. round_num = 1
  1755. while query_input and round_num <= actual_max_rounds:
  1756. query_input, search_list = await run_round_v2(
  1757. round_num=round_num,
  1758. query_input=query_input, # 传递上一轮的输出
  1759. segments=segments,
  1760. o=context.o,
  1761. context=context,
  1762. xiaohongshu_api=xiaohongshu_api,
  1763. xiaohongshu_search=xiaohongshu_search,
  1764. sug_threshold=sug_threshold
  1765. )
  1766. all_search_list.extend(search_list)
  1767. # 如果没有新的query,提前结束
  1768. if not query_input:
  1769. print(f"\n第{round_num}轮后无新query生成,提前结束迭代")
  1770. break
  1771. round_num += 1
  1772. print(f"\n{'='*60}")
  1773. print(f"迭代完成")
  1774. print(f" 实际轮数: {round_num}")
  1775. print(f" 总搜索次数: {len(all_search_list)}")
  1776. print(f" 总帖子数: {sum(len(s.post_list) for s in all_search_list)}")
  1777. print(f"{'='*60}")
  1778. return all_search_list
  1779. # ============================================================================
  1780. # 主函数
  1781. # ============================================================================
  1782. async def main(input_dir: str, max_rounds: int = 2, sug_threshold: float = 0.7, visualize: bool = False):
  1783. """主函数"""
  1784. current_time, log_url = set_trace()
  1785. # 读取输入
  1786. input_context_file = os.path.join(input_dir, 'context.md')
  1787. input_q_file = os.path.join(input_dir, 'q.md')
  1788. c = read_file_as_string(input_context_file) # 原始需求
  1789. o = read_file_as_string(input_q_file) # 原始问题
  1790. # 版本信息
  1791. version = os.path.basename(__file__)
  1792. version_name = os.path.splitext(version)[0]
  1793. # 日志目录
  1794. log_dir = os.path.join(input_dir, "output", version_name, current_time)
  1795. # 创建运行上下文
  1796. run_context = RunContext(
  1797. version=version,
  1798. input_files={
  1799. "input_dir": input_dir,
  1800. "context_file": input_context_file,
  1801. "q_file": input_q_file,
  1802. },
  1803. c=c,
  1804. o=o,
  1805. log_dir=log_dir,
  1806. log_url=log_url,
  1807. )
  1808. # 创建日志目录
  1809. os.makedirs(run_context.log_dir, exist_ok=True)
  1810. # 配置日志文件
  1811. log_file_path = os.path.join(run_context.log_dir, "run.log")
  1812. log_file = open(log_file_path, 'w', encoding='utf-8')
  1813. # 重定向stdout到TeeLogger(同时输出到控制台和文件)
  1814. original_stdout = sys.stdout
  1815. sys.stdout = TeeLogger(original_stdout, log_file)
  1816. try:
  1817. print(f"📝 日志文件: {log_file_path}")
  1818. print(f"{'='*60}\n")
  1819. # 执行迭代 (v121: 使用新架构)
  1820. all_search_list = await iterative_loop_v2(
  1821. run_context,
  1822. max_rounds=max_rounds,
  1823. sug_threshold=sug_threshold
  1824. )
  1825. # 格式化输出
  1826. output = f"原始需求:{run_context.c}\n"
  1827. output += f"原始问题:{run_context.o}\n"
  1828. output += f"总搜索次数:{len(all_search_list)}\n"
  1829. output += f"总帖子数:{sum(len(s.post_list) for s in all_search_list)}\n"
  1830. output += "\n" + "="*60 + "\n"
  1831. if all_search_list:
  1832. output += "【搜索结果】\n\n"
  1833. for idx, search in enumerate(all_search_list, 1):
  1834. output += f"{idx}. 搜索词: {search.text} (分数: {search.score_with_o:.2f})\n"
  1835. output += f" 帖子数: {len(search.post_list)}\n"
  1836. if search.post_list:
  1837. for post_idx, post in enumerate(search.post_list[:3], 1): # 只显示前3个
  1838. output += f" {post_idx}) {post.title}\n"
  1839. output += f" URL: {post.note_url}\n"
  1840. output += "\n"
  1841. else:
  1842. output += "未找到搜索结果\n"
  1843. run_context.final_output = output
  1844. print(f"\n{'='*60}")
  1845. print("最终结果")
  1846. print(f"{'='*60}")
  1847. print(output)
  1848. # 保存上下文文件
  1849. context_file_path = os.path.join(run_context.log_dir, "run_context.json")
  1850. context_dict = run_context.model_dump()
  1851. with open(context_file_path, "w", encoding="utf-8") as f:
  1852. json.dump(context_dict, f, ensure_ascii=False, indent=2)
  1853. print(f"\nRunContext saved to: {context_file_path}")
  1854. # 保存详细的搜索结果
  1855. search_results_path = os.path.join(run_context.log_dir, "search_results.json")
  1856. search_results_data = [s.model_dump() for s in all_search_list]
  1857. with open(search_results_path, "w", encoding="utf-8") as f:
  1858. json.dump(search_results_data, f, ensure_ascii=False, indent=2)
  1859. print(f"Search results saved to: {search_results_path}")
  1860. # 可视化
  1861. if visualize:
  1862. import subprocess
  1863. output_html = os.path.join(run_context.log_dir, "visualization.html")
  1864. print(f"\n🎨 生成可视化HTML...")
  1865. # 获取绝对路径
  1866. abs_context_file = os.path.abspath(context_file_path)
  1867. abs_output_html = os.path.abspath(output_html)
  1868. # 运行可视化脚本
  1869. result = subprocess.run([
  1870. "node",
  1871. "visualization/sug_v6_1_2_121/index.js",
  1872. abs_context_file,
  1873. abs_output_html
  1874. ])
  1875. if result.returncode == 0:
  1876. print(f"✅ 可视化已生成: {output_html}")
  1877. else:
  1878. print(f"❌ 可视化生成失败")
  1879. finally:
  1880. # 恢复stdout
  1881. sys.stdout = original_stdout
  1882. log_file.close()
  1883. print(f"\n📝 运行日志已保存: {log_file_path}")
  1884. if __name__ == "__main__":
  1885. parser = argparse.ArgumentParser(description="搜索query优化工具 - v6.1.2.121 语义分段跨域组词版")
  1886. parser.add_argument(
  1887. "--input-dir",
  1888. type=str,
  1889. default="input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?",
  1890. help="输入目录路径,默认: input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?"
  1891. )
  1892. parser.add_argument(
  1893. "--max-rounds",
  1894. type=int,
  1895. default=4,
  1896. help="最大轮数,默认: 4"
  1897. )
  1898. parser.add_argument(
  1899. "--sug-threshold",
  1900. type=float,
  1901. default=0.7,
  1902. help="suggestion阈值,默认: 0.7"
  1903. )
  1904. parser.add_argument(
  1905. "--visualize",
  1906. action="store_true",
  1907. default=True,
  1908. help="运行完成后自动生成可视化HTML"
  1909. )
  1910. args = parser.parse_args()
  1911. asyncio.run(main(args.input_dir, max_rounds=args.max_rounds, sug_threshold=args.sug_threshold, visualize=args.visualize))