| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228 |
- """AI review for generated image materials.
- This module is intentionally separate from image generation. It can be used
- both inline after generation and later for rescanning historical assets.
- """
- from __future__ import annotations
- import json
- import logging
- import os
- import re
- from dataclasses import dataclass
- from typing import Any
- import httpx
- logger = logging.getLogger(__name__)
- OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
- "OPENROUTER_CHAT_COMPLETIONS_URL",
- "https://openrouter.ai/api/v1/chat/completions",
- )
- AI_MATERIAL_REVIEW_MODEL = os.getenv("AI_MATERIAL_REVIEW_MODEL", "google/gemini-3-flash-preview")
- @dataclass(frozen=True)
- class MaterialReviewResult:
- status: str
- score: int
- reason: str
- risk_tags: list[str]
- ocr_text: str
- raw: dict[str, Any]
- @property
- def passed(self) -> bool:
- return self.status == "pass"
- def _openrouter_api_key() -> str:
- key = os.getenv("OPEN_ROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY")
- if not key:
- raise RuntimeError("缺少 OPENROUTER_API_KEY/OPEN_ROUTER_API_KEY,无法进行AI素材审核")
- return key
- def _extract_chat_completion_text(data: dict) -> str:
- choices = data.get("choices") or []
- if not choices:
- raise RuntimeError("OpenRouter AI审核响应缺 choices")
- message = choices[0].get("message") or {}
- content = message.get("content")
- if isinstance(content, str):
- return content.strip()
- if isinstance(content, list):
- parts = []
- for item in content:
- if isinstance(item, dict) and isinstance(item.get("text"), str):
- parts.append(item["text"])
- return "\n".join(parts).strip()
- return ""
- def _extract_json_object(text: str) -> dict[str, Any]:
- raw = str(text or "").strip()
- if raw.startswith("```"):
- raw = re.sub(r"^```(?:json)?", "", raw).strip()
- raw = re.sub(r"```$", "", raw).strip()
- try:
- parsed = json.loads(raw)
- except json.JSONDecodeError:
- match = re.search(r"\{.*\}", raw, flags=re.S)
- if not match:
- raise
- parsed = json.loads(match.group(0))
- if not isinstance(parsed, dict):
- raise ValueError("AI审核模型返回不是JSON object")
- return parsed
- def _normalize_status(value: Any) -> str:
- status = str(value or "").strip().lower()
- if status in {"pass", "approve", "approved", "通过"}:
- return "pass"
- if status in {"hold", "review", "manual_review", "人工复核"}:
- return "hold"
- return "reject"
- def review_generated_material(
- *,
- image_url: str,
- prompt_type: str,
- prompt_text: str,
- feature_hits: list[dict],
- model: str = AI_MATERIAL_REVIEW_MODEL,
- ) -> MaterialReviewResult:
- """Review one generated material image with multimodal model."""
- payload = {
- "task": "审核一张AI生成的腾讯广告信息流中文封面图是否可进入人工投放审批候选",
- "review_goals": [
- "判断图片标题是否有乱码、错字、悬挂标点、分裂文案、不可读文字",
- "判断是否包含强领取承诺、专家背书、假官方、假按钮、二维码、下载/播放按钮伪装",
- "判断是否涉及医疗疗效、健康恐吓、违法血腥、迷信、低俗、名人肖像、外国人物",
- "判断图片是否和视频主题及pattern相关",
- "判断是否适合60-75岁中老年用户的信息流广告封面",
- ],
- "hard_reject_rules": [
- "出现乱码、明显错字、不可读标题、标题被截断或标点悬挂",
- "出现国家发钱、官方发放、已到账、马上到账、保证到账、立即领取、直接领取、点这里领取等明确承诺或行动诱导",
- "出现假微信/假聊天/假按钮/二维码/下载按钮/播放按钮伪装",
- "出现医疗疗效承诺、健康恐吓、医院药品专家治疗背书",
- "出现真实名人肖像感、外国人物、logo、水印、畸形人物",
- "图片与视频主题或pattern明显不相关",
- ],
- "soft_review_rules": [
- "政策、退休补贴、养老金、清单讲解类素材中,领取、领全、能领、补贴等词不单独构成拒绝理由",
- "如果标题只是提出疑问或提示核对,例如“这几项你知道吗”“你问清了吗”“你领全了吗”,可判为hold或pass,不要仅因词语本身reject",
- "专家详细解读、老师讲解、讲清楚、一图看懂不单独构成拒绝理由;只有和医疗疗效、投资收益、官方承诺、领取承诺强绑定时才reject",
- "如果存在轻度政策诱导但画面质量好、主题相关,优先hold;只有明确承诺结果或伪官方才reject",
- ],
- "prompt_type": prompt_type,
- "feature_hits": feature_hits[:3],
- "output_schema": {
- "decision": "pass/reject/hold",
- "score": "0-100整数",
- "ocr_text": "识别到的主标题文字",
- "risk_tags": ["命中的风险标签"],
- "reason": "一句话说明审核结论",
- "theme_relevance": "high/medium/low",
- "title_quality": "good/medium/bad",
- "visual_quality": "good/medium/bad",
- },
- }
- messages = [
- {
- "role": "system",
- "content": (
- "你是腾讯广告中文信息流素材的AI预审员。"
- "只输出JSON object,不要解释正文。"
- "审核要区分硬性违规和轻度风险:标题不可读、明确承诺、伪官方或主题不相关才reject;"
- "政策信息讲解中的领取相关疑问或专家解读表述,不应仅凭单个词直接reject,可按风险程度给hold或pass。"
- ),
- },
- {
- "role": "user",
- "content": [
- {"type": "text", "text": json.dumps(payload, ensure_ascii=False)},
- {"type": "image_url", "image_url": {"url": image_url}},
- ],
- },
- ]
- resp = httpx.post(
- OPENROUTER_CHAT_COMPLETIONS_URL,
- headers={
- "Authorization": f"Bearer {_openrouter_api_key()}",
- "Content-Type": "application/json",
- "Accept": "application/json",
- },
- json={
- "model": model,
- "messages": messages,
- "temperature": 0.1,
- "max_tokens": 900,
- },
- timeout=90,
- )
- resp.raise_for_status()
- parsed = _extract_json_object(_extract_chat_completion_text(resp.json()))
- try:
- score = int(float(parsed.get("score", 0)))
- except (TypeError, ValueError):
- score = 0
- status = _normalize_status(parsed.get("decision"))
- if score < 60 and status == "pass":
- status = "hold"
- return MaterialReviewResult(
- status=status,
- score=max(0, min(100, score)),
- reason=str(parsed.get("reason") or "").strip(),
- risk_tags=[str(v) for v in parsed.get("risk_tags") or [] if str(v).strip()],
- ocr_text=str(parsed.get("ocr_text") or "").strip(),
- raw=parsed,
- )
- def update_material_review_result(
- material_id: int,
- result: MaterialReviewResult,
- *,
- model: str = AI_MATERIAL_REVIEW_MODEL,
- ) -> None:
- from db.connection import get_connection
- status = "generated" if result.passed else result.status
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- cur.execute(
- """
- UPDATE ai_generated_material
- SET status=%s,
- ai_review_status=%s,
- ai_review_score=%s,
- ai_review_model=%s,
- ai_review_reason=%s,
- ai_review_json=%s,
- ai_reviewed_at=CURRENT_TIMESTAMP,
- error=CASE WHEN %s='pass' THEN error ELSE %s END,
- updated_at=CURRENT_TIMESTAMP
- WHERE id=%s
- """,
- (
- status,
- result.status,
- result.score,
- model,
- result.reason[:2000],
- json.dumps(result.raw, ensure_ascii=False, default=str)[:16000000],
- result.status,
- result.reason[:2000],
- int(material_id),
- ),
- )
- conn.commit()
- finally:
- conn.close()
|