config.py 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013
  1. """
  2. 广告智能决策引擎配置 — auto_put_ad_mini
  3. 运营可直接修改此文件调整决策参数。
  4. 当前模式:智能判断
  5. - 基于 动态 ROI (7日均值) 的精细化决策
  6. - AI 推理结合领域知识
  7. - 三级分类:零消耗待关停(规则)+ 待优化评估(智能)+ 正常运行(规则)
  8. """
  9. import os
  10. import logging
  11. from pathlib import Path
  12. from typing import Optional
  13. from agent.core.runner import RunConfig, KnowledgeConfig
  14. # 初始化 logger(必须在使用前定义)
  15. logger = logging.getLogger(__name__)
  16. # 加载 .env 文件(如果存在)
  17. try:
  18. from dotenv import load_dotenv
  19. load_dotenv(Path(__file__).parent / ".env")
  20. except ImportError:
  21. pass
  22. # ═══════════════════════════════════════════
  23. # Agent 运行配置
  24. # ═══════════════════════════════════════════
  25. MAIN_CONFIG = RunConfig(
  26. model="anthropic/claude-sonnet-4.5",
  27. temperature=0.3,
  28. max_iterations=50,
  29. name="广告智能调控助手",
  30. tools=[
  31. "fetch_creative_data",
  32. "merge_creative_data",
  33. "calculate_roi_metrics",
  34. "calculate_creative_roi", # 创意级动态 ROI(广告级 pause 候选的二次细化)
  35. "calculate_portfolio_summary",
  36. "get_ads_for_review",
  37. "apply_decisions",
  38. "query_ad_detail", # Mode 2: 查询广告详情
  39. "modify_decisions", # Mode 3: 修改已有决策
  40. "validate_decisions",
  41. "generate_report",
  42. # 执行引擎 + IM 审批(已集成阻塞式审批流):
  43. "execute_decisions",
  44. "check_execution_feedback",
  45. "send_approval_request",
  46. "check_approval_status",
  47. "send_feishu_text_message", # 执行后向您同步 diff / 确认 / 质疑回应
  48. # 飞书文档(报告导入 & 分享):
  49. "import_to_feishu",
  50. # 注:曾考虑用内置 "agent" 工具按 tier 并行委托子 Agent,
  51. # 但框架的 agent 工具只返回文本 summary,主 Agent 拿不回结构化决策,
  52. # 会陷入"无法 apply"的死循环。直接在主 Agent 单次输出完成全部 decisions 更可靠。
  53. ],
  54. skills=[
  55. "ad-domain", # 业务模型:裂变模型、R值、ROI公式、字段定义
  56. "platform-rules", # 平台硬约束:oCPM学习期、调价上限、数据口径
  57. "decision-strategy", # 决策策略:角色 + 基准 + 候选标记 + 年龄策略 + 7种action + 输出规范
  58. "posterior-wisdom", # 后验经验:学习中断/降价恢复/创意冷启动/置信度分级
  59. ],
  60. extra_llm_params={"max_tokens": 32000},
  61. knowledge=KnowledgeConfig(
  62. enable_extraction=False, # 从决策过程中提取后验经验(投放后开启)
  63. enable_completion_extraction=False, # 完成后总结本轮经验(投放后开启)
  64. enable_injection=False, # 决策时自动注入相关历史经验(投放后开启)
  65. owner="ad_mini_team",
  66. ),
  67. )
  68. SKILLS_DIR = str(Path(__file__).parent / "skills")
  69. TRACE_STORE_PATH = ".trace"
  70. LOG_LEVEL = "INFO"
  71. LOG_FILE = None
  72. # ═══════════════════════════════════════════
  73. # 时区配置(海外部署)
  74. # ═══════════════════════════════════════════
  75. TIMEZONE = os.getenv("TZ", "UTC")
  76. logger.info(f"运行时区:{TIMEZONE}")
  77. # ═══════════════════════════════════════════
  78. # V3 数据窗口配置
  79. # ═══════════════════════════════════════════
  80. DATA_WINDOW_DAYS = 14 # 数据采集窗口:14 天历史数据
  81. ROI_CALCULATION_DAYS = 7 # 动态 ROI (7日均值) 计算窗口(保持 7 天)
  82. # ═══════════════════════════════════════════
  83. # V3 决策阈值(默认值,可被 SKILL 覆盖)
  84. # ═══════════════════════════════════════════
  85. MIN_DAILY_COST = 100 # 日消耗 >= 100元才参与 ROI 计算
  86. MIN_AD_AGE_DAYS = 3 # 广告创建 >= 3天才参与决策(与 min_periods 对齐)
  87. ROI_LOW_FACTOR = 0.75 # 动态 ROI (7日均值) < 全体均值 × 0.75 → 关停
  88. NO_SPEND_THRESHOLD = 10 # 7日消耗均值 < 10元 → 关停
  89. STABLE_SPEND_THRESHOLD = 100 # 稳定消耗定义:>100元/天
  90. # ═══════════════════════════════════════════
  91. # 出价调整配置
  92. # ═══════════════════════════════════════════
  93. BID_ADJUSTMENT_ENABLED = True
  94. BID_DOWN_ROI_FACTOR = 0.90 # ROI < 均值×0.90 → 考虑降价(低于渠道均值10%)
  95. BID_UP_ROI_FACTOR = 1.05 # ROI > 均值×1.05 → 考虑提价(高于渠道均值5%)
  96. BID_UP_MAX_SPEND = 1000 # 提价消耗上限:均值消耗<1000才提价(投手经验原文)
  97. BID_CHANGE_MIN_PCT = 0.03 # 最小调幅 3%(兼容旧代码)
  98. BID_CHANGE_MAX_PCT = 0.10 # 最大单次调幅 10%(兼容旧代码)
  99. BID_UP_MIN_PCT = 0.05 # 提价最小幅度 5%
  100. BID_UP_MAX_PCT = 0.10 # 提价最大幅度 10%
  101. BID_DOWN_MIN_PCT = 0.03 # 降价最小幅度 3%
  102. BID_DOWN_MAX_PCT = 0.05 # 降价最大幅度 5%
  103. BID_DOWN_MIN_SPEND = 500 # 降价消耗门槛:7日日均消耗≥500元
  104. BID_FLOOR_YUAN = 0.05 # 出价下限(元)
  105. BID_CEILING_YUAN = 1.00 # 出价上限(元)
  106. # 广告年龄分段(基于决策树图片)
  107. COLD_START_DAYS = 3 # 冷启动期(≤3天):极度保护,几乎不干预
  108. EARLY_GROWTH_DAYS = 7 # 早期成长期(4-7天):可提价放量(满足ROI+消耗条件)
  109. AD_AGE_MATURE = 7 # 成熟期(>7天):全面调控
  110. # 兼容性(已废弃)
  111. AD_AGE_NEWBORN = COLD_START_DAYS # 兼容旧代码
  112. CAUTIOUS_DAYS = EARLY_GROWTH_DAYS # 兼容旧代码
  113. # 高燃烧预警配置
  114. HIGH_BURN_AGE_THRESHOLD = 3 # 广告年龄>3天才检查
  115. HIGH_BURN_COST_THRESHOLD = 300 # 昨日消耗>300元触发预警
  116. ROI_LOW_MIN_YESTERDAY_COST = 300 # 关停消耗门槛:昨日消耗≥300才检查关停(投手经验2.4)
  117. # ═══════════════════════════════════════════
  118. # 创意级 pause 细化配置
  119. # ═══════════════════════════════════════════
  120. # 当广告级判 pause 时,先做创意级二次分析:全员低于阈值才真关广告,部分拖累只关差创意
  121. CREATIVE_PAUSE_ENABLED = True # 总开关:False 时全部走广告级 pause(降级路径)
  122. CREATIVE_MIN_COST_SHARE = 0.15 # 创意 7 日消耗占比 < 此值视为数据稀疏,不纳入"判死刑"
  123. CREATIVE_MIN_AGE_DAYS = 7 # 创意年龄 < 此值视为冷启动,不纳入"判死刑"(对齐广告级 EARLY_GROWTH_DAYS)
  124. CREATIVE_MIN_VALID_DAYS = 3 # 创意有效 ROI 数据天数 < 此值视为不充分,不纳入"判死刑"
  125. CREATIVE_MIN_REMAINING = 2 # 关停后剩余 eligible 创意数 < 此值,升级为广告级 pause
  126. CREATIVE_MAX_PAUSE_COST_SHARE = 0.80 # pause_targets 总占消耗 > 此值,本质是关广告,升级为广告级 pause
  127. CREATIVE_RATELIMIT_DAYS = 7 # 同一创意 7 天内不允许重复 pause
  128. # ═══════════════════════════════════════════
  129. # 安全护栏配置
  130. # ═══════════════════════════════════════════
  131. GUARDRAILS_ENABLED = True
  132. DRY_RUN_MODE = False # 关闭干运行,让护栏正常放行(实际执行由 EXECUTION_ENABLED 控制)
  133. MAX_ADJUSTMENTS_PER_AD_PER_DAY = 2
  134. MIN_ADJUSTMENT_INTERVAL_HOURS = 6
  135. MAX_DAILY_CUMULATIVE_CHANGE_PCT = 0.20 # 日累计调幅上限 20%
  136. MAX_DAILY_OPS = 10000 # 单日最多操作广告数(实际不限制)
  137. DATA_FRESHNESS_MAX_HOURS = 96 # 数据超过 96 小时视为过期(已从48小时放宽至96小时)
  138. # ═══════════════════════════════════════════
  139. # 执行引擎配置
  140. # ═══════════════════════════════════════════
  141. # 执行开关(优先级:数据库 > 环境变量 > 默认值False)
  142. EXECUTION_ENABLED = False
  143. try:
  144. from db import get_system_config
  145. _db_execution_enabled = get_system_config("execution_enabled", default=None)
  146. if _db_execution_enabled is not None:
  147. EXECUTION_ENABLED = _db_execution_enabled
  148. logger.info(f"✅ 从数据库读取执行开关:{EXECUTION_ENABLED}")
  149. else:
  150. # 降级到环境变量
  151. _env_execution_enabled = os.getenv("EXECUTION_ENABLED", "").strip().lower()
  152. if _env_execution_enabled:
  153. EXECUTION_ENABLED = _env_execution_enabled in ("true", "1", "yes")
  154. logger.info(f"从环境变量读取执行开关:{EXECUTION_ENABLED}")
  155. except Exception as e:
  156. logger.warning(f"⚠️ 数据库读取执行开关失败({e}),使用默认值:{EXECUTION_ENABLED}")
  157. API_QPS_LIMIT = 8 # 保守QPS(平台上限10)
  158. API_MAX_RETRIES = 3
  159. TIER1_MAX_CHANGE_PCT = 0.00 # Tier1自动执行已禁用(改为0%,所有操作都需审批)
  160. TIER3_MIN_DAILY_SPEND = 1500 # 高价值广告门槛(元/天)
  161. FEEDBACK_CHECK_HOURS = 6
  162. # ═══════════════════════════════════════════
  163. # IM 审批配置(飞书直连)
  164. # ═══════════════════════════════════════════
  165. IM_ENABLED = True # IM 主开关(True 时审批消息发飞书)
  166. IM_APPROVAL_TIMEOUT_MINUTES = 120 # 审批超时(分钟)— 2小时
  167. IM_APPROVAL_POLL_INTERVAL_SECONDS = 30 # 审批轮询间隔(秒)
  168. # 飞书应用凭据("增长投放"机器人)— 优先从环境变量读取
  169. FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a955e97067f85cb3")
  170. FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "NQaG4ci1plXRDTgwCqrLJgMLLoA2tdF8")
  171. # 运营审批人飞书信息
  172. FEISHU_OPERATOR_OPEN_ID = os.getenv("FEISHU_OPERATOR_OPEN_ID", "ou_498988d823b61ab89c9afe4310f85bb4")
  173. FEISHU_OPERATOR_CHAT_ID = os.getenv("FEISHU_OPERATOR_CHAT_ID", "oc_88e0a1970a7de02eb5ac225a8b0cedea")
  174. # 投放项目群聊 — 用于接收决策结果通知和审批回复
  175. # 置空则不发送到群,仅发送到个人
  176. FEISHU_AD_PROJECT_CHAT_ID = os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "oc_7940ec97cde40b245cff9cb606ff1ac7")
  177. # 腾讯广告默认账户(测试账户)
  178. TENCENT_AD_ACCOUNT_ID = int(os.getenv("TENCENT_AD_ACCOUNT_ID", "80769799"))
  179. # ═══════════════════════════════════════════
  180. # 账户白名单配置
  181. # ═══════════════════════════════════════════
  182. # 白名单模式开关(优先级:数据库 > 环境变量)
  183. WHITELIST_ENABLED = None
  184. WHITELIST_ACCOUNTS = []
  185. # 尝试从数据库读取配置
  186. try:
  187. from db import get_whitelist_accounts, get_system_config
  188. # 读取白名单开关
  189. WHITELIST_ENABLED = get_system_config("whitelist_enabled", default=None)
  190. # 读取白名单账户列表
  191. WHITELIST_ACCOUNTS = get_whitelist_accounts()
  192. logger.info(f"✅ 从数据库读取白名单配置:{len(WHITELIST_ACCOUNTS)} 个账户")
  193. except Exception as db_error:
  194. logger.warning(f"⚠️ 数据库读取失败({db_error}),降级到环境变量配置")
  195. # 降级方案1:从环境变量读取
  196. _whitelist_str = os.getenv("WHITELIST_ACCOUNTS", "")
  197. if _whitelist_str:
  198. # 格式:逗号分隔,如 "80769799,71305011"
  199. WHITELIST_ACCOUNTS = [int(x.strip()) for x in _whitelist_str.split(",") if x.strip()]
  200. logger.info(f"从环境变量读取白名单:{len(WHITELIST_ACCOUNTS)} 个账户")
  201. else:
  202. # 降级方案2:从文件读取(可选)
  203. _whitelist_file = Path(__file__).parent / "whitelist.json"
  204. if _whitelist_file.exists():
  205. import json
  206. with open(_whitelist_file) as f:
  207. whitelist_data = json.load(f)
  208. WHITELIST_ACCOUNTS = whitelist_data.get("accounts", [])
  209. logger.info(f"从 whitelist.json 读取白名单:{len(WHITELIST_ACCOUNTS)} 个账户")
  210. # 白名单开关降级处理
  211. if WHITELIST_ENABLED is None:
  212. WHITELIST_ENABLED = os.getenv("WHITELIST_ENABLED", "true").lower() == "true"
  213. # 向后兼容:单账户模式
  214. if not WHITELIST_ACCOUNTS:
  215. WHITELIST_ACCOUNTS = [TENCENT_AD_ACCOUNT_ID]
  216. logger.info(f"白名单为空,使用单账户模式:{TENCENT_AD_ACCOUNT_ID}")
  217. logger.info(
  218. f"白名单配置:{'启用' if WHITELIST_ENABLED else '禁用'},"
  219. f"账户数={len(WHITELIST_ACCOUNTS)},列表={WHITELIST_ACCOUNTS[:5]}..."
  220. )
  221. # ═══════════════════════════════════════════
  222. # 实验范围 Scope(MVP)
  223. # ═══════════════════════════════════════════
  224. # 2026-06-08:DB 白名单已直接收窄到 2 个测试账户,不再 override
  225. # 单一真相源 = DB account_whitelist 表 enabled=1 的行
  226. # 若要临时收窄,改这里为 {account_id...}
  227. EXPERIMENTAL_SCOPE_ACCOUNTS = None
  228. EXPERIMENTAL_SCOPE_REASON = "已迁 DB,config 不再 override"
  229. if EXPERIMENTAL_SCOPE_ACCOUNTS is not None:
  230. _scope_before = len(WHITELIST_ACCOUNTS)
  231. WHITELIST_ACCOUNTS = [a for a in WHITELIST_ACCOUNTS if a in EXPERIMENTAL_SCOPE_ACCOUNTS]
  232. # 兜底:如 scope 中账户不在 DB 白名单(或 DB 读取失败),直接采用 scope 列表
  233. if not WHITELIST_ACCOUNTS:
  234. WHITELIST_ACCOUNTS = list(EXPERIMENTAL_SCOPE_ACCOUNTS)
  235. logger.warning(
  236. f"⚠️ EXPERIMENTAL_SCOPE {EXPERIMENTAL_SCOPE_ACCOUNTS} 与现有白名单交集为空,"
  237. f"直接使用 scope 列表"
  238. )
  239. logger.info(
  240. f"🧪 EXPERIMENTAL_SCOPE 启用:WHITELIST_ACCOUNTS 由 {_scope_before} → "
  241. f"{len(WHITELIST_ACCOUNTS)} 个 ({WHITELIST_ACCOUNTS})。原因:{EXPERIMENTAL_SCOPE_REASON}"
  242. )
  243. # ═══════════════════════════════════════════
  244. # 待投放账户配置(DB 单一真相源)
  245. # ═══════════════════════════════════════════
  246. # 人工只配置:account_id / audience_name / bid_min_fen / bid_max_fen / age_min / age_max / delivery_version。
  247. # 系统自动解析 audience_pack_id、授权并验证目标账户可见后再创建广告。
  248. # - audience_name="泛人群" 表示不使用 custom_audience,无需授权。
  249. # - bid_amount_fen 若为空,按 bid_min_fen/bid_max_fen 和 BID_PICK_STRATEGY 取值。
  250. def get_account_creation_config(account_id: int) -> dict:
  251. """从 DB 读取账户级广告创建配置。
  252. Returns:
  253. {
  254. account_id, audience_name, audience_pack_id,
  255. audience_tier_label, bid_amount_fen,
  256. bid_min_fen, bid_max_fen, age, delivery_version, ...
  257. }
  258. Raises:
  259. ValueError: 账户未配置、未启用,或缺少必要人工配置。
  260. """
  261. try:
  262. from db.connection import get_connection
  263. except Exception as e:
  264. raise ValueError(
  265. f"无法读取 account_id {account_id} 的创建配置: DB 模块不可用({e})"
  266. ) from e
  267. conn = get_connection()
  268. try:
  269. with conn.cursor() as cur:
  270. cur.execute(
  271. """
  272. SELECT c.account_id, c.enabled, c.delivery_version,
  273. c.audience_name, c.audience_pack_id,
  274. c.audience_tier_label, c.bid_min_fen, c.bid_max_fen,
  275. c.bid_amount_fen, c.age_min, c.age_max,
  276. c.daily_budget_fen AS account_daily_budget_fen,
  277. t.site_set_json, t.location_types_json, t.region_ids_json,
  278. t.daily_budget_fen, t.time_series_json,
  279. w.enabled AS whitelist_enabled
  280. FROM ad_creation_account_config c
  281. JOIN account_whitelist w ON w.account_id = c.account_id
  282. JOIN ad_delivery_template t
  283. ON t.delivery_version = c.delivery_version
  284. AND t.enabled = TRUE
  285. WHERE c.account_id=%s
  286. """,
  287. (account_id,),
  288. )
  289. row = cur.fetchone()
  290. finally:
  291. conn.close()
  292. if not row:
  293. raise ValueError(
  294. f"account_id {account_id} 未写入 ad_creation_account_config,不能创建广告"
  295. )
  296. if not row.get("whitelist_enabled"):
  297. raise ValueError(
  298. f"account_id {account_id} 未在 account_whitelist 启用,不能创建广告"
  299. )
  300. if not row.get("enabled"):
  301. raise ValueError(
  302. f"account_id {account_id} 在 ad_creation_account_config 中未启用,不能创建广告"
  303. )
  304. audience_name = (row.get("audience_name") or "").strip()
  305. if not audience_name:
  306. raise ValueError(
  307. f"account_id {account_id} 缺少 audience_name。"
  308. "请先人工配置待投放人群包名称;泛人群账户写 audience_name='泛人群'"
  309. )
  310. bid_amount_fen = row.get("bid_amount_fen")
  311. bid_min_fen = row.get("bid_min_fen")
  312. bid_max_fen = row.get("bid_max_fen")
  313. if bid_amount_fen is None:
  314. if bid_min_fen is None or bid_max_fen is None:
  315. raise ValueError(
  316. f"account_id {account_id} 缺少 bid_amount_fen 或 bid_min_fen/bid_max_fen"
  317. )
  318. if int(bid_min_fen) > int(bid_max_fen):
  319. raise ValueError(
  320. f"account_id {account_id} 出价范围非法:"
  321. f" bid_min_fen={bid_min_fen} > bid_max_fen={bid_max_fen}"
  322. )
  323. if BID_PICK_STRATEGY == "max":
  324. bid_amount_fen = int(bid_max_fen)
  325. elif BID_PICK_STRATEGY == "min":
  326. bid_amount_fen = int(bid_min_fen)
  327. else:
  328. bid_amount_fen = int(round((int(bid_min_fen) + int(bid_max_fen)) / 2))
  329. pack_id = row.get("audience_pack_id")
  330. age_min = row.get("age_min")
  331. age_max = row.get("age_max")
  332. if age_min is None or age_max is None:
  333. raise ValueError(
  334. f"account_id {account_id} 缺少 age_min/age_max"
  335. )
  336. if int(age_min) > int(age_max):
  337. raise ValueError(
  338. f"account_id {account_id} 年龄范围非法: age_min={age_min} > age_max={age_max}"
  339. )
  340. import json as _json
  341. site_set = _json.loads(row["site_set_json"])
  342. location_types = _json.loads(row["location_types_json"])
  343. region_ids = _json.loads(row["region_ids_json"])
  344. time_series = _json.loads(row["time_series_json"])
  345. return {
  346. "account_id": int(row["account_id"]),
  347. "delivery_version": row["delivery_version"],
  348. "audience_name": audience_name,
  349. "audience_pack_id": int(pack_id) if pack_id is not None else None,
  350. "audience_tier_label": row.get("audience_tier_label") or audience_name,
  351. "bid_min_fen": int(bid_min_fen) if bid_min_fen is not None else None,
  352. "bid_max_fen": int(bid_max_fen) if bid_max_fen is not None else None,
  353. "bid_amount_fen": int(bid_amount_fen),
  354. "age": [{"min": int(age_min), "max": int(age_max)}],
  355. "site_set": site_set,
  356. "location_types": location_types,
  357. "region_ids": region_ids,
  358. "daily_budget_fen": int(row.get("account_daily_budget_fen") or row["daily_budget_fen"]),
  359. "time_series": time_series,
  360. }
  361. def get_account_audience_pack(account_id: int) -> tuple[Optional[int], str]:
  362. """兼容旧调用:返回 (audience_pack_id, audience_tier_label)。"""
  363. cfg = get_account_creation_config(account_id)
  364. return cfg["audience_pack_id"], cfg["audience_tier_label"]
  365. def get_creation_account_ids() -> list[int]:
  366. """读取当前启用的待投放账户,作为 Phase 0 创建广告的账户范围。"""
  367. try:
  368. from db.connection import get_connection
  369. except Exception as e:
  370. logger.warning("读取待投放账户失败:DB 模块不可用(%s)", e)
  371. return []
  372. conn = get_connection()
  373. try:
  374. with conn.cursor() as cur:
  375. cur.execute(
  376. """
  377. SELECT c.account_id
  378. FROM ad_creation_account_config c
  379. JOIN account_whitelist w ON w.account_id = c.account_id
  380. JOIN ad_delivery_template t
  381. ON t.delivery_version = c.delivery_version
  382. AND t.enabled = TRUE
  383. WHERE c.enabled = TRUE
  384. AND w.enabled = TRUE
  385. ORDER BY c.id ASC
  386. """
  387. )
  388. rows = cur.fetchall()
  389. return [int(r["account_id"]) for r in rows]
  390. except Exception as e:
  391. logger.warning("读取待投放账户失败:%s", e)
  392. return []
  393. finally:
  394. conn.close()
  395. # ═══════════════════════════════════════════════════════════════════
  396. # [CREATION SOP] 广告搭建 SOP 固定参数(2026-06-05 业务确认)
  397. # ═══════════════════════════════════════════════════════════════════
  398. # 投放 SOP:广告搭建 = 固定定向 & 人群 & 出价
  399. # 小程序产品:票圈 | 3亿人喜欢的视频平台
  400. # 几乎所有维度都是固定的,LLM 不参与"营销内容/定向/出价类型"决策
  401. # 唯一可变维度:site_set 组合(3 种) + 出价数值(从区间内取)
  402. # --- 营销内容(全固定)---
  403. MARKETING_GOAL = "MARKETING_GOAL_USER_GROWTH"
  404. MARKETING_SUB_GOAL = "MARKETING_SUB_GOAL_UNKNOWN"
  405. # 修正(2026-06-05 真实样本反推):marketing_carrier_type 是 JUMP_PAGE,不是 MINI_PROGRAM_WECHAT
  406. # 小程序信息通过 marketing_asset_outer_spec 嵌套传递
  407. MARKETING_CARRIER_TYPE = "MARKETING_CARRIER_TYPE_JUMP_PAGE"
  408. MARKETING_CARRIER_NAME = "票圈 | 3亿人喜欢的视频平台"
  409. MARKETING_CARRIER_GH_ID = "gh_ecd1ea0b84cf" # 小程序 GH ID
  410. MARKETING_CARRIER_WX_APP_ID = "wx89e7eb06478361d7" # 小程序 WX AppID(可能 add 时不传,待 dry run)
  411. # marketing_asset_outer_spec 嵌套结构(add 时传)
  412. MARKETING_TARGET_TYPE = "MARKETING_TARGET_TYPE_MINI_PROGRAM_WECHAT"
  413. MARKETING_ASSET_OUTER_SPEC = {
  414. "marketing_target_type": MARKETING_TARGET_TYPE,
  415. "marketing_asset_outer_id": MARKETING_CARRIER_GH_ID,
  416. }
  417. # conversion_id 不传(可选 + 不支持朋友圈版位)
  418. # 改用 optimization_goal 直接走
  419. # ═══════════════════════════════════════════════════════════════════
  420. # 账户级 feedback_id 映射(2026-06-05 业务确认)
  421. # ═══════════════════════════════════════════════════════════════════
  422. # feedback_id = 监测链接 ID,是账户级配置,广告 add 时必填
  423. # 短期:本字典占位;长期:迁到 DB(扩展 account_whitelist 表加列)
  424. # 设计接口:get_account_feedback_id(account_id) → 先查 DB,fallback 本字典
  425. ACCOUNT_FEEDBACK_ID_MAPPING = {
  426. # account_id : feedback_id
  427. 83846793: 6700703, # 用户 2026-06-05 提供(非人群包账户)
  428. 83846804: 6700002, # 用户 2026-06-05 提供(R330+ 人群包账户)
  429. }
  430. def get_account_feedback_id(account_id: int):
  431. """获取账户的 feedback_id(监测链接 ID)。
  432. 优先级:
  433. 1. DB account_whitelist.feedback_id
  434. 2. ad_creation_account_config.feedback_key → feedback_asset_template 自动查找/创建
  435. 3. config.py 旧字典
  436. 返回 None 时调用方应反问或报错,不能猜测。
  437. """
  438. db_feedback_id = _get_account_feedback_id_from_db(account_id)
  439. if db_feedback_id is not None:
  440. return db_feedback_id
  441. try:
  442. created_id = _ensure_account_feedback_id(account_id)
  443. if created_id is not None:
  444. return created_id
  445. except Exception as e:
  446. logger.warning(
  447. "自动获取/创建 account_id %s 的 feedback_id 失败, fallback config 字典:%s",
  448. account_id, e,
  449. )
  450. return ACCOUNT_FEEDBACK_ID_MAPPING.get(account_id)
  451. def _get_account_feedback_id_from_db(account_id: int) -> Optional[int]:
  452. """从 account_whitelist 读取账户级 feedback_id。"""
  453. try:
  454. from db.connection import get_connection
  455. conn = get_connection()
  456. try:
  457. with conn.cursor() as cur:
  458. cur.execute(
  459. "SELECT feedback_id FROM account_whitelist WHERE account_id=%s",
  460. (account_id,),
  461. )
  462. row = cur.fetchone()
  463. if row and row.get("feedback_id") is not None:
  464. return int(row["feedback_id"])
  465. finally:
  466. conn.close()
  467. except Exception as e:
  468. logger.warning(
  469. "读取 account_id %s 的 DB feedback_id 失败, fallback config 字典:%s",
  470. account_id, e,
  471. )
  472. return None
  473. def _ensure_account_feedback_id(account_id: int) -> Optional[int]:
  474. """按账户绑定的 feedback_key 查找或新建 DataNexus 监测链接组。
  475. feedback_url 是全局模板;feedback_id 是账户级结果,拿到后写回
  476. account_whitelist.feedback_id。
  477. """
  478. import json as _json
  479. import time as _time
  480. import uuid as _uuid
  481. from urllib.parse import urlencode as _urlencode
  482. import httpx as _httpx
  483. try:
  484. from db.connection import get_connection
  485. except Exception as e:
  486. raise RuntimeError(f"DB 模块不可用:{e}") from e
  487. conn = get_connection()
  488. try:
  489. with conn.cursor() as cur:
  490. cur.execute(
  491. """
  492. SELECT f.feedback_key, f.feedback_name, f.second_category_type,
  493. f.feedback_type, f.feedback_url
  494. FROM ad_creation_account_config c
  495. JOIN feedback_asset_template f
  496. ON f.feedback_key = c.feedback_key
  497. AND f.enabled = TRUE
  498. WHERE c.account_id=%s
  499. AND c.enabled = TRUE
  500. """,
  501. (account_id,),
  502. )
  503. tpl = cur.fetchone()
  504. finally:
  505. conn.close()
  506. if not tpl:
  507. return None
  508. feedback_name = str(tpl["feedback_name"])
  509. second_category_type = str(tpl["second_category_type"])
  510. feedback_type = str(tpl["feedback_type"])
  511. feedback_url = str(tpl["feedback_url"])
  512. token = _get_datanexus_access_token(account_id)
  513. common = {
  514. "access_token": token,
  515. "timestamp": str(int(_time.time())),
  516. "nonce": _uuid.uuid4().hex,
  517. }
  518. page = 1
  519. while True:
  520. params = dict(common)
  521. params.update({"account_id": account_id, "page": page, "page_size": 100})
  522. resp = _httpx.get(
  523. f"https://api.e.qq.com/v3.0/feedback_info/get?{_urlencode(params)}",
  524. timeout=30,
  525. ).json()
  526. if resp.get("code") != 0:
  527. raise RuntimeError(
  528. f"feedback_info/get 失败 code={resp.get('code')} "
  529. f"msg={resp.get('message_cn') or resp.get('message')}"
  530. )
  531. data = resp.get("data") or {}
  532. items = data.get("list") or []
  533. for item in items:
  534. if item.get("second_category_type") != second_category_type:
  535. continue
  536. url_infos = item.get("url_info_list") or []
  537. for info in url_infos:
  538. if info.get("feedback_type") == feedback_type and info.get("feedback_url") == feedback_url:
  539. feedback_id = int(item["feedback_id"])
  540. _write_account_feedback_id(account_id, feedback_id)
  541. logger.info(
  542. "[feedback] account=%d 复用已有 feedback_id=%s name=%s",
  543. account_id, feedback_id, item.get("feedback_name"),
  544. )
  545. return feedback_id
  546. if item.get("feedback_name") == feedback_name:
  547. feedback_id = int(item["feedback_id"])
  548. _write_account_feedback_id(account_id, feedback_id)
  549. logger.info(
  550. "[feedback] account=%d 按名称复用已有 feedback_id=%s name=%s",
  551. account_id, feedback_id, item.get("feedback_name"),
  552. )
  553. return feedback_id
  554. page_info = data.get("page_info") or resp.get("page_info") or {}
  555. total_page = int(page_info.get("total_page") or page)
  556. if page >= total_page:
  557. break
  558. page += 1
  559. add_params = dict(common)
  560. add_params["nonce"] = _uuid.uuid4().hex
  561. body = {
  562. "account_id": account_id,
  563. "feedback_name": feedback_name,
  564. "second_category_type": second_category_type,
  565. "url_info_list": [
  566. {
  567. "feedback_type": feedback_type,
  568. "feedback_url": feedback_url,
  569. }
  570. ],
  571. }
  572. add_resp = _httpx.post(
  573. f"https://api.e.qq.com/v3.0/feedback_info/add?{_urlencode(add_params)}",
  574. json=body,
  575. timeout=30,
  576. ).json()
  577. if add_resp.get("code") != 0:
  578. raise RuntimeError(
  579. f"feedback_info/add 失败 code={add_resp.get('code')} "
  580. f"msg={add_resp.get('message_cn') or add_resp.get('message')} "
  581. f"body={_json.dumps(body, ensure_ascii=False)[:300]}"
  582. )
  583. feedback_id = int(((add_resp.get("data") or {}).get("feedback_info") or {})["feedback_id"])
  584. _write_account_feedback_id(account_id, feedback_id)
  585. logger.info(
  586. "[feedback] account=%d 新建 feedback_id=%s feedback_key=%s",
  587. account_id, feedback_id, tpl.get("feedback_key"),
  588. )
  589. return feedback_id
  590. def _get_datanexus_access_token(account_id: int) -> str:
  591. """获取 DataNexus API access_token。
  592. 当前内部 token API 实测可用于 /feedback_info/get;如后续拆分 DataNexus
  593. 专用 token,可通过 DATANEXUS_ACCESS_TOKEN 或 DATANEXUS_TOKEN_API 覆盖。
  594. """
  595. import httpx as _httpx
  596. static_token = os.getenv("DATANEXUS_ACCESS_TOKEN", "").strip()
  597. if static_token:
  598. return static_token
  599. token_api = os.getenv("DATANEXUS_TOKEN_API") or os.getenv(
  600. "TENCENT_AD_TOKEN_API",
  601. "https://api.piaoquantv.com/ad/put/tencent/getAccessToken",
  602. )
  603. resp = _httpx.get(token_api, params={"accountId": account_id}, timeout=10)
  604. resp.raise_for_status()
  605. token = resp.text.strip()
  606. if not token or len(token) <= 10:
  607. raise RuntimeError("DataNexus token 返回异常")
  608. return token
  609. def _write_account_feedback_id(account_id: int, feedback_id: int) -> None:
  610. """把账户级 DataNexus feedback_id 写回白名单。"""
  611. from db.connection import get_connection
  612. conn = get_connection()
  613. try:
  614. with conn.cursor() as cur:
  615. cur.execute(
  616. """
  617. UPDATE account_whitelist
  618. SET feedback_id=%s,
  619. updated_by=%s,
  620. updated_at=CURRENT_TIMESTAMP
  621. WHERE account_id=%s
  622. """,
  623. (feedback_id, "auto-feedback-init", account_id),
  624. )
  625. conn.commit()
  626. finally:
  627. conn.close()
  628. OPTIMIZATION_GOAL = "OPTIMIZATIONGOAL_PROMOTION_VIEW_KEY_PAGE" # 关键页面访问次数(USER_GROWTH 配套)
  629. # 注:2026-06-05 业务确认 — 两个测试账户均走 USER_GROWTH + PAGE_KEY 路线
  630. # 即使 83846804 带人群包,仍用此优化目标,不切换到 BRAND_PROMOTION + CLICK
  631. # --- 定向(全固定 SOP)---
  632. FIXED_TARGETING_AGE = [{"min": 45, "max": 66}] # 45-66 岁(自定义)
  633. FIXED_TARGETING_GENDER = "ALL" # 不限制(不传 gender)
  634. FIXED_TARGETING_LOCATION_TYPES = ["LIVE_IN"] # 常住地
  635. # 地域 region_id 列表 — 从 JSON 读(运营可改 JSON 调整生效地域)
  636. # 来源:ad 95205841163 (account 81214386) 实际投放地域反推
  637. # 实际排除:港澳台 + 东三省 + 河南(共 7 个一级行政区)
  638. FIXED_TARGETING_REGIONS_JSON_PATH = Path(__file__).parent / "data" / "tencent_constants" / "regions_sop_current.json"
  639. try:
  640. import json as _json
  641. with open(FIXED_TARGETING_REGIONS_JSON_PATH, encoding="utf-8") as _f:
  642. _regions_data = _json.load(_f)
  643. FIXED_TARGETING_REGION_IDS = [r["id"] for r in _regions_data["list"]]
  644. logger.info(
  645. f"✅ 从 {FIXED_TARGETING_REGIONS_JSON_PATH.name} 加载 {len(FIXED_TARGETING_REGION_IDS)} 个地域 region_id"
  646. )
  647. except FileNotFoundError:
  648. FIXED_TARGETING_REGION_IDS = []
  649. logger.warning(
  650. f"⚠️ 地域 JSON 未找到:{FIXED_TARGETING_REGIONS_JSON_PATH},新建广告时 targeting.geo_location 为空"
  651. )
  652. # 实际排除的一级行政区(给审批表 / 报告人类可读用)
  653. EXCLUDED_PROVINCES_SEMANTIC = ["香港", "澳门", "台湾", "辽宁", "吉林", "黑龙江", "河南"]
  654. # --- 出价 / 计费 ---
  655. BID_MODE = "BID_MODE_OCPM"
  656. SMART_BID_TYPE = "SMART_BID_TYPE_CUSTOM"
  657. BID_STRATEGY = "BID_STRATEGY_AVERAGE_COST"
  658. # 一键起量(auto_acquisition)默认关闭(用户 2026-06-05 确认,与多数样本一致)
  659. # 注:腾讯硬约束 — 若启用,budget 必须 >= 20000(200 元)
  660. AUTO_ACQUISITION_ENABLED = False
  661. AUTO_ACQUISITION_BUDGET_FEN = 20000 # 占位最小值,enabled=False 时不生效
  662. AUTO_DERIVED_CREATIVE_ENABLED = False
  663. AIM_SMART_TARGETING_ENABLED = False
  664. AIM_SMART_SITE_ENABLED = False
  665. DEEP_CONVERSION_SPEC: dict = {}
  666. # --- 转化(conversion_id)---
  667. # 用户 2026-06-05 指示:两个测试账户都用 1007(与样本 92067863445 一致)
  668. # 长期:迁到 account_whitelist 表加列 conversion_id
  669. DEFAULT_CONVERSION_ID = 1007
  670. # --- 搜索场景扩量 · 定向拓展开关(用户 2026-06-05 反推确认)---
  671. # 3 条线上样本均为 CLOSE,我们若不传腾讯默认 OPEN → 与 SOP 不一致
  672. # 用 ad_api 反推得知字段名:search_expand_targeting_switch
  673. SEARCH_EXPAND_TARGETING_SWITCH = "SEARCH_EXPAND_TARGETING_SWITCH_CLOSE"
  674. # --- 版位(2026-06-09 用户确认:参考 78420850/105832100128 加朋友圈版位)---
  675. AVAILABLE_SITE_SETS = [
  676. "SITE_SET_WECHAT", # 微信公众号
  677. "SITE_SET_WECHAT_PLUGIN", # 微信插件
  678. "SITE_SET_SEARCH_SCENE", # 搜索场景
  679. "SITE_SET_MOMENTS", # 朋友圈
  680. ]
  681. # MVP 阶段:单一固定版位组合(差异化先不靠 site_set)
  682. SITE_SET_COMBINATIONS = [
  683. ["SITE_SET_WECHAT", "SITE_SET_WECHAT_PLUGIN", "SITE_SET_SEARCH_SCENE", "SITE_SET_MOMENTS"],
  684. ]
  685. # --- AIM 智能定向(2026-06-11 实测修正:跨接口字段名/枚举值不一致)---
  686. # 文档:https://developers.e.qq.com/v3.0/docs/api/adgroups/update
  687. # https://developers.e.qq.com/v3.0/docs/enums#smart_targeting_mode
  688. #
  689. # write 接口(/adgroups/add + /adgroups/update)字段 = smart_targeting_mode (enum)
  690. # · SMART_TARGETING_MANUAL = 手动定向(等价于 AIM 关闭,本期目标)
  691. # · 不传该字段 = 腾讯默认开 AIM(实测:get 反查会变成 SMART_TARGETING_AUTO)
  692. # · 必须每次显式传(腾讯文档原话:"若选择手动定向需在每次调用接口时显式携带该参数")
  693. #
  694. # read 接口(/adgroups/get)字段 = smart_targeting_status (只读)
  695. # · SMART_TARGETING_NONE = 已关 · SMART_TARGETING_AUTO = 智能定向中
  696. SMART_TARGETING_MODE = "SMART_TARGETING_MANUAL"
  697. # --- WECHAT_POSITION 定投场景(2026-06-11 用户确认:生产 5 项小程序版位)---
  698. # 历史:9 项 preset(来自参考广告 77868332)→ 实际生产删除重建走 3 项 → 本期补 1024797。
  699. # 中文映射通过 tools.scene_spec.get_wechat_position_tags(account_id) 运行时查询(进程内缓存 1h)
  700. #
  701. # 业务生效场景(全部小程序流量位):
  702. # 1024795 小程序激励式广告
  703. # 1024796 小程序插屏广告
  704. # 2100748 小程序原生广告
  705. # 1024797 小程序封面广告(2026-06-11 新增)
  706. #
  707. # 1024794 小程序 banner 广告已移除:
  708. # → /adgroups/update 接口已下线,报 code=1800945
  709. # → 2026-06-30 /adgroups/add 对 84502339 同样报 code=1800945
  710. #
  711. # ⚠️ 创建后锁死(2026-06-11 实测复现):wechat_position 一旦创建,update 报 code=36840
  712. # → 要新增场景必须删除重建广告,代价是丢失已挂创意 + 学习数据
  713. WECHAT_POSITION_TARGETED_PRESET = [
  714. 1024795, 1024796, 2100748, 1024797,
  715. ]
  716. # 1 账户广告数(2026-06-09 用户确认:2 条,一条有 wechat_position 定投,一条无定投)
  717. ADS_PER_ACCOUNT = 2
  718. # --- 时段 / 日期(真实样本 95205841163 反推:6:00-22:30 投放)---
  719. # 一天 48 段 × 7 天 = 336 位字符串
  720. TIME_SERIES_ONE_DAY = "000000000000" + "1" * 34 + "00" # 0:00-6:00 关 / 6:00-23:00 投 / 23:00-24:00 关
  721. TIME_SERIES_DEFAULT = TIME_SERIES_ONE_DAY * 7
  722. # 长度自验
  723. assert len(TIME_SERIES_DEFAULT) == 336, f"time_series 长度错误:{len(TIME_SERIES_DEFAULT)}"
  724. DEFAULT_BEGIN_DATE_OFFSET_DAYS = 0 # 创建当日开始
  725. DEFAULT_END_DATE = "0" # 真实样本是字符串 "0",不是 None(腾讯特殊表示长期)
  726. # --- 预算(用户 2026-06-05 确认:200 元/广告)---
  727. # 真实样本是 0(不限),但 MVP 阶段我们设硬上限保护
  728. DEFAULT_DAILY_BUDGET_YUAN = 200 # 单广告日预算
  729. DEFAULT_DAILY_BUDGET_FEN = DEFAULT_DAILY_BUDGET_YUAN * 100 # 元 → 分
  730. # --- 出价区间表(按 audience tier label 索引)---
  731. # 来源:用户提供的投放 SOP 出价区间表
  732. AUDIENCE_BID_RANGES = {
  733. # tier_label : (min_yuan, max_yuan)
  734. "R50_泛惊奇_奇观技艺": (0.35, 0.45),
  735. "R50_泛知识_生活科普": (0.35, 0.45),
  736. "R50_泛知识_时政历史": (0.35, 0.45),
  737. "R50_泛祝福": (0.35, 0.45),
  738. "R50_全品类": (0.25, 0.38),
  739. "R50_同感个体_个人情感": (0.35, 0.45),
  740. "R50_同感个体_退休榜样": (0.35, 0.45),
  741. "R500_全品类": (0.38, 0.48),
  742. "回流100-180": (0.22, 0.28),
  743. "回流180-330": (0.30, 0.40),
  744. "回流330+": (0.35, 0.40),
  745. "R330+": (0.35, 0.40), # 别名,83846804(Q-R_330+)用
  746. "回流50-100": (0.19, 0.22),
  747. "泛人群": (0.19, 0.25),
  748. "no_audience_pack": (0.19, 0.25), # 别名,83846793(无人群包)用
  749. }
  750. # --- 出价取值策略 ---
  751. BID_PICK_STRATEGY = "midpoint" # midpoint / max / min / random
  752. COLD_START_BID_PICK_STRATEGY = "midpoint" # 冷启动期取中位(可改 max 抢量)
  753. # --- 朋友圈版位专属设置(运营标准模板,待提供)---
  754. FEED_AD_SETTING_HEAD_IMAGE_URL = None # TODO
  755. FEED_AD_SETTING_NICK_NAME = None # TODO
  756. FEED_AD_SETTING_CONVERSION_BUTTON_TEXT = "查看详情" # 默认
  757. # --- 单账户起步广告条数(Cold Start)---
  758. COLD_START_PER_ACCOUNT_AD_COUNT = 3 # 对应 3 种 site_set 组合
  759. COLD_START_TOTAL_ADS = COLD_START_PER_ACCOUNT_AD_COUNT * len(WHITELIST_ACCOUNTS) # 跟随 DB
  760. # ═══════════════════════════════════════════
  761. # 输出路径配置
  762. # ═══════════════════════════════════════════
  763. OUTPUTS_DIR = Path(__file__).parent / "outputs"
  764. RAW_DATA_DIR = OUTPUTS_DIR / "raw" # 创意级原始 CSV
  765. AD_STATUS_DIR = OUTPUTS_DIR / "ad_status" # 广告状态 CSV
  766. REPORTS_DIR = OUTPUTS_DIR / "reports" # 决策报告
  767. EXECUTION_LOG_DIR = OUTPUTS_DIR / "execution_log" # 执行审计日志
  768. DATA_DIR = OUTPUTS_DIR / "data" # 运行时数据(如调整历史)
  769. ADJUSTMENT_HISTORY_PATH = DATA_DIR / "adjustment_history.json"
  770. # ═══════════════════════════════════════════
  771. # 人群包系数(保留,用于展示)
  772. # ═══════════════════════════════════════════
  773. AUDIENCE_COEFFICIENTS = {
  774. "R500": 3.0,
  775. "R330+": 2.5,
  776. "R330": 2.0,
  777. "R180": 1.5,
  778. "R100": 1.2,
  779. "R50": 1.0,
  780. "R10": 1.0,
  781. "R2": 1.0,
  782. "default": 1.0,
  783. }
  784. # 从广告名称提取 R 值的匹配顺序
  785. AUDIENCE_TIER_PATTERNS = [
  786. ("R500", ["R500", "R_500", "r500"]),
  787. ("R330+", ["回流330+", "回流330+-", "回流q330", "330+全品类", "R330+", "R_330+"]),
  788. ("R330", ["回流330", "R330", "R_330", "定向330", "r330", "r300"]),
  789. ("R180", ["回流180", "R180", "R_180", "定向180", "r180",
  790. "r180-330", "r180-300", "R100-180", "R_100-180", "r100-180"]),
  791. ("R100", ["回流100", "R100", "R_100", "定向100", "r100", "R50-100"]),
  792. ("R50", ["回流50", "R50", "R_50", "r50"]),
  793. ("R10", ["R_10", "R10", "r10"]),
  794. ("R2", ["R_2", "R2", "r2"]),
  795. ]
  796. # ═══════════════════════════════════════════════════════════════════
  797. # [MODULE B / 创意搭建] 模块 B 主循环配置
  798. # ═══════════════════════════════════════════════════════════════════
  799. # 模块 B = 给广告挂创意(creative)的子系统,与模块 A(广告新建)对偶。
  800. # 数据流:find_ads_needing_creatives → 关联点过滤 → 召回素材 → POST 创意。
  801. # --- 创意补量目标(单广告期望创意数)---
  802. # 2026-07-01:用户确认最终合格创意不少于 4 个
  803. # 生产阶段长期应回到 15(腾讯经验下限,MIN_CREATIVES_PER_AD)
  804. # 这是 find_ads_needing_creatives 阈值 + 补量目标的**同一个语义变量**,不要拆
  805. TARGET_CREATIVES_PER_AD = 4
  806. # --- 主循环 try-fallback 限额(防无限召回)---
  807. # 单广告最多尝试 N 条 landing,超过即放弃此条创意(不影响广告剩余 to_add)
  808. # 2026-07-01 用户确认:视频获取/尝试上限 100 条
  809. MAX_LANDING_ATTEMPTS_PER_AD = 100
  810. # 单 landing 最多尝试 N 条素材(召回 top N)
  811. MAX_MATERIAL_PER_LANDING = 10
  812. # 内容服务返回的内容品类黑名单。为空则不过滤;多个品类用英文逗号分隔。
  813. LANDING_EXCLUDED_CATEGORIES = {
  814. v.strip()
  815. for v in os.getenv("LANDING_EXCLUDED_CATEGORIES", "早中晚好,祝福音乐").split(",")
  816. if v.strip()
  817. }
  818. # --- 承接视频风险审核(2026-06-29 接入)---
  819. # 在 xcx/save 之前调用 piaoquantv 风险标签接口。高风险视频直接跳过,继续尝试下一条 landing。
  820. VIDEO_RISK_CHECK_ENABLED = True
  821. VIDEO_RISK_API_URL = os.getenv(
  822. "VIDEO_RISK_API_URL",
  823. "https://longvideoapi.piaoquantv.com/longvideoapi/openapi/video/getVideoTagIds",
  824. )
  825. # 风险等级映射:tag_id -> level,level 越高风险越大。
  826. VIDEO_RISK_TAG_LEVELS = {
  827. "85856": 1,
  828. "85862": 2,
  829. "85863": 3,
  830. "85864": 4,
  831. "85865": 5,
  832. "85866": 6,
  833. "85867": 7,
  834. "85868": 8,
  835. "85869": 9,
  836. "85870": 10,
  837. }
  838. # 默认仅允许 0/1,拦截 2-10。可通过环境变量临时调整。
  839. VIDEO_RISK_MAX_ALLOWED_LEVEL = int(os.getenv("VIDEO_RISK_MAX_ALLOWED_LEVEL", "1"))
  840. VIDEO_RISK_API_TIMEOUT_SECONDS = int(os.getenv("VIDEO_RISK_API_TIMEOUT_SECONDS", "10"))
  841. # --- 素材召回质量过滤(2026-06-10 用户确认,batchByText 升级)---
  842. # 服务端 ranking 参数:simThreshold=0.7(语义相关),alpha=0(不要 sim 加权)
  843. # wCtr=1, wCvr=wRoi=wOpenRate=wFissionRate=0 → qualityScore 由 ctr 主导 → 等价"取 CTR 最高"
  844. # 客户端 impressions > 阈值 二次筛 → 保证"语义相关 + 曝光足够 + CTR 最高"
  845. RECALL_SIM_THRESHOLD = 0.7
  846. RECALL_ALPHA = 0
  847. RECALL_W_CTR = 1
  848. RECALL_W_CVR = 0
  849. RECALL_W_ROI = 0
  850. RECALL_W_OPEN_RATE = 0
  851. RECALL_W_FISSION_RATE = 0
  852. RECALL_DECONSTRUCT_BOOST = 0.4
  853. RECALL_DAYS = 180 # 投放统计天数(2026-06-10 修正:30→180,跟 admin 后台一致,冷门素材需长周期)
  854. RECALL_DISPLAY_K = 30 # 服务端展示条数
  855. RECALL_MIN_IMPRESSIONS = 2000 # 客户端 impressions 过滤阈值(2026-06-10 用户:曝光 > 2000)
  856. RECALL_MIN_CTR = 0.10 # 客户端 CTR 阈值(2026-06-10 用户:CTR ≥ 10%)
  857. RECALL_SOURCE_LABELS = ["内部素材"] # 只要内部
  858. RECALL_CONFIG_CODES_FULL = [
  859. "VIDEO_TOPIC", "VIDEO_INSPIRATION", "VIDEO_PURPOSE",
  860. "VIDEO_KEYPOINT", "VIDEO_TITLE",
  861. "RESULT_LOG_TOPIC", "RESULT_LOG_THEME",
  862. "RESULT_LOG_KEYWORDS", "RESULT_LOG_NARRATION",
  863. "INSPIRATION_SUBSTANCE", "KEYPOINT_SUBSTANCE", "PURPOSE_SUBSTANCE",
  864. "INSPIRATION_FORM", "KEYPOINT_FORM", "PURPOSE_FORM",
  865. ]
  866. # --- 创意文案池---
  867. # 2026-07-01 用户确认:创意文案统一使用"打开看看"。
  868. CREATIVE_DESCRIPTION_POOL = [
  869. "打开看看",
  870. ]
  871. CREATIVE_DESCRIPTION_COUNT_PER_AD = 1
  872. # --- 审批开关 ---
  873. # True(默认):Phase 1 准备后写待审批 CSV + 发飞书 sheet → 等运营审批 → Phase 3 POST
  874. # False:Phase 1 跑完直接 Phase 3 POST(skip 飞书)— 用于自动化 cron + 信任规则的场景
  875. CREATION_APPROVAL_REQUIRED = True
  876. # 审批超时(分钟,与现有调控审批配置对齐)
  877. CREATION_APPROVAL_TIMEOUT_MINUTES = 120
  878. # 飞书 chat_id:复用现有调控审批群(FEISHU_OPERATOR_CHAT_ID)
  879. # 等创意审批和调控审批要分群时,再加 FEISHU_CREATION_CHAT_ID 覆盖
  880. # ═══════════════════════════════════════════
  881. # 阿里云 SLS 日志上报(2026-06-11 接入,K8s pod 中 SDK 直发)
  882. # ═══════════════════════════════════════════
  883. # 从 os.environ 读取(.env 已 load_dotenv),任一缺失 → SLS_ENABLED=False → 不上报,只本地 file
  884. SLS_ENDPOINT = os.environ.get("SLS_ENDPOINT", "") # 例 cn-hangzhou.log.aliyuncs.com
  885. SLS_ACCESS_KEY_ID = os.environ.get("SLS_ACCESS_KEY_ID", "") # RAM 子账号 AK(只授 Log:PutLogs 权限)
  886. SLS_ACCESS_KEY_SECRET = os.environ.get("SLS_ACCESS_KEY_SECRET", "") # 同上的 SK
  887. SLS_PROJECT = os.environ.get("SLS_PROJECT", "auto-put-tecent")
  888. SLS_LOGSTORE = os.environ.get("SLS_LOGSTORE", "info-log")
  889. # 全开:任一缺失则降级为 False,主链路不受影响
  890. SLS_ENABLED = bool(SLS_ENDPOINT and SLS_ACCESS_KEY_ID and SLS_ACCESS_KEY_SECRET
  891. and SLS_PROJECT and SLS_LOGSTORE)
  892. # 上报等级 — 用户决策(2026-06-11):所有 INFO+ 上报
  893. # 注:material_recall 每个 landing 打 4 条 INFO,日 cron 量级约 5k-20k 条,SLS 流量成本几 RMB/月
  894. SLS_LOG_LEVEL = "INFO"
  895. # QueuedLogHandler 内部异步队列参数(SDK 默认 + 微调,避免长连接 idle 断)
  896. SLS_BATCH_SIZE_MAX = 1024 # 单次 PutLogs 最多条数
  897. SLS_PUT_WAIT_MS = 2000 # 队列攒到 batch_size 或等 2s flush 一次