config.py 48 KB

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