ai_generated_material.py 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206
  1. """模块 B 的 AI 生成创意图片资产。
  2. 本模块与历史素材召回链路保持隔离;只有账户明确配置
  3. material_source=ai_generated 时才产生外部副作用。
  4. """
  5. from __future__ import annotations
  6. import base64
  7. import hashlib
  8. import hmac
  9. import json
  10. import logging
  11. import mimetypes
  12. import os
  13. import re
  14. import uuid
  15. from dataclasses import dataclass
  16. from email.utils import formatdate
  17. from io import BytesIO
  18. from pathlib import Path
  19. from typing import Any, Iterable, Optional, Sequence
  20. from urllib.parse import quote, urlparse
  21. import httpx
  22. from PIL import Image
  23. from tools.material_recall import Material
  24. from tools.video_feature_query import VideoElementFeature, read_cached_video_element_features
  25. from tools.video_recall import LandingVideo
  26. logger = logging.getLogger(__name__)
  27. OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
  28. "OPENROUTER_CHAT_COMPLETIONS_URL",
  29. "https://openrouter.ai/api/v1/chat/completions",
  30. )
  31. OPENROUTER_IMAGES_URL = os.getenv(
  32. "OPENROUTER_IMAGES_URL",
  33. "https://openrouter.ai/api/v1/images",
  34. )
  35. OPENROUTER_IMAGE_MODEL = os.getenv(
  36. "OPENROUTER_IMAGE_MODEL",
  37. "google/gemini-3.1-flash-image",
  38. )
  39. OPENROUTER_TEXT_MODEL = os.getenv(
  40. "OPENROUTER_TEXT_MODEL",
  41. "google/gemini-3-flash-preview",
  42. )
  43. AI_MATERIAL_REVIEW_MODEL = os.getenv("AI_MATERIAL_REVIEW_MODEL", "google/gemini-3-flash-preview")
  44. AI_IMAGE_USE_PATTERN_SELECTOR = os.getenv("AI_IMAGE_USE_PATTERN_SELECTOR", "1").strip().lower() not in (
  45. "0", "false", "no", "否",
  46. )
  47. AI_IMAGE_PATTERN_TOP_K = int(os.getenv("AI_IMAGE_PATTERN_TOP_K", "1"))
  48. AI_IMAGE_PATTERN_PLACEMENT = os.getenv("AI_IMAGE_PATTERN_PLACEMENT", "WECHAT_OFFICIAL_ACCOUNTS")
  49. AI_COVER_COPY_REQUIRED = os.getenv("AI_COVER_COPY_REQUIRED", "1").strip().lower() not in (
  50. "0", "false", "no", "否",
  51. )
  52. AI_COVER_COPY_MAX_TOKENS = int(os.getenv("AI_COVER_COPY_MAX_TOKENS", "900"))
  53. AI_COVER_COPY_TITLE_MAX_LEN = int(os.getenv("AI_COVER_COPY_TITLE_MAX_LEN", "22"))
  54. AI_IMAGE_OSS_PREFIX = os.getenv("AI_IMAGE_OSS_PREFIX", "auto_put_tencent/image").strip("/")
  55. AI_IMAGE_ASPECT_RATIO = os.getenv("AI_IMAGE_ASPECT_RATIO", "16:9")
  56. AI_IMAGE_RESOLUTION = os.getenv("AI_IMAGE_RESOLUTION", "1K")
  57. AI_IMAGE_OUTPUT_FORMAT = os.getenv("AI_IMAGE_OUTPUT_FORMAT", "jpeg")
  58. AI_IMAGE_TARGET_WIDTH = int(os.getenv("AI_IMAGE_TARGET_WIDTH", "1280"))
  59. AI_IMAGE_TARGET_HEIGHT = int(os.getenv("AI_IMAGE_TARGET_HEIGHT", "720"))
  60. DEFAULT_AI_IMAGE_PROMPT_TEMPLATE_PATH = (
  61. Path(__file__).resolve().parents[1] / "prompts" / "ai_generated_material.md"
  62. )
  63. DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH = (
  64. Path(__file__).resolve().parents[1] / "prompts" / "ai_sanitize_video_description.md"
  65. )
  66. DEFAULT_AI_COVER_COPY_PROMPT_TEMPLATE_PATH = (
  67. Path(__file__).resolve().parents[1] / "prompts" / "ai_cover_copy.md"
  68. )
  69. DEFAULT_AI_IMAGE_OSS_BUCKET = "art-pubbucket"
  70. DEFAULT_AI_IMAGE_PUBLIC_BASE_URL = "https://rescdn.yishihui.com"
  71. CREATE_AI_MATERIAL_TABLE_SQL = """
  72. CREATE TABLE IF NOT EXISTS ai_generated_material (
  73. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  74. account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
  75. adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
  76. crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
  77. landing_video_id BIGINT NOT NULL COMMENT '承接视频ID',
  78. landing_title VARCHAR(500) DEFAULT NULL COMMENT '承接视频标题',
  79. landing_category VARCHAR(200) DEFAULT NULL COMMENT '承接视频品类',
  80. prompt_type VARCHAR(50) NOT NULL COMMENT '生成角度:topic',
  81. prompt_text MEDIUMTEXT NOT NULL COMMENT '最终生成prompt',
  82. prompt_feature_json MEDIUMTEXT DEFAULT NULL COMMENT 'prompt使用的视频特征JSON',
  83. model VARCHAR(200) NOT NULL COMMENT 'OpenRouter模型',
  84. oss_object_key VARCHAR(500) DEFAULT NULL COMMENT 'OSS对象key',
  85. oss_url VARCHAR(1000) DEFAULT NULL COMMENT 'OSS公网URL',
  86. status VARCHAR(50) NOT NULL DEFAULT 'generated' COMMENT 'generated/prepared/approved/rejected/hold/skip/posted_ok/post_failed/error',
  87. approval_status VARCHAR(50) DEFAULT NULL COMMENT '人工审批状态',
  88. tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
  89. dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
  90. ai_review_status VARCHAR(50) DEFAULT NULL COMMENT 'AI审核状态:pass/reject/hold/error',
  91. ai_review_score INT DEFAULT NULL COMMENT 'AI审核评分0-100',
  92. ai_review_model VARCHAR(200) DEFAULT NULL COMMENT 'AI审核模型',
  93. ai_review_reason TEXT DEFAULT NULL COMMENT 'AI审核原因摘要',
  94. ai_review_json MEDIUMTEXT DEFAULT NULL COMMENT 'AI审核原始JSON',
  95. ai_reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT 'AI审核时间',
  96. error TEXT DEFAULT NULL COMMENT '生成/上传/创建错误',
  97. raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
  98. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  99. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  100. KEY idx_account_ad_landing_status (account_id, adgroup_id, landing_video_id, status),
  101. KEY idx_crowd_created (crowd_package, created_at),
  102. KEY idx_status_created (status, created_at)
  103. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成创意图片素材'
  104. """
  105. AI_MATERIAL_REVIEW_COLUMNS_SQL = {
  106. "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",
  107. "ai_review_score": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_score INT DEFAULT NULL COMMENT 'AI审核评分0-100' AFTER ai_review_status",
  108. "ai_review_model": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_model VARCHAR(200) DEFAULT NULL COMMENT 'AI审核模型' AFTER ai_review_score",
  109. "ai_review_reason": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_reason TEXT DEFAULT NULL COMMENT 'AI审核原因摘要' AFTER ai_review_model",
  110. "ai_review_json": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_json MEDIUMTEXT DEFAULT NULL COMMENT 'AI审核原始JSON' AFTER ai_review_reason",
  111. "ai_reviewed_at": "ALTER TABLE ai_generated_material ADD COLUMN ai_reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT 'AI审核时间' AFTER ai_review_json",
  112. }
  113. @dataclass(frozen=True)
  114. class GenerationPrompt:
  115. prompt_type: str
  116. prompt_text: str
  117. feature_hits: list[dict]
  118. @dataclass(frozen=True)
  119. class CoverCopy:
  120. main_title: str
  121. hook_angle: str = ""
  122. hook_point: str = ""
  123. highlight_terms: list[str] | None = None
  124. reason: str = ""
  125. candidates: list[dict[str, Any]] | None = None
  126. def to_dict(self) -> dict[str, Any]:
  127. return {
  128. "main_title": self.main_title,
  129. "hook_angle": self.hook_angle,
  130. "hook_point": self.hook_point,
  131. "highlight_terms": self.highlight_terms or [],
  132. "reason": self.reason,
  133. "candidates": self.candidates or [],
  134. }
  135. @dataclass(frozen=True)
  136. class GeneratedMaterialAsset:
  137. id: int
  138. account_id: int
  139. adgroup_id: Optional[int]
  140. crowd_package: str
  141. landing_video_id: int
  142. prompt_type: str
  143. prompt_text: str
  144. model: str
  145. oss_url: str
  146. oss_object_key: str
  147. feature_hits: list[dict]
  148. @property
  149. def material_id(self) -> str:
  150. return f"ai:{self.id}"
  151. def to_material(self) -> Material:
  152. return Material(
  153. material_id=self.material_id,
  154. score=1.0,
  155. title=f"AI生成素材-{self.prompt_type}",
  156. cover=self.oss_url,
  157. cost=None,
  158. ctr=None,
  159. cvr=None,
  160. roi=None,
  161. impressions=None,
  162. quality_score=None,
  163. recall_strategy=f"AI生成-{self.prompt_type}",
  164. recall_query_text=self.prompt_text[:500],
  165. recall_config_code="AI_GENERATED_IMAGE",
  166. recall_element_dimension="AI生成素材",
  167. recall_point_type=self.prompt_type,
  168. recall_standard_element="",
  169. recall_hit_queries=self.feature_hits,
  170. raw={
  171. "ai_generated_material_id": self.id,
  172. "oss_url": self.oss_url,
  173. "prompt_type": self.prompt_type,
  174. "prompt_text": self.prompt_text,
  175. "model": self.model,
  176. },
  177. )
  178. def _feature_attr(feature, name: str, default=""):
  179. if isinstance(feature, dict):
  180. return feature.get(name, default)
  181. return getattr(feature, name, default)
  182. def _feature_to_hit(feature) -> dict:
  183. return {
  184. "element_dimension": str(_feature_attr(feature, "element_dimension") or ""),
  185. "point_type": str(_feature_attr(feature, "point_type") or ""),
  186. "standard_element": str(_feature_attr(feature, "standard_element") or ""),
  187. "contribution_score": float(_feature_attr(feature, "contribution_score", 0) or 0),
  188. "dt": str(_feature_attr(feature, "dt") or ""),
  189. }
  190. def _top_feature(features: Iterable, *, dimension: str, point_type: str = "") -> Optional[dict]:
  191. candidates = []
  192. for feature in features:
  193. hit = _feature_to_hit(feature)
  194. if hit["element_dimension"] != dimension:
  195. continue
  196. if point_type and hit["point_type"] != point_type:
  197. continue
  198. if not hit["standard_element"] or hit["standard_element"] == "-":
  199. continue
  200. candidates.append(hit)
  201. if not candidates:
  202. return None
  203. return sorted(candidates, key=lambda x: x["contribution_score"], reverse=True)[0]
  204. def _load_prompt_template() -> str:
  205. raw_path = os.getenv("AI_IMAGE_PROMPT_TEMPLATE_PATH", "").strip()
  206. path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_IMAGE_PROMPT_TEMPLATE_PATH
  207. return path.read_text(encoding="utf-8")
  208. def _load_sanitize_prompt_messages(raw_description: str) -> list[dict]:
  209. raw_path = os.getenv("AI_SANITIZE_PROMPT_TEMPLATE_PATH", "").strip()
  210. path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH
  211. template = path.read_text(encoding="utf-8")
  212. if "【system】" not in template or "【user】" not in template:
  213. raise RuntimeError(f"清洗prompt模板缺少【system】/【user】标记:{path}")
  214. system_part, user_part = template.split("【user】", 1)
  215. system_text = system_part.replace("【system】", "", 1).strip()
  216. user_text = user_part.replace("{{raw_description}}", raw_description).strip()
  217. if not system_text or not user_text:
  218. raise RuntimeError(f"清洗prompt模板为空:{path}")
  219. return [
  220. {"role": "system", "content": system_text},
  221. {"role": "user", "content": user_text},
  222. ]
  223. def _load_cover_copy_prompt_messages(video_description: str, selection) -> list[dict]:
  224. raw_path = os.getenv("AI_COVER_COPY_PROMPT_TEMPLATE_PATH", "").strip()
  225. path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_COVER_COPY_PROMPT_TEMPLATE_PATH
  226. template = path.read_text(encoding="utf-8")
  227. if "【system】" not in template or "【user】" not in template:
  228. raise RuntimeError(f"标题prompt模板缺少【system】/【user】标记:{path}")
  229. pattern = selection.pattern
  230. pattern_json = {
  231. "pattern_key": pattern.pattern_key,
  232. "pattern_name": pattern.pattern_name,
  233. "hook_category": pattern.hook_category,
  234. "visual_template": pattern.visual_template,
  235. "title_hook_rule": pattern.title_hook_rule,
  236. "visual_rule": pattern.visual_rule,
  237. "relevance_rule": pattern.relevance_rule,
  238. "positive_examples": pattern.positive_examples or [],
  239. "negative_examples": pattern.negative_examples or [],
  240. }
  241. system_part, user_part = template.split("【user】", 1)
  242. system_text = system_part.replace("【system】", "", 1).strip()
  243. user_text = (
  244. user_part
  245. .replace("{{video_description}}", video_description)
  246. .replace("{{pattern_json}}", json.dumps(pattern_json, ensure_ascii=False, indent=2))
  247. .strip()
  248. )
  249. if not system_text or not user_text:
  250. raise RuntimeError(f"标题prompt模板为空:{path}")
  251. return [
  252. {"role": "system", "content": system_text},
  253. {"role": "user", "content": user_text},
  254. ]
  255. def _render_prompt(video_description: str) -> str:
  256. return (
  257. _load_prompt_template()
  258. .replace("{{video_description}}", video_description)
  259. .replace("{{aspect_ratio}}", AI_IMAGE_ASPECT_RATIO)
  260. )
  261. def _extract_chat_completion_text(data: dict) -> str:
  262. choices = data.get("choices") or []
  263. if not choices:
  264. raise RuntimeError("OpenRouter 清洗响应缺 choices")
  265. message = choices[0].get("message") or {}
  266. content = message.get("content")
  267. if isinstance(content, str):
  268. return content.strip()
  269. if isinstance(content, list):
  270. parts = []
  271. for item in content:
  272. if isinstance(item, dict) and isinstance(item.get("text"), str):
  273. parts.append(item["text"])
  274. return "\n".join(parts).strip()
  275. return ""
  276. def _extract_json_object(text: str) -> dict[str, Any]:
  277. raw = str(text or "").strip()
  278. if raw.startswith("```"):
  279. raw = re.sub(r"^```(?:json)?", "", raw).strip()
  280. raw = re.sub(r"```$", "", raw).strip()
  281. try:
  282. parsed = json.loads(raw)
  283. except json.JSONDecodeError:
  284. start = raw.find("{")
  285. if start >= 0:
  286. try:
  287. parsed, _ = json.JSONDecoder().raw_decode(raw[start:])
  288. except json.JSONDecodeError:
  289. parsed = None
  290. if isinstance(parsed, dict):
  291. return parsed
  292. match = re.search(r"\{.*?\}", raw, flags=re.S)
  293. if not match:
  294. raise
  295. parsed = json.loads(match.group(0))
  296. if isinstance(parsed, list):
  297. parsed = next((item for item in parsed if isinstance(item, dict)), None)
  298. if isinstance(parsed, dict) and "selected" in parsed and isinstance(parsed["selected"], list):
  299. selected = next((item for item in parsed["selected"] if isinstance(item, dict)), None)
  300. if selected:
  301. parsed = selected
  302. if not isinstance(parsed, dict):
  303. raise ValueError("模型返回不是JSON object")
  304. return parsed
  305. def sanitize_video_description(raw_description: str, model: str = OPENROUTER_TEXT_MODEL) -> str:
  306. """将 ODPS 主题改写为图片生成使用的广告主题种子。"""
  307. raw = (raw_description or "").strip()
  308. if not raw:
  309. raise RuntimeError("缺少视频解构选题,无法清洗生成描述")
  310. body = {
  311. "model": model,
  312. "temperature": 0.2,
  313. "max_tokens": 180,
  314. "messages": _load_sanitize_prompt_messages(raw),
  315. }
  316. headers = {
  317. "authorization": f"Bearer {_openrouter_api_key()}",
  318. "content-type": "application/json",
  319. "accept": "application/json",
  320. }
  321. resp = httpx.post(OPENROUTER_CHAT_COMPLETIONS_URL, json=body, headers=headers, timeout=60)
  322. resp.raise_for_status()
  323. cleaned = _extract_chat_completion_text(resp.json())
  324. cleaned = cleaned.strip().strip("`").strip().strip("“”\"'").replace("\n", "")
  325. if not cleaned:
  326. raise RuntimeError("OpenRouter 清洗后描述为空")
  327. return cleaned
  328. def _clean_cover_copy_text(value: Any, *, max_len: int) -> str:
  329. text = str(value or "").strip()
  330. text = text.strip("`").strip().strip("“”\"'‘’")
  331. text = re.sub(r"[\r\n\t]+", "", text)
  332. text = re.sub(r"[,,。.!!??::;;、]+", "", text)
  333. text = re.sub(r"\s+", "", text)
  334. return text[:max_len]
  335. def _normalize_cover_copy_candidates(value: Any) -> list[dict[str, Any]]:
  336. if not isinstance(value, list):
  337. return []
  338. candidates: list[dict[str, Any]] = []
  339. for item in value:
  340. if not isinstance(item, dict):
  341. continue
  342. title = _clean_cover_copy_text(item.get("title"), max_len=64)
  343. if not title:
  344. continue
  345. if len(title) > AI_COVER_COPY_TITLE_MAX_LEN:
  346. continue
  347. normalized = dict(item)
  348. normalized["title"] = title
  349. for key in ("hook_score", "plain_score", "relevance_score"):
  350. try:
  351. normalized[key] = int(float(normalized.get(key) or 0))
  352. except (TypeError, ValueError):
  353. normalized[key] = 0
  354. candidates.append(normalized)
  355. return candidates
  356. def _best_cover_copy_candidate(candidates: list[dict[str, Any]]) -> dict[str, Any] | None:
  357. if not candidates:
  358. return None
  359. return sorted(
  360. candidates,
  361. key=lambda item: (
  362. int(item.get("hook_score") or 0),
  363. int(item.get("plain_score") or 0),
  364. int(item.get("relevance_score") or 0),
  365. ),
  366. reverse=True,
  367. )[0]
  368. def _normalize_highlight_terms(value: Any, *, title: str) -> list[str]:
  369. if not isinstance(value, list):
  370. return []
  371. out: list[str] = []
  372. for item in value:
  373. term = _clean_cover_copy_text(item, max_len=8)
  374. if not term:
  375. continue
  376. if term not in title:
  377. continue
  378. if term in out:
  379. continue
  380. out.append(term)
  381. if len(out) >= 2:
  382. break
  383. return out
  384. def generate_cover_copy(video_description: str, selection, model: str = OPENROUTER_TEXT_MODEL) -> CoverCopy:
  385. """在生成图片前先生成封面标题。
  386. 图片模型不擅长同时决定广告文案并准确渲染,因此此节点先明确文案,最终合规
  387. 仍交由后续审核判断。
  388. """
  389. description = (video_description or "").strip()
  390. if not description:
  391. raise RuntimeError("缺少广告主题种子,无法生成封面标题")
  392. body = {
  393. "model": model,
  394. "temperature": 0.5,
  395. "max_tokens": AI_COVER_COPY_MAX_TOKENS,
  396. "response_format": {"type": "json_object"},
  397. "messages": _load_cover_copy_prompt_messages(description, selection),
  398. }
  399. headers = {
  400. "authorization": f"Bearer {_openrouter_api_key()}",
  401. "content-type": "application/json",
  402. "accept": "application/json",
  403. }
  404. resp = httpx.post(OPENROUTER_CHAT_COMPLETIONS_URL, json=body, headers=headers, timeout=60)
  405. resp.raise_for_status()
  406. parsed = _extract_json_object(_extract_chat_completion_text(resp.json()))
  407. candidates = _normalize_cover_copy_candidates(parsed.get("candidates"))
  408. best_candidate = _best_cover_copy_candidate(candidates)
  409. main_title = _clean_cover_copy_text(
  410. parsed.get("selected_title") or parsed.get("main_title"),
  411. max_len=64,
  412. )
  413. if len(main_title) > AI_COVER_COPY_TITLE_MAX_LEN:
  414. main_title = ""
  415. if not main_title and best_candidate:
  416. main_title = str(best_candidate.get("title") or "")
  417. if not main_title:
  418. raise RuntimeError(
  419. f"OpenRouter 标题节点未返回 {AI_COVER_COPY_TITLE_MAX_LEN} 字以内的 main_title"
  420. )
  421. highlight_terms = _normalize_highlight_terms(
  422. parsed.get("highlight_terms"),
  423. title=main_title,
  424. )
  425. return CoverCopy(
  426. main_title=main_title,
  427. hook_angle=str(parsed.get("hook_angle") or (best_candidate or {}).get("hook_angle") or "").strip()[:50],
  428. hook_point=str(parsed.get("hook_point") or "").strip()[:160],
  429. highlight_terms=highlight_terms,
  430. reason=str(parsed.get("reason") or "").strip()[:200],
  431. candidates=candidates,
  432. )
  433. def _render_pattern_prompt(video_description: str, selection, cover_copy: CoverCopy | None = None) -> str:
  434. pattern = selection.pattern
  435. positive_examples = "、".join(pattern.positive_examples or []) or "无"
  436. negative_examples = "、".join(pattern.negative_examples or []) or "无"
  437. cover_copy_text = ""
  438. if cover_copy:
  439. cover_copy_text = f"""
  440. 【封面标题】
  441. - 主标题:{cover_copy.main_title}
  442. - 标题钩子:{cover_copy.hook_angle or "未标注"}
  443. - 高亮词:{",".join(cover_copy.highlight_terms or []) or "无"}
  444. 图片中必须使用上面的主标题。主标题不能添加任何括号、引号、书名号或额外符号。只允许高亮【高亮词】中列出的词;如果高亮词为“无”,不要强行彩色高亮。不要生成副标题,也不要自行新增其他标题、角标或引导文字。
  445. """
  446. enriched_description = f"""【广告主题种子】
  447. {video_description}
  448. {cover_copy_text}
  449. 【选中的创意Pattern】
  450. - pattern_key:{pattern.pattern_key}
  451. - pattern_name:{pattern.pattern_name}
  452. - hook_category:{pattern.hook_category}
  453. - visual_template:{pattern.visual_template}
  454. - title_hook_rule:{pattern.title_hook_rule}
  455. - visual_direction:{pattern.visual_rule}
  456. - relevance_rule:{pattern.relevance_rule}
  457. - compliance_rule:{pattern.compliance_rule}
  458. - positive_examples:{positive_examples}
  459. - negative_examples:{negative_examples}
  460. visual_direction 只是可选视觉方向参考,不是硬约束;如果它和画面主体多样性、非默认人物、非默认老人正脸冲突,以全局视觉策略为准。
  461. 请基于【广告主题种子】和【选中的创意Pattern】生成广告封面。视频内容只提供主题方向,画面可以做适合信息流点击的合理转译。"""
  462. return _render_prompt(enriched_description)
  463. def build_generation_prompts(
  464. *,
  465. video_id: int,
  466. title: str,
  467. category: str,
  468. features: Iterable[VideoElementFeature],
  469. sanitized_description: str = "",
  470. ) -> list[GenerationPrompt]:
  471. """根据视频的 ODPS 主题构建一个生成提示词。"""
  472. feature_list = list(features or [])
  473. topic = _top_feature(feature_list, dimension="解构选题")
  474. topic_text = (topic or {}).get("standard_element")
  475. if not topic_text:
  476. return []
  477. video_description = sanitized_description.strip() or topic_text
  478. topic_hit = dict(topic)
  479. topic_hit["original_standard_element"] = topic_text
  480. topic_hit["video_description"] = video_description
  481. return [
  482. GenerationPrompt(
  483. prompt_type="topic",
  484. prompt_text=_render_prompt(video_description),
  485. feature_hits=[topic_hit],
  486. )
  487. ]
  488. def build_pattern_generation_prompts(
  489. *,
  490. video_id: int,
  491. title: str,
  492. category: str,
  493. features: Iterable[VideoElementFeature],
  494. sanitized_description: str,
  495. crowd_package: str = "",
  496. placement: str = AI_IMAGE_PATTERN_PLACEMENT,
  497. top_k: int = AI_IMAGE_PATTERN_TOP_K,
  498. text_model: str = OPENROUTER_TEXT_MODEL,
  499. ) -> list[GenerationPrompt]:
  500. """使用模型选出的创意模式构建生成提示词。"""
  501. from tools.material_strategy_learning import select_creative_patterns
  502. feature_list = list(features or [])
  503. topic = _top_feature(feature_list, dimension="解构选题")
  504. topic_text = (topic or {}).get("standard_element")
  505. if not topic_text:
  506. return []
  507. video_description = sanitized_description.strip() or topic_text
  508. selections = select_creative_patterns(
  509. video_features=feature_list,
  510. crowd_package=crowd_package,
  511. placement=placement,
  512. include_draft=True,
  513. top_k=top_k,
  514. use_model=True,
  515. model=text_model,
  516. )
  517. if not selections:
  518. logger.info(
  519. "[ai_generated_material] video=%d no matched pattern, fallback to topic prompt",
  520. video_id,
  521. )
  522. return build_generation_prompts(
  523. video_id=video_id,
  524. title=title,
  525. category=category,
  526. features=feature_list,
  527. sanitized_description=video_description,
  528. )
  529. prompts: list[GenerationPrompt] = []
  530. for selection in selections[:max(1, int(top_k))]:
  531. cover_copy = None
  532. try:
  533. cover_copy = generate_cover_copy(video_description, selection, model=text_model)
  534. except Exception as e:
  535. message = (
  536. f"[ai_generated_material] video={video_id} pattern={selection.pattern.pattern_key} "
  537. f"cover copy generation failed:{e}"
  538. )
  539. if AI_COVER_COPY_REQUIRED:
  540. raise RuntimeError(message) from e
  541. logger.warning("%s, fallback to image prompt", message)
  542. topic_hit = dict(topic)
  543. topic_hit["original_standard_element"] = topic_text
  544. topic_hit["video_description"] = video_description
  545. topic_hit["pattern_selection"] = {
  546. "pattern_key": selection.pattern.pattern_key,
  547. "pattern_name": selection.pattern.pattern_name,
  548. "score": selection.score,
  549. "reasons": selection.reasons,
  550. "penalties": selection.penalties,
  551. "matched_features": selection.matched_features,
  552. "hook_category": selection.pattern.hook_category,
  553. "visual_template": selection.pattern.visual_template,
  554. "title_hook_rule": selection.pattern.title_hook_rule,
  555. "visual_rule": selection.pattern.visual_rule,
  556. "relevance_rule": selection.pattern.relevance_rule,
  557. "compliance_rule": selection.pattern.compliance_rule,
  558. "positive_examples": selection.pattern.positive_examples or [],
  559. "negative_examples": selection.pattern.negative_examples or [],
  560. }
  561. if cover_copy:
  562. topic_hit["cover_copy"] = cover_copy.to_dict()
  563. prompts.append(
  564. GenerationPrompt(
  565. prompt_type=selection.pattern.pattern_key,
  566. prompt_text=_render_pattern_prompt(video_description, selection, cover_copy),
  567. feature_hits=[topic_hit],
  568. )
  569. )
  570. return prompts
  571. def ensure_ai_material_table() -> None:
  572. from db.connection import get_connection
  573. conn = get_connection()
  574. try:
  575. with conn.cursor() as cur:
  576. cur.execute(CREATE_AI_MATERIAL_TABLE_SQL)
  577. for column_name, alter_sql in AI_MATERIAL_REVIEW_COLUMNS_SQL.items():
  578. cur.execute("SHOW COLUMNS FROM ai_generated_material LIKE %s", (column_name,))
  579. if not cur.fetchone():
  580. cur.execute(alter_sql)
  581. conn.commit()
  582. finally:
  583. conn.close()
  584. def _openrouter_api_key() -> str:
  585. key = os.getenv("OPEN_ROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY")
  586. if not key:
  587. raise RuntimeError("缺少 OPENROUTER_API_KEY/OPEN_ROUTER_API_KEY,无法生成AI素材")
  588. return key
  589. def _to_jpeg_bytes(image_bytes: bytes) -> bytes:
  590. """将模型输出标准化为 RGB JPEG,供腾讯素材上传。"""
  591. with Image.open(BytesIO(image_bytes)) as img:
  592. if img.mode not in ("RGB", "L"):
  593. background = Image.new("RGB", img.size, (255, 255, 255))
  594. if img.mode in ("RGBA", "LA"):
  595. alpha = img.getchannel("A")
  596. background.paste(img.convert("RGB"), mask=alpha)
  597. else:
  598. background.paste(img.convert("RGB"))
  599. out_img = background
  600. else:
  601. out_img = img.convert("RGB")
  602. target_ratio = AI_IMAGE_TARGET_WIDTH / AI_IMAGE_TARGET_HEIGHT
  603. image_ratio = out_img.width / out_img.height
  604. if image_ratio > target_ratio:
  605. new_width = int(out_img.height * target_ratio)
  606. left = (out_img.width - new_width) // 2
  607. out_img = out_img.crop((left, 0, left + new_width, out_img.height))
  608. elif image_ratio < target_ratio:
  609. new_height = int(out_img.width / target_ratio)
  610. top = (out_img.height - new_height) // 2
  611. out_img = out_img.crop((0, top, out_img.width, top + new_height))
  612. out_img = out_img.resize((AI_IMAGE_TARGET_WIDTH, AI_IMAGE_TARGET_HEIGHT), Image.Resampling.LANCZOS)
  613. out = BytesIO()
  614. out_img.save(out, format="JPEG", quality=92, optimize=True)
  615. return out.getvalue()
  616. def _extract_image_ref_from_openrouter(data: dict) -> str:
  617. choices = data.get("choices") or []
  618. if not choices:
  619. raise RuntimeError("OpenRouter 响应缺 choices")
  620. msg = choices[0].get("message") or {}
  621. images = msg.get("images") or []
  622. for image in images:
  623. image_url = image.get("image_url") if isinstance(image, dict) else None
  624. if isinstance(image_url, dict) and image_url.get("url"):
  625. return image_url["url"]
  626. if isinstance(image_url, str):
  627. return image_url
  628. content = msg.get("content")
  629. if isinstance(content, list):
  630. for part in content:
  631. if not isinstance(part, dict):
  632. continue
  633. image_url = part.get("image_url")
  634. if isinstance(image_url, dict) and image_url.get("url"):
  635. return image_url["url"]
  636. if part.get("type") in ("image_url", "output_image") and part.get("url"):
  637. return part["url"]
  638. if isinstance(content, str):
  639. match = re.search(r"!\[[^\]]*]\(([^)]+)\)", content)
  640. if match:
  641. return match.group(1)
  642. data_match = re.search(r"(data:image/[^;\s]+;base64,[A-Za-z0-9+/=]+)", content)
  643. if data_match:
  644. return data_match.group(1)
  645. url_match = re.search(r"https?://\S+", content)
  646. if url_match:
  647. return url_match.group(0).rstrip(").,,。")
  648. raise RuntimeError("OpenRouter 响应未包含可识别图片URL/base64")
  649. def generate_image_bytes(
  650. prompt: str,
  651. model: str = OPENROUTER_IMAGE_MODEL,
  652. *,
  653. input_image_url: str = "",
  654. ) -> tuple[bytes, str, dict]:
  655. """通过 OpenRouter Images API 生成一张图片。
  656. Images API 支持直接传 aspect_ratio;但并非所有模型都支持 output_format,
  657. 因此代码仍会把返回的位图标准化为 JPEG。
  658. """
  659. body = {
  660. "model": model,
  661. "prompt": prompt,
  662. "aspect_ratio": AI_IMAGE_ASPECT_RATIO,
  663. "resolution": AI_IMAGE_RESOLUTION,
  664. "n": 1,
  665. }
  666. if input_image_url:
  667. body["input_references"] = [
  668. {
  669. "type": "image_url",
  670. "image_url": {"url": input_image_url},
  671. }
  672. ]
  673. headers = {
  674. "authorization": f"Bearer {_openrouter_api_key()}",
  675. "content-type": "application/json",
  676. "accept": "application/json",
  677. }
  678. resp = httpx.post(OPENROUTER_IMAGES_URL, json=body, headers=headers, timeout=120)
  679. resp.raise_for_status()
  680. data = resp.json()
  681. images = data.get("data") or []
  682. if not images or not images[0].get("b64_json"):
  683. raise RuntimeError("OpenRouter Images API 响应缺少 data[0].b64_json")
  684. image_bytes = base64.b64decode(images[0]["b64_json"])
  685. if AI_IMAGE_OUTPUT_FORMAT.lower() in ("jpg", "jpeg"):
  686. return _to_jpeg_bytes(image_bytes), "image/jpeg", data
  687. media_type = images[0].get("media_type") or "image/png"
  688. return image_bytes, media_type, data
  689. def _public_oss_url(public_base_url: str, object_key: str) -> str:
  690. return f"{public_base_url.rstrip('/')}/{quote(object_key, safe='/')}"
  691. def _oss_env() -> tuple[str, str, str, str, str, str]:
  692. endpoint = os.getenv("ALIYUN_OSS_ENDPOINT", "").strip()
  693. bucket = (
  694. os.getenv("ALIYUN_OSS_BUCKET", "").strip()
  695. or os.getenv("AI_IMAGE_OSS_BUCKET", "").strip()
  696. or DEFAULT_AI_IMAGE_OSS_BUCKET
  697. )
  698. access_key_id = os.getenv("ALIYUN_OSS_ACCESS_KEY_ID", "").strip()
  699. access_key_secret = os.getenv("ALIYUN_OSS_ACCESS_KEY_SECRET", "").strip()
  700. public_base_url = (
  701. os.getenv("AI_IMAGE_PUBLIC_BASE_URL", "").strip()
  702. or DEFAULT_AI_IMAGE_PUBLIC_BASE_URL
  703. )
  704. object_prefix = os.getenv("AI_IMAGE_OSS_PREFIX", AI_IMAGE_OSS_PREFIX).strip("/")
  705. missing = [
  706. name for name, value in [
  707. ("ALIYUN_OSS_ENDPOINT", endpoint),
  708. ("ALIYUN_OSS_BUCKET", bucket),
  709. ("ALIYUN_OSS_ACCESS_KEY_ID", access_key_id),
  710. ("ALIYUN_OSS_ACCESS_KEY_SECRET", access_key_secret),
  711. ]
  712. if not value
  713. ]
  714. if missing:
  715. raise RuntimeError(f"缺少 OSS 配置:{','.join(missing)}")
  716. return endpoint, bucket, access_key_id, access_key_secret, public_base_url.rstrip("/"), object_prefix
  717. def _normalize_oss_endpoint(endpoint: str, bucket: str) -> str:
  718. endpoint = endpoint.rstrip("/")
  719. if not endpoint.startswith(("http://", "https://")):
  720. endpoint = "https://" + endpoint
  721. parsed = urlparse(endpoint)
  722. host = parsed.netloc
  723. if not host.startswith(f"{bucket}."):
  724. host = f"{bucket}.{host}"
  725. return f"{parsed.scheme}://{host}"
  726. def upload_image_to_oss(image_bytes: bytes, content_type: str, object_key: str) -> str:
  727. endpoint, bucket, access_key_id, access_key_secret, public_base_url, _ = _oss_env()
  728. content_md5 = base64.b64encode(hashlib.md5(image_bytes).digest()).decode("ascii")
  729. date = formatdate(usegmt=True)
  730. canonical_resource = f"/{bucket}/{object_key}"
  731. string_to_sign = f"PUT\n{content_md5}\n{content_type}\n{date}\n{canonical_resource}"
  732. signature = base64.b64encode(
  733. hmac.new(
  734. access_key_secret.encode("utf-8"),
  735. string_to_sign.encode("utf-8"),
  736. hashlib.sha1,
  737. ).digest()
  738. ).decode("ascii")
  739. upload_base = _normalize_oss_endpoint(endpoint, bucket)
  740. url = f"{upload_base}/{quote(object_key, safe='/')}"
  741. resp = httpx.put(
  742. url,
  743. content=image_bytes,
  744. headers={
  745. "Date": date,
  746. "Content-Type": content_type,
  747. "Content-MD5": content_md5,
  748. "Authorization": f"OSS {access_key_id}:{signature}",
  749. },
  750. timeout=60,
  751. )
  752. resp.raise_for_status()
  753. return _public_oss_url(public_base_url, object_key)
  754. def build_ai_image_object_key(
  755. *,
  756. account_id: int | str,
  757. landing_video_id: int | str,
  758. prompt_type: str,
  759. extension: str,
  760. debug: bool = False,
  761. ) -> str:
  762. object_prefix = os.getenv("AI_IMAGE_OSS_PREFIX", AI_IMAGE_OSS_PREFIX).strip("/")
  763. ext = extension if extension.startswith(".") else f".{extension}"
  764. if debug:
  765. scope = "debug"
  766. else:
  767. scope = f"account_{account_id}/video_{landing_video_id}"
  768. return f"{object_prefix}/{scope}/{prompt_type}_{uuid.uuid4().hex}{ext}"
  769. def _insert_generated_material(
  770. *,
  771. account_id: int,
  772. adgroup_id: int,
  773. crowd_package: str,
  774. landing: LandingVideo,
  775. prompt: GenerationPrompt,
  776. model: str,
  777. object_key: str,
  778. oss_url: str,
  779. raw_response: dict,
  780. ) -> GeneratedMaterialAsset:
  781. ensure_ai_material_table()
  782. from db.connection import get_connection
  783. conn = get_connection()
  784. try:
  785. with conn.cursor() as cur:
  786. cur.execute(
  787. """
  788. INSERT INTO ai_generated_material
  789. (account_id, adgroup_id, crowd_package, landing_video_id,
  790. landing_title, landing_category, prompt_type, prompt_text,
  791. prompt_feature_json, model, oss_object_key, oss_url, raw_response)
  792. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  793. """,
  794. (
  795. account_id,
  796. adgroup_id,
  797. crowd_package,
  798. landing.video_id,
  799. landing.title,
  800. landing.category,
  801. prompt.prompt_type,
  802. prompt.prompt_text,
  803. json.dumps(prompt.feature_hits, ensure_ascii=False),
  804. model,
  805. object_key,
  806. oss_url,
  807. json.dumps(raw_response, ensure_ascii=False, default=str)[:16000000],
  808. ),
  809. )
  810. asset_id = int(cur.lastrowid)
  811. conn.commit()
  812. finally:
  813. conn.close()
  814. return GeneratedMaterialAsset(
  815. id=asset_id,
  816. account_id=account_id,
  817. adgroup_id=adgroup_id,
  818. crowd_package=crowd_package,
  819. landing_video_id=landing.video_id,
  820. prompt_type=prompt.prompt_type,
  821. prompt_text=prompt.prompt_text,
  822. model=model,
  823. oss_url=oss_url,
  824. oss_object_key=object_key,
  825. feature_hits=prompt.feature_hits,
  826. )
  827. def insert_and_review_generated_material(
  828. *,
  829. account_id: int,
  830. adgroup_id: int,
  831. crowd_package: str,
  832. landing: LandingVideo,
  833. prompt: GenerationPrompt,
  834. model: str,
  835. object_key: str,
  836. oss_url: str,
  837. raw_response: dict,
  838. ) -> tuple[GeneratedMaterialAsset, Any]:
  839. """持久化一个生成资产,并执行与生产一致的 AI 审核。"""
  840. asset = _insert_generated_material(
  841. account_id=account_id,
  842. adgroup_id=adgroup_id,
  843. crowd_package=crowd_package,
  844. landing=landing,
  845. prompt=prompt,
  846. model=model,
  847. object_key=object_key,
  848. oss_url=oss_url,
  849. raw_response=raw_response,
  850. )
  851. from tools.ai_material_review import (
  852. MaterialReviewResult,
  853. review_generated_material,
  854. update_material_review_result,
  855. )
  856. try:
  857. review = review_generated_material(
  858. image_url=oss_url,
  859. prompt_type=prompt.prompt_type,
  860. prompt_text=prompt.prompt_text,
  861. feature_hits=prompt.feature_hits,
  862. )
  863. except Exception as e:
  864. logger.exception(
  865. "[ai_generated_material] AI审核异常 account=%d adgroup=%d landing=%d asset=%d: %s",
  866. account_id, adgroup_id, landing.video_id, asset.id, e,
  867. )
  868. review = MaterialReviewResult(
  869. status="error",
  870. score=0,
  871. reason=f"AI审核异常:{e}",
  872. risk_tags=["review_error"],
  873. ocr_text="",
  874. raw={"error": str(e)},
  875. )
  876. update_material_review_result(asset.id, review)
  877. return asset, review
  878. def _rows_to_assets(rows: list[dict]) -> list[GeneratedMaterialAsset]:
  879. out = []
  880. for row in rows:
  881. try:
  882. feature_hits = json.loads(row.get("prompt_feature_json") or "[]")
  883. except Exception:
  884. feature_hits = []
  885. out.append(GeneratedMaterialAsset(
  886. id=int(row["id"]),
  887. account_id=int(row["account_id"]),
  888. adgroup_id=int(row["adgroup_id"]) if row.get("adgroup_id") else None,
  889. crowd_package=str(row.get("crowd_package") or ""),
  890. landing_video_id=int(row["landing_video_id"]),
  891. prompt_type=str(row.get("prompt_type") or ""),
  892. prompt_text=str(row.get("prompt_text") or ""),
  893. model=str(row.get("model") or ""),
  894. oss_url=str(row.get("oss_url") or ""),
  895. oss_object_key=str(row.get("oss_object_key") or ""),
  896. feature_hits=feature_hits,
  897. ))
  898. return out
  899. def load_available_generated_assets(
  900. account_id: int,
  901. adgroup_id: int,
  902. landing_video_id: int,
  903. ) -> list[GeneratedMaterialAsset]:
  904. ensure_ai_material_table()
  905. from db.connection import get_connection
  906. conn = get_connection()
  907. try:
  908. with conn.cursor() as cur:
  909. cur.execute(
  910. """
  911. SELECT *
  912. FROM ai_generated_material
  913. WHERE account_id=%s
  914. AND adgroup_id=%s
  915. AND landing_video_id=%s
  916. AND status IN ('generated', 'prepared')
  917. AND ai_review_status='pass'
  918. AND oss_url IS NOT NULL
  919. AND oss_url <> ''
  920. ORDER BY id ASC
  921. """,
  922. (account_id, adgroup_id, landing_video_id),
  923. )
  924. rows = cur.fetchall() or []
  925. finally:
  926. conn.close()
  927. return _rows_to_assets(rows)
  928. def generate_assets_for_landing(
  929. *,
  930. account_id: int,
  931. adgroup_id: int,
  932. crowd_package: str,
  933. landing: LandingVideo,
  934. model: str = OPENROUTER_IMAGE_MODEL,
  935. use_pattern_selector: bool = AI_IMAGE_USE_PATTERN_SELECTOR,
  936. placement: str = AI_IMAGE_PATTERN_PLACEMENT,
  937. pattern_top_k: int = AI_IMAGE_PATTERN_TOP_K,
  938. text_model: str = OPENROUTER_TEXT_MODEL,
  939. skip_prompt_types: set[str] | None = None,
  940. max_new_assets: int | None = None,
  941. ) -> list[GeneratedMaterialAsset]:
  942. db_features = read_cached_video_element_features([landing.video_id]).get(landing.video_id) or []
  943. if not db_features:
  944. logger.info(
  945. "[ai_generated_material] landing=%d no cached DB features for generation",
  946. landing.video_id,
  947. )
  948. return []
  949. topic = _top_feature(db_features, dimension="解构选题")
  950. topic_text = (topic or {}).get("standard_element")
  951. if not topic_text:
  952. logger.info(
  953. "[ai_generated_material] landing=%d no topic feature for generation",
  954. landing.video_id,
  955. )
  956. return []
  957. sanitized_description = sanitize_video_description(topic_text)
  958. logger.info(
  959. "[ai_generated_material] landing=%d sanitized description=%r",
  960. landing.video_id, sanitized_description,
  961. )
  962. if use_pattern_selector:
  963. prompts = build_pattern_generation_prompts(
  964. video_id=landing.video_id,
  965. title=landing.title,
  966. category=landing.category,
  967. features=db_features,
  968. sanitized_description=sanitized_description,
  969. crowd_package=crowd_package,
  970. placement=placement,
  971. top_k=pattern_top_k,
  972. text_model=text_model,
  973. )
  974. logger.info(
  975. "[ai_generated_material] landing=%d pattern prompts=%s",
  976. landing.video_id, [p.prompt_type for p in prompts],
  977. )
  978. else:
  979. prompts = build_generation_prompts(
  980. video_id=landing.video_id,
  981. title=landing.title,
  982. category=landing.category,
  983. features=db_features,
  984. sanitized_description=sanitized_description,
  985. )
  986. if skip_prompt_types:
  987. prompts = [p for p in prompts if p.prompt_type not in skip_prompt_types]
  988. if max_new_assets is not None:
  989. prompts = prompts[:max(0, int(max_new_assets))]
  990. assets: list[GeneratedMaterialAsset] = []
  991. for prompt in prompts:
  992. image_bytes, content_type, raw_response = generate_image_bytes(prompt.prompt_text, model)
  993. ext = mimetypes.guess_extension(content_type) or ".jpg"
  994. if ext == ".jpe":
  995. ext = ".jpg"
  996. object_key = build_ai_image_object_key(
  997. account_id=account_id,
  998. landing_video_id=landing.video_id,
  999. prompt_type=prompt.prompt_type,
  1000. extension=ext,
  1001. )
  1002. oss_url = upload_image_to_oss(image_bytes, content_type, object_key)
  1003. asset, review = insert_and_review_generated_material(
  1004. account_id=account_id,
  1005. adgroup_id=adgroup_id,
  1006. crowd_package=crowd_package,
  1007. landing=landing,
  1008. prompt=prompt,
  1009. model=model,
  1010. object_key=object_key,
  1011. oss_url=oss_url,
  1012. raw_response=raw_response,
  1013. )
  1014. if not review.passed:
  1015. logger.warning(
  1016. "[ai_generated_material] AI审核未通过 account=%d adgroup=%d landing=%d asset=%d status=%s score=%d reason=%s",
  1017. account_id, adgroup_id, landing.video_id, asset.id, review.status, review.score, review.reason,
  1018. )
  1019. continue
  1020. assets.append(asset)
  1021. logger.info(
  1022. "[ai_generated_material] generated reviewed account=%d adgroup=%d landing=%d asset=%d type=%s score=%d url=%s",
  1023. account_id, adgroup_id, landing.video_id, asset.id, prompt.prompt_type, review.score, oss_url,
  1024. )
  1025. return assets
  1026. def get_or_generate_assets_for_landing(
  1027. *,
  1028. account_id: int,
  1029. adgroup_id: int,
  1030. crowd_package: str,
  1031. landing: LandingVideo,
  1032. use_pattern_selector: bool = AI_IMAGE_USE_PATTERN_SELECTOR,
  1033. placement: str = AI_IMAGE_PATTERN_PLACEMENT,
  1034. pattern_top_k: int = AI_IMAGE_PATTERN_TOP_K,
  1035. text_model: str = OPENROUTER_TEXT_MODEL,
  1036. ) -> list[GeneratedMaterialAsset]:
  1037. existing = load_available_generated_assets(account_id, adgroup_id, landing.video_id)
  1038. target_count = max(1, int(pattern_top_k) if use_pattern_selector else 1)
  1039. if len(existing) >= target_count:
  1040. return existing[:target_count]
  1041. skip_prompt_types = {asset.prompt_type for asset in existing}
  1042. generated = generate_assets_for_landing(
  1043. account_id=account_id,
  1044. adgroup_id=adgroup_id,
  1045. crowd_package=crowd_package,
  1046. landing=landing,
  1047. use_pattern_selector=use_pattern_selector,
  1048. placement=placement,
  1049. pattern_top_k=pattern_top_k,
  1050. text_model=text_model,
  1051. skip_prompt_types=skip_prompt_types,
  1052. max_new_assets=target_count - len(existing),
  1053. )
  1054. return [*existing, *generated]
  1055. def update_generated_material_status(
  1056. record: dict,
  1057. status: str,
  1058. *,
  1059. dynamic_creative_id: int | str | None = None,
  1060. tencent_image_id: str = "",
  1061. error: str = "",
  1062. ) -> None:
  1063. asset_id = record.get("_ai_generated_material_id")
  1064. if not asset_id:
  1065. return
  1066. ensure_ai_material_table()
  1067. from db.connection import get_connection
  1068. normalized_status = {
  1069. "approve": "approved",
  1070. "reject": "rejected",
  1071. }.get(status, status)
  1072. approval_status = {
  1073. "approve": "approved",
  1074. "reject": "rejected",
  1075. "hold": "hold",
  1076. "skip": "skip",
  1077. }.get(status, normalized_status)
  1078. conn = get_connection()
  1079. try:
  1080. with conn.cursor() as cur:
  1081. cur.execute(
  1082. """
  1083. UPDATE ai_generated_material
  1084. SET status=%s,
  1085. approval_status=%s,
  1086. dynamic_creative_id=COALESCE(%s, dynamic_creative_id),
  1087. tencent_image_id=COALESCE(NULLIF(%s, ''), tencent_image_id),
  1088. error=COALESCE(NULLIF(%s, ''), error),
  1089. updated_at=CURRENT_TIMESTAMP
  1090. WHERE id=%s
  1091. """,
  1092. (
  1093. normalized_status,
  1094. approval_status,
  1095. int(dynamic_creative_id) if dynamic_creative_id else None,
  1096. tencent_image_id,
  1097. error[:2000] if error else "",
  1098. int(asset_id),
  1099. ),
  1100. )
  1101. conn.commit()
  1102. finally:
  1103. conn.close()