post_evaluator_v2.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. """
  2. 帖子评估模块 V2 - 分离的知识评估和相关性评估
  3. 改进:
  4. 1. 知识评估: 6维度分层打分系统 (0-100分)
  5. 2. 相关性评估: 目的性(70%) + 品类(30%)
  6. 3. 并发评估: 两个API同时调用
  7. 4. 详细数据: 嵌套结构存储完整评估信息
  8. """
  9. import asyncio
  10. import json
  11. import os
  12. from datetime import datetime
  13. from typing import Optional
  14. from pydantic import BaseModel, Field
  15. import requests
  16. MODEL_NAME = "google/gemini-2.5-flash"
  17. MAX_IMAGES_PER_POST = 10
  18. MAX_CONCURRENT_EVALUATIONS = 5
  19. API_TIMEOUT = 120
  20. # ============================================================================
  21. # 数据模型
  22. # ============================================================================
  23. class KnowledgeEvaluation(BaseModel):
  24. """知识评估结果"""
  25. is_knowledge: bool = Field(..., description="是否是知识内容")
  26. quick_exclude: bool = Field(False, description="快速排除判定")
  27. dimension_scores: dict[str, int] = Field(default_factory=dict, description="6维度得分")
  28. weighted_score: float = Field(..., description="加权总分(0-100)")
  29. level: int = Field(..., description="满足度等级(1-5星)")
  30. evidence: list[str] = Field(default_factory=list, description="关键证据")
  31. issues: list[str] = Field(default_factory=list, description="存在问题")
  32. summary: str = Field(..., description="总结陈述")
  33. class RelevanceEvaluation(BaseModel):
  34. """相关性评估结果"""
  35. purpose_score: float = Field(..., description="目的性匹配得分(0-100)")
  36. category_score: float = Field(..., description="品类匹配得分(0-100)")
  37. total_score: float = Field(..., description="综合得分(0-100)")
  38. conclusion: str = Field(..., description="匹配结论")
  39. summary: str = Field(..., description="总结说明")
  40. # ============================================================================
  41. # Prompt 定义
  42. # ============================================================================
  43. KNOWLEDGE_EVALUATION_PROMPT = """# 内容知识判定系统
  44. ## 角色定义
  45. 你是一个多模态内容评估专家,专门判断社交媒体帖子是否属于"内容知识"类别。
  46. ## 内容知识定义
  47. **内容知识**是指对创作/制作有实际帮助的、具有通用性和可迁移性的知识,包括:
  48. - ✅ **原理型知识**: 讲解创作背后的原理、逻辑、方法论
  49. - ✅ **体系型知识**: 提供完整的框架、流程、体系化方法
  50. - ✅ **案例提炼型知识**: 通过多案例总结出通用规律和可复用方法
  51. **非内容知识**(需严格排除):
  52. - ❌ **单案例展示**: 仅展示某一个作品/项目,无方法论提炼
  53. - ❌ **单点细节**: 只讲某个具体细节的操作,缺乏系统性
  54. - ❌ **纯元素展示**: 配色/字体/素材等单点展示,无创作方法
  55. - ❌ **作品集型**: 纯粹的作品展示集合,无教学目的
  56. ---
  57. ## 输入信息
  58. - **标题**: {title}
  59. - **正文**: {body_text}
  60. - **图片数量**: {num_images}张
  61. ---
  62. ## 判断流程
  63. ### 第一步: 快速排除判断(任一项为"是"则直接判定为非内容知识)
  64. 1. 标题是否为纯展示型? (如:"我的XX作品"、"今天做了XX"、"分享一下")
  65. 2. 正文或者图片里内容是否缺乏方法/原理/步骤描述,仅是叙事或展示?
  66. 3. 图片是否全为作品展示,无原理型/体系型/知识提炼型内容元素?
  67. 4. 是否只讲一个具体项目的单次操作,无通用性?
  68. **输出**: "quick_exclude": true/false
  69. ---
  70. ### 第二步: 分层评估体系(满分10分)
  71. #### 维度1: 标题语义 (权重15%)
  72. - 10分: 明确包含"教程/方法/技巧/如何/原理/攻略/指南/X步"等教学词
  73. - 7分: 包含"合集/总结/分享XX方法"等整理型词汇
  74. - 4分: 描述性标题但暗示有方法论
  75. - 0分: 纯展示型标题或单案例描述
  76. #### 维度2: 封面首图 (权重60%)
  77. - 10分: 包含步骤编号/流程图/对比图/知识框架图
  78. - 7分: 有明显的教学性文字标注或视觉引导
  79. - 4分: 有多个知识点的视觉呈现
  80. - 0分: 单一作品展示或纯美图
  81. #### 维度3: 多图教学性 (权重60%)
  82. - 10分: 多图形成步骤/对比/原理说明体系,有标注/序号/箭头
  83. - 7分: 多图展示不同方法/案例,有一定教学逻辑
  84. - 4分: 多图但教学性不明显
  85. - 0分: 多图仅为作品多角度展示
  86. #### 维度4: 内容结构 (权重60%)
  87. - 10分: 有清晰的知识框架(原理→方法→案例,或问题→方案→总结)
  88. - 7分: 有分层次的内容组织(分章节/要点/步骤展示)
  89. - 4分: 有一定逻辑但不够系统
  90. - 0分: 流水账式/单线性叙述
  91. #### 维度5: 正文步骤性 (权重25%)
  92. - 10分: 有清晰的步骤序号和完整流程(≥3步)
  93. - 7分: 有步骤描述但不够系统化
  94. - 4分: 有零散的方法提及
  95. - 0分: 无步骤,纯叙事或展示
  96. #### 维度6: 知识提炼度 (权重25%)
  97. - 10分: 有明确的总结/归纳/对比/框架化输出
  98. - 7分: 有一定的知识整理
  99. - 4分: 有零散总结
  100. - 0分: 无任何知识提炼
  101. ---
  102. ### 第三步: 综合计算
  103. **加权总分计算**:
  104. ```
  105. 加权分 = 维度1×0.15 + (维度2+维度3+维度4)×0.6/3 + (维度5+维度6)×0.25/2
  106. 最终得分(weighted_score) = 加权分 × 10 (转换为0-100分)
  107. ```
  108. **满足度等级**:
  109. - 90-100分: 5星 ⭐⭐⭐⭐⭐ 优质内容知识
  110. - 75-89分: 4星 ⭐⭐⭐⭐ 良好内容知识
  111. - 60-74分: 3星 ⭐⭐⭐ 基础内容知识
  112. - 45-59分: 2星 ⭐⭐ 弱内容知识倾向
  113. - 0-44分: 1星 ⭐ 非内容知识
  114. ---
  115. ## 输出格式
  116. 请严格按照以下JSON格式输出:
  117. {{
  118. "is_knowledge": true/false,
  119. "quick_exclude": false,
  120. "dimension_scores": {{
  121. "标题语义": 8,
  122. "封面首图": 9,
  123. "多图教学性": 10,
  124. "内容结构": 7,
  125. "正文步骤性": 9,
  126. "知识提炼度": 8
  127. }},
  128. "weighted_score": 85.5,
  129. "level": 4,
  130. "evidence": [
  131. "证据1",
  132. "证据2"
  133. ],
  134. "issues": [
  135. "问题1"
  136. ],
  137. "summary": "总结陈述(2-3句话)"
  138. }}
  139. ## 重要提示
  140. - 严格按照评分标准打分
  141. - 每个维度得分范围: 0-10分
  142. - weighted_score必须是0-100分(维度加权分×10)
  143. - 图片层占60%权重,重点评估
  144. - 综合得分>=60分才判定为知识内容
  145. """
  146. RELEVANCE_EVALUATION_PROMPT = """# 相关性评估系统
  147. ## 角色定义
  148. 你是一位专业的多模态内容评估专家,擅长分析社交媒体UGC平台的帖子内容,能够精准判断帖子与用户搜索需求的匹配程度。
  149. ## 任务说明
  150. 评估帖子与原始搜索需求的匹配程度。
  151. ---
  152. ## 输入信息
  153. **原始搜索需求:** {original_query}
  154. **多模态帖子内容:**
  155. - **标题:** {title}
  156. - **正文:** {body_text}
  157. - **图片数量:** {num_images}张
  158. ---
  159. ## 评估维度
  160. ### 1. 目的性匹配判断(权重:70%)
  161. **分析要点:**
  162. - 识别原始需求中的**核心动词/意图**(如:推荐、教程、评测、对比、寻找、了解等)
  163. - 判断帖子是否实质性地**解答或满足**了这个目的
  164. - 评估帖子内容的**实用性和完整性**
  165. **评分标准(0-100分):**
  166. - 90-100分:完全解答需求,内容实用且完整
  167. - 70-89分:基本解答需求,但信息不够全面或深入
  168. - 40-69分:部分相关,但核心目的未充分满足
  169. - 10-39分:仅有微弱关联,未真正解答需求
  170. - 0-9分:完全不相关
  171. ---
  172. ### 2. 品类匹配判断(权重:30%)
  173. **分析要点:**
  174. - 从**图片内容**中识别:产品类别、场景、属性特征
  175. - 从**标题和正文**中提取:品类名称、产品类型、关键词
  176. - 将提取的品类信息与**原始需求中的品类**进行对比
  177. - 判断品类的**一致性、包含关系或相关性**
  178. **评分标准(0-100分):**
  179. - 90-100分:品类完全一致,精准匹配
  180. - 70-89分:品类高度相关,属于同类或子类
  181. - 40-69分:品类部分相关,有交叉但存在偏差
  182. - 10-39分:品类关联较弱,仅边缘相关
  183. - 0-9分:品类完全不匹配
  184. ---
  185. ## 综合评分计算
  186. **总分 = 目的性匹配得分 × 0.7 + 品类匹配得分 × 0.3**
  187. **匹配结论:**
  188. - 85-100分:高度匹配
  189. - 65-84分:基本匹配
  190. - 40-64分:部分匹配
  191. - 0-39分:不匹配
  192. ---
  193. ## 输出格式
  194. 请严格按照以下JSON格式输出:
  195. {{
  196. "purpose_score": 85.0,
  197. "category_score": 90.0,
  198. "total_score": 86.5,
  199. "conclusion": "高度匹配",
  200. "summary": "总结说明(2-3句话)"
  201. }}
  202. ## 重要提示
  203. - 目的性权重70%,是评估重点
  204. - 综合考虑文本和图片信息
  205. - 评分要客观公正,避免主观偏好
  206. """
  207. # ============================================================================
  208. # 核心评估函数
  209. # ============================================================================
  210. async def evaluate_knowledge_v2(
  211. post,
  212. semaphore: Optional[asyncio.Semaphore] = None
  213. ) -> Optional[KnowledgeEvaluation]:
  214. """
  215. 评估帖子的知识属性(新版6维度评估)
  216. """
  217. if post.type == "video":
  218. return None
  219. image_urls = post.images[:MAX_IMAGES_PER_POST] if post.images else []
  220. try:
  221. if semaphore:
  222. async with semaphore:
  223. result = await _evaluate_knowledge_internal(post, image_urls)
  224. else:
  225. result = await _evaluate_knowledge_internal(post, image_urls)
  226. return result
  227. except Exception as e:
  228. print(f" ❌ 知识评估失败: {post.note_id} - {str(e)[:100]}")
  229. return None
  230. async def _evaluate_knowledge_internal(post, image_urls: list[str]) -> KnowledgeEvaluation:
  231. """内部知识评估函数"""
  232. api_key = os.getenv("OPENROUTER_API_KEY")
  233. if not api_key:
  234. raise ValueError("OPENROUTER_API_KEY environment variable not set")
  235. prompt_text = KNOWLEDGE_EVALUATION_PROMPT.format(
  236. title=post.title,
  237. body_text=post.body_text or "",
  238. num_images=len(image_urls)
  239. )
  240. content = [{"type": "text", "text": prompt_text}]
  241. for url in image_urls:
  242. content.append({"type": "image_url", "image_url": {"url": url}})
  243. payload = {
  244. "model": MODEL_NAME,
  245. "messages": [{"role": "user", "content": content}],
  246. "response_format": {"type": "json_object"}
  247. }
  248. headers = {
  249. "Authorization": f"Bearer {api_key}",
  250. "Content-Type": "application/json"
  251. }
  252. loop = asyncio.get_event_loop()
  253. response = await loop.run_in_executor(
  254. None,
  255. lambda: requests.post(
  256. "https://openrouter.ai/api/v1/chat/completions",
  257. headers=headers,
  258. json=payload,
  259. timeout=API_TIMEOUT
  260. )
  261. )
  262. if response.status_code != 200:
  263. raise Exception(f"API error: {response.status_code} - {response.text[:200]}")
  264. result = response.json()
  265. content_text = result["choices"][0]["message"]["content"]
  266. # 清理JSON标记
  267. content_text = content_text.strip()
  268. if content_text.startswith("```json"):
  269. content_text = content_text[7:]
  270. elif content_text.startswith("```"):
  271. content_text = content_text[3:]
  272. if content_text.endswith("```"):
  273. content_text = content_text[:-3]
  274. content_text = content_text.strip()
  275. data = json.loads(content_text)
  276. return KnowledgeEvaluation(
  277. is_knowledge=data.get("is_knowledge", False),
  278. quick_exclude=data.get("quick_exclude", False),
  279. dimension_scores=data.get("dimension_scores", {}),
  280. weighted_score=data.get("weighted_score", 0.0),
  281. level=data.get("level", 1),
  282. evidence=data.get("evidence", []),
  283. issues=data.get("issues", []),
  284. summary=data.get("summary", "")
  285. )
  286. async def evaluate_relevance_v2(
  287. post,
  288. original_query: str,
  289. semaphore: Optional[asyncio.Semaphore] = None
  290. ) -> Optional[RelevanceEvaluation]:
  291. """
  292. 评估帖子与原始query的相关性(新版双维度评估)
  293. """
  294. if post.type == "video":
  295. return None
  296. image_urls = post.images[:MAX_IMAGES_PER_POST] if post.images else []
  297. try:
  298. if semaphore:
  299. async with semaphore:
  300. result = await _evaluate_relevance_internal(post, original_query, image_urls)
  301. else:
  302. result = await _evaluate_relevance_internal(post, original_query, image_urls)
  303. return result
  304. except Exception as e:
  305. print(f" ❌ 相关性评估失败: {post.note_id} - {str(e)[:100]}")
  306. return None
  307. async def _evaluate_relevance_internal(
  308. post,
  309. original_query: str,
  310. image_urls: list[str]
  311. ) -> RelevanceEvaluation:
  312. """内部相关性评估函数"""
  313. api_key = os.getenv("OPENROUTER_API_KEY")
  314. if not api_key:
  315. raise ValueError("OPENROUTER_API_KEY environment variable not set")
  316. prompt_text = RELEVANCE_EVALUATION_PROMPT.format(
  317. original_query=original_query,
  318. title=post.title,
  319. body_text=post.body_text or "",
  320. num_images=len(image_urls)
  321. )
  322. content = [{"type": "text", "text": prompt_text}]
  323. for url in image_urls:
  324. content.append({"type": "image_url", "image_url": {"url": url}})
  325. payload = {
  326. "model": MODEL_NAME,
  327. "messages": [{"role": "user", "content": content}],
  328. "response_format": {"type": "json_object"}
  329. }
  330. headers = {
  331. "Authorization": f"Bearer {api_key}",
  332. "Content-Type": "application/json"
  333. }
  334. loop = asyncio.get_event_loop()
  335. response = await loop.run_in_executor(
  336. None,
  337. lambda: requests.post(
  338. "https://openrouter.ai/api/v1/chat/completions",
  339. headers=headers,
  340. json=payload,
  341. timeout=API_TIMEOUT
  342. )
  343. )
  344. if response.status_code != 200:
  345. raise Exception(f"API error: {response.status_code} - {response.text[:200]}")
  346. result = response.json()
  347. content_text = result["choices"][0]["message"]["content"]
  348. # 清理JSON标记
  349. content_text = content_text.strip()
  350. if content_text.startswith("```json"):
  351. content_text = content_text[7:]
  352. elif content_text.startswith("```"):
  353. content_text = content_text[3:]
  354. if content_text.endswith("```"):
  355. content_text = content_text[:-3]
  356. content_text = content_text.strip()
  357. data = json.loads(content_text)
  358. return RelevanceEvaluation(
  359. purpose_score=data.get("purpose_score", 0.0),
  360. category_score=data.get("category_score", 0.0),
  361. total_score=data.get("total_score", 0.0),
  362. conclusion=data.get("conclusion", "不匹配"),
  363. summary=data.get("summary", "")
  364. )
  365. async def evaluate_post_v2(
  366. post,
  367. original_query: str,
  368. semaphore: Optional[asyncio.Semaphore] = None
  369. ) -> tuple[Optional[KnowledgeEvaluation], Optional[RelevanceEvaluation]]:
  370. """
  371. 串行评估帖子(先知识,分数>40再评估相关性)
  372. Returns:
  373. (KnowledgeEvaluation, RelevanceEvaluation) 或 (Knowledge, None) 或 (None, None)
  374. """
  375. if post.type == "video":
  376. print(f" ⊗ 跳过视频帖子: {post.note_id}")
  377. return None, None
  378. print(f" 🔍 开始评估帖子: {post.note_id}")
  379. # 第一步:先评估知识
  380. knowledge_eval = await evaluate_knowledge_v2(post, semaphore)
  381. if not knowledge_eval:
  382. print(f" ⚠️ 知识评估失败: {post.note_id}")
  383. return None, None
  384. # 第二步:只有知识分数>40才评估相关性
  385. relevance_eval = None
  386. if knowledge_eval.weighted_score > 40:
  387. print(f" ✅ 知识:{knowledge_eval.weighted_score:.1f}分({knowledge_eval.level}⭐) - 继续评估相关性")
  388. relevance_eval = await evaluate_relevance_v2(post, original_query, semaphore)
  389. if relevance_eval:
  390. print(f" ✅ 评估完成 | 相关性:{relevance_eval.total_score:.1f}分({relevance_eval.conclusion})")
  391. else:
  392. print(f" ⚠️ 相关性评估失败")
  393. else:
  394. print(f" ⊗ 知识:{knowledge_eval.weighted_score:.1f}分({knowledge_eval.level}⭐) - 分数≤40,跳过相关性评估")
  395. return knowledge_eval, relevance_eval
  396. def apply_evaluation_v2_to_post(
  397. post,
  398. knowledge_eval: Optional[KnowledgeEvaluation],
  399. relevance_eval: Optional[RelevanceEvaluation]
  400. ):
  401. """
  402. 将V2评估结果应用到Post对象
  403. """
  404. # 知识评估
  405. if knowledge_eval:
  406. post.is_knowledge = knowledge_eval.is_knowledge
  407. post.knowledge_score = knowledge_eval.weighted_score
  408. post.knowledge_level = knowledge_eval.level
  409. post.knowledge_reason = knowledge_eval.summary[:100] # 简短版本
  410. # 详细信息
  411. post.knowledge_evaluation = {
  412. "quick_exclude": knowledge_eval.quick_exclude,
  413. "dimension_scores": knowledge_eval.dimension_scores,
  414. "weighted_score": knowledge_eval.weighted_score,
  415. "level": knowledge_eval.level,
  416. "level_text": "⭐" * knowledge_eval.level,
  417. "evidence": knowledge_eval.evidence,
  418. "issues": knowledge_eval.issues,
  419. "summary": knowledge_eval.summary
  420. }
  421. # 相关性评估
  422. if relevance_eval:
  423. post.relevance_score = relevance_eval.total_score
  424. post.relevance_conclusion = relevance_eval.conclusion
  425. post.relevance_reason = relevance_eval.summary[:150] # 简短版本
  426. # 设置相关性级别(兼容旧系统)
  427. if relevance_eval.total_score >= 85:
  428. post.relevance_level = "高度相关"
  429. elif relevance_eval.total_score >= 65:
  430. post.relevance_level = "中度相关"
  431. else:
  432. post.relevance_level = "低度相关"
  433. # 详细信息
  434. post.relevance_evaluation = {
  435. "purpose_score": relevance_eval.purpose_score,
  436. "category_score": relevance_eval.category_score,
  437. "total_score": relevance_eval.total_score,
  438. "conclusion": relevance_eval.conclusion,
  439. "summary": relevance_eval.summary
  440. }
  441. # 设置评估时间和版本
  442. post.evaluation_time = datetime.now().isoformat()
  443. post.evaluator_version = "v2.0"
  444. async def batch_evaluate_posts_v2(
  445. posts: list,
  446. original_query: str,
  447. max_concurrent: int = MAX_CONCURRENT_EVALUATIONS
  448. ) -> int:
  449. """
  450. 批量评估多个帖子(V2版本)
  451. Returns:
  452. 成功评估的帖子数量
  453. """
  454. semaphore = asyncio.Semaphore(max_concurrent)
  455. print(f"\n📊 开始批量评估 {len(posts)} 个帖子(并发限制: {max_concurrent})...")
  456. tasks = [evaluate_post_v2(post, original_query, semaphore) for post in posts]
  457. results = await asyncio.gather(*tasks)
  458. success_count = 0
  459. for i, (knowledge_eval, relevance_eval) in enumerate(results):
  460. if knowledge_eval and relevance_eval:
  461. apply_evaluation_v2_to_post(posts[i], knowledge_eval, relevance_eval)
  462. success_count += 1
  463. print(f"✅ 批量评估完成: 成功 {success_count}/{len(posts)}")
  464. return success_count