Просмотр исходного кода

feat: 预算域工具拆分准备 + 执行Agent + 决策框架文档

- 更新 TODO.md:记录工具拆分、Skill重构、多Agent架构规划
- config.py:新增 EXECUTE_CONFIG,准备多Agent配置
- presets.json:新增 execute 角色定义
- budget_strategy.md:补充冷启动保护、赔付规则、调价规则详细说明
- budget_calc.py:优化冷启动判定逻辑、赔付保护逻辑
- 新增 execute.prompt:执行Agent的系统提示词
- 新增 run_execute.py:执行Agent独立运行入口
- 新增 execute_agent.py:执行Agent工具封装
- 新增 budget_decision_framework.md:预算决策框架文档

准备进行策略配置层改造
刘立冬 1 месяц назад
Родитель
Сommit
74d6ae8e0f

+ 67 - 14
examples/auto_put_ad/TODO.md

@@ -1,32 +1,85 @@
 # 自动化投放系统 - 待办事项
 
-## 预算模块预留功能
+## 已完成 (2026-04-09)
 
-- [ ] **时段差异化出价**:根据广告历史分时段表现数据,在特定时段提高/降低出价
-  - 需要数据:各广告分小时的消耗、转化、ROI
-  - 触发场景:运营说"增加早间流量占比"
+- [x] **config.py 工具白名单**:分析 Agent 只暴露只读工具,执行 Agent 暴露全部业务工具
+  - ANALYSIS_TOOLS: 不含写操作工具
+  - EXECUTION_TOOLS: 包含 execute_adjustment_plan、bid_adjustment_execute 等
+
+- [x] **执行 Agent 实现**:
+  - `tools/execute_agent.py`:execute_adjustment_plan 工具(加载/验证/执行/关停/监控一体化)
+  - `prompts/execute.prompt`:执行 Agent 系统提示词
+  - `run_execute.py`:独立运行入口
+  - `presets.json`:新增 execute Agent 预设
+
+- [x] **冷启动保护**:
+  - SQL 新增 `create_time`(广告创建时间)和 `conversions_count`(转化量)字段
+  - 冷启动判定:create_time < 48h 或 conversions_count < 6 → 标记 observe 不调价
+  - 赔付门槛保护:conversions 3-5 的广告不执行关停
+
+- [x] **budget_strategy.md 领域知识更新**:
+  - 调价规则(幅度限制、间隔、频率)
+  - 冷启动保护规则
+  - 出价与消耗的非线性关系
+  - 关停保护规则(赔付门槛)
+
+- [x] **执行后监控集成**:execute_adjustment_plan 执行完成后自动触发 monitor_check_metrics
+
+- [x] **import 路径修复**:monitor_tools.py、audience_tools.py 的相对 import 改为绝对 import
+
+## P0 — 灰度上线前(目标 0422)
+
+- [ ] **广告创建能力串联**
+  - 扩量场景下新建广告完整流程:定向 + 出价 + 创意一体化创建
+  - 先实现"复制高效广告"的简化版本
+
+- [ ] **监控定时触发**
+  - 定时任务(cron/scheduler)每小时调用监控 Agent
+  - 或在执行 Agent 完成后自动触发(已部分实现)
+  - 异常时通知运营
+
+- [ ] **端到端测试**
+  - dry-run 模式验证完整链路
+  - API mock 测试(不实际调用腾讯广告 API)
+  - test_budget.py 扩展覆盖冷启动保护逻辑
+
+- [ ] **配置统一管理**
+  - ODPS 连接信息从 odps_module.py 硬编码移到环境变量
+  - 腾讯广告 API 配置统一到 config.py
+
+## P1 — 完整能力集成(目标 0430)
+
+- [ ] **人群策略深化**:人群包效果分析 → 自动推荐定向组合 → 新建广告时自动选包
+
+- [ ] **素材策略**:素材衰退检测 → 素材-人群匹配 → 创意自动组合
 
 - [ ] **公众号渠道预算**:daily 核心 roi(GT/GW)和即转 roi
   - 等公众号数据就绪后实现
   - 当前仅支持小程序渠道
 
-- [ ] **样本不足广告关停**:独立规则
-  - 首层打开数 < 100 的广告,按独立规则判断是否关停
-  - 不与预算调整耦合
+- [ ] **多Agent调度**:presets.json 中 8 个 Agent 的实际调度串联
 
-- [ ] **执行后监控**
-  - 消耗进度监控:每小时检查是否符合预期节奏(如10点前应消耗30%)
-  - 异常熔断:CPA 飙升、消耗过快等异常自动暂停并通知
+## P2 — 反馈强化(0430 后持续优化)
+
+- [ ] **时段差异化出价**:根据广告历史分时段表现数据,在特定时段提高/降低出价
+  - 需要数据:各广告分小时的消耗、转化、ROI
+  - 触发场景:运营说"增加早间流量占比"
+
+- [ ] **后验强化**:基于调价后实际消耗/ROI变化,迭代调整幅度参数
+
+- [ ] **日内 PID 控制**:实时消耗进度 vs 预期节奏,动态微调出价
 
 - [ ] **调整历史与回滚**
   - 记录每次出价调整的详细日志(调整前/后出价、时间、策略)
   - 支持一键回滚到上次调整前的状态
 
-- [ ] **账户关停判断**(独立流程,不与预算耦合)
-  - 条件:7天内账户有开启的广告、有新建广告,但无消耗(跑不出去)
+- [ ] **账户关停判断**(独立流程)
+  - 条件:7天内账户有开启的广告、有新建广告,但无消耗
   - 动作:关停该账户
