ad_decision.py 85 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800
  1. """
  2. 广告决策引擎 — auto_put_ad_mini
  3. 智能引擎:
  4. - LLM 推理 + 候选信号(ROI/裂变/CTR/消耗)驱动
  5. - 三级分类:零消耗待关停(规则)+ 待优化评估(LLM)+ 正常运行(规则)
  6. - 年龄保护三层架构(冷启动 / 早期成长 / 成熟期)
  7. """
  8. import logging
  9. import sys
  10. from datetime import datetime, timedelta
  11. from pathlib import Path
  12. from typing import Dict, List, Optional, Tuple
  13. import numpy as np
  14. import pandas as pd
  15. from agent.tools import tool
  16. from agent.tools.models import ToolContext, ToolResult
  17. _MINI_DIR = Path(__file__).resolve().parent.parent
  18. _TOOLS_DIR = Path(__file__).resolve().parent
  19. if str(_MINI_DIR) not in sys.path:
  20. sys.path.insert(0, str(_MINI_DIR))
  21. if str(_TOOLS_DIR) not in sys.path:
  22. sys.path.insert(0, str(_TOOLS_DIR))
  23. from config import (
  24. AUDIENCE_TIER_PATTERNS,
  25. BID_ADJUSTMENT_ENABLED,
  26. BID_DOWN_ROI_FACTOR,
  27. BID_UP_ROI_FACTOR,
  28. BID_UP_MAX_SPEND,
  29. BID_CHANGE_MIN_PCT,
  30. BID_CHANGE_MAX_PCT,
  31. BID_UP_MIN_PCT,
  32. BID_UP_MAX_PCT,
  33. BID_DOWN_MIN_PCT,
  34. BID_DOWN_MAX_PCT,
  35. BID_DOWN_MIN_SPEND,
  36. BID_FLOOR_YUAN,
  37. BID_CEILING_YUAN,
  38. COLD_START_DAYS, # ≤3天:冷启动期(极度保护)
  39. EARLY_GROWTH_DAYS, # 4-7天:早期成长期(可提价)
  40. AD_AGE_MATURE, # >7天:成熟期(全面调控)
  41. HIGH_BURN_AGE_THRESHOLD,
  42. HIGH_BURN_COST_THRESHOLD,
  43. ROI_LOW_FACTOR,
  44. ROI_LOW_MIN_YESTERDAY_COST,
  45. CREATIVE_PAUSE_ENABLED,
  46. CREATIVE_MIN_COST_SHARE,
  47. CREATIVE_MIN_AGE_DAYS,
  48. CREATIVE_MIN_VALID_DAYS,
  49. CREATIVE_MIN_REMAINING,
  50. CREATIVE_MAX_PAUSE_COST_SHARE,
  51. CREATIVE_RATELIMIT_DAYS,
  52. )
  53. logger = logging.getLogger(__name__)
  54. # ═══════════════════════════════════════════
  55. # 策略参数动态加载(阈值不写死在代码中)
  56. # ═══════════════════════════════════════════
  57. STRATEGY_PARAMS_FILE = _MINI_DIR / "strategy_params.json"
  58. def _load_strategy_params():
  59. """从json文件加载策略参数,如不存在则使用config.py默认值"""
  60. import json
  61. if STRATEGY_PARAMS_FILE.exists():
  62. try:
  63. with open(STRATEGY_PARAMS_FILE) as f:
  64. data = json.load(f)
  65. return data.get("params", {})
  66. except Exception as e:
  67. logger.warning(f"加载strategy_params.json失败,使用config.py默认值: {e}")
  68. # 使用config.py默认值
  69. return {
  70. "ROI_LOW_FACTOR": ROI_LOW_FACTOR,
  71. "BID_DOWN_ROI_FACTOR": BID_DOWN_ROI_FACTOR,
  72. "BID_UP_ROI_FACTOR": BID_UP_ROI_FACTOR,
  73. }
  74. # ═══════════════════════════════════════════
  75. # 决策动作类型(扩展支持)
  76. # ═══════════════════════════════════════════
  77. VALID_ACTIONS = [
  78. "pause", # 关停
  79. "bid_down", # 降价
  80. "bid_up", # 提价
  81. "hold", # 保持
  82. "creative_adjust", # 调整素材方向(需人工执行)
  83. "observe", # 观察等待(数据不稳定或接近阈值)
  84. "scale_up", # 扩量:建议新增广告/创意(需人工执行)
  85. ]
  86. # ═══════════════════════════════════════════
  87. # 辅助函数
  88. # ═══════════════════════════════════════════
  89. def _extract_audience_tier(ad_name: str) -> str:
  90. """从广告名称提取人群包 R 层级(保留自 V2)。"""
  91. if not ad_name:
  92. return "default"
  93. for tier, patterns in AUDIENCE_TIER_PATTERNS:
  94. for pat in patterns:
  95. if pat.lower() in str(ad_name).lower():
  96. return tier
  97. return "default"
  98. def _calculate_ad_age_days(create_time) -> Optional[int]:
  99. """计算广告从创建到现在的天数。"""
  100. if pd.isna(create_time):
  101. return None
  102. try:
  103. if isinstance(create_time, str):
  104. ct = datetime.strptime(create_time[:19], "%Y-%m-%d %H:%M:%S")
  105. else:
  106. ct = pd.Timestamp(create_time).to_pydatetime()
  107. return (datetime.now() - ct).days
  108. except Exception:
  109. return None
  110. # ═══════════════════════════════════════════
  111. # 衰退检测辅助函数
  112. # ═══════════════════════════════════════════
  113. def _detect_decay_signals(
  114. ad_ids: List[int],
  115. raw_dir: Path,
  116. ad_status_dir: Path,
  117. end_date: str
  118. ) -> pd.DataFrame:
  119. """
  120. 检测广告衰退信号(提价、换创意)。
  121. 输入:
  122. ad_ids: 需要检测的广告 ID 列表
  123. raw_dir: 创意级原始 CSV 目录
  124. ad_status_dir: 广告状态 CSV 目录
  125. end_date: 结束日期(YYYYMMDD)
  126. 输出:
  127. DataFrame,列:ad_id, bid_increased_7d, creative_changed_7d, stable_spend_days_30d
  128. """
  129. end_dt = datetime.strptime(end_date, "%Y%m%d")
  130. # 加载近 14 天创意数据(用于检测创意变化)
  131. creative_dfs = []
  132. for i in range(14):
  133. date = (end_dt - timedelta(days=i)).strftime("%Y%m%d")
  134. csv_path = raw_dir / f"creative_{date}.csv"
  135. if csv_path.exists():
  136. df = pd.read_csv(csv_path)
  137. df["date"] = date
  138. creative_dfs.append(df)
  139. if not creative_dfs:
  140. logger.warning("无创意数据,无法检测衰退信号")
  141. return pd.DataFrame(columns=["ad_id", "bid_increased_7d", "creative_changed_7d", "stable_spend_days_30d"])
  142. creative_df = pd.concat(creative_dfs, ignore_index=True)
  143. creative_df = creative_df[creative_df["ad_id"].isin(ad_ids)]
  144. # 加载近 14 天广告状态(用于检测提价)
  145. status_dfs = []
  146. for i in range(14):
  147. date = (end_dt - timedelta(days=i)).strftime("%Y%m%d")
  148. csv_path = ad_status_dir / f"ad_status_{date}.csv"
  149. if csv_path.exists():
  150. df = pd.read_csv(csv_path)
  151. df["date"] = date
  152. status_dfs.append(df)
  153. if not status_dfs:
  154. logger.warning("无广告状态数据,无法检测提价")
  155. status_df = pd.DataFrame()
  156. else:
  157. status_df = pd.concat(status_dfs, ignore_index=True)
  158. status_df = status_df[status_df["ad_id"].isin(ad_ids)]
  159. # 检测创意变化(近 7 天 vs 前 7-14 天)
  160. recent_7d_start = (end_dt - timedelta(days=6)).strftime("%Y%m%d")
  161. prior_7d_start = (end_dt - timedelta(days=13)).strftime("%Y%m%d")
  162. prior_7d_end = (end_dt - timedelta(days=7)).strftime("%Y%m%d")
  163. recent_creatives = (
  164. creative_df[creative_df["date"] >= recent_7d_start]
  165. .groupby("ad_id")["creative_id"]
  166. .apply(set)
  167. )
  168. prior_creatives = (
  169. creative_df[
  170. (creative_df["date"] >= prior_7d_start) & (creative_df["date"] <= prior_7d_end)
  171. ]
  172. .groupby("ad_id")["creative_id"]
  173. .apply(set)
  174. )
  175. creative_changed = {}
  176. for ad_id in ad_ids:
  177. recent_set = recent_creatives.get(ad_id, set())
  178. prior_set = prior_creatives.get(ad_id, set())
  179. creative_changed[ad_id] = (recent_set != prior_set) and len(recent_set) > 0 and len(prior_set) > 0
  180. # 检测提价(近 7 天最大出价 > 前 7-14 天最大出价)
  181. bid_increased = {}
  182. if not status_df.empty:
  183. recent_bids = (
  184. status_df[status_df["date"] >= recent_7d_start]
  185. .groupby("ad_id")["bid_amount"]
  186. .max()
  187. )
  188. prior_bids = (
  189. status_df[
  190. (status_df["date"] >= prior_7d_start) & (status_df["date"] <= prior_7d_end)
  191. ]
  192. .groupby("ad_id")["bid_amount"]
  193. .max()
  194. )
  195. for ad_id in ad_ids:
  196. recent_bid = recent_bids.get(ad_id, 0)
  197. prior_bid = prior_bids.get(ad_id, 0)
  198. bid_increased[ad_id] = recent_bid > prior_bid
  199. else:
  200. bid_increased = {ad_id: False for ad_id in ad_ids}
  201. # 计算 30 天稳定消耗天数(加载 30 天创意数据)
  202. creative_30d_dfs = []
  203. for i in range(30):
  204. date = (end_dt - timedelta(days=i)).strftime("%Y%m%d")
  205. csv_path = raw_dir / f"creative_{date}.csv"
  206. if csv_path.exists():
  207. df = pd.read_csv(csv_path)
  208. df["date"] = date
  209. creative_30d_dfs.append(df)
  210. if creative_30d_dfs:
  211. creative_30d_df = pd.concat(creative_30d_dfs, ignore_index=True)
  212. creative_30d_df = creative_30d_df[creative_30d_df["ad_id"].isin(ad_ids)]
  213. # 按 ad_id + date 聚合消耗
  214. daily_cost = (
  215. creative_30d_df.groupby(["ad_id", "date"])["cost"]
  216. .sum()
  217. .reset_index()
  218. )
  219. stable_days = {}
  220. for ad_id in ad_ids:
  221. ad_cost = daily_cost[daily_cost["ad_id"] == ad_id]
  222. stable_days[ad_id] = (ad_cost["cost"] >= 100).sum()
  223. else:
  224. stable_days = {ad_id: 0 for ad_id in ad_ids}
  225. # 组装结果(不含 stable_spend_days_30d,该值已在 metrics CSV 中)
  226. result = pd.DataFrame({
  227. "ad_id": ad_ids,
  228. "bid_increased_7d": [bid_increased.get(ad_id, False) for ad_id in ad_ids],
  229. "creative_changed_7d": [creative_changed.get(ad_id, False) for ad_id in ad_ids],
  230. })
  231. return result
  232. # ═══════════════════════════════════════════
  233. # 智能引擎工具 1:整理待评估广告数据
  234. # ═══════════════════════════════════════════
  235. @tool(description="智能引擎:整理需要关注的广告数据,供LLM推理决策")
  236. async def get_ads_for_review(
  237. ctx: ToolContext = None,
  238. metrics_csv: str = "",
  239. end_date: str = "yesterday",
  240. roi_review_factor: float = 0.8,
  241. min_spend_for_zero_spend: float = 10.0,
  242. ) -> ToolResult:
  243. """
  244. 不做决策,将广告分为三类,返回结构化摘要供 LLM 推理。
  245. 【零消耗待关停】:7日均消耗 < 10元(几乎零活动),规则直接关停
  246. 【待评估(候选)】:消耗有意义但指标异常(ROI偏低或衰退信号),需LLM评估
  247. 【正常运行】:无异常信号,仅返回摘要统计
  248. Args:
  249. metrics_csv: ROI 指标 CSV 路径(calculate_roi_metrics 输出)
  250. end_date: 结束日期
  251. roi_review_factor: 动态ROI < 全体均值 × 此值 → 进入 待评估(候选)(默认 0.8)
  252. min_spend_for_zero_spend: 7日均消耗低于此值(元)→ 零消耗待关停(默认 10.0)
  253. """
  254. try:
  255. # 加载策略参数(动态阈值,不写死在代码中)
  256. params = _load_strategy_params()
  257. if not metrics_csv:
  258. metrics_csv = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
  259. df = pd.read_csv(metrics_csv)
  260. if df.empty:
  261. return ToolResult(title="get_ads_for_review", output="指标数据为空")
  262. if end_date == "yesterday":
  263. end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
  264. # 前置过滤:SUSPEND / DELETED 广告不参与 LLM 评估(省 token + 避免无效候选)
  265. # 注:apply_decisions 里还有一道兜底过滤;这里是前置剪枝
  266. if "configured_status" in df.columns:
  267. before = len(df)
  268. excluded_status = {"AD_STATUS_SUSPEND", "AD_STATUS_DELETED"}
  269. df = df[~df["configured_status"].isin(excluded_status)].copy()
  270. dropped = before - len(df)
  271. if dropped > 0:
  272. logger.info(f"get_ads_for_review 入口过滤 {dropped} 条 SUSPEND/DELETED 广告")
  273. # ===== 白名单说明 =====
  274. # 白名单仅在执行阶段(execute_decisions)生效,用于限制实际API操作的账户范围。
  275. # 分析阶段不做白名单过滤,确保所有账户的广告都被评估并出现在审批表中。
  276. # ===== 新增:读取人群包级别统计数据(同类对比基准)=====
  277. logger.info("读取人群包级别统计数据...")
  278. by_tier_stats = {}
  279. by_tier_goal = {}
  280. try:
  281. # 读取 portfolio_summary JSON 文件
  282. portfolio_dir = _MINI_DIR / "outputs" / "portfolio_summary"
  283. portfolio_file = portfolio_dir / f"portfolio_summary_{end_date}.json"
  284. if portfolio_file.exists():
  285. import json
  286. with open(portfolio_file, "r", encoding="utf-8") as f:
  287. portfolio_data = json.load(f)
  288. by_tier_stats = portfolio_data.get("by_audience_tier", {})
  289. by_tier_goal = portfolio_data.get("by_tier_goal", {})
  290. logger.info(f"✅ 从 {portfolio_file.name} 加载了 {len(by_tier_stats)} 个人群包 + {len(by_tier_goal)} 个tier+goal组的统计数据")
  291. else:
  292. logger.warning(f"未找到 portfolio_summary 文件: {portfolio_file}(请确认 calculate_roi_metrics 已正常运行)")
  293. by_tier_goal = {}
  294. except Exception as e:
  295. logger.warning(f"读取人群包统计数据失败,使用空字典兜底: {e}")
  296. by_tier_stats = {}
  297. by_tier_goal = {}
  298. # 广告年龄:优先使用 metrics CSV 中已有的 ad_age_days(基于 end_dt 计算,与 ROI 数据口径一致)
  299. # ⚠️ 不再用 datetime.now() 重新计算,避免与 roi_calculator 的 end_dt 基准差 1 天
  300. if "ad_age_days" not in df.columns or df["ad_age_days"].isna().all():
  301. logger.warning("metrics CSV 缺少 ad_age_days 列,使用 datetime.now() 兜底计算")
  302. df["ad_age_days"] = df["create_time"].apply(_calculate_ad_age_days)
  303. # 检测衰退信号
  304. raw_dir = _MINI_DIR / "outputs" / "raw"
  305. ad_status_dir = _MINI_DIR / "outputs" / "ad_status"
  306. decay_signals = _detect_decay_signals(
  307. ad_ids=df["ad_id"].tolist(),
  308. raw_dir=raw_dir,
  309. ad_status_dir=ad_status_dir,
  310. end_date=end_date,
  311. )
  312. df = df.merge(decay_signals, on="ad_id", how="left")
  313. df["bid_increased_7d"] = df["bid_increased_7d"].fillna(False)
  314. df["creative_changed_7d"] = df["creative_changed_7d"].fillna(False)
  315. df["stable_spend_days_30d"] = df["stable_spend_days_30d"].fillna(0)
  316. # 全体 ROI 分布
  317. roi_series = df["动态ROI_7日均值"].dropna()
  318. # channel_roi_p50 = 渠道P50(全体广告"动态ROI 7日均值"的中位数),决策基准
  319. channel_roi_p50 = float(roi_series.median()) if len(roi_series) > 0 else 0.0
  320. roi_p25 = float(roi_series.quantile(0.25)) if len(roi_series) > 0 else 0.0
  321. roi_p75 = float(roi_series.quantile(0.75)) if len(roi_series) > 0 else 0.0
  322. roi_p90 = float(roi_series.quantile(0.90)) if len(roi_series) > 0 else 0.0
  323. # 加载调整历史(用于"持续低ROI升级关停"判断)
  324. try:
  325. from examples.auto_put_ad_mini.tools.guardrails import AdjustmentHistory
  326. adjustment_history = AdjustmentHistory()
  327. except ImportError:
  328. from types import SimpleNamespace
  329. adjustment_history = SimpleNamespace(was_recently_adjusted=lambda *a, **kw: False)
  330. logger.warning("guardrails.AdjustmentHistory 导入失败,跳过调整历史检查")
  331. # 分类(业务语言)
  332. zero_spend_ads = [] # 零消耗待关停
  333. need_review_ads = [] # 待优化评估
  334. normal_ads_count = 0 # 正常运行
  335. for _, row in df.iterrows():
  336. cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
  337. dynamic_roi_7d = row.get("动态ROI_7日均值")
  338. ad_age = row.get("ad_age_days")
  339. bid_inc = bool(row.get("bid_increased_7d", False))
  340. creative_chg = bool(row.get("creative_changed_7d", False))
  341. stable_days = float(row.get("stable_spend_days_30d", 0) or 0)
  342. if pd.isna(stable_days):
  343. stable_days = 0.0
  344. bid_amount = float(row.get("bid_amount", 0) or 0)
  345. # 零消耗待关停:7日均消耗 < 10元,几乎无活动(强规则,仍保留)
  346. # ⚠️ 年龄保护分层:
  347. # - ≤3天(冷启动期):保护,不关停且不评估(不动)
  348. # - 4-7天(早期成长期)+ 低消耗:放行进入候选评估,可能命中"提价分支A"(投手经验1.1第一条:唤醒沉默)
  349. # - >7天(成熟期)+ 低消耗:正常应用零消耗规则关停
  350. if cost_7d_avg < min_spend_for_zero_spend:
  351. if ad_age is not None and ad_age <= COLD_START_DAYS:
  352. # ≤3天(冷启动期):保护,不关停也不评估
  353. normal_ads_count += 1
  354. logger.debug(
  355. f"广告 {row['ad_id']} 年龄{ad_age}天≤{COLD_START_DAYS}天(冷启动期),"
  356. f"虽消耗低({cost_7d_avg:.2f}元),但年龄保护不关停"
  357. )
  358. continue
  359. elif ad_age is not None and ad_age <= EARLY_GROWTH_DAYS:
  360. # 4-7天(早期成长期)+ 低消耗:放行进入候选评估
  361. # 不 continue,让其落到下方 bid_up_candidate_a 判断(投手经验1.1第一条)
  362. logger.debug(
  363. f"广告 {row['ad_id']} 年龄{ad_age}天属早期成长期+消耗低({cost_7d_avg:.2f}元),"
  364. f"放行评估提价分支A(唤醒沉默)"
  365. )
  366. else:
  367. # >7天的低消耗广告:正常应用零消耗规则
  368. zero_spend_ads.append({
  369. "ad_id": int(row["ad_id"]),
  370. "ad_name": str(row.get("ad_name", "")),
  371. "cost_7d_avg": round(cost_7d_avg, 2),
  372. })
  373. continue
  374. # 昨日消耗(用于关停消耗门槛:投手经验2.4 "当天消耗>300")
  375. yesterday_cost = float(row.get("yesterday_cost", 0) or 0)
  376. # 待优化评估:ROI 偏低 或 衰退信号 或 出价调整候选(需要智能判断)
  377. # ★ 关停条件对齐投手经验2.4:需要昨日消耗≥300 且 广告年龄>3天
  378. roi_low = (
  379. (not pd.isna(dynamic_roi_7d))
  380. and (dynamic_roi_7d < channel_roi_p50 * roi_review_factor)
  381. and yesterday_cost >= ROI_LOW_MIN_YESTERDAY_COST # 昨日消耗≥300
  382. and (ad_age is not None and ad_age > COLD_START_DAYS) # 广告年龄>3天
  383. )
  384. decay_signal = (
  385. stable_days >= 7
  386. and cost_7d_avg < 100
  387. and (bid_inc or creative_chg)
  388. )
  389. # ===== 裂变率 + CTR 数据(用于候选信号判断)=====
  390. ad_fission = row.get("T0裂变系数_7日均值")
  391. if ad_fission is None or pd.isna(ad_fission):
  392. ad_fission = None
  393. else:
  394. ad_fission = float(ad_fission)
  395. # 人群包名称(优先用 audience_tier=package_name,兜底用 ad_name 提取 R 层级)
  396. tier = str(row.get("audience_tier", "")) or _extract_audience_tier(str(row.get("ad_name", "")))
  397. tier_stats = by_tier_stats.get(tier, {})
  398. tier_fission_mean = tier_stats.get("fission_mean")
  399. # CTR 数据
  400. ad_view = float(row.get("view_count", 0) or 0)
  401. ad_click = float(row.get("valid_click_count", 0) or 0)
  402. ad_ctr = ad_click / ad_view if ad_view > 0 else None
  403. tier_ctr_mean = tier_stats.get("ctr_mean")
  404. # ===== 出价调整候选(投手经验1.1 双分支 - 不同观察角度,OR 关系)=====
  405. # 分支A(消耗角度 / 唤醒沉默):
  406. # 3-7天 + 日均消耗 < 10元 + CTR 正常 → 提价 5-10%
  407. # 含义:"广告还没跑起来,先用提价信号试探系统是否愿意分发"
  408. bid_up_candidate_a = (
  409. ad_age is not None
  410. and COLD_START_DAYS < ad_age <= EARLY_GROWTH_DAYS # 4-7天
  411. and cost_7d_avg < min_spend_for_zero_spend # 日均消耗 < 10元(与 L394 放行口径一致)
  412. and bid_amount > 0
  413. and (tier_ctr_mean is None or ad_ctr is None # CTR 不低于同类均值80%("正常"定义)
  414. or ad_ctr >= tier_ctr_mean * 0.80)
  415. ) if BID_ADJUSTMENT_ENABLED else False
  416. # 分支B(ROI+裂变角度 / 优质放量):
  417. # 3-7天 + 后端数据好 + 均值消耗 <1000 + ROI>渠道均值5% + 裂变>同类10% + CTR 正常 → 提价 5-10%
  418. # 含义:"数据已证明这条广告优质,提价拉更多量"
  419. bid_up_candidate_b = (
  420. (not pd.isna(dynamic_roi_7d))
  421. and dynamic_roi_7d > channel_roi_p50 * params["BID_UP_ROI_FACTOR"] # ROI 高于渠道均值5%
  422. and cost_7d_avg < BID_UP_MAX_SPEND # 消耗<1000(固定阈值)
  423. and bid_amount > 0
  424. and (ad_age is not None and ad_age <= EARLY_GROWTH_DAYS) # 仅4-7天可提价(≤3天已被冷启动排除)
  425. and (tier_fission_mean is None or ad_fission is None # 裂变高于同类均值10%(无数据时跳过)
  426. or ad_fission > tier_fission_mean * 1.10)
  427. and (tier_ctr_mean is None or ad_ctr is None # CTR 不低于同类均值80%
  428. or ad_ctr >= tier_ctr_mean * 0.80)
  429. ) if BID_ADJUSTMENT_ENABLED else False
  430. # 命中任一分支即视为提价候选(OR 关系,两条经验路径独立有效)
  431. bid_up_candidate = bid_up_candidate_a or bid_up_candidate_b
  432. # 降价候选(入池):ROI 在降价区间 + 消耗≥500元/天
  433. # ★ 裂变率条件已移至 LLM 层判断(fission_vs_tier 字段),规则层只负责入池
  434. bid_down_candidate = (
  435. (not pd.isna(dynamic_roi_7d))
  436. and dynamic_roi_7d < channel_roi_p50 * params["BID_DOWN_ROI_FACTOR"] # ROI 在降价区间
  437. and dynamic_roi_7d >= channel_roi_p50 * params["ROI_LOW_FACTOR"] # 但未达关停线
  438. and cost_7d_avg >= BID_DOWN_MIN_SPEND # 消耗有数据意义
  439. and bid_amount > 0
  440. ) if BID_ADJUSTMENT_ENABLED else False
  441. # ===== 持续低ROI升级关停(投手经验2.4:"降价后持续低于均值就关停")=====
  442. persistent_low_roi = False
  443. if (
  444. not roi_low # 当前未达关停线(ROI在0.75~0.90之间)
  445. and (not pd.isna(dynamic_roi_7d))
  446. and dynamic_roi_7d < channel_roi_p50 * params["BID_DOWN_ROI_FACTOR"] # ROI仍低于渠道均值10%
  447. and yesterday_cost >= ROI_LOW_MIN_YESTERDAY_COST # 昨日消耗≥300
  448. and (ad_age is not None and ad_age > COLD_START_DAYS) # 年龄>3天
  449. ):
  450. last_bd_ts = adjustment_history.get_last_bid_down_ts(str(row["ad_id"]))
  451. if last_bd_ts is not None:
  452. days_since_bd = (datetime.now() - last_bd_ts).days
  453. if days_since_bd >= 7:
  454. # 降价后≥7天ROI仍低 → 升级为关停候选
  455. persistent_low_roi = True
  456. roi_low = True # 升级!
  457. logger.info(
  458. f"广告 {row['ad_id']} 降价后{days_since_bd}天ROI仍低"
  459. f"({dynamic_roi_7d:.4f}<{channel_roi_p50 * params['BID_DOWN_ROI_FACTOR']:.4f}),升级为关停候选"
  460. )
  461. # 扩量候选:成熟期 + 消耗稳定 + 高消耗 + ROI正常(基于决策树)
  462. scale_up_candidate = (
  463. ad_age is not None
  464. and ad_age > 7 # 成熟期(>7天)
  465. and stable_days >= 7 # 消耗稳定(≥7天)
  466. and cost_7d_avg > 1000 # 高消耗(>1000元/天)
  467. and (not pd.isna(dynamic_roi_7d))
  468. and dynamic_roi_7d >= channel_roi_p50 * 0.9 # ROI正常(≥均值的90%)
  469. )
  470. # ===== 消耗稳定性前置门控(决策树:成熟期+不稳定→observe)=====
  471. if ad_age is not None and ad_age > EARLY_GROWTH_DAYS and stable_days < 7:
  472. # 成熟期广告但消耗不稳定:清除负向信号,不进入降价/关停评估
  473. if roi_low or decay_signal or bid_down_candidate:
  474. logger.debug(
  475. f"广告 {row['ad_id']} 成熟期({ad_age}天)但消耗不稳定(稳定天数{stable_days}<7),"
  476. f"清除负向信号: roi_low={roi_low}, decay={decay_signal}, bid_down={bid_down_candidate}"
  477. )
  478. roi_low = False
  479. decay_signal = False
  480. bid_down_candidate = False
  481. # ===== 年龄保护(第一优先级)=====
  482. # 无论是否满足候选条件,年龄保护都是第一层判断
  483. age_protected_skip = False # 标记是否被年龄保护排除
  484. if ad_age is not None:
  485. # 冷启动期(≤3天):极度保护,直接排除所有评估
  486. if ad_age <= COLD_START_DAYS:
  487. normal_ads_count += 1
  488. logger.debug(
  489. f"广告 {row['ad_id']} 处于冷启动期({ad_age}天≤{COLD_START_DAYS}天),"
  490. f"年龄保护规则自动排除(无论是否满足候选条件)"
  491. )
  492. age_protected_skip = True
  493. # 早期成长期(4-7天):仅允许提价和扩量评估
  494. # ⚠️ 核心修复:强制清除所有负向候选标志,无论是否有提价标志
  495. elif ad_age <= EARLY_GROWTH_DAYS:
  496. # 检查原始候选状态(用于日志)
  497. has_negative_flags = roi_low or decay_signal or bid_down_candidate
  498. has_positive_flags = bid_up_candidate or scale_up_candidate
  499. # 强制清除负向候选标志(即使同时有提价标志)
  500. if has_negative_flags:
  501. logger.debug(
  502. f"广告 {row['ad_id']} 处于早期成长期({ad_age}天),"
  503. f"年龄保护强制清除负向候选标志:"
  504. f"roi_low={roi_low}→False, decay={decay_signal}→False, "
  505. f"bid_down={bid_down_candidate}→False"
  506. )
  507. roi_low = False
  508. decay_signal = False
  509. bid_down_candidate = False
  510. # 如果清除后没有任何候选标志 → 排除
  511. if not has_positive_flags:
  512. normal_ads_count += 1
  513. logger.debug(
  514. f"广告 {row['ad_id']} 处于早期成长期({ad_age}天),"
  515. f"无提价/扩量候选标志,已排除"
  516. )
  517. age_protected_skip = True
  518. # else: 有提价或扩量候选,允许进入评估(负向标志已清除)
  519. # 年龄保护排除的广告,直接跳过
  520. if age_protected_skip:
  521. continue
  522. # ===== 业务逻辑判断(第二层)=====
  523. # 只有通过年龄保护的广告才会到这里
  524. # 早期成长期的广告只会带着 bid_up_candidate 或 scale_up_candidate 到这里
  525. if roi_low or decay_signal or bid_up_candidate or bid_down_candidate or scale_up_candidate:
  526. # ===== 构建广告字典(基础字段)=====
  527. ad_dict = {
  528. "ad_id": int(row["ad_id"]),
  529. "ad_name": str(row.get("ad_name", "")),
  530. "动态ROI_7日均值": round(float(dynamic_roi_7d), 4) if not pd.isna(dynamic_roi_7d) else None,
  531. "cost_7d_avg": round(cost_7d_avg, 2),
  532. "cost_7d_total": round(float(row.get("cost_7d_total", 0) or 0), 2),
  533. "ad_age_days": int(ad_age) if ad_age is not None else None,
  534. "bid_increased_7d": bid_inc,
  535. "creative_changed_7d": creative_chg,
  536. "stable_spend_days_30d": int(stable_days),
  537. "bid_amount": round(bid_amount, 2),
  538. # ★ 客观信号(替代旧 bid_candidate 预设答案)
  539. "roi_zone": (
  540. "below_pause_line" if (not pd.isna(dynamic_roi_7d) and dynamic_roi_7d < channel_roi_p50 * params["ROI_LOW_FACTOR"])
  541. else "bid_down_zone" if bid_down_candidate
  542. else "above_bid_up_line" if (not pd.isna(dynamic_roi_7d) and dynamic_roi_7d > channel_roi_p50 * params["BID_UP_ROI_FACTOR"])
  543. else "normal"
  544. ),
  545. "bid_up_candidate": bid_up_candidate,
  546. "fission_vs_tier": (
  547. "high" if (ad_fission is not None and tier_fission_mean is not None and ad_fission >= tier_fission_mean * 1.10)
  548. else "low" if (ad_fission is not None and tier_fission_mean is not None and ad_fission < tier_fission_mean * 0.90)
  549. else "normal" if (ad_fission is not None and tier_fission_mean is not None)
  550. else "unknown"
  551. ),
  552. "scale_up_candidate": scale_up_candidate,
  553. # ===== 广告自身指标(供LLM对比同类基准) =====
  554. "ad_fission": round(ad_fission, 4) if ad_fission is not None else None,
  555. "ad_ctr": round(ad_ctr, 4) if ad_ctr is not None else None,
  556. "yesterday_cost": round(yesterday_cost, 2),
  557. }
  558. # ===== 新增:添加 audience_tier 和 roi_valid_days =====
  559. ad_dict["audience_tier"] = str(row.get("audience_tier", "default"))
  560. ad_dict["roi_valid_days"] = int(row.get("roi_valid_days", 0) or 0)
  561. # ===== 同类对比数据(仅裂变/CTR/出价,ROI 对比走渠道) =====
  562. tier = ad_dict.get("audience_tier", "default")
  563. tier_stats = by_tier_stats.get(tier, {})
  564. # ROI 对比走"渠道整体"(channel_roi_p50),故此处不注入 tier_roi_* 字段
  565. # 裂变率同类对比数据(裂变必须对比同人群)
  566. ad_dict["tier_fission_mean"] = tier_stats.get("fission_mean")
  567. ad_dict["tier_fission_p50"] = tier_stats.get("fission_p50")
  568. # ===== 新增:CTR + 同类均值出价(基于 tier+goal 分组)=====
  569. ad_dict["tier_ctr_mean"] = tier_stats.get("ctr_mean")
  570. ad_goal = str(row.get("广告优化目标", ""))
  571. tier_goal_key = f"{tier}_{ad_goal}"
  572. tier_goal_stats = by_tier_goal.get(tier_goal_key, tier_stats)
  573. tier_bid_mean = tier_goal_stats.get("bid_mean")
  574. ad_dict["tier_bid_mean"] = tier_bid_mean # 同类(tier+goal)均值出价
  575. ad_dict["bid_up_target_min"] = round(tier_bid_mean * 1.05, 4) if tier_bid_mean else None
  576. ad_dict["bid_up_target_max"] = round(tier_bid_mean * 1.10, 4) if tier_bid_mean else None
  577. # ROI 阈值线:基于"渠道P50"(channel_roi_p50,全体广告7日均值的中位数),严禁用同类
  578. # 精简为单值(减少 LLM 字段混淆,避免阈值幻觉)
  579. ad_dict["pause_line"] = round(channel_roi_p50 * params["ROI_LOW_FACTOR"], 4) if channel_roi_p50 else None # 关停线 = 渠道P50 × 0.75
  580. ad_dict["bid_down_line"] = round(channel_roi_p50 * params["BID_DOWN_ROI_FACTOR"], 4) if channel_roi_p50 else None # 降价线 = 渠道P50 × 0.90
  581. ad_dict["bid_up_line"] = round(channel_roi_p50 * params["BID_UP_ROI_FACTOR"], 4) if channel_roi_p50 else None # 提价线 = 渠道P50 × 1.05
  582. # ===== 新增:年龄分段标签(基于决策树图片)=====
  583. if ad_age is not None:
  584. if ad_age <= COLD_START_DAYS: # ≤3天:冷启动期
  585. ad_dict["age_segment"] = "cold_start"
  586. ad_dict["age_protection_level"] = "极度保护(冷启动期)"
  587. ad_dict["allow_bid_down"] = False # 不允许降价
  588. ad_dict["allow_bid_up"] = False # 不允许提价
  589. elif ad_age <= EARLY_GROWTH_DAYS: # 4-7天:早期成长期
  590. ad_dict["age_segment"] = "early_growth"
  591. ad_dict["age_protection_level"] = "仅允许提价(早期成长期)"
  592. ad_dict["allow_bid_down"] = False # 不允许降价
  593. ad_dict["allow_bid_up"] = True # 允许提价(满足ROI+消耗条件时)
  594. ad_dict["max_bid_down_pct"] = 0 # 不允许降价
  595. else: # >7天:成熟期
  596. ad_dict["age_segment"] = "mature"
  597. ad_dict["age_protection_level"] = "正常调控(成熟期)"
  598. ad_dict["allow_bid_down"] = True
  599. ad_dict["allow_bid_up"] = True
  600. ad_dict["max_bid_down_pct"] = 0.05 # 最大降价5%(决策树上限)
  601. # ⚠️ 高燃烧预警:广告年龄>3天 且 昨日消耗>300元
  602. yesterday_cost = float(row.get("前1日消耗", 0) or 0)
  603. if ad_age > HIGH_BURN_AGE_THRESHOLD and yesterday_cost > HIGH_BURN_COST_THRESHOLD:
  604. ad_dict["high_burn_alert"] = True
  605. ad_dict["yesterday_cost"] = round(yesterday_cost, 2)
  606. else:
  607. ad_dict["high_burn_alert"] = False
  608. # ===== 调幅参数分离(基于候选类型)=====
  609. if bid_up_candidate:
  610. ad_dict["bid_change_min_pct"] = BID_UP_MIN_PCT # 0.05
  611. ad_dict["bid_change_max_pct"] = BID_UP_MAX_PCT # 0.10
  612. elif bid_down_candidate:
  613. ad_dict["bid_change_min_pct"] = BID_DOWN_MIN_PCT # 0.03
  614. ad_dict["bid_change_max_pct"] = BID_DOWN_MAX_PCT # 0.05
  615. else:
  616. # 兜底:roi_low/decay/scale_up 等非出价候选,LLM 仍可能建议调价
  617. ad_dict["bid_change_min_pct"] = BID_CHANGE_MIN_PCT # 0.03
  618. ad_dict["bid_change_max_pct"] = BID_CHANGE_MAX_PCT # 0.10
  619. # ★ 持续低ROI升级标记(告知LLM这是升级后的关停候选)
  620. if persistent_low_roi:
  621. ad_dict["persistent_low_roi"] = True
  622. ad_dict["recommendation_hint"] = "该广告降价后≥7天ROI仍低于渠道均值,建议关停"
  623. need_review_ads.append(ad_dict)
  624. continue
  625. # 正常运行:ROI 正常且无异常信号
  626. normal_ads_count += 1
  627. import json
  628. # ═══════════════════════════════════════════
  629. # 按 audience_tier 分组 need_review_ads(用于子 Agent 并行评估)
  630. # ═══════════════════════════════════════════
  631. review_by_tier: Dict[str, List[Dict]] = {}
  632. for ad in need_review_ads:
  633. tier = str(ad.get("audience_tier", "default") or "default")
  634. review_by_tier.setdefault(tier, []).append(ad)
  635. # tier 分批:每个 tier 单独评估(降低单次 LLM 输入量,提升质量)
  636. tier_batches = sorted(
  637. [
  638. {
  639. "audience_tier": t,
  640. "count": len(ads),
  641. "ads": ads, # 完整广告数据
  642. }
  643. for t, ads in review_by_tier.items()
  644. ],
  645. key=lambda x: -x["count"],
  646. )
  647. result = {
  648. "summary": {
  649. "total": len(df),
  650. "zero_spend_ads": len(zero_spend_ads),
  651. "need_review_ads": len(need_review_ads),
  652. "normal_ads": normal_ads_count,
  653. "tier_groups": len(review_by_tier), # 并发批次数
  654. "max_batch_size": max((b["count"] for b in tier_batches), default=0),
  655. },
  656. "distribution": {
  657. "channel_roi_p50": round(channel_roi_p50, 4),
  658. "p25": round(roi_p25, 4),
  659. "p50": round(channel_roi_p50, 4),
  660. "p75": round(roi_p75, 4),
  661. "p90": round(roi_p90, 4),
  662. },
  663. "bid_adjustment": {
  664. "enabled": BID_ADJUSTMENT_ENABLED,
  665. "bid_down_line": round(channel_roi_p50 * params["BID_DOWN_ROI_FACTOR"], 4),
  666. "bid_up_line": round(channel_roi_p50 * params["BID_UP_ROI_FACTOR"], 4),
  667. "bid_up_max_spend": BID_UP_MAX_SPEND,
  668. "roi_low_min_yesterday_cost": ROI_LOW_MIN_YESTERDAY_COST,
  669. },
  670. "thresholds_used": {
  671. "ROI_LOW_FACTOR": params["ROI_LOW_FACTOR"],
  672. "BID_DOWN_ROI_FACTOR": params["BID_DOWN_ROI_FACTOR"],
  673. "BID_UP_ROI_FACTOR": params["BID_UP_ROI_FACTOR"],
  674. "BID_UP_MAX_SPEND": BID_UP_MAX_SPEND,
  675. "ROI_LOW_MIN_YESTERDAY_COST": ROI_LOW_MIN_YESTERDAY_COST,
  676. "channel_roi_p50": round(channel_roi_p50, 4),
  677. "pause_line": round(channel_roi_p50 * params["ROI_LOW_FACTOR"], 4),
  678. "bid_down_line": round(channel_roi_p50 * params["BID_DOWN_ROI_FACTOR"], 4),
  679. "bid_up_line": round(channel_roi_p50 * params["BID_UP_ROI_FACTOR"], 4),
  680. },
  681. # 零消耗广告由规则全自动处理,LLM 无需逐条决策
  682. # 仅传入规模 + 10 条样本(供 LLM 追溯形态,避免 1000+ 条名单挤占 context)
  683. "zero_spend_ads_count": len(zero_spend_ads),
  684. "zero_spend_ads_samples": zero_spend_ads[:10],
  685. # ★ 按 tier 分批评估(降低单次 LLM 输入量,提升决策质量)
  686. # tier_batches 包含完整广告数据,LLM 需循环处理每个 batch
  687. "tier_batches": tier_batches,
  688. "need_review_ads_total": len(need_review_ads), # 总数统计
  689. }
  690. output_json = json.dumps(result, ensure_ascii=False, indent=2)
  691. return ToolResult(
  692. title=(
  693. f"广告分类(零消耗:{len(zero_spend_ads)} 待评估:{len(need_review_ads)} "
  694. f"分 {len(review_by_tier)} 个 tier 批次 正常:{normal_ads_count})"
  695. ),
  696. output=output_json,
  697. metadata={
  698. "total": len(df),
  699. "zero_spend_ads": len(zero_spend_ads),
  700. "need_review_ads": len(need_review_ads),
  701. "normal_ads": normal_ads_count,
  702. "tier_groups": len(review_by_tier),
  703. "channel_roi_p50": channel_roi_p50,
  704. "end_date": end_date,
  705. },
  706. )
  707. except Exception as e:
  708. logger.error("get_ads_for_review 失败: %s", e, exc_info=True)
  709. return ToolResult(title="get_ads_for_review 失败", output=str(e))
  710. # ═══════════════════════════════════════════
  711. # 创意级 pause 细化分析
  712. # ═══════════════════════════════════════════
  713. def _analyze_creatives_for_pause(
  714. ad_id: int,
  715. audience_tier: Optional[str],
  716. end_date: str,
  717. channel_roi_p50: Optional[float],
  718. creative_roi_csv: Optional[Path] = None,
  719. portfolio_summary: Optional[dict] = None,
  720. ) -> dict:
  721. """
  722. 对一条广告级 pause 候选广告做创意级二次分析。
  723. 决策树:
  724. 1. eligible_creatives 为空(数据不足/冷启动)→ ad_pause(继续走广告级关停)
  725. 2. pause_targets == eligible_creatives(全部判死刑)→ ad_pause(等价于关广告)
  726. 3. pause_targets 为空(eligible 中没人触发)→ downgrade_to_observe
  727. 4. 部分触发 → creative_pause(只关具体差创意)
  728. 5. 边界保护:
  729. - 关停后剩余 eligible < CREATIVE_MIN_REMAINING → 升级 ad_pause
  730. - pause_targets 总占消耗 > CREATIVE_MAX_PAUSE_COST_SHARE → 升级 ad_pause
  731. Returns:
  732. {
  733. "ad_id": int,
  734. "verdict": "ad_pause" | "creative_pause" | "downgrade_to_observe",
  735. "reason": str,
  736. "all_creatives": [...], # 全量摘要
  737. "eligible_creatives": [...], # 通过资格门槛
  738. "pause_targets": [...], # 应被关停的 creative_id 列表
  739. "kept_creatives": [...], # 保留的 creative_id 列表
  740. "channel_p50": float,
  741. "threshold": float,
  742. }
  743. """
  744. result = {
  745. "ad_id": int(ad_id),
  746. "verdict": "ad_pause",
  747. "reason": "未做创意级分析(默认走广告级 pause)",
  748. "all_creatives": [],
  749. "eligible_creatives": [],
  750. "pause_targets": [],
  751. "kept_creatives": [],
  752. "channel_p50": channel_roi_p50,
  753. "threshold": None,
  754. }
  755. if not CREATIVE_PAUSE_ENABLED:
  756. result["reason"] = "CREATIVE_PAUSE_ENABLED=False,创意级细化功能关闭"
  757. return result
  758. # 选择阈值基线:优先该广告所属 audience_tier 的 P50,缺失退化到 channel_roi_p50
  759. p50 = None
  760. if portfolio_summary and audience_tier:
  761. tier_stats = portfolio_summary.get("by_audience_tier", {}).get(str(audience_tier), {})
  762. p50 = tier_stats.get("roi_p50")
  763. if p50 is None:
  764. p50 = channel_roi_p50
  765. if p50 is None or p50 <= 0:
  766. result["reason"] = "无可用 channel/tier P50 基线,跳过创意级细化"
  767. return result
  768. threshold = float(p50) * float(ROI_LOW_FACTOR)
  769. result["channel_p50"] = float(p50)
  770. result["threshold"] = round(threshold, 4)
  771. # 加载创意级 ROI CSV
  772. if creative_roi_csv is None:
  773. creative_roi_csv = _MINI_DIR / "outputs" / "creative_roi" / f"creative_roi_{end_date}.csv"
  774. if not Path(creative_roi_csv).exists():
  775. result["reason"] = f"创意级 ROI CSV 不存在({creative_roi_csv}),无法做细化判定"
  776. return result
  777. try:
  778. df_cr = pd.read_csv(creative_roi_csv, dtype={"ad_id": str, "creative_id": str})
  779. except Exception as e:
  780. result["reason"] = f"加载创意级 ROI CSV 失败:{e}"
  781. return result
  782. ad_str = str(int(ad_id))
  783. df_ad = df_cr[df_cr["ad_id"] == ad_str].copy()
  784. if df_ad.empty:
  785. result["reason"] = f"广告 {ad_id} 在创意级 ROI 表中无记录"
  786. return result
  787. # 创意级 pause 速率限制(同一创意 7 天内不再 pause)
  788. try:
  789. from guardrails import CreativePauseHistory
  790. creative_history = CreativePauseHistory()
  791. except Exception as e:
  792. logger.warning(f"加载 CreativePauseHistory 失败,跳过创意级速率限制: {e}")
  793. creative_history = None
  794. # 资格门槛 + 死刑判定
  795. all_summary = []
  796. eligible = []
  797. pause_targets = []
  798. kept = []
  799. rate_limited = [] # 因近期已 pause 被速率限制的(也算 kept)
  800. eligible_total_cost_share = 0.0
  801. pause_total_cost_share = 0.0
  802. for _, row in df_ad.iterrows():
  803. cid = str(row["creative_id"])
  804. cost_share = float(row.get("cost_share_7d") or 0)
  805. age = row.get("creative_age_days")
  806. valid_days = int(row.get("roi_valid_days") or 0)
  807. roi_7d = row.get("创意动态ROI_7日均值")
  808. roi_7d_val = float(roi_7d) if pd.notna(roi_7d) else None
  809. item = {
  810. "creative_id": cid,
  811. "creative_name": str(row.get("creative_name") or ""),
  812. "cost_7d": round(float(row.get("cost_7d") or 0), 2),
  813. "cost_share_7d": round(cost_share, 4),
  814. "creative_age_days": int(age) if pd.notna(age) else None,
  815. "roi_valid_days": valid_days,
  816. "creative_dynamic_roi_7d": round(roi_7d_val, 4) if roi_7d_val is not None else None,
  817. }
  818. all_summary.append(item)
  819. # 资格门槛
  820. is_eligible = (
  821. cost_share >= CREATIVE_MIN_COST_SHARE
  822. and (item["creative_age_days"] is not None and item["creative_age_days"] >= CREATIVE_MIN_AGE_DAYS)
  823. and valid_days >= CREATIVE_MIN_VALID_DAYS
  824. and roi_7d_val is not None
  825. )
  826. if not is_eligible:
  827. continue
  828. eligible.append(item)
  829. eligible_total_cost_share += cost_share
  830. # 死刑判定
  831. if roi_7d_val < threshold:
  832. # 速率限制:同一创意 CREATIVE_RATELIMIT_DAYS 天内不再 pause
  833. if creative_history is not None and creative_history.was_paused_within(
  834. ad_str, cid, CREATIVE_RATELIMIT_DAYS
  835. ):
  836. item_with_note = dict(item)
  837. item_with_note["rate_limited"] = True
  838. rate_limited.append(item_with_note)
  839. kept.append(item_with_note) # 速率限制项也算 kept(保留观察)
  840. logger.info(
  841. f"创意级速率限制:广告 {ad_id} 创意 {cid} {CREATIVE_RATELIMIT_DAYS} 天内已 pause,跳过"
  842. )
  843. else:
  844. pause_targets.append(item)
  845. pause_total_cost_share += cost_share
  846. else:
  847. kept.append(item)
  848. result["all_creatives"] = all_summary
  849. result["eligible_creatives"] = eligible
  850. # ===== verdict 决策树 =====
  851. n_eligible = len(eligible)
  852. n_pause = len(pause_targets)
  853. if n_eligible == 0:
  854. result["verdict"] = "ad_pause"
  855. result["reason"] = (
  856. f"创意级数据不充分(全 {len(all_summary)} 个创意均未通过资格门槛:"
  857. f"cost_share≥{CREATIVE_MIN_COST_SHARE}/age≥{CREATIVE_MIN_AGE_DAYS}d/"
  858. f"valid_days≥{CREATIVE_MIN_VALID_DAYS}),沿用广告级 pause"
  859. )
  860. return result
  861. if n_pause == 0:
  862. result["verdict"] = "downgrade_to_observe"
  863. result["reason"] = (
  864. f"创意级细化:{n_eligible} 个有效创意 ROI 均 ≥ 阈值 {threshold:.4f}"
  865. f"(渠道/tier P50={p50:.4f} × {ROI_LOW_FACTOR}),广告级 ROI 偏低可能是噪声,降级观察"
  866. )
  867. return result
  868. if n_pause == n_eligible:
  869. result["verdict"] = "ad_pause"
  870. result["reason"] = (
  871. f"创意级细化:{n_eligible} 个有效创意全部低于阈值 {threshold:.4f},"
  872. f"等价于关整广告"
  873. )
  874. result["pause_targets"] = pause_targets
  875. return result
  876. # 部分触发 + 边界保护
  877. n_remaining = n_eligible - n_pause
  878. if n_remaining < CREATIVE_MIN_REMAINING:
  879. result["verdict"] = "ad_pause"
  880. result["reason"] = (
  881. f"创意级细化:本可关 {n_pause}/{n_eligible} 个差创意,"
  882. f"但关停后剩余 eligible 创意数={n_remaining} < {CREATIVE_MIN_REMAINING},"
  883. f"为保系统优选空间升级为广告级 pause"
  884. )
  885. result["pause_targets"] = pause_targets
  886. return result
  887. if pause_total_cost_share > CREATIVE_MAX_PAUSE_COST_SHARE:
  888. result["verdict"] = "ad_pause"
  889. result["reason"] = (
  890. f"创意级细化:本可关 {n_pause}/{n_eligible} 个差创意,"
  891. f"但 pause_targets 总占消耗 {pause_total_cost_share:.2%} > {CREATIVE_MAX_PAUSE_COST_SHARE:.0%},"
  892. f"本质就是关广告主体,升级为广告级 pause"
  893. )
  894. result["pause_targets"] = pause_targets
  895. return result
  896. result["verdict"] = "creative_pause"
  897. result["pause_targets"] = pause_targets
  898. result["kept_creatives"] = kept
  899. result["reason"] = (
  900. f"创意级细化:关 {n_pause}/{n_eligible} 个低于阈值 {threshold:.4f} 的创意"
  901. f"(占消耗 {pause_total_cost_share:.1%}),保留 {n_remaining} 个 eligible 创意"
  902. f"让系统重新优选"
  903. )
  904. return result
  905. # ═══════════════════════════════════════════
  906. # 智能引擎工具 2:保存 LLM 决策结果
  907. # ═══════════════════════════════════════════
  908. @tool(description="智能引擎:接收LLM的决策列表,合并零消耗/正常运行类自动决策,保存为结构化结果")
  909. async def apply_decisions(
  910. ctx: ToolContext = None,
  911. decisions: str = "",
  912. end_date: str = "yesterday",
  913. metrics_csv: str = "",
  914. ) -> ToolResult:
  915. """
  916. 接收 LLM 的决策,合并【零消耗待关停】(自动关停)和【正常运行】(自动保持)广告,保存到 llm_decisions_{date}.csv。
  917. 决策分类:
  918. - 零消耗待关停:7日均消耗 < 10元,几乎无活动 → 规则判断自动关停
  919. - 待评估(候选):ROI 偏低、衰退信号、出价调整候选 → 智能判断
  920. - 正常运行:ROI 正常且无异常信号 → 规则判断自动保持
  921. Args:
  922. decisions: JSON 字符串,LLM 输出的【待评估(候选)】广告决策列表
  923. 格式:[{"ad_id": 123, "action": "pause"/"hold"/"bid_up"/"bid_down",
  924. "dimension": "...", "reason": "...", "confidence": "high"/"medium"/"low"}]
  925. end_date: 结束日期
  926. metrics_csv: ROI 指标 CSV 路径(用于获取【零消耗待关停】和【正常运行】广告)
  927. """
  928. import json
  929. try:
  930. if end_date == "yesterday":
  931. end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
  932. # 解析 LLM 决策
  933. try:
  934. llm_list = json.loads(decisions)
  935. except json.JSONDecodeError as e:
  936. return ToolResult(title="apply_decisions 失败", output=f"decisions 不是合法 JSON: {e}")
  937. if not isinstance(llm_list, list):
  938. return ToolResult(title="apply_decisions 失败", output="decisions 必须是 JSON 数组")
  939. # 加载零消耗待关停广告(规则判断)
  940. zero_spend_rows = []
  941. if not metrics_csv:
  942. metrics_csv = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
  943. try:
  944. df_metrics = pd.read_csv(metrics_csv)
  945. for _, row in df_metrics.iterrows():
  946. # ⚠️ 状态过滤:跳过已关停的广告(避免重复决策)
  947. ad_status = row.get("configured_status", "")
  948. if ad_status in ["AD_STATUS_SUSPEND", "AD_STATUS_DELETED", "SUSPEND", "DELETED"]:
  949. continue
  950. cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
  951. ad_age = row.get("ad_age_days") # 获取广告年龄
  952. # ⚠️ 年龄保护:≤7天的广告不适用零消耗规则
  953. if cost_7d_avg < 10.0:
  954. # 检查广告年龄
  955. if ad_age is not None and ad_age <= EARLY_GROWTH_DAYS:
  956. # 4-7天或≤3天:保护,跳过
  957. logger.debug(
  958. f"零消耗规则跳过广告 {row['ad_id']}:年龄{ad_age}天≤{EARLY_GROWTH_DAYS}天"
  959. )
  960. continue
  961. # >7天的低消耗广告:正常应用零消耗规则
  962. # 优化reason表达:避免"0.00元"显示,改用"几乎无消耗"
  963. if cost_7d_avg == 0:
  964. reason_text = "7日几乎无消耗,长期无活动"
  965. else:
  966. reason_text = f"7日均消耗={cost_7d_avg:.2f}元,长期低消耗"
  967. zero_spend_rows.append({
  968. "ad_id": int(row["ad_id"]),
  969. "action": "pause",
  970. "pause_scope": "ad", # 零消耗规则 pause 一律广告级,跳过创意级细化
  971. "dimension": "长期零消耗",
  972. "reason": reason_text,
  973. "confidence": "high",
  974. "source": "规则判断",
  975. "cost_7d_avg": cost_7d_avg, # 用于排序
  976. })
  977. except Exception as e:
  978. logger.warning("加载零消耗待关停广告失败(跳过): %s", e)
  979. # 合并 LLM 决策(标注来源 + 添加cost_7d_avg用于排序 + 冷启动期决策过滤)
  980. for item in llm_list:
  981. item["source"] = "智能判断"
  982. # ★ 关键修复:统一 ad_id 为 int,避免 int vs string 导致去重失败
  983. try:
  984. item["ad_id"] = int(item["ad_id"])
  985. except (ValueError, TypeError):
  986. pass
  987. ad_id = item.get("ad_id")
  988. action = item.get("action", "hold")
  989. # 从metrics中获取广告信息
  990. try:
  991. cost_row = df_metrics[df_metrics["ad_id"] == ad_id]
  992. if not cost_row.empty:
  993. row_data = cost_row.iloc[0]
  994. item["cost_7d_avg"] = float(row_data.get("cost_7d_avg", 0) or 0)
  995. # ===== 年龄保护兜底检查(阶段3)=====
  996. # 阶段1已做前置过滤,这里仅作兜底检查(理论上不应触发)
  997. ad_age_days = row_data.get("ad_age_days")
  998. if ad_age_days is not None:
  999. if ad_age_days <= COLD_START_DAYS: # ≤3天:冷启动期(极度保护)
  1000. # 所有操作都改为observe
  1001. if action in ["bid_down", "pause", "bid_up"]:
  1002. original_action = action
  1003. original_reason = item.get("reason", "")
  1004. item["action"] = "observe"
  1005. item["reason"] = f"{original_reason}(LLM建议{original_action},但广告处于冷启动期{ad_age_days}天,年龄保护规则自动改为观察)"
  1006. item["confidence"] = "low"
  1007. item["recommended_change_pct"] = None
  1008. logger.error(
  1009. f"⚠️ 兜底检查触发!广告 {ad_id} 处于冷启动期({ad_age_days}天≤{COLD_START_DAYS}天),"
  1010. f"LLM建议 {original_action},已自动转换为 observe。"
  1011. f"这不应该发生(阶段1应已过滤),请检查逻辑!"
  1012. )
  1013. elif ad_age_days <= EARLY_GROWTH_DAYS: # 4-7天:早期成长期(仅允许提价)
  1014. # 不允许降价/关停
  1015. if action in ["bid_down", "pause"]:
  1016. original_action = action
  1017. original_reason = item.get("reason", "")
  1018. item["action"] = "observe"
  1019. item["reason"] = f"{original_reason}(LLM建议{original_action},但广告处于早期成长期{ad_age_days}天,年龄保护规则仅允许提价,改为观察)"
  1020. item["confidence"] = "low"
  1021. item["recommended_change_pct"] = None
  1022. logger.error(
  1023. f"⚠️ 兜底检查触发!广告 {ad_id} 处于早期成长期({ad_age_days}天,4-{EARLY_GROWTH_DAYS}天),"
  1024. f"LLM建议 {original_action},已自动转换为 observe。"
  1025. f"这不应该发生(阶段1应已过滤),请检查逻辑!"
  1026. )
  1027. else:
  1028. item["cost_7d_avg"] = 0.0
  1029. except Exception as e:
  1030. item["cost_7d_avg"] = 0.0
  1031. logger.warning(f"处理广告 {ad_id} 信息时出错: {e}")
  1032. # ===== 创意级 pause 细化(阶段 2)=====
  1033. # 对所有 LLM 输出 pause 的决策做创意级二次分析,改写为:
  1034. # creative_pause(只关差创意) / 保持 ad_pause / downgrade_to_observe
  1035. if CREATIVE_PAUSE_ENABLED:
  1036. # 加载 portfolio_summary + channel_p50
  1037. portfolio_summary = {}
  1038. channel_p50 = None
  1039. try:
  1040. import json as _json
  1041. portfolio_file = _MINI_DIR / "outputs" / "portfolio_summary" / f"portfolio_summary_{end_date}.json"
  1042. if portfolio_file.exists():
  1043. with open(portfolio_file, "r", encoding="utf-8") as f:
  1044. portfolio_summary = _json.load(f)
  1045. g = portfolio_summary.get("global", {}) or {}
  1046. channel_p50 = g.get("roi_p50")
  1047. except Exception as e:
  1048. logger.warning(f"加载 portfolio_summary 失败,创意级细化可能降级:{e}")
  1049. # 创意级 ROI CSV 路径
  1050. creative_roi_csv = _MINI_DIR / "outputs" / "creative_roi" / f"creative_roi_{end_date}.csv"
  1051. if not creative_roi_csv.exists():
  1052. logger.warning(
  1053. f"创意级 ROI CSV 不存在({creative_roi_csv}),所有 pause 均沿用广告级。"
  1054. f"请确认 calculate_creative_roi 已运行。"
  1055. )
  1056. verdict_counts = {"ad_pause": 0, "creative_pause": 0, "downgrade_to_observe": 0}
  1057. for item in llm_list:
  1058. if item.get("action") != "pause":
  1059. continue
  1060. ad_id = item.get("ad_id")
  1061. if ad_id is None:
  1062. continue
  1063. # audience_tier 从 metrics 拿
  1064. tier = None
  1065. try:
  1066. cost_row = df_metrics[df_metrics["ad_id"] == ad_id]
  1067. if not cost_row.empty:
  1068. tier = cost_row.iloc[0].get("audience_tier")
  1069. if pd.isna(tier):
  1070. tier = None
  1071. except Exception:
  1072. tier = None
  1073. analysis = _analyze_creatives_for_pause(
  1074. ad_id=int(ad_id),
  1075. audience_tier=tier,
  1076. end_date=end_date,
  1077. channel_roi_p50=channel_p50,
  1078. creative_roi_csv=creative_roi_csv,
  1079. portfolio_summary=portfolio_summary,
  1080. )
  1081. verdict = analysis["verdict"]
  1082. verdict_counts[verdict] = verdict_counts.get(verdict, 0) + 1
  1083. original_reason = item.get("reason", "")
  1084. if verdict == "creative_pause":
  1085. item["pause_scope"] = "creatives"
  1086. item["pause_creative_ids"] = [c["creative_id"] for c in analysis["pause_targets"]]
  1087. item["pause_creative_details"] = analysis["pause_targets"]
  1088. item["kept_creative_ids"] = [c["creative_id"] for c in analysis["kept_creatives"]]
  1089. item["reason"] = f"{original_reason} | {analysis['reason']}"
  1090. elif verdict == "downgrade_to_observe":
  1091. item["action"] = "observe"
  1092. item["pause_scope"] = "ad"
  1093. item["confidence"] = "low"
  1094. item["recommended_change_pct"] = None
  1095. item["reason"] = f"{original_reason} | {analysis['reason']}"
  1096. else: # ad_pause
  1097. item["pause_scope"] = "ad"
  1098. if analysis.get("threshold") is not None:
  1099. item["reason"] = f"{original_reason} | {analysis['reason']}"
  1100. logger.info(
  1101. f"创意级细化结果:ad_pause={verdict_counts.get('ad_pause', 0)},"
  1102. f"creative_pause={verdict_counts.get('creative_pause', 0)},"
  1103. f"downgrade_to_observe={verdict_counts.get('downgrade_to_observe', 0)}"
  1104. )
  1105. # 加载正常运行广告(规则判断)
  1106. normal_running_rows = []
  1107. try:
  1108. # 收集零消耗和待评估的 ad_id
  1109. zero_spend_ad_ids = {row["ad_id"] for row in zero_spend_rows}
  1110. need_review_ad_ids = {item["ad_id"] for item in llm_list}
  1111. # 正常运行 = 所有广告 - 零消耗 - 待评估 - 已关停/已删除
  1112. for _, row in df_metrics.iterrows():
  1113. ad_id = int(row["ad_id"])
  1114. # ⚠️ 与零消耗扫描保持一致:跳过 SUSPEND/DELETED 广告
  1115. # (含 cache enrichment 从 NORMAL 覆盖过来的历史状态)
  1116. ad_status = str(row.get("configured_status", "")).upper()
  1117. if ad_status in ("AD_STATUS_SUSPEND", "AD_STATUS_DELETED", "SUSPEND", "DELETED"):
  1118. continue
  1119. if ad_id not in zero_spend_ad_ids and ad_id not in need_review_ad_ids:
  1120. cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
  1121. dynamic_roi_7d = row.get("动态ROI_7日均值")
  1122. ad_age_days = row.get("ad_age_days")
  1123. # 冷启动保护:广告年龄 ≤ 3天(基于决策树)
  1124. if ad_age_days is not None and ad_age_days <= COLD_START_DAYS:
  1125. roi_str = f"{dynamic_roi_7d:.2f}" if not pd.isna(dynamic_roi_7d) else "数据不足"
  1126. normal_running_rows.append({
  1127. "ad_id": ad_id,
  1128. "action": "hold",
  1129. "dimension": "冷启动保护",
  1130. "reason": f"广告年龄{ad_age_days}天 ≤ {COLD_START_DAYS}天(冷启动期),ROI={roi_str},消耗{cost_7d_avg:.2f}元/天,极度保护",
  1131. "confidence": "high",
  1132. "source": "规则判断",
  1133. "cost_7d_avg": cost_7d_avg, # 用于排序
  1134. })
  1135. else:
  1136. # 正常运行
  1137. roi_str = f"{dynamic_roi_7d:.2f}" if not pd.isna(dynamic_roi_7d) else "数据不足"
  1138. normal_running_rows.append({
  1139. "ad_id": ad_id,
  1140. "action": "hold",
  1141. "dimension": "正常运行",
  1142. "reason": f"ROI={roi_str},消耗正常({cost_7d_avg:.2f}元/天),保持当前出价",
  1143. "confidence": "high",
  1144. "source": "规则判断",
  1145. "cost_7d_avg": cost_7d_avg, # 用于排序
  1146. })
  1147. except Exception as e:
  1148. logger.warning("加载正常运行广告失败(跳过): %s", e)
  1149. all_decisions = zero_spend_rows + llm_list + normal_running_rows
  1150. if not all_decisions:
  1151. return ToolResult(title="apply_decisions", output="无决策数据")
  1152. df_out = pd.DataFrame(all_decisions)
  1153. # ★ 统一 ad_id 为 int64,确保后续 merge/去重不因类型不匹配而失败
  1154. df_out["ad_id"] = pd.to_numeric(df_out["ad_id"], errors="coerce").astype("Int64")
  1155. # ===== 去重:同一 ad_id 只保留优先级最高的决策 =====
  1156. # 优先级:智能判断 > 规则判断(LLM 的判断优先于规则默认值)
  1157. source_priority = {"智能判断": 0, "llm_modified": 1, "规则判断": 2}
  1158. df_out["_source_rank"] = df_out["source"].map(source_priority).fillna(9)
  1159. df_out = df_out.sort_values("_source_rank").drop_duplicates(subset=["ad_id"], keep="first")
  1160. df_out = df_out.drop(columns=["_source_rank"])
  1161. logger.info(f"去重后决策数: {len(df_out)}(智能判断优先)")
  1162. # ===== 关键修复:合并 metrics CSV 中的字段 =====
  1163. # 从 metrics CSV 补充 ad_name, ad_age_days, cost_7d_avg, 动态ROI 等字段
  1164. try:
  1165. df_metrics_full = pd.read_csv(metrics_csv)
  1166. df_metrics_full["ad_id"] = pd.to_numeric(df_metrics_full["ad_id"], errors="coerce").astype("Int64")
  1167. # 选择需要合并的列(OUTPUT_COLUMNS中定义的所有列)
  1168. merge_cols = [
  1169. "ad_id", "account_id", "agent_name", "ad_name", "audience_tier", "create_time", "ad_age_days",
  1170. "bid_amount", "yesterday_cost", "yesterday_revenue", "yesterday_roi",
  1171. "cost_7d_total", "cost_7d_avg", "revenue_7d_total",
  1172. "动态ROI", "动态ROI_7日均值", "cost_30d_total", "cost_30d_avg",
  1173. "stable_spend_days_30d", "creative_count", "roi_valid_days",
  1174. # 飞书审批表当日裂变指标(取有数据的最近一天)
  1175. "open_count_latest", "fission_count_latest", "T0裂变系数_latest",
  1176. ]
  1177. # 只保留存在的列
  1178. merge_cols = [c for c in merge_cols if c in df_metrics_full.columns]
  1179. df_metrics_merge = df_metrics_full[merge_cols]
  1180. # 左连接:保留df_out的所有行,补充字段
  1181. df_out = df_out.merge(df_metrics_merge, on="ad_id", how="left", suffixes=("", "_metrics"))
  1182. logger.info(f"已从 metrics CSV 合并 {len(merge_cols)} 个字段")
  1183. except Exception as e:
  1184. logger.warning(f"合并 metrics 字段失败(决策CSV将缺少扩展字段): {e}")
  1185. # 过滤:已暂停(SUSPEND)或腾讯侧已删除(is_deleted=True,由 sync_ad_status.py 写入)
  1186. # 优先读 now()-1d 的 ad_status(和 sync_ad_status.py、im_approval 保持一致,
  1187. # 因为 is_deleted 是"当前 API 状态快照",不绑定 end_date;且 sync 默认同步 T-1)
  1188. # 若 T-1 CSV 不存在,降级到 end_date 当天的 CSV 作为兜底。
  1189. sync_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
  1190. ad_status_path = _MINI_DIR / "outputs" / "ad_status" / f"ad_status_{sync_date}.csv"
  1191. if not ad_status_path.exists():
  1192. fallback = _MINI_DIR / "outputs" / "ad_status" / f"ad_status_{end_date}.csv"
  1193. if fallback.exists():
  1194. logger.info(f"ad_status T-1 CSV 不存在,降级使用 end_date={end_date} 的快照")
  1195. ad_status_path = fallback
  1196. if ad_status_path.exists():
  1197. try:
  1198. df_status = pd.read_csv(ad_status_path)
  1199. # 1) SUSPEND
  1200. suspended_mask = df_status["ad_status"] == "AD_STATUS_SUSPEND"
  1201. # 2) 腾讯侧已删除(sync_ad_status.py 每日同步回写,向后兼容:列缺失即视为全 False)
  1202. if "is_deleted" in df_status.columns:
  1203. deleted_mask = df_status["is_deleted"].fillna(False).astype(bool)
  1204. else:
  1205. deleted_mask = pd.Series(False, index=df_status.index)
  1206. excluded_ads = set(
  1207. df_status[suspended_mask | deleted_mask]["ad_id"].tolist()
  1208. )
  1209. before_count = len(df_out)
  1210. df_out = df_out[~df_out["ad_id"].isin(excluded_ads)]
  1211. filtered_count = before_count - len(df_out)
  1212. if filtered_count > 0:
  1213. n_suspend = int(suspended_mask.sum())
  1214. n_deleted = int(deleted_mask.sum())
  1215. logger.info(
  1216. f"过滤掉 {filtered_count} 个广告"
  1217. f"(暂停 {n_suspend} + 腾讯侧已删除 {n_deleted})"
  1218. )
  1219. except Exception as e:
  1220. logger.warning(f"加载广告状态数据失败,跳过过滤: {e}")
  1221. # 确保必要列存在
  1222. for col in ["ad_id", "action", "dimension", "reason", "confidence", "source"]:
  1223. if col not in df_out.columns:
  1224. df_out[col] = ""
  1225. # 数值列用 None 而非空字符串,避免 float("") 异常
  1226. for col in ["recommended_change_pct", "current_bid", "recommended_bid", "cost_7d_avg"]:
  1227. if col not in df_out.columns:
  1228. df_out[col] = None
  1229. # 按7日均消耗降序排列(消耗高的广告排在前面,更需要关注)
  1230. if "cost_7d_avg" in df_out.columns:
  1231. df_out["cost_7d_avg"] = pd.to_numeric(df_out["cost_7d_avg"], errors="coerce").fillna(0)
  1232. df_out = df_out.sort_values("cost_7d_avg", ascending=False).reset_index(drop=True)
  1233. # ⚠️ 不再删除 cost_7d_avg,保留所有字段到最终报告
  1234. # 保存
  1235. reports_dir = _MINI_DIR / "outputs" / "reports"
  1236. reports_dir.mkdir(parents=True, exist_ok=True)
  1237. out_path = reports_dir / f"llm_decisions_{end_date}.csv"
  1238. df_out.to_csv(out_path, index=False, encoding="utf-8-sig")
  1239. pause_count = (df_out["action"] == "pause").sum()
  1240. hold_count = (df_out["action"] == "hold").sum()
  1241. bid_up_count = (df_out["action"] == "bid_up").sum()
  1242. bid_down_count = (df_out["action"] == "bid_down").sum()
  1243. output_parts = [
  1244. f"智能引擎决策已保存: {out_path}",
  1245. f" 关停: {pause_count} 个(含零消耗待关停: {len(zero_spend_rows)} 个)",
  1246. f" 保持: {hold_count} 个(含正常运行: {len(normal_running_rows)} 个)",
  1247. ]
  1248. if bid_up_count > 0:
  1249. output_parts.append(f" 提价: {bid_up_count} 个")
  1250. if bid_down_count > 0:
  1251. output_parts.append(f" 降价: {bid_down_count} 个")
  1252. return ToolResult(
  1253. title=f"智能引擎决策已保存({len(df_out)}条)",
  1254. output="\n".join(output_parts),
  1255. metadata={
  1256. "csv_path": str(out_path),
  1257. "total": len(df_out),
  1258. "pause": int(pause_count),
  1259. "hold": int(hold_count),
  1260. "bid_up": int(bid_up_count),
  1261. "bid_down": int(bid_down_count),
  1262. "zero_spend_ads": len(zero_spend_rows),
  1263. "normal_running_ads": len(normal_running_rows),
  1264. "end_date": end_date,
  1265. },
  1266. )
  1267. except Exception as e:
  1268. logger.error("apply_decisions 失败: %s", e, exc_info=True)
  1269. return ToolResult(title="apply_decisions 失败", output=str(e))
  1270. # ═══════════════════════════════════════════
  1271. # 智能引擎工具 3:查询单个广告详情(Mode 2 支撑)
  1272. # ═══════════════════════════════════════════
  1273. @tool(description="查询单个广告的当前指标和历史数据")
  1274. async def query_ad_detail(
  1275. ctx: ToolContext = None,
  1276. ad_id: str = "",
  1277. metrics_csv: str = "",
  1278. ) -> ToolResult:
  1279. """
  1280. 查询单个广告的当前指标 + 全局分布上下文(Mode 2 定向操作用)。
  1281. Args:
  1282. ctx: 工具上下文
  1283. ad_id: 广告 ID(字符串或数字均可)
  1284. metrics_csv: ROI 指标 CSV 路径(默认 outputs/metrics_temp.csv)
  1285. Returns:
  1286. ToolResult,包含该广告的详细指标和全局上下文
  1287. """
  1288. import json
  1289. import os
  1290. try:
  1291. if not metrics_csv:
  1292. metrics_csv = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
  1293. metrics_path = Path(metrics_csv)
  1294. if not metrics_path.exists():
  1295. return ToolResult(
  1296. title="query_ad_detail 失败",
  1297. output=f"指标文件不存在: {metrics_csv},请先执行 calculate_roi_metrics",
  1298. )
  1299. # 检查数据新鲜度
  1300. file_mtime = os.path.getmtime(metrics_path)
  1301. age_hours = (datetime.now().timestamp() - file_mtime) / 3600
  1302. freshness_warning = ""
  1303. if age_hours > 24:
  1304. freshness_warning = f"⚠️ 数据已过期({age_hours:.1f}小时前更新),建议先执行 fetch_creative_data + calculate_roi_metrics 刷新数据。\n\n"
  1305. df = pd.read_csv(metrics_csv)
  1306. # 查找目标广告
  1307. ad_id_int = int(ad_id)
  1308. ad_row = df[df["ad_id"] == ad_id_int]
  1309. if ad_row.empty:
  1310. return ToolResult(
  1311. title="query_ad_detail",
  1312. output=f"{freshness_warning}未找到广告 {ad_id},共有 {len(df)} 个广告",
  1313. )
  1314. row = ad_row.iloc[0]
  1315. # 计算广告年龄
  1316. ad_age_days = _calculate_ad_age_days(row.get("create_time"))
  1317. # 全局 ROI 分布(使用中位数作为基准,避免被少数高ROI广告拉高)
  1318. roi_series = df["动态ROI_7日均值"].dropna()
  1319. channel_roi_p50 = float(roi_series.median()) if len(roi_series) > 0 else 0.0
  1320. roi_low_line = channel_roi_p50 * ROI_LOW_FACTOR
  1321. bid_down_line = channel_roi_p50 * BID_DOWN_ROI_FACTOR
  1322. bid_up_line = channel_roi_p50 * BID_UP_ROI_FACTOR
  1323. # 构建广告详情
  1324. dynamic_roi_7d = row.get("动态ROI_7日均值")
  1325. ad_detail = {
  1326. "ad_id": ad_id_int,
  1327. "ad_name": str(row.get("ad_name", "")),
  1328. "bid_amount": round(float(row.get("bid_amount", 0) or 0), 2),
  1329. "动态ROI_7日均值": round(float(dynamic_roi_7d), 4) if not pd.isna(dynamic_roi_7d) else None,
  1330. "cost_7d_avg": round(float(row.get("cost_7d_avg", 0) or 0), 2),
  1331. "cost_7d_total": round(float(row.get("cost_7d_total", 0) or 0), 2),
  1332. "ad_age_days": ad_age_days,
  1333. "configured_status": str(row.get("configured_status", "")),
  1334. }
  1335. # 检测干预信号
  1336. try:
  1337. end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
  1338. raw_dir = _MINI_DIR / "outputs" / "raw"
  1339. ad_status_dir = _MINI_DIR / "outputs" / "ad_status"
  1340. decay_signals = _detect_decay_signals(
  1341. ad_ids=[ad_id_int],
  1342. raw_dir=raw_dir,
  1343. ad_status_dir=ad_status_dir,
  1344. end_date=end_date,
  1345. )
  1346. if not decay_signals.empty:
  1347. ds_row = decay_signals.iloc[0]
  1348. ad_detail["bid_increased_7d"] = bool(ds_row.get("bid_increased_7d", False))
  1349. ad_detail["creative_changed_7d"] = bool(ds_row.get("creative_changed_7d", False))
  1350. except Exception as e:
  1351. logger.warning("检测干预信号失败: %s", e)
  1352. # 全局上下文
  1353. global_context = {
  1354. "全体动态ROI基准(中位数)": round(channel_roi_p50, 4),
  1355. "ROI关停线": round(roi_low_line, 4),
  1356. "ROI降价线": round(bid_down_line, 4),
  1357. "ROI提价线": round(bid_up_line, 4),
  1358. "提价消耗上限": BID_UP_MAX_SPEND,
  1359. "关停消耗门槛(昨日)": ROI_LOW_MIN_YESTERDAY_COST,
  1360. }
  1361. result = {
  1362. "ad_detail": ad_detail,
  1363. "global_context": global_context,
  1364. }
  1365. output = freshness_warning + json.dumps(result, ensure_ascii=False, indent=2)
  1366. return ToolResult(
  1367. title=f"广告 {ad_id} 详情",
  1368. output=output,
  1369. metadata=result,
  1370. )
  1371. except Exception as e:
  1372. logger.error("query_ad_detail 失败: %s", e, exc_info=True)
  1373. return ToolResult(title="query_ad_detail 失败", output=str(e))
  1374. # ═══════════════════════════════════════════
  1375. # 智能引擎工具 4:修改已有决策(Mode 3 支撑)
  1376. # ═══════════════════════════════════════════
  1377. @tool(description="修改已有决策:修改指定广告的操作或调幅,也可新增决策")
  1378. async def modify_decisions(
  1379. ctx: ToolContext = None,
  1380. modifications: str = "",
  1381. decisions_csv: str = "",
  1382. end_date: str = "yesterday",
  1383. ) -> ToolResult:
  1384. """
  1385. 修改已有 llm_decisions_{date}.csv 中的决策(Mode 3 反馈修改用)。
  1386. 支持两种修改方式:
  1387. 1. 按 ad_id 精确修改/新增(upsert):
  1388. [{"ad_id": "90289631207", "new_action": "bid_down", "new_change_pct": -0.05}]
  1389. 2. 按过滤器批量修改:
  1390. [{"filter": "all_bid_down", "new_change_pct": -0.03}]
  1391. 支持: all_pause / all_bid_down / all_bid_up / all_llm
  1392. Args:
  1393. ctx: 工具上下文
  1394. modifications: JSON 字符串,修改列表
  1395. decisions_csv: 决策 CSV 路径(默认自动查找最新)
  1396. end_date: 结束日期(用于查找默认 CSV)
  1397. Returns:
  1398. ToolResult,包含修改日志和新的 action 分布
  1399. """
  1400. import json
  1401. import glob as glob_mod
  1402. try:
  1403. if end_date == "yesterday":
  1404. end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
  1405. # 解析修改列表
  1406. try:
  1407. mod_list = json.loads(modifications)
  1408. except json.JSONDecodeError as e:
  1409. return ToolResult(title="modify_decisions 失败", output=f"modifications 不是合法 JSON: {e}")
  1410. if not isinstance(mod_list, list):
  1411. return ToolResult(title="modify_decisions 失败", output="modifications 必须是 JSON 数组")
  1412. # 定位决策 CSV
  1413. if not decisions_csv:
  1414. reports_dir = _MINI_DIR / "outputs" / "reports"
  1415. # 先找当天的,再找最新的
  1416. target_path = reports_dir / f"llm_decisions_{end_date}.csv"
  1417. if target_path.exists():
  1418. decisions_csv = str(target_path)
  1419. else:
  1420. # 查找最新的 llm_decisions_*.csv
  1421. pattern = str(reports_dir / "llm_decisions_*.csv")
  1422. files = sorted(glob_mod.glob(pattern), reverse=True)
  1423. if files:
  1424. decisions_csv = files[0]
  1425. else:
  1426. return ToolResult(
  1427. title="modify_decisions 失败",
  1428. output="未找到任何已有决策文件(llm_decisions_*.csv),请先执行全量分析",
  1429. )
  1430. decisions_path = Path(decisions_csv)
  1431. if not decisions_path.exists():
  1432. return ToolResult(title="modify_decisions 失败", output=f"决策文件不存在: {decisions_csv}")
  1433. df = pd.read_csv(decisions_csv)
  1434. if df.empty:
  1435. return ToolResult(title="modify_decisions 失败", output="决策文件为空")
  1436. # 加载 metrics 获取 bid_amount
  1437. metrics_csv_path = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
  1438. bid_map = {}
  1439. try:
  1440. df_metrics = pd.read_csv(metrics_csv_path)
  1441. bid_map = dict(zip(df_metrics["ad_id"].astype(int), df_metrics["bid_amount"].fillna(0)))
  1442. except Exception as e:
  1443. logger.warning("加载 metrics 获取 bid_amount 失败: %s", e)
  1444. change_log = []
  1445. new_rows = []
  1446. for mod in mod_list:
  1447. if "filter" in mod:
  1448. # 批量修改
  1449. filter_type = mod["filter"]
  1450. filter_map = {
  1451. "all_pause": "pause",
  1452. "all_bid_down": "bid_down",
  1453. "all_bid_up": "bid_up",
  1454. "all_llm": None, # 所有 LLM 决策
  1455. }
  1456. if filter_type not in filter_map:
  1457. change_log.append(f"⚠️ 未知 filter: {filter_type},跳过")
  1458. continue
  1459. target_action = filter_map[filter_type]
  1460. if target_action:
  1461. mask = df["action"] == target_action
  1462. else:
  1463. mask = df["source"] == "llm"
  1464. matched = mask.sum()
  1465. if matched == 0:
  1466. change_log.append(f"filter={filter_type}: 无匹配行")
  1467. continue
  1468. # 应用修改
  1469. if "new_action" in mod:
  1470. df.loc[mask, "action"] = mod["new_action"]
  1471. if "new_change_pct" in mod:
  1472. df.loc[mask, "recommended_change_pct"] = mod["new_change_pct"]
  1473. # 重算 recommended_bid
  1474. for idx in df[mask].index:
  1475. ad_id_val = int(df.at[idx, "ad_id"])
  1476. bid = bid_map.get(ad_id_val, 0)
  1477. if bid > 0:
  1478. new_bid = round(bid * (1 + mod["new_change_pct"]), 2)
  1479. new_bid = max(new_bid, BID_FLOOR_YUAN)
  1480. new_bid = min(new_bid, BID_CEILING_YUAN)
  1481. df.at[idx, "recommended_bid"] = new_bid
  1482. df.at[idx, "current_bid"] = round(bid, 2)
  1483. if "new_dimension" in mod:
  1484. df.loc[mask, "dimension"] = mod["new_dimension"]
  1485. if "new_reason" in mod:
  1486. df.loc[mask, "reason"] = mod["new_reason"]
  1487. df.loc[mask, "source"] = "llm_modified"
  1488. change_log.append(f"filter={filter_type}: 修改 {matched} 行")
  1489. elif "ad_id" in mod:
  1490. # 精确修改/新增(upsert)
  1491. target_id = int(mod["ad_id"])
  1492. mask = df["ad_id"] == target_id
  1493. if mask.any():
  1494. # 修改已有行
  1495. if "new_action" in mod:
  1496. old_action = df.loc[mask, "action"].iloc[0]
  1497. df.loc[mask, "action"] = mod["new_action"]
  1498. change_log.append(f"ad_id={target_id}: action {old_action} → {mod['new_action']}")
  1499. if "new_change_pct" in mod:
  1500. df.loc[mask, "recommended_change_pct"] = mod["new_change_pct"]
  1501. bid = bid_map.get(target_id, 0)
  1502. if bid > 0:
  1503. new_bid = round(bid * (1 + mod["new_change_pct"]), 2)
  1504. new_bid = max(new_bid, BID_FLOOR_YUAN)
  1505. new_bid = min(new_bid, BID_CEILING_YUAN)
  1506. df.loc[mask, "recommended_bid"] = new_bid
  1507. df.loc[mask, "current_bid"] = round(bid, 2)
  1508. change_log.append(f"ad_id={target_id}: change_pct → {mod['new_change_pct']}")
  1509. if "new_dimension" in mod:
  1510. df.loc[mask, "dimension"] = mod["new_dimension"]
  1511. if "new_reason" in mod:
  1512. df.loc[mask, "reason"] = mod["new_reason"]
  1513. df.loc[mask, "source"] = "llm_modified"
  1514. else:
  1515. # 新增行
  1516. new_action = mod.get("new_action", "hold")
  1517. change_pct = mod.get("new_change_pct")
  1518. bid = bid_map.get(target_id, 0)
  1519. new_bid = None
  1520. if change_pct is not None and bid > 0:
  1521. new_bid = round(bid * (1 + change_pct), 2)
  1522. new_bid = max(new_bid, BID_FLOOR_YUAN)
  1523. new_bid = min(new_bid, BID_CEILING_YUAN)
  1524. new_row = {
  1525. "ad_id": target_id,
  1526. "action": new_action,
  1527. "dimension": mod.get("new_dimension", "用户指定"),
  1528. "reason": mod.get("new_reason", "用户定向操作"),
  1529. "confidence": mod.get("confidence", "high"),
  1530. "source": "llm_modified",
  1531. "recommended_change_pct": change_pct,
  1532. "current_bid": round(bid, 2) if bid > 0 else None,
  1533. "recommended_bid": new_bid,
  1534. }
  1535. new_rows.append(new_row)
  1536. change_log.append(f"ad_id={target_id}: 新增 action={new_action}")
  1537. else:
  1538. change_log.append(f"⚠️ 修改项缺少 ad_id 或 filter,跳过: {mod}")
  1539. # 合并新增行
  1540. if new_rows:
  1541. df = pd.concat([df, pd.DataFrame(new_rows)], ignore_index=True)
  1542. # 保存(覆盖原文件)
  1543. df.to_csv(decisions_csv, index=False, encoding="utf-8-sig")
  1544. # 统计新的 action 分布
  1545. action_dist = df["action"].value_counts().to_dict()
  1546. output_parts = [
  1547. f"决策已修改并保存: {decisions_csv}",
  1548. "",
  1549. "修改日志:",
  1550. ]
  1551. for log in change_log:
  1552. output_parts.append(f" {log}")
  1553. output_parts.extend([
  1554. "",
  1555. "当前 action 分布:",
  1556. ])
  1557. for action, count in action_dist.items():
  1558. output_parts.append(f" {action}: {count} 个")
  1559. output_parts.append(f" 总计: {len(df)} 个")
  1560. return ToolResult(
  1561. title=f"决策修改完成({len(change_log)}项变更)",
  1562. output="\n".join(output_parts),
  1563. metadata={
  1564. "csv_path": str(decisions_csv),
  1565. "changes": len(change_log),
  1566. "action_distribution": action_dist,
  1567. "total": len(df),
  1568. },
  1569. )
  1570. except Exception as e:
  1571. logger.error("modify_decisions 失败: %s", e, exc_info=True)
  1572. return ToolResult(title="modify_decisions 失败", output=str(e))