post_evaluator_v3.py 72 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084
  1. """
  2. 帖子评估模块 V3 - 4步串行+并行评估系统
  3. 改进:
  4. 1. Prompt1: 判断是知识 (is_knowledge)
  5. 2. Prompt2: 判断是否是内容知识 (is_content_knowledge)
  6. 3. Prompt3 & Prompt4: 并行执行 - 目的性(50%) + 品类(50%)
  7. 4. 代码计算综合得分: final_score = purpose × 0.5 + category × 0.5
  8. 5. 完全替代V2评估结果
  9. """
  10. import asyncio
  11. import base64
  12. import json
  13. import mimetypes
  14. import os
  15. import traceback
  16. from datetime import datetime
  17. from typing import Optional
  18. from pydantic import BaseModel, Field
  19. import requests
  20. MODEL_NAME = "google/gemini-2.5-flash"
  21. MAX_IMAGES_PER_POST = 10
  22. MAX_CONCURRENT_EVALUATIONS = 15 # 提升并发数以加快评估速度
  23. API_TIMEOUT = 120
  24. # 缓存配置
  25. ENABLE_CACHE = True # 是否启用评估结果缓存
  26. CACHE_DIR = ".evaluation_cache" # 缓存目录
  27. # 视频处理配置
  28. MAX_VIDEO_SIZE_MB = 10 # 最大视频大小限制(MB)
  29. VIDEO_DOWNLOAD_TIMEOUT = 60 # 视频下载超时(秒)
  30. TEMP_VIDEO_DIR = "/tmp/kg_agent_videos" # 临时视频存储目录(同时也是缓存目录)
  31. VIDEO_CHUNK_SIZE = 8192 # 下载分块大小(字节)
  32. MAX_VIDEO_DOWNLOAD_RETRIES = 2 # 下载重试次数
  33. # ============================================================================
  34. # 数据模型
  35. # ============================================================================
  36. class KnowledgeEvaluation(BaseModel):
  37. """Prompt1: 判断是知识 - 评估结果"""
  38. is_knowledge: bool = Field(..., description="是否是知识内容")
  39. quick_exclude: dict = Field(default_factory=dict, description="快速排除判定")
  40. title_layer: dict = Field(default_factory=dict, description="标题层判断")
  41. image_layer: dict = Field(default_factory=dict, description="图片层判断(核心)")
  42. text_layer: dict = Field(default_factory=dict, description="正文层判断(辅助)")
  43. judgment_logic: str = Field(..., description="综合判定逻辑")
  44. core_evidence: list[str] = Field(default_factory=list, description="核心证据")
  45. issues: list[str] = Field(default_factory=list, description="不足或疑虑")
  46. conclusion: str = Field(..., description="结论陈述")
  47. class ContentKnowledgeEvaluation(BaseModel):
  48. """Prompt2: 判断是否是内容知识 - 评估结果"""
  49. is_content_knowledge: bool = Field(..., description="是否属于内容知识")
  50. final_score: int = Field(..., description="最终得分(0-100)")
  51. level: str = Field(..., description="判定等级")
  52. quick_exclude: dict = Field(default_factory=dict, description="快速排除判定")
  53. dimension_scores: dict = Field(default_factory=dict, description="分层评分详情")
  54. core_evidence: list[str] = Field(default_factory=list, description="核心证据")
  55. issues: list[str] = Field(default_factory=list, description="不足之处")
  56. summary: str = Field(..., description="总结陈述")
  57. class PurposeEvaluation(BaseModel):
  58. """Prompt3: 目的性匹配 - 评估结果"""
  59. purpose_score: int = Field(..., description="目的动机得分(0-100整数)")
  60. core_motivation: str = Field(..., description="原始需求核心动机")
  61. image_value: str = Field(..., description="图片提供的价值")
  62. title_intention: str = Field(..., description="标题体现的意图")
  63. text_content: str = Field(..., description="正文补充的内容")
  64. match_level: str = Field(..., description="匹配度等级")
  65. core_basis: str = Field(..., description="核心依据")
  66. class CategoryEvaluation(BaseModel):
  67. """Prompt4: 品类匹配 - 评估结果"""
  68. category_score: int = Field(..., description="品类匹配得分(0-100整数)")
  69. original_category_analysis: dict = Field(default_factory=dict, description="原始需求品类分析")
  70. actual_category: dict = Field(default_factory=dict, description="帖子实际品类")
  71. match_level: str = Field(..., description="匹配度等级")
  72. category_match_analysis: dict = Field(default_factory=dict, description="品类匹配分析")
  73. core_basis: str = Field(..., description="核心依据")
  74. # ============================================================================
  75. # Prompt 定义
  76. # ============================================================================
  77. PROMPT1_IS_KNOWLEDGE = """# 知识判定系统 v1.0
  78. ## 角色定义
  79. 你是一个多模态内容评估专家,专门判断社交媒体帖子是否提供了"知识"。
  80. ## 知识定义
  81. **知识**是指经过验证的、具有可靠性和真实性的信息,能够被理解、学习、传播和应用。
  82. ### 知识类型
  83. - ✅ **事实性知识**: 客观事实、数据、现象描述
  84. - ✅ **原理性知识**: 规律、原理、理论、因果关系
  85. - ✅ **方法性知识**: 技能、流程、步骤、操作方法
  86. - ✅ **经验性知识**: 总结提炼的经验、教训、最佳实践
  87. - ✅ **概念性知识**: 定义、分类、框架、体系
  88. - ✅ **应用性知识**: 解决方案、工具使用、实践指南
  89. ### 非知识类型(严格排除)
  90. - ❌ **纯观点/立场**: 未经验证的个人观点、偏好表达
  91. - ❌ **情感表达**: 纯粹的情绪抒发、心情分享
  92. - ❌ **单纯展示**: 作品展示、生活记录、打卡(无知识提炼)
  93. - ❌ **娱乐内容**: 段子、搞笑、八卦(无信息价值)
  94. - ❌ **纯营销/广告**: 单纯的产品推销
  95. - ❌ **虚假/未验证信息**: 谣言、伪科学、未经证实的说法
  96. ---
  97. ## 输入信息
  98. - **标题**: {title}
  99. - **正文**: {body_text}
  100. - **图片**: {num_images}张
  101. ---
  102. ## 判断流程
  103. ### 第一步: 快速排除(任一为"是"则判定为非知识)
  104. 1. 是否为纯情感表达/生活记录/打卡?
  105. 2. 是否为单纯的作品/产品展示(无知识提炼)?
  106. 3. 是否为娱乐搞笑/八卦/纯营销内容?
  107. 4. 是否包含虚假信息或伪科学?
  108. 5. 是否完全没有新信息(纯重复常识)?
  109. **排除判定**: □ 是(判定为非知识) / □ 否(继续评估)
  110. ---
  111. ### 第二步: 分层知识判断
  112. ## 🏷️ 标题层
  113. **判断:标题是否指向知识?**
  114. - ✅ 明确传达知识主题(如何/为什么/什么是/XX方法/XX原理)
  115. - ⚠️ 描述性标题,但暗示有知识内容
  116. - ❌ 展示型(我的XX/今天XX)或情感型标题
  117. **结果**: □ 有知识指向 / □ 无知识指向
  118. ---
  119. ## 🖼️ 图片层(信息主要承载层)
  120. **判断1: 图片知识呈现方式**
  121. - ✅ 包含信息图表(数据、流程图、对比图、结构图)
  122. - ✅ 有知识性标注(解释、说明、步骤、原理)
  123. - ✅ 多图形成知识体系(步骤序列、案例对比)
  124. - ❌ 纯作品展示、美图、氛围图
  125. **判断2: 图片教育价值**
  126. - ✅ 图片能教会他人方法、技能或原理
  127. - ✅ 提供可学习的步骤或解决方案
  128. - ❌ 图片无教学意义
  129. **判断3: 图片结构化程度**
  130. - ✅ 有清晰的逻辑组织(序号、分层、框架)
  131. - ✅ 有步骤、要点的结构化呈现
  132. - ❌ 碎片化、无逻辑的展示
  133. **判断4: 图片实用性**
  134. - ✅ 提供可直接应用的方法或工具
  135. - ✅ 能帮助解决实际问题
  136. - ❌ 纯观赏性,无实际应用价值
  137. **判断5: 图片信息密度**
  138. - ✅ 包含≥3个独立知识点
  139. - ⚠️ 包含1-2个知识点
  140. - ❌ 无明确知识点
  141. **图片层综合评估**: □ 图片传递了知识 / □ 图片无知识价值
  142. ---
  143. ## 📝 正文层(辅助判断)
  144. **判断1: 信息增量**
  145. - ✅ 提供了明确的新信息、新认知或新方法
  146. - ❌ 无新信息,只是个人记录或情感表达
  147. **判断2: 可验证性**
  148. - ✅ 基于事实、数据、可验证的经验
  149. - ❌ 纯主观观点或感受,无依据
  150. **判断3: 知识类型归属**
  151. - ✅ 能归入至少一种知识类型(事实/原理/方法/经验/概念/应用)
  152. - ❌ 无法归类为任何知识类型
  153. **正文层综合评估**: □ 正文提供了知识支撑 / □ 正文无知识价值
  154. ---
  155. ### 第三步: 综合判定
  156. #### 判定规则
  157. **直接判定为"非知识"(任一成立)**:
  158. - 未通过快速排除
  159. - 图片层 = 无知识价值 且 正文层 = 无知识支撑
  160. - 正文判断3(知识类型)= 无法归类
  161. **判定为"是知识"(需同时满足)**:
  162. 1. 通过快速排除
  163. 2. 图片层 = 传递了知识 或 正文层 = 提供了知识支撑
  164. 3. 正文判断1(信息增量)= 有新信息
  165. 4. 正文判断3(知识类型)= 可归类
  166. **特别说明**:
  167. - 社交媒体帖子以图片为主要信息载体,图片层权重最高
  168. - 标题为辅助判断,正文为补充验证
  169. ---
  170. ## 输出格式
  171. 请严格按照以下JSON格式输出:
  172. {{
  173. "is_knowledge": true/false,
  174. "quick_exclude": {{
  175. "result": "通过/未通过",
  176. "reason": "如未通过,说明原因"
  177. }},
  178. "title_layer": {{
  179. "has_knowledge_direction": true/false,
  180. "reason": "一句话说明"
  181. }},
  182. "image_layer": {{
  183. "knowledge_presentation": {{"match": true/false, "reason": "简述"}},
  184. "educational_value": {{"has_value": true/false, "reason": "简述"}},
  185. "structure_level": {{"structured": true/false, "reason": "简述"}},
  186. "practicality": {{"practical": true/false, "reason": "简述"}},
  187. "information_density": {{"level": "高/中/低", "reason": "简述"}},
  188. "overall": "传递知识/无知识价值"
  189. }},
  190. "text_layer": {{
  191. "information_gain": {{"has_gain": true/false, "reason": "简述"}},
  192. "verifiability": {{"verifiable": true/false, "reason": "简述"}},
  193. "knowledge_type": {{"type": "具体类型/无法归类", "reason": "简述"}},
  194. "overall": "有知识支撑/无知识价值"
  195. }},
  196. "judgment_logic": "说明是否满足判定规则,关键依据是什么",
  197. "core_evidence": [
  198. "最强支持证据1",
  199. "最强支持证据2"
  200. ],
  201. "issues": [
  202. "不足或疑虑(如无则为空数组)"
  203. ],
  204. "conclusion": "用1-2句话说明判定结果和核心理由"
  205. }}
  206. ## 判断原则
  207. 1. **图片优先**: 社交媒体以图片为主要信息载体,图片层是核心判断依据,标题和正文信息辅助
  208. 2. **严格性**: 宁可误判为"非知识",也不放过无价值内容
  209. 3. **证据性**: 基于明确的视觉和文本证据判断
  210. 4. **价值导向**: 优先判断内容对读者是否有实际学习价值
  211. """
  212. PROMPT2_IS_CONTENT_KNOWLEDGE = """## 角色定义
  213. 你是一个多模态内容评估专家,专门判断社交媒体帖子是否属于"内容知识"类别。
  214. ## 前置条件
  215. 该帖子已通过知识判定,确认提供了知识。现在需要进一步判断是否属于"内容知识"。
  216. ---
  217. ## 内容知识的底层定义
  218. **内容知识**:关于社交媒体内容创作与制作的通识性、原理性知识,帮助创作者策划、生产、优化和传播优质内容。
  219. ### 核心特征
  220. 1. **领域特定性**:专注于社交媒体内容本身的创作与制作
  221. 2. **通识性**:跨平台、跨领域适用的内容创作原理和方法
  222. 3. **原理性**:不仅是操作步骤,更包含背后的逻辑和原理
  223. 4. **可迁移性**:方法可应用于不同类型的社交媒体内容创作
  224. ### 内容知识的完整范畴
  225. #### 1️⃣ 内容策划层
  226. - **选题方法**:如何找选题、选题原理、热点捕捉、用户需求分析
  227. - **内容定位**:账号定位、人设打造、差异化策略
  228. - **结构设计**:内容框架、故事结构、信息组织方式
  229. - **创意方法**:创意思路、脑暴方法、灵感来源
  230. #### 2️⃣ 内容制作层
  231. - **文案创作**:标题技巧、正文写作、文案公式、钩子设计、情绪调动
  232. - **视觉呈现**:封面设计原理、排版方法、配色技巧(用于内容呈现的)
  233. - **视频制作**:脚本结构、拍摄技巧、镜头语言、剪辑节奏、转场方法
  234. - **多模态组合**:图文配合、视频+文案组合、内容形式选择
  235. #### 3️⃣ 内容优化层
  236. - **开头/钩子**:前3秒设计、开头公式、吸引注意力的方法
  237. - **节奏控制**:信息密度、节奏把控、留白技巧
  238. - **完播/完读**:提升完播率/完读率的方法和原理
  239. - **互动设计**:评论引导、互动话术、用户参与设计
  240. #### 4️⃣ 内容方法论
  241. - **创作体系**:完整的内容创作流程和体系
  242. - **底层原理**:为什么这样做有效的原理解释
  243. - **通用框架**:可复用的内容创作框架和模板
  244. - **案例提炼**:从多个案例中总结的通用规律
  245. ---
  246. ### 内容知识 vs 非内容知识
  247. **✅ 属于内容知识的例子**:
  248. - "小红书爆款标题的5个公式"(文案创作)
  249. - "短视频前3秒如何抓住用户"(开头设计)
  250. - "如何策划一个涨粉选题"(内容策划)
  251. - "视频节奏控制的底层逻辑"(内容优化)
  252. - "图文笔记的排版原理"(视觉呈现)
  253. - "从10个爆款视频总结的脚本结构"(方法论提炼)
  254. **❌ 不属于内容知识的例子**:
  255. - "摄影构图的三分法则"(专业摄影技能,除非用于讲解社交媒体内容拍摄)
  256. - "PS修图教程"(设计软件技能,除非用于讲解封面/配图制作)
  257. - "我的探店vlog"(单个作品展示,无创作方法)
  258. - "今天涨粉100个好开心"(个人记录,无方法论)
  259. - "健康饮食的10个建议"(其他领域知识)
  260. - "这套配色真好看"(纯元素展示,无创作方法)
  261. **⚠️ 边界情况判断**:
  262. - **专业技能类**:如果是为社交媒体内容创作服务的,属于内容知识(如"拍摄短视频的灯光布置");如果是纯技能教学,不属于(如"专业摄影的灯光理论")
  263. - **工具使用类**:如果是为内容制作服务的,属于内容知识(如"剪映做转场的3种方法");如果是纯软件教程,不属于(如"AE粒子特效教程")
  264. - **案例分析类**:如果从案例中提炼了内容创作方法,属于内容知识;如果只是案例展示,不属于
  265. ---
  266. ### 判断核心准则
  267. **问自己三个问题**:
  268. 1. **这个知识是关于"如何创作社交媒体内容"的吗?**
  269. - 是 → 可能是内容知识
  270. - 否 → 不是内容知识
  271. 2. **这个方法/原理是通识性的吗?能跨内容类型/平台应用吗?**
  272. - 是 → 符合内容知识特征
  273. - 否 → 可能只是单点技巧
  274. 3. **看完后,创作者能用它来改进自己的内容创作吗?**
  275. - 能 → 是内容知识
  276. - 不能 → 不是内容知识
  277. ---
  278. ## 输入信息
  279. - **标题**: {title}
  280. - **正文**: {body_text}
  281. - **图片**: {num_images}张
  282. ---
  283. ## 判断流程
  284. ### 第一步: 领域快速筛查
  285. **判断:内容是否属于社交媒体内容创作/制作领域?**
  286. 核心判断标准:
  287. - 属于: 讲的是如何创作/制作社交媒体内容(选题、文案、拍摄、剪辑、运营等)
  288. - 属于:讲的是内容创作的原理、方法、技巧
  289. - 属于:讲的是平台运营、爆款方法、涨粉策略
  290. - 不属于:讲的是其他专业领域技能(摄影、设计、编程等),与内容创作无关
  291. - 不属于:讲的是其他行业知识(财经、健康、科普等)
  292. **判定**: □ 属于内容创作领域(继续) / □ 不属于(判定为非内容知识)
  293. ---
  294. ### 第二步: 快速排除判断(任一为"是"则判定为非内容知识)
  295. 1. 标题是否为纯展示型?("我的XX"、"今天拍了XX"、"作品分享")
  296. 2. 图片是否全为作品展示,无任何内容创作方法说明?
  297. 3. 是否只讲单个项目/单次创作的特定操作,完全无通用性?
  298. 4. 是否为纯元素/素材展示,无创作方法?(仅展示配色、字体、模板)
  299. 5. 是否为其他领域的专业知识,与内容创作无关?
  300. **排除判定**: □ 是(判定为非内容知识) / □ 否(继续评估)
  301. ---
  302. ### 第三步: 分层打分评估(满分100分)
  303. ## 🖼️ 图片层评估(权重70%,满分70分)
  304. > **说明**: 社交媒体以图片为主要信息载体,图片层是核心判断依据
  305. #### 维度1: 内容创作方法呈现(20分)
  306. **评分依据**: 图片是否清晰展示了具体的内容创作/制作方法、技巧
  307. - **20分**: 图片详细展示≥3个可操作的内容创作方法(如标题公式、脚本结构、拍摄技巧等)
  308. - **15分**: 图片展示2个内容创作方法,方法较为具体
  309. - **10分**: 图片展示1个内容创作方法,但不够详细
  310. - **5分**: 图片暗示有方法,但未明确展示
  311. - **0分**: 图片无任何方法展示,纯作品呈现
  312. **得分**: __/20
  313. ---
  314. #### 维度2: 内容知识体系化(15分)
  315. **评分依据**: 多图是否形成完整的内容创作知识体系或逻辑链条
  316. - **15分**: 多图形成完整体系(如选题→文案→制作→优化,或原理→方法→案例),逻辑清晰
  317. - **12分**: 多图有知识关联性,形成部分内容创作体系
  318. - **8分**: 多图展示多个内容创作知识点,但关联性弱
  319. - **4分**: 多图仅为同类案例堆砌,无体系
  320. - **0分**: 单图或多图无逻辑关联
  321. **得分**: __/15
  322. ---
  323. #### 维度3: 教学性标注与说明(15分)
  324. **评分依据**: 图片是否包含教学性的视觉元素(标注、序号、箭头、文字说明)
  325. - **15分**: 大量教学标注(序号、箭头、高亮、文字说明、对比标记等),清晰易懂
  326. - **12分**: 有明显的教学标注,但不够完善
  327. - **8分**: 有少量标注或说明
  328. - **4分**: 仅有简单文字,无视觉教学元素
  329. - **0分**: 无任何教学标注,纯视觉展示
  330. **得分**: __/15
  331. ---
  332. #### 维度4: 方法通识性与可迁移性(10分)
  333. **评分依据**: 图片展示的方法是否具有通识性,可迁移到不同类型的内容创作
  334. - **10分**: 明确展示通识性方法,可应用于多种内容类型/平台(配公式/框架)
  335. - **8分**: 方法有较强通识性,可迁移到类似内容
  336. - **5分**: 方法通识性一般,适用范围较窄
  337. - **2分**: 方法仅适用于特定单一场景
  338. - **0分**: 无通识性方法
  339. **得分**: __/10
  340. ---
  341. #### 维度5: 原理性深度(10分)
  342. **评分依据**: 图片是否讲解了内容创作背后的原理和逻辑,而非仅操作步骤
  343. - **10分**: 深入讲解原理(为什么这样做有效),配合方法和案例
  344. - **8分**: 有原理说明,但深度不够
  345. - **5分**: 主要是方法,略有原理提及
  346. - **2分**: 仅有操作步骤,无原理
  347. - **0分**: 纯案例展示,无原理无方法
  348. **得分**: __/10
  349. ---
  350. **🖼️ 图片层总分**: __/70
  351. ---
  352. ## 📝 正文层评估(权重20%,满分20分)
  353. > **说明**: 正文作为辅助判断,补充图片未完整呈现的知识信息
  354. #### 维度6: 方法/步骤描述(10分)
  355. **评分依据**: 正文是否描述了具体的内容创作方法或操作步骤
  356. - **10分**: 有完整的内容创作步骤(≥3步)或详细的方法说明
  357. - **7分**: 有步骤或方法描述,但不够系统
  358. - **4分**: 有零散的方法提及
  359. - **0分**: 无方法/步骤,纯叙事或展示性文字
  360. **得分**: __/10
  361. ---
  362. #### 维度7: 知识总结与提炼(10分)
  363. **评分依据**: 正文是否对内容创作经验/规律进行总结提炼
  364. - **10分**: 有明确的知识总结、规律归纳、框架化输出
  365. - **7分**: 有一定的经验总结或要点提炼
  366. - **4分**: 有零散的心得,但未成体系
  367. - **0分**: 无任何知识提炼
  368. **得分**: __/10
  369. ---
  370. **📝 正文层总分**: __/20
  371. ---
  372. ## 🏷️ 标题层评估(权重10%,满分10分)
  373. > **说明**: 标题作为内容导向,辅助判断内容主题
  374. #### 维度8: 标题内容指向性(10分)
  375. **评分依据**: 标题是否明确指向内容创作/制作相关的知识
  376. - **10分**: 标题明确包含内容创作相关词汇("爆款XX"、"涨粉XX"、"XX文案"、"XX脚本"、"XX选题"、"XX标题"、"如何拍/写/做XX")
  377. - **7分**: 标题包含整理型词汇("XX合集"、"XX技巧总结")
  378. - **4分**: 描述性标题,暗示有内容创作知识
  379. - **0分**: 纯展示型标题("我的作品"、"今天拍了XX")或与内容创作无关
  380. **得分**: __/10
  381. ---
  382. **🏷️标题层总分**: __/10
  383. ---
  384. ### 第三步: 综合评分与判定
  385. **总分计算**:
  386. 总分 = 图片层总分(70分) + 正文层总分(20分) + 标题层总分(10分)
  387. **最终得分**: __/100分
  388. ---
  389. **判定等级**:
  390. - **85-100分**: ⭐⭐⭐⭐⭐ 优质内容知识 - 强烈符合
  391. - **70-84分**: ⭐⭐⭐⭐ 良好内容知识 - 符合
  392. - **55-69分**: ⭐⭐⭐ 基础内容知识 - 基本符合
  393. - **40-54分**: ⭐⭐ 弱内容知识 - 不符合
  394. - **0-39分**: ⭐ 非内容知识 - 完全不符合
  395. ---
  396. ## 输出格式
  397. ### 判定结果
  398. - **是否属于内容知识**: [是/否]
  399. - **最终得分**: [X]/100分
  400. - **判定等级**: [⭐等级]
  401. ---
  402. ### 分层评分详情
  403. **🖼️ 图片层(70分)**
  404. | 维度 | 得分 | 评分依据 |
  405. |------|------|----------|
  406. | 内容创作方法呈现 | __/20 | [简述:图片展示了哪些内容创作方法] |
  407. | 内容知识体系化 | __/15 | [简述:多图逻辑关系] |
  408. | 教学性标注 | __/15 | [简述:标注元素情况] |
  409. | 方法通识性 | __/10 | [简述:通识性与可迁移性评估] |
  410. | 原理性深度 | __/10 | [简述:是否讲解原理] |
  411. | **小计** | **__/70** | |
  412. **📝 正文层(20分)**
  413. | 维度 | 得分 | 评分依据 |
  414. |------|------|----------|
  415. | 方法/步骤描述 | __/10 | [简述] |
  416. | 知识总结提炼 | __/10 | [简述] |
  417. | **小计** | **__/20** | |
  418. **🏷️ 标题层(10分)**
  419. | 维度 | 得分 | 评分依据 |
  420. |------|------|----------|
  421. | 标题内容创作指向性 | __/10 | [简述] |
  422. | **小计** | **__/10** | |
  423. ---
  424. ### 核心证据
  425. **支持判定的最强证据**(2-3条):
  426. 1. [从图片/正文/标题中提取的关键证据]
  427. 2. [...]
  428. **不足之处**(如有):
  429. 1. [存在的问题]
  430. ---
  431. ### 总结陈述
  432. [用5-6句话说明判定结果和核心理由,明确指出为何属于/不属于内容知识]
  433. ---
  434. ## JSON输出格式
  435. 请严格按照以下JSON格式输出:
  436. {{
  437. "is_content_knowledge": true/false,
  438. "final_score": 85,
  439. "level": "⭐⭐⭐⭐⭐ 优质内容知识",
  440. "quick_exclude": {{
  441. "result": "是/否",
  442. "reason": "原因"
  443. }},
  444. "dimension_scores": {{
  445. "image_layer": {{
  446. "creation_method": {{"score": 20, "reason": "简述"}},
  447. "knowledge_system": {{"score": 15, "reason": "简述"}},
  448. "teaching_annotation": {{"score": 15, "reason": "简述"}},
  449. "method_reusability": {{"score": 10, "reason": "简述"}},
  450. "principle_case": {{"score": 10, "reason": "简述"}},
  451. "subtotal": 70
  452. }},
  453. "text_layer": {{
  454. "method_description": {{"score": 10, "reason": "简述"}},
  455. "knowledge_summary": {{"score": 10, "reason": "简述"}},
  456. "subtotal": 20
  457. }},
  458. "title_layer": {{
  459. "content_direction": {{"score": 10, "reason": "简述"}},
  460. "subtotal": 10
  461. }}
  462. }},
  463. "core_evidence": [
  464. "证据1",
  465. "证据2"
  466. ],
  467. "issues": [
  468. "问题1(如无则为空数组)"
  469. ],
  470. "summary": "总结陈述(5-6句话)"
  471. }}
  472. ---
  473. ## 判断原则
  474. 1. **图片主导原则**: 图片占70%权重,是核心判断依据;标题和正文为辅助
  475. 2. **创作领域限定**: 必须属于创作/制作/设计领域,其他领域知识不属于内容知识
  476. 3. **方法优先原则**: 重点评估是否提供了可操作的创作方法,而非纯作品展示
  477. 4. **通用性要求**: 优先考虑方法的可复用性和可迁移性
  478. 5. **严格性原则**: 宁可误判为"非内容知识",也不放过纯展示型内容
  479. 6. **证据性原则**: 评分需基于明确的视觉和文本证据,可量化衡量
  480. """
  481. PROMPT3_PURPOSE_MATCH = """# Prompt 1: 多模态内容目的动机匹配评估
  482. ## 角色定义
  483. 你是一位专业的多模态内容评估专家,擅长分析社交媒体UGC平台帖子的**目的动机匹配度**,能够精准判断帖子是否满足用户的核心意图。
  484. ---
  485. ## 任务说明
  486. 你将收到一个**原始搜索需求**和一条**多模态帖子**(包含图片、标题、正文)
  487. 请**仅评估目的动机维度**的匹配度,输出0-100分的量化得分。
  488. ---
  489. ## 输入格式
  490. **原始搜索需求:**
  491. {original_query}
  492. **多模态帖子内容:**
  493. - **图片:** {num_images}张
  494. - **标题:** {title}
  495. - **正文:** {body_text}
  496. ---
  497. ## 评估维度:目的动机匹配
  498. ### 核心评估逻辑
  499. **目的动机 = 用户想做什么 = 核心动词/意图**
  500. 常见动机类型:
  501. - **获取型**:寻找、下载、收藏、获取
  502. - **学习型**:教程、学习、了解、掌握
  503. - **决策型**:推荐、对比、评测、选择
  504. - **创作型**:拍摄、制作、设计、生成
  505. - **分享型**:晒单、记录、分享、展示
  506. ---
  507. ## 评估流程
  508. ### 第一步:识别原始需求的核心动机
  509. - 提取**核心动词**(如果是纯名词短语,识别隐含意图)
  510. - 判断用户的**最终目的**是什么
  511. ### 第二步:分析帖子提供的价值(重点看图片)
  512. **图片分析(权重70%):**
  513. - 图片展示的是什么类型的内容?
  514. - 图片是否直接解答了需求的目的?
  515. - 图片的信息完整度和实用性如何?
  516. **标题分析(权重15%):**
  517. - 标题是否明确了内容的目的?
  518. **正文分析(权重15%):**
  519. - 正文是否提供了实质性的解答内容?
  520. ### 第三步:判断目的匹配度
  521. - 帖子是否**实质性地满足**了需求的动机?
  522. - 内容是否**实用、完整、可执行**?
  523. ---
  524. ## 评分标准(0-100分)
  525. ### 高度匹配区间
  526. **90-100分:完全满足动机,内容实用完整**
  527. - 图片直接展示解决方案/教程步骤/对比结果
  528. - 内容完整、清晰、可直接使用
  529. - 例:需求"如何拍摄夜景" vs 图片展示完整的夜景拍摄参数设置和效果对比
  530. **75-89分:基本满足动机,信息较全面**
  531. - 图片提供了核心解答内容
  532. - 信息相对完整但深度略有不足
  533. - 例:需求"推荐旅行路线" vs 图片展示了路线图但缺少详细说明
  534. **60-74分:部分满足动机,有参考价值**
  535. - 图片提供了相关内容但不够直接
  536. - 需要结合文字才能理解完整意图
  537. ### 中度相关区间
  538. **40-59分:弱相关,核心目的未充分满足**
  539. - 图片内容与动机有关联但不是直接解答
  540. - 实用性较低
  541. - 例:需求"如何拍摄" vs 图片只展示成品照片,无教程内容
  542. ### 不相关/负向区间
  543. **20-39分:微弱关联,基本未解答**
  544. - 图片仅有外围相关性
  545. - 对满足需求帮助极小
  546. **1-19分:几乎无关**
  547. - 图片与需求动机关联极弱
  548. **0分:完全不相关**
  549. - 图片与需求动机无任何关联
  550. **负分不使用**(目的动机维度不设负分)
  551. ---
  552. ## 输出格式(JSON)
  553. ```json
  554. {{
  555. "目的动机评估": {{
  556. "目的动机得分": 0-100的整数,
  557. "原始需求核心动机": "识别出的用户意图(一句话)",
  558. "图片提供的价值": "图片展示了什么,如何满足动机",
  559. "标题体现的意图": "标题说明了什么",
  560. "正文补充的内容": "正文是否有实质解答",
  561. "匹配度等级": "完全匹配/高度匹配/基本匹配/弱匹配/不匹配",
  562. "核心依据": "为什么给这个分数(100字以内)"
  563. }}
  564. }}
  565. ```
  566. ---
  567. ## 评估原则
  568. 1. **图片优先**:图片权重70%,是判断的主要依据
  569. 2. **实用导向**:不看表面相关,看实际解答程度
  570. 3. **严格标准**:宁可低估,避免虚高
  571. 4. **客观量化**:基于可观察的内容特征打分
  572. ---
  573. ## 特别注意
  574. - 本评估**只关注目的动机维度**,不考虑品类是否匹配
  575. - 输出的分数必须是**0-100的整数**
  576. - 不要自行计算综合分数,只输出目的动机分数
  577. - 评分依据要具体、可验证
  578. """
  579. PROMPT4_CATEGORY_MATCH = """# Prompt 2: 多模态内容品类匹配评估
  580. ## 角色定义
  581. 你是一位专业的多模态内容评估专家,擅长分析社交媒体UGC平台帖子的**品类匹配度**
  582. 能够精准判断帖子的内容主体是否与用户需求一致。
  583. ---
  584. ## 任务说明
  585. 你将收到一个**原始搜索需求**和一条**多模态帖子**(包含图片、标题、正文)
  586. 请**仅评估品类维度**的匹配度,输出0-100分的量化得分。
  587. ---
  588. ## 输入格式
  589. **原始搜索需求:**
  590. {original_query}
  591. **多模态帖子内容:**
  592. - **图片:** {num_images}张
  593. - **标题:** {title}
  594. - **正文:** {body_text}
  595. ---
  596. ## 评估维度:品类匹配
  597. ## 评估维度
  598. 本评估系统围绕 **品类维度** 进行:
  599. # 维度独立性警告
  600. 【严格约束】本评估**只评估品类维度**,,必须遵守以下规则:
  601. 1. **只看名词和限定词**:评估时只考虑主体、限定词的匹配度
  602. 2. **完全忽略动词**:动作意图、目的等动机信息对本维度评分无影响
  603. 3. **只看词条表面,禁止联想推演
  604. 4. **通用概念 ≠ 特定概念
  605. ### 核心评估逻辑
  606. **品类 = 核心内容主体(实体名词)+ 场景/地域限定**
  607. ### 品类识别规则
  608. #### 第一步:剥离动作词,识别核心主体
  609. **必须剥离的动作词(属于目的动机,不是品类):**
  610. - 如何、怎么、制作、拍摄、寻找、推荐、学习、了解等
  611. **示例:**
  612. - "如何制作猫咪表情包" → 品类主体是**猫咪**,不是"表情包制作"
  613. - "川西风光摄影教程" → 品类主体是**川西风光**,不是"摄影教程"
  614. - "推荐日本旅行景点" → 品类主体是**日本旅行/景点**,不是"推荐"
  615. #### 第二步:识别核心主体类别
  616. **核心主体(实体名词):**
  617. - **生物类**:猫咪、狗狗、植物、人物(具体指儿童、女孩、老人等)
  618. - **地理类**:川西、成都、日本、景点名称
  619. - **物品类**:美食、服装、电子产品、家具
  620. - **场景类**:风光、建筑、室内、户外
  621. - **活动类**:旅行、运动、工作、学习场景
  622. **关键原则:品类主体必须是具体的内容对象,不是动作或形式**
  623. #### 第三步:识别场景/地域等限定词(可选)
  624. **场景/地域限定:**
  625. - **地域限定**:川西、成都、日本、欧洲
  626. - **时间限定**:秋季、夏天、2024
  627. - **场景限定**:户外、室内、职场、家居
  628. **注意:**
  629. - "表情包"、"梗图"、"照片"、"视频"等是**内容形式/载体**,不是品类主体
  630. - "教程"、"攻略"、"指南"等是**内容类型**,属于目的动机,不是品类
  631. ---
  632. ## 评估流程
  633. ### 第一步:提取原始需求的品类信息
  634. 1. **剥离所有动作词和内容形式词**
  635. 2. **识别核心主体名词**(生物、地理、物品、场景等)
  636. 3. **识别场景/地域限定**(如果有)
  637. **示例分析:**
  638. - "如何制作猫咪表情包梗图"
  639. - 剥离动作:如何、制作
  640. - 剥离形式:表情包、梗图
  641. - **核心品类主体:猫咪**
  642. - 场景限定:无
  643. ### 第二步:从帖子中提取品类信息(重点看图片)
  644. **图片识别(权重70%):**
  645. - 图片的**核心主体**是什么?(是猫、是人、是风景、是物品?)
  646. - 图片的**场景/地域特征**是什么?
  647. **标题提取(权重15%):**
  648. - 标题明确的品类主体名词
  649. **正文提取(权重15%):**
  650. - 正文描述的品类主体
  651. ### 第三步:对比品类匹配度
  652. **核心判断:主体是否一致?**
  653. - 猫咪 ≠ 女孩 → 品类完全不同 → 0-10分
  654. - 猫咪 = 猫咪 → 品类一致 → 进一步看场景限定
  655. - 川西风光 ≠ 日本风光 → 地域不同 → 30-50分
  656. - 川西风光 = 四川风光 → 地域相近 → 70-85分
  657. ---
  658. ## 评分标准(0-100分)
  659. ### 高度匹配区间
  660. **90-100分:核心主体完全一致 + 场景/地域等限定词完全匹配**
  661. - 图片主体与需求完全一致
  662. - 关键限定词全部匹配(场景、地域、时间等)
  663. - 例:需求"川西秋季风光" vs 图片展示川西秋季风景
  664. **75-89分:核心主体完全一致 + 场景/地域等限定词部分匹配**
  665. - 图片主体一致
  666. - 存在1-2个限定词缺失但不影响核心匹配
  667. - 例:需求"川西秋季风光" vs 图片展示川西风光(缺秋季)
  668. **60-74分:核心主体匹配,限定词大量缺失**
  669. - 图片主体在同一大类
  670. - 场景/地域等限定词大部分缺失
  671. - 例:需求"川西秋季风光" vs 图片展示风光
  672. ### 中度相关区间
  673. **40-59分:核心主体同大类但具体不同**
  674. - 图片主体相同但上下文不同
  675. - 限定词严重缺失或不匹配
  676. - 例:需求"川西风光摄影" vs 图片展示风光照但无地域特征
  677. ### 不相关/负向区间
  678. **20-39分:核心主体相关但类别差异明显**
  679. - 图片主体是通用概念,需求是特定概念
  680. - 仅有抽象类别相似
  681. - 例:需求"川西旅行攻略" vs 图片展示普通旅行场景
  682. **1-19分:核心主体几乎不相关**
  683. - 图片主体与需求差异明显
  684. **0分:核心主体完全不同**
  685. - 图片主体类别完全不同
  686. - 例:需求"风光摄影" vs 图片展示美食
  687. **关键原则:品类主体不同 = 品类不匹配 = 0分或极低分**
  688. ---
  689. ## 输出格式(JSON)
  690. ```json
  691. {{
  692. "品类评估": {{
  693. "原始需求品类分析": {{
  694. "完整需求": "用户的原始搜索词",
  695. "剥离动作词": "识别并剥离的动作词",
  696. "剥离形式词": "识别并剥离的内容形式词",
  697. "核心主体": "提取的核心品类主体",
  698. "场景地域限定": ["限定词1", "限定词2"]
  699. }},
  700. "帖子实际品类": {{
  701. "图片主体": "图片展示的核心主体(权重70%)",
  702. "图片场景特征": "图片的场景/地域特征",
  703. "标题主体": "标题提及的主体",
  704. "正文主体": "正文描述的主体"
  705. }},
  706. "品类匹配分析": {{
  707. "主体对比": "需求主体 vs 帖子主体",
  708. "主体是否一致": "一致/同大类不同/完全不同",
  709. "场景限定匹配情况": "哪些匹配/哪些缺失"
  710. }},
  711. "品类匹配得分": 0-100的整数,
  712. "匹配度等级": "完全匹配/高度匹配/基本匹配/弱匹配/不匹配",
  713. "核心依据": "为什么给这个分数(必须说明主体是否一致)"
  714. }}
  715. }}
  716. ```
  717. ---
  718. ## 评估原则
  719. 1. **图片优先**:图片权重70%,是判断的主要依据
  720. 2. **表面匹配**:只看实际展示的内容,禁止推测联想
  721. 3. **通用≠特定**:通用概念不等于特定概念,需明确区分
  722. 4. **严格标准**:宁可低估,避免虚高
  723. 5. **客观量化**:基于可观察的视觉特征和文字信息打分
  724. ---
  725. ## 特别注意
  726. - 本评估**只关注品类维度**,不考虑目的是否匹配
  727. - 严格标准一致性:对所有用例使用相同的评估标准,避免评分飘移
  728. - 输出的分数必须是**0-100的整数**
  729. - 不要自行计算综合分数,只输出品类分数
  730. - 禁止因为"可能相关"就给分,必须有明确视觉证据,不得用可能相关,你的评估
  731. ---
  732. """
  733. # ============================================================================
  734. # 辅助函数
  735. # ============================================================================
  736. # 视频处理函数
  737. async def download_video(video_url: str, note_id: str) -> Optional[str]:
  738. """
  739. 异步下载视频到本地文件(支持缓存)
  740. Args:
  741. video_url: 视频URL
  742. note_id: 帖子ID(用于文件命名)
  743. Returns:
  744. 本地文件路径,失败返回None
  745. """
  746. os.makedirs(TEMP_VIDEO_DIR, exist_ok=True)
  747. video_path = os.path.join(TEMP_VIDEO_DIR, f"{note_id}.mp4")
  748. # 检查视频缓存(如果文件已存在,直接返回)
  749. if os.path.exists(video_path):
  750. file_size = os.path.getsize(video_path)
  751. print(f" ♻️ 使用缓存的视频: {file_size / 1024 / 1024:.2f}MB")
  752. return video_path
  753. for attempt in range(MAX_VIDEO_DOWNLOAD_RETRIES + 1):
  754. try:
  755. loop = asyncio.get_event_loop()
  756. response = await loop.run_in_executor(
  757. None,
  758. lambda: requests.get(
  759. video_url,
  760. stream=True,
  761. timeout=VIDEO_DOWNLOAD_TIMEOUT
  762. )
  763. )
  764. if response.status_code != 200:
  765. raise Exception(f"HTTP {response.status_code}")
  766. # 检查Content-Length header(如果存在)
  767. content_length = response.headers.get('content-length')
  768. if content_length:
  769. size_mb = int(content_length) / 1024 / 1024
  770. print(f" 📊 视频大小: {size_mb:.2f}MB")
  771. if size_mb > MAX_VIDEO_SIZE_MB:
  772. print(f" ⚠️ 视频超过{MAX_VIDEO_SIZE_MB}MB限制,跳过下载")
  773. return None
  774. # 流式下载,检查大小
  775. current_size = 0
  776. max_size = MAX_VIDEO_SIZE_MB * 1024 * 1024
  777. with open(video_path, 'wb') as f:
  778. for chunk in response.iter_content(chunk_size=VIDEO_CHUNK_SIZE):
  779. if chunk:
  780. current_size += len(chunk)
  781. if current_size > max_size:
  782. # 删除不完整的文件
  783. if os.path.exists(video_path):
  784. try:
  785. os.remove(video_path)
  786. except:
  787. pass
  788. print(f" ⚠️ 视频超过{MAX_VIDEO_SIZE_MB}MB限制")
  789. return None
  790. f.write(chunk)
  791. print(f" 📥 视频下载成功: {current_size / 1024 / 1024:.2f}MB")
  792. return video_path
  793. except Exception as e:
  794. if attempt < MAX_VIDEO_DOWNLOAD_RETRIES:
  795. wait_time = 2 * (attempt + 1)
  796. print(f" ⚠️ 下载失败,{wait_time}秒后重试 ({attempt + 1}/{MAX_VIDEO_DOWNLOAD_RETRIES}) - {str(e)[:100]}")
  797. await asyncio.sleep(wait_time)
  798. else:
  799. print(f" ❌ 视频下载失败: {str(e)[:100]}")
  800. print(f" 📋 错误详情: {traceback.format_exc()[:300]}")
  801. # 清理可能的不完整文件
  802. if os.path.exists(video_path):
  803. try:
  804. os.remove(video_path)
  805. except:
  806. pass
  807. return None
  808. async def encode_video_to_base64(video_path: str) -> Optional[str]:
  809. """
  810. 将视频文件编码为base64字符串
  811. Args:
  812. video_path: 本地视频文件路径
  813. Returns:
  814. base64编码字符串,失败返回None
  815. """
  816. try:
  817. # 检查文件是否存在
  818. if not os.path.exists(video_path):
  819. print(f" ❌ 视频文件不存在: {video_path}")
  820. return None
  821. # 检查文件大小(base64编码会增加约33%的大小)
  822. file_size = os.path.getsize(video_path)
  823. estimated_base64_size = file_size * 1.33 # base64编码后的大小估算
  824. max_memory_size = MAX_VIDEO_SIZE_MB * 1024 * 1024 * 1.5 # 允许一些余量
  825. if estimated_base64_size > max_memory_size:
  826. print(f" ⚠️ 视频文件过大,无法编码到内存 ({file_size / 1024 / 1024:.2f}MB)")
  827. return None
  828. loop = asyncio.get_event_loop()
  829. def _encode_video():
  830. """同步编码函数"""
  831. try:
  832. with open(video_path, 'rb') as f:
  833. video_data = f.read()
  834. base64_str = base64.b64encode(video_data).decode('utf-8')
  835. return base64_str
  836. except MemoryError as e:
  837. print(f" ❌ 内存不足,无法编码视频: {str(e)[:100]}")
  838. raise
  839. except Exception as e:
  840. print(f" ❌ 读取/编码视频文件失败: {str(e)[:100]}")
  841. raise
  842. base64_str = await loop.run_in_executor(None, _encode_video)
  843. if base64_str:
  844. print(f" 🔐 视频编码完成: {len(base64_str) / 1024 / 1024:.2f}MB (base64)")
  845. return base64_str
  846. else:
  847. return None
  848. except MemoryError as e:
  849. print(f" ❌ 内存不足,无法编码视频: {str(e)[:100]}")
  850. print(f" 📋 错误详情: {traceback.format_exc()[:300]}")
  851. return None
  852. except Exception as e:
  853. print(f" ❌ 视频编码失败: {str(e)[:100]}")
  854. print(f" 📋 错误详情: {traceback.format_exc()[:300]}")
  855. return None
  856. def get_video_mime_type(video_path: str) -> str:
  857. """
  858. 检测视频的MIME类型
  859. Args:
  860. video_path: 视频文件路径
  861. Returns:
  862. MIME类型字符串 (默认 "video/mp4")
  863. """
  864. mime_type, _ = mimetypes.guess_type(video_path)
  865. if mime_type and mime_type.startswith('video/'):
  866. return mime_type
  867. return "video/mp4"
  868. # 视频不再清理,保留作为缓存
  869. # async def cleanup_video(video_path: str):
  870. # """
  871. # 清理临时视频文件
  872. #
  873. # Args:
  874. # video_path: 要删除的视频路径
  875. # """
  876. # try:
  877. # if os.path.exists(video_path):
  878. # os.remove(video_path)
  879. # print(f" 🗑️ 清理临时文件: {os.path.basename(video_path)}")
  880. # except Exception as e:
  881. # print(f" ⚠️ 清理失败: {str(e)[:50]}")
  882. async def _prepare_media_content(post) -> tuple[list[str], Optional[str], str]:
  883. """
  884. 统一准备媒体内容(图片+视频)
  885. Args:
  886. post: Post对象
  887. Returns:
  888. (image_urls, video_base64, video_mime_type)
  889. """
  890. # 提取图片(包括视频封面图)
  891. image_urls = post.images[:MAX_IMAGES_PER_POST] if post.images else []
  892. # 初始化视频相关变量
  893. video_base64 = None
  894. video_mime_type = "video/mp4"
  895. # 处理视频
  896. if post.type == "video" and post.video:
  897. print(f" 🎬 检测到视频帖子 (ID: {post.note_id})")
  898. print(f" 📍 视频URL: {post.video[:80]}...")
  899. print(f" 🖼️ 封面图数量: {len(image_urls)}")
  900. print(f" ⏳ 开始下载视频...")
  901. video_path = None
  902. try:
  903. # 下载视频
  904. video_path = await download_video(post.video, post.note_id)
  905. if video_path and os.path.exists(video_path):
  906. try:
  907. print(f" 🔄 开始编码视频...")
  908. # 编码视频
  909. video_base64 = await encode_video_to_base64(video_path)
  910. if video_base64:
  911. video_mime_type = get_video_mime_type(video_path)
  912. print(f" ✅ 视频处理成功!类型: {video_mime_type}")
  913. print(f" 📦 将使用视频+封面图进行评估")
  914. else:
  915. print(f" ⚠️ 视频编码失败,降级使用封面图评估")
  916. except MemoryError as e:
  917. print(f" ⚠️ 内存不足,无法处理视频: {str(e)[:100]}")
  918. print(f" 📦 降级使用封面图评估")
  919. except Exception as e:
  920. print(f" ⚠️ 视频编码异常: {str(e)[:100]}")
  921. print(f" 📋 错误详情: {traceback.format_exc()[:300]}")
  922. print(f" 📦 降级使用封面图评估")
  923. # 视频不再清理,保留作为缓存
  924. else:
  925. print(f" ⚠️ 视频下载失败,降级使用封面图评估")
  926. except Exception as e:
  927. print(f" ⚠️ 视频处理流程异常: {str(e)[:100]}")
  928. print(f" 📋 错误详情: {traceback.format_exc()[:300]}")
  929. print(f" 📦 降级使用封面图评估")
  930. # 视频不再清理,保留作为缓存
  931. elif post.type == "video" and not post.video:
  932. print(f" ⚠️ 视频类型帖子但video字段为空 (ID: {post.note_id})")
  933. # 打印最终使用的媒体
  934. if post.type == "video":
  935. if video_base64:
  936. print(f" 📊 最终媒体: {len(image_urls)}张图片 + 1个视频")
  937. else:
  938. print(f" 📊 最终媒体: {len(image_urls)}张图片 (视频处理失败)")
  939. return image_urls, video_base64, video_mime_type
  940. def _get_cache_key(note_id: str) -> str:
  941. """
  942. 生成缓存key
  943. Args:
  944. note_id: 帖子ID
  945. Returns:
  946. 缓存文件名(不含目录)
  947. """
  948. return f"{note_id}_v3.0.json"
  949. def _load_from_cache(note_id: str) -> Optional[tuple]:
  950. """
  951. 从缓存加载评估结果
  952. Args:
  953. note_id: 帖子ID
  954. Returns:
  955. 缓存的评估结果元组 (knowledge_eval, content_eval, purpose_eval, category_eval, final_score, match_level)
  956. 如果缓存不存在或读取失败,返回None
  957. """
  958. if not ENABLE_CACHE:
  959. return None
  960. cache_file = os.path.join(CACHE_DIR, _get_cache_key(note_id))
  961. if not os.path.exists(cache_file):
  962. return None
  963. try:
  964. with open(cache_file, 'r', encoding='utf-8') as f:
  965. data = json.load(f)
  966. # 重建评估对象
  967. knowledge_eval = None
  968. if data.get("knowledge_eval"):
  969. knowledge_eval = KnowledgeEvaluation(**data["knowledge_eval"])
  970. content_eval = None
  971. if data.get("content_eval"):
  972. content_eval = ContentKnowledgeEvaluation(**data["content_eval"])
  973. purpose_eval = None
  974. if data.get("purpose_eval"):
  975. purpose_eval = PurposeEvaluation(**data["purpose_eval"])
  976. category_eval = None
  977. if data.get("category_eval"):
  978. category_eval = CategoryEvaluation(**data["category_eval"])
  979. final_score = data.get("final_score")
  980. match_level = data.get("match_level")
  981. return (knowledge_eval, content_eval, purpose_eval, category_eval, final_score, match_level)
  982. except Exception as e:
  983. print(f" ⚠️ 缓存读取失败: {note_id} - {str(e)[:50]}")
  984. return None
  985. def _save_to_cache(note_id: str, eval_results: tuple):
  986. """
  987. 保存评估结果到缓存
  988. Args:
  989. note_id: 帖子ID
  990. eval_results: 评估结果元组 (knowledge_eval, content_eval, purpose_eval, category_eval, final_score, match_level)
  991. """
  992. if not ENABLE_CACHE:
  993. return
  994. knowledge_eval, content_eval, purpose_eval, category_eval, final_score, match_level = eval_results
  995. # 确保缓存目录存在
  996. os.makedirs(CACHE_DIR, exist_ok=True)
  997. # 转换为可序列化的dict
  998. cache_data = {
  999. "knowledge_eval": knowledge_eval.model_dump() if knowledge_eval else None,
  1000. "content_eval": content_eval.model_dump() if content_eval else None,
  1001. "purpose_eval": purpose_eval.model_dump() if purpose_eval else None,
  1002. "category_eval": category_eval.model_dump() if category_eval else None,
  1003. "final_score": final_score,
  1004. "match_level": match_level,
  1005. "cache_time": datetime.now().isoformat(),
  1006. "evaluator_version": "v3.0"
  1007. }
  1008. cache_file = os.path.join(CACHE_DIR, _get_cache_key(note_id))
  1009. try:
  1010. with open(cache_file, 'w', encoding='utf-8') as f:
  1011. json.dump(cache_data, f, ensure_ascii=False, indent=2)
  1012. except Exception as e:
  1013. print(f" ⚠️ 缓存保存失败: {note_id} - {str(e)[:50]}")
  1014. def _clean_json_response(content_text: str) -> str:
  1015. """清理API返回的JSON内容"""
  1016. content_text = content_text.strip()
  1017. if content_text.startswith("```json"):
  1018. content_text = content_text[7:]
  1019. elif content_text.startswith("```"):
  1020. content_text = content_text[3:]
  1021. if content_text.endswith("```"):
  1022. content_text = content_text[:-3]
  1023. return content_text.strip()
  1024. async def _call_openrouter_api(
  1025. prompt_text: str,
  1026. image_urls: list[str],
  1027. video_base64: Optional[str] = None,
  1028. video_mime_type: str = "video/mp4",
  1029. semaphore: Optional[asyncio.Semaphore] = None
  1030. ) -> dict:
  1031. """
  1032. 调用OpenRouter API的通用函数
  1033. Args:
  1034. prompt_text: Prompt文本
  1035. image_urls: 图片URL列表
  1036. video_base64: 视频的base64编码字符串(可选)
  1037. video_mime_type: 视频MIME类型(默认video/mp4)
  1038. semaphore: 并发控制信号量
  1039. Returns:
  1040. 解析后的JSON响应
  1041. """
  1042. api_key = os.getenv("OPENROUTER_API_KEY")
  1043. if not api_key:
  1044. raise ValueError("OPENROUTER_API_KEY environment variable not set")
  1045. content = [{"type": "text", "text": prompt_text}]
  1046. for url in image_urls:
  1047. content.append({"type": "image_url", "image_url": {"url": url}})
  1048. # 添加视频(如果存在)
  1049. if video_base64:
  1050. data_url = f"data:{video_mime_type};base64,{video_base64}"
  1051. content.append({"type": "video_url", "video_url": {"url": data_url}})
  1052. payload = {
  1053. "model": MODEL_NAME,
  1054. "messages": [{"role": "user", "content": content}],
  1055. "response_format": {"type": "json_object"}
  1056. }
  1057. headers = {
  1058. "Authorization": f"Bearer {api_key}",
  1059. "Content-Type": "application/json"
  1060. }
  1061. async def _make_request():
  1062. loop = asyncio.get_event_loop()
  1063. response = await loop.run_in_executor(
  1064. None,
  1065. lambda: requests.post(
  1066. "https://openrouter.ai/api/v1/chat/completions",
  1067. headers=headers,
  1068. json=payload,
  1069. timeout=API_TIMEOUT
  1070. )
  1071. )
  1072. if response.status_code != 200:
  1073. raise Exception(f"API error: {response.status_code} - {response.text[:200]}")
  1074. result = response.json()
  1075. content_text = result["choices"][0]["message"]["content"]
  1076. content_text = _clean_json_response(content_text)
  1077. return json.loads(content_text)
  1078. async def _execute_with_retry():
  1079. """执行API请求,失败时自动重试最多2次"""
  1080. MAX_RETRIES = 2
  1081. for attempt in range(MAX_RETRIES + 1):
  1082. try:
  1083. return await _make_request()
  1084. except Exception as e:
  1085. if attempt < MAX_RETRIES:
  1086. wait_time = 2 * (attempt + 1) # 2秒, 4秒
  1087. print(f" ⚠️ API调用失败,{wait_time}秒后重试 (第{attempt + 1}/{MAX_RETRIES}次重试) - {str(e)[:50]}")
  1088. await asyncio.sleep(wait_time)
  1089. else:
  1090. # 最后一次尝试也失败,抛出异常
  1091. raise
  1092. if semaphore:
  1093. async with semaphore:
  1094. return await _execute_with_retry()
  1095. else:
  1096. return await _execute_with_retry()
  1097. # ============================================================================
  1098. # 核心评估函数
  1099. # ============================================================================
  1100. async def evaluate_is_knowledge(
  1101. post,
  1102. semaphore: Optional[asyncio.Semaphore] = None
  1103. ) -> Optional[KnowledgeEvaluation]:
  1104. """
  1105. Prompt1: 判断是知识
  1106. Args:
  1107. post: Post对象
  1108. semaphore: 并发控制信号量
  1109. Returns:
  1110. KnowledgeEvaluation 或 None(失败时)
  1111. """
  1112. # 准备媒体内容(图片+视频)
  1113. image_urls, video_base64, video_mime_type = await _prepare_media_content(post)
  1114. try:
  1115. prompt_text = PROMPT1_IS_KNOWLEDGE.format(
  1116. title=post.title,
  1117. body_text=post.body_text or "",
  1118. num_images=len(image_urls)
  1119. )
  1120. data = await _call_openrouter_api(prompt_text, image_urls, video_base64, video_mime_type, semaphore)
  1121. return KnowledgeEvaluation(
  1122. is_knowledge=data.get("is_knowledge", False),
  1123. quick_exclude=data.get("quick_exclude", {}),
  1124. title_layer=data.get("title_layer", {}),
  1125. image_layer=data.get("image_layer", {}),
  1126. text_layer=data.get("text_layer", {}),
  1127. judgment_logic=data.get("judgment_logic", ""),
  1128. core_evidence=data.get("core_evidence", []),
  1129. issues=data.get("issues", []),
  1130. conclusion=data.get("conclusion", "")
  1131. )
  1132. except Exception as e:
  1133. print(f" ❌ Prompt1评估失败: {post.note_id} - {str(e)[:100]}")
  1134. return None
  1135. async def evaluate_is_content_knowledge(
  1136. post,
  1137. semaphore: Optional[asyncio.Semaphore] = None
  1138. ) -> Optional[ContentKnowledgeEvaluation]:
  1139. """
  1140. Prompt2: 判断是否是内容知识
  1141. Args:
  1142. post: Post对象
  1143. semaphore: 并发控制信号量
  1144. Returns:
  1145. ContentKnowledgeEvaluation 或 None(失败时)
  1146. """
  1147. # 准备媒体内容(图片+视频)
  1148. image_urls, video_base64, video_mime_type = await _prepare_media_content(post)
  1149. try:
  1150. prompt_text = PROMPT2_IS_CONTENT_KNOWLEDGE.format(
  1151. title=post.title,
  1152. body_text=post.body_text or "",
  1153. num_images=len(image_urls)
  1154. )
  1155. data = await _call_openrouter_api(prompt_text, image_urls, video_base64, video_mime_type, semaphore)
  1156. # 判定是否是内容知识:得分 >= 55 分
  1157. final_score = data.get("final_score", 0)
  1158. is_content_knowledge = final_score >= 55
  1159. return ContentKnowledgeEvaluation(
  1160. is_content_knowledge=is_content_knowledge,
  1161. final_score=final_score,
  1162. level=data.get("level", ""),
  1163. quick_exclude=data.get("quick_exclude", {}),
  1164. dimension_scores=data.get("dimension_scores", {}),
  1165. core_evidence=data.get("core_evidence", []),
  1166. issues=data.get("issues", []),
  1167. summary=data.get("summary", "")
  1168. )
  1169. except Exception as e:
  1170. print(f" ❌ Prompt2评估失败: {post.note_id} - {str(e)[:100]}")
  1171. return None
  1172. async def evaluate_purpose_match(
  1173. post,
  1174. original_query: str,
  1175. semaphore: Optional[asyncio.Semaphore] = None
  1176. ) -> Optional[PurposeEvaluation]:
  1177. """
  1178. Prompt3: 目的性匹配评估
  1179. Args:
  1180. post: Post对象
  1181. original_query: 原始搜索query
  1182. semaphore: 并发控制信号量
  1183. Returns:
  1184. PurposeEvaluation 或 None(失败时)
  1185. """
  1186. # 准备媒体内容(图片+视频)
  1187. image_urls, video_base64, video_mime_type = await _prepare_media_content(post)
  1188. try:
  1189. prompt_text = PROMPT3_PURPOSE_MATCH.format(
  1190. original_query=original_query,
  1191. title=post.title,
  1192. body_text=post.body_text or "",
  1193. num_images=len(image_urls)
  1194. )
  1195. data = await _call_openrouter_api(prompt_text, image_urls, video_base64, video_mime_type, semaphore)
  1196. # Prompt3的输出在"目的动机评估"键下
  1197. purpose_data = data.get("目的动机评估", {})
  1198. return PurposeEvaluation(
  1199. purpose_score=purpose_data.get("目的动机得分", 0),
  1200. core_motivation=purpose_data.get("原始需求核心动机", ""),
  1201. image_value=purpose_data.get("图片提供的价值", ""),
  1202. title_intention=purpose_data.get("标题体现的意图", ""),
  1203. text_content=purpose_data.get("正文补充的内容", ""),
  1204. match_level=purpose_data.get("匹配度等级", ""),
  1205. core_basis=purpose_data.get("核心依据", "")
  1206. )
  1207. except Exception as e:
  1208. print(f" ❌ Prompt3评估失败: {post.note_id} - {str(e)[:100]}")
  1209. return None
  1210. async def evaluate_category_match(
  1211. post,
  1212. original_query: str,
  1213. semaphore: Optional[asyncio.Semaphore] = None
  1214. ) -> Optional[CategoryEvaluation]:
  1215. """
  1216. Prompt4: 品类匹配评估
  1217. Args:
  1218. post: Post对象
  1219. original_query: 原始搜索query
  1220. semaphore: 并发控制信号量
  1221. Returns:
  1222. CategoryEvaluation 或 None(失败时)
  1223. """
  1224. # 准备媒体内容(图片+视频)
  1225. image_urls, video_base64, video_mime_type = await _prepare_media_content(post)
  1226. try:
  1227. prompt_text = PROMPT4_CATEGORY_MATCH.format(
  1228. original_query=original_query,
  1229. title=post.title,
  1230. body_text=post.body_text or "",
  1231. num_images=len(image_urls)
  1232. )
  1233. data = await _call_openrouter_api(prompt_text, image_urls, video_base64, video_mime_type, semaphore)
  1234. # Prompt4的输出在"品类评估"键下
  1235. category_data = data.get("品类评估", {})
  1236. return CategoryEvaluation(
  1237. category_score=category_data.get("品类匹配得分", 0),
  1238. original_category_analysis=category_data.get("原始需求品类分析", {}),
  1239. actual_category=category_data.get("帖子实际品类", {}),
  1240. match_level=category_data.get("匹配度等级", ""),
  1241. category_match_analysis=category_data.get("品类匹配分析", {}),
  1242. core_basis=category_data.get("核心依据", "")
  1243. )
  1244. except Exception as e:
  1245. print(f" ❌ Prompt4评估失败: {post.note_id} - {str(e)[:100]}")
  1246. return None
  1247. def calculate_final_score(purpose_score: int, category_score: int) -> tuple[float, str]:
  1248. """
  1249. 计算综合得分和匹配等级
  1250. Args:
  1251. purpose_score: 目的性得分 (0-100整数)
  1252. category_score: 品类得分 (0-100整数)
  1253. Returns:
  1254. (final_score, match_level)
  1255. - final_score: 保留2位小数
  1256. - match_level: 匹配等级字符串
  1257. """
  1258. # 计算综合得分: 目的性70% + 品类30%
  1259. final = round(purpose_score * 0.5 + category_score * 0.5, 2)
  1260. # 判定匹配等级
  1261. if final >= 85:
  1262. level = "高度匹配"
  1263. elif final >= 70:
  1264. level = "基本匹配"
  1265. elif final >= 50:
  1266. level = "部分匹配"
  1267. elif final >= 30:
  1268. level = "弱匹配"
  1269. else:
  1270. level = "不匹配"
  1271. return final, level
  1272. async def evaluate_post_v3(
  1273. post,
  1274. original_query: str,
  1275. semaphore: Optional[asyncio.Semaphore] = None
  1276. ) -> tuple:
  1277. """
  1278. V3评估主函数(4步流程)
  1279. 流程:
  1280. 1. Prompt1: 判断是知识 → 如果不是知识,停止
  1281. 2. Prompt2: 判断是否是内容知识 → 如果不是内容知识,停止
  1282. 3. Prompt3 & Prompt4: 并行执行目的性和品类匹配
  1283. 4. 计算综合得分
  1284. Returns:
  1285. (knowledge_eval, content_eval, purpose_eval, category_eval, final_score, match_level)
  1286. 任一步骤失败,后续结果为None
  1287. """
  1288. # 检查缓存
  1289. if ENABLE_CACHE:
  1290. cached_result = _load_from_cache(post.note_id)
  1291. if cached_result is not None:
  1292. print(f" ♻️ 使用缓存结果: {post.note_id}")
  1293. return cached_result
  1294. print(f" 🔍 开始V3评估: {post.note_id}")
  1295. # Step 1: 判断是知识
  1296. print(f" 📝 Step 1/4: 判断是知识...")
  1297. knowledge_eval = await evaluate_is_knowledge(post, semaphore)
  1298. if not knowledge_eval:
  1299. print(f" ❌ Step 1失败,停止评估")
  1300. return (None, None, None, None, None, None)
  1301. if not knowledge_eval.is_knowledge:
  1302. print(f" ⊗ 非知识内容,停止后续评估")
  1303. return (knowledge_eval, None, None, None, None, None)
  1304. print(f" ✅ Step 1: 是知识内容")
  1305. # Step 2: 判断是否是内容知识
  1306. print(f" 📝 Step 2/4: 判断是否是内容知识...")
  1307. content_eval = await evaluate_is_content_knowledge(post, semaphore)
  1308. if not content_eval:
  1309. print(f" ❌ Step 2失败,停止评估")
  1310. return (knowledge_eval, None, None, None, None, None)
  1311. if not content_eval.is_content_knowledge:
  1312. print(f" ⊗ 非内容知识,停止后续评估 (得分: {content_eval.final_score})")
  1313. return (knowledge_eval, content_eval, None, None, None, None)
  1314. print(f" ✅ Step 2: 是内容知识 (得分: {content_eval.final_score})")
  1315. # Step 3 & 4: 并行执行目的性和品类匹配
  1316. print(f" 📝 Step 3&4/4: 并行执行目的性和品类匹配...")
  1317. purpose_task = evaluate_purpose_match(post, original_query, semaphore)
  1318. category_task = evaluate_category_match(post, original_query, semaphore)
  1319. purpose_eval, category_eval = await asyncio.gather(purpose_task, category_task)
  1320. if not purpose_eval or not category_eval:
  1321. print(f" ❌ Step 3或4失败")
  1322. return (knowledge_eval, content_eval, purpose_eval, category_eval, None, None)
  1323. print(f" ✅ Step 3: 目的性得分 = {purpose_eval.purpose_score}")
  1324. print(f" ✅ Step 4: 品类得分 = {category_eval.category_score}")
  1325. # Step 5: 计算综合得分
  1326. final_score, match_level = calculate_final_score(
  1327. purpose_eval.purpose_score,
  1328. category_eval.category_score
  1329. )
  1330. print(f" ✅ 综合得分: {final_score} ({match_level})")
  1331. # 保存到缓存
  1332. if ENABLE_CACHE:
  1333. _save_to_cache(post.note_id, (knowledge_eval, content_eval, purpose_eval, category_eval, final_score, match_level))
  1334. return (knowledge_eval, content_eval, purpose_eval, category_eval, final_score, match_level)
  1335. def apply_evaluation_v3_to_post(
  1336. post,
  1337. knowledge_eval: Optional[KnowledgeEvaluation],
  1338. content_eval: Optional[ContentKnowledgeEvaluation],
  1339. purpose_eval: Optional[PurposeEvaluation],
  1340. category_eval: Optional[CategoryEvaluation],
  1341. final_score: Optional[float],
  1342. match_level: Optional[str]
  1343. ):
  1344. """
  1345. 将V3评估结果应用到Post对象(覆盖原有字段)
  1346. Args:
  1347. post: Post对象
  1348. knowledge_eval: Prompt1结果
  1349. content_eval: Prompt2结果
  1350. purpose_eval: Prompt3结果
  1351. category_eval: Prompt4结果
  1352. final_score: 综合得分
  1353. match_level: 匹配等级
  1354. """
  1355. # Prompt1: 判断是知识
  1356. if knowledge_eval:
  1357. post.is_knowledge = knowledge_eval.is_knowledge
  1358. post.knowledge_evaluation = {
  1359. "quick_exclude": knowledge_eval.quick_exclude,
  1360. "title_layer": knowledge_eval.title_layer,
  1361. "image_layer": knowledge_eval.image_layer,
  1362. "text_layer": knowledge_eval.text_layer,
  1363. "judgment_logic": knowledge_eval.judgment_logic,
  1364. "core_evidence": knowledge_eval.core_evidence,
  1365. "issues": knowledge_eval.issues,
  1366. "conclusion": knowledge_eval.conclusion
  1367. }
  1368. # Prompt2: 判断是否是内容知识
  1369. if content_eval:
  1370. post.is_content_knowledge = content_eval.is_content_knowledge
  1371. post.knowledge_score = float(content_eval.final_score)
  1372. post.content_knowledge_evaluation = {
  1373. "is_content_knowledge": content_eval.is_content_knowledge,
  1374. "final_score": content_eval.final_score,
  1375. "level": content_eval.level,
  1376. "quick_exclude": content_eval.quick_exclude,
  1377. "dimension_scores": content_eval.dimension_scores,
  1378. "core_evidence": content_eval.core_evidence,
  1379. "issues": content_eval.issues,
  1380. "summary": content_eval.summary
  1381. }
  1382. # Prompt3: 目的性匹配
  1383. if purpose_eval:
  1384. post.purpose_score = purpose_eval.purpose_score
  1385. post.purpose_evaluation = {
  1386. "purpose_score": purpose_eval.purpose_score,
  1387. "core_motivation": purpose_eval.core_motivation,
  1388. "image_value": purpose_eval.image_value,
  1389. "title_intention": purpose_eval.title_intention,
  1390. "text_content": purpose_eval.text_content,
  1391. "match_level": purpose_eval.match_level,
  1392. "core_basis": purpose_eval.core_basis
  1393. }
  1394. # Prompt4: 品类匹配
  1395. if category_eval:
  1396. post.category_score = category_eval.category_score
  1397. post.category_evaluation = {
  1398. "category_score": category_eval.category_score,
  1399. "original_category_analysis": category_eval.original_category_analysis,
  1400. "actual_category": category_eval.actual_category,
  1401. "match_level": category_eval.match_level,
  1402. "category_match_analysis": category_eval.category_match_analysis,
  1403. "core_basis": category_eval.core_basis
  1404. }
  1405. # 综合得分
  1406. if final_score is not None and match_level is not None:
  1407. post.final_score = final_score
  1408. post.match_level = match_level
  1409. # 设置评估时间和版本
  1410. post.evaluation_time = datetime.now().isoformat()
  1411. post.evaluator_version = "v3.0"
  1412. async def batch_evaluate_posts_v3(
  1413. posts: list,
  1414. original_query: str,
  1415. max_concurrent: int = MAX_CONCURRENT_EVALUATIONS
  1416. ) -> int:
  1417. """
  1418. 批量评估多个帖子(V3版本)
  1419. Args:
  1420. posts: Post对象列表
  1421. original_query: 原始搜索query
  1422. max_concurrent: 最大并发数
  1423. Returns:
  1424. 成功评估的帖子数量
  1425. """
  1426. semaphore = asyncio.Semaphore(max_concurrent)
  1427. print(f"\n📊 开始批量评估 {len(posts)} 个帖子(并发限制: {max_concurrent})...")
  1428. tasks = [evaluate_post_v3(post, original_query, semaphore) for post in posts]
  1429. results = await asyncio.gather(*tasks)
  1430. success_count = 0
  1431. for i, result in enumerate(results):
  1432. knowledge_eval, content_eval, purpose_eval, category_eval, final_score, match_level = result
  1433. # 只要有Prompt1结果就算部分成功
  1434. if knowledge_eval:
  1435. apply_evaluation_v3_to_post(
  1436. posts[i],
  1437. knowledge_eval,
  1438. content_eval,
  1439. purpose_eval,
  1440. category_eval,
  1441. final_score,
  1442. match_level
  1443. )
  1444. success_count += 1
  1445. print(f"✅ 批量评估完成: {success_count}/{len(posts)} 帖子已评估")
  1446. return success_count
  1447. # ============================================================================
  1448. # 两阶段评估:粗评+细评
  1449. # ============================================================================
  1450. async def _prepare_media_content_lite(post) -> tuple[list[str], None, str]:
  1451. """
  1452. 轻量级媒体准备(粗评专用)
  1453. 只使用封面图,不处理视频,正文截断
  1454. Args:
  1455. post: Post对象
  1456. Returns:
  1457. (image_urls, None, "video/mp4") # 不返回视频
  1458. """
  1459. # 只取第一张图作为封面
  1460. image_urls = [post.images[0]] if post.images else []
  1461. # 不处理视频,直接返回None
  1462. return image_urls, None, "video/mp4"
  1463. async def evaluate_post_quick(
  1464. post,
  1465. original_query: str,
  1466. semaphore: Optional[asyncio.Semaphore] = None
  1467. ) -> tuple:
  1468. """
  1469. 粗评函数:只评估Prompt1+Prompt2,使用简化数据
  1470. 用于快速过滤非知识和非内容知识的帖子
  1471. Args:
  1472. post: Post对象
  1473. original_query: 原始搜索query(暂未使用,为接口一致性保留)
  1474. semaphore: 并发控制信号量
  1475. Returns:
  1476. (knowledge_eval, content_eval, should_proceed_to_detail)
  1477. - knowledge_eval: Prompt1评估结果
  1478. - content_eval: Prompt2评估结果(如果Prompt1通过)
  1479. - should_proceed_to_detail: 是否需要进入细评
  1480. """
  1481. # 备份原始数据
  1482. original_body_text = post.body_text
  1483. original_prepare_func = globals()['_prepare_media_content']
  1484. try:
  1485. # 临时替换为轻量级媒体准备函数
  1486. globals()['_prepare_media_content'] = _prepare_media_content_lite
  1487. # 正文截断到500字符
  1488. if post.body_text:
  1489. post.body_text = post.body_text[:500]
  1490. # Step 1: 判断是知识
  1491. knowledge_eval = await evaluate_is_knowledge(post, semaphore)
  1492. if not knowledge_eval or not knowledge_eval.is_knowledge:
  1493. # 非知识,不需要细评
  1494. return (knowledge_eval, None, False)
  1495. # Step 2: 判断是否是内容知识
  1496. content_eval = await evaluate_is_content_knowledge(post, semaphore)
  1497. if not content_eval or not content_eval.is_content_knowledge:
  1498. # 非内容知识,不需要细评
  1499. return (knowledge_eval, content_eval, False)
  1500. # 通过粗评,需要细评
  1501. return (knowledge_eval, content_eval, True)
  1502. finally:
  1503. # 恢复原始数据和函数
  1504. post.body_text = original_body_text
  1505. globals()['_prepare_media_content'] = original_prepare_func
  1506. async def two_stage_batch_evaluate(
  1507. posts: list,
  1508. original_query: str,
  1509. quick_concurrent: int = 15,
  1510. detail_concurrent: int = 15
  1511. ) -> int:
  1512. """
  1513. 两阶段批量评估:粗评过滤 + 细评打分
  1514. Args:
  1515. posts: Post对象列表
  1516. original_query: 原始搜索query
  1517. quick_concurrent: 粗评并发数
  1518. detail_concurrent: 细评并发数
  1519. Returns:
  1520. 成功评估的帖子数量
  1521. """
  1522. import time
  1523. # 记录总开始时间
  1524. total_start_time = time.time()
  1525. print(f"\n{'='*80}")
  1526. print(f"🚀 两阶段评估模式")
  1527. print(f"{'='*80}\n")
  1528. # ========== 阶段1: 粗评(快速过滤) ==========
  1529. print(f"📊 阶段1/2: 粗评(快速过滤)")
  1530. print(f" - 数据: 标题 + 正文(前500字) + 封面图")
  1531. print(f" - 评估: Prompt1 + Prompt2")
  1532. print(f" - 并发: {quick_concurrent}")
  1533. print(f" - 帖子数: {len(posts)}\n")
  1534. # 粗评开始时间
  1535. quick_start_time = time.time()
  1536. print("⏳ 正在执行粗评...")
  1537. quick_semaphore = asyncio.Semaphore(quick_concurrent)
  1538. quick_tasks = [evaluate_post_quick(post, original_query, quick_semaphore) for post in posts]
  1539. quick_results = await asyncio.gather(*quick_tasks)
  1540. # 粗评结束时间
  1541. quick_end_time = time.time()
  1542. quick_duration = quick_end_time - quick_start_time
  1543. # 过滤结果
  1544. print("\n📋 粗评结果:\n")
  1545. posts_for_detail = []
  1546. quick_stats = {"淘汰": 0, "通过": 0}
  1547. for i, (knowledge_eval, content_eval, should_detail) in enumerate(quick_results):
  1548. post = posts[i]
  1549. progress_percent = (i + 1) / len(posts) * 100
  1550. if should_detail:
  1551. posts_for_detail.append(post)
  1552. quick_stats["通过"] += 1
  1553. print(f" ✅ [{i+1:3d}/{len(posts)} {progress_percent:5.1f}%] {post.title[:45]:<45} - 通过粗评")
  1554. else:
  1555. quick_stats["淘汰"] += 1
  1556. if not knowledge_eval or not knowledge_eval.is_knowledge:
  1557. reason = "非知识"
  1558. else:
  1559. reason = f"非内容知识(得分:{content_eval.final_score if content_eval else 0})"
  1560. print(f" ❌ [{i+1:3d}/{len(posts)} {progress_percent:5.1f}%] {post.title[:45]:<45} - {reason}")
  1561. # 保存粗评结果(标记为淘汰)
  1562. apply_evaluation_v3_to_post(
  1563. post, knowledge_eval, content_eval, None, None, None, "粗评淘汰"
  1564. )
  1565. print(f"\n📈 粗评统计:")
  1566. print(f" 通过: {quick_stats['通过']:3d}/{len(posts)} ({quick_stats['通过']/len(posts)*100:5.1f}%)")
  1567. print(f" 淘汰: {quick_stats['淘汰']:3d}/{len(posts)} ({quick_stats['淘汰']/len(posts)*100:5.1f}%)")
  1568. print(f" ⏱️ 耗时: {quick_duration:.2f}秒 (平均 {quick_duration/len(posts):.2f}秒/帖)")
  1569. if not posts_for_detail:
  1570. total_duration = time.time() - total_start_time
  1571. print(f"\n⚠️ 没有帖子通过粗评,评估结束")
  1572. print(f"⏱️ 总耗时: {total_duration:.2f}秒")
  1573. return len(posts) - quick_stats["淘汰"]
  1574. # ========== 阶段2: 细评(完整评估) ==========
  1575. print(f"\n{'='*80}")
  1576. print(f"📊 阶段2/2: 细评(完整评估)")
  1577. print(f" - 数据: 全部图片(最多10张) + 完整正文 + 视频")
  1578. print(f" - 评估: 完整4步流程(Prompt1-4)")
  1579. print(f" - 并发: {detail_concurrent}")
  1580. print(f" - 帖子数: {len(posts_for_detail)}\n")
  1581. # 细评开始时间
  1582. detail_start_time = time.time()
  1583. print("⏳ 正在执行细评...")
  1584. detail_semaphore = asyncio.Semaphore(detail_concurrent)
  1585. detail_tasks = [evaluate_post_v3(post, original_query, detail_semaphore)
  1586. for post in posts_for_detail]
  1587. detail_results = await asyncio.gather(*detail_tasks)
  1588. # 细评结束时间
  1589. detail_end_time = time.time()
  1590. detail_duration = detail_end_time - detail_start_time
  1591. print("\n📋 细评结果:\n")
  1592. success_count = 0
  1593. for i, result in enumerate(detail_results):
  1594. knowledge_eval, content_eval, purpose_eval, category_eval, final_score, match_level = result
  1595. progress_percent = (i + 1) / len(posts_for_detail) * 100
  1596. if knowledge_eval:
  1597. apply_evaluation_v3_to_post(
  1598. posts_for_detail[i],
  1599. knowledge_eval, content_eval, purpose_eval, category_eval,
  1600. final_score, match_level
  1601. )
  1602. success_count += 1
  1603. post = posts_for_detail[i]
  1604. score_str = f"{final_score:.1f}" if final_score is not None else "N/A"
  1605. level_str = match_level if match_level else "未完成"
  1606. print(f" ✅ [{i+1:3d}/{len(posts_for_detail)} {progress_percent:5.1f}%] {post.title[:40]:<40} - {score_str}分 ({level_str})")
  1607. # 计算总耗时
  1608. total_duration = time.time() - total_start_time
  1609. print(f"\n{'='*80}")
  1610. print(f"✅ 两阶段评估完成:")
  1611. print(f" 粗评通过: {len(posts_for_detail):3d}/{len(posts)}")
  1612. print(f" 细评成功: {success_count:3d}/{len(posts_for_detail)}")
  1613. print(f" 最终有效: {success_count:3d}/{len(posts)} ({success_count/len(posts)*100:5.1f}%)")
  1614. print(f"\n⏱️ 耗时统计:")
  1615. print(f" 粗评阶段: {quick_duration:.2f}秒 ({len(posts)}个帖子, 平均 {quick_duration/len(posts):.2f}秒/帖)")
  1616. print(f" 细评阶段: {detail_duration:.2f}秒 ({len(posts_for_detail)}个帖子, 平均 {detail_duration/len(posts_for_detail):.2f}秒/帖)" if len(posts_for_detail) > 0 else f" 细评阶段: {detail_duration:.2f}秒")
  1617. print(f" 总耗时: {total_duration:.2f}秒 ({total_duration/60:.2f}分钟)")
  1618. print(f" 平均速度: {total_duration/len(posts):.2f}秒/帖")
  1619. print(f"{'='*80}\n")
  1620. return len(posts) - quick_stats["淘汰"] + success_count