ad_decision.py 75 KB

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