ai_generated_material.py 45 KB

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