post_evaluator_v4_langgraph.py 74 KB

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