knowledge_search_traverse.py 157 KB

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