ai_material_review.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. """AI review for generated image materials.
  2. This module is intentionally separate from image generation. It can be used
  3. both inline after generation and later for rescanning historical assets.
  4. """
  5. from __future__ import annotations
  6. import json
  7. import logging
  8. import os
  9. import re
  10. from dataclasses import dataclass
  11. from typing import Any
  12. import httpx
  13. logger = logging.getLogger(__name__)
  14. OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
  15. "OPENROUTER_CHAT_COMPLETIONS_URL",
  16. "https://openrouter.ai/api/v1/chat/completions",
  17. )
  18. AI_MATERIAL_REVIEW_MODEL = os.getenv("AI_MATERIAL_REVIEW_MODEL", "google/gemini-3-flash-preview")
  19. @dataclass(frozen=True)
  20. class MaterialReviewResult:
  21. status: str
  22. score: int
  23. reason: str
  24. risk_tags: list[str]
  25. ocr_text: str
  26. raw: dict[str, Any]
  27. @property
  28. def passed(self) -> bool:
  29. return self.status == "pass"
  30. def _openrouter_api_key() -> str:
  31. key = os.getenv("OPEN_ROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY")
  32. if not key:
  33. raise RuntimeError("缺少 OPENROUTER_API_KEY/OPEN_ROUTER_API_KEY,无法进行AI素材审核")
  34. return key
  35. def _extract_chat_completion_text(data: dict) -> str:
  36. choices = data.get("choices") or []
  37. if not choices:
  38. raise RuntimeError("OpenRouter AI审核响应缺 choices")
  39. message = choices[0].get("message") or {}
  40. content = message.get("content")
  41. if isinstance(content, str):
  42. return content.strip()
  43. if isinstance(content, list):
  44. parts = []
  45. for item in content:
  46. if isinstance(item, dict) and isinstance(item.get("text"), str):
  47. parts.append(item["text"])
  48. return "\n".join(parts).strip()
  49. return ""
  50. def _extract_json_object(text: str) -> dict[str, Any]:
  51. raw = str(text or "").strip()
  52. if raw.startswith("```"):
  53. raw = re.sub(r"^```(?:json)?", "", raw).strip()
  54. raw = re.sub(r"```$", "", raw).strip()
  55. try:
  56. parsed = json.loads(raw)
  57. except json.JSONDecodeError:
  58. match = re.search(r"\{.*\}", raw, flags=re.S)
  59. if not match:
  60. raise
  61. parsed = json.loads(match.group(0))
  62. if not isinstance(parsed, dict):
  63. raise ValueError("AI审核模型返回不是JSON object")
  64. return parsed
  65. def _normalize_status(value: Any) -> str:
  66. status = str(value or "").strip().lower()
  67. if status in {"pass", "approve", "approved", "通过"}:
  68. return "pass"
  69. if status in {"hold", "review", "manual_review", "人工复核"}:
  70. return "hold"
  71. return "reject"
  72. def review_generated_material(
  73. *,
  74. image_url: str,
  75. prompt_type: str,
  76. prompt_text: str,
  77. feature_hits: list[dict],
  78. model: str = AI_MATERIAL_REVIEW_MODEL,
  79. ) -> MaterialReviewResult:
  80. """Review one generated material image with multimodal model."""
  81. payload = {
  82. "task": "审核一张AI生成的腾讯广告信息流中文封面图是否可进入人工投放审批候选",
  83. "review_goals": [
  84. "判断图片标题是否有乱码、错字、悬挂标点、分裂文案、不可读文字",
  85. "判断是否包含强领取承诺、专家背书、假官方、假按钮、二维码、下载/播放按钮伪装",
  86. "判断是否涉及医疗疗效、健康恐吓、违法血腥、迷信、低俗、名人肖像、外国人物",
  87. "判断图片是否和视频主题及pattern相关",
  88. "判断是否适合60-75岁中老年用户的信息流广告封面",
  89. ],
  90. "hard_reject_rules": [
  91. "出现乱码、明显错字、不可读标题、标题被截断或标点悬挂",
  92. "出现国家发钱、官方发放、已到账、马上到账、保证到账、立即领取、直接领取、点这里领取等明确承诺或行动诱导",
  93. "出现假微信/假聊天/假按钮/二维码/下载按钮/播放按钮伪装",
  94. "出现医疗疗效承诺、健康恐吓、医院药品专家治疗背书",
  95. "出现真实名人肖像感、外国人物、logo、水印、畸形人物",
  96. "图片与视频主题或pattern明显不相关",
  97. ],
  98. "soft_review_rules": [
  99. "政策、退休补贴、养老金、清单讲解类素材中,领取、领全、能领、补贴等词不单独构成拒绝理由",
  100. "如果标题只是提出疑问或提示核对,例如“这几项你知道吗”“你问清了吗”“你领全了吗”,可判为hold或pass,不要仅因词语本身reject",
  101. "专家详细解读、老师讲解、讲清楚、一图看懂不单独构成拒绝理由;只有和医疗疗效、投资收益、官方承诺、领取承诺强绑定时才reject",
  102. "如果存在轻度政策诱导但画面质量好、主题相关,优先hold;只有明确承诺结果或伪官方才reject",
  103. ],
  104. "prompt_type": prompt_type,
  105. "feature_hits": feature_hits[:3],
  106. "output_schema": {
  107. "decision": "pass/reject/hold",
  108. "score": "0-100整数",
  109. "ocr_text": "识别到的主标题文字",
  110. "risk_tags": ["命中的风险标签"],
  111. "reason": "一句话说明审核结论",
  112. "theme_relevance": "high/medium/low",
  113. "title_quality": "good/medium/bad",
  114. "visual_quality": "good/medium/bad",
  115. },
  116. }
  117. messages = [
  118. {
  119. "role": "system",
  120. "content": (
  121. "你是腾讯广告中文信息流素材的AI预审员。"
  122. "只输出JSON object,不要解释正文。"
  123. "审核要区分硬性违规和轻度风险:标题不可读、明确承诺、伪官方或主题不相关才reject;"
  124. "政策信息讲解中的领取相关疑问或专家解读表述,不应仅凭单个词直接reject,可按风险程度给hold或pass。"
  125. ),
  126. },
  127. {
  128. "role": "user",
  129. "content": [
  130. {"type": "text", "text": json.dumps(payload, ensure_ascii=False)},
  131. {"type": "image_url", "image_url": {"url": image_url}},
  132. ],
  133. },
  134. ]
  135. resp = httpx.post(
  136. OPENROUTER_CHAT_COMPLETIONS_URL,
  137. headers={
  138. "Authorization": f"Bearer {_openrouter_api_key()}",
  139. "Content-Type": "application/json",
  140. "Accept": "application/json",
  141. },
  142. json={
  143. "model": model,
  144. "messages": messages,
  145. "temperature": 0.1,
  146. "max_tokens": 900,
  147. },
  148. timeout=90,
  149. )
  150. resp.raise_for_status()
  151. parsed = _extract_json_object(_extract_chat_completion_text(resp.json()))
  152. try:
  153. score = int(float(parsed.get("score", 0)))
  154. except (TypeError, ValueError):
  155. score = 0
  156. status = _normalize_status(parsed.get("decision"))
  157. if score < 60 and status == "pass":
  158. status = "hold"
  159. return MaterialReviewResult(
  160. status=status,
  161. score=max(0, min(100, score)),
  162. reason=str(parsed.get("reason") or "").strip(),
  163. risk_tags=[str(v) for v in parsed.get("risk_tags") or [] if str(v).strip()],
  164. ocr_text=str(parsed.get("ocr_text") or "").strip(),
  165. raw=parsed,
  166. )
  167. def update_material_review_result(
  168. material_id: int,
  169. result: MaterialReviewResult,
  170. *,
  171. model: str = AI_MATERIAL_REVIEW_MODEL,
  172. ) -> None:
  173. from db.connection import get_connection
  174. status = "generated" if result.passed else result.status
  175. conn = get_connection()
  176. try:
  177. with conn.cursor() as cur:
  178. cur.execute(
  179. """
  180. UPDATE ai_generated_material
  181. SET status=%s,
  182. ai_review_status=%s,
  183. ai_review_score=%s,
  184. ai_review_model=%s,
  185. ai_review_reason=%s,
  186. ai_review_json=%s,
  187. ai_reviewed_at=CURRENT_TIMESTAMP,
  188. error=CASE WHEN %s='pass' THEN error ELSE %s END,
  189. updated_at=CURRENT_TIMESTAMP
  190. WHERE id=%s
  191. """,
  192. (
  193. status,
  194. result.status,
  195. result.score,
  196. model,
  197. result.reason[:2000],
  198. json.dumps(result.raw, ensure_ascii=False, default=str)[:16000000],
  199. result.status,
  200. result.reason[:2000],
  201. int(material_id),
  202. ),
  203. )
  204. conn.commit()
  205. finally:
  206. conn.close()