creative_review.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. """Tencent dynamic creative review result scanner.
  2. This module tracks creatives submitted by the creation pipeline and polls
  3. Tencent's official review-result API. It does not perform prereview.
  4. """
  5. from __future__ import annotations
  6. import json
  7. import logging
  8. from dataclasses import dataclass, field
  9. from datetime import datetime, timezone
  10. from typing import Iterable, Iterator, Optional
  11. logger = logging.getLogger(__name__)
  12. CREATE_REVIEW_TABLES_SQL = [
  13. """
  14. CREATE TABLE IF NOT EXISTS creative_creation_task (
  15. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  16. account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
  17. adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
  18. dynamic_creative_id BIGINT NOT NULL COMMENT '动态创意ID',
  19. dynamic_creative_name VARCHAR(200) DEFAULT NULL COMMENT '动态创意名称',
  20. landing_video_id BIGINT DEFAULT NULL COMMENT '承接视频ID',
  21. material_id VARCHAR(100) DEFAULT NULL COMMENT '内部素材ID',
  22. material_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
  23. review_status VARCHAR(50) NOT NULL DEFAULT 'submitted' COMMENT '审核状态',
  24. submit_status VARCHAR(50) NOT NULL DEFAULT 'submitted' COMMENT '提交状态',
  25. submitted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间',
  26. last_review_checked_at TIMESTAMP NULL DEFAULT NULL COMMENT '最近审核扫描时间',
  27. review_finished_at TIMESTAMP NULL DEFAULT NULL COMMENT '审核完成时间',
  28. error TEXT DEFAULT NULL COMMENT '提交或扫描错误',
  29. raw_record MEDIUMTEXT DEFAULT NULL COMMENT '创建记录JSON',
  30. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  31. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  32. UNIQUE KEY uk_account_creative (account_id, dynamic_creative_id),
  33. KEY idx_review_status (review_status),
  34. KEY idx_submitted_at (submitted_at),
  35. KEY idx_landing_submitted (landing_video_id, submitted_at),
  36. KEY idx_last_review_checked_at (last_review_checked_at),
  37. KEY idx_account_status (account_id, review_status)
  38. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意创建与审核扫描任务'
  39. """,
  40. """
  41. CREATE TABLE IF NOT EXISTS creative_review_result (
  42. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  43. account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
  44. dynamic_creative_id BIGINT NOT NULL COMMENT '动态创意ID',
  45. review_status VARCHAR(50) NOT NULL COMMENT '解析后的审核状态',
  46. reject_messages MEDIUMTEXT DEFAULT NULL COMMENT '拒绝原因JSON数组',
  47. delay_messages MEDIUMTEXT DEFAULT NULL COMMENT '延迟审核原因JSON数组',
  48. raw_result MEDIUMTEXT NOT NULL COMMENT '腾讯审核结果原文JSON',
  49. checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '扫描时间',
  50. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  51. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  52. UNIQUE KEY uk_account_creative (account_id, dynamic_creative_id),
  53. KEY idx_review_status (review_status),
  54. KEY idx_checked_at (checked_at)
  55. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯动态创意审核结果'
  56. """,
  57. """
  58. CREATE TABLE IF NOT EXISTS creative_rejection_fact (
  59. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  60. account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
  61. dynamic_creative_id BIGINT NOT NULL COMMENT '动态创意ID',
  62. fact_type VARCHAR(50) NOT NULL COMMENT '拒绝事实类型:site/element/compose',
  63. fact_key VARCHAR(255) DEFAULT NULL COMMENT '元素/版位/组件标识',
  64. reason TEXT NOT NULL COMMENT '拒绝原因',
  65. site_set VARCHAR(100) DEFAULT NULL COMMENT '影响版位',
  66. element_type VARCHAR(100) DEFAULT NULL COMMENT '元素类型',
  67. component_type VARCHAR(100) DEFAULT NULL COMMENT '组件类型',
  68. raw_detail MEDIUMTEXT DEFAULT NULL COMMENT '原始明细JSON',
  69. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  70. KEY idx_account_creative (account_id, dynamic_creative_id),
  71. KEY idx_fact_type (fact_type),
  72. KEY idx_fact_key (fact_key(100))
  73. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意审核拒绝事实'
  74. """,
  75. ]
  76. FINAL_REVIEW_STATUSES = {"approved", "rejected"}
  77. PENDING_REVIEW_STATUSES = {"submitted", "pending", "unknown"}
  78. @dataclass
  79. class RejectionFact:
  80. fact_type: str
  81. reason: str
  82. fact_key: str = ""
  83. site_set: str = ""
  84. element_type: str = ""
  85. component_type: str = ""
  86. raw_detail: dict = field(default_factory=dict)
  87. @dataclass
  88. class ParsedReviewResult:
  89. dynamic_creative_id: Optional[int]
  90. review_status: str
  91. reject_messages: list[str] = field(default_factory=list)
  92. delay_messages: list[str] = field(default_factory=list)
  93. rejection_facts: list[RejectionFact] = field(default_factory=list)
  94. def _dedupe(values: Iterable[str]) -> list[str]:
  95. out: list[str] = []
  96. seen: set[str] = set()
  97. for raw in values:
  98. value = str(raw or "").strip()
  99. if not value or value in seen:
  100. continue
  101. seen.add(value)
  102. out.append(value)
  103. return out
  104. def _status_is_rejected(status: str) -> bool:
  105. upper = (status or "").upper()
  106. return "REJECT" in upper or "DENIED" in upper
  107. def _status_is_pending(status: str) -> bool:
  108. upper = (status or "").upper()
  109. return "PENDING" in upper or "REVIEWING" in upper
  110. def _status_is_approved(status: str) -> bool:
  111. upper = (status or "").upper()
  112. return "PASS" in upper or "APPROVED" in upper or "NORMAL" in upper
  113. def _component_type(detail: dict) -> str:
  114. component = detail.get("component_info") or {}
  115. if isinstance(component, dict):
  116. return str(component.get("component_type") or "")
  117. return ""
  118. def _collect_element_facts(result: dict) -> tuple[list[str], list[RejectionFact], bool, bool]:
  119. messages: list[str] = []
  120. facts: list[RejectionFact] = []
  121. has_pending = False
  122. has_approved = False
  123. for element in result.get("element_result_list") or []:
  124. if not isinstance(element, dict):
  125. continue
  126. status = str(element.get("review_status") or "")
  127. has_pending = has_pending or _status_is_pending(status)
  128. has_approved = has_approved or _status_is_approved(status)
  129. if not _status_is_rejected(status):
  130. continue
  131. details = element.get("element_reject_detail_info") or []
  132. if not details:
  133. details = [{"reason": element.get("reason") or ""}]
  134. for detail in details:
  135. if not isinstance(detail, dict):
  136. continue
  137. reason = detail.get("reason") or element.get("reason") or ""
  138. if reason:
  139. messages.append(str(reason))
  140. facts.append(RejectionFact(
  141. fact_type="element",
  142. fact_key=str(
  143. element.get("image_id")
  144. or element.get("video_id")
  145. or element.get("element_name")
  146. or ""
  147. ),
  148. reason=str(reason),
  149. element_type=str(element.get("element_type") or ""),
  150. component_type=_component_type(element),
  151. raw_detail={**element, "element_reject_detail": detail},
  152. ))
  153. return messages, facts, has_pending, has_approved
  154. def _collect_site_facts(result: dict) -> tuple[list[str], list[RejectionFact], bool, bool]:
  155. messages: list[str] = []
  156. facts: list[RejectionFact] = []
  157. has_pending = False
  158. has_approved = False
  159. for site in result.get("site_set_result_list") or []:
  160. if not isinstance(site, dict):
  161. continue
  162. status = str(site.get("system_status") or site.get("review_status") or "")
  163. has_pending = has_pending or _status_is_pending(status)
  164. has_approved = has_approved or _status_is_approved(status)
  165. reject_message = site.get("reject_message") or ""
  166. if _status_is_rejected(status) or reject_message:
  167. if reject_message:
  168. messages.append(str(reject_message))
  169. facts.append(RejectionFact(
  170. fact_type="site",
  171. fact_key=str(site.get("site_set") or ""),
  172. site_set=str(site.get("site_set") or ""),
  173. reason=str(reject_message),
  174. raw_detail=site,
  175. ))
  176. for detail in site.get("element_reject_detail_info") or []:
  177. if not isinstance(detail, dict):
  178. continue
  179. reason = detail.get("reason") or ""
  180. if reason:
  181. messages.append(str(reason))
  182. facts.append(RejectionFact(
  183. fact_type="element",
  184. fact_key=str(detail.get("element_name") or ""),
  185. site_set=str(site.get("site_set") or ""),
  186. reason=str(reason),
  187. element_type=str(detail.get("element_type") or ""),
  188. component_type=_component_type(detail),
  189. raw_detail={**detail, "site_set": site.get("site_set")},
  190. ))
  191. return messages, facts, has_pending, has_approved
  192. def _collect_compose_facts(result: dict) -> tuple[list[str], list[RejectionFact]]:
  193. messages: list[str] = []
  194. facts: list[RejectionFact] = []
  195. for item in result.get("reject_component_compose_info_list") or []:
  196. if not isinstance(item, dict):
  197. continue
  198. reason = item.get("reject_message") or ""
  199. if reason:
  200. messages.append(str(reason))
  201. facts.append(RejectionFact(
  202. fact_type="compose",
  203. fact_key="component_compose",
  204. reason=str(reason),
  205. raw_detail=item,
  206. ))
  207. return messages, facts
  208. def parse_review_result(result: dict) -> ParsedReviewResult:
  209. """Parse Tencent dynamic creative review result into stable internal status."""
  210. reject_messages: list[str] = []
  211. delay_messages = [str(v) for v in (result.get("delay_message_list") or []) if v]
  212. facts: list[RejectionFact] = []
  213. has_pending = bool(result.get("is_all_component_compose_pending"))
  214. has_approved = False
  215. reject_messages.extend(str(v) for v in (result.get("reject_message_list") or []) if v)
  216. if result.get("reject_component_compose_count"):
  217. reject_messages.append("组件组合审核拒绝")
  218. if result.get("pass_component_compose_count"):
  219. has_approved = True
  220. if result.get("total_component_compose_count") and result.get("reject_component_compose_count") == 0:
  221. has_approved = True
  222. for collector in (_collect_site_facts, _collect_element_facts):
  223. messages, new_facts, pending, approved = collector(result)
  224. reject_messages.extend(messages)
  225. facts.extend(new_facts)
  226. has_pending = has_pending or pending
  227. has_approved = has_approved or approved
  228. messages, new_facts = _collect_compose_facts(result)
  229. reject_messages.extend(messages)
  230. facts.extend(new_facts)
  231. reject_messages = _dedupe(reject_messages)
  232. delay_messages = _dedupe(delay_messages)
  233. if reject_messages or facts or int(result.get("reject_component_compose_count") or 0) > 0:
  234. status = "rejected"
  235. elif has_pending or delay_messages:
  236. status = "pending"
  237. elif has_approved:
  238. status = "approved"
  239. else:
  240. status = "unknown"
  241. dynamic_creative_id = result.get("dynamic_creative_id")
  242. try:
  243. dynamic_creative_id = int(dynamic_creative_id) if dynamic_creative_id is not None else None
  244. except (TypeError, ValueError):
  245. dynamic_creative_id = None
  246. return ParsedReviewResult(
  247. dynamic_creative_id=dynamic_creative_id,
  248. review_status=status,
  249. reject_messages=reject_messages,
  250. delay_messages=delay_messages,
  251. rejection_facts=facts,
  252. )
  253. def group_review_tasks(
  254. tasks: Iterable[dict],
  255. batch_size: int = 100,
  256. ) -> Iterator[tuple[int, list[int]]]:
  257. """Yield (account_id, dynamic_creative_ids) batches for Tencent API."""
  258. by_account: dict[int, list[int]] = {}
  259. for task in tasks:
  260. try:
  261. account_id = int(task["account_id"])
  262. creative_id = int(task["dynamic_creative_id"])
  263. except (KeyError, TypeError, ValueError):
  264. continue
  265. by_account.setdefault(account_id, []).append(creative_id)
  266. for account_id in sorted(by_account):
  267. ids = by_account[account_id]
  268. for i in range(0, len(ids), batch_size):
  269. yield account_id, ids[i:i + batch_size]
  270. def ensure_review_tables() -> None:
  271. from db.connection import get_connection
  272. conn = get_connection()
  273. try:
  274. with conn.cursor() as cur:
  275. for sql in CREATE_REVIEW_TABLES_SQL:
  276. cur.execute(sql)
  277. finally:
  278. conn.close()
  279. def record_creation_submission(record: dict, dynamic_creative_id: int) -> None:
  280. """Persist a successfully submitted creative as pending review scan."""
  281. ensure_review_tables()
  282. from db.connection import get_connection
  283. body = record.get("_request_body") or {}
  284. raw_record = json.dumps(record, ensure_ascii=False, default=str)
  285. conn = get_connection()
  286. try:
  287. with conn.cursor() as cur:
  288. cur.execute(
  289. """
  290. INSERT INTO creative_creation_task
  291. (account_id, adgroup_id, dynamic_creative_id, dynamic_creative_name,
  292. landing_video_id, material_id, material_image_id,
  293. review_status, submit_status, submitted_at, raw_record)
  294. VALUES (%s, %s, %s, %s, %s, %s, %s, 'submitted', 'submitted', NOW(), %s)
  295. ON DUPLICATE KEY UPDATE
  296. adgroup_id=VALUES(adgroup_id),
  297. dynamic_creative_name=VALUES(dynamic_creative_name),
  298. landing_video_id=VALUES(landing_video_id),
  299. material_id=VALUES(material_id),
  300. material_image_id=VALUES(material_image_id),
  301. submit_status='submitted',
  302. review_status=IF(review_status IN ('approved','rejected'), review_status, 'submitted'),
  303. raw_record=VALUES(raw_record),
  304. updated_at=CURRENT_TIMESTAMP
  305. """,
  306. (
  307. int(record["account_id"]),
  308. int(record.get("adgroup_id") or body.get("adgroup_id") or 0) or None,
  309. int(dynamic_creative_id),
  310. record.get("creative_name") or body.get("dynamic_creative_name"),
  311. record.get("landing_video_id"),
  312. str(record.get("_material_id") or ""),
  313. str(record.get("_material_image_id") or ""),
  314. raw_record,
  315. ),
  316. )
  317. finally:
  318. conn.close()
  319. def mark_creation_submit_failed(record: dict, error: str) -> None:
  320. """Persist submit failure for observability."""
  321. ensure_review_tables()
  322. from db.connection import get_connection
  323. body = record.get("_request_body") or {}
  324. raw_record = json.dumps(record, ensure_ascii=False, default=str)
  325. conn = get_connection()
  326. try:
  327. with conn.cursor() as cur:
  328. cur.execute(
  329. """
  330. INSERT INTO creative_creation_task
  331. (account_id, adgroup_id, dynamic_creative_id, dynamic_creative_name,
  332. landing_video_id, material_id, material_image_id,
  333. review_status, submit_status, error, raw_record)
  334. VALUES (%s, %s, 0, %s, %s, %s, %s, 'submit_failed', 'failed', %s, %s)
  335. """,
  336. (
  337. int(record["account_id"]),
  338. int(record.get("adgroup_id") or body.get("adgroup_id") or 0) or None,
  339. record.get("creative_name") or body.get("dynamic_creative_name"),
  340. record.get("landing_video_id"),
  341. str(record.get("_material_id") or ""),
  342. str(record.get("_material_image_id") or ""),
  343. error[:2000],
  344. raw_record,
  345. ),
  346. )
  347. except Exception as e:
  348. logger.warning("[creative_review] 记录提交失败状态异常:%s", e)
  349. finally:
  350. conn.close()
  351. def load_pending_review_tasks(lookback_hours: int = 72, limit: int = 1000) -> list[dict]:
  352. ensure_review_tables()
  353. from db.connection import get_connection
  354. conn = get_connection()
  355. try:
  356. with conn.cursor() as cur:
  357. cur.execute(
  358. """
  359. SELECT account_id, dynamic_creative_id
  360. FROM creative_creation_task
  361. WHERE dynamic_creative_id > 0
  362. AND review_status IN ('submitted','pending','unknown')
  363. AND submitted_at >= DATE_SUB(NOW(), INTERVAL %s HOUR)
  364. ORDER BY COALESCE(last_review_checked_at, submitted_at) ASC
  365. LIMIT %s
  366. """,
  367. (int(lookback_hours), int(limit)),
  368. )
  369. return list(cur.fetchall())
  370. finally:
  371. conn.close()
  372. def fetch_dynamic_creative_review_results(
  373. account_id: int,
  374. creative_ids: list[int],
  375. ) -> list[dict]:
  376. if not creative_ids:
  377. return []
  378. from tools.ad_api import _check, _get
  379. resp = _get(
  380. "/dynamic_creative_review_results/get",
  381. {
  382. "account_id": account_id,
  383. "dynamic_creative_id_list": [int(v) for v in creative_ids],
  384. },
  385. )
  386. data = _check(resp, "dynamic_creative_review_results/get")
  387. return data.get("list") or []
  388. def save_review_result(account_id: int, raw_result: dict) -> ParsedReviewResult:
  389. ensure_review_tables()
  390. parsed = parse_review_result(raw_result)
  391. if parsed.dynamic_creative_id is None:
  392. raise ValueError("审核结果缺 dynamic_creative_id")
  393. raw_json = json.dumps(raw_result, ensure_ascii=False, default=str)
  394. reject_json = json.dumps(parsed.reject_messages, ensure_ascii=False)
  395. delay_json = json.dumps(parsed.delay_messages, ensure_ascii=False)
  396. finished_at_sql = "NOW()" if parsed.review_status in FINAL_REVIEW_STATUSES else "NULL"
  397. from db.connection import get_connection
  398. conn = get_connection()
  399. try:
  400. with conn.cursor() as cur:
  401. cur.execute(
  402. """
  403. INSERT INTO creative_review_result
  404. (account_id, dynamic_creative_id, review_status, reject_messages, delay_messages, raw_result, checked_at)
  405. VALUES (%s, %s, %s, %s, %s, %s, NOW())
  406. ON DUPLICATE KEY UPDATE
  407. review_status=VALUES(review_status),
  408. reject_messages=VALUES(reject_messages),
  409. delay_messages=VALUES(delay_messages),
  410. raw_result=VALUES(raw_result),
  411. checked_at=NOW(),
  412. updated_at=CURRENT_TIMESTAMP
  413. """,
  414. (
  415. int(account_id),
  416. int(parsed.dynamic_creative_id),
  417. parsed.review_status,
  418. reject_json,
  419. delay_json,
  420. raw_json,
  421. ),
  422. )
  423. cur.execute(
  424. f"""
  425. UPDATE creative_creation_task
  426. SET review_status=%s,
  427. last_review_checked_at=NOW(),
  428. review_finished_at={finished_at_sql},
  429. updated_at=CURRENT_TIMESTAMP
  430. WHERE account_id=%s AND dynamic_creative_id=%s
  431. """,
  432. (parsed.review_status, int(account_id), int(parsed.dynamic_creative_id)),
  433. )
  434. cur.execute(
  435. """
  436. DELETE FROM creative_rejection_fact
  437. WHERE account_id=%s AND dynamic_creative_id=%s
  438. """,
  439. (int(account_id), int(parsed.dynamic_creative_id)),
  440. )
  441. if parsed.rejection_facts:
  442. cur.executemany(
  443. """
  444. INSERT INTO creative_rejection_fact
  445. (account_id, dynamic_creative_id, fact_type, fact_key, reason,
  446. site_set, element_type, component_type, raw_detail)
  447. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
  448. """,
  449. [
  450. (
  451. int(account_id),
  452. int(parsed.dynamic_creative_id),
  453. fact.fact_type,
  454. fact.fact_key[:255] if fact.fact_key else None,
  455. fact.reason,
  456. fact.site_set or None,
  457. fact.element_type or None,
  458. fact.component_type or None,
  459. json.dumps(fact.raw_detail, ensure_ascii=False, default=str),
  460. )
  461. for fact in parsed.rejection_facts
  462. ],
  463. )
  464. finally:
  465. conn.close()
  466. return parsed
  467. def scan_pending_reviews(
  468. lookback_hours: int = 72,
  469. limit: int = 1000,
  470. batch_size: int = 100,
  471. ) -> dict:
  472. """Scan pending submitted creatives and persist official review results."""
  473. tasks = load_pending_review_tasks(lookback_hours=lookback_hours, limit=limit)
  474. summary = {
  475. "run_started": datetime.now(timezone.utc).isoformat(),
  476. "tasks": len(tasks),
  477. "batches": 0,
  478. "results": 0,
  479. "approved": 0,
  480. "rejected": 0,
  481. "pending": 0,
  482. "unknown": 0,
  483. "errors": [],
  484. }
  485. for account_id, creative_ids in group_review_tasks(tasks, batch_size=batch_size):
  486. summary["batches"] += 1
  487. try:
  488. results = fetch_dynamic_creative_review_results(account_id, creative_ids)
  489. except Exception as e:
  490. err = f"account={account_id} ids={len(creative_ids)} error={e}"
  491. logger.exception("[creative_review] 查询审核结果失败:%s", err)
  492. summary["errors"].append(err)
  493. continue
  494. for raw in results:
  495. try:
  496. parsed = save_review_result(account_id, raw)
  497. except Exception as e:
  498. err = f"account={account_id} creative={raw.get('dynamic_creative_id')} error={e}"
  499. logger.exception("[creative_review] 保存审核结果失败:%s", err)
  500. summary["errors"].append(err)
  501. continue
  502. summary["results"] += 1
  503. summary[parsed.review_status] = summary.get(parsed.review_status, 0) + 1
  504. logger.info(
  505. "[creative_review] account=%d creative=%s status=%s reject=%d delay=%d",
  506. account_id,
  507. parsed.dynamic_creative_id,
  508. parsed.review_status,
  509. len(parsed.reject_messages),
  510. len(parsed.delay_messages),
  511. )
  512. summary["run_finished"] = datetime.now(timezone.utc).isoformat()
  513. return summary