execute_creation_once.py 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989
  1. """模块 B 创意搭建子系统 — 主循环入口(P0-A / P0-C / P0-E,2026-06-09)。
  2. 数据流(三阶段,先审后挂):
  3. Phase 1 — 准备:
  4. 扫账户 → 关联点过滤(读调控当日 latest_decisions 排除 pause)
  5. → find_ads_needing_creatives
  6. → 对每条广告 prepare_one_creative_for_ad(召回 + 上传图 + xcx/save + build body)
  7. → pending_records (含完整 Phase 3 用的 _request_body)
  8. → 写 outputs/data/creation_pending_{date}.json(追溯 + 独立 apply 用)
  9. Phase 2 — 审批(若 CREATION_APPROVAL_REQUIRED=True):
  10. run_approval_workflow 生成 20 列 xlsx → 上传飞书 sheet → 发链接 → 轮询读决策列
  11. actions {row_idx: approve/reject/hold}
  12. → 写回 records["action"]
  13. Phase 3 — 执行:
  14. 对 action=approve 的 → POST /dynamic_creatives/add
  15. → 写 outputs/data/creation_run_{date}.json
  16. → 发飞书"执行汇报"消息
  17. 开关 CREATION_APPROVAL_REQUIRED=False 时跳过 Phase 2,全 records 直接 approve。
  18. """
  19. import json
  20. import logging
  21. import os
  22. import sys
  23. import time
  24. from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
  25. from pathlib import Path
  26. _HERE = Path(__file__).parent
  27. sys.path.insert(0, str(_HERE.parent.parent))
  28. sys.path.insert(0, str(_HERE))
  29. from dotenv import load_dotenv # noqa: E402
  30. load_dotenv(_HERE / ".env")
  31. from config import ( # noqa: E402
  32. ADS_PER_ACCOUNT,
  33. CREATION_APPROVAL_REQUIRED,
  34. CREATION_APPROVAL_TIMEOUT_MINUTES,
  35. CREATIVE_PREPARE_MAX_WORKERS,
  36. CREATIVE_PREPARE_TASK_BUFFER,
  37. MAX_SAME_LANDING_PER_AD_IN_RUN,
  38. TARGET_CREATIVES_PER_AD,
  39. WHITELIST_ACCOUNTS,
  40. get_creation_account_ids,
  41. now_in_timezone,
  42. )
  43. # config import 副作用会改 sys.path(把 im-client / agent/tools/builtin 顶到前面),
  44. # 强占 sys.path[0],只影响本入口自己的 import resolution。
  45. while str(_HERE) in sys.path:
  46. sys.path.remove(str(_HERE))
  47. sys.path.insert(0, str(_HERE))
  48. from tools.ad_creation import ( # noqa: E402
  49. build_ad_request_body,
  50. compute_fingerprint,
  51. enumerate_new_ad_candidates,
  52. post_ad_with_prepared_body,
  53. )
  54. from tools.audience_grant import ensure_account_audience_grant # noqa: E402
  55. from tools.creative_creation import ( # noqa: E402
  56. build_landing_candidate_pool,
  57. find_ads_needing_creatives,
  58. load_excluded_ad_ids_from_adjustment,
  59. prepare_one_creative_for_ad,
  60. )
  61. from tools.creative_material_usage import ( # noqa: E402
  62. load_recent_landing_usage_counts,
  63. load_recoverable_prepared_records,
  64. record_prepared_material_usage,
  65. )
  66. from tools.account_material_strategy import ( # noqa: E402
  67. MATERIAL_SOURCE_AI_GENERATED,
  68. load_account_material_strategy,
  69. )
  70. from tools.video_recall import get_account_crowd_package # noqa: E402
  71. from execute_creation_apply import ( # noqa: E402
  72. apply_pending_records,
  73. write_summary as write_apply_summary,
  74. _send_apply_summary_to_feishu,
  75. )
  76. logger = logging.getLogger("execute_creation_once")
  77. MATERIAL_SOURCE_HISTORY = "history"
  78. def _env_flag(name: str, default: bool = False) -> bool:
  79. raw = os.getenv(name)
  80. if raw is None:
  81. return default
  82. return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
  83. def _env_csv_set(name: str) -> set[str]:
  84. return {
  85. item.strip()
  86. for item in os.getenv(name, "").split(",")
  87. if item.strip()
  88. }
  89. def _filter_creation_accounts(accounts: list[int], phase: str) -> list[int]:
  90. """Apply one-off run filters. Empty env means production default: no filter."""
  91. account_filter = {
  92. int(v)
  93. for v in _env_csv_set("CREATION_ONLY_ACCOUNT_IDS")
  94. if v.isdigit()
  95. }
  96. crowd_filter = _env_csv_set("CREATION_ONLY_CROWD_PACKAGES")
  97. if not account_filter and not crowd_filter:
  98. return accounts
  99. selected: list[int] = []
  100. for account_id in accounts:
  101. if account_filter and account_id not in account_filter:
  102. continue
  103. if crowd_filter:
  104. try:
  105. crowd_package = get_account_crowd_package(account_id)
  106. except Exception as e:
  107. logger.warning(
  108. "[%s] account=%d 临时过滤读取 crowd_package 失败,跳过:%s",
  109. phase, account_id, e,
  110. )
  111. continue
  112. if crowd_package not in crowd_filter:
  113. continue
  114. selected.append(account_id)
  115. logger.info(
  116. "[%s] 临时运行过滤 account_ids=%s crowd_packages=%s: %d -> %d",
  117. phase,
  118. sorted(account_filter) if account_filter else "ALL",
  119. sorted(crowd_filter) if crowd_filter else "ALL",
  120. len(accounts),
  121. len(selected),
  122. )
  123. return selected
  124. def _setup_logging() -> None:
  125. logging.basicConfig(
  126. level=logging.INFO,
  127. format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
  128. datefmt="%H:%M:%S",
  129. )
  130. for noisy in ("httpx", "httpcore", "config", "db.config"):
  131. logging.getLogger(noisy).setLevel(logging.WARNING)
  132. # SLS 上报(2026-06-11 接入,K8s pod 直发)— 配置缺失自动降级,不阻塞主链路
  133. try:
  134. from tools.sls_setup import attach_sls_handler
  135. attach_sls_handler()
  136. except Exception as e:
  137. logger.warning("[sls] 挂载异常(降级为本地 only):%s", e)
  138. def _fetch_existing_fingerprints_for_account(account_id: int) -> set[str]:
  139. """Task 27:拉账户下所有非 DELETED 广告 → 反推 fingerprint 集合(预校验唯一性)。
  140. 腾讯文档:NORMAL + SUSPEND 状态广告均占用唯一性槽位,DENIED 也算占用。
  141. list 接口默认返回所有非 DELETED 状态 ad,故无需显式 filter status。
  142. 若腾讯 list 接口某些字段不返回(field 实测差异),降级:跳过该条 + log 警告。
  143. 返回空集 → 上层退化为"不预校验",enumerate 仍能跑(原有行为)。
  144. """
  145. from tools.ad_api import _get
  146. fingerprints: set[str] = set()
  147. skipped = 0
  148. page = 1
  149. try:
  150. while True:
  151. r = _get("/adgroups/get", {
  152. "account_id": account_id, "page": page, "page_size": 100,
  153. "fields": [
  154. "adgroup_id", "configured_status", "site_set",
  155. "targeting", "scene_spec",
  156. ],
  157. })
  158. ads = (r.get("data") or {}).get("list") or []
  159. if not ads:
  160. break
  161. for ad in ads:
  162. tgt = ad.get("targeting") or {}
  163. sc = ad.get("scene_spec") or {}
  164. wp = sc.get("wechat_position") if sc else None
  165. site_set = ad.get("site_set") or []
  166. age = tgt.get("age") or []
  167. geo_regions = (tgt.get("geo_location") or {}).get("regions") or []
  168. custom_audience = tgt.get("custom_audience")
  169. if not site_set or not age:
  170. skipped += 1
  171. continue
  172. try:
  173. fp = compute_fingerprint(
  174. account_id=account_id,
  175. site_set=site_set,
  176. custom_audience=custom_audience,
  177. age=age,
  178. geo_regions=geo_regions,
  179. wechat_position=wp,
  180. )
  181. fingerprints.add(fp)
  182. except Exception as e:
  183. skipped += 1
  184. logger.debug(
  185. "[phase0] fingerprint 算失败 ad=%s: %s",
  186. ad.get("adgroup_id"), e,
  187. )
  188. if len(ads) < 100:
  189. break
  190. page += 1
  191. except Exception as e:
  192. logger.warning(
  193. "[phase0] account=%d 拉 fingerprint 集失败(降级为不预校验): %s",
  194. account_id, e,
  195. )
  196. return set()
  197. logger.info(
  198. "[phase0] 已存在 fingerprint=%d 个(skip 缺字段广告 %d 条)",
  199. len(fingerprints), skipped,
  200. )
  201. return fingerprints
  202. def phase0_create_ads(target_ads: int = ADS_PER_ACCOUNT) -> list[dict]:
  203. """Phase 0:对每账户检查广告数,不足则建到 target_ads 条(模块 A,2026-06-09 P1-G)。
  204. 流程:
  205. account → 查当前广告数(NORMAL+SUSPEND 算占用)→ 不足 target_ads →
  206. → 反查 fingerprint 集 → enumerate candidates(排除已有 fp)→
  207. → 飞书审批 → approve 项 → 真 POST /adgroups/add → 返回新建的 adgroup_id 列表
  208. Returns: 本次新建成功的广告 list[dict],每项含 {account_id, adgroup_id, adgroup_name, wechat_position}
  209. """
  210. from tools.ad_api import _get
  211. from tools.im_approval_ad_creation import run_ad_approval_workflow
  212. creation_accounts = get_creation_account_ids()
  213. if not creation_accounts:
  214. logger.error("[phase0] 待投放账户配置为空,退出")
  215. return []
  216. creation_accounts = _filter_creation_accounts(creation_accounts, "phase0")
  217. if not creation_accounts:
  218. logger.info("[phase0] 临时过滤后无待处理账户")
  219. return []
  220. # Task 26:NORMAL + SUSPEND 都算占用唯一性槽位(腾讯文档:删除前历史广告占槽位)
  221. OCCUPIED_STATUSES = {"AD_STATUS_NORMAL", "AD_STATUS_SUSPEND"}
  222. pending_ad_records: list[dict] = []
  223. for account_id in creation_accounts:
  224. logger.info("=" * 60)
  225. logger.info("[phase0] 账户 %d 处理开始", account_id)
  226. try:
  227. grant_result = ensure_account_audience_grant(account_id)
  228. logger.info(
  229. "[phase0] 人群包校验完成: status=%s audience_id=%s audience=%r",
  230. grant_result.get("status"),
  231. grant_result.get("audience_id"),
  232. grant_result.get("audience_name"),
  233. )
  234. except Exception as e:
  235. logger.exception("[phase0] account=%d 人群包授权/验证失败,跳过:%s", account_id, e)
  236. continue
  237. # Task 26:查所有非 DELETED 广告,NORMAL+SUSPEND 都算占用槽位
  238. try:
  239. r = _get("/adgroups/get", {
  240. "account_id": account_id, "page": 1, "page_size": 100,
  241. "fields": ["adgroup_id", "configured_status"],
  242. })
  243. all_ads = (r.get("data") or {}).get("list") or []
  244. normal_n = sum(1 for a in all_ads if a.get("configured_status") == "AD_STATUS_NORMAL")
  245. suspend_n = sum(1 for a in all_ads if a.get("configured_status") == "AD_STATUS_SUSPEND")
  246. occupied = [a for a in all_ads if a.get("configured_status") in OCCUPIED_STATUSES]
  247. logger.info(
  248. "[phase0] 广告数: 总 %d,NORMAL %d + SUSPEND %d = 占用 %d / 目标 %d",
  249. len(all_ads), normal_n, suspend_n, len(occupied), target_ads,
  250. )
  251. except Exception as e:
  252. logger.exception("[phase0] account=%d 查广告数失败: %s", account_id, e)
  253. continue
  254. to_create = max(0, target_ads - len(occupied))
  255. if to_create == 0:
  256. logger.info("[phase0] 占用槽位已满,跳过")
  257. continue
  258. # Task 27:fingerprint 预校验 — 反查现存广告的 fp 集合,enumerate 时排除
  259. existing_fps = _fetch_existing_fingerprints_for_account(account_id)
  260. # enumerate 候选(模块 A,差异化 wechat_position 有/无)
  261. try:
  262. candidates = enumerate_new_ad_candidates(
  263. account_id, count=to_create,
  264. existing_fingerprints=existing_fps,
  265. )
  266. except Exception as e:
  267. logger.exception("[phase0] account=%d enumerate 失败: %s", account_id, e)
  268. continue
  269. if not candidates:
  270. logger.warning("[phase0] enumerate 返回 0 条候选,跳过")
  271. continue
  272. today = now_in_timezone().strftime("%Y-%m-%d")
  273. for c in candidates:
  274. age_range = ",".join(
  275. f"{a.get('min')}-{a.get('max')}" for a in c.age
  276. )
  277. # 新建广告默认开启;实际投放仍受创意审核/账户/预算控制。
  278. # 这里显式传 NORMAL,避免未来默认值变化影响端到端创建语义。
  279. body = build_ad_request_body(c, configured_status="AD_STATUS_NORMAL")
  280. rec = {
  281. "approval_date": today,
  282. "account_id": c.account_id,
  283. "audience_tier_label": c.audience_tier_label,
  284. "adgroup_name": c.adgroup_name,
  285. "site_set": c.site_set,
  286. "delivery_version": c.delivery_version,
  287. "wechat_position": c.wechat_position,
  288. "bid_amount_fen": c.bid_amount_fen,
  289. "bid_scene": c.bid_scene,
  290. "custom_cost_cap_fen": c.custom_cost_cap_fen,
  291. "automatic_site_enabled": c.automatic_site_enabled,
  292. "age_range": age_range,
  293. "fingerprint": c.fingerprint,
  294. "_request_body": body,
  295. }
  296. pending_ad_records.append(rec)
  297. if not pending_ad_records:
  298. logger.info("[phase0] 无广告需新建,Phase 0 结束")
  299. return []
  300. # 飞书审批(如需要)
  301. data_dir = _HERE / "outputs" / "data"
  302. if CREATION_APPROVAL_REQUIRED:
  303. logger.info(
  304. "[phase0] CREATION_APPROVAL_REQUIRED=True → 飞书审批 %d 条广告候选",
  305. len(pending_ad_records),
  306. )
  307. sheet_meta, actions = run_ad_approval_workflow(
  308. records=pending_ad_records,
  309. xlsx_output_dir=data_dir,
  310. timeout_minutes=CREATION_APPROVAL_TIMEOUT_MINUTES,
  311. )
  312. for i, rec in enumerate(pending_ad_records, start=1):
  313. rec["action"] = actions.get(i, "skip")
  314. else:
  315. for rec in pending_ad_records:
  316. rec["action"] = "approve"
  317. # POST 真建 approve 项
  318. created: list[dict] = []
  319. for rec in pending_ad_records:
  320. if rec.get("action") != "approve":
  321. continue
  322. adgroup_id = post_ad_with_prepared_body(
  323. account_id=int(rec["account_id"]),
  324. body=rec["_request_body"],
  325. )
  326. if adgroup_id:
  327. created.append({
  328. "account_id": rec["account_id"],
  329. "adgroup_id": adgroup_id,
  330. "adgroup_name": rec["adgroup_name"],
  331. "wechat_position": rec.get("wechat_position"),
  332. })
  333. logger.info(
  334. "[phase0] 完成:候选 %d 条,approve %d 条,实际建 %d 条",
  335. len(pending_ad_records),
  336. sum(1 for r in pending_ad_records if r.get("action") == "approve"),
  337. len(created),
  338. )
  339. return created
  340. def _wait_created_ads_visible(created_ads: list[dict], timeout_seconds: int = 60) -> None:
  341. """等待刚创建的广告在 /adgroups/get 中可见。
  342. 腾讯 adgroups/add 成功后,list 接口存在短暂一致性延迟。若立刻进入 Phase 1,
  343. 可能只扫到部分新广告,导致同一轮只给一条广告补创意。
  344. """
  345. if not created_ads:
  346. return
  347. from tools.ad_api import _get
  348. by_account: dict[int, set[int]] = {}
  349. for ad in created_ads:
  350. by_account.setdefault(int(ad["account_id"]), set()).add(int(ad["adgroup_id"]))
  351. deadline = time.time() + timeout_seconds
  352. remaining = {acct: set(ids) for acct, ids in by_account.items()}
  353. while time.time() < deadline and any(remaining.values()):
  354. for account_id, ids in list(remaining.items()):
  355. if not ids:
  356. continue
  357. try:
  358. resp = _get("/adgroups/get", {
  359. "account_id": account_id,
  360. "page": 1,
  361. "page_size": 100,
  362. "fields": ["adgroup_id", "configured_status", "system_status"],
  363. })
  364. visible = {
  365. int(item["adgroup_id"])
  366. for item in ((resp.get("data") or {}).get("list") or [])
  367. if item.get("adgroup_id") is not None
  368. }
  369. ids.difference_update(visible)
  370. except Exception as e:
  371. logger.warning(
  372. "[phase0] 等待新广告可见失败 account=%d: %s",
  373. account_id, e,
  374. )
  375. if any(remaining.values()):
  376. logger.info("[phase0] 等待新广告在列表接口可见: %s", remaining)
  377. time.sleep(5)
  378. not_visible = {acct: sorted(ids) for acct, ids in remaining.items() if ids}
  379. if not_visible:
  380. logger.warning("[phase0] 部分新广告仍未在 list 可见,继续 Phase 1: %s", not_visible)
  381. else:
  382. logger.info("[phase0] 新建广告均已在 list 接口可见")
  383. def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict]:
  384. """Phase 1:扫账户 → 关联点过滤 → 准备 pending records(不 POST 腾讯)。
  385. Returns:
  386. pending_records — 每个元素是 prepare_one_creative_for_ad 返回的 dict
  387. """
  388. creation_accounts = get_creation_account_ids()
  389. if not creation_accounts:
  390. logger.error("[phase1] 待投放账户配置为空,Phase 1 退出")
  391. return []
  392. creation_accounts = _filter_creation_accounts(creation_accounts, "phase1")
  393. if not creation_accounts:
  394. logger.info("[phase1] 临时过滤后无待处理账户")
  395. return []
  396. excluded_ad_ids = load_excluded_ad_ids_from_adjustment()
  397. logger.info("[phase1] 关联点过滤集合 size=%d", len(excluded_ad_ids))
  398. pending_records: list[dict] = []
  399. # 素材排重只读取已有明确结果的 DB 记录。
  400. # pending/prepared 没有审批或投放结果,不进入排重。
  401. # 但同一轮审批候选需要做展示去重,避免表格里同 crowd_package 反复出现同一素材。
  402. # 这个 set 只存在内存里,不写历史排重库;进程结束即失效。
  403. display_used_material_ids_by_crowd: dict[str, set[str]] = {}
  404. # landing_video 按素材来源拆池。历史素材和 AI 生成素材互不占用,但池内都严格去重。
  405. landing_usage_counts_by_crowd: dict[str, dict[str, dict[int, int]]] = {}
  406. def accept_pending_record(
  407. rec: dict,
  408. *,
  409. crowd_package: str,
  410. display_excluded_material_ids: set[str],
  411. crowd_landing_counts: dict[str, dict[int, int]],
  412. landing_counts_for_ad: dict[int, int],
  413. landing_max_uses: int,
  414. recovered: bool = False,
  415. ) -> bool:
  416. material_id = rec.get("_material_id")
  417. if material_id and str(material_id) in display_excluded_material_ids:
  418. logger.info(
  419. "[phase1] pending 接收排重丢弃 crowd=%r material=%s%s",
  420. crowd_package, material_id,
  421. " recovered" if recovered else "",
  422. )
  423. return False
  424. landing_video_id = rec.get("landing_video_id")
  425. actual_material_source = (
  426. MATERIAL_SOURCE_AI_GENERATED
  427. if str(rec.get("material_source") or "") == MATERIAL_SOURCE_AI_GENERATED
  428. or str(rec.get("_material_id") or "").startswith("ai:")
  429. else MATERIAL_SOURCE_HISTORY
  430. )
  431. if landing_video_id is not None:
  432. landing_video_id = int(landing_video_id)
  433. if landing_counts_for_ad.get(landing_video_id, 0) >= MAX_SAME_LANDING_PER_AD_IN_RUN:
  434. logger.info(
  435. "[phase1] pending 接收排重丢弃 adgroup=%s landing=%d 同广告已达上限%s",
  436. rec.get("adgroup_id"), landing_video_id,
  437. " recovered" if recovered else "",
  438. )
  439. return False
  440. source_counts = crowd_landing_counts.setdefault(actual_material_source, {})
  441. if source_counts.get(landing_video_id, 0) >= landing_max_uses:
  442. logger.info(
  443. "[phase1] pending 接收排重丢弃 crowd=%r material_source=%s landing=%d 同人群包已达上限%s",
  444. crowd_package, actual_material_source, landing_video_id,
  445. " recovered" if recovered else "",
  446. )
  447. return False
  448. pending_records.append(rec)
  449. if material_id:
  450. display_excluded_material_ids.add(str(material_id))
  451. logger.info(
  452. "[phase1] 本轮展示去重登记 crowd=%r material=%s size=%d%s",
  453. crowd_package, material_id,
  454. len(display_excluded_material_ids),
  455. " recovered" if recovered else "",
  456. )
  457. if landing_video_id is not None:
  458. landing_counts_for_ad[landing_video_id] = (
  459. landing_counts_for_ad.get(landing_video_id, 0) + 1
  460. )
  461. source_counts = crowd_landing_counts.setdefault(actual_material_source, {})
  462. source_counts[landing_video_id] = source_counts.get(landing_video_id, 0) + 1
  463. logger.info(
  464. "[phase1] 同人群包 landing 使用登记 crowd=%r material_source=%s landing=%d count=%d max=%d%s",
  465. crowd_package, actual_material_source, landing_video_id,
  466. source_counts[landing_video_id],
  467. landing_max_uses,
  468. " recovered" if recovered else "",
  469. )
  470. logger.info(
  471. "[phase1] 本轮同广告 landing 计数 adgroup=%s landing=%d count=%d limit=%d%s",
  472. rec.get("adgroup_id"), landing_video_id,
  473. landing_counts_for_ad[landing_video_id],
  474. MAX_SAME_LANDING_PER_AD_IN_RUN,
  475. " recovered" if recovered else "",
  476. )
  477. if not recovered:
  478. try:
  479. record_prepared_material_usage(rec)
  480. except Exception as e:
  481. logger.warning(
  482. "[phase1] material usage 记录失败 account=%s adgroup=%s material=%s:%s",
  483. rec.get("account_id"), rec.get("adgroup_id"),
  484. rec.get("_material_id"), e,
  485. )
  486. return True
  487. for account_id in creation_accounts:
  488. logger.info("=" * 60)
  489. logger.info("[phase1] 账户 %d 处理开始", account_id)
  490. try:
  491. ads = find_ads_needing_creatives(account_id, min_creatives=target_creatives)
  492. except Exception as e:
  493. logger.exception("[phase1] account=%d find_ads 失败,跳过:%s", account_id, e)
  494. continue
  495. ads_after_filter = [a for a in ads if a["adgroup_id"] not in excluded_ad_ids]
  496. excluded_count = len(ads) - len(ads_after_filter)
  497. if excluded_count:
  498. logger.info(
  499. "[phase1] account=%d 关联点过滤排除 %d 条",
  500. account_id, excluded_count,
  501. )
  502. if not ads_after_filter:
  503. logger.info("[phase1] account=%d 无广告需补创意", account_id)
  504. continue
  505. try:
  506. crowd_package = get_account_crowd_package(account_id)
  507. except Exception as e:
  508. logger.exception(
  509. "[phase1] account=%d 读取 crowd_package 失败,跳过:%s",
  510. account_id, e,
  511. )
  512. continue
  513. display_excluded_material_ids = display_used_material_ids_by_crowd.setdefault(
  514. crowd_package, set(),
  515. )
  516. if crowd_package not in landing_usage_counts_by_crowd:
  517. try:
  518. landing_usage_counts_by_crowd[crowd_package] = load_recent_landing_usage_counts(
  519. crowd_package,
  520. )
  521. except Exception as e:
  522. logger.warning(
  523. "[phase1] crowd=%r 读取 landing 使用历史失败,仅使用本轮排重:%s",
  524. crowd_package, e,
  525. )
  526. landing_usage_counts_by_crowd[crowd_package] = {
  527. MATERIAL_SOURCE_HISTORY: {},
  528. MATERIAL_SOURCE_AI_GENERATED: {},
  529. }
  530. logger.info(
  531. "[phase1] crowd=%r 近期 landing 使用 history=%d ai_generated=%d",
  532. crowd_package,
  533. len(landing_usage_counts_by_crowd[crowd_package].get(MATERIAL_SOURCE_HISTORY, {})),
  534. len(landing_usage_counts_by_crowd[crowd_package].get(MATERIAL_SOURCE_AI_GENERATED, {})),
  535. )
  536. crowd_landing_counts = landing_usage_counts_by_crowd[crowd_package]
  537. crowd_landing_counts.setdefault(MATERIAL_SOURCE_HISTORY, {})
  538. crowd_landing_counts.setdefault(MATERIAL_SOURCE_AI_GENERATED, {})
  539. try:
  540. material_strategy = load_account_material_strategy(account_id)
  541. except Exception as e:
  542. logger.warning(
  543. "[phase1] account=%d 读取素材策略失败,按 history 排重:%s",
  544. account_id, e,
  545. )
  546. material_strategy = None
  547. requested_material_source = (
  548. MATERIAL_SOURCE_AI_GENERATED
  549. if material_strategy is not None and material_strategy.use_ai_generated
  550. else MATERIAL_SOURCE_HISTORY
  551. )
  552. landing_max_uses = MAX_SAME_LANDING_PER_AD_IN_RUN
  553. landing_candidate_pool = None
  554. logger.info(
  555. "[phase1] account=%d landing 排重池=%s max_uses=%d",
  556. account_id, requested_material_source, landing_max_uses,
  557. )
  558. for ad in ads_after_filter:
  559. adgroup_id = ad["adgroup_id"]
  560. already_have = ad["creative_count"]
  561. to_add = max(0, target_creatives - already_have)
  562. prepared_for_ad = 0
  563. failed_prepare_for_ad = 0
  564. failed_landing_ids_for_ad: set[int] = set()
  565. landing_counts_for_ad: dict[int, int] = {}
  566. logger.info(
  567. "[phase1] adgroup=%d(have=%d need=%d)",
  568. adgroup_id, already_have, to_add,
  569. )
  570. recovered_records = load_recoverable_prepared_records(
  571. account_id=account_id,
  572. adgroup_id=adgroup_id,
  573. crowd_package=crowd_package,
  574. material_source=requested_material_source,
  575. limit=to_add,
  576. )
  577. recovered_accepted = 0
  578. for rec in recovered_records:
  579. if accept_pending_record(
  580. rec,
  581. crowd_package=crowd_package,
  582. display_excluded_material_ids=display_excluded_material_ids,
  583. crowd_landing_counts=crowd_landing_counts,
  584. landing_counts_for_ad=landing_counts_for_ad,
  585. landing_max_uses=landing_max_uses,
  586. recovered=True,
  587. ):
  588. recovered_accepted += 1
  589. if recovered_records:
  590. prepared_for_ad += recovered_accepted
  591. logger.info(
  592. "[phase1] adgroup=%d 恢复未提交 prepared records=%d accepted=%d remaining=%d",
  593. adgroup_id, len(recovered_records), recovered_accepted,
  594. max(0, to_add - prepared_for_ad),
  595. )
  596. def prepare_attempt(
  597. *,
  598. attempt_no: int,
  599. excluded_material_ids_snapshot: set[str],
  600. excluded_landing_ids_snapshot: set[int],
  601. ) -> dict | None:
  602. nonlocal landing_candidate_pool
  603. try:
  604. if landing_candidate_pool is None:
  605. landing_candidate_pool = build_landing_candidate_pool(account_id)
  606. return prepare_one_creative_for_ad(
  607. account_id, adgroup_id,
  608. excluded_material_ids=excluded_material_ids_snapshot,
  609. excluded_landing_ids=excluded_landing_ids_snapshot,
  610. landing_candidates=landing_candidate_pool,
  611. failed_landing_ids=failed_landing_ids_for_ad,
  612. )
  613. except Exception as e:
  614. logger.exception(
  615. "[phase1] adgroup=%d attempt=%d 准备失败:%s",
  616. adgroup_id, attempt_no, e,
  617. )
  618. return None
  619. def current_excluded_landing_ids() -> set[int]:
  620. landing_excluded_for_ad = {
  621. vid
  622. for vid, count in landing_counts_for_ad.items()
  623. if count >= MAX_SAME_LANDING_PER_AD_IN_RUN
  624. }
  625. landing_excluded_for_source = {
  626. vid
  627. for vid, count in crowd_landing_counts
  628. .get(requested_material_source, {})
  629. .items()
  630. if count >= landing_max_uses
  631. }
  632. return landing_excluded_for_source | landing_excluded_for_ad
  633. remaining_to_add = max(0, to_add - prepared_for_ad)
  634. max_workers = max(1, int(CREATIVE_PREPARE_MAX_WORKERS))
  635. if max_workers > 1:
  636. logger.warning(
  637. "[phase1] CREATIVE_PREPARE_MAX_WORKERS=%d 已配置但当前 prepare_one_creative_for_ad "
  638. "包含图片上传/xcx-save 等外部副作用,为避免重复创建落地计划,本轮强制串行执行",
  639. max_workers,
  640. )
  641. max_workers = 1
  642. task_buffer = max(remaining_to_add, int(CREATIVE_PREPARE_TASK_BUFFER))
  643. task_limit = min(max(0, remaining_to_add * 2), task_buffer)
  644. if remaining_to_add > 0 and max_workers > 1:
  645. logger.info(
  646. "[phase1] adgroup=%d 并行准备 start remaining=%d workers=%d task_limit=%d",
  647. adgroup_id, remaining_to_add, max_workers, task_limit,
  648. )
  649. submitted = 0
  650. accepted_parallel = 0
  651. dropped_parallel = 0
  652. failed_parallel = 0
  653. started_at = time.monotonic()
  654. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  655. futures = {}
  656. def submit_next() -> None:
  657. nonlocal submitted
  658. # 已接收 + 运行中任务不超过缺口,避免并发准备产出无法追踪的多余副作用。
  659. if (
  660. submitted >= task_limit
  661. or prepared_for_ad + len(futures) >= to_add
  662. ):
  663. return
  664. submitted += 1
  665. attempt_no = submitted
  666. future = executor.submit(
  667. prepare_attempt,
  668. attempt_no=attempt_no,
  669. excluded_material_ids_snapshot=set(display_excluded_material_ids),
  670. excluded_landing_ids_snapshot=current_excluded_landing_ids(),
  671. )
  672. futures[future] = attempt_no
  673. for _ in range(min(max_workers, task_limit)):
  674. submit_next()
  675. while futures and prepared_for_ad < to_add:
  676. done, _ = wait(futures, return_when=FIRST_COMPLETED)
  677. for future in done:
  678. futures.pop(future, None)
  679. rec = future.result()
  680. if not rec:
  681. failed_prepare_for_ad += 1
  682. failed_parallel += 1
  683. elif accept_pending_record(
  684. rec,
  685. crowd_package=crowd_package,
  686. display_excluded_material_ids=display_excluded_material_ids,
  687. crowd_landing_counts=crowd_landing_counts,
  688. landing_counts_for_ad=landing_counts_for_ad,
  689. landing_max_uses=landing_max_uses,
  690. ):
  691. prepared_for_ad += 1
  692. accepted_parallel += 1
  693. else:
  694. dropped_parallel += 1
  695. if prepared_for_ad < to_add:
  696. submit_next()
  697. else:
  698. break
  699. if prepared_for_ad >= to_add:
  700. logger.info(
  701. "[phase1] adgroup=%d 并行准备 target reached accepted=%d",
  702. adgroup_id, accepted_parallel,
  703. )
  704. cancelled = 0
  705. for pending_future in futures:
  706. if pending_future.cancel():
  707. cancelled += 1
  708. if cancelled:
  709. logger.info(
  710. "[phase1] adgroup=%d 并行准备 cancelled_pending=%d",
  711. adgroup_id, cancelled,
  712. )
  713. for future in futures:
  714. if future.cancelled():
  715. continue
  716. rec = future.result()
  717. if rec:
  718. dropped_parallel += 1
  719. logger.info(
  720. "[phase1] adgroup=%d 并行结果丢弃:target reached material=%s landing=%s",
  721. adgroup_id, rec.get("_material_id"), rec.get("landing_video_id"),
  722. )
  723. logger.info(
  724. "[phase1] adgroup=%d 并行准备 done submitted=%d accepted=%d dropped=%d failed=%d elapsed=%.1fs",
  725. adgroup_id, submitted, accepted_parallel, dropped_parallel,
  726. failed_parallel, time.monotonic() - started_at,
  727. )
  728. else:
  729. for attempt_no in range(1, remaining_to_add + 1):
  730. rec = prepare_attempt(
  731. attempt_no=attempt_no,
  732. excluded_material_ids_snapshot=set(display_excluded_material_ids),
  733. excluded_landing_ids_snapshot=current_excluded_landing_ids(),
  734. )
  735. if rec:
  736. if accept_pending_record(
  737. rec,
  738. crowd_package=crowd_package,
  739. display_excluded_material_ids=display_excluded_material_ids,
  740. crowd_landing_counts=crowd_landing_counts,
  741. landing_counts_for_ad=landing_counts_for_ad,
  742. landing_max_uses=landing_max_uses,
  743. ):
  744. prepared_for_ad += 1
  745. else:
  746. failed_prepare_for_ad += 1
  747. # 2026-06-10 用户要求:单条 prepare 失败 → continue 不 break
  748. # 同广告剩余 to_add 创意还能继续试,不被一次失败拖累
  749. logger.info(
  750. "[phase1] adgroup=%d 本条创意 prepare 失败,试下一条",
  751. adgroup_id,
  752. )
  753. logger.info(
  754. "[phase1] adgroup=%d 补创意完成 target=%d have_before=%d "
  755. "planned=%d prepared=%d failed_prepare=%d",
  756. adgroup_id, target_creatives, already_have, to_add,
  757. prepared_for_ad, failed_prepare_for_ad,
  758. )
  759. if prepared_for_ad < to_add:
  760. logger.warning(
  761. "[phase1] adgroup=%d 未补满:缺口=%d。详细原因见上方 "
  762. "prepare_one_creative source_summary/失败日志",
  763. adgroup_id, to_add - prepared_for_ad,
  764. )
  765. logger.info("=" * 60)
  766. logger.info("[phase1] 准备完成,共 %d 条 pending records", len(pending_records))
  767. return pending_records
  768. def _write_pending_records(records: list[dict], output_dir: Path) -> Path:
  769. """落 Phase 1 产物 JSON(供 Phase 3 独立 apply 用 / 追溯)。"""
  770. output_dir.mkdir(parents=True, exist_ok=True)
  771. now = now_in_timezone()
  772. date_str = now.strftime("%Y%m%d")
  773. ts = now.strftime("%H%M%S")
  774. out_path = output_dir / f"creation_pending_{date_str}_{ts}.json"
  775. with open(out_path, "w", encoding="utf-8") as f:
  776. json.dump(records, f, ensure_ascii=False, indent=2)
  777. return out_path
  778. def phase2_approval(records: list[dict], xlsx_output_dir: Path) -> dict[int, str]:
  779. """Phase 2:飞书审批(若需要)。
  780. Returns: actions {row_idx: action}, 1-based row_idx
  781. """
  782. from tools.im_approval_creation import run_approval_workflow
  783. sheet_meta, actions = run_approval_workflow(
  784. records=records,
  785. xlsx_output_dir=xlsx_output_dir,
  786. timeout_minutes=CREATION_APPROVAL_TIMEOUT_MINUTES,
  787. )
  788. logger.info(
  789. "[phase2] 审批完成 sheet_url=%s actions=%d/%d",
  790. sheet_meta.get("url"), len(actions), len(records),
  791. )
  792. return actions
  793. def run_once() -> dict:
  794. """完整主循环:Phase 0 → Phase 1 → Phase 2(若开关 True)→ Phase 3。"""
  795. run_started = now_in_timezone().isoformat()
  796. sync_stats: dict = {}
  797. # Phase -1:每日主流程启动时先同步飞书「自动化账户」配置表。
  798. logger.info("=" * 60)
  799. logger.info("[main] Phase -1 启动 — 同步飞书自动化账户配置")
  800. try:
  801. from sync_feishu_account_config import sync_from_feishu
  802. sync_stats = sync_from_feishu()
  803. logger.info("[main] Phase -1 完成: %s", sync_stats)
  804. except Exception as e:
  805. logger.exception("[main] Phase -1 同步飞书配置失败,本轮停止:%s", e)
  806. return {
  807. "run_started": run_started,
  808. "run_finished": now_in_timezone().isoformat(),
  809. "approval_required": CREATION_APPROVAL_REQUIRED,
  810. "sync_stats": {"error": str(e)},
  811. "phase0_created_ads": 0,
  812. "total": {"phase1_prepared": 0},
  813. }
  814. # Phase 0:模块 A 建广告(满足每账户 ADS_PER_ACCOUNT 条)
  815. logger.info("=" * 60)
  816. if _env_flag("CREATION_SKIP_PHASE0"):
  817. logger.info("[main] CREATION_SKIP_PHASE0=True → 跳过模块 A 建广告")
  818. created_ads = []
  819. else:
  820. logger.info("[main] Phase 0 启动 — 模块 A 检查 + 建广告")
  821. created_ads = phase0_create_ads()
  822. logger.info("[main] Phase 0 完成:本轮新建广告 %d 条", len(created_ads))
  823. _wait_created_ads_visible(created_ads)
  824. # Phase 1:模块 B 给所有广告(新+旧)补创意
  825. logger.info("=" * 60)
  826. logger.info("[main] Phase 1 启动 — 模块 B 给广告补创意")
  827. pending_records = phase1_prepare()
  828. if not pending_records:
  829. logger.info("[main] Phase 1 无 pending records,主循环退出")
  830. return {
  831. "run_started": run_started,
  832. "run_finished": now_in_timezone().isoformat(),
  833. "approval_required": CREATION_APPROVAL_REQUIRED,
  834. "sync_stats": sync_stats,
  835. "phase0_created_ads": len(created_ads),
  836. "total": {"phase1_prepared": 0},
  837. }
  838. data_dir = _HERE / "outputs" / "data"
  839. pending_path = _write_pending_records(pending_records, data_dir)
  840. logger.info("[main] pending records 已写入: %s", pending_path)
  841. # Phase 2:审批(或 skip)
  842. if CREATION_APPROVAL_REQUIRED:
  843. logger.info("[main] CREATION_APPROVAL_REQUIRED=True → 进 Phase 2 飞书审批")
  844. actions = phase2_approval(pending_records, data_dir)
  845. for i, rec in enumerate(pending_records, start=1):
  846. rec["action"] = actions.get(i, "skip")
  847. else:
  848. logger.info("[main] CREATION_APPROVAL_REQUIRED=False → 全 records approve")
  849. for rec in pending_records:
  850. rec["action"] = "approve"
  851. # Phase 3:执行
  852. logger.info("=" * 60)
  853. logger.info("[main] Phase 3 执行启动")
  854. summary = apply_pending_records(pending_records)
  855. summary["approval_required"] = CREATION_APPROVAL_REQUIRED
  856. summary["pending_records_path"] = str(pending_path)
  857. summary_path = write_apply_summary(summary, data_dir)
  858. logger.info("[main] summary 已写入: %s", summary_path)
  859. # 发执行汇报
  860. _send_apply_summary_to_feishu(summary)
  861. summary["run_started"] = run_started
  862. summary["run_finished"] = now_in_timezone().isoformat()
  863. summary["sync_stats"] = sync_stats
  864. summary["phase0_created_ads"] = len(created_ads)
  865. summary["phase0_ads"] = created_ads
  866. return summary
  867. def main() -> int:
  868. _setup_logging()
  869. logger.info("=" * 60)
  870. logger.info("模块 B 创意搭建子系统 — 主循环启动")
  871. logger.info("TARGET_CREATIVES_PER_AD = %d", TARGET_CREATIVES_PER_AD)
  872. logger.info("CREATION_APPROVAL_REQUIRED = %s", CREATION_APPROVAL_REQUIRED)
  873. logger.info("WHITELIST_ACCOUNTS = %s", WHITELIST_ACCOUNTS)
  874. logger.info("=" * 60)
  875. summary = run_once()
  876. t = summary.get("total") or {}
  877. logger.info("=" * 60)
  878. logger.info("[main] 主循环结束")
  879. if "phase1_prepared" in t:
  880. logger.info(" phase1_prepared = %d", t["phase1_prepared"])
  881. else:
  882. logger.info(" records = %d", t.get("records", 0))
  883. logger.info(" approved = %d", t.get("approved", 0))
  884. logger.info(" posted_ok = %d", t.get("posted_ok", 0))
  885. logger.info(" posted_failed = %d", t.get("posted_failed", 0))
  886. logger.info("=" * 60)
  887. return 0 if t.get("posted_failed", 0) == 0 else 1
  888. if __name__ == "__main__":
  889. sys.exit(main())