-  - 需要独立的检测流程和执行逻辑
+
+- [ ] **样本不足广告关停**:独立规则
+  - 首层打开数 < 100 的广告,按独立规则判断是否关停
 
 ---
 
-最后更新:2026-04-08
+最后更新:2026-04-09

+ 58 - 2
examples/auto_put_ad/config.py

@@ -1,14 +1,70 @@
 """
 运行配置 — 自动化投放 Agent 系统
 """
+from pathlib import Path
 from agent.core.runner import RunConfig, KnowledgeConfig
 
-# Agent 运行配置
+# ===== 工具白名单 =====
+# 只暴露业务工具,避免 LLM 误调 IM/知识库/浏览器等非业务工具
+ANALYSIS_TOOLS = [
+    # 预算出价
+    "account_evaluate",
+    "budget_calculate_from_data",
+    # 数据查询
+    "data_query",
+    "data_aggregate",
+    "get_ad_current_status",
+    # 广告 API(只读)
+    "account_get_info",
+    "ad_get_list",
+    "ad_get_report",
+    "creative_get_report",
+    "asset_get_list",
+    "audience_get_list",
+    # 人群定向
+    "audience_build_targeting",
+    "audience_recommend_targeting",
+    # 监控
+    "monitor_check_metrics",
+]
+
+EXECUTION_TOOLS = ANALYSIS_TOOLS + [
+    # 执行类工具(仅执行 Agent 使用)
+    "execute_adjustment_plan",
+    "bid_adjustment_execute",
+    "ad_create",
+    "ad_update",
+    "ad_batch_update_status",
+    "creative_create",
+    "creative_update",
+    "monitor_circuit_break",
+]
+
+# ===== 分析 Agent 配置(默认,只输出方案不执行) =====
 RUN_CONFIG = RunConfig(
     model="qwen/qwen3.5-plus-02-15",
     temperature=0.3,
     max_iterations=50,
     name="自动化投放系统",
+    tools=ANALYSIS_TOOLS,
+    skills=["planning", "ad_domain", "budget_strategy"],
+    extra_llm_params={"extra_body": {"enable_thinking": True}},
+    knowledge=KnowledgeConfig(
+        enable_extraction=False,
+        enable_completion_extraction=False,
+        enable_injection=False,
+        owner="ad_placement_team",
+    ),
+)
+
+# ===== 执行 Agent 配置 =====
+EXECUTE_CONFIG = RunConfig(
+    model="qwen/qwen3.5-plus-02-15",
+    temperature=0.1,        # 执行Agent更低温度,减少随机性
+    max_iterations=30,
+    name="投放执行系统",
+    tools=EXECUTION_TOOLS,
+    skills=["planning", "ad_domain", "budget_strategy", "monitor_rules"],
     extra_llm_params={"extra_body": {"enable_thinking": True}},
     knowledge=KnowledgeConfig(
         enable_extraction=False,
@@ -19,7 +75,7 @@ RUN_CONFIG = RunConfig(
 )
 
 # 基础设施配置
-SKILLS_DIR = "./skills"
+SKILLS_DIR = str(Path(__file__).parent / "skills")
 TRACE_STORE_PATH = ".trace"
 LOG_LEVEL = "INFO"
 LOG_FILE = None

+ 181 - 0
examples/auto_put_ad/docs/budget_decision_framework.md

@@ -0,0 +1,181 @@
+# 预算决策框架
+
+> 最后更新:2026-04-09
+> 适用系统:自动化投放 Agent(auto_put_ad)
+> 核心文件:`tools/budget_calc.py`、`skills/budget_strategy.md`
+
+---
+
+## 一、整体架构
+
+```
+运营输入今日预算
+        ↓
+① 拉昨日数据 → 算 scale_ratio → 确定全局策略
+        ↓
+② 筛有效广告(open_count ≥ 100)
+        ↓
+③ 计算分位数阈值(ROI P70/P30 + 消耗 P50)
+        ↓
+④ 冷启动保护判定(优先于矩阵)
+        ↓
+⑤ ROI × 跑量 二维矩阵 → 输出动作
+        ↓
+⑥ 关停保护检查(赔付门槛)
+        ↓
+⑦ 出价边界约束 → 写入 Excel
+```
+
+---
+
+## 二、全局策略判定(scale_ratio)
+
+```
+scale_ratio = 今日预算 ÷ 昨日实际消耗
+```
+
+| scale_ratio 区间 | 策略名称 | 含义 |
+|---|---|---|
+| < 0.70 | `aggressive_scale_down` | 大幅缩量(>30%) |
+| 0.70 ~ 0.95 | `moderate_scale_down` | 温和缩量 |
+| 0.95 ~ 1.05 | `maintain` | 基本持平 |
+| 1.05 ~ 1.30 | `moderate_scale_up` | 温和扩量 |
+| > 1.30 | `aggressive_scale_up` | 大幅扩量(>30%) |
+
+---
+
+## 三、广告分类(二维象限)
+
+**ROI 维度**(效率分 = 裂变0层回流数 / 昨日消耗)
+
+- **High**:效率分 ≥ P70(头部 30%)
+- **Mid**:P30 ~ P70(腰部 40%)
+- **Low**:< P30(尾部 30%)
+
+**跑量维度**(昨日消耗绝对值)
+
+- **High**:消耗 ≥ P50(中位数以上)
+- **Low**:消耗 < P50
+
+> 阈值基于当日有效广告池(open_count ≥ 100)动态计算,无固定值。
+
+---
+
+## 四、决策矩阵(三套)
+
+### 缩量矩阵(scale_ratio < 0.95)
+
+| | 高跑量(≥ P50) | 低跑量(< P50) |
+|---|---|---|
+| **高 ROI(≥ P70)** | keep | keep |
+| **中 ROI(P30~P70)** | decrease **-10%**(大幅) / **-5%**(温和) | observe |
+| **低 ROI(< P30)** | decrease **-15%**(大幅) / **-10%**(温和) | close |
+
+### 扩量矩阵(scale_ratio > 1.05)
+
+| | 高跑量(≥ P50) | 低跑量(< P50) |
+|---|---|---|
+| **高 ROI(≥ P70)** | keep | increase **+15%**(大幅) / **+10%**(温和) |
+| **中 ROI(P30~P70)** | keep | increase **+5%** |
+| **低 ROI(< P30)** | decrease -10% | close |
+
+### 持平矩阵(0.95 ≤ scale_ratio ≤ 1.05)
+
+- 低 ROI + 低跑量 → `close`
+- 其余全部 → `keep`
+
+---
+
+## 五、5 种动作说明
+
+| 动作 | 含义 | 出价变化 |
+|------|------|---------|
+| `keep` | 保持不动 | 不调整 |
+| `increase` | 提价放量 | +5% ~ +15% |
+| `decrease` | 降价控量 | -5% ~ -15% |
+| `close` | 建议关停 | 不调整(标记,需人工确认) |
+| `observe` | 观察不动 | 不调整 |
+
+---
+
+## 六、保护机制
+
+### 6.1 冷启动保护(最高优先级,覆盖矩阵结果)
+
+| 判定条件 | 处理方式 |
+|---|---|
+| 广告 `create_time` 距今 < 48h | 强制 `observe`,不调价 |
+| 累计 `conversions_count` < 6 次 | 强制 `observe`,不调价不关停 |
+
+> ⚠️ 冷启动判定**优先于** ROI × 跑量矩阵。即使 ROI=Low,只要满足冷启动条件,一律 observe。
+
+### 6.2 赔付门槛保护(关停前检查)
+
+腾讯广告赔付规则:转化 ≥ 6 且 CPA 偏离目标 ≥ 20%/30% 时,可申请赔付。
+
+| 转化数 | 处理 |
+|---|---|
+| < 3 | 可正常执行 close |
+| 3 ~ 5 | 改为 `observe`,等积累到 6 次 |
+| ≥ 6 且 CPA 偏离 ≥ 20% | 改为 `observe`,先申请赔付再关停 |
+| ≥ 6 且 CPA 正常 | 可正常执行 close |
+
+---
+
+## 七、出价执行约束
+
+| 约束 | 规则 |
+|---|---|
+| 出价范围 | 10分(0.10元)~ 10000分(100元) |
+| `keep / observe / close` | **不改出价** |
+| `increase / decrease` | 按矩阵比例计算新出价,截断到边界 |
+| 单次调整幅度 | ≤ 10%(超过需拆阶梯执行,间隔 ≥ 2h) |
+| 调整间隔 | ≥ 2 小时(oCPM 模型重学习需要时间) |
+| 每天调整次数 | 2 ~ 3 次 |
+
+**阶梯式调价原则**:
+- 单次降幅超过 10%:拆分为多次执行,每次间隔 2h
+- 单次提幅超过 15%:建议拆分为 2 次执行
+
+---
+
+## 八、广告样本过滤
+
+```
+open_count < 100  →  样本不足,本次跳过,不进入矩阵决策
+```
+
+---
+
+## 九、出价与消耗的非线性关系
+
+oCPM 模式下,出价调整与消耗变化是**非线性**关系:
+
+```
+eCPM = bid × pCTR × pCVR × 1000
+```
+
+- **降价效果放大**:降 10% 出价 ≈ 降 15~25% 消耗
+- **掉量悬崖**:eCPM 低于竞争水位时,展示量骤降至接近 0(断崖,非线性)
+- **提价天花板**:提价 10% 不一定多消耗 10%,存在边际递减
+
+> ⚠️ 降价操作比提价风险更高,应更谨慎;接近 MIN_BID 时调幅控制在 3% 以内。
+
+---
+
+## 十、核心设计哲学
+
+> 用**出价(bid)**而非**预算上限(day_amount)**控制消耗。
+>
+> oCPM 下:出价 → eCPM → 竞价胜率 → 消耗量,是一个连续但非线性的链路,比设 day_amount 上限更精细灵活。
+
+---
+
+## 十一、待实现(预留接口)
+
+| 功能 | 状态 |
+|---|---|
+| 日内 PID 控制(实时消耗 vs 节奏对比,动态微调出价) | 待实现 |
+| 时段差异化出价(早间/晚间流量差异) | 待实现 |
+| 调价后验强化(实际消耗/ROI 变化反馈) | 待实现 |
+| 公众号渠道独立预算分配(GT/GW + 即转 ROI) | 待实现 |

+ 7 - 0
examples/auto_put_ad/presets.json

@@ -25,6 +25,13 @@
     "skills": ["planning", "budget_strategy"],
     "description": "预算出价 Agent - 预算分配与 ROI 优化"
   },
+  "execute": {
+    "system_prompt_file": "prompts/execute.prompt",
+    "max_iterations": 30,
+    "temperature": 0.1,
+    "skills": ["planning", "budget_strategy"],
+    "description": "执行 Agent - 读取确认方案并执行出价调整与广告关停"
+  },
   "system_ops": {
     "system_prompt_file": "prompts/system_ops.prompt",
     "max_iterations": 150,

+ 91 - 0
examples/auto_put_ad/prompts/execute.prompt

@@ -0,0 +1,91 @@
+你是腾讯广告自动化投放系统的执行 Agent,负责读取运营确认后的调整方案并执行 API 操作。
+
+## 你的职责
+
+1. **加载方案**:读取运营确认后的调整方案(Excel 文件或上游传入的 adjustment_plan)
+2. **验证方案**:检查方案合理性(出价范围、调整幅度)
+3. **执行出价调整**:调用 API 执行 increase / decrease 操作
+4. **执行广告关停**:运营确认后,执行 close 操作(暂停广告)
+5. **执行后监控**:调整完成后触发一次监控检查
+
+## 重要约束
+
+- **只执行运营已确认的方案**,不自行决策
+- **冷启动广告不操作**:is_cold_start=True 的广告跳过
+- **close 动作需运营单独确认**,不与 increase/decrease 一起批量执行
+- **赔付门槛保护**:转化数 3-5 的广告不执行关停
+- **单次调整幅度 <= 15%**:超过此幅度的调整需告警并拆分
+- **执行失败时不重试**,记录错误并汇报
+
+## 可用工具
+
+- `execute_adjustment_plan`:**主工具** — 加载方案、验证、执行出价调整、关停、执行后监控(支持 Excel 和方案列表输入,支持 dry-run)
+- `bid_adjustment_execute`:简易版批量出价调整(仅 increase/decrease,无验证无保护)
+- `ad_batch_update_status`:批量修改广告状态(用于关停)
+- `ad_update`:更新单个广告设置
+- `monitor_check_metrics`:执行后监控检查
+- `get_ad_current_status`:查询广告当前状态(执行前确认)
+- `data_query`:查询数据(验证用)
+
+## 执行流程
+
+### 推荐方式:使用 `execute_adjustment_plan`(一步完成)
+
+1. 接收用户提供的 Excel 路径或 adjustment_plan 列表
+2. **先 dry-run 验证**:调用 `execute_adjustment_plan(account_id=X, excel_path="...", dry_run=True)`
+3. 展示验证结果,等待用户确认
+4. **正式执行**:调用 `execute_adjustment_plan(account_id=X, excel_path="...", dry_run=False)`
+5. 如需执行关停:调用 `execute_adjustment_plan(account_id=X, excel_path="...", execute_close=True)`
+
+### 手动方式(仅在需要精细控制时使用)
+
+1. 接收 adjustment_plan(列表)或 Excel 文件路径
+2. 验证每条调整:
+   - 出价在合理范围 [10, 10000] 分
+   - 单次调整幅度 <= 15%
+   - 冷启动广告(is_cold_start=True)标记跳过
+   - 赔付门槛广告(conversions_count 3-5)标记跳过关停
+3. 调用 `bid_adjustment_execute(adjustment_plan=筛选后方案, account_id=X)` 执行出价调整
+4. 调用 `ad_batch_update_status(adgroup_ids=[...], configured_status="AD_STATUS_SUSPEND")` 执行关停
+5. 调用 `monitor_check_metrics` 检查异常
+
+## Dry-Run 模式
+
+当用户指定 dry_run=true 时:
+- 执行所有验证步骤
+- 输出"将要执行"的操作列表
+- **不调用任何写操作 API**
+- 输出模拟执行报告
+
+## 输出格式
+
+执行完成后,请提供:
+
+```
+执行报告
+========
+方案来源: [Excel路径 或 上游传入]
+执行模式: [正式执行 / Dry-Run]
+
+出价调整:
+- 提价(increase): X 个,成功 Y / 失败 Z
+- 降价(decrease): X 个,成功 Y / 失败 Z
+- 跳过(冷启动保护): X 个
+- 跳过(赔付门槛保护): X 个
+
+广告关停:
+- 待确认关停: X 个
+- [已确认] 关停成功: Y 个 / 失败: Z 个
+
+执行后监控:
+- 异常项: [无 / 列表]
+
+失败详情:
+- [如有失败,列出 ad_id 和错误原因]
+```
+
+## 错误处理
+
+- API 调用失败:记录错误,继续执行其他广告,最后汇总失败列表
+- 出价超出范围:自动 clamp 到 [10, 10000] 分,并在报告中标注
+- 广告已暂停:跳过重复关停,在报告中标注

+ 204 - 0
examples/auto_put_ad/run_execute.py

@@ -0,0 +1,204 @@
+"""
+执行 Agent — 运行入口
+
+运行方式:
+    cd /Users/liulidong/project/agent/Agent
+    python examples/auto_put_ad/run_execute.py
+
+用途:
+    读取运营确认后的调整方案(Excel 文件),执行出价调整和广告关停。
+
+与 run.py 的区别:
+    - run.py  → 分析 Agent,只输出方案不执行
+    - run_execute.py → 执行 Agent,读取确认方案并调用 API
+"""
+
+import asyncio
+import os
+import sys
+from pathlib import Path
+
+# 添加项目根目录到 Python 路径
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from agent.core.runner import AgentRunner
+from agent.trace import FileSystemTraceStore, Trace, Message
+from agent.llm import create_openrouter_llm_call
+from agent.utils import setup_logging
+
+# 导入配置(使用执行 Agent 配置)
+from examples.auto_put_ad.config import EXECUTE_CONFIG, SKILLS_DIR, TRACE_STORE_PATH, LOG_LEVEL, LOG_FILE
+
+# 导入自定义工具(触发 @tool 注册)
+from examples.auto_put_ad.tools.ad_api import (
+    account_get_info, ad_batch_update_status, ad_create, ad_get_list,
+    ad_get_report, ad_update, asset_get_list, audience_get_list,
+    creative_create, creative_get_report, creative_update,
+)
+from examples.auto_put_ad.tools.audience_tools import (
+    audience_build_targeting, audience_recommend_targeting,
+)
+from examples.auto_put_ad.tools.budget_calc import (
+    account_evaluate, bid_adjustment_execute, budget_calculate_from_data,
+)
+from examples.auto_put_ad.tools.data_query import (
+    data_aggregate, data_query, get_ad_current_status,
+)
+from examples.auto_put_ad.tools.monitor_tools import (
+    monitor_check_metrics, monitor_circuit_break,
+)
+# 导入执行 Agent 工具
+from examples.auto_put_ad.tools.execute_agent import (
+    execute_adjustment_plan,
+)
+
+
+def _find_latest_excel() -> str:
+    """找到 outputs/ 目录下最新的调整方案 Excel 文件"""
+    outputs_dir = Path(__file__).parent / "outputs"
+    if not outputs_dir.exists():
+        return ""
+    xlsx_files = sorted(outputs_dir.glob("adjustment_plan_*.xlsx"), reverse=True)
+    return str(xlsx_files[0]) if xlsx_files else ""
+
+
+async def init_project_env(messages=None):
+    """供 api_server 可视化调用:返回 (runner, messages, config)"""
+    base_dir = Path(__file__).parent
+
+    # 读取 execute prompt
+    prompt_path = base_dir / "prompts" / "execute.prompt"
+    system_prompt = ""
+    if prompt_path.exists():
+        system_prompt = prompt_path.read_text(encoding="utf-8")
+
+    store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
+    runner = AgentRunner(
+        trace_store=store,
+        llm_call=create_openrouter_llm_call(model=EXECUTE_CONFIG.model),
+        skills_dir=SKILLS_DIR if Path(SKILLS_DIR).exists() else None,
+        logger_name="agents.auto_put_ad.execute",
+    )
+
+    config = EXECUTE_CONFIG
+    if system_prompt:
+        config.system_prompt = system_prompt
+
+    if not messages:
+        latest_excel = _find_latest_excel()
+        if latest_excel:
+            messages = [{
+                "role": "user",
+                "content": f"请执行以下调整方案(dry-run 模式先验证):\n文件路径: {latest_excel}",
+            }]
+        else:
+            messages = [{"role": "user", "content": "没有找到调整方案文件,请先运行分析 Agent 生成方案"}]
+
+    return runner, messages, config
+
+
+async def main():
+    """主函数"""
+    base_dir = Path(__file__).parent
+
+    # 初始化日志
+    setup_logging(level=LOG_LEVEL, file=LOG_FILE)
+
+    # 读取 execute prompt
+    prompt_path = base_dir / "prompts" / "execute.prompt"
+    system_prompt = ""
+    if prompt_path.exists():
+        system_prompt = prompt_path.read_text(encoding="utf-8")
+
+    # 创建 Runner
+    store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
+    runner = AgentRunner(
+        trace_store=store,
+        llm_call=create_openrouter_llm_call(model=EXECUTE_CONFIG.model),
+        skills_dir=SKILLS_DIR if Path(SKILLS_DIR).exists() else None,
+        logger_name="agents.auto_put_ad.execute",
+    )
+
+    config = EXECUTE_CONFIG
+    if system_prompt:
+        config.system_prompt = system_prompt
+
+    print("=" * 60)
+    print("  执行 Agent 已启动")
+    print("=" * 60)
+
+    # 查找最新方案文件
+    latest_excel = _find_latest_excel()
+    if latest_excel:
+        print(f"最新方案文件: {latest_excel}")
+    else:
+        print("未找到方案文件,请先运行 run.py 生成方案")
+
+    print()
+    print("请输入执行指令(输入 'exit' 退出):")
+    print("示例:")
+    print(f"  - 执行最新方案(dry-run)")
+    print(f"  - 执行方案 outputs/adjustment_plan_xxx.xlsx")
+    print(f"  - 确认关停")
+    print()
+
+    while True:
+        try:
+            user_input = input("\n> ").strip()
+            if not user_input:
+                continue
+            if user_input.lower() in ("exit", "quit", "q"):
+                print("退出系统")
+                break
+
+            # 自动注入最新 Excel 路径(如果用户没指定)
+            if "最新" in user_input and latest_excel:
+                user_input += f"\n\n最新方案文件路径: {latest_excel}"
+
+            messages = [{"role": "user", "content": user_input}]
+            config.trace_id = None
+
+            print(f"\n执行任务: {user_input[:80]}...\n")
+
+            async for item in runner.run(messages=messages, config=config):
+                if isinstance(item, Trace):
+                    print(f"[Trace] 状态: {item.status}")
+                elif isinstance(item, Message):
+                    if item.role == "assistant" and item.content:
+                        content = item.content
+                        if isinstance(content, dict):
+                            text = content.get("text", "")
+                        else:
+                            text = content
+                        if text and text.strip():
+                            print(f"\n{text}\n")
+                    elif item.role == "tool" and item.content:
+                        content = item.content
+                        if isinstance(content, str):
+                            text = content
+                        elif isinstance(content, dict):
+                            text = content.get("text", str(content))
+                        else:
+                            text = str(content)
+                        if len(text) > 500:
+                            text = text[:500] + "..."
+                        print(f"  [Tool] {text}")
+
+            print("\n" + "=" * 60)
+            print("任务完成")
+            print("=" * 60)
+
+        except KeyboardInterrupt:
+            print("\n用户中断,退出系统")
+            break
+        except Exception as e:
+            print(f"\n执行失败: {e}")
+            import traceback
+            traceback.print_exc()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 82 - 3
examples/auto_put_ad/skills/budget_strategy.md

@@ -74,9 +74,88 @@ description: 预算分配与出价策略的领域知识
 - 每天调整一次
 - 调整后观察至少 2 小时再做下一次调整
 
+## 调价规则(行业共识)
+
+| 规则 | 具体值 | 说明 |
+|------|-------|------|
+| 单次调整幅度 | <= 5-10% | 大幅调整需阶梯式执行(如 -20% 拆成 3 次 -7%/-7%/-6%) |
+| 调整间隔 | >= 2 小时 | oCPM 模型需要时间重新学习竞价,频繁调价破坏模型学习 |
+| 每天最多调整次数 | 2-3 次 | 避免频繁扰动模型导致效果波动 |
+| 接近掉量悬崖时 | <= 3% | 出价越低、竞争越激烈,调整幅度应越谨慎 |
+| 阶梯式执行示例 | -20% 拆为 3 次 | 每次间隔 2 小时:第1次 -7%,第2次 -7%,第3次 -6% |
+
+**阶梯式调价原则**:
+- 单次降幅超过 10% 时,必须拆分为多次执行
+- 单次提价超过 15% 时,建议拆分为 2 次执行
+- 每次调整后观察 2 小时以上再做下一次调整
+
+## 冷启动保护
+
+冷启动期间广告处于 oCPM 模型学习阶段,频繁调价会破坏学习过程。
+
+| 条件 | 判定标准 | 处理方式 |
+|------|---------|---------|
+| 新广告冷启动期 | 广告 `create_time` 距今 < 48 小时 | **不调价**,保持初始出价,标记 observe |
+| 转化数不足 | 累计 `conversions_count` < 6 | **不调价不关停**,标记 observe |
+| 接近赔付门槛 | 转化数接近 6 且 CPA 偏高 | 标记 observe(非 close),等待赔付触发 |
+| 学习期消耗异常 | 冷启动期内 CPA 明显偏高 | 仅告警通知运营,不触发自动熔断 |
+| 冷启动成功判定 | 累计转化 >= 6 且 create_time 距今 > 48h | 退出保护期,进入正常决策矩阵 |
+
+**冷启动判定优先级**:冷启动保护判定优先于 ROI x 跑量决策矩阵。即使 ROI 分类为 "low",只要在冷启动期内,一律 observe。
+
+**所需数据字段**(来自 `loghubods` 数据表):
+- `create_time`:来自 `loghubods.ad_put_tencent_ad` 表,广告创建时间
+- `conversions_count`:来自 `loghubods.ad_put_tencent_creative_data_day` 表,转化量
+
+## 出价与消耗的非线性关系
+
+oCPM 模式下,出价调整与消耗变化是**非线性关系**,Agent 决策时必须理解:
+
+- **eCPM 公式**:`eCPM = bid x pCTR x pCVR x 1000`
+- **降价效果放大**:降 10% 出价 约等于 降 15-25% 消耗(非线性放大)
+- **掉量悬崖**:当 eCPM 低于竞争水位时,展示量会骤降至接近 0
+  - 这不是线性下降,而是断崖式跌落
+  - 一旦掉入悬崖,即使恢复出价也需要时间重新竞价
+- **提价天花板**:提价消耗增长同样非线性,受日预算上限和竞争环境约束
+  - 提价 10% 不一定能多消耗 10%
+  - 存在边际递减效应
+
+**Agent 应用原则**:
+- 降价操作比提价操作风险更高,应更谨慎
+- 单次降幅不超过 10%,避免触发掉量悬崖
+- 如果广告当前出价已经较低(接近 MIN_BID),降价幅度应控制在 3% 以内
+- 提价后需要观察 2-4 小时等待 oCPM 模型重新学习
+
+## 关停保护规则
+
+关停广告前必须检查以下保护条件:
+
+### 赔付门槛保护
+
+腾讯广告赔付规则:**转化 >= 6 且 CPA 偏离目标 >= 20%/30% 时,可申请赔付**。
+
+| 场景 | 转化数 | 处理 |
+|------|--------|------|
+| 转化 < 3 | 远离赔付门槛 | 可正常执行 close |
+| 转化 3-5 | 接近赔付门槛 | 标记 observe,等待转化积累到 6 |
+| 转化 >= 6 且 CPA 偏离 >= 20% | 已触发赔付条件 | 标记 observe,先申请赔付再关停 |
+| 转化 >= 6 且 CPA 正常 | 非赔付场景 | 可正常执行 close |
+
+### 沉没成本保护
+
+- 已投入较大成本(消耗 > 账户日均消耗 x 30%)但接近赔付门槛的广告,应等待触发赔付后再关停
+- 关停前计算"沉没成本 vs 潜在赔付收益",避免白白损失已投入成本
+
+### 关停执行规范
+
+- close 动作**不自动执行**,标记后由运营确认
+- 执行关停时调用 `ad_batch_update_status(configured_status="AD_STATUS_SUSPEND")`
+- 关停后记录日志:广告ID、关停原因、关停时消耗和转化数
+- 支持关停后恢复:运营可手动恢复被暂停的广告
+
 ## 预留功能(待实现)
 
 1. **后验强化**:基于调价后实际消耗/ROI变化,迭代调整幅度参数
-2. **时段差异化出价**:根据分时段投放数据差异化出价
-3. **公众号渠道**:daily 核心 roi(GT/GW)和即转 roi
-4. **样本不足广告关停**:独立规则
+2. **时段差异化出价**:根据分时段投放数据差异化出价(早间/晚间流量差异)
+3. **公众号渠道**:daily 核心 roi(GT/GW)和即转 roi,独立预算分配
+4. **日内 PID 控制**:实时消耗进度 vs 预期节奏,动态微调出价

+ 1 - 1
examples/auto_put_ad/tools/audience_tools.py

@@ -114,7 +114,7 @@ async def audience_recommend_targeting(
             OPTIMIZATIONGOAL_CLICK(点击)
         account_id: 广告主账号ID
     """
-    from tools.ad_api import audience_get_list
+    from examples.auto_put_ad.tools.ad_api import audience_get_list
 
     # 查询账户下所有可用人群包
     result = await audience_get_list(account_id=account_id)

+ 76 - 13
examples/auto_put_ad/tools/budget_calc.py

@@ -124,14 +124,21 @@ def _parse_bizdate(bizdate: str) -> tuple:
 
 
 def _build_efficiency_sql(biz: str, biz_dash: str) -> str:
-    """构建昨日效率数据 SQL(广告维度聚合)"""
+    """构建昨日效率数据 SQL(广告维度聚合)
+
+    包含冷启动保护所需字段:
+    - create_time: 广告创建时间(判定冷启动期 48h)
+    - conversions_count: 转化量(判定赔付门槛 6 次)
+    """
     return f"""
 SELECT
     a.account_id,
     a.ad_id,
     c.ad_name,
+    c.create_time,
     SUM(b.cost/100)          AS cost,
     SUM(b.valid_click_count) AS valid_click_count,
+    SUM(b.conversions_count) AS conversions_count,
     SUM(t.首层小程序打开数)   AS open_count,
     SUM(t.裂变0层回流数)     AS fission0_count,
     SUM(t.总回流人数)        AS total_return_count
@@ -152,9 +159,9 @@ FROM (
 LEFT JOIN loghubods.ad_put_tencent_creative_day a ON t.creative_name = a.creative_name
 LEFT JOIN loghubods.ad_put_tencent_ad c ON a.ad_id = c.ad_id
 LEFT JOIN (
-    SELECT creative_id, valid_click_count, cost
+    SELECT creative_id, valid_click_count, cost, conversions_count
     FROM (
-        SELECT creative_id, valid_click_count, cost,
+        SELECT creative_id, valid_click_count, cost, conversions_count,
             ROW_NUMBER() OVER (PARTITION BY creative_id ORDER BY update_time DESC) AS rank
         FROM loghubods.ad_put_tencent_creative_data_day
         WHERE dt = '{biz_dash}'
@@ -164,7 +171,7 @@ WHERE t.dt = '{biz}'
   AND a.ad_id IS NOT NULL
   AND b.cost IS NOT NULL
   AND b.cost > 0
-GROUP BY a.account_id, a.ad_id, c.ad_name
+GROUP BY a.account_id, a.ad_id, c.ad_name, c.create_time
 """
 
 
@@ -327,7 +334,9 @@ WHERE ad_id IN ({ad_ids_str})
 
         # Step 4: 效率分 + 有效广告筛选
         df["efficiency"] = df.apply(
-            lambda r: r["fission0_count"] / r["cost"] if r["cost"] and r["cost"] > 0 else None,
+            lambda r: r["fission0_count"] / r["cost"]
+            if r["cost"] and r["cost"] > 0 and r["fission0_count"] is not None and pd.notna(r["fission0_count"])
+            else None,
             axis=1,
         )
         df_valid = df[df["open_count"] >= 100].copy().sort_values("efficiency", ascending=False).reset_index(drop=True)
@@ -345,13 +354,52 @@ WHERE ad_id IN ({ad_ids_str})
         if strategy == "auto":
             strategy = _determine_strategy(scale_ratio)
 
-        # Step 7: 二维矩阵决策
+        # Step 7: 二维矩阵决策(含冷启动保护)
         results = []
+        cold_start_count = 0
         for _, row in df_valid.iterrows():
             eff = float(row["efficiency"]) if pd.notna(row["efficiency"]) else 0.0
             cost = float(row["cost"])
-            roi_level, volume_level = _classify_ad(eff, cost, thresholds)
-            action, adj_ratio = _decide_action(roi_level, volume_level, strategy)
+            conversions = int(row["conversions_count"]) if pd.notna(row.get("conversions_count")) else 0
+
+            # --- 冷启动保护判定(优先于决策矩阵) ---
+            is_cold_start = False
+            cold_start_reason = ""
+
+            # 判定1:广告创建时间 < 48 小时
+            create_time = row.get("create_time")
+            if create_time and pd.notna(create_time):
+                try:
+                    if isinstance(create_time, str):
+                        ct = datetime.strptime(create_time[:19], "%Y-%m-%d %H:%M:%S")
+                    else:
+                        ct = pd.Timestamp(create_time).to_pydatetime()
+                    hours_since_creation = (datetime.now() - ct).total_seconds() / 3600
+                    if hours_since_creation < 48:
+                        is_cold_start = True
+                        cold_start_reason = f"冷启动期({hours_since_creation:.0f}h<48h)"
+                except (ValueError, TypeError):
+                    pass
+
+            # 判定2:转化数不足 < 6
+            if conversions < 6 and not is_cold_start:
+                is_cold_start = True
+                cold_start_reason = f"转化不足({conversions}<6)"
+
+            if is_cold_start:
+                cold_start_count += 1
+                roi_level, volume_level = _classify_ad(eff, cost, thresholds)
+                action = ACTION_OBSERVE
+                adj_ratio = 0.0
+            else:
+                roi_level, volume_level = _classify_ad(eff, cost, thresholds)
+                action, adj_ratio = _decide_action(roi_level, volume_level, strategy)
+
+                # --- 关停保护:赔付门槛检查 ---
+                if action == ACTION_CLOSE and 3 <= conversions < 6:
+                    action = ACTION_OBSERVE
+                    adj_ratio = 0.0
+                    cold_start_reason = f"接近赔付门槛({conversions}次转化,等待积累到6)"
 
             bid = row["bid_amount"] if pd.notna(row["bid_amount"]) else None
             new_bid = None
@@ -361,6 +409,7 @@ WHERE ad_id IN ({ad_ids_str})
                 new_bid = int(float(bid))  # keep/observe/close 不改出价
 
             results.append({
+                "date": datetime.now().strftime("%Y-%m-%d"),
                 "ad_id": int(row["ad_id"]),
                 "ad_name": str(row["ad_name"]) if pd.notna(row["ad_name"]) else "",
                 "account_id": int(row["account_id"]) if pd.notna(row["account_id"]) else 0,
@@ -369,6 +418,9 @@ WHERE ad_id IN ({ad_ids_str})
                 "efficiency": round(eff, 4),
                 "cost": round(cost, 2),
                 "open_count": int(row["open_count"]),
+                "conversions_count": conversions,
+                "is_cold_start": is_cold_start,
+                "cold_start_reason": cold_start_reason,
                 "current_bid": int(float(bid)) if bid else None,
                 "new_bid": new_bid,
                 "adjustment_ratio": f"{adj_ratio:+.0%}" if adj_ratio != 0 else "—",
@@ -412,12 +464,21 @@ WHERE ad_id IN ({ad_ids_str})
             lines.append(f"【样本不足 - {len(df_nosample)}个,本次不操作】")
             lines.append("")
 
+        if cold_start_count > 0:
+            cold_start_ads = [r for r in results if r.get("is_cold_start")]
+            lines.append(f"【冷启动保护 - {cold_start_count}个,标记observe不调价】")
+            for item in cold_start_ads[:5]:
+                lines.append(f"  {item['ad_id']} | {item['cold_start_reason']} | 转化:{item['conversions_count']} | 消耗:{item['cost']:.0f}元")
+            if cold_start_count > 5:
+                lines.append(f"  ... 还有 {cold_start_count - 5} 个")
+            lines.append("")
+
         # 汇总
         action_counts = {}
         for r in results:
             action_counts[r["action"]] = action_counts.get(r["action"], 0) + 1
         summary_parts = [f"{label}:{action_counts.get(act, 0)}" for act, label in action_labels]
-        lines.append(f"合计:{' / '.join(summary_parts)} / 样本不足:{len(df_nosample)}")
+        lines.append(f"合计:{' / '.join(summary_parts)} / 样本不足:{len(df_nosample)} / 冷启动保护:{cold_start_count}")
 
         # Step 9: 输出 Excel(按动作颜色标识)
         try:
@@ -436,11 +497,12 @@ WHERE ad_id IN ({ad_ids_str})
             output_dir.mkdir(exist_ok=True)
             xlsx_path = output_dir / f"adjustment_plan_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
 
-            headers_cn = ["广告ID", "广告名称", "账户ID", "动作", "当前出价(分)", "新出价(分)",
-                          "调整幅度", "ROI等级", "跑量等级", "效率分", "昨日消耗(元)", "打开数", "广告状态"]
-            fields_en = ["ad_id", "ad_name", "account_id", "action", "current_bid", "new_bid",
+            headers_cn = ["日期", "账户ID", "广告ID", "广告名称", "动作", "当前出价(分)", "新出价(分)",
+                          "调整幅度", "ROI等级", "跑量等级", "效率分", "昨日消耗(元)", "打开数",
+                          "转化数", "冷启动", "冷启动原因", "广告状态"]
+            fields_en = ["date", "account_id", "ad_id", "ad_name", "action", "current_bid", "new_bid",
                          "adjustment_ratio", "roi_level", "volume_level", "efficiency", "cost",
-                         "open_count", "ad_status"]
+                         "open_count", "conversions_count", "is_cold_start", "cold_start_reason", "ad_status"]
 
             wb = openpyxl.Workbook()
             ws = wb.active
@@ -481,6 +543,7 @@ WHERE ad_id IN ({ad_ids_str})
                 "yesterday_total": yesterday_total,
                 "total_budget_yuan": total_budget_yuan,
                 "nosample_count": len(df_nosample),
+                "cold_start_count": cold_start_count,
                 "action_counts": action_counts,
             },
         )

+ 380 - 0
examples/auto_put_ad/tools/execute_agent.py

@@ -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))

+ 3 - 3
examples/auto_put_ad/tools/monitor_tools.py

@@ -34,8 +34,8 @@ async def monitor_check_metrics(
     Returns:
         异常检测结果,包含触发的异常项和详细信息
     """
-    from tools.data_query import data_query
-    from tools.ad_api import account_get_info
+    from examples.auto_put_ad.tools.data_query import data_query
+    from examples.auto_put_ad.tools.ad_api import account_get_info
 
     default_threshold = {
         "cost_spike_ratio": 2.0,      # CPA 超过目标 2 倍
@@ -141,7 +141,7 @@ async def monitor_circuit_break(
         target_ids: 需要熔断的广告ID列表(adgroup_id)
         reason: 熔断原因(记录到日志)
     """
-    from tools.ad_api import ad_batch_update_status
+    from examples.auto_put_ad.tools.ad_api import ad_batch_update_status
 
     logger.warning(
         "[熔断] 账户 %s 触发熔断,暂停 %d 个广告,原因: %s",