ad_decision.py 71 KB

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