budget_calc.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  1. """
  2. 预算计算引擎 — 出价调整与账户评估
  3. 改造后的三层架构:
  4. - 数据层:get_ad_performance / get_account_summary — 获取原始数据
  5. - 计算层:compute_budget_thresholds / classify_ads / compute_bid_adjustment — 确定性计算
  6. - 执行层:bid_adjustment_execute — 调用 API 执行调整
  7. 所有策略参数(分位数、决策矩阵、幅度)从策略配置层传入,不在此处硬编码。
  8. """
  9. import json
  10. import logging
  11. from datetime import datetime, timedelta
  12. from pathlib import Path
  13. from typing import Any, Dict, List, Optional
  14. import pandas as pd
  15. from agent.tools import tool
  16. from agent.tools.models import ToolContext, ToolResult
  17. from examples.auto_put_ad.tools.ad_api import ad_update
  18. from examples.auto_put_ad.tools.data_query import _get_odps_client
  19. from examples.auto_put_ad.tools.strategy_config import (
  20. get_strategy_config,
  21. get_decision_matrix,
  22. determine_strategy,
  23. )
  24. logger = logging.getLogger(__name__)
  25. # ===== 内部辅助函数 =====
  26. def _parse_bizdate(bizdate: str) -> tuple:
  27. """解析业务日期,返回 (YYYYMMDD, YYYY-MM-DD)"""
  28. if bizdate in ("yesterday", ""):
  29. biz = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
  30. else:
  31. biz = bizdate.replace("-", "")
  32. biz_dash = f"{biz[:4]}-{biz[4:6]}-{biz[6:]}"
  33. return biz, biz_dash
  34. def _build_efficiency_sql(biz: str, biz_dash: str) -> str:
  35. """构建昨日效率数据 SQL(广告维度聚合)"""
  36. return f"""
  37. SELECT
  38. a.account_id,
  39. a.ad_id,
  40. c.ad_name,
  41. c.create_time,
  42. SUM(b.cost/100) AS cost,
  43. SUM(b.valid_click_count) AS valid_click_count,
  44. SUM(b.conversions_count) AS conversions_count,
  45. SUM(t.首层小程序打开数) AS open_count,
  46. SUM(t.裂变0层回流数) AS fission0_count,
  47. SUM(t.总回流人数) AS total_return_count
  48. FROM (
  49. SELECT
  50. IF(c.creative_name IS NOT NULL, c.creative_name, t.rootsourceid) AS creative_name,
  51. t.*
  52. FROM loghubods.touliu_data t
  53. LEFT JOIN (
  54. SELECT DISTINCT creative_name,
  55. SPLIT(GET_JSON_OBJECT(page_spec,'$.wechat_mini_program_spec.mini_program_path'),'rootSourceId%3D')[1] AS rootsourceid
  56. FROM loghubods.ad_put_tencent_creative_components a
  57. LEFT JOIN loghubods.ad_put_tencent_creative_day b ON a.creative_id = b.creative_id
  58. WHERE page_type = 'PAGE_TYPE_WECHAT_MINI_PROGRAM'
  59. ) c ON c.rootsourceid = t.rootsourceid
  60. WHERE t.dt = '{biz}'
  61. ) t
  62. LEFT JOIN loghubods.ad_put_tencent_creative_day a ON t.creative_name = a.creative_name
  63. LEFT JOIN loghubods.ad_put_tencent_ad c ON a.ad_id = c.ad_id
  64. LEFT JOIN (
  65. SELECT creative_id, valid_click_count, cost, conversions_count
  66. FROM (
  67. SELECT creative_id, valid_click_count, cost, conversions_count,
  68. ROW_NUMBER() OVER (PARTITION BY creative_id ORDER BY update_time DESC) AS rank
  69. FROM loghubods.ad_put_tencent_creative_data_day
  70. WHERE dt = '{biz_dash}'
  71. ) t WHERE rank = 1
  72. ) b ON a.creative_id = b.creative_id
  73. WHERE t.dt = '{biz}'
  74. AND a.ad_id IS NOT NULL
  75. AND b.cost IS NOT NULL
  76. AND b.cost > 0
  77. GROUP BY a.account_id, a.ad_id, c.ad_name, c.create_time
  78. """
  79. # ═════════════════════════════════════════════════════════════
  80. # 数据层 — 获取原始数据
  81. # ═════════════════════════════════════════════════════════════
  82. @tool(description="获取指定账户昨日广告效果数据,包含效率分、消耗、转化数、冷启动信息等")
  83. async def get_ad_performance(
  84. account_id: int = 0,
  85. bizdate: str = "yesterday",
  86. context: Optional[ToolContext] = None,
  87. ) -> ToolResult:
  88. """
  89. 拉取昨日广告效果数据(广告维度聚合)。
  90. Args:
  91. account_id: 账户ID(传 0 不过滤账户,拉全量)
  92. bizdate: 业务日期,"yesterday" 或 YYYYMMDD 格式
  93. Returns:
  94. 结构化广告列表,每条含: ad_id, account_id, cost, efficiency,
  95. open_count, conversions_count, create_time, bid_amount, ad_status
  96. """
  97. try:
  98. client = _get_odps_client()
  99. if client is None:
  100. return ToolResult(title="get_ad_performance 失败", output="ODPS 客户端未初始化")
  101. biz, biz_dash = _parse_bizdate(bizdate)
  102. # 拉取效率数据
  103. sql_efficiency = _build_efficiency_sql(biz, biz_dash)
  104. df_eff = client.execute_sql(sql_efficiency)
  105. if df_eff.empty:
  106. return ToolResult(title="get_ad_performance", output=f"昨日({biz})无效率数据")
  107. # 按账户过滤
  108. if account_id > 0:
  109. df_eff = df_eff[df_eff["account_id"].astype(float).astype("Int64") == account_id]
  110. if df_eff.empty:
  111. return ToolResult(title="get_ad_performance", output=f"账户 {account_id} 昨日({biz})无数据")
  112. # 拉取当前出价/状态
  113. ad_ids = [int(x) for x in df_eff["ad_id"].dropna().unique() if str(x) != "nan"]
  114. ad_ids_str = ",".join(map(str, ad_ids))
  115. sql_status = f"""
  116. SELECT ad_id, ad_name, account_id, bid_amount, day_amount, ad_status, optimization_goal
  117. FROM loghubods.ad_put_tencent_ad
  118. WHERE ad_id IN ({ad_ids_str})
  119. """
  120. df_status = client.execute_sql(sql_status)
  121. # 合并
  122. df_eff["ad_id"] = df_eff["ad_id"].astype(float).astype("Int64")
  123. df_status["ad_id"] = df_status["ad_id"].astype(float).astype("Int64")
  124. df = pd.merge(
  125. df_eff,
  126. df_status[["ad_id", "bid_amount", "day_amount", "ad_status", "optimization_goal"]],
  127. on="ad_id", how="left",
  128. )
  129. # 计算效率分
  130. df["efficiency"] = df.apply(
  131. lambda r: r["fission0_count"] / r["cost"]
  132. if r["cost"] and r["cost"] > 0 and r["fission0_count"] is not None and pd.notna(r["fission0_count"])
  133. else None,
  134. axis=1,
  135. )
  136. total_cost = float(df["cost"].sum())
  137. avg_eff = float(df["efficiency"].mean()) if not df["efficiency"].isna().all() else 0
  138. lines = [
  139. f"广告效果数据({biz},{len(df)}条广告)",
  140. f"总消耗: {total_cost:,.0f}元,平均效率分: {avg_eff:.4f}",
  141. "",
  142. f"{'广告ID':<12} {'账户ID':<12} {'消耗(元)':>10} {'效率分':>8} {'转化':>5} {'出价(分)':>8} {'状态':<10}",
  143. "-" * 70,
  144. ]
  145. for _, row in df.head(20).iterrows():
  146. eff_str = f"{row['efficiency']:.4f}" if pd.notna(row["efficiency"]) else "-"
  147. bid_str = str(int(float(row["bid_amount"]))) if pd.notna(row.get("bid_amount")) else "-"
  148. conv = int(row["conversions_count"]) if pd.notna(row.get("conversions_count")) else 0
  149. lines.append(
  150. f"{int(row['ad_id']):<12} {int(row['account_id']):<12} "
  151. f"{row['cost']:>10,.0f} {eff_str:>8} {conv:>5} {bid_str:>8} "
  152. f"{str(row.get('ad_status', '')):>10}"
  153. )
  154. if len(df) > 20:
  155. lines.append(f"... 还有 {len(df) - 20} 条")
  156. return ToolResult(
  157. title=f"广告效果数据({len(df)}条,总消耗{total_cost:,.0f}元)",
  158. output="\n".join(lines),
  159. metadata={
  160. "ad_data": df.to_dict("records"),
  161. "total_cost": total_cost,
  162. "avg_efficiency": avg_eff,
  163. "ad_count": len(df),
  164. "bizdate": biz,
  165. },
  166. )
  167. except Exception as e:
  168. logger.error("get_ad_performance 失败: %s", e, exc_info=True)
  169. return ToolResult(title="get_ad_performance 失败", output=str(e))
  170. @tool(description="获取账户维度昨日汇总数据,包含消耗、效率、广告数、稳定性标签")
  171. async def get_account_summary(
  172. bizdate: str = "yesterday",
  173. context: Optional[ToolContext] = None,
  174. ) -> ToolResult:
  175. """
  176. 账户级汇总:查询各账户昨日汇总数据,按消耗量判断稳定性。
  177. Args:
  178. bizdate: 数据日期,"yesterday" 或 YYYYMMDD 格式
  179. """
  180. try:
  181. client = _get_odps_client()
  182. if client is None:
  183. return ToolResult(title="get_account_summary 失败", output="ODPS 客户端未初始化")
  184. biz, biz_dash = _parse_bizdate(bizdate)
  185. inner_sql = _build_efficiency_sql(biz, biz_dash)
  186. sql = f"""
  187. SELECT
  188. account_id,
  189. COUNT(DISTINCT ad_id) AS ad_count,
  190. SUM(cost) AS total_cost,
  191. SUM(open_count) AS total_open,
  192. SUM(fission0_count) AS total_fission0
  193. FROM ({inner_sql}) t
  194. GROUP BY account_id
  195. """
  196. df = client.execute_sql(sql)
  197. if df.empty:
  198. return ToolResult(title="get_account_summary", output=f"昨日({biz})无账户数据")
  199. df["avg_efficiency"] = df.apply(
  200. lambda r: r["total_fission0"] / r["total_cost"] if r["total_cost"] and r["total_cost"] > 0 else 0,
  201. axis=1,
  202. )
  203. median_cost = df["total_cost"].median()
  204. p30_cost = df["total_cost"].quantile(0.30)
  205. def label_stability(cost):
  206. if cost >= median_cost:
  207. return "稳定"
  208. elif cost >= p30_cost:
  209. return "一般"
  210. else:
  211. return "低量"
  212. df["stability"] = df["total_cost"].apply(label_stability)
  213. df = df.sort_values("total_cost", ascending=False).reset_index(drop=True)
  214. lines = [
  215. f"账户汇总({biz},共 {len(df)} 个账户)",
  216. f"消耗中位数: {median_cost:,.0f}元",
  217. "",
  218. f"{'账户ID':<15} {'广告数':>6} {'昨日消耗(元)':>12} {'效率分均值':>10} {'稳定性':>6}",
  219. "-" * 55,
  220. ]
  221. for _, row in df.iterrows():
  222. lines.append(
  223. f"{int(row['account_id']):<15} {int(row['ad_count']):>6} "
  224. f"{row['total_cost']:>12,.0f} {row['avg_efficiency']:>10.4f} "
  225. f"{row['stability']:>6}"
  226. )
  227. stable_high_eff = df[(df["stability"] == "稳定") & (df["avg_efficiency"] > df["avg_efficiency"].median())]
  228. if not stable_high_eff.empty:
  229. lines += ["", "扩量建议账户(稳定 + 效率分高于中位数):"]
  230. for _, row in stable_high_eff.iterrows():
  231. lines.append(f" 账户 {int(row['account_id'])}(消耗 {row['total_cost']:,.0f}元,效率分 {row['avg_efficiency']:.4f})")
  232. return ToolResult(
  233. title=f"账户汇总({len(df)}个账户)",
  234. output="\n".join(lines),
  235. metadata={
  236. "accounts": df.to_dict("records"),
  237. "median_cost": median_cost,
  238. "bizdate": biz,
  239. },
  240. )
  241. except Exception as e:
  242. logger.error("get_account_summary 失败: %s", e, exc_info=True)
  243. return ToolResult(title="get_account_summary 失败", output=str(e))
  244. # ═════════════════════════════════════════════════════════════
  245. # 计算层 — 确定性计算,策略参数从外部传入
  246. # ═════════════════════════════════════════════════════════════
  247. @tool(description="计算分位数阈值(ROI高/低 + 消耗中位数),分位数参数可自定义")
  248. async def compute_budget_thresholds(
  249. ad_data_json: str,
  250. roi_high_percentile: float = 0.70,
  251. roi_low_percentile: float = 0.30,
  252. cost_mid_percentile: float = 0.50,
  253. min_open_count: int = 100,
  254. context: Optional[ToolContext] = None,
  255. ) -> ToolResult:
  256. """
  257. 基于广告效果数据计算分位数阈值。
  258. Args:
  259. ad_data_json: 广告效果数据 JSON(来自 get_ad_performance 的 metadata.ad_data)
  260. roi_high_percentile: ROI 高阈值分位数(默认 P70,可调)
  261. roi_low_percentile: ROI 低阈值分位数(默认 P30,可调)
  262. cost_mid_percentile: 消耗中位数分位数(默认 P50,可调)
  263. min_open_count: 有效广告最低打开数(默认 100)
  264. """
  265. try:
  266. ad_data = json.loads(ad_data_json) if isinstance(ad_data_json, str) else ad_data_json
  267. df = pd.DataFrame(ad_data)
  268. # 筛选有效广告
  269. df_valid = df[df["open_count"] >= min_open_count].copy()
  270. df_nosample = df[df["open_count"] < min_open_count]
  271. if df_valid.empty:
  272. return ToolResult(
  273. title="compute_budget_thresholds",
  274. output=f"无有效广告(open_count >= {min_open_count})",
  275. )
  276. # 确保 efficiency 列有效
  277. df_valid["efficiency"] = pd.to_numeric(df_valid["efficiency"], errors="coerce")
  278. df_valid = df_valid.dropna(subset=["efficiency"])
  279. thresholds = {
  280. "roi_high": float(df_valid["efficiency"].quantile(roi_high_percentile)),
  281. "roi_low": float(df_valid["efficiency"].quantile(roi_low_percentile)),
  282. "cost_mid": float(df_valid["cost"].quantile(cost_mid_percentile)),
  283. }
  284. output = (
  285. f"阈值计算结果(有效广告 {len(df_valid)} 条,样本不足 {len(df_nosample)} 条):\n"
  286. f" ROI 高阈值 (P{int(roi_high_percentile*100)}): {thresholds['roi_high']:.4f}\n"
  287. f" ROI 低阈值 (P{int(roi_low_percentile*100)}): {thresholds['roi_low']:.4f}\n"
  288. f" 消耗中位数 (P{int(cost_mid_percentile*100)}): {thresholds['cost_mid']:.0f}元\n"
  289. f"\n分位数参数: roi_high=P{int(roi_high_percentile*100)}, "
  290. f"roi_low=P{int(roi_low_percentile*100)}, "
  291. f"cost_mid=P{int(cost_mid_percentile*100)}"
  292. )
  293. return ToolResult(
  294. title=f"阈值计算({len(df_valid)}条有效广告)",
  295. output=output,
  296. metadata={
  297. "thresholds": thresholds,
  298. "valid_count": len(df_valid),
  299. "nosample_count": len(df_nosample),
  300. "percentiles_used": {
  301. "roi_high": roi_high_percentile,
  302. "roi_low": roi_low_percentile,
  303. "cost_mid": cost_mid_percentile,
  304. },
  305. },
  306. )
  307. except Exception as e:
  308. logger.error("compute_budget_thresholds 失败: %s", e, exc_info=True)
  309. return ToolResult(title="compute_budget_thresholds 失败", output=str(e))
  310. @tool(description="将广告按 ROI×跑量 二维象限分类,阈值从外部传入")
  311. async def classify_ads(
  312. ad_data_json: str,
  313. thresholds_json: str,
  314. min_open_count: int = 100,
  315. context: Optional[ToolContext] = None,
  316. ) -> ToolResult:
  317. """
  318. 将每条广告分类到 ROI × 跑量 二维象限。
  319. Args:
  320. ad_data_json: 广告效果数据 JSON
  321. thresholds_json: 阈值 JSON,如 {"roi_high": 0.05, "roi_low": 0.02, "cost_mid": 500}
  322. min_open_count: 有效广告最低打开数
  323. """
  324. try:
  325. ad_data = json.loads(ad_data_json) if isinstance(ad_data_json, str) else ad_data_json
  326. thresholds = json.loads(thresholds_json) if isinstance(thresholds_json, str) else thresholds_json
  327. df = pd.DataFrame(ad_data)
  328. df_valid = df[df["open_count"] >= min_open_count].copy()
  329. df_nosample = df[df["open_count"] < min_open_count]
  330. if df_valid.empty:
  331. return ToolResult(title="classify_ads", output="无有效广告")
  332. classified = []
  333. counts = {"high_high": 0, "high_low": 0, "mid_high": 0, "mid_low": 0, "low_high": 0, "low_low": 0}
  334. for _, row in df_valid.iterrows():
  335. eff = float(row["efficiency"]) if pd.notna(row.get("efficiency")) else 0.0
  336. cost = float(row["cost"])
  337. if eff >= thresholds["roi_high"]:
  338. roi_level = "high"
  339. elif eff >= thresholds["roi_low"]:
  340. roi_level = "mid"
  341. else:
  342. roi_level = "low"
  343. volume_level = "high" if cost >= thresholds["cost_mid"] else "low"
  344. quadrant = f"{roi_level}_{volume_level}"
  345. counts[quadrant] = counts.get(quadrant, 0) + 1
  346. item = row.to_dict()
  347. item["roi_level"] = roi_level
  348. item["volume_level"] = volume_level
  349. item["quadrant"] = quadrant
  350. classified.append(item)
  351. lines = [
  352. f"广告分类结果({len(classified)}条有效,{len(df_nosample)}条样本不足)",
  353. f"阈值: ROI高={thresholds['roi_high']:.4f}, ROI低={thresholds['roi_low']:.4f}, 消耗中位={thresholds['cost_mid']:.0f}元",
  354. "",
  355. "分布:",
  356. ]
  357. for q, c in counts.items():
  358. roi, vol = q.split("_")
  359. lines.append(f" {roi}ROI + {vol}跑量: {c}条")
  360. return ToolResult(
  361. title=f"广告分类({len(classified)}条)",
  362. output="\n".join(lines),
  363. metadata={
  364. "classified_ads": classified,
  365. "nosample_ads": df_nosample.to_dict("records"),
  366. "distribution": counts,
  367. "thresholds_used": thresholds,
  368. },
  369. )
  370. except Exception as e:
  371. logger.error("classify_ads 失败: %s", e, exc_info=True)
  372. return ToolResult(title="classify_ads 失败", output=str(e))
  373. @tool(description="计算出价调整方案,策略参数(决策矩阵、幅度、保护规则)全部从外部传入")
  374. async def compute_bid_adjustment(
  375. classified_ads_json: str,
  376. strategy: str,
  377. decision_matrix_json: str = "",
  378. max_increase_pct: float = 0.15,
  379. max_decrease_pct: float = -0.15,
  380. protect_cold_start: bool = True,
  381. cold_start_hours: int = 48,
  382. cold_start_min_conversions: int = 6,
  383. protect_compensation: bool = True,
  384. compensation_min_conversions: int = 6,
  385. min_bid: int = 10,
  386. max_bid: int = 10000,
  387. context: Optional[ToolContext] = None,
  388. ) -> ToolResult:
  389. """
  390. 根据分类结果和策略参数,计算每条广告的出价调整方案。
  391. Args:
  392. classified_ads_json: 分类后的广告数据 JSON(来自 classify_ads)
  393. strategy: 策略名称(aggressive_scale_down / moderate_scale_down / maintain / moderate_scale_up / aggressive_scale_up)
  394. decision_matrix_json: 决策矩阵 JSON。为空则从配置层加载。
  395. 格式: {"high_high": ["keep", 0.0], "mid_high": ["decrease", -0.05], ...}
  396. max_increase_pct: 最大提价幅度(默认 0.15 = +15%)
  397. max_decrease_pct: 最大降价幅度(默认 -0.15 = -15%)
  398. protect_cold_start: 是否启用冷启动保护
  399. cold_start_hours: 冷启动期小时数
  400. cold_start_min_conversions: 冷启动最少转化数
  401. protect_compensation: 是否启用赔付保护
  402. compensation_min_conversions: 赔付门槛转化数
  403. min_bid: 最低出价(分)
  404. max_bid: 最高出价(分)
  405. """
  406. try:
  407. classified_ads = json.loads(classified_ads_json) if isinstance(classified_ads_json, str) else classified_ads_json
  408. # 加载决策矩阵
  409. if decision_matrix_json:
  410. matrix = json.loads(decision_matrix_json) if isinstance(decision_matrix_json, str) else decision_matrix_json
  411. # 转为 tuple
  412. matrix = {k: tuple(v) for k, v in matrix.items()}
  413. else:
  414. config = get_strategy_config()
  415. matrix = get_decision_matrix(config, strategy)
  416. results = []
  417. cold_start_count = 0
  418. for ad in classified_ads:
  419. conversions = int(ad.get("conversions_count", 0) or 0)
  420. quadrant = ad.get("quadrant", f"{ad.get('roi_level', 'mid')}_{ad.get('volume_level', 'low')}")
  421. # --- 冷启动保护 ---
  422. is_cold_start = False
  423. cold_start_reason = ""
  424. if protect_cold_start:
  425. create_time = ad.get("create_time")
  426. if create_time and pd.notna(create_time):
  427. try:
  428. if isinstance(create_time, str):
  429. ct = datetime.strptime(create_time[:19], "%Y-%m-%d %H:%M:%S")
  430. else:
  431. ct = pd.Timestamp(create_time).to_pydatetime()
  432. hours_since = (datetime.now() - ct).total_seconds() / 3600
  433. if hours_since < cold_start_hours:
  434. is_cold_start = True
  435. cold_start_reason = f"冷启动期({hours_since:.0f}h<{cold_start_hours}h)"
  436. except (ValueError, TypeError):
  437. pass
  438. if conversions < cold_start_min_conversions and not is_cold_start:
  439. is_cold_start = True
  440. cold_start_reason = f"转化不足({conversions}<{cold_start_min_conversions})"
  441. if is_cold_start:
  442. cold_start_count += 1
  443. action, adj_ratio = "observe", 0.0
  444. else:
  445. action, adj_ratio = matrix.get(quadrant, ("keep", 0.0))
  446. # 限制幅度范围
  447. if adj_ratio > 0:
  448. adj_ratio = min(adj_ratio, max_increase_pct)
  449. elif adj_ratio < 0:
  450. adj_ratio = max(adj_ratio, max_decrease_pct)
  451. # --- 赔付保护 ---
  452. if protect_compensation and action == "close":
  453. if 3 <= conversions < compensation_min_conversions:
  454. action = "observe"
  455. adj_ratio = 0.0
  456. cold_start_reason = f"接近赔付门槛({conversions}次转化,等待积累到{compensation_min_conversions})"
  457. # 计算新出价
  458. bid = ad.get("bid_amount")
  459. bid_val = float(bid) if bid and pd.notna(bid) else None
  460. new_bid = None
  461. if bid_val and action in ("increase", "decrease"):
  462. new_bid = max(min_bid, min(max_bid, int(bid_val * (1 + adj_ratio))))
  463. elif bid_val:
  464. new_bid = int(bid_val)
  465. results.append({
  466. "date": datetime.now().strftime("%Y-%m-%d"),
  467. "ad_id": int(ad.get("ad_id", 0)),
  468. "ad_name": str(ad.get("ad_name", "")),
  469. "account_id": int(ad.get("account_id", 0)),
  470. "roi_level": ad.get("roi_level", ""),
  471. "volume_level": ad.get("volume_level", ""),
  472. "quadrant": quadrant,
  473. "efficiency": round(float(ad.get("efficiency", 0) or 0), 4),
  474. "cost": round(float(ad.get("cost", 0)), 2),
  475. "open_count": int(ad.get("open_count", 0)),
  476. "conversions_count": conversions,
  477. "is_cold_start": is_cold_start,
  478. "cold_start_reason": cold_start_reason,
  479. "current_bid": int(bid_val) if bid_val else None,
  480. "new_bid": new_bid,
  481. "adjustment_ratio": f"{adj_ratio:+.0%}" if adj_ratio != 0 else "-",
  482. "action": action,
  483. "ad_status": str(ad.get("ad_status", "")),
  484. })
  485. # 汇总统计
  486. action_counts = {}
  487. for r in results:
  488. action_counts[r["action"]] = action_counts.get(r["action"], 0) + 1
  489. action_labels = [
  490. ("keep", "保持不动"), ("increase", "提价放量"), ("decrease", "降价控量"),
  491. ("close", "建议关停"), ("observe", "观察不动"),
  492. ]
  493. lines = [
  494. f"出价调整方案({len(results)}条广告)",
  495. f"策略: {strategy}",
  496. "",
  497. ]
  498. for act, label in action_labels:
  499. sub = [r for r in results if r["action"] == act]
  500. if not sub:
  501. continue
  502. lines.append(f"【{label}({act})- {len(sub)}个】")
  503. for item in sub[:5]:
  504. bid_info = f"出价:{item['current_bid']}→{item['new_bid']}分 {item['adjustment_ratio']}" if item["current_bid"] else "无出价"
  505. lines.append(
  506. f" {item['ad_id']} | ROI:{item['roi_level']}/量:{item['volume_level']} | "
  507. f"效率:{item['efficiency']} | 消耗:{item['cost']:.0f}元 | {bid_info}"
  508. )
  509. if len(sub) > 5:
  510. lines.append(f" ... 还有 {len(sub)-5} 个")
  511. lines.append("")
  512. if cold_start_count > 0:
  513. cold_ads = [r for r in results if r["is_cold_start"]]
  514. lines.append(f"【冷启动保护 - {cold_start_count}个】")
  515. for item in cold_ads[:5]:
  516. lines.append(f" {item['ad_id']} | {item['cold_start_reason']} | 转化:{item['conversions_count']}")
  517. if cold_start_count > 5:
  518. lines.append(f" ... 还有 {cold_start_count - 5} 个")
  519. lines.append("")
  520. summary_parts = [f"{label}:{action_counts.get(act, 0)}" for act, label in action_labels]
  521. lines.append(f"合计: {' / '.join(summary_parts)} / 冷启动保护:{cold_start_count}")
  522. # 添加 JSON 数据块供执行 Agent 使用
  523. lines.append("\n" + "=" * 60)
  524. lines.append("执行数据(JSON格式,供执行Agent使用):")
  525. lines.append("```json")
  526. lines.append(json.dumps({"adjustment_plan": results}, ensure_ascii=False, indent=2))
  527. lines.append("```")
  528. return ToolResult(
  529. title=f"出价调整方案({len(results)}条,{strategy})",
  530. output="\n".join(lines),
  531. metadata={
  532. "adjustment_plan": results,
  533. "strategy": strategy,
  534. "action_counts": action_counts,
  535. "cold_start_count": cold_start_count,
  536. "params": {
  537. "max_increase_pct": max_increase_pct,
  538. "max_decrease_pct": max_decrease_pct,
  539. "protect_cold_start": protect_cold_start,
  540. "protect_compensation": protect_compensation,
  541. },
  542. },
  543. )
  544. except Exception as e:
  545. logger.error("compute_bid_adjustment 失败: %s", e, exc_info=True)
  546. return ToolResult(title="compute_bid_adjustment 失败", output=str(e))
  547. # ═════════════════════════════════════════════════════════════
  548. # 执行层 — 调用 API 执行调整
  549. # ═════════════════════════════════════════════════════════════
  550. @tool(description="执行出价调整方案,批量调用 API 修改出价")
  551. async def bid_adjustment_execute(
  552. adjustment_plan_json: str,
  553. account_id: int,
  554. context: Optional[ToolContext] = None,
  555. ) -> ToolResult:
  556. """
  557. 批量执行出价调整。
  558. Args:
  559. adjustment_plan_json: 调整方案 JSON 字符串或列表,每项包含 ad_id, new_bid, action
  560. account_id: 账户ID
  561. """
  562. # 解析 JSON(支持字符串或已解析的列表)
  563. if isinstance(adjustment_plan_json, str):
  564. try:
  565. data = json.loads(adjustment_plan_json)
  566. # 如果是 {"adjustment_plan": [...]} 格式,提取列表
  567. if isinstance(data, dict) and "adjustment_plan" in data:
  568. adjustment_plan = data["adjustment_plan"]
  569. else:
  570. adjustment_plan = data
  571. except json.JSONDecodeError as e:
  572. return ToolResult(
  573. title="执行失败",
  574. output=f"JSON 解析失败: {e}",
  575. error=str(e),
  576. )
  577. else:
  578. adjustment_plan = adjustment_plan_json
  579. success_count = 0
  580. failed_count = 0
  581. errors = []
  582. for item in adjustment_plan:
  583. if item["action"] not in ("increase", "decrease"):
  584. continue
  585. try:
  586. await ad_update(
  587. account_id=account_id,
  588. adgroup_id=item["ad_id"],
  589. bid_amount=item["new_bid"]
  590. )
  591. success_count += 1
  592. logger.info("调整出价: ad_id=%s, new_bid=%s", item["ad_id"], item["new_bid"])
  593. except Exception as e:
  594. failed_count += 1
  595. error_msg = f"ad_id={item['ad_id']}: {str(e)}"
  596. errors.append(error_msg)
  597. logger.error("执行失败: %s", error_msg)
  598. output_lines = [
  599. "执行完成:",
  600. f"- 成功调整: {success_count} 个",
  601. f"- 失败: {failed_count} 个",
  602. ]
  603. if errors:
  604. output_lines.append("\n失败详情:")
  605. for err in errors[:10]:
  606. output_lines.append(f" {err}")
  607. if len(errors) > 10:
  608. output_lines.append(f" ... 还有 {len(errors)-10} 个错误")
  609. return ToolResult(
  610. title="出价调整执行结果",
  611. output="\n".join(output_lines),
  612. )
  613. # ═════════════════════════════════════════════════════════════
  614. # 兼容层 — 保留旧接口,内部调用新工具链
  615. # ═════════════════════════════════════════════════════════════
  616. # 保留旧名称引用,避免 config.py 中的工具白名单报错
  617. account_evaluate = get_account_summary
  618. budget_calculate_from_data = None # 已废弃,功能拆分到 get_ad_performance + compute_* 工具链