| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196 |
- """AI generated creative image assets for module B.
- This module is intentionally isolated from the historical material recall path.
- External side effects happen only when an account explicitly uses
- material_source=ai_generated.
- """
- from __future__ import annotations
- import base64
- import hashlib
- import hmac
- import json
- import logging
- import mimetypes
- import os
- import re
- import uuid
- from dataclasses import dataclass
- from email.utils import formatdate
- from io import BytesIO
- from pathlib import Path
- from typing import Any, Iterable, Optional, Sequence
- from urllib.parse import quote, urlparse
- import httpx
- from PIL import Image
- from tools.material_recall import Material
- from tools.video_feature_query import VideoElementFeature, read_cached_video_element_features
- from tools.video_recall import LandingVideo
- logger = logging.getLogger(__name__)
- OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
- "OPENROUTER_CHAT_COMPLETIONS_URL",
- "https://openrouter.ai/api/v1/chat/completions",
- )
- OPENROUTER_IMAGES_URL = os.getenv(
- "OPENROUTER_IMAGES_URL",
- "https://openrouter.ai/api/v1/images",
- )
- OPENROUTER_IMAGE_MODEL = os.getenv(
- "OPENROUTER_IMAGE_MODEL",
- "google/gemini-3.1-flash-image",
- )
- OPENROUTER_TEXT_MODEL = os.getenv(
- "OPENROUTER_TEXT_MODEL",
- "google/gemini-3-flash-preview",
- )
- AI_MATERIAL_REVIEW_MODEL = os.getenv("AI_MATERIAL_REVIEW_MODEL", "google/gemini-3-flash-preview")
- AI_IMAGE_USE_PATTERN_SELECTOR = os.getenv("AI_IMAGE_USE_PATTERN_SELECTOR", "1").strip().lower() not in (
- "0", "false", "no", "否",
- )
- AI_IMAGE_PATTERN_TOP_K = int(os.getenv("AI_IMAGE_PATTERN_TOP_K", "1"))
- AI_IMAGE_PATTERN_PLACEMENT = os.getenv("AI_IMAGE_PATTERN_PLACEMENT", "WECHAT_OFFICIAL_ACCOUNTS")
- AI_COVER_COPY_REQUIRED = os.getenv("AI_COVER_COPY_REQUIRED", "1").strip().lower() not in (
- "0", "false", "no", "否",
- )
- AI_COVER_COPY_MAX_TOKENS = int(os.getenv("AI_COVER_COPY_MAX_TOKENS", "900"))
- AI_COVER_COPY_TITLE_MAX_LEN = int(os.getenv("AI_COVER_COPY_TITLE_MAX_LEN", "22"))
- AI_IMAGE_OSS_PREFIX = os.getenv("AI_IMAGE_OSS_PREFIX", "auto_put_tencent/image").strip("/")
- AI_IMAGE_ASPECT_RATIO = os.getenv("AI_IMAGE_ASPECT_RATIO", "16:9")
- AI_IMAGE_RESOLUTION = os.getenv("AI_IMAGE_RESOLUTION", "1K")
- AI_IMAGE_OUTPUT_FORMAT = os.getenv("AI_IMAGE_OUTPUT_FORMAT", "jpeg")
- AI_IMAGE_TARGET_WIDTH = int(os.getenv("AI_IMAGE_TARGET_WIDTH", "1280"))
- AI_IMAGE_TARGET_HEIGHT = int(os.getenv("AI_IMAGE_TARGET_HEIGHT", "720"))
- DEFAULT_AI_IMAGE_PROMPT_TEMPLATE_PATH = (
- Path(__file__).resolve().parents[1] / "prompts" / "ai_generated_material.md"
- )
- DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH = (
- Path(__file__).resolve().parents[1] / "prompts" / "ai_sanitize_video_description.md"
- )
- DEFAULT_AI_COVER_COPY_PROMPT_TEMPLATE_PATH = (
- Path(__file__).resolve().parents[1] / "prompts" / "ai_cover_copy.md"
- )
- DEFAULT_AI_IMAGE_OSS_BUCKET = "art-pubbucket"
- DEFAULT_AI_IMAGE_PUBLIC_BASE_URL = "https://rescdn.yishihui.com"
- CREATE_AI_MATERIAL_TABLE_SQL = """
- CREATE TABLE IF NOT EXISTS ai_generated_material (
- id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
- account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
- adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
- crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
- landing_video_id BIGINT NOT NULL COMMENT '承接视频ID',
- landing_title VARCHAR(500) DEFAULT NULL COMMENT '承接视频标题',
- landing_category VARCHAR(200) DEFAULT NULL COMMENT '承接视频品类',
- prompt_type VARCHAR(50) NOT NULL COMMENT '生成角度:topic',
- prompt_text MEDIUMTEXT NOT NULL COMMENT '最终生成prompt',
- prompt_feature_json MEDIUMTEXT DEFAULT NULL COMMENT 'prompt使用的视频特征JSON',
- model VARCHAR(200) NOT NULL COMMENT 'OpenRouter模型',
- oss_object_key VARCHAR(500) DEFAULT NULL COMMENT 'OSS对象key',
- oss_url VARCHAR(1000) DEFAULT NULL COMMENT 'OSS公网URL',
- status VARCHAR(50) NOT NULL DEFAULT 'generated' COMMENT 'generated/prepared/approved/rejected/hold/skip/posted_ok/post_failed/error',
- approval_status VARCHAR(50) DEFAULT NULL COMMENT '人工审批状态',
- tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
- dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
- ai_review_status VARCHAR(50) DEFAULT NULL COMMENT 'AI审核状态:pass/reject/hold/error',
- ai_review_score INT DEFAULT NULL COMMENT 'AI审核评分0-100',
- ai_review_model VARCHAR(200) DEFAULT NULL COMMENT 'AI审核模型',
- ai_review_reason TEXT DEFAULT NULL COMMENT 'AI审核原因摘要',
- ai_review_json MEDIUMTEXT DEFAULT NULL COMMENT 'AI审核原始JSON',
- ai_reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT 'AI审核时间',
- error TEXT DEFAULT NULL COMMENT '生成/上传/创建错误',
- raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- KEY idx_account_ad_landing_status (account_id, adgroup_id, landing_video_id, status),
- KEY idx_crowd_created (crowd_package, created_at),
- KEY idx_status_created (status, created_at)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成创意图片素材'
- """
- AI_MATERIAL_REVIEW_COLUMNS_SQL = {
- "ai_review_status": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_status VARCHAR(50) DEFAULT NULL COMMENT 'AI审核状态:pass/reject/hold/error' AFTER dynamic_creative_id",
- "ai_review_score": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_score INT DEFAULT NULL COMMENT 'AI审核评分0-100' AFTER ai_review_status",
- "ai_review_model": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_model VARCHAR(200) DEFAULT NULL COMMENT 'AI审核模型' AFTER ai_review_score",
- "ai_review_reason": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_reason TEXT DEFAULT NULL COMMENT 'AI审核原因摘要' AFTER ai_review_model",
- "ai_review_json": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_json MEDIUMTEXT DEFAULT NULL COMMENT 'AI审核原始JSON' AFTER ai_review_reason",
- "ai_reviewed_at": "ALTER TABLE ai_generated_material ADD COLUMN ai_reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT 'AI审核时间' AFTER ai_review_json",
- }
- @dataclass(frozen=True)
- class GenerationPrompt:
- prompt_type: str
- prompt_text: str
- feature_hits: list[dict]
- @dataclass(frozen=True)
- class CoverCopy:
- main_title: str
- hook_angle: str = ""
- hook_point: str = ""
- highlight_terms: list[str] | None = None
- reason: str = ""
- candidates: list[dict[str, Any]] | None = None
- def to_dict(self) -> dict[str, Any]:
- return {
- "main_title": self.main_title,
- "hook_angle": self.hook_angle,
- "hook_point": self.hook_point,
- "highlight_terms": self.highlight_terms or [],
- "reason": self.reason,
- "candidates": self.candidates or [],
- }
- @dataclass(frozen=True)
- class GeneratedMaterialAsset:
- id: int
- account_id: int
- adgroup_id: Optional[int]
- crowd_package: str
- landing_video_id: int
- prompt_type: str
- prompt_text: str
- model: str
- oss_url: str
- oss_object_key: str
- feature_hits: list[dict]
- @property
- def material_id(self) -> str:
- return f"ai:{self.id}"
- def to_material(self) -> Material:
- return Material(
- material_id=self.material_id,
- score=1.0,
- title=f"AI生成素材-{self.prompt_type}",
- cover=self.oss_url,
- cost=None,
- ctr=None,
- cvr=None,
- roi=None,
- impressions=None,
- quality_score=None,
- recall_strategy=f"AI生成-{self.prompt_type}",
- recall_query_text=self.prompt_text[:500],
- recall_config_code="AI_GENERATED_IMAGE",
- recall_element_dimension="AI生成素材",
- recall_point_type=self.prompt_type,
- recall_standard_element="",
- recall_hit_queries=self.feature_hits,
- raw={
- "ai_generated_material_id": self.id,
- "oss_url": self.oss_url,
- "prompt_type": self.prompt_type,
- "prompt_text": self.prompt_text,
- "model": self.model,
- },
- )
- def _feature_attr(feature, name: str, default=""):
- if isinstance(feature, dict):
- return feature.get(name, default)
- return getattr(feature, name, default)
- def _feature_to_hit(feature) -> dict:
- return {
- "element_dimension": str(_feature_attr(feature, "element_dimension") or ""),
- "point_type": str(_feature_attr(feature, "point_type") or ""),
- "standard_element": str(_feature_attr(feature, "standard_element") or ""),
- "contribution_score": float(_feature_attr(feature, "contribution_score", 0) or 0),
- "dt": str(_feature_attr(feature, "dt") or ""),
- }
- def _top_feature(features: Iterable, *, dimension: str, point_type: str = "") -> Optional[dict]:
- candidates = []
- for feature in features:
- hit = _feature_to_hit(feature)
- if hit["element_dimension"] != dimension:
- continue
- if point_type and hit["point_type"] != point_type:
- continue
- if not hit["standard_element"] or hit["standard_element"] == "-":
- continue
- candidates.append(hit)
- if not candidates:
- return None
- return sorted(candidates, key=lambda x: x["contribution_score"], reverse=True)[0]
- def _load_prompt_template() -> str:
- raw_path = os.getenv("AI_IMAGE_PROMPT_TEMPLATE_PATH", "").strip()
- path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_IMAGE_PROMPT_TEMPLATE_PATH
- return path.read_text(encoding="utf-8")
- def _load_sanitize_prompt_messages(raw_description: str) -> list[dict]:
- raw_path = os.getenv("AI_SANITIZE_PROMPT_TEMPLATE_PATH", "").strip()
- path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH
- template = path.read_text(encoding="utf-8")
- if "【system】" not in template or "【user】" not in template:
- raise RuntimeError(f"清洗prompt模板缺少【system】/【user】标记:{path}")
- system_part, user_part = template.split("【user】", 1)
- system_text = system_part.replace("【system】", "", 1).strip()
- user_text = user_part.replace("{{raw_description}}", raw_description).strip()
- if not system_text or not user_text:
- raise RuntimeError(f"清洗prompt模板为空:{path}")
- return [
- {"role": "system", "content": system_text},
- {"role": "user", "content": user_text},
- ]
- def _load_cover_copy_prompt_messages(video_description: str, selection) -> list[dict]:
- raw_path = os.getenv("AI_COVER_COPY_PROMPT_TEMPLATE_PATH", "").strip()
- path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_COVER_COPY_PROMPT_TEMPLATE_PATH
- template = path.read_text(encoding="utf-8")
- if "【system】" not in template or "【user】" not in template:
- raise RuntimeError(f"标题prompt模板缺少【system】/【user】标记:{path}")
- pattern = selection.pattern
- pattern_json = {
- "pattern_key": pattern.pattern_key,
- "pattern_name": pattern.pattern_name,
- "hook_category": pattern.hook_category,
- "visual_template": pattern.visual_template,
- "title_hook_rule": pattern.title_hook_rule,
- "visual_rule": pattern.visual_rule,
- "relevance_rule": pattern.relevance_rule,
- "positive_examples": pattern.positive_examples or [],
- "negative_examples": pattern.negative_examples or [],
- }
- system_part, user_part = template.split("【user】", 1)
- system_text = system_part.replace("【system】", "", 1).strip()
- user_text = (
- user_part
- .replace("{{video_description}}", video_description)
- .replace("{{pattern_json}}", json.dumps(pattern_json, ensure_ascii=False, indent=2))
- .strip()
- )
- if not system_text or not user_text:
- raise RuntimeError(f"标题prompt模板为空:{path}")
- return [
- {"role": "system", "content": system_text},
- {"role": "user", "content": user_text},
- ]
- def _render_prompt(video_description: str) -> str:
- return (
- _load_prompt_template()
- .replace("{{video_description}}", video_description)
- .replace("{{aspect_ratio}}", AI_IMAGE_ASPECT_RATIO)
- )
- def _extract_chat_completion_text(data: dict) -> str:
- choices = data.get("choices") or []
- if not choices:
- raise RuntimeError("OpenRouter 清洗响应缺 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:
- start = raw.find("{")
- if start >= 0:
- try:
- parsed, _ = json.JSONDecoder().raw_decode(raw[start:])
- except json.JSONDecodeError:
- parsed = None
- if isinstance(parsed, dict):
- return parsed
- match = re.search(r"\{.*?\}", raw, flags=re.S)
- if not match:
- raise
- parsed = json.loads(match.group(0))
- if isinstance(parsed, list):
- parsed = next((item for item in parsed if isinstance(item, dict)), None)
- if isinstance(parsed, dict) and "selected" in parsed and isinstance(parsed["selected"], list):
- selected = next((item for item in parsed["selected"] if isinstance(item, dict)), None)
- if selected:
- parsed = selected
- if not isinstance(parsed, dict):
- raise ValueError("模型返回不是JSON object")
- return parsed
- def sanitize_video_description(raw_description: str, model: str = OPENROUTER_TEXT_MODEL) -> str:
- """Rewrite ODPS topic into an ad-theme seed for image generation."""
- raw = (raw_description or "").strip()
- if not raw:
- raise RuntimeError("缺少视频解构选题,无法清洗生成描述")
- body = {
- "model": model,
- "temperature": 0.2,
- "max_tokens": 180,
- "messages": _load_sanitize_prompt_messages(raw),
- }
- headers = {
- "authorization": f"Bearer {_openrouter_api_key()}",
- "content-type": "application/json",
- "accept": "application/json",
- }
- resp = httpx.post(OPENROUTER_CHAT_COMPLETIONS_URL, json=body, headers=headers, timeout=60)
- resp.raise_for_status()
- cleaned = _extract_chat_completion_text(resp.json())
- cleaned = cleaned.strip().strip("`").strip().strip("“”\"'").replace("\n", "")
- if not cleaned:
- raise RuntimeError("OpenRouter 清洗后描述为空")
- return cleaned
- def _clean_cover_copy_text(value: Any, *, max_len: int) -> str:
- text = str(value or "").strip()
- text = text.strip("`").strip().strip("“”\"'‘’")
- text = re.sub(r"[\r\n\t]+", "", text)
- text = re.sub(r"[,,。.!!??::;;、]+", "", text)
- text = re.sub(r"\s+", "", text)
- return text[:max_len]
- def _normalize_cover_copy_candidates(value: Any) -> list[dict[str, Any]]:
- if not isinstance(value, list):
- return []
- candidates: list[dict[str, Any]] = []
- for item in value:
- if not isinstance(item, dict):
- continue
- title = _clean_cover_copy_text(item.get("title"), max_len=64)
- if not title:
- continue
- if len(title) > AI_COVER_COPY_TITLE_MAX_LEN:
- continue
- normalized = dict(item)
- normalized["title"] = title
- for key in ("hook_score", "plain_score", "relevance_score"):
- try:
- normalized[key] = int(float(normalized.get(key) or 0))
- except (TypeError, ValueError):
- normalized[key] = 0
- candidates.append(normalized)
- return candidates
- def _best_cover_copy_candidate(candidates: list[dict[str, Any]]) -> dict[str, Any] | None:
- if not candidates:
- return None
- return sorted(
- candidates,
- key=lambda item: (
- int(item.get("hook_score") or 0),
- int(item.get("plain_score") or 0),
- int(item.get("relevance_score") or 0),
- ),
- reverse=True,
- )[0]
- def _normalize_highlight_terms(value: Any, *, title: str) -> list[str]:
- if not isinstance(value, list):
- return []
- out: list[str] = []
- for item in value:
- term = _clean_cover_copy_text(item, max_len=8)
- if not term:
- continue
- if term not in title:
- continue
- if term in out:
- continue
- out.append(term)
- if len(out) >= 2:
- break
- return out
- def generate_cover_copy(video_description: str, selection, model: str = OPENROUTER_TEXT_MODEL) -> CoverCopy:
- """Generate the cover title before image generation.
- The image model is weak at deciding ad copy and then rendering it. This node
- makes the copy explicit while keeping final compliance to later review.
- """
- description = (video_description or "").strip()
- if not description:
- raise RuntimeError("缺少广告主题种子,无法生成封面标题")
- body = {
- "model": model,
- "temperature": 0.5,
- "max_tokens": AI_COVER_COPY_MAX_TOKENS,
- "response_format": {"type": "json_object"},
- "messages": _load_cover_copy_prompt_messages(description, selection),
- }
- headers = {
- "authorization": f"Bearer {_openrouter_api_key()}",
- "content-type": "application/json",
- "accept": "application/json",
- }
- resp = httpx.post(OPENROUTER_CHAT_COMPLETIONS_URL, json=body, headers=headers, timeout=60)
- resp.raise_for_status()
- parsed = _extract_json_object(_extract_chat_completion_text(resp.json()))
- candidates = _normalize_cover_copy_candidates(parsed.get("candidates"))
- best_candidate = _best_cover_copy_candidate(candidates)
- main_title = _clean_cover_copy_text(
- parsed.get("selected_title") or parsed.get("main_title"),
- max_len=64,
- )
- if len(main_title) > AI_COVER_COPY_TITLE_MAX_LEN:
- main_title = ""
- if not main_title and best_candidate:
- main_title = str(best_candidate.get("title") or "")
- if not main_title:
- raise RuntimeError(
- f"OpenRouter 标题节点未返回 {AI_COVER_COPY_TITLE_MAX_LEN} 字以内的 main_title"
- )
- highlight_terms = _normalize_highlight_terms(
- parsed.get("highlight_terms"),
- title=main_title,
- )
- return CoverCopy(
- main_title=main_title,
- hook_angle=str(parsed.get("hook_angle") or (best_candidate or {}).get("hook_angle") or "").strip()[:50],
- hook_point=str(parsed.get("hook_point") or "").strip()[:160],
- highlight_terms=highlight_terms,
- reason=str(parsed.get("reason") or "").strip()[:200],
- candidates=candidates,
- )
- def _render_pattern_prompt(video_description: str, selection, cover_copy: CoverCopy | None = None) -> str:
- pattern = selection.pattern
- positive_examples = "、".join(pattern.positive_examples or []) or "无"
- negative_examples = "、".join(pattern.negative_examples or []) or "无"
- cover_copy_text = ""
- if cover_copy:
- cover_copy_text = f"""
- 【封面标题】
- - 主标题:{cover_copy.main_title}
- - 标题钩子:{cover_copy.hook_angle or "未标注"}
- - 高亮词:{",".join(cover_copy.highlight_terms or []) or "无"}
- 图片中必须使用上面的主标题。主标题不能添加任何括号、引号、书名号或额外符号。只允许高亮【高亮词】中列出的词;如果高亮词为“无”,不要强行彩色高亮。不要生成副标题,也不要自行新增其他标题、角标或引导文字。
- """
- enriched_description = f"""【广告主题种子】
- {video_description}
- {cover_copy_text}
- 【选中的创意Pattern】
- - pattern_key:{pattern.pattern_key}
- - pattern_name:{pattern.pattern_name}
- - hook_category:{pattern.hook_category}
- - visual_template:{pattern.visual_template}
- - title_hook_rule:{pattern.title_hook_rule}
- - visual_direction:{pattern.visual_rule}
- - relevance_rule:{pattern.relevance_rule}
- - compliance_rule:{pattern.compliance_rule}
- - positive_examples:{positive_examples}
- - negative_examples:{negative_examples}
- visual_direction 只是可选视觉方向参考,不是硬约束;如果它和画面主体多样性、非默认人物、非默认老人正脸冲突,以全局视觉策略为准。
- 请基于【广告主题种子】和【选中的创意Pattern】生成广告封面。视频内容只提供主题方向,画面可以做适合信息流点击的合理转译。"""
- return _render_prompt(enriched_description)
- def build_generation_prompts(
- *,
- video_id: int,
- title: str,
- category: str,
- features: Iterable[VideoElementFeature],
- sanitized_description: str = "",
- ) -> list[GenerationPrompt]:
- """Build one generation prompt from the video's ODPS topic."""
- feature_list = list(features or [])
- topic = _top_feature(feature_list, dimension="解构选题")
- topic_text = (topic or {}).get("standard_element")
- if not topic_text:
- return []
- video_description = sanitized_description.strip() or topic_text
- topic_hit = dict(topic)
- topic_hit["original_standard_element"] = topic_text
- topic_hit["video_description"] = video_description
- return [
- GenerationPrompt(
- prompt_type="topic",
- prompt_text=_render_prompt(video_description),
- feature_hits=[topic_hit],
- )
- ]
- def build_pattern_generation_prompts(
- *,
- video_id: int,
- title: str,
- category: str,
- features: Iterable[VideoElementFeature],
- sanitized_description: str,
- crowd_package: str = "",
- placement: str = AI_IMAGE_PATTERN_PLACEMENT,
- top_k: int = AI_IMAGE_PATTERN_TOP_K,
- text_model: str = OPENROUTER_TEXT_MODEL,
- ) -> list[GenerationPrompt]:
- """Build generation prompts using model-selected creative patterns."""
- from tools.material_strategy_learning import select_creative_patterns
- feature_list = list(features or [])
- topic = _top_feature(feature_list, dimension="解构选题")
- topic_text = (topic or {}).get("standard_element")
- if not topic_text:
- return []
- video_description = sanitized_description.strip() or topic_text
- selections = select_creative_patterns(
- video_features=feature_list,
- crowd_package=crowd_package,
- placement=placement,
- include_draft=True,
- top_k=top_k,
- use_model=True,
- model=text_model,
- )
- if not selections:
- logger.info(
- "[ai_generated_material] video=%d no matched pattern, fallback to topic prompt",
- video_id,
- )
- return build_generation_prompts(
- video_id=video_id,
- title=title,
- category=category,
- features=feature_list,
- sanitized_description=video_description,
- )
- prompts: list[GenerationPrompt] = []
- for selection in selections[:max(1, int(top_k))]:
- cover_copy = None
- try:
- cover_copy = generate_cover_copy(video_description, selection, model=text_model)
- except Exception as e:
- message = (
- f"[ai_generated_material] video={video_id} pattern={selection.pattern.pattern_key} "
- f"cover copy generation failed:{e}"
- )
- if AI_COVER_COPY_REQUIRED:
- raise RuntimeError(message) from e
- logger.warning("%s, fallback to image prompt", message)
- topic_hit = dict(topic)
- topic_hit["original_standard_element"] = topic_text
- topic_hit["video_description"] = video_description
- topic_hit["pattern_selection"] = {
- "pattern_key": selection.pattern.pattern_key,
- "pattern_name": selection.pattern.pattern_name,
- "score": selection.score,
- "reasons": selection.reasons,
- "penalties": selection.penalties,
- "matched_features": selection.matched_features,
- "hook_category": selection.pattern.hook_category,
- "visual_template": selection.pattern.visual_template,
- "title_hook_rule": selection.pattern.title_hook_rule,
- "visual_rule": selection.pattern.visual_rule,
- "relevance_rule": selection.pattern.relevance_rule,
- "compliance_rule": selection.pattern.compliance_rule,
- "positive_examples": selection.pattern.positive_examples or [],
- "negative_examples": selection.pattern.negative_examples or [],
- }
- if cover_copy:
- topic_hit["cover_copy"] = cover_copy.to_dict()
- prompts.append(
- GenerationPrompt(
- prompt_type=selection.pattern.pattern_key,
- prompt_text=_render_pattern_prompt(video_description, selection, cover_copy),
- feature_hits=[topic_hit],
- )
- )
- return prompts
- def ensure_ai_material_table() -> None:
- from db.connection import get_connection
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- cur.execute(CREATE_AI_MATERIAL_TABLE_SQL)
- for column_name, alter_sql in AI_MATERIAL_REVIEW_COLUMNS_SQL.items():
- cur.execute("SHOW COLUMNS FROM ai_generated_material LIKE %s", (column_name,))
- if not cur.fetchone():
- cur.execute(alter_sql)
- conn.commit()
- finally:
- conn.close()
- 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 _to_jpeg_bytes(image_bytes: bytes) -> bytes:
- """Normalize model output to RGB JPEG for Tencent material upload."""
- with Image.open(BytesIO(image_bytes)) as img:
- if img.mode not in ("RGB", "L"):
- background = Image.new("RGB", img.size, (255, 255, 255))
- if img.mode in ("RGBA", "LA"):
- alpha = img.getchannel("A")
- background.paste(img.convert("RGB"), mask=alpha)
- else:
- background.paste(img.convert("RGB"))
- out_img = background
- else:
- out_img = img.convert("RGB")
- target_ratio = AI_IMAGE_TARGET_WIDTH / AI_IMAGE_TARGET_HEIGHT
- image_ratio = out_img.width / out_img.height
- if image_ratio > target_ratio:
- new_width = int(out_img.height * target_ratio)
- left = (out_img.width - new_width) // 2
- out_img = out_img.crop((left, 0, left + new_width, out_img.height))
- elif image_ratio < target_ratio:
- new_height = int(out_img.width / target_ratio)
- top = (out_img.height - new_height) // 2
- out_img = out_img.crop((0, top, out_img.width, top + new_height))
- out_img = out_img.resize((AI_IMAGE_TARGET_WIDTH, AI_IMAGE_TARGET_HEIGHT), Image.Resampling.LANCZOS)
- out = BytesIO()
- out_img.save(out, format="JPEG", quality=92, optimize=True)
- return out.getvalue()
- def _extract_image_ref_from_openrouter(data: dict) -> str:
- choices = data.get("choices") or []
- if not choices:
- raise RuntimeError("OpenRouter 响应缺 choices")
- msg = choices[0].get("message") or {}
- images = msg.get("images") or []
- for image in images:
- image_url = image.get("image_url") if isinstance(image, dict) else None
- if isinstance(image_url, dict) and image_url.get("url"):
- return image_url["url"]
- if isinstance(image_url, str):
- return image_url
- content = msg.get("content")
- if isinstance(content, list):
- for part in content:
- if not isinstance(part, dict):
- continue
- image_url = part.get("image_url")
- if isinstance(image_url, dict) and image_url.get("url"):
- return image_url["url"]
- if part.get("type") in ("image_url", "output_image") and part.get("url"):
- return part["url"]
- if isinstance(content, str):
- match = re.search(r"!\[[^\]]*]\(([^)]+)\)", content)
- if match:
- return match.group(1)
- data_match = re.search(r"(data:image/[^;\s]+;base64,[A-Za-z0-9+/=]+)", content)
- if data_match:
- return data_match.group(1)
- url_match = re.search(r"https?://\S+", content)
- if url_match:
- return url_match.group(0).rstrip(").,,。")
- raise RuntimeError("OpenRouter 响应未包含可识别图片URL/base64")
- def generate_image_bytes(prompt: str, model: str = OPENROUTER_IMAGE_MODEL) -> tuple[bytes, str, dict]:
- """Generate one image through OpenRouter Images API.
- The Images API lets us pass aspect_ratio directly. We still normalize the
- returned raster to JPEG in code because not every model supports
- output_format.
- """
- body = {
- "model": model,
- "prompt": prompt,
- "aspect_ratio": AI_IMAGE_ASPECT_RATIO,
- "resolution": AI_IMAGE_RESOLUTION,
- "n": 1,
- }
- headers = {
- "authorization": f"Bearer {_openrouter_api_key()}",
- "content-type": "application/json",
- "accept": "application/json",
- }
- resp = httpx.post(OPENROUTER_IMAGES_URL, json=body, headers=headers, timeout=120)
- resp.raise_for_status()
- data = resp.json()
- images = data.get("data") or []
- if not images or not images[0].get("b64_json"):
- raise RuntimeError("OpenRouter Images API 响应缺少 data[0].b64_json")
- image_bytes = base64.b64decode(images[0]["b64_json"])
- if AI_IMAGE_OUTPUT_FORMAT.lower() in ("jpg", "jpeg"):
- return _to_jpeg_bytes(image_bytes), "image/jpeg", data
- media_type = images[0].get("media_type") or "image/png"
- return image_bytes, media_type, data
- def _public_oss_url(public_base_url: str, object_key: str) -> str:
- return f"{public_base_url.rstrip('/')}/{quote(object_key, safe='/')}"
- def _oss_env() -> tuple[str, str, str, str, str, str]:
- endpoint = os.getenv("ALIYUN_OSS_ENDPOINT", "").strip()
- bucket = (
- os.getenv("ALIYUN_OSS_BUCKET", "").strip()
- or os.getenv("AI_IMAGE_OSS_BUCKET", "").strip()
- or DEFAULT_AI_IMAGE_OSS_BUCKET
- )
- access_key_id = os.getenv("ALIYUN_OSS_ACCESS_KEY_ID", "").strip()
- access_key_secret = os.getenv("ALIYUN_OSS_ACCESS_KEY_SECRET", "").strip()
- public_base_url = (
- os.getenv("AI_IMAGE_PUBLIC_BASE_URL", "").strip()
- or DEFAULT_AI_IMAGE_PUBLIC_BASE_URL
- )
- object_prefix = os.getenv("AI_IMAGE_OSS_PREFIX", AI_IMAGE_OSS_PREFIX).strip("/")
- missing = [
- name for name, value in [
- ("ALIYUN_OSS_ENDPOINT", endpoint),
- ("ALIYUN_OSS_BUCKET", bucket),
- ("ALIYUN_OSS_ACCESS_KEY_ID", access_key_id),
- ("ALIYUN_OSS_ACCESS_KEY_SECRET", access_key_secret),
- ]
- if not value
- ]
- if missing:
- raise RuntimeError(f"缺少 OSS 配置:{','.join(missing)}")
- return endpoint, bucket, access_key_id, access_key_secret, public_base_url.rstrip("/"), object_prefix
- def _normalize_oss_endpoint(endpoint: str, bucket: str) -> str:
- endpoint = endpoint.rstrip("/")
- if not endpoint.startswith(("http://", "https://")):
- endpoint = "https://" + endpoint
- parsed = urlparse(endpoint)
- host = parsed.netloc
- if not host.startswith(f"{bucket}."):
- host = f"{bucket}.{host}"
- return f"{parsed.scheme}://{host}"
- def upload_image_to_oss(image_bytes: bytes, content_type: str, object_key: str) -> str:
- endpoint, bucket, access_key_id, access_key_secret, public_base_url, _ = _oss_env()
- content_md5 = base64.b64encode(hashlib.md5(image_bytes).digest()).decode("ascii")
- date = formatdate(usegmt=True)
- canonical_resource = f"/{bucket}/{object_key}"
- string_to_sign = f"PUT\n{content_md5}\n{content_type}\n{date}\n{canonical_resource}"
- signature = base64.b64encode(
- hmac.new(
- access_key_secret.encode("utf-8"),
- string_to_sign.encode("utf-8"),
- hashlib.sha1,
- ).digest()
- ).decode("ascii")
- upload_base = _normalize_oss_endpoint(endpoint, bucket)
- url = f"{upload_base}/{quote(object_key, safe='/')}"
- resp = httpx.put(
- url,
- content=image_bytes,
- headers={
- "Date": date,
- "Content-Type": content_type,
- "Content-MD5": content_md5,
- "Authorization": f"OSS {access_key_id}:{signature}",
- },
- timeout=60,
- )
- resp.raise_for_status()
- return _public_oss_url(public_base_url, object_key)
- def build_ai_image_object_key(
- *,
- account_id: int | str,
- landing_video_id: int | str,
- prompt_type: str,
- extension: str,
- debug: bool = False,
- ) -> str:
- object_prefix = os.getenv("AI_IMAGE_OSS_PREFIX", AI_IMAGE_OSS_PREFIX).strip("/")
- ext = extension if extension.startswith(".") else f".{extension}"
- if debug:
- scope = "debug"
- else:
- scope = f"account_{account_id}/video_{landing_video_id}"
- return f"{object_prefix}/{scope}/{prompt_type}_{uuid.uuid4().hex}{ext}"
- def _insert_generated_material(
- *,
- account_id: int,
- adgroup_id: int,
- crowd_package: str,
- landing: LandingVideo,
- prompt: GenerationPrompt,
- model: str,
- object_key: str,
- oss_url: str,
- raw_response: dict,
- ) -> GeneratedMaterialAsset:
- ensure_ai_material_table()
- from db.connection import get_connection
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- cur.execute(
- """
- INSERT INTO ai_generated_material
- (account_id, adgroup_id, crowd_package, landing_video_id,
- landing_title, landing_category, prompt_type, prompt_text,
- prompt_feature_json, model, oss_object_key, oss_url, raw_response)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
- """,
- (
- account_id,
- adgroup_id,
- crowd_package,
- landing.video_id,
- landing.title,
- landing.category,
- prompt.prompt_type,
- prompt.prompt_text,
- json.dumps(prompt.feature_hits, ensure_ascii=False),
- model,
- object_key,
- oss_url,
- json.dumps(raw_response, ensure_ascii=False, default=str)[:16000000],
- ),
- )
- asset_id = int(cur.lastrowid)
- conn.commit()
- finally:
- conn.close()
- return GeneratedMaterialAsset(
- id=asset_id,
- account_id=account_id,
- adgroup_id=adgroup_id,
- crowd_package=crowd_package,
- landing_video_id=landing.video_id,
- prompt_type=prompt.prompt_type,
- prompt_text=prompt.prompt_text,
- model=model,
- oss_url=oss_url,
- oss_object_key=object_key,
- feature_hits=prompt.feature_hits,
- )
- def insert_and_review_generated_material(
- *,
- account_id: int,
- adgroup_id: int,
- crowd_package: str,
- landing: LandingVideo,
- prompt: GenerationPrompt,
- model: str,
- object_key: str,
- oss_url: str,
- raw_response: dict,
- ) -> tuple[GeneratedMaterialAsset, Any]:
- """Persist one generated asset and run the same AI review used by production."""
- asset = _insert_generated_material(
- account_id=account_id,
- adgroup_id=adgroup_id,
- crowd_package=crowd_package,
- landing=landing,
- prompt=prompt,
- model=model,
- object_key=object_key,
- oss_url=oss_url,
- raw_response=raw_response,
- )
- from tools.ai_material_review import (
- MaterialReviewResult,
- review_generated_material,
- update_material_review_result,
- )
- try:
- review = review_generated_material(
- image_url=oss_url,
- prompt_type=prompt.prompt_type,
- prompt_text=prompt.prompt_text,
- feature_hits=prompt.feature_hits,
- )
- except Exception as e:
- logger.exception(
- "[ai_generated_material] AI审核异常 account=%d adgroup=%d landing=%d asset=%d: %s",
- account_id, adgroup_id, landing.video_id, asset.id, e,
- )
- review = MaterialReviewResult(
- status="error",
- score=0,
- reason=f"AI审核异常:{e}",
- risk_tags=["review_error"],
- ocr_text="",
- raw={"error": str(e)},
- )
- update_material_review_result(asset.id, review)
- return asset, review
- def _rows_to_assets(rows: list[dict]) -> list[GeneratedMaterialAsset]:
- out = []
- for row in rows:
- try:
- feature_hits = json.loads(row.get("prompt_feature_json") or "[]")
- except Exception:
- feature_hits = []
- out.append(GeneratedMaterialAsset(
- id=int(row["id"]),
- account_id=int(row["account_id"]),
- adgroup_id=int(row["adgroup_id"]) if row.get("adgroup_id") else None,
- crowd_package=str(row.get("crowd_package") or ""),
- landing_video_id=int(row["landing_video_id"]),
- prompt_type=str(row.get("prompt_type") or ""),
- prompt_text=str(row.get("prompt_text") or ""),
- model=str(row.get("model") or ""),
- oss_url=str(row.get("oss_url") or ""),
- oss_object_key=str(row.get("oss_object_key") or ""),
- feature_hits=feature_hits,
- ))
- return out
- def load_available_generated_assets(
- account_id: int,
- adgroup_id: int,
- landing_video_id: int,
- ) -> list[GeneratedMaterialAsset]:
- ensure_ai_material_table()
- from db.connection import get_connection
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- cur.execute(
- """
- SELECT *
- FROM ai_generated_material
- WHERE account_id=%s
- AND adgroup_id=%s
- AND landing_video_id=%s
- AND status IN ('generated', 'prepared')
- AND ai_review_status='pass'
- AND oss_url IS NOT NULL
- AND oss_url <> ''
- ORDER BY id ASC
- """,
- (account_id, adgroup_id, landing_video_id),
- )
- rows = cur.fetchall() or []
- finally:
- conn.close()
- return _rows_to_assets(rows)
- def generate_assets_for_landing(
- *,
- account_id: int,
- adgroup_id: int,
- crowd_package: str,
- landing: LandingVideo,
- model: str = OPENROUTER_IMAGE_MODEL,
- use_pattern_selector: bool = AI_IMAGE_USE_PATTERN_SELECTOR,
- placement: str = AI_IMAGE_PATTERN_PLACEMENT,
- pattern_top_k: int = AI_IMAGE_PATTERN_TOP_K,
- text_model: str = OPENROUTER_TEXT_MODEL,
- skip_prompt_types: set[str] | None = None,
- max_new_assets: int | None = None,
- ) -> list[GeneratedMaterialAsset]:
- db_features = read_cached_video_element_features([landing.video_id]).get(landing.video_id) or []
- if not db_features:
- logger.info(
- "[ai_generated_material] landing=%d no cached DB features for generation",
- landing.video_id,
- )
- return []
- topic = _top_feature(db_features, dimension="解构选题")
- topic_text = (topic or {}).get("standard_element")
- if not topic_text:
- logger.info(
- "[ai_generated_material] landing=%d no topic feature for generation",
- landing.video_id,
- )
- return []
- sanitized_description = sanitize_video_description(topic_text)
- logger.info(
- "[ai_generated_material] landing=%d sanitized description=%r",
- landing.video_id, sanitized_description,
- )
- if use_pattern_selector:
- prompts = build_pattern_generation_prompts(
- video_id=landing.video_id,
- title=landing.title,
- category=landing.category,
- features=db_features,
- sanitized_description=sanitized_description,
- crowd_package=crowd_package,
- placement=placement,
- top_k=pattern_top_k,
- text_model=text_model,
- )
- logger.info(
- "[ai_generated_material] landing=%d pattern prompts=%s",
- landing.video_id, [p.prompt_type for p in prompts],
- )
- else:
- prompts = build_generation_prompts(
- video_id=landing.video_id,
- title=landing.title,
- category=landing.category,
- features=db_features,
- sanitized_description=sanitized_description,
- )
- if skip_prompt_types:
- prompts = [p for p in prompts if p.prompt_type not in skip_prompt_types]
- if max_new_assets is not None:
- prompts = prompts[:max(0, int(max_new_assets))]
- assets: list[GeneratedMaterialAsset] = []
- for prompt in prompts:
- image_bytes, content_type, raw_response = generate_image_bytes(prompt.prompt_text, model)
- ext = mimetypes.guess_extension(content_type) or ".jpg"
- if ext == ".jpe":
- ext = ".jpg"
- object_key = build_ai_image_object_key(
- account_id=account_id,
- landing_video_id=landing.video_id,
- prompt_type=prompt.prompt_type,
- extension=ext,
- )
- oss_url = upload_image_to_oss(image_bytes, content_type, object_key)
- asset, review = insert_and_review_generated_material(
- account_id=account_id,
- adgroup_id=adgroup_id,
- crowd_package=crowd_package,
- landing=landing,
- prompt=prompt,
- model=model,
- object_key=object_key,
- oss_url=oss_url,
- raw_response=raw_response,
- )
- if not review.passed:
- logger.warning(
- "[ai_generated_material] AI审核未通过 account=%d adgroup=%d landing=%d asset=%d status=%s score=%d reason=%s",
- account_id, adgroup_id, landing.video_id, asset.id, review.status, review.score, review.reason,
- )
- continue
- assets.append(asset)
- logger.info(
- "[ai_generated_material] generated reviewed account=%d adgroup=%d landing=%d asset=%d type=%s score=%d url=%s",
- account_id, adgroup_id, landing.video_id, asset.id, prompt.prompt_type, review.score, oss_url,
- )
- return assets
- def get_or_generate_assets_for_landing(
- *,
- account_id: int,
- adgroup_id: int,
- crowd_package: str,
- landing: LandingVideo,
- use_pattern_selector: bool = AI_IMAGE_USE_PATTERN_SELECTOR,
- placement: str = AI_IMAGE_PATTERN_PLACEMENT,
- pattern_top_k: int = AI_IMAGE_PATTERN_TOP_K,
- text_model: str = OPENROUTER_TEXT_MODEL,
- ) -> list[GeneratedMaterialAsset]:
- existing = load_available_generated_assets(account_id, adgroup_id, landing.video_id)
- target_count = max(1, int(pattern_top_k) if use_pattern_selector else 1)
- if len(existing) >= target_count:
- return existing[:target_count]
- skip_prompt_types = {asset.prompt_type for asset in existing}
- generated = generate_assets_for_landing(
- account_id=account_id,
- adgroup_id=adgroup_id,
- crowd_package=crowd_package,
- landing=landing,
- use_pattern_selector=use_pattern_selector,
- placement=placement,
- pattern_top_k=pattern_top_k,
- text_model=text_model,
- skip_prompt_types=skip_prompt_types,
- max_new_assets=target_count - len(existing),
- )
- return [*existing, *generated]
- def update_generated_material_status(
- record: dict,
- status: str,
- *,
- dynamic_creative_id: int | str | None = None,
- tencent_image_id: str = "",
- error: str = "",
- ) -> None:
- asset_id = record.get("_ai_generated_material_id")
- if not asset_id:
- return
- ensure_ai_material_table()
- from db.connection import get_connection
- normalized_status = {
- "approve": "approved",
- "reject": "rejected",
- }.get(status, status)
- approval_status = {
- "approve": "approved",
- "reject": "rejected",
- "hold": "hold",
- "skip": "skip",
- }.get(status, normalized_status)
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- cur.execute(
- """
- UPDATE ai_generated_material
- SET status=%s,
- approval_status=%s,
- dynamic_creative_id=COALESCE(%s, dynamic_creative_id),
- tencent_image_id=COALESCE(NULLIF(%s, ''), tencent_image_id),
- error=COALESCE(NULLIF(%s, ''), error),
- updated_at=CURRENT_TIMESTAMP
- WHERE id=%s
- """,
- (
- normalized_status,
- approval_status,
- int(dynamic_creative_id) if dynamic_creative_id else None,
- tencent_image_id,
- error[:2000] if error else "",
- int(asset_id),
- ),
- )
- conn.commit()
- finally:
- conn.close()
|