sug_v6_1_2_128.py 136 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880
  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, Optional
  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.02
  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. source_word_details: list[dict] = Field(default_factory=list) # 词及其得分信息 [{"domain_index":0,"segment_type":"","words":[{"text":"","score":0.0}]}]
  65. source_scores: list[float] = Field(default_factory=list) # 来源词的分数列表(扁平化)
  66. max_source_score: float | None = None # 来源词的最高分
  67. is_above_source_scores: bool = False # 组合得分是否超过所有来源词
  68. # ============================================================================
  69. # 旧架构数据模型(保留但不使用)
  70. # ============================================================================
  71. # class Word(BaseModel):
  72. # """词(旧版)- v120使用,v121不再使用"""
  73. # text: str
  74. # score_with_o: float = 0.0 # 与原始问题的评分
  75. # from_o: str = "" # 原始问题
  76. class Word(BaseModel):
  77. """词"""
  78. text: str
  79. score_with_o: float = 0.0 # 与原始问题的评分
  80. from_o: str = "" # 原始问题
  81. class QFromQ(BaseModel):
  82. """Q来源信息(用于Sug中记录)"""
  83. text: str
  84. score_with_o: float = 0.0
  85. class Q(BaseModel):
  86. """查询"""
  87. text: str
  88. score_with_o: float = 0.0 # 与原始问题的评分
  89. reason: str = "" # 评分理由
  90. from_source: str = "" # v120: seg/sug/add; v121新增: segment/domain_comb/sug
  91. type_label: str = "" # v121新增:域类型标签(仅用于domain_comb来源)
  92. domain_index: int = -1 # v121新增:域索引(word来源时有效,-1表示无域)
  93. domain_type: str = "" # v121新增:域类型(word来源时表示所属segment的type,如"中心名词")
  94. class Sug(BaseModel):
  95. """建议词"""
  96. text: str
  97. score_with_o: float = 0.0 # 与原始问题的评分
  98. reason: str = "" # 评分理由
  99. from_q: QFromQ | None = None # 来自的q
  100. class Seed(BaseModel):
  101. """种子(旧版)- v120使用,v121不再使用"""
  102. text: str
  103. added_words: list[str] = Field(default_factory=list) # 已经增加的words
  104. from_type: str = "" # seg/sug/add
  105. score_with_o: float = 0.0 # 与原始问题的评分
  106. class Post(BaseModel):
  107. """帖子"""
  108. title: str = ""
  109. body_text: str = ""
  110. type: str = "normal" # video/normal
  111. images: list[str] = Field(default_factory=list) # 图片url列表,第一张为封面
  112. video: str = "" # 视频url
  113. interact_info: dict = Field(default_factory=dict) # 互动信息
  114. note_id: str = ""
  115. note_url: str = ""
  116. class Search(Sug):
  117. """搜索结果(继承Sug)"""
  118. post_list: list[Post] = Field(default_factory=list) # 搜索得到的帖子列表
  119. class RunContext(BaseModel):
  120. """运行上下文"""
  121. version: str
  122. input_files: dict[str, str]
  123. c: str # 原始需求
  124. o: str # 原始问题
  125. log_url: str
  126. log_dir: str
  127. # v121新增:语义分段结果
  128. segments: list[dict] = Field(default_factory=list) # Round 0的语义分段结果
  129. # 每轮的数据
  130. rounds: list[dict] = Field(default_factory=list) # 每轮的详细数据
  131. # 最终结果
  132. final_output: str | None = None
  133. # 评估缓存:避免重复评估相同文本
  134. evaluation_cache: dict[str, tuple[float, str]] = Field(default_factory=dict)
  135. # key: 文本, value: (score, reason)
  136. # 历史词/组合得分追踪(用于Round 2+计算系数)
  137. word_score_history: dict[str, float] = Field(default_factory=dict)
  138. # key: 词/组合文本, value: 最终得分
  139. # ============================================================================
  140. # Agent 定义
  141. # ============================================================================
  142. # ============================================================================
  143. # v121 新增 Agent
  144. # ============================================================================
  145. # Agent: 语义分段专家 (Prompt1)
  146. class SemanticSegment(BaseModel):
  147. """单个语义片段"""
  148. segment_text: str = Field(..., description="片段文本")
  149. segment_type: str = Field(..., description="语义类型(疑问标记/核心动作/修饰短语/中心名词/逻辑连接)")
  150. reasoning: str = Field(..., description="分段理由")
  151. class SemanticSegmentation(BaseModel):
  152. """语义分段结果"""
  153. segments: list[SemanticSegment] = Field(..., description="语义片段列表")
  154. overall_reasoning: str = Field(..., description="整体分段思路")
  155. semantic_segmentation_instructions = """
  156. 你是语义分段专家。给定一个搜索query,将其拆分成不同语义类型的片段。
  157. ## 语义类型定义
  158. 1. **疑问引导**:如何、怎么、什么、哪里等疑问词
  159. 2. **核心动作**:关键动词,如获取、制作、拍摄、寻找等
  160. 3. **修饰短语**:形容词、副词等修饰成分
  161. 4. **中心名词**:核心名词
  162. 5. **逻辑连接**:并且、或者、以及等连接词(较少出现)
  163. ## 分段原则
  164. 1. **语义完整性**:每个片段应该是一个完整的语义单元
  165. 2. **类型互斥**:每个片段只能属于一种类型
  166. 3. **保留原文**:片段文本必须保留原query中的字符,不得改写
  167. 4. **顺序保持**:片段顺序应与原query一致
  168. ## 输出要求
  169. - segments: 片段列表
  170. - segment_text: 片段文本(必须来自原query)
  171. - segment_type: 语义类型(从5种类型中选择)
  172. - reasoning: 为什么这样分段
  173. - overall_reasoning: 整体分段思路
  174. ## JSON输出规范
  175. 1. **格式要求**:必须输出标准JSON格式
  176. 2. **引号规范**:字符串中如需表达引用,使用书名号《》或「」,不要使用英文引号或中文引号""
  177. """.strip()
  178. semantic_segmenter = Agent[None](
  179. name="语义分段专家",
  180. instructions=semantic_segmentation_instructions,
  181. model=get_model(MODEL_NAME),
  182. output_type=SemanticSegmentation,
  183. )
  184. # ============================================================================
  185. # v120 保留 Agent
  186. # ============================================================================
  187. # Agent 1: 分词专家(v121用于Round 0拆词)
  188. class WordSegmentation(BaseModel):
  189. """分词结果"""
  190. words: list[str] = Field(..., description="分词结果列表")
  191. reasoning: str = Field(..., description="分词理由")
  192. word_segmentation_instructions = """
  193. 你是分词专家。给定一个query,将其拆分成有意义的最小单元。
  194. ## 分词原则
  195. 1. 保留有搜索意义的词汇
  196. 2. 拆分成独立的概念
  197. 3. 保留专业术语的完整性
  198. 4. 去除虚词(的、吗、呢等),但保留疑问词(如何、为什么、怎样等)
  199. ## 输出要求
  200. 返回分词列表和分词理由。
  201. """.strip()
  202. word_segmenter = Agent[None](
  203. name="分词专家",
  204. instructions=word_segmentation_instructions,
  205. model=get_model(MODEL_NAME),
  206. output_type=WordSegmentation,
  207. )
  208. # Agent 2: 动机维度评估专家 + 品类维度评估专家(两阶段评估)
  209. # 动机评估的嵌套模型
  210. class CoreMotivationExtraction(BaseModel):
  211. """核心动机提取"""
  212. 简要说明核心动机: str = Field(..., description="核心动机说明")
  213. class MotivationEvaluation(BaseModel):
  214. """动机维度评估"""
  215. 原始问题核心动机提取: CoreMotivationExtraction = Field(..., description="原始问题核心动机提取")
  216. 动机维度得分: float = Field(..., description="动机维度得分 -1~1")
  217. 简要说明动机维度相关度理由: str = Field(..., description="动机维度相关度理由")
  218. 得分为零的原因: Optional[Literal["原始问题无动机", "sug词条无动机", "动机不匹配", "不适用"]] = Field(None, description="当得分为0时的原因分类(可选,仅SUG评估使用)")
  219. class CategoryEvaluation(BaseModel):
  220. """品类维度评估"""
  221. 品类维度得分: float = Field(..., description="品类维度得分 -1~1")
  222. 简要说明品类维度相关度理由: str = Field(..., description="品类维度相关度理由")
  223. class ExtensionWordEvaluation(BaseModel):
  224. """延伸词评估"""
  225. 延伸词得分: float = Field(..., ge=-1, le=1, description="延伸词得分 -1~1")
  226. 简要说明延伸词维度相关度理由: str = Field(..., description="延伸词维度相关度理由")
  227. # 动机评估 prompt(统一版本)
  228. motivation_evaluation_instructions = """
  229. # 角色
  230. 你是**专业的动机意图评估专家**。
  231. 任务:判断<平台sug词条>与<原始问题>的**动机意图匹配度**,给出**-1到1之间**的数值评分。
  232. ---
  233. # 输入信息
  234. 你将接收到以下输入:
  235. - **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
  236. - **<平台sug词条>**:待评估的词条,可能是单个或多个作用域的组合
  237. ---
  238. # 核心约束
  239. ## 维度独立性声明
  240. 【严格约束】本评估**仅评估动机意图维度**:
  241. - **只评估** 用户"想要做什么",即原始问题的行为意图和目的
  242. - 核心是 **动词**:获取、学习、拍摄、制作、寻找等
  243. - 包括:核心动作 + 使用场景 + 最终目的
  244. - **评估重点**:动作本身及其语义方向
  245. **禁止使用"主题相关"作为评分依据**:评分理由中不得出现"主题"、"内容"、"话题"等词
  246. ---
  247. # 作用域与动作意图
  248. ## 什么是作用域?
  249. **作用域 = 动机层 + 对象层 + 场景层**
  250. ## 动作意图的识别
  251. ### 方法1: 显性动词直接提取
  252. 当原始问题明确包含动词时,直接提取
  253. 示例:
  254. "如何获取素材" → 核心动机 = "获取"
  255. "寻找拍摄技巧" → 核心动机 = "寻找"(或"学习")
  256. "制作视频教程" → 核心动机 = "制作"
  257. ### 方法2: 隐性动词语义推理
  258. 当原始问题没有显性动词时,需要结合上下文推理
  259. 如果原始问题是纯名词短语,无任何动作线索:
  260. → 核心动机 = 无法识别
  261. → 在此情况下,动机维度得分应为 0。
  262. 示例:
  263. "摄影" → 无法识别动机,动机维度得分 = 0
  264. "川西风光" → 无法识别动机,动机维度得分 = 0
  265. ---
  266. # 部分作用域的处理
  267. ## 情况1:sug词条是原始问题的部分作用域
  268. 当sug词条只包含原始问题的部分作用域时,需要判断:
  269. 1. sug词条是否包含动作意图
  270. 2. 如果包含,动作是否匹配
  271. **示例**:
  272. ```
  273. 原始问题:"川西旅行行程规划"
  274. - 完整作用域:规划(动作)+ 旅行行程(对象)+ 川西(场景)
  275. Sug词条:"川西旅行"
  276. - 包含作用域:旅行(部分对象)+ 川西(场景)
  277. - 缺失作用域:规划(动作)
  278. - 动作意图评分:0(无动作意图)
  279. ```
  280. **评分原则**:
  281. - 如果sug词条缺失动机层(动作) → 动作意图得分 = 0
  282. - 如果sug词条包含动机层 → 按动作匹配度评分
  283. ---
  284. # 评分标准
  285. ## 【正向匹配】
  286. ### +0.9~1.0:核心动作完全一致
  287. **示例**:
  288. - "规划旅行行程" vs "安排旅行路线" → 0.98
  289. - 规划≈安排,语义完全一致
  290. - "获取素材" vs "下载素材" → 0.97
  291. - 获取≈下载,语义完全一致
  292. - 特殊规则: 如果sug词的核心动作是原始问题动作的**具体化子集**,也判定为完全一致
  293. 例: 原始问题"扣除猫咪主体的方法" vs sug词"扣除猫咪眼睛的方法"(子集但目的一致
  294. **注意**:此处不考虑对象和场景是否一致,只看动作本身
  295. ###+0.75~0.95: 核心动作语义相近或为同义表达
  296. - 例: 原始问题"如何获取素材" vs sug词"如何下载素材"
  297. - 同义词对: 获取≈下载≈寻找, 技巧≈方法≈教程≈攻略
  298. ### +0.50~0.75:动作意图相关
  299. **判定标准**:
  300. - 动作是实现原始意图的相关路径
  301. - 或动作是原始意图的前置/后置步骤
  302. **示例**:
  303. - "获取素材" vs "管理素材" → 0.65
  304. - 管理是获取后的相关步骤
  305. - "规划行程" vs "预订酒店" → 0.60
  306. - 预订是规划的具体实施步骤
  307. ### +0.25~0.50:动作意图弱相关
  308. **判定标准**:
  309. - 动作在同一大类但方向不同
  310. - 或动作有间接关联
  311. **示例**:
  312. - "学习摄影技巧" vs "欣赏摄影作品" → 0.35
  313. - 都与摄影有关,但学习≠欣赏
  314. - "规划旅行" vs "回忆旅行" → 0.30
  315. - 都与旅行有关,但方向不同
  316. ---
  317. ## 【中性/无关】
  318. ### 0:无动作意图或动作完全无关
  319. **适用场景**:
  320. 1. 原始问题或sug词条无法识别动作
  321. 2. 两者动作意图完全无关
  322. **示例**:
  323. - "如何获取素材" vs "摄影器材" → 0
  324. - sug词条无动作意图
  325. - "川西风光" vs "风光摄影作品" → 0
  326. - 原始问题无动作意图
  327. **理由模板**:
  328. - "sug词条无明确动作意图,无法评估动作匹配度"
  329. - "原始问题无明确动作意图,动作维度得分为0"
  330. ---
  331. ## 【负向偏离】
  332. ### -0.2~-0.05:动作方向轻度偏离
  333. **示例**:
  334. - "学习摄影技巧" vs "销售摄影课程" → -0.10
  335. - 学习 vs 销售,方向有偏差
  336. ### -0.5~-0.25:动作意图明显冲突
  337. **示例**:
  338. - "获取免费素材" vs "购买素材" → -0.35
  339. - 获取免费 vs 购买,明显冲突
  340. ### -1.0~-0.55:动作意图完全相反
  341. **示例**:
  342. - "下载素材" vs "上传素材" → -0.70
  343. - 下载 vs 上传,方向完全相反
  344. ---
  345. ## 得分为零的原因(语义判断)
  346. 当动机维度得分为 0 时,需要在 `得分为零的原因` 字段中选择以下之一:
  347. - **"原始问题无动机"**:原始问题是纯名词短语,无法识别任何动作意图
  348. - **"sug词条无动机"**:sug词条中不包含任何动作意图
  349. - **"动机不匹配"**:双方都有动作,但完全无关联
  350. - **"不适用"**:得分不为零时使用此默认值
  351. ---
  352. # 输出格式
  353. 输出结果必须为一个 **JSON 格式**,包含以下内容:
  354. ```json
  355. {
  356. "原始问题核心动机提取": {
  357. "简要说明核心动机": ""
  358. },
  359. "动机维度得分": "-1到1之间的小数",
  360. "简要说明动机维度相关度理由": "评估该sug词条与原始问题动机匹配程度的理由,包含作用域覆盖情况",
  361. "得分为零的原因": "原始问题无动机/sug词条无动机/动机不匹配/不适用"
  362. }
  363. ```
  364. **输出约束(非常重要)**:
  365. 1. **字符串长度限制**:\"简要说明动机维度相关度理由\"字段必须控制在**150字以内**
  366. 2. **JSON格式规范**:必须生成完整的JSON格式,确保字符串用双引号包裹且正确闭合
  367. 3. **引号使用**:字符串中如需表达引用,请使用《》或「」代替单引号或双引号
  368. ---
  369. # 核心原则总结
  370. 1. **只评估动作**:完全聚焦于动作意图,不管对象和场景
  371. 2. **作用域识别**:识别作用域但只评估动机层
  372. 3. **严格标准一致性**:对所有用例使用相同的评估标准,避免评分飘移
  373. 4. **理由纯粹**:评分理由只能谈动作,不能谈对象、场景、主题
  374. """.strip()
  375. # 品类评估 prompt
  376. category_evaluation_instructions = """
  377. # 角色
  378. 你是**专业的内容主体评估专家**。
  379. 任务:判断<平台sug词条>与<原始问题>的**内容主体匹配度**,给出**-1到1之间**的数值评分。
  380. ---
  381. # 输入信息
  382. - **<原始问题>**:用户的完整需求描述
  383. - **<平台sug词条>**:待评估的词条,可能是单个或多个作用域的组合
  384. ---
  385. # 核心约束
  386. ## 维度独立性声明
  387. 【严格约束】本评估**仅评估内容主体维度**:
  388. - **只评估**:名词主体 + 限定词(地域、时间、场景、质量等)
  389. - **完全忽略**:动作、意图、目的
  390. - **评估重点**:内容本身的主题和属性
  391. ---
  392. # 作用域与内容主体
  393. ## 什么是作用域?
  394. **作用域 = 动机层 + 对象层 + 场景层**
  395. 在Prompt2中:
  396. - **动机层(动作)完全忽略**
  397. - **只评估对象层 + 场景层(限定词)**
  398. ## 内容主体的构成
  399. **内容主体 = 核心名词 + 限定词**
  400. ---
  401. # 作用域覆盖度评估
  402. ## 核心原则:越完整越高分
  403. **完整性公式**:
  404. ```
  405. 作用域覆盖度 = sug词条包含的作用域元素 / 原始问题的作用域元素总数
  406. ```
  407. **评分影响**:
  408. - 覆盖度100% → 基础高分(0.9+)
  409. - 覆盖度50-99% → 中高分(0.6-0.9)
  410. - 覆盖度<50% → 中低分(0.3-0.6)
  411. - 覆盖度=0 → 低分或0分
  412. ---
  413. ## 部分作用域的处理
  414. ### 情况1:sug词条包含原始问题的所有对象层和场景层元素
  415. **评分**:0.95-1.0
  416. **示例**:
  417. ```
  418. 原始问题:"川西秋季风光摄影素材"
  419. - 对象层:摄影素材
  420. - 场景层:川西 + 秋季 + 风光
  421. Sug词条:"川西秋季风光摄影作品"
  422. - 对象层:摄影作品(≈素材)
  423. - 场景层:川西 + 秋季 + 风光
  424. - 覆盖度:100%
  425. - 评分:0.98
  426. ```
  427. ### 情况2:sug词条包含部分场景层元素
  428. **评分**:根据覆盖比例
  429. **示例**:
  430. ```
  431. 原始问题:"川西秋季风光摄影素材"
  432. - 对象层:摄影素材
  433. - 场景层:川西 + 秋季 + 风光(3个元素)
  434. Sug词条:"川西风光摄影素材"
  435. - 对象层:摄影素材 ✓
  436. - 场景层:川西 + 风光(2个元素)
  437. - 覆盖度:(1+2)/(1+3) = 75%
  438. - 评分:0.85
  439. ```
  440. ### 情况3:sug词条只包含对象层,无场景层
  441. **评分**:根据对象匹配度和覆盖度
  442. **示例**:
  443. ```
  444. 原始问题:"川西秋季风光摄影素材"
  445. - 对象层:摄影素材
  446. - 场景层:川西 + 秋季 + 风光
  447. Sug词条:"摄影素材"
  448. - 对象层:摄影素材 ✓
  449. - 场景层:无
  450. - 覆盖度:1/4 = 25%
  451. - 评分:0.50(对象匹配但缺失所有限定)
  452. ```
  453. ### 情况4:sug词条只包含场景层,无对象层
  454. **评分**:较低分
  455. **示例**:
  456. ```
  457. 原始问题:"川西旅行行程规划"
  458. - 对象层:旅行行程
  459. - 场景层:川西
  460. Sug词条:"川西"
  461. - 对象层:无
  462. - 场景层:川西 ✓
  463. - 覆盖度:1/2 = 50%
  464. - 评分:0.35(只有场景,缺失核心对象)
  465. ```
  466. ---
  467. # 评估核心原则
  468. ## 原则1:只看表面词汇,禁止联想推演
  469. **严格约束**:只能基于sug词实际包含的词汇评分
  470. **错误案例**:
  471. - ❌ "川西旅行" vs "旅行"
  472. - 错误:"旅行可以包括川西,所以有关联" → 评分0.7
  473. - 正确:"sug词只有'旅行',无'川西',缺失地域限定" → 评分0.50
  474. ---
  475. # 评分标准
  476. ## 【正向匹配】
  477. +0.95~1.0: 核心主体+所有关键限定词完全匹配
  478. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西秋季风光摄影作品"
  479. +0.75~0.95: 核心主体匹配,存在限定词匹配
  480. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西风光摄影素材"(缺失"秋季")
  481. +0.5~0.75: 核心主体匹配,无限定词匹配或合理泛化
  482. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"四川风光摄影"
  483. +0.3~0.5: 核心主体匹配,但限定词缺失或存在语义错位
  484. - 特别注意"语义身份"差异,主体词出现但上下文语义不同
  485. - 例:
  486. · "猫咪的XX行为"(猫咪是行为者)
  487. · vs "用猫咪表达XX的梗图"(猫咪是媒介)
  488. · 虽都含"猫咪+XX",但语义角色不同
  489. +0.2~0.3: 主体词不匹配,限定词缺失或错位
  490. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"风光摄影入门"
  491. +0.05~0.2: 主体词过度泛化或仅抽象相似
  492. - 例: sug词是通用概念,原始问题是特定概念
  493. sug词"每日计划"(通用)vs 原始问题 "川西旅行行程"(特定)
  494. → 评分:0.08
  495. 【中性/无关】
  496. 0: 类别明显不同,没有明确目的,无明确关联
  497. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"人像摄影素材"
  498. - 例: 原始问题无法识别动机 且 sug词也无明确动作 → 0
  499. 【负向偏离】
  500. -0.2~-0.05: 主体词或限定词存在误导性
  501. - 例: 原始问题"免费摄影素材" vs sug词"付费摄影素材库"
  502. -0.5~-0.25: 主体词明显错位或品类冲突
  503. - 例: 原始问题"风光摄影素材" vs sug词"人像修图教程"
  504. -1.0~-0.55: 完全错误的品类或有害引导
  505. - 例: 原始问题"正版素材获取" vs sug词"盗版素材下载"
  506. ---
  507. # 输出格式
  508. 输出结果必须为一个 **JSON 格式**,包含以下内容:
  509. ```json
  510. {
  511. "品类维度得分": "-1到1之间的小数",
  512. "简要说明品类维度相关度理由": "评估该sug词条与原始问题品类匹配程度的理由,包含作用域覆盖理由"
  513. }
  514. ```
  515. **输出约束(非常重要)**:
  516. 1. **字符串长度限制**:\"简要说明品类维度相关度理由\"字段必须控制在**150字以内**
  517. 2. **JSON格式规范**:必须生成完整的JSON格式,确保字符串用双引号包裹且正确闭合
  518. 3. **引号使用**:字符串中如需表达引用,请使用《》或「」代替单引号或双引号
  519. ---
  520. # 核心原则总结
  521. 1. **只看名词和限定词**:完全忽略动作和意图
  522. 2. **作用域覆盖优先**:覆盖的作用域元素越多,分数越高
  523. 3. **禁止联想推演**:只看sug词实际包含的词汇
  524. 4. **通用≠特定**:通用概念不等于特定概念
  525. 5. **理由纯粹**:评分理由只能谈对象、限定词、覆盖度
  526. """.strip()
  527. # 延伸词评估 prompt
  528. extension_word_evaluation_instructions = """
  529. # 角色
  530. 你是**专业的延伸词语义评估专家**。
  531. 任务:识别<平台sug词条>中的延伸词,评估其对原始问题作用域的补全度和目的贡献度,给出**-1到1之间**的数值评分。
  532. ---
  533. # 输入信息
  534. - **<原始问题>**:用户的完整需求描述
  535. - **<平台sug词条>**:待评估的词条,可能是单个或多个作用域的组合
  536. ---
  537. # 核心概念
  538. ## 什么是延伸词?
  539. **延伸词**:<平台sug词条>中出现,但不属于<原始问题>作用域范围内的词汇或概念
  540. **关键判断**:
  541. ```
  542. IF sug词的词汇属于原始问题的作用域元素(动机/对象/场景):
  543. → 不是延伸词,是作用域内的词
  544. IF sug词的词汇不属于原始问题的作用域:
  545. → 是延伸词
  546. → 由Prompt3评估
  547. ```
  548. ---
  549. # 作用域与延伸词
  550. ## 作用域
  551. **作用域 = 动机层 + 对象层 + 场景层**
  552. **非延伸词示例**(属于作用域内):
  553. ```
  554. 原始问题:"川西旅行行程规划"
  555. 作用域:
  556. - 动机层:规划
  557. - 对象层:旅行行程
  558. - 场景层:川西
  559. Sug词条:"川西旅行行程规划攻略"
  560. - "川西"→ 属于场景层,不是延伸词
  561. - "旅行"→ 属于对象层,不是延伸词
  562. - "行程"→ 属于对象层,不是延伸词
  563. - "规划"→ 属于动机层,不是延伸词
  564. - "攻略"→ 与"规划"同义,不是延伸词
  565. - 结论:无延伸词
  566. ```
  567. **延伸词示例**(不属于作用域):
  568. ```
  569. 原始问题:"川西旅行行程规划"
  570. 作用域:规划 + 旅行行程 + 川西
  571. Sug词条:"川西旅行行程规划住宿推荐"
  572. - "住宿推荐"→ 不属于原始问题任何作用域
  573. - 结论:延伸词 = ["住宿推荐"]
  574. ```
  575. ---
  576. # 延伸词识别方法
  577. ## 步骤1:提取原始问题的作用域元素
  578. ```
  579. 动机层:提取动作及其同义词
  580. 对象层:提取核心名词及其同义词
  581. 场景层:提取所有限定词
  582. ```
  583. ## 步骤2:提取sug词条的所有关键词
  584. ```
  585. 提取sug词条中的所有实词(名词、动词、形容词)
  586. ```
  587. ## 步骤3:匹配判定
  588. ```
  589. FOR 每个sug词条关键词:
  590. IF 该词 ∈ 原始问题作用域元素(包括同义词):
  591. → 不是延伸词
  592. ELSE:
  593. → 是延伸词
  594. ```
  595. ## 步骤4:同义词/相近词判定规则
  596. ### 不算延伸词的情况:
  597. **同义词**:
  598. - 行程 ≈ 路线 ≈ 安排 ≈ 计划
  599. - 获取 ≈ 下载 ≈ 寻找 ≈ 收集
  600. - 技巧 ≈ 方法 ≈ 教程 ≈ 攻略
  601. - 素材 ≈ 资源 ≈ 作品 ≈ 内容
  602. **具体化/细化**:
  603. - 原始:"川西旅游" + sug词:"稻城亚丁"(川西的具体地点)→ 不算延伸
  604. - 原始:"摄影技巧" + sug词:"风光摄影"(摄影的细化)→ 不算延伸
  605. - 原始:"素材" + sug词:"高清素材"(素材的质量细化)→ 不算延伸
  606. **判定逻辑**:
  607. ```
  608. IF sug词的概念是原始问题概念的子集/下位词/同义词:
  609. → 不算延伸词
  610. → 视为对原问题的细化或重述
  611. ```
  612. ---
  613. ### 算延伸词的情况:
  614. **新增维度**:原始问题未涉及的信息维度
  615. - 原始:"川西旅行" + sug词:"住宿" → 延伸词
  616. - 原始:"摄影素材" + sug词:"版权" → 延伸词
  617. **新增限定条件**:原始问题未提及的约束
  618. - 原始:"素材获取" + sug词:"免费" → 延伸词
  619. - 原始:"旅行行程" + sug词:"7天" → 延伸词
  620. **扩展主题**:相关但非原问题范围
  621. - 原始:"川西行程" + sug词:"美食推荐" → 延伸词
  622. - 原始:"摄影技巧" + sug词:"后期修图" → 延伸词
  623. **工具/方法**:原始问题未提及的具体工具
  624. - 原始:"视频剪辑" + sug词:"PR软件" → 延伸词
  625. - 原始:"图片处理" + sug词:"PS教程" → 延伸词
  626. ---
  627. # 延伸词类型与评分
  628. ## 核心评估维度:对原始问题作用域的贡献
  629. ### 维度1:作用域补全度
  630. 延伸词是否帮助sug词条更接近原始问题的完整作用域?
  631. ### 维度2:目的达成度
  632. 延伸词是否促进原始问题核心目的的达成?
  633. ---
  634. ####类型1:作用域增强型
  635. **定义**:延伸词是原始问题核心目的,或补全关键作用域
  636. **得分范围**:+0.12~+0.20
  637. **判定标准**:
  638. - 使sug词条更接近原始问题的完整需求
  639. ---
  640. ####类型2:作用域辅助型
  641. **定义**:延伸词对核心目的有辅助作用,但非必需
  642. **得分范围**:+0.05~+0.12
  643. **判定标准**:
  644. - sug词条更丰富但不改变原始需求核心
  645. ---
  646. ####类型3:作用域无关型
  647. **定义**:延伸词与核心目的无实质关联
  648. **得分**:0
  649. **示例**:
  650. - 原始:"如何拍摄风光" + 延伸词:"相机品牌排行"
  651. - 评分:0
  652. - 理由:品牌排行与拍摄技巧无关
  653. ---
  654. ####类型4:作用域稀释型(轻度负向)
  655. **定义**:延伸词稀释原始问题的聚焦度,降低内容针对性
  656. **得分范围**:-0.08~-0.18
  657. **判定标准**:
  658. - 引入无关信息,分散注意力
  659. - 降低内容的专注度和深度
  660. - 使sug词条偏离原始问题的核心
  661. **示例**:
  662. - 原始:"专业风光摄影技巧" + 延伸词:"手机拍照"
  663. - 评分:-0.12
  664. - 理由:手机拍照与专业摄影需求不符,稀释专业度
  665. - 原始:"川西深度游攻略" + 延伸词:"周边一日游"
  666. - 评分:-0.10
  667. - 理由:一日游与深度游定位冲突,稀释深度
  668. ---
  669. # 特殊情况处理
  670. ## 情况1:多个延伸词同时存在
  671. **处理方法**:分别评估每个延伸词,然后综合
  672. **综合规则**:
  673. ```
  674. 延伸词总得分 = Σ(每个延伸词得分) / 延伸词数量
  675. 考虑累积效应:
  676. - 多个增强型延伸词 → 总分可能超过单个最高分,但上限+0.25
  677. - 正负延伸词并存 → 相互抵消
  678. - 多个冲突型延伸词 → 总分下限-0.60
  679. ```
  680. **示例**:
  681. ```
  682. 原始:"川西旅行行程"
  683. Sug词条:"川西旅行行程住宿美食推荐"
  684. 延伸词识别:
  685. - "住宿推荐"→ 增强型,+0.18
  686. - "美食推荐"→ 辅助型,+0.10
  687. 总得分:(0.18 + 0.10) / 2 = 0.14
  688. ```
  689. ---
  690. ## 情况2:无延伸词
  691. **处理方法**:
  692. ```
  693. IF sug词条无延伸词:
  694. 延伸词得分 = 0
  695. 理由:"sug词条未引入延伸词,所有词汇均属于原始问题作用域范围"
  696. ```
  697. ---
  698. ## 情况3:延伸词使sug词条更接近原始问题
  699. **特殊加成**:
  700. ```
  701. IF 延伸词是原始问题隐含需求的显式化:
  702. → 额外加成 +0.05
  703. ```
  704. **示例**:
  705. ```
  706. 原始:"川西旅行" (隐含需要行程规划)
  707. Sug词条:"川西旅行行程规划"
  708. - "行程规划"可能被识别为延伸词,但它显式化了隐含需求
  709. - 给予额外加成
  710. ```
  711. ---
  712. # 输出格式
  713. 输出结果必须为一个 **JSON 格式**,包含以下内容:
  714. ```json
  715. {
  716. "延伸词得分": "-1到1之间的小数",
  717. "简要说明延伸词维度相关度理由": "评估延伸词对作用域的影响"
  718. }
  719. ```
  720. **输出约束(非常重要)**:
  721. 1. **字符串长度限制**:\"简要说明延伸词维度相关度理由\"字段必须控制在**150字以内**
  722. 2. **JSON格式规范**:必须生成完整的JSON格式,确保字符串用双引号包裹且正确闭合
  723. 3. **引号使用**:字符串中如需表达引用,请使用《》或「」代替单引号或双引号
  724. ---
  725. # 核心原则总结
  726. 1. **严格区分**:作用域内的词 ≠ 延伸词
  727. 2. **同义词/细化词不算延伸**:属于作用域范围的词由其他prompt评估
  728. 3. **作用域导向**:评估延伸词是否使sug词条更接近原始问题的完整作用域
  729. 4. **目的导向**:评估延伸词是否促进核心目的达成
  730. 5. **分类明确**:准确判定延伸词类型
  731. 6. **理由充分**:每个延伸词都要说明其对作用域和目的的影响
  732. 7. **谨慎负分**:仅在明确冲突或有害时使用负分
  733. """.strip()
  734. # 创建评估 Agent
  735. motivation_evaluator = Agent[None](
  736. name="动机维度评估专家(后续轮次)",
  737. instructions=motivation_evaluation_instructions,
  738. model=get_model(MODEL_NAME),
  739. output_type=MotivationEvaluation)
  740. category_evaluator = Agent[None](
  741. name="品类维度评估专家",
  742. instructions=category_evaluation_instructions,
  743. model=get_model(MODEL_NAME),
  744. output_type=CategoryEvaluation
  745. )
  746. extension_word_evaluator = Agent[None](
  747. name="延伸词评估专家",
  748. instructions=extension_word_evaluation_instructions,
  749. model=get_model(MODEL_NAME),
  750. output_type=ExtensionWordEvaluation,
  751. model_settings=ModelSettings(temperature=0.2)
  752. )
  753. # ============================================================================
  754. # Round 0 专用 Agent(v124新增 - 需求1)
  755. # ============================================================================
  756. # Round 0 动机评估 prompt(不含延伸词)
  757. round0_motivation_evaluation_instructions = """
  758. #角色
  759. 你是**专业的动机意图评估专家**
  760. 你的任务是:判断我给你的 <词条> 与 <原始问题> 的需求动机匹配度,给出 **-1 到 1 之间** 的数值评分。
  761. ---
  762. # 输入信息
  763. 你将接收到以下输入:
  764. - **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
  765. - **<词条>**:平台推荐的词条列表,每个词条需要单独评估。
  766. # 核心约束
  767. ## 维度独立性声明
  768. 【严格约束】本评估**仅评估动机意图维度**:
  769. - **只评估** 用户"想要做什么",即原始问题的行为意图和目的
  770. - 核心是 **动词**:获取、学习、拍摄、制作、寻找等
  771. - 包括:核心动作 + 使用场景 + 最终目的
  772. - **评估重点**:动作本身及其语义方向
  773. **禁止使用"主题相关"作为评分依据**:评分理由中不得出现"主题"、"内容"、"话题"等词
  774. ---
  775. # 作用域与动作意图
  776. ## 什么是作用域?
  777. **作用域 = 动机层 + 对象层 + 场景层**
  778. ## 动作意图的识别
  779. ### 方法1: 显性动词直接提取
  780. 当原始问题明确包含动词时,直接提取
  781. 示例:
  782. "如何获取素材" → 核心动机 = "获取"
  783. "寻找拍摄技巧" → 核心动机 = "寻找"(或"学习")
  784. "制作视频教程" → 核心动机 = "制作"
  785. ### 方法2: 隐性动词语义推理
  786. 当原始问题没有显性动词时,需要结合上下文推理
  787. 如果原始问题是纯名词短语,无任何动作线索:
  788. → 核心动机 = 无法识别
  789. → 在此情况下,动机维度得分应为 0。
  790. 示例:
  791. "摄影" → 无法识别动机,动机维度得分 = 0
  792. "川西风光" → 无法识别动机,动机维度得分 = 0
  793. ---
  794. # 部分作用域的处理
  795. ## 情况1:词条是原始问题的部分作用域
  796. 当词条只包含原始问题的部分作用域时,需要判断:
  797. 1. 词条是否包含动作意图
  798. 2. 如果包含,动作是否匹配
  799. **示例**:
  800. ```
  801. 原始问题:"川西旅行行程规划"
  802. - 完整作用域:规划(动作)+ 旅行行程(对象)+ 川西(场景)
  803. 词条:"川西旅行"
  804. - 包含作用域:旅行(部分对象)+ 川西(场景)
  805. - 缺失作用域:规划(动作)
  806. - 动作意图评分:0(无动作意图)
  807. ```
  808. **评分原则**:
  809. - 如果sug词条缺失动机层(动作) → 动作意图得分 = 0
  810. - 如果sug词条包含动机层 → 按动作匹配度评分
  811. ---
  812. #评分标准:
  813. 【正向匹配】
  814. ### +0.9~1.0:核心动作完全一致
  815. **示例**:
  816. - "规划旅行行程" vs "安排旅行路线" → 0.98
  817. - 规划≈安排,语义完全一致
  818. - "获取素材" vs "下载素材" → 0.97
  819. - 获取≈下载,语义完全一致
  820. - 特殊规则: 如果sug词的核心动作是原始问题动作的**具体化子集**,也判定为完全一致
  821. 例: 原始问题"扣除猫咪主体的方法" vs 词条"扣除猫咪眼睛的方法"(子集但目的一致
  822. **注意**:此处不考虑对象和场景是否一致,只看动作本身
  823. ###+0.75~0.90: 核心动作语义相近或为同义表达
  824. - 例: 原始问题"如何获取素材" vs 词条"如何下载素材"
  825. - 同义词对: 获取≈下载≈寻找, 技巧≈方法≈教程≈攻略
  826. ### +0.50~0.75:动作意图相关
  827. **判定标准**:
  828. - 动作是实现原始意图的相关路径
  829. - 或动作是原始意图的前置/后置步骤
  830. **示例**:
  831. - "获取素材" vs "管理素材" → 0.65
  832. - 管理是获取后的相关步骤
  833. - "规划行程" vs "预订酒店" → 0.60
  834. - 预订是规划的具体实施步骤
  835. ### +0.25~0.50:动作意图弱相关
  836. **判定标准**:
  837. - 动作在同一大类但方向不同
  838. - 或动作有间接关联
  839. **示例**:
  840. - "学习摄影技巧" vs "欣赏摄影作品" → 0.35
  841. - 都与摄影有关,但学习≠欣赏
  842. - "规划旅行" vs "回忆旅行" → 0.30
  843. - 都与旅行有关,但方向不同
  844. ---
  845. ## 【中性/无关】
  846. ### 0:无动作意图或动作完全无关
  847. **适用场景**:
  848. 1. 原始问题或词条无法识别动作
  849. 2. 两者动作意图完全无关
  850. **示例**:
  851. - "如何获取素材" vs "摄影器材" → 0
  852. - sug词条无动作意图
  853. - "川西风光" vs "风光摄影作品" → 0
  854. - 原始问题无动作意图
  855. **理由模板**:
  856. - "sug词条无明确动作意图,无法评估动作匹配度"
  857. - "原始问题无明确动作意图,动作维度得分为0"
  858. ---
  859. ## 【负向偏离】
  860. ### -0.2~-0.05:动作方向轻度偏离
  861. **示例**:
  862. - "学习摄影技巧" vs "销售摄影课程" → -0.10
  863. - 学习 vs 销售,方向有偏差
  864. ### -0.5~-0.25:动作意图明显冲突
  865. **示例**:
  866. - "获取免费素材" vs "购买素材" → -0.35
  867. - 获取免费 vs 购买,明显冲突
  868. ### -1.0~-0.55:动作意图完全相反
  869. **示例**:
  870. - "下载素材" vs "上传素材" → -0.70
  871. - 下载 vs 上传,方向完全相反
  872. ---
  873. # 输出要求
  874. 输出结果必须为一个 **JSON 格式**,包含以下内容:
  875. ```json
  876. {
  877. "原始问题核心动机提取": {
  878. "简要说明核心动机": ""
  879. },
  880. "动机维度得分": "-1到1之间的小数",
  881. "简要说明动机维度相关度理由": "评估该词条与原始问题动机匹配程度的理由"
  882. }
  883. ```
  884. #注意事项:
  885. 始终围绕动机维度:所有评估都基于"动机"维度,不偏离
  886. 核心动机必须是动词:在评估前,必须先提取原始问题的核心动机(动词),这是整个评估的基础
  887. 严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
  888. 负分使用原则:仅当词条对原始问题动机产生误导、冲突或有害引导时给予负分
  889. 零分使用原则:当词条与原始问题动机无明确关联,既不相关也不冲突时给予零分,或原始问题无法识别动机时。
  890. """.strip()
  891. # Round 0 品类评估 prompt(不含延伸词)
  892. round0_category_evaluation_instructions = """
  893. #角色
  894. 你是一个 **专业的语言专家和语义相关性评判专家**。
  895. 你的任务是:判断我给你的 <词条> 与 <原始问题> 的内容主体和限定词匹配度,给出 **-1 到 1 之间** 的数值评分。
  896. ---
  897. # 核心概念与方法论
  898. ## 评估维度
  899. 本评估系统围绕 **品类维度** 进行:
  900. # 维度独立性警告
  901. 【严格约束】本评估**只评估品类维度**,,必须遵守以下规则:
  902. 1. **只看名词和限定词**:评估时只考虑主体、限定词的匹配度
  903. 2. **完全忽略动词**:动作意图、目的等动机信息对本维度评分无影响
  904. ### 品类维度
  905. **定义:** 用户"关于什么内容",即原始问题的主题对象和限定词
  906. - 核心是 **名词+限定词**:川西秋季风光摄影素材
  907. - 包括:核心主体 + 地域限定 + 时间限定 + 质量限定等
  908. ## ⚠️ 品类评估核心原则(必读)
  909. ### 原则1:只看词条表面,禁止联想推演
  910. - 只能基于词条实际包含的词汇评分
  911. - 禁止推测"可能包含"、"可以理解为"
  912. **错误示例:**
  913. 原始问题:"川西旅行行程" vs 词条:"每日计划"
  914. - 错误 "每日计划可以包含旅行规划,所以有关联" → 这是不允许的联想
  915. - 正确: "词条只有'每日计划',无'旅行'字眼,品类不匹配" → 正确判断
  916. ### 原则2:通用概念 ≠ 特定概念
  917. - **通用**:计划、方法、技巧、素材(无领域限定)
  918. - **特定**:旅行行程、摄影技巧、烘焙方法(有明确领域)
  919. IF 词条是通用 且 原始问题是特定:
  920. → 品类不匹配 → 评分0.05~0.1
  921. 关键:通用概念不等于特定概念,不能因为"抽象上都是规划"就给分
  922. ---
  923. # 输入信息
  924. 你将接收到以下输入:
  925. - **<原始问题>**:用户的初始查询问题,代表用户的真实需求意图。
  926. - **<词条>**:平台推荐的词条列表,每个词条需要单独评估。
  927. #判定流程
  928. #评估架构
  929. 输入: <原始问题> + <词条>
  930. 【品类维度相关性判定】
  931. ├→ 步骤1: 评估<词条>与<原始问题>的内容主体和限定词匹配度
  932. └→ 输出: -1到1之间的数值 + 判定依据
  933. 相关度评估维度详解
  934. 维度2: 品类维度评估
  935. 评估对象: <词条> 与 <原始问题> 的内容主体和限定词匹配度
  936. 评分标准:
  937. 【正向匹配】
  938. +0.95~1.0: 核心主体+所有关键限定词完全匹配
  939. - 例: 原始问题"川西秋季风光摄影素材" vs 词条"川西秋季风光摄影作品"
  940. +0.75~0.95: 核心主体匹配,存在限定词匹配
  941. - 例: 原始问题"川西秋季风光摄影素材" vs 词条"川西风光摄影素材"(缺失"秋季")
  942. +0.5~0.75: 核心主体匹配,无限定词匹配或合理泛化
  943. - 例: 原始问题"川西秋季风光摄影素材" vs 词条"四川风光摄影"
  944. +0.3~0.5: 核心主体匹配,但限定词缺失或存在语义错位
  945. - 特别注意"语义身份"差异,主体词出现但上下文语义不同
  946. - 例:
  947. · "猫咪的XX行为"(猫咪是行为者)
  948. · vs "用猫咪表达XX的梗图"(猫咪是媒介)
  949. · 虽都含"猫咪+XX",但语义角色不同
  950. +0.2~0.3: 主体词不匹配,限定词缺失或错位
  951. - 例: 原始问题"川西秋季风光摄影素材" vs 词条"风光摄影入门"
  952. +0.05~0.2: 主体词过度泛化或仅抽象相似
  953. - 例: 词条是通用概念,原始问题是特定概念
  954. 词条"每日计划"(通用)vs 原始问题 "川西旅行行程"(特定)
  955. → 评分:0.08
  956. 【中性/无关】
  957. 0: 类别明显不同,没有明确目的,无明确关联
  958. - 例: 原始问题"川西秋季风光摄影素材" vs 词条"人像摄影素材"
  959. - 例: 原始问题无法识别动机 且 词条也无明确动作 → 0
  960. 【负向偏离】
  961. -0.2~-0.05: 主体词或限定词存在误导性
  962. - 例: 原始问题"免费摄影素材" vs 词条"付费摄影素材库"
  963. -0.5~-0.25: 主体词明显错位或品类冲突
  964. - 例: 原始问题"风光摄影素材" vs 词条"人像修图教程"
  965. -1.0~-0.55: 完全错误的品类或有害引导
  966. - 例: 原始问题"正版素材获取" vs 词条"盗版素材下载"
  967. ---
  968. # 输出要求
  969. 输出结果必须为一个 **JSON 格式**,包含以下内容:
  970. ```json
  971. {
  972. "品类维度得分": "-1到1之间的小数",
  973. "简要说明品类维度相关度理由": "评估该词条与原始问题品类匹配程度的理由"
  974. }
  975. ```
  976. ---
  977. #注意事项:
  978. 始终围绕品类维度:所有评估都基于"品类"维度,不偏离
  979. 严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
  980. 负分使用原则:仅当词条对原始问题品类产生误导、冲突或有害引导时给予负分
  981. 零分使用原则:当词条与原始问题品类无明确关联,既不相关也不冲突时给予零分
  982. """.strip()
  983. # 创建 Round 0 评估 Agent
  984. round0_motivation_evaluator = Agent[None](
  985. name="Round 0动机维度评估专家",
  986. instructions=round0_motivation_evaluation_instructions,
  987. model=get_model(MODEL_NAME),
  988. output_type=MotivationEvaluation,
  989. model_settings=ModelSettings(temperature=0.2)
  990. )
  991. round0_category_evaluator = Agent[None](
  992. name="Round 0品类维度评估专家",
  993. instructions=round0_category_evaluation_instructions,
  994. model=get_model(MODEL_NAME),
  995. output_type=CategoryEvaluation,
  996. model_settings=ModelSettings(temperature=0.2)
  997. )
  998. # ============================================================================
  999. # 域内/域间 专用 Agent(v124新增 - 需求2&3)
  1000. # ============================================================================
  1001. # 域内/域间 动机评估 prompt(不含延伸词)
  1002. scope_motivation_evaluation_instructions = """
  1003. # 角色
  1004. 你是**专业的动机意图评估专家**。
  1005. 任务:判断<词条>与<同一作用域词条>的**动机意图匹配度**,给出**-1到1之间**的数值评分。
  1006. ---
  1007. # 输入信息
  1008. 你将接收到以下输入:
  1009. **<同一作用域词条>**:用户的初始查询问题,代表用户的真实需求意图。
  1010. - **<词条>**:平台推荐的词条列表,每个词条需要单独评估。
  1011. ---
  1012. # 评估架构
  1013. 输入: <同一作用域词条> + <词条>
  1014. 【动机维度相关性判定】
  1015. ├→ 步骤1: 评估<词条>与<同一作用域词条>的需求动机匹配度
  1016. └→ 输出: -1到1之间的数值 + 判定依据
  1017. # 核心约束
  1018. ## 维度独立性声明
  1019. 【严格约束】本评估**仅评估动机意图维度**:
  1020. - **只评估** 用户"想要做什么",即原始问题的行为意图和目的
  1021. - 核心是 **动词**:获取、学习、拍摄、制作、寻找等
  1022. - 包括:核心动作 + 使用场景 + 最终目的
  1023. - **评估重点**:动作本身及其语义方向
  1024. **禁止使用"主题相关"作为评分依据**:评分理由中不得出现"主题"、"内容"、"话题"等词
  1025. ---
  1026. # 作用域与动作意图
  1027. ## 什么是作用域?
  1028. **作用域 = 动机层 + 对象层 + 场景层**
  1029. 当前任务:
  1030. - **只提取动机层**:动作意图(获取、学习、规划、拍摄等)
  1031. ## 动作意图的识别
  1032. ### 1. 动机维度
  1033. **定义:** 用户"想要做什么",即原始问题的行为意图和目的
  1034. - 核心是 **动词**:获取、学习、拍摄、制作、寻找等
  1035. - 包括:核心动作 + 使用场景 + 最终目的
  1036. ### 方法1: 显性动词直接提取
  1037. 当原始问题明确包含动词时,直接提取
  1038. 示例:
  1039. "如何获取素材" → 核心动机 = "获取"
  1040. "寻找拍摄技巧" → 核心动机 = "寻找"(或"学习")
  1041. "制作视频教程" → 核心动机 = "制作"
  1042. ### 方法2: 隐性动词语义推理
  1043. 当原始问题没有显性动词时,需要结合上下文推理
  1044. ---
  1045. # 评分标准
  1046. ## 【正向匹配】
  1047. ### +0.9~1.0:核心动作完全一致
  1048. **示例**:
  1049. - "规划旅行行程" vs "安排旅行路线" → 0.98
  1050. - 规划≈安排,语义完全一致
  1051. - "获取素材" vs "下载素材" → 0.97
  1052. - 获取≈下载,语义完全一致
  1053. - 特殊规则: 如果sug词的核心动作是原始问题动作的**具体化子集**,也判定为完全一致
  1054. 例: 原始问题"扣除猫咪主体的方法" vs sug词"扣除猫咪眼睛的方法"(子集但目的一致
  1055. **注意**:此处不考虑对象和场景是否一致,只看动作本身
  1056. ###+0.75~0.95: 核心动作语义相近或为同义表达
  1057. - 例: 原始问题"如何获取素材" vs sug词"如何下载素材"
  1058. - 同义词对: 获取≈下载≈寻找, 技巧≈方法≈教程≈攻略
  1059. ### +0.50~0.75:动作意图相关
  1060. **判定标准**:
  1061. - 动作是实现原始意图的相关路径
  1062. - 或动作是原始意图的前置/后置步骤
  1063. **示例**:
  1064. - "获取素材" vs "管理素材" → 0.65
  1065. - 管理是获取后的相关步骤
  1066. - "规划行程" vs "预订酒店" → 0.60
  1067. - 预订是规划的具体实施步骤
  1068. ### +0.25~0.50:动作意图弱相关
  1069. **判定标准**:
  1070. - 动作在同一大类但方向不同
  1071. - 或动作有间接关联
  1072. **示例**:
  1073. - "学习摄影技巧" vs "欣赏摄影作品" → 0.35
  1074. - 都与摄影有关,但学习≠欣赏
  1075. - "规划旅行" vs "回忆旅行" → 0.30
  1076. - 都与旅行有关,但方向不同
  1077. ---
  1078. ## 【中性/无关】
  1079. ### 0:无动作意图或动作完全无关
  1080. **适用场景**:
  1081. 1. 原始问题或词条无法识别动作
  1082. 2. 两者动作意图完全无关
  1083. **示例**:
  1084. - "如何获取素材" vs "摄影器材" → 0
  1085. - 词条无动作意图
  1086. - "川西风光" vs "风光摄影作品" → 0
  1087. - 原始问题无动作意图
  1088. **理由模板**:
  1089. - "词条无明确动作意图,无法评估动作匹配度"
  1090. - "原始问题无明确动作意图,动作维度得分为0"
  1091. ---
  1092. ## 【负向偏离】
  1093. ### -0.2~-0.05:动作方向轻度偏离
  1094. **示例**:
  1095. - "学习摄影技巧" vs "销售摄影课程" → -0.10
  1096. - 学习 vs 销售,方向有偏差
  1097. ### -0.5~-0.25:动作意图明显冲突
  1098. **示例**:
  1099. - "获取免费素材" vs "购买素材" → -0.35
  1100. - 获取免费 vs 购买,明显冲突
  1101. ### -1.0~-0.55:动作意图完全相反
  1102. **示例**:
  1103. - "下载素材" vs "上传素材" → -0.70
  1104. - 下载 vs 上传,方向完全相反
  1105. ---
  1106. # 输出格式
  1107. 输出结果必须为一个 **JSON 格式**,包含以下内容:
  1108. ```json
  1109. {
  1110. "原始问题核心动机提取": {
  1111. "简要说明核心动机": ""
  1112. },
  1113. "动机维度得分": "-1到1之间的小数",
  1114. "简要说明动机维度相关度理由": "评估该词条与该条作用域匹配程度的理由",
  1115. "得分为零的原因": "原始问题无动机/sug词条无动机/动机不匹配/不适用"
  1116. }
  1117. ```
  1118. ---
  1119. # 核心原则总结
  1120. 1. **只评估动作**:完全聚焦于动作意图,不管对象和场景
  1121. 2. **作用域识别**:识别作用域但只评估动机层
  1122. 3. **严格标准一致性**:对所有用例使用相同的评估标准,避免评分飘移
  1123. 4. **理由纯粹**:评分理由只能谈动作,不能谈对象、场景、主题
  1124. """.strip()
  1125. # 域内/域间 品类评估 prompt(不含延伸词)
  1126. scope_category_evaluation_instructions = """
  1127. #角色
  1128. 你是一个 **专业的语言专家和语义相关性评判专家**。
  1129. 你的任务是:判断我给你的 <词条> 与 <同一作用域词条> 的内容主体和限定词匹配度,给出 **-1 到 1 之间** 的数值评分。
  1130. ---
  1131. # 输入信息
  1132. 你将接收到以下输入:
  1133. - **<同一作用域词条>**:用户的初始查询问题,代表用户的真实需求意图。
  1134. - **<词条>**:平台推荐的词条列表,每个词条需要单独评估。
  1135. ---
  1136. #判定流程
  1137. #评估架构
  1138. 输入: <同一作用域词条> + <词条>
  1139. 【品类维度相关性判定】
  1140. ├→ 步骤1: 评估<词条>与<同一作用域词条>的内容主体和限定词匹配度
  1141. └→ 输出: -1到1之间的数值 + 判定依据
  1142. ---
  1143. # 核心概念与方法论
  1144. ## 评估维度
  1145. 本评估系统围绕 **品类维度** 进行:
  1146. # 维度独立性警告
  1147. 【严格约束】本评估**只评估品类维度**,,必须遵守以下规则:
  1148. 1. **只看名词和限定词**:评估时只考虑主体、限定词的匹配度
  1149. 2. **完全忽略动词**:动作意图、目的等动机信息对本维度评分无影响
  1150. ### 品类维度
  1151. **定义:** 用户"关于什么内容",即原始问题的主题对象和限定词
  1152. - 核心是 **名词+限定词**:川西秋季风光摄影素材
  1153. - 包括:核心主体 + 地域限定 + 时间限定 + 质量限定等
  1154. ## ⚠️ 品类评估核心原则(必读)
  1155. ### 原则1:只看词条表面,禁止联想推演
  1156. - 只能基于sug词实际包含的词汇评分
  1157. - 禁止推测"可能包含"、"可以理解为"
  1158. **错误示例:**
  1159. 原始问题:"川西旅行行程" vs sug词:"每日计划"
  1160. - 错误 "每日计划可以包含旅行规划,所以有关联" → 这是不允许的联想
  1161. - 正确: "sug词只有'每日计划',无'旅行'字眼,品类不匹配" → 正确判断
  1162. ### 原则2:通用概念 ≠ 特定概念
  1163. - **通用**:计划、方法、技巧、素材(无领域限定)
  1164. - **特定**:旅行行程、摄影技巧、烘焙方法(有明确领域)
  1165. IF sug词是通用 且 原始问题是特定:
  1166. → 品类不匹配 → 评分0.05~0.1
  1167. 关键:通用概念不等于特定概念,不能因为"抽象上都是规划"就给分
  1168. ---
  1169. #相关度评估维度详解
  1170. ##评估对象: <词条> 与 <同一作用域词条> 的内容主体和限定词匹配度
  1171. 评分标准:
  1172. 【正向匹配】
  1173. +0.95~1.0: 核心主体+所有关键限定词完全匹配
  1174. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西秋季风光摄影作品"
  1175. +0.75~0.95: 核心主体匹配,存在限定词匹配
  1176. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"川西风光摄影素材"(缺失"秋季")
  1177. +0.5~0.75: 核心主体匹配,无限定词匹配或合理泛化
  1178. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"四川风光摄影"
  1179. +0.3~0.5: 核心主体匹配,但限定词缺失或存在语义错位
  1180. - 特别注意"语义身份"差异,主体词出现但上下文语义不同
  1181. - 例:
  1182. · "猫咪的XX行为"(猫咪是行为者)
  1183. · vs "用猫咪表达XX的梗图"(猫咪是媒介)
  1184. · 虽都含"猫咪+XX",但语义角色不同
  1185. +0.2~0.3: 主体词不匹配,限定词缺失或错位
  1186. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"风光摄影入门"
  1187. +0.05~0.2: 主体词过度泛化或仅抽象相似
  1188. - 例: sug词是通用概念,原始问题是特定概念
  1189. sug词"每日计划"(通用)vs 原始问题 "川西旅行行程"(特定)
  1190. → 评分:0.08
  1191. 【中性/无关】
  1192. 0: 类别明显不同,没有明确目的,无明确关联
  1193. - 例: 原始问题"川西秋季风光摄影素材" vs sug词"人像摄影素材"
  1194. - 例: 原始问题无法识别动机 且 sug词也无明确动作 → 0
  1195. 【负向偏离】
  1196. -0.2~-0.05: 主体词或限定词存在误导性
  1197. - 例: 原始问题"免费摄影素材" vs sug词"付费摄影素材库"
  1198. -0.5~-0.25: 主体词明显错位或品类冲突
  1199. - 例: 原始问题"风光摄影素材" vs sug词"人像修图教程"
  1200. -1.0~-0.55: 完全错误的品类或有害引导
  1201. - 例: 原始问题"正版素材获取" vs sug词"盗版素材下载"
  1202. ---
  1203. # 输出要求
  1204. 输出结果必须为一个 **JSON 格式**,包含以下内容:
  1205. ```json
  1206. {
  1207. "品类维度得分": "-1到1之间的小数",
  1208. "简要说明品类维度相关度理由": "评估该词条与同一作用域词条品类匹配程度的理由"
  1209. }
  1210. ```
  1211. ---
  1212. #注意事项:
  1213. 始终围绕品类维度:所有评估都基于"品类"维度,不偏离
  1214. 严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
  1215. 负分使用原则:仅当词条对原始问题品类产生误导、冲突或有害引导时给予负分
  1216. 零分使用原则:当词条与原始问题品类无明确关联,既不相关也不冲突时给予零分
  1217. """.strip()
  1218. # 创建域内/域间评估 Agent
  1219. scope_motivation_evaluator = Agent[None](
  1220. name="域内动机维度评估专家",
  1221. instructions=scope_motivation_evaluation_instructions,
  1222. model=get_model(MODEL_NAME),
  1223. output_type=MotivationEvaluation,
  1224. model_settings=ModelSettings(temperature=0.2)
  1225. )
  1226. scope_category_evaluator = Agent[None](
  1227. name="域内品类维度评估专家",
  1228. instructions=scope_category_evaluation_instructions,
  1229. model=get_model(MODEL_NAME),
  1230. output_type=CategoryEvaluation,
  1231. model_settings=ModelSettings(temperature=0.2)
  1232. )
  1233. # ============================================================================
  1234. # v120 保留但不使用的 Agent(v121不再使用)
  1235. # ============================================================================
  1236. # # Agent 3: 加词选择专家(旧版 - v120使用,v121不再使用)
  1237. # class WordCombination(BaseModel):
  1238. # """单个词组合"""
  1239. # selected_word: str = Field(..., description="选择的词")
  1240. # combined_query: str = Field(..., description="组合后的新query")
  1241. # reasoning: str = Field(..., description="选择理由")
  1242. # class WordSelectionTop5(BaseModel):
  1243. # """加词选择结果(Top 5)"""
  1244. # combinations: list[WordCombination] = Field(
  1245. # ...,
  1246. # description="选择的Top 5组合(不足5个则返回所有)",
  1247. # min_items=1,
  1248. # max_items=5
  1249. # )
  1250. # overall_reasoning: str = Field(..., description="整体选择思路")
  1251. # word_selection_instructions 已删除 (v121不再使用)
  1252. # word_selector = Agent[None](
  1253. # name="加词组合专家",
  1254. # instructions=word_selection_instructions,
  1255. # model=get_model(MODEL_NAME),
  1256. # output_type=WordSelectionTop5,
  1257. # model_settings=ModelSettings(temperature=0.2),
  1258. # )
  1259. # ============================================================================
  1260. # 辅助函数
  1261. # ============================================================================
  1262. # ============================================================================
  1263. # v121 新增辅助函数
  1264. # ============================================================================
  1265. def get_ordered_subsets(words: list[str], min_len: int = 1) -> list[list[str]]:
  1266. """
  1267. 生成words的所有有序子集(可跳过但不可重排)
  1268. 使用 itertools.combinations 生成索引组合,保持原始顺序
  1269. Args:
  1270. words: 词列表
  1271. min_len: 子集最小长度
  1272. Returns:
  1273. 所有可能的有序子集列表
  1274. Example:
  1275. words = ["川西", "秋季", "风光"]
  1276. 结果:
  1277. - 长度1: ["川西"], ["秋季"], ["风光"]
  1278. - 长度2: ["川西", "秋季"], ["川西", "风光"], ["秋季", "风光"]
  1279. - 长度3: ["川西", "秋季", "风光"]
  1280. 共 C(3,1) + C(3,2) + C(3,3) = 3 + 3 + 1 = 7种
  1281. """
  1282. from itertools import combinations
  1283. subsets = []
  1284. n = len(words)
  1285. # 遍历所有可能的长度(从min_len到n)
  1286. for r in range(min_len, n + 1):
  1287. # 生成长度为r的所有索引组合
  1288. for indices in combinations(range(n), r):
  1289. # 按照原始顺序提取词
  1290. subset = [words[i] for i in indices]
  1291. subsets.append(subset)
  1292. return subsets
  1293. def generate_domain_combinations(segments: list[Segment], n_domains: int) -> list[DomainCombination]:
  1294. """
  1295. 生成N域组合
  1296. 步骤:
  1297. 1. 从len(segments)个域中选择n_domains个域(组合,保持顺序)
  1298. 2. 对每个选中的域,生成其words的所有有序子集
  1299. 3. 计算笛卡尔积,生成所有可能的组合
  1300. Args:
  1301. segments: 语义片段列表
  1302. n_domains: 参与组合的域数量
  1303. Returns:
  1304. 所有可能的N域组合列表
  1305. Example:
  1306. 有4个域: [疑问标记, 核心动作, 修饰短语, 中心名词]
  1307. n_domains=2时,选择域的方式: C(4,2) = 6种
  1308. 假设选中[核心动作, 中心名词]:
  1309. - 核心动作的words: ["获取"], 子集: ["获取"]
  1310. - 中心名词的words: ["风光", "摄影", "素材"], 子集: 7种
  1311. 则该域选择下的组合数: 1 * 7 = 7种
  1312. """
  1313. from itertools import combinations, product
  1314. all_combinations = []
  1315. n = len(segments)
  1316. # 检查参数有效性
  1317. if n_domains > n or n_domains < 1:
  1318. return []
  1319. # 1. 选择n_domains个域(保持原始顺序)
  1320. for domain_indices in combinations(range(n), n_domains):
  1321. selected_segments = [segments[i] for i in domain_indices]
  1322. # 新增:如果所有域都只有1个词,跳过(单段落单词不组合)
  1323. if all(len(seg.words) == 1 for seg in selected_segments):
  1324. continue
  1325. # 2. 为每个选中的域生成其words的所有有序子集
  1326. domain_subsets = []
  1327. for seg in selected_segments:
  1328. if len(seg.words) == 0:
  1329. # 如果某个域没有词,跳过该域组合
  1330. domain_subsets = []
  1331. break
  1332. subsets = get_ordered_subsets(seg.words, min_len=1)
  1333. domain_subsets.append(subsets)
  1334. # 如果某个域没有词,跳过
  1335. if len(domain_subsets) != n_domains:
  1336. continue
  1337. # 3. 计算笛卡尔积
  1338. for word_combination in product(*domain_subsets):
  1339. # word_combination 是一个tuple,每个元素是一个词列表
  1340. # 例如: (["获取"], ["风光", "摄影"])
  1341. # 计算总词数
  1342. total_words = sum(len(words) for words in word_combination)
  1343. # 如果总词数<=1,跳过(组词必须大于1个词)
  1344. if total_words <= 1:
  1345. continue
  1346. # 将所有词连接成一个字符串
  1347. combined_text = "".join(["".join(words) for words in word_combination])
  1348. # 生成类型标签
  1349. type_labels = [selected_segments[i].type for i in range(n_domains)]
  1350. type_label = "[" + "+".join(type_labels) + "]"
  1351. # 创建DomainCombination对象
  1352. comb = DomainCombination(
  1353. text=combined_text,
  1354. domains=list(domain_indices),
  1355. type_label=type_label,
  1356. source_words=[list(words) for words in word_combination], # 保存来源词
  1357. from_segments=[seg.text for seg in selected_segments]
  1358. )
  1359. all_combinations.append(comb)
  1360. return all_combinations
  1361. def extract_words_from_segments(segments: list[Segment]) -> list[Q]:
  1362. """
  1363. 从 segments 中提取所有 words,转换为 Q 对象列表
  1364. 用于 Round 1 的输入:将 Round 0 的 words 转换为可用于请求SUG的 query 列表
  1365. Args:
  1366. segments: Round 0 的语义片段列表
  1367. Returns:
  1368. list[Q]: word 列表,每个 word 作为一个 Q 对象
  1369. """
  1370. q_list = []
  1371. for seg_idx, segment in enumerate(segments):
  1372. for word in segment.words:
  1373. # 从 segment.word_scores 获取该 word 的评分
  1374. word_score = segment.word_scores.get(word, 0.0)
  1375. word_reason = segment.word_reasons.get(word, "")
  1376. # 创建 Q 对象
  1377. q = Q(
  1378. text=word,
  1379. score_with_o=word_score,
  1380. reason=word_reason,
  1381. from_source="word", # 标记来源为 word
  1382. type_label=f"[{segment.type}]", # 保留域信息
  1383. domain_index=seg_idx, # 添加域索引
  1384. domain_type=segment.type # 添加域类型(如"中心名词"、"核心动作")
  1385. )
  1386. q_list.append(q)
  1387. return q_list
  1388. # ============================================================================
  1389. # v120 保留辅助函数
  1390. # ============================================================================
  1391. def calculate_final_score(
  1392. motivation_score: float,
  1393. category_score: float,
  1394. extension_score: float,
  1395. zero_reason: Optional[str],
  1396. extension_reason: str = ""
  1397. ) -> tuple[float, str]:
  1398. """
  1399. 三维评估综合打分
  1400. 实现动态权重分配:
  1401. - 情况1:标准情况 → 动机50% + 品类40% + 延伸词10%
  1402. - 情况2:原始问题无动机 → 品类70% + 延伸词30%
  1403. - 情况3:sug词条无动机 → 品类80% + 延伸词20%
  1404. - 情况4:无延伸词 → 动机70% + 品类30%
  1405. - 规则3:负分传导 → 核心维度严重负向时上限为0
  1406. - 规则4:完美匹配加成 → 双维度≥0.95时加成+0.10
  1407. Args:
  1408. motivation_score: 动机维度得分 -1~1
  1409. category_score: 品类维度得分 -1~1
  1410. extension_score: 延伸词得分 -1~1
  1411. zero_reason: 当motivation_score=0时的原因(可选)
  1412. extension_reason: 延伸词评估理由,用于判断是否无延伸词
  1413. Returns:
  1414. (最终得分, 规则说明)
  1415. """
  1416. # 情况2:原始问题无动作意图
  1417. if motivation_score == 0 and zero_reason == "原始问题无动机":
  1418. W1, W2, W3 = 0.0, 0.70, 0.30
  1419. base_score = category_score * W2 + extension_score * W3
  1420. rule_applied = "情况2:原始问题无动作意图,权重调整为 品类70% + 延伸词30%"
  1421. # 情况3:sug词条无动作意图(但原始问题有)
  1422. elif motivation_score == 0 and zero_reason == "sug词条无动机":
  1423. W1, W2, W3 = 0.0, 0.80, 0.20
  1424. base_score = category_score * W2 + extension_score * W3
  1425. rule_applied = "情况3:sug词条无动作意图,权重调整为 品类80% + 延伸词20%"
  1426. # 情况4:无延伸词
  1427. elif extension_score == 0:
  1428. W1, W2, W3 = 0.70, 0.30, 0.0
  1429. base_score = motivation_score * W1 + category_score * W2
  1430. rule_applied = "情况4:无延伸词,权重调整为 动机70% + 品类30%"
  1431. else:
  1432. # 情况1:标准权重
  1433. W1, W2, W3 = 0.50, 0.40, 0.10
  1434. base_score = motivation_score * W1 + category_score * W2 + extension_score * W3
  1435. rule_applied = ""
  1436. # 规则4:完美匹配加成
  1437. if motivation_score >= 0.95 and category_score >= 0.95:
  1438. base_score += 0.10
  1439. rule_applied += (" + " if rule_applied else "") + "规则4:双维度完美匹配,加成+0.10"
  1440. # 规则3:负分传导
  1441. if motivation_score <= -0.5 or category_score <= -0.5:
  1442. base_score = min(base_score, 0)
  1443. rule_applied += (" + " if rule_applied else "") + "规则3:核心维度严重负向,上限=0"
  1444. # 边界处理
  1445. final_score = max(-1.0, min(1.0, base_score))
  1446. return final_score, rule_applied
  1447. def calculate_final_score_v2(
  1448. motivation_score: float,
  1449. category_score: float
  1450. ) -> tuple[float, str]:
  1451. """
  1452. 两维评估综合打分(v124新增 - 需求1)
  1453. 用于Round 0分词评估和域内/域间评估,不含延伸词维度
  1454. 基础权重:动机70% + 品类30%
  1455. 应用规则:
  1456. - 规则A:动机高分保护机制
  1457. IF 动机维度得分 ≥ 0.8:
  1458. 品类得分即使为0或轻微负向(-0.2~0)
  1459. → 最终得分应该不低于0.7
  1460. 解释: 当目的高度一致时,品类的泛化不应导致"弱相关"
  1461. - 规则B:动机低分限制机制
  1462. IF 动机维度得分 ≤ 0.2:
  1463. 无论品类得分多高
  1464. → 最终得分不高于0.5
  1465. 解释: 目的不符时,品类匹配的价值有限
  1466. - 规则C:动机负向决定机制
  1467. IF 动机维度得分 < 0:
  1468. → 最终得分为0
  1469. 解释: 动作意图冲突时,推荐具有误导性,不应为正相关
  1470. Args:
  1471. motivation_score: 动机维度得分 -1~1
  1472. category_score: 品类维度得分 -1~1
  1473. Returns:
  1474. (最终得分, 规则说明)
  1475. """
  1476. rule_applied = ""
  1477. # 规则C:动机负向决定机制
  1478. if motivation_score < 0:
  1479. final_score = 0.0
  1480. rule_applied = "规则C:动机负向,最终得分=0"
  1481. return final_score, rule_applied
  1482. # 基础加权计算: 动机70% + 品类30%
  1483. base_score = motivation_score * 0.7 + category_score * 0.3
  1484. # 规则A:动机高分保护机制
  1485. if motivation_score >= 0.8:
  1486. if base_score < 0.7:
  1487. final_score = 0.7
  1488. rule_applied = f"规则A:动机高分保护(动机{motivation_score:.2f}≥0.8),最终得分下限=0.7"
  1489. else:
  1490. final_score = base_score
  1491. rule_applied = f"规则A:动机高分保护生效(动机{motivation_score:.2f}≥0.8),实际得分{base_score:.2f}已≥0.7"
  1492. # 规则B:动机低分限制机制
  1493. elif motivation_score <= 0.2:
  1494. if base_score > 0.5:
  1495. final_score = 0.5
  1496. rule_applied = f"规则B:动机低分限制(动机{motivation_score:.2f}≤0.2),最终得分上限=0.5"
  1497. else:
  1498. final_score = base_score
  1499. rule_applied = f"规则B:动机低分限制生效(动机{motivation_score:.2f}≤0.2),实际得分{base_score:.2f}已≤0.5"
  1500. # 无规则触发
  1501. else:
  1502. final_score = base_score
  1503. rule_applied = ""
  1504. # 边界处理
  1505. final_score = max(-1.0, min(1.0, final_score))
  1506. return final_score, rule_applied
  1507. def clean_json_string(text: str) -> str:
  1508. """清理JSON中的非法控制字符(保留 \t \n \r)"""
  1509. import re
  1510. # 移除除了 \t(09) \n(0A) \r(0D) 之外的所有控制字符
  1511. return re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', text)
  1512. def process_note_data(note: dict) -> Post:
  1513. """处理搜索接口返回的帖子数据"""
  1514. note_card = note.get("note_card", {})
  1515. image_list = note_card.get("image_list", [])
  1516. interact_info = note_card.get("interact_info", {})
  1517. user_info = note_card.get("user", {})
  1518. # ========== 调试日志 START ==========
  1519. note_id = note.get("id", "")
  1520. raw_title = note_card.get("display_title") # 不提供默认值
  1521. raw_body = note_card.get("desc")
  1522. raw_type = note_card.get("type")
  1523. # 打印原始值类型和内容
  1524. print(f"\n[DEBUG] 处理帖子 {note_id}:")
  1525. print(f" raw_title 类型: {type(raw_title).__name__}, 值: {repr(raw_title)}")
  1526. print(f" raw_body 类型: {type(raw_body).__name__}, 值: {repr(raw_body)[:100] if raw_body else repr(raw_body)}")
  1527. print(f" raw_type 类型: {type(raw_type).__name__}, 值: {repr(raw_type)}")
  1528. # 检查是否为 None
  1529. if raw_title is None:
  1530. print(f" ⚠️ WARNING: display_title 是 None!")
  1531. if raw_body is None:
  1532. print(f" ⚠️ WARNING: desc 是 None!")
  1533. if raw_type is None:
  1534. print(f" ⚠️ WARNING: type 是 None!")
  1535. # ========== 调试日志 END ==========
  1536. # 提取图片URL - 使用新的字段名 image_url
  1537. images = []
  1538. for img in image_list:
  1539. if isinstance(img, dict):
  1540. # 尝试新字段名 image_url,如果不存在则尝试旧字段名 url_default
  1541. img_url = img.get("image_url") or img.get("url_default")
  1542. if img_url:
  1543. images.append(img_url)
  1544. # 判断类型
  1545. note_type = note_card.get("type", "normal")
  1546. video_url = ""
  1547. if note_type == "video":
  1548. video_info = note_card.get("video", {})
  1549. if isinstance(video_info, dict):
  1550. # 尝试获取视频URL
  1551. video_url = video_info.get("media", {}).get("stream", {}).get("h264", [{}])[0].get("master_url", "")
  1552. return Post(
  1553. note_id=note.get("id") or "",
  1554. title=note_card.get("display_title") or "",
  1555. body_text=note_card.get("desc") or "",
  1556. type=note_type,
  1557. images=images,
  1558. video=video_url,
  1559. interact_info={
  1560. "liked_count": interact_info.get("liked_count", 0),
  1561. "collected_count": interact_info.get("collected_count", 0),
  1562. "comment_count": interact_info.get("comment_count", 0),
  1563. "shared_count": interact_info.get("shared_count", 0)
  1564. },
  1565. note_url=f"https://www.xiaohongshu.com/explore/{note.get('id', '')}"
  1566. )
  1567. async def evaluate_with_o(text: str, o: str, cache: dict[str, tuple[float, str]] | None = None) -> tuple[float, str]:
  1568. """评估文本与原始问题o的相关度
  1569. 采用两阶段评估 + 代码计算规则:
  1570. 1. 动机维度评估(权重70%)
  1571. 2. 品类维度评估(权重30%)
  1572. 3. 应用规则A/B/C调整得分
  1573. Args:
  1574. text: 待评估的文本
  1575. o: 原始问题
  1576. cache: 评估缓存(可选),用于避免重复评估
  1577. Returns:
  1578. tuple[float, str]: (最终相关度分数, 综合评估理由)
  1579. """
  1580. # 检查缓存
  1581. if cache is not None and text in cache:
  1582. cached_score, cached_reason = cache[text]
  1583. print(f" ⚡ 缓存命中: {text} -> {cached_score:.2f}")
  1584. return cached_score, cached_reason
  1585. # 准备输入
  1586. eval_input = f"""
  1587. <原始问题>
  1588. {o}
  1589. </原始问题>
  1590. <平台sug词条>
  1591. {text}
  1592. </平台sug词条>
  1593. 请评估平台sug词条与原始问题的匹配度。
  1594. """
  1595. # 添加重试机制
  1596. max_retries = 2
  1597. last_error = None
  1598. for attempt in range(max_retries):
  1599. try:
  1600. # 并发调用三个评估器
  1601. motivation_task = Runner.run(motivation_evaluator, eval_input)
  1602. category_task = Runner.run(category_evaluator, eval_input)
  1603. extension_task = Runner.run(extension_word_evaluator, eval_input)
  1604. motivation_result, category_result, extension_result = await asyncio.gather(
  1605. motivation_task,
  1606. category_task,
  1607. extension_task
  1608. )
  1609. # 获取评估结果
  1610. motivation_eval: MotivationEvaluation = motivation_result.final_output
  1611. category_eval: CategoryEvaluation = category_result.final_output
  1612. extension_eval: ExtensionWordEvaluation = extension_result.final_output
  1613. # 提取得分
  1614. motivation_score = motivation_eval.动机维度得分
  1615. category_score = category_eval.品类维度得分
  1616. extension_score = extension_eval.延伸词得分
  1617. zero_reason = motivation_eval.得分为零的原因
  1618. # 应用规则计算最终得分
  1619. final_score, rule_applied = calculate_final_score(
  1620. motivation_score, category_score, extension_score, zero_reason,
  1621. extension_eval.简要说明延伸词维度相关度理由
  1622. )
  1623. # 组合评估理由
  1624. core_motivation = motivation_eval.原始问题核心动机提取.简要说明核心动机
  1625. motivation_reason = motivation_eval.简要说明动机维度相关度理由
  1626. category_reason = category_eval.简要说明品类维度相关度理由
  1627. extension_reason = extension_eval.简要说明延伸词维度相关度理由
  1628. combined_reason = (
  1629. f'【评估对象】词条"{text}" vs 原始问题"{o}"\n'
  1630. f"【核心动机】{core_motivation}\n"
  1631. f"【动机维度 {motivation_score:.2f}】{motivation_reason}\n"
  1632. f"【品类维度 {category_score:.2f}】{category_reason}\n"
  1633. f"【延伸词维度 {extension_score:.2f}】{extension_reason}\n"
  1634. f"【最终得分 {final_score:.2f}】"
  1635. )
  1636. # 添加规则说明
  1637. if rule_applied:
  1638. combined_reason += f"\n【规则说明】{rule_applied}"
  1639. # 存入缓存
  1640. if cache is not None:
  1641. cache[text] = (final_score, combined_reason)
  1642. return final_score, combined_reason
  1643. except Exception as e:
  1644. last_error = e
  1645. error_msg = str(e)
  1646. if attempt < max_retries - 1:
  1647. print(f" ⚠️ 评估失败 (尝试 {attempt+1}/{max_retries}): {error_msg[:150]}")
  1648. print(f" 正在重试...")
  1649. await asyncio.sleep(1) # 等待1秒后重试
  1650. else:
  1651. print(f" ❌ 评估失败 (已达最大重试次数): {error_msg[:150]}")
  1652. # 所有重试失败后,返回默认值
  1653. fallback_reason = f"评估失败(重试{max_retries}次): {str(last_error)[:200]}"
  1654. print(f" 使用默认值: score=0.0, reason={fallback_reason[:100]}...")
  1655. return 0.0, fallback_reason
  1656. async def evaluate_with_o_round0(text: str, o: str, cache: dict[str, tuple[float, str]] | None = None) -> tuple[float, str]:
  1657. """Round 0专用评估函数(v124新增 - 需求1)
  1658. 用于评估segment和word与原始问题的相关度
  1659. 不含延伸词维度,使用Round 0专用Prompt和新评分逻辑
  1660. 采用两维评估:
  1661. 1. 动机维度评估(权重70%)
  1662. 2. 品类维度评估(权重30%)
  1663. 3. 应用规则A/B/C调整得分
  1664. Args:
  1665. text: 待评估的文本(segment或word)
  1666. o: 原始问题
  1667. cache: 评估缓存(可选),用于避免重复评估
  1668. Returns:
  1669. tuple[float, str]: (最终相关度分数, 综合评估理由)
  1670. """
  1671. # 检查缓存
  1672. cache_key = f"round0:{text}:{o}" # 添加前缀以区分不同评估类型
  1673. if cache is not None and cache_key in cache:
  1674. cached_score, cached_reason = cache[cache_key]
  1675. print(f" ⚡ Round0缓存命中: {text} -> {cached_score:.2f}")
  1676. return cached_score, cached_reason
  1677. # 准备输入
  1678. eval_input = f"""
  1679. <原始问题>
  1680. {o}
  1681. </原始问题>
  1682. <词条>
  1683. {text}
  1684. </词条>
  1685. 请评估词条与原始问题的匹配度。
  1686. """
  1687. # 添加重试机制
  1688. max_retries = 2
  1689. last_error = None
  1690. for attempt in range(max_retries):
  1691. try:
  1692. # 并发调用两个评估器(不含延伸词)
  1693. motivation_task = Runner.run(round0_motivation_evaluator, eval_input)
  1694. category_task = Runner.run(round0_category_evaluator, eval_input)
  1695. motivation_result, category_result = await asyncio.gather(
  1696. motivation_task,
  1697. category_task
  1698. )
  1699. # 获取评估结果
  1700. motivation_eval: MotivationEvaluation = motivation_result.final_output
  1701. category_eval: CategoryEvaluation = category_result.final_output
  1702. # 提取得分
  1703. motivation_score = motivation_eval.动机维度得分
  1704. category_score = category_eval.品类维度得分
  1705. # 应用新规则计算最终得分
  1706. final_score, rule_applied = calculate_final_score_v2(
  1707. motivation_score, category_score
  1708. )
  1709. # 组合评估理由
  1710. core_motivation = motivation_eval.原始问题核心动机提取.简要说明核心动机
  1711. motivation_reason = motivation_eval.简要说明动机维度相关度理由
  1712. category_reason = category_eval.简要说明品类维度相关度理由
  1713. combined_reason = (
  1714. f'【评估对象】词条"{text}" vs 原始问题"{o}"\n'
  1715. f"【核心动机】{core_motivation}\n"
  1716. f"【动机维度 {motivation_score:.2f}】{motivation_reason}\n"
  1717. f"【品类维度 {category_score:.2f}】{category_reason}\n"
  1718. f"【最终得分 {final_score:.2f}】"
  1719. )
  1720. # 添加规则说明
  1721. if rule_applied:
  1722. combined_reason += f"\n【规则说明】{rule_applied}"
  1723. # 存入缓存
  1724. if cache is not None:
  1725. cache[cache_key] = (final_score, combined_reason)
  1726. return final_score, combined_reason
  1727. except Exception as e:
  1728. last_error = e
  1729. error_msg = str(e)
  1730. if attempt < max_retries - 1:
  1731. print(f" ⚠️ Round0评估失败 (尝试 {attempt+1}/{max_retries}): {error_msg[:150]}")
  1732. print(f" 正在重试...")
  1733. await asyncio.sleep(1)
  1734. else:
  1735. print(f" ❌ Round0评估失败 (已达最大重试次数): {error_msg[:150]}")
  1736. # 所有重试失败后,返回默认值
  1737. fallback_reason = f"Round0评估失败(重试{max_retries}次): {str(last_error)[:200]}"
  1738. print(f" 使用默认值: score=0.0, reason={fallback_reason[:100]}...")
  1739. return 0.0, fallback_reason
  1740. async def evaluate_within_scope(text: str, scope_text: str, cache: dict[str, tuple[float, str]] | None = None) -> tuple[float, str]:
  1741. """域内/域间专用评估函数(v124新增 - 需求2&3)
  1742. 用于评估词条与作用域词条(单域或域组合)的相关度
  1743. 不含延伸词维度,使用域内专用Prompt和新评分逻辑
  1744. 采用两维评估:
  1745. 1. 动机维度评估(权重70%)
  1746. 2. 品类维度评估(权重30%)
  1747. 3. 应用规则A/B/C调整得分
  1748. Args:
  1749. text: 待评估的词条
  1750. scope_text: 作用域词条(可以是单域词条或域组合词条)
  1751. cache: 评估缓存(可选),用于避免重复评估
  1752. Returns:
  1753. tuple[float, str]: (最终相关度分数, 综合评估理由)
  1754. """
  1755. # 检查缓存
  1756. cache_key = f"scope:{text}:{scope_text}" # 添加前缀以区分不同评估类型
  1757. if cache is not None and cache_key in cache:
  1758. cached_score, cached_reason = cache[cache_key]
  1759. print(f" ⚡ 域内缓存命中: {text} -> {cached_score:.2f}")
  1760. return cached_score, cached_reason
  1761. # 准备输入
  1762. eval_input = f"""
  1763. <同一作用域词条>
  1764. {scope_text}
  1765. </同一作用域词条>
  1766. <词条>
  1767. {text}
  1768. </词条>
  1769. 请评估词条与同一作用域词条的匹配度。
  1770. """
  1771. # 添加重试机制
  1772. max_retries = 2
  1773. last_error = None
  1774. for attempt in range(max_retries):
  1775. try:
  1776. # 并发调用两个评估器(不含延伸词)
  1777. motivation_task = Runner.run(scope_motivation_evaluator, eval_input)
  1778. category_task = Runner.run(scope_category_evaluator, eval_input)
  1779. motivation_result, category_result = await asyncio.gather(
  1780. motivation_task,
  1781. category_task
  1782. )
  1783. # 获取评估结果
  1784. motivation_eval: MotivationEvaluation = motivation_result.final_output
  1785. category_eval: CategoryEvaluation = category_result.final_output
  1786. # 提取得分
  1787. motivation_score = motivation_eval.动机维度得分
  1788. category_score = category_eval.品类维度得分
  1789. # 应用新规则计算最终得分
  1790. final_score, rule_applied = calculate_final_score_v2(
  1791. motivation_score, category_score
  1792. )
  1793. # 组合评估理由
  1794. core_motivation = motivation_eval.原始问题核心动机提取.简要说明核心动机
  1795. motivation_reason = motivation_eval.简要说明动机维度相关度理由
  1796. category_reason = category_eval.简要说明品类维度相关度理由
  1797. combined_reason = (
  1798. f'【评估对象】词条"{text}" vs 作用域词条"{scope_text}"\n'
  1799. f"【核心动机】{core_motivation}\n"
  1800. f"【动机维度 {motivation_score:.2f}】{motivation_reason}\n"
  1801. f"【品类维度 {category_score:.2f}】{category_reason}\n"
  1802. f"【最终得分 {final_score:.2f}】"
  1803. )
  1804. # 添加规则说明
  1805. if rule_applied:
  1806. combined_reason += f"\n【规则说明】{rule_applied}"
  1807. # 存入缓存
  1808. if cache is not None:
  1809. cache[cache_key] = (final_score, combined_reason)
  1810. return final_score, combined_reason
  1811. except Exception as e:
  1812. last_error = e
  1813. error_msg = str(e)
  1814. if attempt < max_retries - 1:
  1815. print(f" ⚠️ 域内评估失败 (尝试 {attempt+1}/{max_retries}): {error_msg[:150]}")
  1816. print(f" 正在重试...")
  1817. await asyncio.sleep(1)
  1818. else:
  1819. print(f" ❌ 域内评估失败 (已达最大重试次数): {error_msg[:150]}")
  1820. # 所有重试失败后,返回默认值
  1821. fallback_reason = f"域内评估失败(重试{max_retries}次): {str(last_error)[:200]}"
  1822. print(f" 使用默认值: score=0.0, reason={fallback_reason[:100]}...")
  1823. return 0.0, fallback_reason
  1824. # ============================================================================
  1825. # v125 新增辅助函数(用于新评分逻辑)
  1826. # ============================================================================
  1827. def get_source_word_score(
  1828. word_text: str,
  1829. segment: Segment,
  1830. context: RunContext
  1831. ) -> float:
  1832. """
  1833. 查找来源词的得分
  1834. 查找顺序:
  1835. 1. 先查 segment.word_scores (Round 0的单个词)
  1836. 2. 再查 context.word_score_history (Round 1+的组合)
  1837. Args:
  1838. word_text: 词文本
  1839. segment: 该词所在的segment
  1840. context: 运行上下文
  1841. Returns:
  1842. 词的得分,找不到返回0.0
  1843. """
  1844. # 优先查Round 0的词得分
  1845. if word_text in segment.word_scores:
  1846. return segment.word_scores[word_text]
  1847. # 其次查历史组合得分
  1848. if word_text in context.word_score_history:
  1849. return context.word_score_history[word_text]
  1850. # 都找不到
  1851. print(f" ⚠️ 警告: 未找到来源词得分: {word_text}")
  1852. return 0.0
  1853. async def evaluate_domain_combination_round1(
  1854. comb: DomainCombination,
  1855. segments: list[Segment],
  1856. context: RunContext
  1857. ) -> tuple[float, str]:
  1858. """
  1859. Round 1 域内组合评估(新逻辑)
  1860. 最终得分 = 品类得分 × 原始域得分
  1861. Args:
  1862. comb: 域内组合对象
  1863. segments: 所有segment列表
  1864. context: 运行上下文
  1865. Returns:
  1866. (最终得分, 评估理由)
  1867. """
  1868. # 获取所属segment
  1869. domain_idx = comb.domains[0] if comb.domains else 0
  1870. segment = segments[domain_idx] if 0 <= domain_idx < len(segments) else None
  1871. if not segment:
  1872. return 0.0, "错误: 无法找到所属segment"
  1873. # 拼接作用域文本
  1874. scope_text = segment.text
  1875. # 准备输入
  1876. eval_input = f"""
  1877. <同一作用域词条>
  1878. {scope_text}
  1879. </同一作用域词条>
  1880. <词条>
  1881. {comb.text}
  1882. </词条>
  1883. 请评估词条与同一作用域词条的匹配度。
  1884. """
  1885. # 只调用品类评估器
  1886. try:
  1887. category_result = await Runner.run(scope_category_evaluator, eval_input)
  1888. category_eval: CategoryEvaluation = category_result.final_output
  1889. category_score = category_eval.品类维度得分
  1890. category_reason = category_eval.简要说明品类维度相关度理由
  1891. except Exception as e:
  1892. print(f" ❌ Round 1品类评估失败: {e}")
  1893. return 0.0, f"评估失败: {str(e)[:100]}"
  1894. # 计算最终得分
  1895. domain_score = segment.score_with_o
  1896. final_score = category_score * domain_score
  1897. # 组合评估理由
  1898. combined_reason = (
  1899. f'【Round 1 域内评估】\n'
  1900. f'【评估对象】组合"{comb.text}" vs 作用域"{scope_text}"\n'
  1901. f'【品类得分】{category_score:.2f} - {category_reason}\n'
  1902. f'【原始域得分】{domain_score:.2f}\n'
  1903. f'【计算公式】品类得分 × 域得分 = {category_score:.2f} × {domain_score:.2f}\n'
  1904. f'【最终得分】{final_score:.2f}'
  1905. )
  1906. return final_score, combined_reason
  1907. async def evaluate_domain_combination_round2plus(
  1908. comb: DomainCombination,
  1909. segments: list[Segment],
  1910. context: RunContext
  1911. ) -> tuple[float, str]:
  1912. """
  1913. Round 2+ 域间组合评估(新逻辑 - 两步评估相乘)
  1914. 步骤:
  1915. 1. 计算全域组合得分 A: 全域组合 vs 原始query(动机+品类两维)
  1916. 2. 计算部分组合得分 B: 部分组合 vs 全域组合(域内评估)
  1917. 3. 最终得分 = A × B,截断到1.0
  1918. Args:
  1919. comb: 域间组合对象
  1920. segments: 所有segment列表
  1921. context: 运行上下文
  1922. Returns:
  1923. (最终得分, 评估理由)
  1924. """
  1925. # 全域组合文本 = 拼接所有参与组合的segments
  1926. full_domain_text = "".join(comb.from_segments)
  1927. # 步骤1: 计算全域组合得分 A
  1928. # 全域组合 vs 原始问题(动机+品类两维评估)
  1929. score_A, reason_A = await evaluate_with_o_round0(
  1930. full_domain_text,
  1931. context.o, # 原始问题
  1932. context.evaluation_cache
  1933. )
  1934. # 步骤2: 计算部分组合得分 B
  1935. # 部分组合 vs 全域组合(域内评估)
  1936. score_B, reason_B = await evaluate_within_scope(
  1937. comb.text, # 部分组合,如"获取川西"
  1938. full_domain_text, # 全域组合,如"如何获取川西秋季特色"
  1939. context.evaluation_cache
  1940. )
  1941. # 步骤3: 计算最终得分
  1942. final_score = score_A * score_B
  1943. final_score = min(1.0, max(-1.0, final_score)) # 截断到[-1.0, 1.0]
  1944. # 组合评估理由
  1945. combined_reason = (
  1946. f'【Round 2+ 域间评估(两步评估相乘)】\n'
  1947. f'【评估对象】部分组合 "{comb.text}"\n'
  1948. f'\n'
  1949. f'【步骤1: 全域组合得分 A】\n'
  1950. f' 全域组合文本: "{full_domain_text}"\n'
  1951. f' 评估方式: 全域组合 vs 原始问题(动机+品类两维)\n'
  1952. f' {reason_A}\n'
  1953. f' 得分A = {score_A:.2f}\n'
  1954. f'\n'
  1955. f'【步骤2: 部分组合得分 B】\n'
  1956. f' 部分组合文本: "{comb.text}"\n'
  1957. f' 评估方式: 部分组合 vs 全域组合(域内评估)\n'
  1958. f' {reason_B}\n'
  1959. f' 得分B = {score_B:.2f}\n'
  1960. f'\n'
  1961. f'【最终得分】A × B = {score_A:.2f} × {score_B:.2f} = {score_A * score_B:.2f}\n'
  1962. f'【截断后】{final_score:.2f}'
  1963. )
  1964. return final_score, combined_reason
  1965. # ============================================================================
  1966. # 核心流程函数
  1967. # ============================================================================
  1968. async def initialize(o: str, context: RunContext) -> tuple[list[Seg], list[Word], list[Q], list[Seed]]:
  1969. """
  1970. 初始化阶段
  1971. Returns:
  1972. (seg_list, word_list_1, q_list_1, seed_list)
  1973. """
  1974. print(f"\n{'='*60}")
  1975. print(f"初始化阶段")
  1976. print(f"{'='*60}")
  1977. # 1. 分词:原始问题(o) ->分词-> seg_list
  1978. print(f"\n[步骤1] 分词...")
  1979. result = await Runner.run(word_segmenter, o)
  1980. segmentation: WordSegmentation = result.final_output
  1981. seg_list = []
  1982. for word in segmentation.words:
  1983. seg_list.append(Seg(text=word, from_o=o))
  1984. print(f"分词结果: {[s.text for s in seg_list]}")
  1985. print(f"分词理由: {segmentation.reasoning}")
  1986. # 2. 分词评估:seg_list -> 每个seg与o进行评分(使用信号量限制并发数)
  1987. print(f"\n[步骤2] 评估每个分词与原始问题的相关度...")
  1988. MAX_CONCURRENT_SEG_EVALUATIONS = 10
  1989. seg_semaphore = asyncio.Semaphore(MAX_CONCURRENT_SEG_EVALUATIONS)
  1990. async def evaluate_seg(seg: Seg) -> Seg:
  1991. async with seg_semaphore:
  1992. # 初始化阶段的分词评估使用第一轮 prompt (round_num=1)
  1993. seg.score_with_o, seg.reason = await evaluate_with_o(seg.text, o, context.evaluation_cache, round_num=1)
  1994. return seg
  1995. if seg_list:
  1996. print(f" 开始评估 {len(seg_list)} 个分词(并发限制: {MAX_CONCURRENT_SEG_EVALUATIONS})...")
  1997. eval_tasks = [evaluate_seg(seg) for seg in seg_list]
  1998. await asyncio.gather(*eval_tasks)
  1999. for seg in seg_list:
  2000. print(f" {seg.text}: {seg.score_with_o:.2f}")
  2001. # 3. 构建word_list_1: seg_list -> word_list_1(固定词库)
  2002. print(f"\n[步骤3] 构建word_list_1(固定词库)...")
  2003. word_list_1 = []
  2004. for seg in seg_list:
  2005. word_list_1.append(Word(
  2006. text=seg.text,
  2007. score_with_o=seg.score_with_o,
  2008. from_o=o
  2009. ))
  2010. print(f"word_list_1(固定): {[w.text for w in word_list_1]}")
  2011. # 4. 构建q_list_1:seg_list 作为 q_list_1
  2012. print(f"\n[步骤4] 构建q_list_1...")
  2013. q_list_1 = []
  2014. for seg in seg_list:
  2015. q_list_1.append(Q(
  2016. text=seg.text,
  2017. score_with_o=seg.score_with_o,
  2018. reason=seg.reason,
  2019. from_source="seg"
  2020. ))
  2021. print(f"q_list_1: {[q.text for q in q_list_1]}")
  2022. # 5. 构建seed_list: seg_list -> seed_list
  2023. print(f"\n[步骤5] 构建seed_list...")
  2024. seed_list = []
  2025. for seg in seg_list:
  2026. seed_list.append(Seed(
  2027. text=seg.text,
  2028. added_words=[],
  2029. from_type="seg",
  2030. score_with_o=seg.score_with_o
  2031. ))
  2032. print(f"seed_list: {[s.text for s in seed_list]}")
  2033. return seg_list, word_list_1, q_list_1, seed_list
  2034. async def run_round(
  2035. round_num: int,
  2036. q_list: list[Q],
  2037. word_list_1: list[Word],
  2038. seed_list: list[Seed],
  2039. o: str,
  2040. context: RunContext,
  2041. xiaohongshu_api: XiaohongshuSearchRecommendations,
  2042. xiaohongshu_search: XiaohongshuSearch,
  2043. sug_threshold: float = 0.7
  2044. ) -> tuple[list[Q], list[Seed], list[Search]]:
  2045. """
  2046. 运行一轮
  2047. Args:
  2048. round_num: 轮次编号
  2049. q_list: 当前轮的q列表
  2050. word_list_1: 固定的词库(第0轮分词结果)
  2051. seed_list: 当前的seed列表
  2052. o: 原始问题
  2053. context: 运行上下文
  2054. xiaohongshu_api: 建议词API
  2055. xiaohongshu_search: 搜索API
  2056. sug_threshold: suggestion的阈值
  2057. Returns:
  2058. (q_list_next, seed_list_next, search_list)
  2059. """
  2060. print(f"\n{'='*60}")
  2061. print(f"第{round_num}轮")
  2062. print(f"{'='*60}")
  2063. round_data = {
  2064. "round_num": round_num,
  2065. "input_q_list": [{"text": q.text, "score": q.score_with_o, "type": "query"} for q in q_list],
  2066. "input_word_list_1_size": len(word_list_1),
  2067. "input_seed_list_size": len(seed_list)
  2068. }
  2069. # 1. 请求sug:q_list -> 每个q请求sug接口 -> sug_list_list
  2070. print(f"\n[步骤1] 为每个q请求建议词...")
  2071. sug_list_list = [] # list of list
  2072. for q in q_list:
  2073. print(f"\n 处理q: {q.text}")
  2074. suggestions = xiaohongshu_api.get_recommendations(keyword=q.text)
  2075. q_sug_list = []
  2076. if suggestions:
  2077. print(f" 获取到 {len(suggestions)} 个建议词")
  2078. for sug_text in suggestions:
  2079. sug = Sug(
  2080. text=sug_text,
  2081. from_q=QFromQ(text=q.text, score_with_o=q.score_with_o)
  2082. )
  2083. q_sug_list.append(sug)
  2084. else:
  2085. print(f" 未获取到建议词")
  2086. sug_list_list.append(q_sug_list)
  2087. # 2. sug评估:sug_list_list -> 每个sug与o进行评分(并发)
  2088. print(f"\n[步骤2] 评估每个建议词与原始问题的相关度...")
  2089. # 2.1 收集所有需要评估的sug,并记录它们所属的q
  2090. all_sugs = []
  2091. sug_to_q_map = {} # 记录每个sug属于哪个q
  2092. for i, q_sug_list in enumerate(sug_list_list):
  2093. if q_sug_list:
  2094. q_text = q_list[i].text
  2095. for sug in q_sug_list:
  2096. all_sugs.append(sug)
  2097. sug_to_q_map[id(sug)] = q_text
  2098. # 2.2 并发评估所有sug(使用信号量限制并发数)
  2099. # 每个 evaluate_sug 内部会并发调用 2 个 LLM,所以这里限制为 5,实际并发 LLM 请求为 10
  2100. MAX_CONCURRENT_EVALUATIONS = 5
  2101. semaphore = asyncio.Semaphore(MAX_CONCURRENT_EVALUATIONS)
  2102. async def evaluate_sug(sug: Sug) -> Sug:
  2103. async with semaphore: # 限制并发数
  2104. # 根据轮次选择 prompt: 第一轮使用 round1 prompt,后续使用标准 prompt
  2105. sug.score_with_o, sug.reason = await evaluate_with_o(sug.text, o, context.evaluation_cache, round_num=round_num)
  2106. return sug
  2107. if all_sugs:
  2108. print(f" 开始评估 {len(all_sugs)} 个建议词(并发限制: {MAX_CONCURRENT_EVALUATIONS})...")
  2109. eval_tasks = [evaluate_sug(sug) for sug in all_sugs]
  2110. await asyncio.gather(*eval_tasks)
  2111. # 2.3 打印结果并组织到sug_details
  2112. sug_details = {} # 保存每个Q对应的sug列表
  2113. for i, q_sug_list in enumerate(sug_list_list):
  2114. if q_sug_list:
  2115. q_text = q_list[i].text
  2116. print(f"\n 来自q '{q_text}' 的建议词:")
  2117. sug_details[q_text] = []
  2118. for sug in q_sug_list:
  2119. print(f" {sug.text}: {sug.score_with_o:.2f}")
  2120. # 保存到sug_details
  2121. sug_details[q_text].append({
  2122. "text": sug.text,
  2123. "score": sug.score_with_o,
  2124. "reason": sug.reason,
  2125. "type": "sug"
  2126. })
  2127. # 2.4 剪枝判断(已禁用 - 保留所有分支)
  2128. pruned_query_texts = set()
  2129. if False: # 原: if round_num >= 2: # 剪枝功能已禁用,保留代码以便后续调整
  2130. print(f"\n[剪枝判断] 第{round_num}轮开始应用剪枝策略...")
  2131. for i, q in enumerate(q_list):
  2132. q_sug_list = sug_list_list[i]
  2133. if len(q_sug_list) == 0:
  2134. continue # 没有sug则不剪枝
  2135. # 剪枝条件1: 所有sug分数都低于query分数
  2136. all_lower_than_query = all(sug.score_with_o < q.score_with_o for sug in q_sug_list)
  2137. # 剪枝条件2: 所有sug分数都低于0.5
  2138. all_below_threshold = all(sug.score_with_o < 0.5 for sug in q_sug_list)
  2139. if all_lower_than_query and all_below_threshold:
  2140. pruned_query_texts.add(q.text)
  2141. max_sug_score = max(sug.score_with_o for sug in q_sug_list)
  2142. print(f" 🔪 剪枝: {q.text} (query分数:{q.score_with_o:.2f}, sug最高分:{max_sug_score:.2f}, 全部<0.5)")
  2143. if pruned_query_texts:
  2144. print(f" 本轮共剪枝 {len(pruned_query_texts)} 个query")
  2145. else:
  2146. print(f" 本轮无query被剪枝")
  2147. else:
  2148. print(f"\n[剪枝判断] 剪枝功能已禁用,保留所有分支")
  2149. # 3. search_list构建
  2150. print(f"\n[步骤3] 构建search_list(阈值>{sug_threshold})...")
  2151. search_list = []
  2152. high_score_sugs = [sug for sug in all_sugs if sug.score_with_o > sug_threshold]
  2153. if high_score_sugs:
  2154. print(f" 找到 {len(high_score_sugs)} 个高分建议词")
  2155. # 并发搜索
  2156. async def search_for_sug(sug: Sug) -> Search:
  2157. print(f" 搜索: {sug.text}")
  2158. try:
  2159. search_result = xiaohongshu_search.search(keyword=sug.text)
  2160. result_str = search_result.get("result", "{}")
  2161. if isinstance(result_str, str):
  2162. result_data = json.loads(result_str)
  2163. else:
  2164. result_data = result_str
  2165. notes = result_data.get("data", {}).get("data", [])
  2166. post_list = []
  2167. for note in notes[:10]: # 只取前10个
  2168. post = process_note_data(note)
  2169. post_list.append(post)
  2170. print(f" → 找到 {len(post_list)} 个帖子")
  2171. return Search(
  2172. text=sug.text,
  2173. score_with_o=sug.score_with_o,
  2174. from_q=sug.from_q,
  2175. post_list=post_list
  2176. )
  2177. except Exception as e:
  2178. print(f" ✗ 搜索失败: {e}")
  2179. return Search(
  2180. text=sug.text,
  2181. score_with_o=sug.score_with_o,
  2182. from_q=sug.from_q,
  2183. post_list=[]
  2184. )
  2185. search_tasks = [search_for_sug(sug) for sug in high_score_sugs]
  2186. search_list = await asyncio.gather(*search_tasks)
  2187. else:
  2188. print(f" 没有高分建议词,search_list为空")
  2189. # 4. 构建q_list_next
  2190. print(f"\n[步骤4] 构建q_list_next...")
  2191. q_list_next = []
  2192. existing_q_texts = set() # 用于去重
  2193. add_word_details = {} # 保存每个seed对应的组合词列表
  2194. all_seed_combinations = [] # 保存本轮所有seed的组合词(用于后续构建seed_list_next)
  2195. # 4.1 对于seed_list中的每个seed,从word_list_1中选词组合,产生Top 5
  2196. print(f"\n 4.1 为每个seed加词(产生Top 5组合)...")
  2197. for seed in seed_list:
  2198. print(f"\n 处理seed: {seed.text}")
  2199. # 剪枝检查:跳过被剪枝的seed
  2200. if seed.text in pruned_query_texts:
  2201. print(f" ⊗ 跳过被剪枝的seed: {seed.text}")
  2202. continue
  2203. # 从固定词库word_list_1筛选候选词
  2204. candidate_words = []
  2205. for word in word_list_1:
  2206. # 检查词是否已在seed中
  2207. if word.text in seed.text:
  2208. continue
  2209. # 检查词是否已被添加过
  2210. if word.text in seed.added_words:
  2211. continue
  2212. candidate_words.append(word)
  2213. if not candidate_words:
  2214. print(f" 没有可用的候选词")
  2215. continue
  2216. print(f" 候选词数量: {len(candidate_words)}")
  2217. # 调用Agent一次性选择并组合Top 5(添加重试机制)
  2218. candidate_words_text = ', '.join([w.text for w in candidate_words])
  2219. selection_input = f"""
  2220. <原始问题>
  2221. {o}
  2222. </原始问题>
  2223. <当前Seed>
  2224. {seed.text}
  2225. </当前Seed>
  2226. <候选词列表>
  2227. {candidate_words_text}
  2228. </候选词列表>
  2229. 请从候选词列表中选择最多5个最合适的词,分别与当前seed组合成新的query。
  2230. """
  2231. # 重试机制
  2232. max_retries = 2
  2233. selection_result = None
  2234. for attempt in range(max_retries):
  2235. try:
  2236. result = await Runner.run(word_selector, selection_input)
  2237. selection_result = result.final_output
  2238. break # 成功则跳出
  2239. except Exception as e:
  2240. error_msg = str(e)
  2241. if attempt < max_retries - 1:
  2242. print(f" ⚠️ 选词失败 (尝试 {attempt+1}/{max_retries}): {error_msg[:100]}")
  2243. await asyncio.sleep(1)
  2244. else:
  2245. print(f" ❌ 选词失败,跳过该seed: {error_msg[:100]}")
  2246. break
  2247. if selection_result is None:
  2248. print(f" 跳过seed: {seed.text}")
  2249. continue
  2250. print(f" Agent选择了 {len(selection_result.combinations)} 个组合")
  2251. print(f" 整体选择思路: {selection_result.overall_reasoning}")
  2252. # 并发评估所有组合的相关度
  2253. async def evaluate_combination(comb: WordCombination) -> dict:
  2254. combined = comb.combined_query
  2255. # 验证:组合结果必须包含完整的seed和word
  2256. # 检查是否包含seed的所有字符
  2257. seed_chars_in_combined = all(char in combined for char in seed.text)
  2258. # 检查是否包含word的所有字符
  2259. word_chars_in_combined = all(char in combined for char in comb.selected_word)
  2260. if not seed_chars_in_combined or not word_chars_in_combined:
  2261. print(f" ⚠️ 警告:组合不完整")
  2262. print(f" Seed: {seed.text}")
  2263. print(f" Word: {comb.selected_word}")
  2264. print(f" 组合: {combined}")
  2265. print(f" 包含完整seed? {seed_chars_in_combined}")
  2266. print(f" 包含完整word? {word_chars_in_combined}")
  2267. # 返回极低分数,让这个组合不会被选中
  2268. return {
  2269. 'word': comb.selected_word,
  2270. 'query': combined,
  2271. 'score': -1.0, # 极低分数
  2272. 'reason': f"组合不完整:缺少seed或word的部分内容",
  2273. 'reasoning': comb.reasoning
  2274. }
  2275. # 正常评估,根据轮次选择 prompt
  2276. score, reason = await evaluate_with_o(combined, o, context.evaluation_cache, round_num=round_num)
  2277. return {
  2278. 'word': comb.selected_word,
  2279. 'query': combined,
  2280. 'score': score,
  2281. 'reason': reason,
  2282. 'reasoning': comb.reasoning
  2283. }
  2284. eval_tasks = [evaluate_combination(comb) for comb in selection_result.combinations]
  2285. top_5 = await asyncio.gather(*eval_tasks)
  2286. print(f" 评估完成,得到 {len(top_5)} 个组合")
  2287. # 将Top 5全部加入q_list_next(去重检查 + 得分过滤)
  2288. for comb in top_5:
  2289. # 得分过滤:组合词必须比种子提升至少REQUIRED_SCORE_GAIN才能加入下一轮
  2290. if comb['score'] < seed.score_with_o + REQUIRED_SCORE_GAIN:
  2291. print(f" ⊗ 跳过低分: {comb['query']} (分数{comb['score']:.2f} < 种子{seed.score_with_o:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
  2292. continue
  2293. # 去重检查
  2294. if comb['query'] in existing_q_texts:
  2295. print(f" ⊗ 跳过重复: {comb['query']}")
  2296. continue
  2297. print(f" ✓ {comb['query']} (分数: {comb['score']:.2f} > 种子: {seed.score_with_o:.2f})")
  2298. new_q = Q(
  2299. text=comb['query'],
  2300. score_with_o=comb['score'],
  2301. reason=comb['reason'],
  2302. from_source="add"
  2303. )
  2304. q_list_next.append(new_q)
  2305. existing_q_texts.add(comb['query']) # 记录到去重集合
  2306. # 记录已添加的词
  2307. seed.added_words.append(comb['word'])
  2308. # 保存到add_word_details
  2309. add_word_details[seed.text] = [
  2310. {
  2311. "text": comb['query'],
  2312. "score": comb['score'],
  2313. "reason": comb['reason'],
  2314. "selected_word": comb['word'],
  2315. "seed_score": seed.score_with_o, # 添加原始种子的得分
  2316. "type": "add"
  2317. }
  2318. for comb in top_5
  2319. ]
  2320. # 保存到all_seed_combinations(用于构建seed_list_next)
  2321. # 附加seed_score,用于后续过滤
  2322. for comb in top_5:
  2323. comb['seed_score'] = seed.score_with_o
  2324. all_seed_combinations.extend(top_5)
  2325. # 4.2 对于sug_list_list中,每个sug大于来自的query分数,加到q_list_next(去重检查)
  2326. print(f"\n 4.2 将高分sug加入q_list_next...")
  2327. for sug in all_sugs:
  2328. # 剪枝检查:跳过来自被剪枝query的sug
  2329. if sug.from_q and sug.from_q.text in pruned_query_texts:
  2330. print(f" ⊗ 跳过来自被剪枝query的sug: {sug.text} (来源: {sug.from_q.text})")
  2331. continue
  2332. # sug必须比来源query提升至少REQUIRED_SCORE_GAIN才能加入下一轮
  2333. if sug.from_q and sug.score_with_o >= sug.from_q.score_with_o + REQUIRED_SCORE_GAIN:
  2334. # 去重检查
  2335. if sug.text in existing_q_texts:
  2336. print(f" ⊗ 跳过重复: {sug.text}")
  2337. continue
  2338. new_q = Q(
  2339. text=sug.text,
  2340. score_with_o=sug.score_with_o,
  2341. reason=sug.reason,
  2342. from_source="sug"
  2343. )
  2344. q_list_next.append(new_q)
  2345. existing_q_texts.add(sug.text) # 记录到去重集合
  2346. print(f" ✓ {sug.text} (分数: {sug.score_with_o:.2f} >= 来源query: {sug.from_q.score_with_o:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
  2347. # 5. 构建seed_list_next(关键修改:不保留上一轮的seed)
  2348. print(f"\n[步骤5] 构建seed_list_next(不保留上轮seed)...")
  2349. seed_list_next = []
  2350. existing_seed_texts = set()
  2351. # 5.1 加入本轮所有组合词(只加入得分提升的)
  2352. print(f" 5.1 加入本轮所有组合词(得分过滤)...")
  2353. for comb in all_seed_combinations:
  2354. # 得分过滤:组合词必须比种子提升至少REQUIRED_SCORE_GAIN才作为下一轮种子
  2355. seed_score = comb.get('seed_score', 0)
  2356. if comb['score'] < seed_score + REQUIRED_SCORE_GAIN:
  2357. print(f" ⊗ 跳过低分: {comb['query']} (分数{comb['score']:.2f} < 种子{seed_score:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
  2358. continue
  2359. if comb['query'] not in existing_seed_texts:
  2360. new_seed = Seed(
  2361. text=comb['query'],
  2362. added_words=[], # 新seed的added_words清空
  2363. from_type="add",
  2364. score_with_o=comb['score']
  2365. )
  2366. seed_list_next.append(new_seed)
  2367. existing_seed_texts.add(comb['query'])
  2368. print(f" ✓ {comb['query']} (分数: {comb['score']:.2f} >= 种子: {seed_score:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
  2369. # 5.2 加入高分sug
  2370. print(f" 5.2 加入高分sug...")
  2371. for sug in all_sugs:
  2372. # 剪枝检查:跳过来自被剪枝query的sug
  2373. if sug.from_q and sug.from_q.text in pruned_query_texts:
  2374. continue
  2375. # sug必须比来源query提升至少REQUIRED_SCORE_GAIN才作为下一轮种子
  2376. 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:
  2377. new_seed = Seed(
  2378. text=sug.text,
  2379. added_words=[],
  2380. from_type="sug",
  2381. score_with_o=sug.score_with_o
  2382. )
  2383. seed_list_next.append(new_seed)
  2384. existing_seed_texts.add(sug.text)
  2385. print(f" ✓ {sug.text} (分数: {sug.score_with_o:.2f} >= 来源query: {sug.from_q.score_with_o:.2f} + {REQUIRED_SCORE_GAIN:.2f})")
  2386. # 序列化搜索结果数据(包含帖子详情)
  2387. search_results_data = []
  2388. for search in search_list:
  2389. search_results_data.append({
  2390. "text": search.text,
  2391. "score_with_o": search.score_with_o,
  2392. "post_list": [
  2393. {
  2394. "note_id": post.note_id,
  2395. "note_url": post.note_url,
  2396. "title": post.title,
  2397. "body_text": post.body_text,
  2398. "images": post.images,
  2399. "interact_info": post.interact_info
  2400. }
  2401. for post in search.post_list
  2402. ]
  2403. })
  2404. # 记录本轮数据
  2405. round_data.update({
  2406. "sug_count": len(all_sugs),
  2407. "high_score_sug_count": len(high_score_sugs),
  2408. "search_count": len(search_list),
  2409. "total_posts": sum(len(s.post_list) for s in search_list),
  2410. "q_list_next_size": len(q_list_next),
  2411. "seed_list_next_size": len(seed_list_next),
  2412. "total_combinations": len(all_seed_combinations),
  2413. "pruned_query_count": len(pruned_query_texts),
  2414. "pruned_queries": list(pruned_query_texts),
  2415. "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],
  2416. "seed_list_next": [{"text": seed.text, "from": seed.from_type, "score": seed.score_with_o} for seed in seed_list_next],
  2417. "sug_details": sug_details,
  2418. "add_word_details": add_word_details,
  2419. "search_results": search_results_data
  2420. })
  2421. context.rounds.append(round_data)
  2422. print(f"\n本轮总结:")
  2423. print(f" 建议词数量: {len(all_sugs)}")
  2424. print(f" 高分建议词: {len(high_score_sugs)}")
  2425. print(f" 搜索数量: {len(search_list)}")
  2426. print(f" 帖子总数: {sum(len(s.post_list) for s in search_list)}")
  2427. print(f" 组合词数量: {len(all_seed_combinations)}")
  2428. print(f" 下轮q数量: {len(q_list_next)}")
  2429. print(f" 下轮seed数量: {len(seed_list_next)}")
  2430. return q_list_next, seed_list_next, search_list
  2431. async def iterative_loop(
  2432. context: RunContext,
  2433. max_rounds: int = 2,
  2434. sug_threshold: float = 0.7
  2435. ):
  2436. """主迭代循环"""
  2437. print(f"\n{'='*60}")
  2438. print(f"开始迭代循环")
  2439. print(f"最大轮数: {max_rounds}")
  2440. print(f"sug阈值: {sug_threshold}")
  2441. print(f"{'='*60}")
  2442. # 初始化
  2443. seg_list, word_list_1, q_list, seed_list = await initialize(context.o, context)
  2444. # API实例
  2445. xiaohongshu_api = XiaohongshuSearchRecommendations()
  2446. xiaohongshu_search = XiaohongshuSearch()
  2447. # 保存初始化数据
  2448. context.rounds.append({
  2449. "round_num": 0,
  2450. "type": "initialization",
  2451. "seg_list": [{"text": s.text, "score": s.score_with_o, "reason": s.reason, "type": "seg"} for s in seg_list],
  2452. "word_list_1": [{"text": w.text, "score": w.score_with_o} for w in word_list_1],
  2453. "q_list_1": [{"text": q.text, "score": q.score_with_o, "reason": q.reason, "type": "query"} for q in q_list],
  2454. "seed_list": [{"text": s.text, "from_type": s.from_type, "score": s.score_with_o, "type": "seed"} for s in seed_list]
  2455. })
  2456. # 收集所有搜索结果
  2457. all_search_list = []
  2458. # 迭代
  2459. round_num = 1
  2460. while q_list and round_num <= max_rounds:
  2461. q_list, seed_list, search_list = await run_round(
  2462. round_num=round_num,
  2463. q_list=q_list,
  2464. word_list_1=word_list_1, # 传递固定词库
  2465. seed_list=seed_list,
  2466. o=context.o,
  2467. context=context,
  2468. xiaohongshu_api=xiaohongshu_api,
  2469. xiaohongshu_search=xiaohongshu_search,
  2470. sug_threshold=sug_threshold
  2471. )
  2472. all_search_list.extend(search_list)
  2473. round_num += 1
  2474. print(f"\n{'='*60}")
  2475. print(f"迭代完成")
  2476. print(f" 总轮数: {round_num - 1}")
  2477. print(f" 总搜索次数: {len(all_search_list)}")
  2478. print(f" 总帖子数: {sum(len(s.post_list) for s in all_search_list)}")
  2479. print(f"{'='*60}")
  2480. return all_search_list
  2481. # ============================================================================
  2482. # v121 新架构核心流程函数
  2483. # ============================================================================
  2484. async def initialize_v2(o: str, context: RunContext) -> list[Segment]:
  2485. """
  2486. v121 Round 0 初始化阶段
  2487. 流程:
  2488. 1. 语义分段: 调用 semantic_segmenter 将原始问题拆分成语义片段
  2489. 2. 拆词: 对每个segment调用 word_segmenter 进行拆词
  2490. 3. 评估: 对每个segment和词进行评估
  2491. 4. 不进行组合(Round 0只分段和拆词)
  2492. Returns:
  2493. 语义片段列表 (Segment)
  2494. """
  2495. print(f"\n{'='*60}")
  2496. print(f"Round 0: 初始化阶段(语义分段 + 拆词)")
  2497. print(f"{'='*60}")
  2498. # 1. 语义分段
  2499. print(f"\n[步骤1] 语义分段...")
  2500. result = await Runner.run(semantic_segmenter, o)
  2501. segmentation: SemanticSegmentation = result.final_output
  2502. print(f"语义分段结果: {len(segmentation.segments)} 个片段")
  2503. print(f"整体分段思路: {segmentation.overall_reasoning}")
  2504. segment_list = []
  2505. for seg_item in segmentation.segments:
  2506. segment = Segment(
  2507. text=seg_item.segment_text,
  2508. type=seg_item.segment_type,
  2509. from_o=o
  2510. )
  2511. segment_list.append(segment)
  2512. print(f" - [{segment.type}] {segment.text}")
  2513. # 2. 对每个segment拆词并评估
  2514. print(f"\n[步骤2] 对每个segment拆词并评估...")
  2515. MAX_CONCURRENT_EVALUATIONS = 5
  2516. semaphore = asyncio.Semaphore(MAX_CONCURRENT_EVALUATIONS)
  2517. async def process_segment(segment: Segment) -> Segment:
  2518. """处理单个segment: 拆词 + 评估segment + 评估词"""
  2519. async with semaphore:
  2520. # 2.1 拆词
  2521. word_result = await Runner.run(word_segmenter, segment.text)
  2522. word_segmentation: WordSegmentation = word_result.final_output
  2523. segment.words = word_segmentation.words
  2524. # 2.2 评估segment与原始问题的相关度(使用Round 0专用评估)
  2525. segment.score_with_o, segment.reason = await evaluate_with_o_round0(
  2526. segment.text, o, context.evaluation_cache
  2527. )
  2528. # 2.3 评估每个词与原始问题的相关度(使用Round 0专用评估)
  2529. word_eval_tasks = []
  2530. for word in segment.words:
  2531. async def eval_word(w: str) -> tuple[str, float, str]:
  2532. score, reason = await evaluate_with_o_round0(w, o, context.evaluation_cache)
  2533. return w, score, reason
  2534. word_eval_tasks.append(eval_word(word))
  2535. word_results = await asyncio.gather(*word_eval_tasks)
  2536. for word, score, reason in word_results:
  2537. segment.word_scores[word] = score
  2538. segment.word_reasons[word] = reason
  2539. return segment
  2540. if segment_list:
  2541. print(f" 开始处理 {len(segment_list)} 个segment(并发限制: {MAX_CONCURRENT_EVALUATIONS})...")
  2542. process_tasks = [process_segment(seg) for seg in segment_list]
  2543. await asyncio.gather(*process_tasks)
  2544. # 打印步骤1结果
  2545. print(f"\n[步骤1: 分段及拆词 结果]")
  2546. for segment in segment_list:
  2547. print(f" [{segment.type}] {segment.text} (分数: {segment.score_with_o:.2f})")
  2548. print(f" 拆词: {segment.words}")
  2549. for word in segment.words:
  2550. score = segment.word_scores.get(word, 0.0)
  2551. print(f" - {word}: {score:.2f}")
  2552. # 保存到context(保留旧格式以兼容)
  2553. context.segments = [
  2554. {
  2555. "text": seg.text,
  2556. "type": seg.type,
  2557. "score": seg.score_with_o,
  2558. "reason": seg.reason,
  2559. "words": seg.words,
  2560. "word_scores": seg.word_scores,
  2561. "word_reasons": seg.word_reasons
  2562. }
  2563. for seg in segment_list
  2564. ]
  2565. # 保存 Round 0 到 context.rounds(新格式用于可视化)
  2566. context.rounds.append({
  2567. "round_num": 0,
  2568. "type": "initialization",
  2569. "segments": [
  2570. {
  2571. "text": seg.text,
  2572. "type": seg.type,
  2573. "domain_index": idx,
  2574. "score": seg.score_with_o,
  2575. "reason": seg.reason,
  2576. "words": [
  2577. {
  2578. "text": word,
  2579. "score": seg.word_scores.get(word, 0.0),
  2580. "reason": seg.word_reasons.get(word, "")
  2581. }
  2582. for word in seg.words
  2583. ]
  2584. }
  2585. for idx, seg in enumerate(segment_list)
  2586. ]
  2587. })
  2588. # 🆕 存储Round 0的所有word得分到历史记录
  2589. print(f"\n[存储Round 0词得分到历史记录]")
  2590. for segment in segment_list:
  2591. for word, score in segment.word_scores.items():
  2592. context.word_score_history[word] = score
  2593. print(f" {word}: {score:.2f}")
  2594. print(f"\n[Round 0 完成]")
  2595. print(f" 分段数: {len(segment_list)}")
  2596. total_words = sum(len(seg.words) for seg in segment_list)
  2597. print(f" 总词数: {total_words}")
  2598. return segment_list
  2599. async def run_round_v2(
  2600. round_num: int,
  2601. query_input: list[Q],
  2602. segments: list[Segment],
  2603. o: str,
  2604. context: RunContext,
  2605. xiaohongshu_api: XiaohongshuSearchRecommendations,
  2606. xiaohongshu_search: XiaohongshuSearch,
  2607. sug_threshold: float = 0.7
  2608. ) -> tuple[list[Q], list[Search]]:
  2609. """
  2610. v121 Round N 执行
  2611. 正确的流程顺序:
  2612. 1. 为 query_input 请求SUG
  2613. 2. 评估SUG
  2614. 3. 高分SUG搜索
  2615. 4. N域组合(从segments生成)
  2616. 5. 评估组合
  2617. 6. 生成 q_list_next(组合 + 高分SUG)
  2618. Args:
  2619. round_num: 轮次编号 (1-4)
  2620. query_input: 本轮的输入query列表(Round 1是words,Round 2+是上轮输出)
  2621. segments: 语义片段列表(用于组合)
  2622. o: 原始问题
  2623. context: 运行上下文
  2624. xiaohongshu_api: 建议词API
  2625. xiaohongshu_search: 搜索API
  2626. sug_threshold: SUG搜索阈值
  2627. Returns:
  2628. (q_list_next, search_list)
  2629. """
  2630. print(f"\n{'='*60}")
  2631. print(f"Round {round_num}: {round_num}域组合")
  2632. print(f"{'='*60}")
  2633. round_data = {
  2634. "round_num": round_num,
  2635. "n_domains": round_num,
  2636. "input_query_count": len(query_input)
  2637. }
  2638. MAX_CONCURRENT_EVALUATIONS = 5
  2639. semaphore = asyncio.Semaphore(MAX_CONCURRENT_EVALUATIONS)
  2640. # 步骤1: 为 query_input 请求SUG
  2641. print(f"\n[步骤1] 为{len(query_input)}个输入query请求SUG...")
  2642. all_sugs = []
  2643. sug_details = {}
  2644. for q in query_input:
  2645. suggestions = xiaohongshu_api.get_recommendations(keyword=q.text)
  2646. if suggestions:
  2647. print(f" {q.text}: 获取到 {len(suggestions)} 个SUG")
  2648. for sug_text in suggestions:
  2649. sug = Sug(
  2650. text=sug_text,
  2651. from_q=QFromQ(text=q.text, score_with_o=q.score_with_o)
  2652. )
  2653. all_sugs.append(sug)
  2654. else:
  2655. print(f" {q.text}: 未获取到SUG")
  2656. print(f" 共获取 {len(all_sugs)} 个SUG")
  2657. # 步骤2: 评估SUG
  2658. if len(all_sugs) > 0:
  2659. print(f"\n[步骤2] 评估{len(all_sugs)}个SUG...")
  2660. async def evaluate_sug(sug: Sug) -> Sug:
  2661. async with semaphore:
  2662. sug.score_with_o, sug.reason = await evaluate_with_o(
  2663. sug.text, o, context.evaluation_cache
  2664. )
  2665. return sug
  2666. eval_tasks = [evaluate_sug(sug) for sug in all_sugs]
  2667. await asyncio.gather(*eval_tasks)
  2668. # 打印结果
  2669. for sug in all_sugs:
  2670. print(f" {sug.text}: {sug.score_with_o:.2f}")
  2671. if sug.from_q:
  2672. if sug.from_q.text not in sug_details:
  2673. sug_details[sug.from_q.text] = []
  2674. sug_details[sug.from_q.text].append({
  2675. "text": sug.text,
  2676. "score": sug.score_with_o,
  2677. "reason": sug.reason,
  2678. "type": "sug"
  2679. })
  2680. # 步骤3: 搜索高分SUG
  2681. print(f"\n[步骤3] 搜索高分SUG(阈值 > {sug_threshold})...")
  2682. high_score_sugs = [sug for sug in all_sugs if sug.score_with_o > sug_threshold]
  2683. print(f" 找到 {len(high_score_sugs)} 个高分SUG")
  2684. search_list = []
  2685. if len(high_score_sugs) > 0:
  2686. async def search_for_sug(sug: Sug) -> Search:
  2687. print(f" 搜索: {sug.text}")
  2688. try:
  2689. search_result = xiaohongshu_search.search(keyword=sug.text)
  2690. result_str = search_result.get("result", "{}")
  2691. if isinstance(result_str, str):
  2692. result_data = json.loads(result_str)
  2693. else:
  2694. result_data = result_str
  2695. notes = result_data.get("data", {}).get("data", [])
  2696. post_list = []
  2697. for note in notes[:10]:
  2698. post = process_note_data(note)
  2699. post_list.append(post)
  2700. print(f" → 找到 {len(post_list)} 个帖子")
  2701. return Search(
  2702. text=sug.text,
  2703. score_with_o=sug.score_with_o,
  2704. from_q=sug.from_q,
  2705. post_list=post_list
  2706. )
  2707. except Exception as e:
  2708. print(f" ✗ 搜索失败: {e}")
  2709. return Search(
  2710. text=sug.text,
  2711. score_with_o=sug.score_with_o,
  2712. from_q=sug.from_q,
  2713. post_list=[]
  2714. )
  2715. search_tasks = [search_for_sug(sug) for sug in high_score_sugs]
  2716. search_list = await asyncio.gather(*search_tasks)
  2717. # 步骤4: 生成N域组合
  2718. print(f"\n[步骤4] 生成{round_num}域组合...")
  2719. domain_combinations = generate_domain_combinations(segments, round_num)
  2720. print(f" 生成了 {len(domain_combinations)} 个组合")
  2721. if len(domain_combinations) == 0:
  2722. print(f" 无法生成{round_num}域组合")
  2723. # 即使无法组合,也返回高分SUG作为下轮输入
  2724. q_list_next = []
  2725. for sug in all_sugs:
  2726. if sug.from_q and sug.score_with_o >= sug.from_q.score_with_o + REQUIRED_SCORE_GAIN:
  2727. q = Q(
  2728. text=sug.text,
  2729. score_with_o=sug.score_with_o,
  2730. reason=sug.reason,
  2731. from_source="sug",
  2732. type_label=""
  2733. )
  2734. q_list_next.append(q)
  2735. round_data.update({
  2736. "domain_combinations_count": 0,
  2737. "sug_count": len(all_sugs),
  2738. "high_score_sug_count": len(high_score_sugs),
  2739. "search_count": len(search_list),
  2740. "sug_details": sug_details,
  2741. "q_list_next_size": len(q_list_next)
  2742. })
  2743. context.rounds.append(round_data)
  2744. return q_list_next, search_list
  2745. # 步骤5: 评估所有组合
  2746. print(f"\n[步骤5] 评估{len(domain_combinations)}个组合...")
  2747. async def evaluate_combination(comb: DomainCombination) -> DomainCombination:
  2748. async with semaphore:
  2749. # 🆕 根据轮次选择评估逻辑
  2750. if round_num == 1:
  2751. # Round 1: 域内评估(新逻辑)
  2752. comb.score_with_o, comb.reason = await evaluate_domain_combination_round1(
  2753. comb, segments, context
  2754. )
  2755. else:
  2756. # Round 2+: 域间评估(新逻辑)
  2757. comb.score_with_o, comb.reason = await evaluate_domain_combination_round2plus(
  2758. comb, segments, context
  2759. )
  2760. # 🆕 存储组合得分到历史记录
  2761. context.word_score_history[comb.text] = comb.score_with_o
  2762. return comb
  2763. eval_tasks = [evaluate_combination(comb) for comb in domain_combinations]
  2764. await asyncio.gather(*eval_tasks)
  2765. # 排序 - 已注释,保持原始顺序
  2766. # domain_combinations.sort(key=lambda x: x.score_with_o, reverse=True)
  2767. # 打印所有组合(保持原始顺序)
  2768. evaluation_strategy = 'Round 1 域内评估(品类×域得分)' if round_num == 1 else 'Round 2+ 域间评估(加权系数调整)'
  2769. print(f" 评估完成,共{len(domain_combinations)}个组合 [策略: {evaluation_strategy}]")
  2770. for i, comb in enumerate(domain_combinations, 1):
  2771. print(f" {i}. {comb.text} {comb.type_label} (分数: {comb.score_with_o:.2f})")
  2772. # 为每个组合补充来源词分数信息,并判断是否超过所有来源词得分
  2773. for comb in domain_combinations:
  2774. word_details = []
  2775. flat_scores: list[float] = []
  2776. for domain_index, words in zip(comb.domains, comb.source_words):
  2777. segment = segments[domain_index] if 0 <= domain_index < len(segments) else None
  2778. segment_type = segment.type if segment else ""
  2779. segment_text = segment.text if segment else ""
  2780. items = []
  2781. for word in words:
  2782. score = 0.0
  2783. if segment and word in segment.word_scores:
  2784. score = segment.word_scores[word]
  2785. items.append({
  2786. "text": word,
  2787. "score": score
  2788. })
  2789. flat_scores.append(score)
  2790. word_details.append({
  2791. "domain_index": domain_index,
  2792. "segment_type": segment_type,
  2793. "segment_text": segment_text,
  2794. "words": items
  2795. })
  2796. comb.source_word_details = word_details
  2797. comb.source_scores = flat_scores
  2798. comb.max_source_score = max(flat_scores) if flat_scores else None
  2799. comb.is_above_source_scores = bool(flat_scores) and all(
  2800. comb.score_with_o > score for score in flat_scores
  2801. )
  2802. # 步骤6: 构建 q_list_next(组合 + 高分SUG)
  2803. print(f"\n[步骤6] 生成下轮输入...")
  2804. q_list_next: list[Q] = []
  2805. # 6.1 添加高增益SUG(满足增益条件),并按分数排序
  2806. sug_candidates: list[tuple[Q, Sug]] = []
  2807. for sug in all_sugs:
  2808. if sug.from_q and sug.score_with_o >= sug.from_q.score_with_o + REQUIRED_SCORE_GAIN:
  2809. q = Q(
  2810. text=sug.text,
  2811. score_with_o=sug.score_with_o,
  2812. reason=sug.reason,
  2813. from_source="sug",
  2814. type_label=""
  2815. )
  2816. sug_candidates.append((q, sug))
  2817. sug_candidates.sort(key=lambda item: item[0].score_with_o, reverse=True)
  2818. q_list_next.extend([item[0] for item in sug_candidates])
  2819. high_gain_sugs = [item[1] for item in sug_candidates]
  2820. print(f" 添加 {len(high_gain_sugs)} 个高增益SUG(增益 ≥ {REQUIRED_SCORE_GAIN:.2f})")
  2821. # 6.2 添加高分组合(需超过所有来源词得分),并按分数排序
  2822. combination_candidates: list[tuple[Q, DomainCombination]] = []
  2823. for comb in domain_combinations:
  2824. if comb.is_above_source_scores and comb.score_with_o > 0:
  2825. domains_str = ','.join([f'D{d}' for d in comb.domains]) if comb.domains else ''
  2826. q = Q(
  2827. text=comb.text,
  2828. score_with_o=comb.score_with_o,
  2829. reason=comb.reason,
  2830. from_source="domain_comb",
  2831. type_label=comb.type_label,
  2832. domain_type=domains_str # 添加域信息
  2833. )
  2834. combination_candidates.append((q, comb))
  2835. combination_candidates.sort(key=lambda item: item[0].score_with_o, reverse=True)
  2836. q_list_next.extend([item[0] for item in combination_candidates])
  2837. high_score_combinations = [item[1] for item in combination_candidates]
  2838. print(f" 添加 {len(high_score_combinations)} 个高分组合(组合得分 > 所有来源词)")
  2839. # 保存round数据(包含完整帖子信息)
  2840. search_results_data = []
  2841. for search in search_list:
  2842. search_results_data.append({
  2843. "text": search.text,
  2844. "score_with_o": search.score_with_o,
  2845. "post_list": [
  2846. {
  2847. "note_id": post.note_id,
  2848. "note_url": post.note_url,
  2849. "title": post.title,
  2850. "body_text": post.body_text,
  2851. "images": post.images,
  2852. "interact_info": post.interact_info
  2853. }
  2854. for post in search.post_list
  2855. ]
  2856. })
  2857. round_data.update({
  2858. "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],
  2859. "domain_combinations_count": len(domain_combinations),
  2860. "domain_combinations": [
  2861. {
  2862. "text": comb.text,
  2863. "type_label": comb.type_label,
  2864. "score": comb.score_with_o,
  2865. "reason": comb.reason,
  2866. "domains": comb.domains,
  2867. "source_words": comb.source_words,
  2868. "from_segments": comb.from_segments,
  2869. "source_word_details": comb.source_word_details,
  2870. "source_scores": comb.source_scores,
  2871. "is_above_source_scores": comb.is_above_source_scores,
  2872. "max_source_score": comb.max_source_score
  2873. }
  2874. for comb in domain_combinations
  2875. ],
  2876. "high_score_combinations": [
  2877. {
  2878. "text": item[0].text,
  2879. "score": item[0].score_with_o,
  2880. "type_label": item[0].type_label,
  2881. "type": "combination",
  2882. "is_above_source_scores": item[1].is_above_source_scores
  2883. }
  2884. for item in combination_candidates
  2885. ],
  2886. "sug_count": len(all_sugs),
  2887. "sug_details": sug_details,
  2888. "high_score_sug_count": len(high_score_sugs),
  2889. "high_gain_sugs": [{"text": q.text, "score": q.score_with_o, "type": "sug"} for q in q_list_next if q.from_source == "sug"],
  2890. "search_count": len(search_list),
  2891. "search_results": search_results_data,
  2892. "q_list_next_size": len(q_list_next),
  2893. "q_list_next_sections": {
  2894. "sugs": [
  2895. {
  2896. "text": item[0].text,
  2897. "score": item[0].score_with_o,
  2898. "from_source": "sug"
  2899. }
  2900. for item in sug_candidates
  2901. ],
  2902. "domain_combinations": [
  2903. {
  2904. "text": item[0].text,
  2905. "score": item[0].score_with_o,
  2906. "from_source": "domain_comb",
  2907. "is_above_source_scores": item[1].is_above_source_scores
  2908. }
  2909. for item in combination_candidates
  2910. ]
  2911. }
  2912. })
  2913. context.rounds.append(round_data)
  2914. print(f"\nRound {round_num} 总结:")
  2915. print(f" 输入Query数: {len(query_input)}")
  2916. print(f" 域组合数: {len(domain_combinations)}")
  2917. print(f" 高分组合: {len(high_score_combinations)}")
  2918. print(f" SUG数: {len(all_sugs)}")
  2919. print(f" 高分SUG数: {len(high_score_sugs)}")
  2920. print(f" 高增益SUG: {len(high_gain_sugs)}")
  2921. print(f" 搜索数: {len(search_list)}")
  2922. print(f" 下轮Query数: {len(q_list_next)}")
  2923. return q_list_next, search_list
  2924. async def iterative_loop_v2(
  2925. context: RunContext,
  2926. max_rounds: int = 4,
  2927. sug_threshold: float = 0.7
  2928. ):
  2929. """v121 主迭代循环"""
  2930. print(f"\n{'='*60}")
  2931. print(f"开始v121迭代循环(语义分段跨域组词版)")
  2932. print(f"最大轮数: {max_rounds}")
  2933. print(f"sug阈值: {sug_threshold}")
  2934. print(f"{'='*60}")
  2935. # Round 0: 初始化(语义分段 + 拆词)
  2936. segments = await initialize_v2(context.o, context)
  2937. # API实例
  2938. xiaohongshu_api = XiaohongshuSearchRecommendations()
  2939. xiaohongshu_search = XiaohongshuSearch()
  2940. # 收集所有搜索结果
  2941. all_search_list = []
  2942. # 准备 Round 1 的输入:从 segments 提取所有 words
  2943. query_input = extract_words_from_segments(segments)
  2944. print(f"\n提取了 {len(query_input)} 个词作为 Round 1 的输入")
  2945. # Round 1-N: 迭代循环
  2946. num_segments = len(segments)
  2947. actual_max_rounds = min(max_rounds, num_segments)
  2948. round_num = 1
  2949. while query_input and round_num <= actual_max_rounds:
  2950. query_input, search_list = await run_round_v2(
  2951. round_num=round_num,
  2952. query_input=query_input, # 传递上一轮的输出
  2953. segments=segments,
  2954. o=context.o,
  2955. context=context,
  2956. xiaohongshu_api=xiaohongshu_api,
  2957. xiaohongshu_search=xiaohongshu_search,
  2958. sug_threshold=sug_threshold
  2959. )
  2960. all_search_list.extend(search_list)
  2961. # 如果没有新的query,提前结束
  2962. if not query_input:
  2963. print(f"\n第{round_num}轮后无新query生成,提前结束迭代")
  2964. break
  2965. round_num += 1
  2966. print(f"\n{'='*60}")
  2967. print(f"迭代完成")
  2968. print(f" 实际轮数: {round_num}")
  2969. print(f" 总搜索次数: {len(all_search_list)}")
  2970. print(f" 总帖子数: {sum(len(s.post_list) for s in all_search_list)}")
  2971. print(f"{'='*60}")
  2972. return all_search_list
  2973. # ============================================================================
  2974. # 主函数
  2975. # ============================================================================
  2976. async def main(input_dir: str, max_rounds: int = 2, sug_threshold: float = 0.7, visualize: bool = False):
  2977. """主函数"""
  2978. current_time, log_url = set_trace()
  2979. # 读取输入
  2980. input_context_file = os.path.join(input_dir, 'context.md')
  2981. input_q_file = os.path.join(input_dir, 'q.md')
  2982. c = read_file_as_string(input_context_file) # 原始需求
  2983. o = read_file_as_string(input_q_file) # 原始问题
  2984. # 版本信息
  2985. version = os.path.basename(__file__)
  2986. version_name = os.path.splitext(version)[0]
  2987. # 日志目录
  2988. log_dir = os.path.join(input_dir, "output", version_name, current_time)
  2989. # 创建运行上下文
  2990. run_context = RunContext(
  2991. version=version,
  2992. input_files={
  2993. "input_dir": input_dir,
  2994. "context_file": input_context_file,
  2995. "q_file": input_q_file,
  2996. },
  2997. c=c,
  2998. o=o,
  2999. log_dir=log_dir,
  3000. log_url=log_url,
  3001. )
  3002. # 创建日志目录
  3003. os.makedirs(run_context.log_dir, exist_ok=True)
  3004. # 配置日志文件
  3005. log_file_path = os.path.join(run_context.log_dir, "run.log")
  3006. log_file = open(log_file_path, 'w', encoding='utf-8')
  3007. # 重定向stdout到TeeLogger(同时输出到控制台和文件)
  3008. original_stdout = sys.stdout
  3009. sys.stdout = TeeLogger(original_stdout, log_file)
  3010. try:
  3011. print(f"📝 日志文件: {log_file_path}")
  3012. print(f"{'='*60}\n")
  3013. # 执行迭代 (v121: 使用新架构)
  3014. all_search_list = await iterative_loop_v2(
  3015. run_context,
  3016. max_rounds=max_rounds,
  3017. sug_threshold=sug_threshold
  3018. )
  3019. # 格式化输出
  3020. output = f"原始需求:{run_context.c}\n"
  3021. output += f"原始问题:{run_context.o}\n"
  3022. output += f"总搜索次数:{len(all_search_list)}\n"
  3023. output += f"总帖子数:{sum(len(s.post_list) for s in all_search_list)}\n"
  3024. output += "\n" + "="*60 + "\n"
  3025. if all_search_list:
  3026. output += "【搜索结果】\n\n"
  3027. for idx, search in enumerate(all_search_list, 1):
  3028. output += f"{idx}. 搜索词: {search.text} (分数: {search.score_with_o:.2f})\n"
  3029. output += f" 帖子数: {len(search.post_list)}\n"
  3030. if search.post_list:
  3031. for post_idx, post in enumerate(search.post_list[:3], 1): # 只显示前3个
  3032. output += f" {post_idx}) {post.title}\n"
  3033. output += f" URL: {post.note_url}\n"
  3034. output += "\n"
  3035. else:
  3036. output += "未找到搜索结果\n"
  3037. run_context.final_output = output
  3038. print(f"\n{'='*60}")
  3039. print("最终结果")
  3040. print(f"{'='*60}")
  3041. print(output)
  3042. # 保存上下文文件
  3043. context_file_path = os.path.join(run_context.log_dir, "run_context.json")
  3044. context_dict = run_context.model_dump()
  3045. with open(context_file_path, "w", encoding="utf-8") as f:
  3046. json.dump(context_dict, f, ensure_ascii=False, indent=2)
  3047. print(f"\nRunContext saved to: {context_file_path}")
  3048. # 保存详细的搜索结果
  3049. search_results_path = os.path.join(run_context.log_dir, "search_results.json")
  3050. search_results_data = [s.model_dump() for s in all_search_list]
  3051. with open(search_results_path, "w", encoding="utf-8") as f:
  3052. json.dump(search_results_data, f, ensure_ascii=False, indent=2)
  3053. print(f"Search results saved to: {search_results_path}")
  3054. # 可视化
  3055. if visualize:
  3056. import subprocess
  3057. output_html = os.path.join(run_context.log_dir, "visualization.html")
  3058. print(f"\n🎨 生成可视化HTML...")
  3059. # 获取绝对路径
  3060. abs_context_file = os.path.abspath(context_file_path)
  3061. abs_output_html = os.path.abspath(output_html)
  3062. # 运行可视化脚本
  3063. result = subprocess.run([
  3064. "node",
  3065. "visualization/sug_v6_1_2_121/index.js",
  3066. abs_context_file,
  3067. abs_output_html
  3068. ])
  3069. if result.returncode == 0:
  3070. print(f"✅ 可视化已生成: {output_html}")
  3071. else:
  3072. print(f"❌ 可视化生成失败")
  3073. finally:
  3074. # 恢复stdout
  3075. sys.stdout = original_stdout
  3076. log_file.close()
  3077. print(f"\n📝 运行日志已保存: {log_file_path}")
  3078. if __name__ == "__main__":
  3079. parser = argparse.ArgumentParser(description="搜索query优化工具 - v6.1.2.121 语义分段跨域组词版")
  3080. parser.add_argument(
  3081. "--input-dir",
  3082. type=str,
  3083. default="input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?",
  3084. help="输入目录路径,默认: input/旅游-逸趣玩旅行/如何获取能体现川西秋季特色的高质量风光摄影素材?"
  3085. )
  3086. parser.add_argument(
  3087. "--max-rounds",
  3088. type=int,
  3089. default=4,
  3090. help="最大轮数,默认: 4"
  3091. )
  3092. parser.add_argument(
  3093. "--sug-threshold",
  3094. type=float,
  3095. default=0.7,
  3096. help="suggestion阈值,默认: 0.7"
  3097. )
  3098. parser.add_argument(
  3099. "--visualize",
  3100. action="store_true",
  3101. default=True,
  3102. help="运行完成后自动生成可视化HTML"
  3103. )
  3104. args = parser.parse_args()
  3105. asyncio.run(main(args.input_dir, max_rounds=args.max_rounds, sug_threshold=args.sug_threshold, visualize=args.visualize))