ai_generated_material.py 46 KB

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