|
|
@@ -0,0 +1,380 @@
|
|
|
+"""
|
|
|
+执行 Agent — 读取确认后的调整方案并执行 API 操作
|
|
|
+
|
|
|
+支持两种输入方式:
|
|
|
+1. 直接传入 adjustment_plan 列表(从分析 Agent 输出获取)
|
|
|
+2. 传入 Excel 文件路径(运营在 Excel 中确认/修改后)
|
|
|
+
|
|
|
+主要功能:
|
|
|
+- 出价调整(increase / decrease)
|
|
|
+- 广告关停(close → AD_STATUS_SUSPEND)
|
|
|
+- 冷启动保护(跳过 is_cold_start=True 的广告)
|
|
|
+- 赔付门槛保护(跳过 conversions_count 3-5 的关停)
|
|
|
+- Dry-run 模式(只验证不执行)
|
|
|
+- 执行后自动触发监控检查
|
|
|
+"""
|
|
|
+
|
|
|
+import logging
|
|
|
+from datetime import datetime
|
|
|
+from pathlib import Path
|
|
|
+from typing import Any, Dict, List, Optional
|
|
|
+
|
|
|
+from agent.tools import tool
|
|
|
+from agent.tools.models import ToolContext, ToolResult
|
|
|
+
|
|
|
+from examples.auto_put_ad.tools.ad_api import ad_batch_update_status, ad_update
|
|
|
+from examples.auto_put_ad.tools.budget_calc import MIN_BID, MAX_BID
|
|
|
+from examples.auto_put_ad.tools.monitor_tools import monitor_check_metrics
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+
|
|
|
+def _load_plan_from_excel(excel_path: str) -> List[Dict]:
|
|
|
+ """从 Excel 文件加载调整方案"""
|
|
|
+ import pandas as pd
|
|
|
+
|
|
|
+ df = pd.read_excel(excel_path)
|
|
|
+
|
|
|
+ # 映射中文列名到英文字段(兼容两种格式)
|
|
|
+ column_map = {
|
|
|
+ "广告ID": "ad_id",
|
|
|
+ "广告名称": "ad_name",
|
|
|
+ "账户ID": "account_id",
|
|
|
+ "动作": "action",
|
|
|
+ "当前出价(分)": "current_bid",
|
|
|
+ "新出价(分)": "new_bid",
|
|
|
+ "调整幅度": "adjustment_ratio",
|
|
|
+ "ROI等级": "roi_level",
|
|
|
+ "跑量等级": "volume_level",
|
|
|
+ "效率分": "efficiency",
|
|
|
+ "昨日消耗(元)": "cost",
|
|
|
+ "打开数": "open_count",
|
|
|
+ "转化数": "conversions_count",
|
|
|
+ "冷启动": "is_cold_start",
|
|
|
+ "冷启动原因": "cold_start_reason",
|
|
|
+ "广告状态": "ad_status",
|
|
|
+ }
|
|
|
+
|
|
|
+ # 重命名已有的中文列
|
|
|
+ rename_cols = {k: v for k, v in column_map.items() if k in df.columns}
|
|
|
+ if rename_cols:
|
|
|
+ df = df.rename(columns=rename_cols)
|
|
|
+
|
|
|
+ # 转换数据类型
|
|
|
+ for col in ["ad_id", "account_id", "current_bid", "new_bid", "open_count", "conversions_count"]:
|
|
|
+ if col in df.columns:
|
|
|
+ df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype(int)
|
|
|
+ if "is_cold_start" in df.columns:
|
|
|
+ df["is_cold_start"] = df["is_cold_start"].astype(bool)
|
|
|
+ if "cost" in df.columns:
|
|
|
+ df["cost"] = pd.to_numeric(df["cost"], errors="coerce").fillna(0.0)
|
|
|
+
|
|
|
+ return df.to_dict("records")
|
|
|
+
|
|
|
+
|
|
|
+def _validate_plan(plan: List[Dict]) -> tuple:
|
|
|
+ """验证调整方案,返回 (validated_plan, warnings, errors)
|
|
|
+
|
|
|
+ 验证规则:
|
|
|
+ 1. 出价范围 [MIN_BID, MAX_BID]
|
|
|
+ 2. 单次调整幅度 <= 15%
|
|
|
+ 3. 冷启动广告跳过
|
|
|
+ 4. 赔付门槛保护
|
|
|
+ """
|
|
|
+ validated = []
|
|
|
+ warnings = []
|
|
|
+ errors = []
|
|
|
+ skip_cold_start = 0
|
|
|
+ skip_payback = 0
|
|
|
+
|
|
|
+ for item in plan:
|
|
|
+ ad_id = item.get("ad_id", "unknown")
|
|
|
+ action = item.get("action", "")
|
|
|
+
|
|
|
+ # 跳过非操作项
|
|
|
+ if action not in ("increase", "decrease", "close"):
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 冷启动保护
|
|
|
+ if item.get("is_cold_start", False):
|
|
|
+ skip_cold_start += 1
|
|
|
+ warnings.append(f" 跳过 ad_id={ad_id}: {item.get('cold_start_reason', '冷启动保护')}")
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 赔付门槛保护(仅针对 close)
|
|
|
+ conversions = item.get("conversions_count", 0)
|
|
|
+ if action == "close" and 3 <= conversions < 6:
|
|
|
+ skip_payback += 1
|
|
|
+ warnings.append(f" 跳过关停 ad_id={ad_id}: 转化数={conversions},接近赔付门槛")
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 出价范围检查
|
|
|
+ new_bid = item.get("new_bid")
|
|
|
+ if new_bid is not None and action in ("increase", "decrease"):
|
|
|
+ if new_bid < MIN_BID:
|
|
|
+ warnings.append(f" ad_id={ad_id}: 出价 {new_bid} < {MIN_BID},自动调整为 {MIN_BID}")
|
|
|
+ item["new_bid"] = MIN_BID
|
|
|
+ elif new_bid > MAX_BID:
|
|
|
+ warnings.append(f" ad_id={ad_id}: 出价 {new_bid} > {MAX_BID},自动调整为 {MAX_BID}")
|
|
|
+ item["new_bid"] = MAX_BID
|
|
|
+
|
|
|
+ # 调整幅度检查:> 15% 自动 clamp,不放行
|
|
|
+ current_bid = item.get("current_bid")
|
|
|
+ if current_bid and new_bid and action in ("increase", "decrease"):
|
|
|
+ ratio = abs(new_bid - current_bid) / current_bid
|
|
|
+ if ratio > 0.15:
|
|
|
+ # 自动 clamp 到 15% 幅度
|
|
|
+ if action == "increase":
|
|
|
+ clamped_bid = int(current_bid * 1.15)
|
|
|
+ else:
|
|
|
+ clamped_bid = int(current_bid * 0.85)
|
|
|
+ clamped_bid = max(MIN_BID, min(MAX_BID, clamped_bid))
|
|
|
+ warnings.append(
|
|
|
+ f" ad_id={ad_id}: 调整幅度 {ratio:.0%} > 15%,"
|
|
|
+ f"已 clamp: {new_bid} -> {clamped_bid} (限制在 15% 内)"
|
|
|
+ )
|
|
|
+ item["new_bid"] = clamped_bid
|
|
|
+
|
|
|
+ validated.append(item)
|
|
|
+
|
|
|
+ if skip_cold_start:
|
|
|
+ warnings.insert(0, f"冷启动保护跳过: {skip_cold_start} 个广告")
|
|
|
+ if skip_payback:
|
|
|
+ warnings.insert(0 if not skip_cold_start else 1, f"赔付门槛保护跳过: {skip_payback} 个广告")
|
|
|
+
|
|
|
+ return validated, warnings, errors
|
|
|
+
|
|
|
+
|
|
|
+@tool(description="加载并执行运营确认后的出价调整方案(支持 Excel 文件或直接传入方案列表)")
|
|
|
+async def execute_adjustment_plan(
|
|
|
+ account_id: int,
|
|
|
+ excel_path: str = "",
|
|
|
+ adjustment_plan: Optional[List[Dict]] = None,
|
|
|
+ dry_run: bool = False,
|
|
|
+ execute_close: bool = False,
|
|
|
+ context: Optional[ToolContext] = None,
|
|
|
+) -> ToolResult:
|
|
|
+ """加载确认后的方案并执行出价调整和广告关停。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ account_id: 账户ID(传 0 则使用方案中每条记录的 account_id)
|
|
|
+ excel_path: Excel 文件路径(与 adjustment_plan 二选一)
|
|
|
+ adjustment_plan: 调整方案列表(与 excel_path 二选一)
|
|
|
+ dry_run: True=只验证不执行,输出模拟报告
|
|
|
+ execute_close: True=同时执行关停操作(默认不执行,需运营单独确认)
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ # === 阶段一:加载方案 ===
|
|
|
+ source = ""
|
|
|
+ if excel_path:
|
|
|
+ path = Path(excel_path)
|
|
|
+ if not path.exists():
|
|
|
+ return ToolResult(title="执行失败", output=f"Excel 文件不存在: {excel_path}")
|
|
|
+ plan = _load_plan_from_excel(excel_path)
|
|
|
+ source = f"Excel: {path.name}"
|
|
|
+ elif adjustment_plan:
|
|
|
+ plan = adjustment_plan
|
|
|
+ source = "上游传入"
|
|
|
+ else:
|
|
|
+ return ToolResult(title="执行失败", output="请提供 excel_path 或 adjustment_plan")
|
|
|
+
|
|
|
+ if not plan:
|
|
|
+ return ToolResult(title="执行失败", output="方案为空,无可执行项")
|
|
|
+
|
|
|
+ # === 阶段二:验证 ===
|
|
|
+ validated, warnings, errors = _validate_plan(plan)
|
|
|
+
|
|
|
+ mode = "Dry-Run" if dry_run else "正式执行"
|
|
|
+ report_lines = [
|
|
|
+ "=" * 50,
|
|
|
+ f"执行报告",
|
|
|
+ "=" * 50,
|
|
|
+ f"方案来源: {source}",
|
|
|
+ f"执行模式: {mode}",
|
|
|
+ f"方案总数: {len(plan)} 条",
|
|
|
+ f"有效操作: {len(validated)} 条",
|
|
|
+ "",
|
|
|
+ ]
|
|
|
+
|
|
|
+ if warnings:
|
|
|
+ report_lines.append("验证警告:")
|
|
|
+ report_lines.extend(warnings)
|
|
|
+ report_lines.append("")
|
|
|
+
|
|
|
+ if errors:
|
|
|
+ report_lines.append("验证错误:")
|
|
|
+ report_lines.extend(errors)
|
|
|
+ report_lines.append("")
|
|
|
+
|
|
|
+ # 分类统计
|
|
|
+ increase_items = [v for v in validated if v["action"] == "increase"]
|
|
|
+ decrease_items = [v for v in validated if v["action"] == "decrease"]
|
|
|
+ close_items = [v for v in validated if v["action"] == "close"]
|
|
|
+ bid_items = increase_items + decrease_items
|
|
|
+
|
|
|
+ report_lines.append(f"出价调整: 提价 {len(increase_items)} 个 + 降价 {len(decrease_items)} 个 = {len(bid_items)} 个")
|
|
|
+ report_lines.append(f"广告关停: {len(close_items)} 个 {'(本次执行)' if execute_close else '(待确认)'}")
|
|
|
+ report_lines.append("")
|
|
|
+
|
|
|
+ # === Dry-Run 模式:只输出不执行 ===
|
|
|
+ if dry_run:
|
|
|
+ if bid_items:
|
|
|
+ report_lines.append("将要执行的出价调整:")
|
|
|
+ for item in bid_items[:20]:
|
|
|
+ report_lines.append(
|
|
|
+ f" ad_id={item['ad_id']} | {item['action']} | "
|
|
|
+ f"{item.get('current_bid', '?')} -> {item.get('new_bid', '?')} 分"
|
|
|
+ )
|
|
|
+ if len(bid_items) > 20:
|
|
|
+ report_lines.append(f" ... 还有 {len(bid_items) - 20} 个")
|
|
|
+
|
|
|
+ if close_items:
|
|
|
+ report_lines.append("\n将要关停的广告:")
|
|
|
+ for item in close_items[:10]:
|
|
|
+ report_lines.append(
|
|
|
+ f" ad_id={item['ad_id']} | 消耗:{item.get('cost', 0):.0f}元 | "
|
|
|
+ f"转化:{item.get('conversions_count', 0)}"
|
|
|
+ )
|
|
|
+
|
|
|
+ report_lines.append("\n[Dry-Run] 以上操作未实际执行")
|
|
|
+ return ToolResult(
|
|
|
+ title=f"Dry-Run 报告({len(validated)} 条有效操作)",
|
|
|
+ output="\n".join(report_lines),
|
|
|
+ metadata={"mode": "dry_run", "validated_count": len(validated)},
|
|
|
+ )
|
|
|
+
|
|
|
+ # === 阶段三:执行出价调整 ===
|
|
|
+ bid_success = 0
|
|
|
+ bid_failed = 0
|
|
|
+ bid_errors = []
|
|
|
+
|
|
|
+ if bid_items:
|
|
|
+ report_lines.append("--- 执行出价调整 ---")
|
|
|
+ for item in bid_items:
|
|
|
+ try:
|
|
|
+ target_account = account_id if account_id else item.get("account_id", 0)
|
|
|
+ await ad_update(
|
|
|
+ account_id=target_account,
|
|
|
+ adgroup_id=item["ad_id"],
|
|
|
+ bid_amount=item["new_bid"],
|
|
|
+ )
|
|
|
+ bid_success += 1
|
|
|
+ logger.info(
|
|
|
+ "出价调整成功: ad_id=%s, %s -> %s (%s)",
|
|
|
+ item["ad_id"], item.get("current_bid"), item["new_bid"], item["action"]
|
|
|
+ )
|
|
|
+ except Exception as e:
|
|
|
+ bid_failed += 1
|
|
|
+ err_msg = f"ad_id={item['ad_id']}: {str(e)}"
|
|
|
+ bid_errors.append(err_msg)
|
|
|
+ logger.error("出价调整失败: %s", err_msg)
|
|
|
+
|
|
|
+ report_lines.append(f"提价(increase): {len(increase_items)} 个")
|
|
|
+ report_lines.append(f"降价(decrease): {len(decrease_items)} 个")
|
|
|
+ report_lines.append(f"成功: {bid_success} / 失败: {bid_failed}")
|
|
|
+ report_lines.append("")
|
|
|
+
|
|
|
+ # === 阶段四:执行广告关停 ===
|
|
|
+ close_success = 0
|
|
|
+ close_failed = 0
|
|
|
+ close_errors = []
|
|
|
+
|
|
|
+ if close_items and execute_close:
|
|
|
+ report_lines.append("--- 执行广告关停 ---")
|
|
|
+
|
|
|
+ # 按 account_id 分组,避免跨账户批量操作
|
|
|
+ from collections import defaultdict
|
|
|
+ close_by_account = defaultdict(list)
|
|
|
+ for item in close_items:
|
|
|
+ target_acct = account_id if account_id else item.get("account_id", 0)
|
|
|
+ close_by_account[target_acct].append(item["ad_id"])
|
|
|
+
|
|
|
+ for target_acct, ad_ids in close_by_account.items():
|
|
|
+ try:
|
|
|
+ result = await ad_batch_update_status(
|
|
|
+ adgroup_ids=ad_ids,
|
|
|
+ configured_status="AD_STATUS_SUSPEND",
|
|
|
+ account_id=target_acct,
|
|
|
+ )
|
|
|
+ if "失败" not in result.title:
|
|
|
+ close_success += len(ad_ids)
|
|
|
+ logger.info("关停成功: 账户%s, %d 个广告", target_acct, len(ad_ids))
|
|
|
+ else:
|
|
|
+ close_failed += len(ad_ids)
|
|
|
+ close_errors.append(f"账户{target_acct}: {result.output}")
|
|
|
+ logger.error("关停失败: 账户%s, %s", target_acct, result.output)
|
|
|
+ except Exception as e:
|
|
|
+ close_failed += len(ad_ids)
|
|
|
+ close_errors.append(f"账户{target_acct}: {str(e)}")
|
|
|
+ logger.error("关停异常: 账户%s, %s", target_acct, e)
|
|
|
+
|
|
|
+ report_lines.append(f"关停: 成功 {close_success} / 失败 {close_failed}")
|
|
|
+ report_lines.append("")
|
|
|
+ elif close_items and not execute_close:
|
|
|
+ report_lines.append("--- 广告关停(待确认)---")
|
|
|
+ report_lines.append(f"以下 {len(close_items)} 个广告建议关停,请确认后使用 execute_close=True 执行:")
|
|
|
+ for item in close_items[:10]:
|
|
|
+ report_lines.append(
|
|
|
+ f" ad_id={item['ad_id']} | 消耗:{item.get('cost', 0):.0f}元 | "
|
|
|
+ f"转化:{item.get('conversions_count', 0)} | 效率:{item.get('efficiency', 0)}"
|
|
|
+ )
|
|
|
+ if len(close_items) > 10:
|
|
|
+ report_lines.append(f" ... 还有 {len(close_items) - 10} 个")
|
|
|
+ report_lines.append("")
|
|
|
+
|
|
|
+ # === 阶段五:执行后监控 ===
|
|
|
+ monitor_result = None
|
|
|
+ if bid_success > 0 or close_success > 0:
|
|
|
+ report_lines.append("--- 执行后监控 ---")
|
|
|
+ try:
|
|
|
+ target_account = account_id if account_id else 0
|
|
|
+ monitor_result = await monitor_check_metrics(
|
|
|
+ account_id=target_account,
|
|
|
+ check_items=["cost_spike", "budget_overrun"],
|
|
|
+ time_window="1h",
|
|
|
+ )
|
|
|
+ report_lines.append(f"监控结果: {monitor_result.title}")
|
|
|
+ if monitor_result.metadata and monitor_result.metadata.get("anomalies"):
|
|
|
+ for a in monitor_result.metadata["anomalies"]:
|
|
|
+ report_lines.append(f" {a.get('type', '')}: {a.get('message', '')}")
|
|
|
+ else:
|
|
|
+ report_lines.append(" 无异常")
|
|
|
+ except Exception as e:
|
|
|
+ report_lines.append(f"监控检查失败: {str(e)}")
|
|
|
+ logger.warning("执行后监控失败: %s", e)
|
|
|
+ report_lines.append("")
|
|
|
+
|
|
|
+ # === 失败详情 ===
|
|
|
+ all_errors = bid_errors + close_errors
|
|
|
+ if all_errors:
|
|
|
+ report_lines.append("--- 失败详情 ---")
|
|
|
+ for err in all_errors[:20]:
|
|
|
+ report_lines.append(f" {err}")
|
|
|
+ if len(all_errors) > 20:
|
|
|
+ report_lines.append(f" ... 还有 {len(all_errors) - 20} 个错误")
|
|
|
+ report_lines.append("")
|
|
|
+
|
|
|
+ # === 汇总 ===
|
|
|
+ report_lines.append("=" * 50)
|
|
|
+ total_success = bid_success + close_success
|
|
|
+ total_failed = bid_failed + close_failed
|
|
|
+ report_lines.append(f"总计: 成功 {total_success} / 失败 {total_failed}")
|
|
|
+ report_lines.append(f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
|
+
|
|
|
+ return ToolResult(
|
|
|
+ title=f"执行完成(成功{total_success}/失败{total_failed})",
|
|
|
+ output="\n".join(report_lines),
|
|
|
+ metadata={
|
|
|
+ "mode": "execute",
|
|
|
+ "bid_success": bid_success,
|
|
|
+ "bid_failed": bid_failed,
|
|
|
+ "close_success": close_success,
|
|
|
+ "close_failed": close_failed,
|
|
|
+ "close_pending": len(close_items) if not execute_close else 0,
|
|
|
+ "errors": all_errors,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error("execute_adjustment_plan 失败: %s", e, exc_info=True)
|
|
|
+ return ToolResult(title="执行失败", output=str(e))
|