execute_creation_once.py 44 KB

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