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

feat(auto_put_ad_mini): 智能引擎 + 执行引擎 + 安全护栏 全链路集成

智能决策引擎:
- 纯智能引擎模式(规则引擎暂时禁用)
- AI 推理 + 领域知识,自动 A/B/C 分类 + B 类重点推理
- ad_decision 扩展出价调整决策(bid_up/bid_down)

执行引擎(execution_engine):
- Tier 1/2/3 分级:自动执行 / 需审批 / 高价值人工确认
- 腾讯广告 API 调用(出价调整 + 暂停广告)
- QPS 限流、重试、审计日志

安全护栏(guardrails):
- 冷启动保护期、单日调幅上限、操作频率限制
- 数据新鲜度校验、DRY_RUN 模式
- 护栏规则知识(guardrail_rules.md)

其他改动:
- config.py 新增执行引擎 / IM / 护栏配置项
- system.prompt 工作流步骤更新
- run.py 运行入口适配新工具链
- roi_strategy.md 策略知识补充
- execute_once.py 单次执行脚本
- .env.example 环境变量模板

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
刘立冬 3 недель назад
Родитель
Сommit
29490eabc2

+ 29 - 0
examples/auto_put_ad_mini/.env.example

@@ -0,0 +1,29 @@
+# 腾讯广告平台 API 配置示例
+# 复制此文件为 .env 并填入真实的配置值
+
+# ========================================
+# 腾讯广告 API 配置 (必需)
+# ========================================
+
+# OAuth2 访问令牌 (从腾讯广告平台获取)
+# 获取方式: https://developers.e.qq.com/docs/guide/auth
+TENCENT_AD_ACCESS_TOKEN=your_access_token_here
+
+# 广告账户 ID (数字)
+# 在腾讯广告平台的账户管理中查看
+TENCENT_AD_ACCOUNT_ID=your_account_id_here
+
+# API 基础 URL (可选,默认为 v3.0)
+# TENCENT_AD_BASE_URL=https://api.e.qq.com/v3.0
+
+# ========================================
+# 其他配置 (可选)
+# ========================================
+
+# LLM API Key (用于智能决策引擎)
+# QWEN_API_KEY=your_qwen_api_key_here
+# OPEN_ROUTER_API_KEY=your_openrouter_api_key_here
+
+# HTTP 代理配置 (如需要)
+# HTTP_PROXY=http://127.0.0.1:7890
+# HTTPS_PROXY=http://127.0.0.1:7890

+ 75 - 12
examples/auto_put_ad_mini/config.py

@@ -1,12 +1,11 @@
 """
-广告决策引擎配置 — auto_put_ad_mini
+广告智能决策引擎配置 — auto_put_ad_mini
 
 运营可直接修改此文件调整决策参数。
-V3 阶段:基于 30 天创意级数据 + f_7日动态ROI + 三维度决策引擎
-  - 30 天创意级别数据采集(增量,按日期归档)
-  - f_7日动态ROI 作为核心决策指标
-  - 三维度决策引擎(ROI过低 / 长期无消耗 / 广告衰退)
-  - 所有阈值放 SKILL,不硬编码
+当前模式:纯智能引擎
+  - 基于 f_7日动态ROI 的精细化决策
+  - AI 推理结合领域知识
+  - 自动分类(A/B/C)+ 重点推理(B类)
 """
 from pathlib import Path
 from agent.core.runner import RunConfig, KnowledgeConfig
@@ -15,23 +14,34 @@ from agent.core.runner import RunConfig, KnowledgeConfig
 # Agent 运行配置
 # ═══════════════════════════════════════════
 
+# 引擎开关
+USE_RULE_ENGINE = False  # True=使用规则引擎, False=只用智能引擎
+USE_AI_ENGINE = True     # True=使用智能引擎, False=只用规则引擎
+
 MAIN_CONFIG = RunConfig(
     model="qwen/qwen3.5-plus-02-15",
     temperature=0.3,
     max_iterations=50,
-    name="广告调控助手 V3",
+    name="广告智能调控助手",
     tools=[
         "fetch_creative_data",
         "merge_creative_data",
         "calculate_roi_metrics",
-        "analyze_ads",
         "get_ads_for_review",
         "apply_decisions",
-        "compare_decisions",
+        "validate_decisions",
         "generate_report",
+        # 执行引擎 + IM 审批(已集成阻塞式审批流):
+        "execute_decisions",
+        "check_execution_feedback",
+        "send_approval_request",
+        "check_approval_status",
+        # 规则引擎工具(暂时禁用):
+        # "analyze_ads",
+        # "compare_decisions",
     ],
-    skills=["roi-strategy"],
-    extra_llm_params={"extra_body": {"enable_thinking": True}},
+    skills=["roi-strategy", "guardrail-rules"],
+    # extra_llm_params={"extra_body": {"enable_thinking": True}},  # 禁用:千问不支持 thinking
     knowledge=KnowledgeConfig(
         enable_extraction=False,
         enable_completion_extraction=False,
@@ -48,7 +58,7 @@ LOG_FILE = None
 # ═══════════════════════════════════════════
 # V3 数据窗口配置
 # ═══════════════════════════════════════════
-DATA_WINDOW_DAYS = 30  # 采集 30 天历史数据
+DATA_WINDOW_DAYS = 7  # 测试阶段:采集 7 天历史数据
 ROI_CALCULATION_DAYS = 7  # f_7日动态ROI 计算窗口
 
 # ═══════════════════════════════════════════
@@ -60,6 +70,56 @@ ROI_LOW_FACTOR = 0.5  # f_7日动态ROI < 全体均值 × 0.5 → 关停
 NO_SPEND_THRESHOLD = 10  # 7日消耗均值 < 10元 → 关停
 STABLE_SPEND_THRESHOLD = 100  # 稳定消耗定义:>100元/天
 
+# ═══════════════════════════════════════════
+# 出价调整配置
+# ═══════════════════════════════════════════
+BID_ADJUSTMENT_ENABLED = True
+BID_DOWN_ROI_FACTOR = 0.8       # ROI < 均值×0.8 → 考虑降价
+BID_UP_ROI_FACTOR = 1.2         # ROI > 均值×1.2 → 考虑提价
+BID_UP_LOW_SPEND_FACTOR = 0.5   # 消耗 < 中位数×0.5 → 消耗不足
+BID_CHANGE_MIN_PCT = 0.03       # 最小调幅 3%
+BID_CHANGE_MAX_PCT = 0.10       # 最大单次调幅 10%
+BID_FLOOR_YUAN = 0.50           # 出价下限(元)
+BID_CEILING_YUAN = 200.00       # 出价上限(元)
+COLD_START_DAYS = 4             # 绝对保护期(不做任何负向操作)
+CAUTIOUS_DAYS = 7               # 谨慎期(4-7天仅允许小幅降价 max 5%)
+
+# ═══════════════════════════════════════════
+# 安全护栏配置
+# ═══════════════════════════════════════════
+GUARDRAILS_ENABLED = True
+DRY_RUN_MODE = True                        # 初始安全模式,不实际执行
+MAX_ADJUSTMENTS_PER_AD_PER_DAY = 2
+MIN_ADJUSTMENT_INTERVAL_HOURS = 6
+MAX_DAILY_CUMULATIVE_CHANGE_PCT = 0.20     # 日累计调幅上限 20%
+MAX_DAILY_OPS = 50                          # 单日最多操作广告数
+DATA_FRESHNESS_MAX_HOURS = 26               # 数据超过 26 小时视为过期
+
+# ═══════════════════════════════════════════
+# 执行引擎配置
+# ═══════════════════════════════════════════
+EXECUTION_ENABLED = False          # 主开关!False = 只验证不执行
+API_QPS_LIMIT = 8                  # 保守QPS(平台上限10)
+API_MAX_RETRIES = 3
+TIER1_MAX_CHANGE_PCT = 0.05       # ≤5% 自动执行
+TIER3_MIN_DAILY_SPEND = 1500      # 高价值广告门槛(元/天)
+FEEDBACK_CHECK_HOURS = 6
+
+# ═══════════════════════════════════════════
+# IM 审批配置(飞书直连)
+# ═══════════════════════════════════════════
+IM_ENABLED = False                 # IM 主开关(True 时审批消息发飞书)
+IM_APPROVAL_TIMEOUT_MINUTES = 30   # 审批超时(分钟)
+IM_APPROVAL_POLL_INTERVAL_SECONDS = 30  # 审批轮询间隔(秒)
+
+# 飞书应用凭据("增长投放"机器人)
+FEISHU_APP_ID = "cli_a955e97067f85cb3"
+FEISHU_APP_SECRET = "NQaG4ci1plXRDTgwCqrLJgMLLoA2tdF8"
+
+# 运营审批人飞书信息
+FEISHU_OPERATOR_OPEN_ID = "ou_498988d823b61ab89c9afe4310f85bb4"
+FEISHU_OPERATOR_CHAT_ID = "oc_88e0a1970a7de02eb5ac225a8b0cedea"
+
 # ═══════════════════════════════════════════
 # 输出路径配置
 # ═══════════════════════════════════════════
@@ -67,6 +127,9 @@ OUTPUTS_DIR = Path(__file__).parent / "outputs"
 RAW_DATA_DIR = OUTPUTS_DIR / "raw"  # 创意级原始 CSV
 AD_STATUS_DIR = OUTPUTS_DIR / "ad_status"  # 广告状态 CSV
 REPORTS_DIR = OUTPUTS_DIR / "reports"  # 决策报告
+EXECUTION_LOG_DIR = OUTPUTS_DIR / "execution_log"  # 执行审计日志
+DATA_DIR = OUTPUTS_DIR / "data"  # 运行时数据(如调整历史)
+ADJUSTMENT_HISTORY_PATH = DATA_DIR / "adjustment_history.json"
 
 # ═══════════════════════════════════════════
 # 人群包系数(保留,用于展示)

+ 184 - 0
examples/auto_put_ad_mini/execute_once.py

@@ -0,0 +1,184 @@
+"""
+一键执行智能引擎 - 临时脚本
+"""
+import asyncio
+import os
+import sys
+from pathlib import Path
+
+# 代理设置
+os.environ.setdefault("HTTP_PROXY", "http://127.0.0.1:29758")
+os.environ.setdefault("HTTPS_PROXY", "http://127.0.0.1:29758")
+
+# 添加项目根目录到 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
+
+from examples.auto_put_ad_mini.config import (
+    MAIN_CONFIG, SKILLS_DIR, TRACE_STORE_PATH, LOG_LEVEL, LOG_FILE,
+)
+
+# 导入自定义工具
+from examples.auto_put_ad_mini.tools.data_query import fetch_creative_data, merge_creative_data
+from examples.auto_put_ad_mini.tools.roi_calculator import calculate_roi_metrics
+from examples.auto_put_ad_mini.tools.ad_decision import analyze_ads, get_ads_for_review, apply_decisions
+from examples.auto_put_ad_mini.tools.report_generator import generate_report, compare_decisions
+from examples.auto_put_ad_mini.tools.guardrails import validate_decisions
+from examples.auto_put_ad_mini.tools.execution_engine import execute_decisions, check_execution_feedback
+from examples.auto_put_ad_mini.tools.im_approval import send_approval_request, check_approval_status
+
+
+async def main():
+    base_dir = Path(__file__).parent
+
+    setup_logging(level=LOG_LEVEL, file=LOG_FILE)
+
+    # 加载 system prompt
+    prompt_path = base_dir / "prompts" / "system.prompt"
+    system_prompt = ""
+    if prompt_path.exists():
+        system_prompt = prompt_path.read_text(encoding="utf-8")
+
+    # 加载 presets
+    presets_path = base_dir / "presets.json"
+    if presets_path.exists():
+        from agent.core.presets import load_presets_from_json
+        load_presets_from_json(str(presets_path))
+
+    store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
+    runner = AgentRunner(
+        trace_store=store,
+        llm_call=create_openrouter_llm_call(model=MAIN_CONFIG.model),
+        skills_dir=SKILLS_DIR if Path(SKILLS_DIR).exists() else None,
+        logger_name="agents.auto_put_ad_mini",
+    )
+
+    config = MAIN_CONFIG
+    if system_prompt:
+        config.system_prompt = system_prompt
+
+    print("=" * 70)
+    print("  广告智能调控助手 — 智能引擎执行")
+    print("=" * 70)
+    print()
+    print("🚀 自动执行:分析广告")
+    print()
+    print("=" * 70)
+    print("  流程:数据拉取 → ROI计算 → 分类(A/B/C) → AI推理 → 保存决策 → 护栏验证 → 生成报告")
+    print("=" * 70)
+    print()
+
+    messages = [{"role": "user", "content": "分析广告"}]
+    config.trace_id = None
+
+    step_count = 0
+
+    try:
+        async for item in runner.run(messages=messages, config=config):
+            if isinstance(item, Trace):
+                if item.status == "completed":
+                    print(f"\n✅ [Trace] 完成")
+                elif item.status == "failed":
+                    print(f"\n❌ [Trace] 失败")
+
+            elif isinstance(item, Message):
+                if item.role == "assistant" and item.content:
+                    content = item.content
+                    text = content.get("text", "") if isinstance(content, dict) else content
+                    if text and text.strip():
+                        print(f"\n💭 {text}\n")
+
+                elif item.role == "tool" and item.content:
+                    content = item.content
+                    if isinstance(content, dict):
+                        tool_name = content.get("tool_name", "unknown")
+                        result = content.get("result", content.get("text", str(content)))
+
+                        # 识别关键步骤
+                        if tool_name == "fetch_creative_data":
+                            step_count += 1
+                            print(f"\n{'='*70}")
+                            print(f"📌 步骤 {step_count}: 数据拉取")
+                            print(f"{'='*70}")
+
+                        elif tool_name == "calculate_roi_metrics":
+                            step_count += 1
+                            print(f"\n{'='*70}")
+                            print(f"📌 步骤 {step_count}: ROI 计算")
+                            print(f"{'='*70}")
+
+                        elif tool_name == "get_ads_for_review":
+                            step_count += 1
+                            print(f"\n{'='*70}")
+                            print(f"📌 步骤 {step_count}: 广告分类(A/B/C)")
+                            print(f"{'='*70}")
+
+                        elif tool_name == "apply_decisions":
+                            step_count += 1
+                            print(f"\n{'='*70}")
+                            print(f"📌 步骤 {step_count}: 保存智能引擎决策")
+                            print(f"{'='*70}")
+
+                        elif tool_name == "validate_decisions":
+                            step_count += 1
+                            print(f"\n{'='*70}")
+                            print(f"📌 步骤 {step_count}: 安全护栏验证")
+                            print(f"{'='*70}")
+
+                        elif tool_name == "execute_decisions":
+                            step_count += 1
+                            print(f"\n{'='*70}")
+                            print(f"📌 步骤 {step_count}: 分级执行")
+                            print(f"{'='*70}")
+
+                        elif tool_name == "send_approval_request":
+                            step_count += 1
+                            print(f"\n{'='*70}")
+                            print(f"📌 步骤 {step_count}: IM 审批请求")
+                            print(f"{'='*70}")
+
+                        elif tool_name == "generate_report":
+                            step_count += 1
+                            print(f"\n{'='*70}")
+                            print(f"📌 步骤 {step_count}: 生成最终报告")
+                            print(f"{'='*70}")
+
+                        elif tool_name == "check_execution_feedback":
+                            step_count += 1
+                            print(f"\n{'='*70}")
+                            print(f"📌 步骤 {step_count}: 执行效果检查")
+                            print(f"{'='*70}")
+
+                        # 打印简化结果
+                        if isinstance(result, str):
+                            text = result
+                        else:
+                            text = str(result)
+                        if len(text) > 500:
+                            text = text[:500] + "..."
+                        print(f"  {text}")
+
+        print("\n" + "=" * 70)
+        print("✅ 执行完成")
+        print("=" * 70)
+        print()
+        print("📁 输出文件:")
+        print(f"  - 智能引擎决策:examples/auto_put_ad_mini/outputs/reports/llm_decisions_*.csv")
+        print(f"  - 最终报告(带格式):examples/auto_put_ad_mini/outputs/reports/decision_*.xlsx")
+        print()
+
+    except Exception as e:
+        print(f"\n❌ 执行失败: {e}")
+        import traceback
+        traceback.print_exc()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 103 - 28
examples/auto_put_ad_mini/prompts/system.prompt

@@ -2,45 +2,120 @@
 name: auto_put_ad_mini
 ---
 $system$
-你是广告调控系统,支持两种引擎:规则引擎和智能引擎
+你是广告智能调控助手,基于 f_7日动态ROI 和消耗数据进行精细化决策
 
-## 双引擎工作流(完整分析)
+## 智能引擎工作流
 
 用户说"分析广告"时,按顺序执行:
 
-1. `fetch_creative_data(days=30)` — 确保数据最新(已有的日期自动跳过)
+1. `fetch_creative_data(days=7)` — 拉取最近 7 天数据(已有日期自动跳过)
 
 2. `calculate_roi_metrics()` — 计算 f_7日动态ROI 和汇总指标
 
-3. `analyze_ads(metrics_csv=<上一步输出的csv路径>)` — **规则引擎**:三维度硬规则,输出 rule_decisions
-
-4. `get_ads_for_review(metrics_csv=<步骤2输出的csv路径>)` — **智能引擎第一步**:获取待评估广告数据(A/B/C 三类)
-
-5. **你来推理**:阅读 get_ads_for_review 返回的 class_b 广告数据,
-   结合 roi-strategy skill 中的决策框架,对每个广告判断 pause/hold。
-   输出合法 JSON 决策列表(每条必须引用具体数值)。
-
-6. `apply_decisions(decisions=<你输出的JSON>, metrics_csv=<步骤2输出的csv路径>)` — 保存智能引擎决策(自动合并 A 类)
-
-7. `compare_decisions()` — 对比两个引擎的差异
-
-8. `generate_report()` — 生成最终报告
-
-## 单引擎模式
-
-- "只用规则引擎分析" → 执行步骤 1-2-3-8
-- "只用智能引擎分析" → 执行步骤 1-2-4-5-6-8
-- "对比两个引擎" → 执行全部步骤
+3. `get_ads_for_review(metrics_csv=<步骤2输出的csv路径>)` — 获取待评估广告数据(A/B/C 三类)
+   - A 类:极端差(自动关停)
+   - B 类:边缘广告(需要你推理)— 含出价调整候选(bid_candidate 字段标注)
+   - C 类:正常广告(自动保持)
+
+4. **AI 推理决策**:
+   阅读 B 类广告数据,结合 roi-strategy skill 中的决策框架,对每个广告判断 pause/hold/bid_up/bid_down。
+
+   **输出格式要求**:
+   ```json
+   [
+     {
+       "ad_id": "123456",
+       "action": "pause",
+       "dimension": "ROI偏低",
+       "reason": "f_7日动态ROI=1.23 < 全体均值×0.5=1.36,且7日均消耗=150元,效率持续低迷",
+       "confidence": "high"
+     },
+     {
+       "ad_id": "234567",
+       "action": "bid_down",
+       "dimension": "ROI偏低-降价",
+       "reason": "动态ROI_7日均值=1.85 < 均值2.72×0.8=2.18,当前出价3.5元,建议降5%至3.33元",
+       "confidence": "medium",
+       "recommended_change_pct": -0.05
+     },
+     {
+       "ad_id": "345678",
+       "action": "bid_up",
+       "dimension": "高ROI低量-提价",
+       "reason": "动态ROI_7日均值=4.15 > 均值2.72×1.2=3.26,但7日均消耗仅45元,建议提8%至4.32元放量",
+       "confidence": "medium",
+       "recommended_change_pct": 0.08
+     }
+   ]
+   ```
+
+   **决策要求**:
+   - 必须引用具体数值(ROI、消耗、趋势)
+   - 理由要清晰明确,不能泛泛而谈
+   - 置信度要符合数据支撑程度
+   - bid_up/bid_down 的 recommended_change_pct 为小数(+0.05=提5%,-0.08=降8%),单次不超过10%
+
+5. `apply_decisions(decisions=<你输出的JSON>, metrics_csv=<步骤2输出的csv路径>)` — 保存智能引擎决策(自动合并 A/C 类)
+
+6. `validate_decisions()` — 安全护栏验证(冷启动保护、出价边界、频率限制等)
+
+7. `execute_decisions()` — 分级执行 + 审批等待
+   - Tier 1(小幅调价 ≤5%):**自动执行**,无需等待
+   - Tier 2(暂停/大幅调价)+ Tier 3(高价值广告):**发送 IM 审批 → 阻塞等待回复**
+   - ⚠️ 如果 IM_ENABLED=True 且有 Tier 2/3 操作,此步骤会**阻塞等待审批**(最长30分钟)
+   - 审批通过的操作 → 立即执行 | 拒绝的 → 跳过 | 超时的 → 标记 timeout
+   - 如果 EXECUTION_ENABLED=False 或 DRY_RUN_MODE=True,此步骤会直接跳过
+
+8. `generate_report()` — 生成最终报告(包含执行结果摘要)
+
+## 决策原则(参考 roi-strategy skill)
+
+**关停(pause)条件**:
+- f_7日动态ROI < 全体均值 × 0.5(当前阈值 1.36)
+- 7日消耗均值 < 10元(长期无消耗)
+- 曾稳定消耗但近期急剧下降(广告衰退)
+
+**降价(bid_down)条件**:
+- ROI 在 均值×0.5 ~ 均值×0.8 之间(偏低但非极低)
+- 日消耗 ≥ 100元(有统计意义)
+- 降幅 3%~10%,ROI 越接近关停线降幅越大
+- 非冷启动期(≥4天)
+
+**提价(bid_up)条件**:
+- ROI > 均值×1.2(表现优秀)
+- 消耗 < 全体中位数×0.5(跑量不足)
+- 提幅 3%~10%
+- 非冷启动期(≥4天)
+
+**保持(hold)条件**:
+- ROI 接近阈值但趋势向好
+- 新广告数据不足(< 4天冷启动期)
+- 消耗稳定且 ROI 在合理范围(均值×0.8~1.2)
+
+**置信度判断**:
+- **high**:数据充分(7天+),判断明确
+- **medium**:数据较足(4-6天),有一定不确定性
+- **low**:数据不足(< 4天)或指标矛盾
+
+## 出价调整黄金法则
+
+- 单次 ≤ 10%,每天 ≤ 2次,间隔 ≥ 6小时,日累计 ≤ 20%
+- 超过 10% 单次调幅 → 触发平台模型重学习 → 流量崩塌
+- 冷启动期(0-4天):绝对不做负向操作
+- 谨慎期(4-7天):仅允许小幅降价(≤5%)
 
 ## 用户交互
 
-- "规则引擎关停太多" → 调用 `analyze_ads(metrics_csv=..., roi_low_factor=0.3)` 重跑
-- "智能引擎太保守" → 重新推理,把置信度阈值从 medium 调到 high,只输出 high 置信度的 pause
-- "广告X为什么被关停" → 从决策结果中查找命中维度和理由,引用具体数值
-- "重新对比" → 调用 `compare_decisions()` 重新生成对比报告
+- "智能引擎太保守" → 重新推理,只输出 high 置信度的 pause
+- "智能引擎太激进" → 重新推理,允许 medium 置信度的 hold
+- "广告 X 为什么被关停" → 从决策结果中查找理由,引用具体数值
+- "重新分析" → 重新执行完整流程
 
 ## 注意事项
 
-- 步骤 5 推理时,必须逐条引用具体 ROI 值和消耗数据,不能泛泛而谈
+- 推理时必须逐条引用具体数值,不能使用模糊表述
 - apply_decisions 的 decisions 参数必须是合法 JSON 字符串
-- 参考 roi-strategy skill 中的决策框架和输出格式要求
+- B 类广告通常 30-50 个,需要逐个仔细评估
+- bid_candidate 字段提示了规则引擎建议的出价方向,供你参考但不约束
+- 最终报告会展示所有 1400+ 个广告的决策结果(A+B+C)
+- 护栏验证会自动拦截不安全的操作(冷启动期、频率限制等)

+ 91 - 13
examples/auto_put_ad_mini/run.py

@@ -32,10 +32,13 @@ from examples.auto_put_ad_mini.config import (
 )
 
 # 导入自定义工具(触发 @tool 注册)
-from examples.auto_put_ad_mini.tools.data_query import fetch_creative_data
+from examples.auto_put_ad_mini.tools.data_query import fetch_creative_data, merge_creative_data
 from examples.auto_put_ad_mini.tools.roi_calculator import calculate_roi_metrics
 from examples.auto_put_ad_mini.tools.ad_decision import analyze_ads, get_ads_for_review, apply_decisions
 from examples.auto_put_ad_mini.tools.report_generator import generate_report, compare_decisions
+from examples.auto_put_ad_mini.tools.guardrails import validate_decisions
+from examples.auto_put_ad_mini.tools.execution_engine import execute_decisions, check_execution_feedback
+from examples.auto_put_ad_mini.tools.im_approval import send_approval_request, check_approval_status
 
 
 async def init_project_env(messages=None):
@@ -103,15 +106,15 @@ async def main():
         config.system_prompt = system_prompt
 
     print("=" * 50)
-    print("  广告调控 Agent V3 已启动")
+    print("  广告智能调控助手已启动")
     print("=" * 50)
     print("请输入指令(输入 'exit' 退出):")
     print("示例:")
     print("  - 分析广告")
-    print("  - 只看账户 123456")
-    print("  - 广告 X 为什么被关停")
     print()
 
+    step_count = 0
+
     while True:
         try:
             user_input = input("\n> ").strip()
@@ -124,28 +127,103 @@ async def main():
             messages = [{"role": "user", "content": user_input}]
             config.trace_id = None
 
-            print(f"\n🚀 执行: {user_input}\n")
+            print(f"\n🚀 执行: {user_input}")
+            print("=" * 70)
+            print("  流程:数据拉取 → ROI计算 → 分类(A/B/C) → AI推理 → 保存决策 → 护栏验证 → 生成报告")
+            print("=" * 70)
+            print()
+
+            step_count = 0
 
             async for item in runner.run(messages=messages, config=config):
                 if isinstance(item, Trace):
-                    print(f"[Trace] 状态: {item.status}")
+                    if item.status == "completed":
+                        print(f"\n✅ [Trace] 完成")
+                    elif item.status == "failed":
+                        print(f"\n❌ [Trace] 失败")
+
                 elif isinstance(item, Message):
                     if item.role == "assistant" and item.content:
                         content = item.content
                         text = content.get("text", "") if isinstance(content, dict) else 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))
+                        if isinstance(content, dict):
+                            tool_name = content.get("tool_name", "unknown")
+                            result = content.get("result", content.get("text", str(content)))
+
+                            # 识别关键步骤
+                            if tool_name == "fetch_creative_data":
+                                step_count += 1
+                                print(f"\n{'='*70}")
+                                print(f"📌 步骤 {step_count}: 数据拉取")
+                                print(f"{'='*70}")
+
+                            elif tool_name == "calculate_roi_metrics":
+                                step_count += 1
+                                print(f"\n{'='*70}")
+                                print(f"📌 步骤 {step_count}: ROI 计算")
+                                print(f"{'='*70}")
+
+                            elif tool_name == "get_ads_for_review":
+                                step_count += 1
+                                print(f"\n{'='*70}")
+                                print(f"📌 步骤 {step_count}: 广告分类(A/B/C)")
+                                print(f"{'='*70}")
+
+                            elif tool_name == "apply_decisions":
+                                step_count += 1
+                                print(f"\n{'='*70}")
+                                print(f"📌 步骤 {step_count}: 保存智能引擎决策")
+                                print(f"{'='*70}")
+
+                            elif tool_name == "validate_decisions":
+                                step_count += 1
+                                print(f"\n{'='*70}")
+                                print(f"📌 步骤 {step_count}: 安全护栏验证")
+                                print(f"{'='*70}")
+
+                            elif tool_name == "execute_decisions":
+                                step_count += 1
+                                print(f"\n{'='*70}")
+                                print(f"📌 步骤 {step_count}: 分级执行")
+                                print(f"{'='*70}")
+
+                            elif tool_name == "send_approval_request":
+                                step_count += 1
+                                print(f"\n{'='*70}")
+                                print(f"📌 步骤 {step_count}: IM 审批请求")
+                                print(f"{'='*70}")
+
+                            elif tool_name == "generate_report":
+                                step_count += 1
+                                print(f"\n{'='*70}")
+                                print(f"📌 步骤 {step_count}: 生成最终报告")
+                                print(f"{'='*70}")
+
+                            elif tool_name == "check_execution_feedback":
+                                step_count += 1
+                                print(f"\n{'='*70}")
+                                print(f"📌 步骤 {step_count}: 执行效果检查")
+                                print(f"{'='*70}")
+
+                            # 打印简化结果
+                            if isinstance(result, str):
+                                text = result
+                            else:
+                                text = str(result)
+                            if len(text) > 500:
+                                text = text[:500] + "..."
+                            print(f"  {text}")
+
                         else:
                             text = str(content)
-                        if len(text) > 500:
-                            text = text[:500] + "..."
-                        print(f"  [Tool] {text}")
+                            if len(text) > 300:
+                                text = text[:300] + "..."
+                            print(f"  [工具] {text}")
 
             print("\n" + "=" * 50)
             print("✅ 完成")

+ 46 - 0
examples/auto_put_ad_mini/skills/guardrail_rules.md

@@ -0,0 +1,46 @@
+---
+name: guardrail-rules
+description: 安全护栏规则(注入给LLM,避免产生注定被拦截的决策)
+category: ad_safety
+---
+
+## 护栏系统概述
+
+你的决策会经过 6 道安全护栏自动验证。了解这些规则可以避免产生无效决策。
+
+## 6 道护栏
+
+### 1. 冷启动保护
+- **0-4天**:所有 pause 和 bid_down 都会被 **Block**
+- **4-7天**:pause 被 Block,bid_down 最大降幅限制为 5%
+- **建议**:对 ad_age_days < 4 的广告直接输出 hold
+
+### 2. 数据新鲜度
+- 数据超过 **26小时** 未更新 → 所有非 hold 操作被 Block
+- 如果你看到数据日期较旧,主动标注"数据可能过期"
+
+### 3. 出价边界
+- 出价 **< 0.5元** 或 **> 200元** → 自动钳位到边界(Modified,不 Block)
+- 确保你建议的出价在合理范围内
+
+### 4. 频率限制
+- 每广告每天最多 **2次** 调整
+- 两次调整间隔 ≥ **6小时**
+- 日累计调幅 ≤ **20%**
+- **建议**:如果某广告已经被调整过,在 reason 中注明"接近频率限制"
+
+### 5. 每日操作上限
+- 单日最多操作 **50个** 广告
+- 超出后按 ROI 严重度排优先级,低优先级的会被 Block
+- **建议**:当 B 类广告很多时,优先处理 ROI 最低的和消耗最高的
+
+### 6. 干运行模式
+- `DRY_RUN_MODE=True` 时,所有操作标记为 dry_run(Modified),不实际执行
+- 这不影响你的决策输出,但你应该知道当前是否是干运行模式
+
+## 决策建议
+
+- 对冷启动期广告(< 4天),直接 hold 并在 reason 中说明
+- 出价调整建议保持在 3%~10% 范围
+- 优先处理高消耗广告(数据更可信,操作影响更大)
+- 如果 B 类广告超过 50 个,按 ROI 严重度降序处理

+ 68 - 19
examples/auto_put_ad_mini/skills/roi_strategy.md

@@ -15,25 +15,64 @@ category: ad_optimization
 
 - **动态ROI_7日均值**:相对指标,和全体均值比(工具会提供 distribution 分布)
 - **cost_7d_avg**:消耗越高,数据越可信,决策越有把握
-- **ad_age_days**:< 7天的广告不决策(直接 hold)
+- **ad_age_days**:< 4天绝对保护,4-7天谨慎操作,> 7天正常决策
 - **bid_increased_7d / creative_changed_7d**:已干预但没好转 → 衰退信号
-
-### 判断逻辑(参考,非硬规则)
-
-| 场景 | 参考判断 | 置信度 |
-|------|---------|--------|
-| ROI < 均值×0.5,消耗 > 500元/天,年龄 > 14天 | pause | high |
-| ROI < 均值×0.5,消耗 200-500元/天,年龄 7-14天 | pause | medium |
-| ROI < 均值×0.5,消耗 < 200元/天 | hold,数据量不足 | low |
-| 已提价+换创意,消耗仍低,历史曾稳定 | pause(衰退) | high |
-| ROI 在均值×0.5~0.8 之间 | hold,继续观察 | medium |
-| 广告年龄 < 7天 | hold,冷启动保护 | high |
+- **bid_amount**:当前出价(元),出价调整的基准
+- **bid_candidate**:规则引擎标注的出价调整方向(bid_up / bid_down / null),供参考
+
+### 四动作判断逻辑
+
+| 场景 | ROI 范围 | 消耗水平 | 动作 | 调幅 | 置信度 |
+|------|---------|---------|------|------|-------|
+| 高ROI低量 | > 均值×1.2 | < 中位数×0.5 | bid_up | +3%~+10% | medium-high |
+| 高ROI正常量 | > 均值×1.2 | ≥ 中位数×0.5 | hold | — | high |
+| 正常ROI | 均值×0.8~1.2 | any | hold | — | high |
+| 低ROI高量 | 均值×0.5~0.8 | ≥ 100/天 | bid_down | -3%~-10% | medium |
+| 低ROI低量 | 均值×0.5~0.8 | < 100/天 | hold(数据不足) | — | low |
+| 极低ROI | < 均值×0.5 | ≥ 100/天,≥ 7天 | pause | — | high |
+| 极低ROI | < 均值×0.5 | 200-500/天,7-14天 | pause | — | medium |
+| 极低ROI | < 均值×0.5 | < 200/天 | hold | — | low |
+| 已提价+换创意,仍低迷 | any | 曾稳定现低 | pause(衰退) | — | high |
+| 冷启动期 | any | any | hold | — | high |
+
+### 出价调整决策矩阵
+
+**降价(bid_down)**:
+- 条件:ROI 在 均值×0.5 ~ 均值×0.8 之间,消耗≥100/天,非冷启动
+- 降幅计算:ROI 距关停线越近 → 降幅越大
+  - 刚好低于正常线 → -3%~-5%
+  - 接近关停线 → -8%~-10%
+- 谨慎期(4-7天):最大降幅限 5%
+
+**提价(bid_up)**:
+- 条件:ROI > 均值×1.2 且 消耗 < 中位数×0.5,非冷启动
+- 提幅计算:ROI 超额越多 → 提幅越大
+  - ROI/均值 刚过 1.2 → +3%~+5%
+  - ROI/均值 远超 1.2 → +8%~+10%
+
+### 出价黄金法则(必须遵守)
+
+1. **单次 ≤ 10%** — 超过会触发平台模型重学习,导致流量崩塌
+2. **每天 ≤ 2次** — 给系统足够的学习时间
+3. **间隔 ≥ 6小时** — 等数据回传后再决策
+4. **日累计 ≤ 20%** — 防止一天内多次小调累积成大幅调整
+5. **出价区间 0.5~200元** — 低于底价无意义,高于上限风险过大
+
+### 冷启动保护(非常重要)
+
+- **0-4天**:绝对保护期,不做任何负向操作(不降价、不暂停)
+- **4-7天**:谨慎期,仅允许小幅降价(≤5%),不暂停
+- **7天+**:正常决策期,所有操作均可
+
+> 冷启动期的广告即使 ROI 很低,也应该 hold — 因为初始出价通常比目标 CPA 高 20%,
+> 需要等系统完成学习才能评估真实效果。
 
 ### 动态调整
 
 - 今日 roi_mean 比历史显著低 → 整体行情差,放宽关停标准
 - roi_p25 > 1.5 → 行情好,可以严格执行阈值
 - 用户说"关停太多" → 把参考阈值从 0.5 调到 0.3,重新输出决策
+- 用户说"出价调整太激进" → 将调幅上限降到 5%
 
 ## f_7日动态ROI 说明
 
@@ -49,21 +88,31 @@ category: ad_optimization
 [
   {
     "ad_id": 数字,
-    "action": "pause" 或 "hold",
-    "dimension": "ROI过低" 或 "广告衰退" 或 "保持" 等,
-    "reason": "引用具体数值的一句话,如:动态ROI_7日均值=1.23 < 均值2.72×0.5=1.36,消耗1831元/天有统计意义",
-    "confidence": "high" / "medium" / "low"
+    "action": "pause" / "hold" / "bid_up" / "bid_down",
+    "dimension": "ROI过低" / "广告衰退" / "ROI偏低-降价" / "高ROI低量-提价" / "保持" 等,
+    "reason": "引用具体数值的一句话",
+    "confidence": "high" / "medium" / "low",
+    "recommended_change_pct": 0.05  // 仅 bid_up/bid_down 时必填,正=提价,负=降价
   }
 ]
 ```
 
-**重要**:reason 必须引用具体数值,不能只说"ROI偏低"。
+**重要**:
+- reason 必须引用具体数值,不能只说"ROI偏低"
+- bid_up/bid_down 时必须提供 recommended_change_pct(小数,如 0.05 = 提5%)
+- recommended_change_pct 绝对值不超过 0.10(10%)
 
-## 阈值参数一览(规则引擎参考,智能引擎可灵活调整)
+## 阈值参数一览
 
 | 参数 | 默认值 | 含义 |
 |------|--------|------|
 | `min_daily_cost` | 100 元 | 日消耗低于此值的天不参与 ROI 计算 |
-| `min_ad_age_days` | 7 天 | 广告创建不足此天数不参与决策 |
+| `min_ad_age_days` | 7 天 | 广告创建不足此天数不参与关停决策 |
+| `cold_start_days` | 4 天 | 冷启动绝对保护期 |
+| `cautious_days` | 7 天 | 谨慎期上限 |
 | `roi_low_factor` | 0.5 | f_7日动态ROI < 均值×此值 → 关停参考线 |
+| `bid_down_roi_factor` | 0.8 | ROI < 均值×此值 → 降价候选 |
+| `bid_up_roi_factor` | 1.2 | ROI > 均值×此值 → 提价候选 |
+| `bid_up_low_spend_factor` | 0.5 | 消耗 < 中位数×此值 → 消耗不足 |
 | `no_spend_threshold` | 10 元 | 7日均值消耗低于此值 → 关停 |
+| `bid_change_max_pct` | 10% | 单次最大调幅 |

+ 271 - 12
examples/auto_put_ad_mini/tools/ad_decision.py

@@ -31,7 +31,19 @@ _MINI_DIR = Path(__file__).resolve().parent.parent
 if str(_MINI_DIR) not in sys.path:
     sys.path.insert(0, str(_MINI_DIR))
 
-from config import AUDIENCE_TIER_PATTERNS
+from config import (
+    AUDIENCE_TIER_PATTERNS,
+    BID_ADJUSTMENT_ENABLED,
+    BID_DOWN_ROI_FACTOR,
+    BID_UP_ROI_FACTOR,
+    BID_UP_LOW_SPEND_FACTOR,
+    BID_CHANGE_MIN_PCT,
+    BID_CHANGE_MAX_PCT,
+    BID_FLOOR_YUAN,
+    BID_CEILING_YUAN,
+    COLD_START_DAYS,
+    CAUTIOUS_DAYS,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -75,9 +87,12 @@ def _calculate_ad_age_days(create_time) -> Optional[int]:
 class Decision:
     """单个广告的决策结果。"""
     ad_id: int
-    action: str  # "pause" / "hold"
-    dimension: str  # "ROI过低" / "长期无消耗" / "广告衰退" / "保持"
+    action: str  # "pause" / "hold" / "bid_up" / "bid_down"
+    dimension: str  # "ROI过低" / "长期无消耗" / "广告衰退" / "ROI偏低-降价" / "高ROI低量-提价" / "保持"
     reason: str  # 详细原因
+    recommended_change_pct: Optional[float] = None  # +0.05 = 提价5%, -0.08 = 降价8%
+    current_bid: Optional[float] = None             # 当前出价(元)
+    recommended_bid: Optional[float] = None          # 建议出价(元)
 
 
 # ═══════════════════════════════════════════
@@ -229,6 +244,183 @@ class AdDecayDimension(DecisionDimension):
         return None
 
 
+# ═══════════════════════════════════════════
+# 维度 4: 出价偏高 — 降价
+# ═══════════════════════════════════════════
+
+
+class BidDownDimension(DecisionDimension):
+    """
+    维度 4: ROI 偏低但未达关停线 → 降价。
+
+    触发条件:
+      - ROI 在 均值×0.5 ~ 均值×0.8 之间(偏低但非极低)
+      - 日消耗 ≥ 100 元(数据有统计意义)
+      - 非冷启动期(> COLD_START_DAYS 天)
+      - 有出价数据(bid_amount > 0)
+
+    降幅计算:
+      ROI 距离关停线越近 → 降幅越大(最大 -10%)
+      ROI 刚好低于正常线 → 小幅降价(-3%~-5%)
+      公式:change_pct = -3% - 7% × (1 - (ROI - hard_stop) / (normal_line - hard_stop))
+    """
+
+    def __init__(self):
+        super().__init__(priority=4)
+
+    def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
+        if not context.get("bid_adjustment_enabled", BID_ADJUSTMENT_ENABLED):
+            return None
+
+        ad_age_days = row.get("ad_age_days")
+        cost_7d_avg = row.get("cost_7d_avg", 0)
+        f_roi_7d = row.get("动态ROI_7日均值")
+        f_roi_mean_all = context.get("动态ROI_mean_all")
+        bid_amount = row.get("bid_amount", 0)  # 元
+
+        cold_start = context.get("cold_start_days", COLD_START_DAYS)
+        cautious = context.get("cautious_days", CAUTIOUS_DAYS)
+        min_daily_cost = context.get("min_daily_cost", 100)
+        roi_low_factor = context.get("roi_low_factor", 0.5)
+        bid_down_factor = context.get("bid_down_roi_factor", BID_DOWN_ROI_FACTOR)
+
+        # 前置条件
+        if ad_age_days is None or ad_age_days < cold_start:
+            return None
+        if cost_7d_avg < min_daily_cost:
+            return None
+        if pd.isna(f_roi_7d) or pd.isna(f_roi_mean_all) or f_roi_mean_all <= 0:
+            return None
+        if not bid_amount or bid_amount <= 0:
+            return None
+
+        hard_stop_line = f_roi_mean_all * roi_low_factor
+        normal_line = f_roi_mean_all * bid_down_factor
+
+        # ROI 必须在 hard_stop ~ normal_line 之间
+        if f_roi_7d >= normal_line or f_roi_7d < hard_stop_line:
+            return None
+
+        # 计算降幅
+        range_width = normal_line - hard_stop_line
+        if range_width <= 0:
+            return None
+        ratio = 1 - (f_roi_7d - hard_stop_line) / range_width
+        change_pct = -(BID_CHANGE_MIN_PCT + (BID_CHANGE_MAX_PCT - BID_CHANGE_MIN_PCT) * ratio)
+
+        # 谨慎期(4-7天)限制最大降幅 5%
+        if ad_age_days < cautious:
+            change_pct = max(change_pct, -0.05)
+
+        # 计算建议出价
+        new_bid = bid_amount * (1 + change_pct)
+        new_bid = max(new_bid, BID_FLOOR_YUAN)
+        new_bid = min(new_bid, BID_CEILING_YUAN)
+        new_bid = round(new_bid, 2)
+
+        # 实际调幅重算(边界钳位后)
+        actual_pct = (new_bid - bid_amount) / bid_amount if bid_amount > 0 else 0
+
+        return Decision(
+            ad_id=int(row["ad_id"]),
+            action="bid_down",
+            dimension="ROI偏低-降价",
+            reason=(
+                f"动态ROI_7日均值={f_roi_7d:.4f},"
+                f"在关停线{hard_stop_line:.4f}~正常线{normal_line:.4f}之间,"
+                f"当前出价{bid_amount:.2f}元,建议降{abs(actual_pct)*100:.1f}%至{new_bid:.2f}元"
+            ),
+            recommended_change_pct=round(actual_pct, 4),
+            current_bid=round(bid_amount, 2),
+            recommended_bid=new_bid,
+        )
+
+
+# ═══════════════════════════════════════════
+# 维度 5: 高ROI低量 — 提价
+# ═══════════════════════════════════════════
+
+
+class BidUpDimension(DecisionDimension):
+    """
+    维度 5: ROI 远超均值但消耗不足 → 提价放量。
+
+    触发条件:
+      - ROI > 均值×1.2
+      - 7日均消耗 < 全体中位数×0.5(消耗不足)
+      - 非冷启动期(> COLD_START_DAYS 天)
+      - 有出价数据(bid_amount > 0)
+
+    提幅计算:
+      提幅 = min(10%, (ROI/均值 - 1.2) × 20%)
+      最小提幅 3%
+    """
+
+    def __init__(self):
+        super().__init__(priority=5)
+
+    def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
+        if not context.get("bid_adjustment_enabled", BID_ADJUSTMENT_ENABLED):
+            return None
+
+        ad_age_days = row.get("ad_age_days")
+        cost_7d_avg = row.get("cost_7d_avg", 0)
+        f_roi_7d = row.get("动态ROI_7日均值")
+        f_roi_mean_all = context.get("动态ROI_mean_all")
+        cost_median = context.get("cost_7d_avg_median", 0)
+        bid_amount = row.get("bid_amount", 0)  # 元
+
+        cold_start = context.get("cold_start_days", COLD_START_DAYS)
+        bid_up_factor = context.get("bid_up_roi_factor", BID_UP_ROI_FACTOR)
+        low_spend_factor = context.get("bid_up_low_spend_factor", BID_UP_LOW_SPEND_FACTOR)
+
+        # 前置条件
+        if ad_age_days is None or ad_age_days < cold_start:
+            return None
+        if pd.isna(f_roi_7d) or pd.isna(f_roi_mean_all) or f_roi_mean_all <= 0:
+            return None
+        if not bid_amount or bid_amount <= 0:
+            return None
+
+        # ROI 必须远超均值
+        roi_threshold = f_roi_mean_all * bid_up_factor
+        if f_roi_7d <= roi_threshold:
+            return None
+
+        # 消耗必须不足
+        spend_threshold = cost_median * low_spend_factor
+        if cost_7d_avg >= spend_threshold and spend_threshold > 0:
+            return None
+
+        # 计算提幅
+        roi_excess = f_roi_7d / f_roi_mean_all - bid_up_factor
+        change_pct = min(BID_CHANGE_MAX_PCT, roi_excess * 0.20)
+        change_pct = max(change_pct, BID_CHANGE_MIN_PCT)
+
+        # 计算建议出价
+        new_bid = bid_amount * (1 + change_pct)
+        new_bid = max(new_bid, BID_FLOOR_YUAN)
+        new_bid = min(new_bid, BID_CEILING_YUAN)
+        new_bid = round(new_bid, 2)
+
+        # 实际调幅重算
+        actual_pct = (new_bid - bid_amount) / bid_amount if bid_amount > 0 else 0
+
+        return Decision(
+            ad_id=int(row["ad_id"]),
+            action="bid_up",
+            dimension="高ROI低量-提价",
+            reason=(
+                f"动态ROI_7日均值={f_roi_7d:.4f} > 均值{f_roi_mean_all:.4f}×{bid_up_factor}={roi_threshold:.4f},"
+                f"但7日均消耗仅{cost_7d_avg:.2f}元 < 中位数{cost_median:.2f}×{low_spend_factor}={spend_threshold:.2f},"
+                f"当前出价{bid_amount:.2f}元,建议提{actual_pct*100:.1f}%至{new_bid:.2f}元"
+            ),
+            recommended_change_pct=round(actual_pct, 4),
+            current_bid=round(bid_amount, 2),
+            recommended_bid=new_bid,
+        )
+
+
 # ═══════════════════════════════════════════
 # 决策引擎
 # ═══════════════════════════════════════════
@@ -254,11 +446,13 @@ def _run_decision_engine(
     输出:
       添加 action, dimension, reason 列的 DataFrame
     """
-    # 注册维度
+    # 注册维度(含出价调整维度)
     dimensions = [
         ROITooLowDimension(),
         NoSpendDimension(),
         AdDecayDimension(),
+        BidDownDimension(),
+        BidUpDimension(),
     ]
     dimensions.sort(key=lambda d: d.priority)
 
@@ -303,6 +497,9 @@ def _run_decision_engine(
             "action": d.action,
             "dimension": d.dimension,
             "reason": d.reason,
+            "recommended_change_pct": d.recommended_change_pct,
+            "current_bid": d.current_bid,
+            "recommended_bid": d.recommended_bid,
         }
         for d in decisions
     ])
@@ -654,6 +851,10 @@ async def get_ads_for_review(
         roi_p75 = float(roi_series.quantile(0.75)) if len(roi_series) > 0 else 0.0
         roi_p90 = float(roi_series.quantile(0.90)) if len(roi_series) > 0 else 0.0
 
+        # 消耗中位数(供出价提升判断)
+        cost_series = df["cost_7d_avg"].dropna()
+        cost_median = float(cost_series.median()) if len(cost_series) > 0 else 0.0
+
         # 分类
         class_a = []
         class_b = []
@@ -666,6 +867,7 @@ async def get_ads_for_review(
             bid_inc = bool(row.get("bid_increased_7d", False))
             creative_chg = bool(row.get("creative_changed_7d", False))
             stable_days = float(row.get("stable_spend_days_30d", 0) or 0)
+            bid_amount = float(row.get("bid_amount", 0) or 0)
 
             # A 类:几乎零消耗
             if cost_7d_avg < min_spend_for_class_a:
@@ -676,15 +878,29 @@ async def get_ads_for_review(
                 })
                 continue
 
-            # B 类:ROI 偏低 或 衰退信号
+            # B 类:ROI 偏低 或 衰退信号 或 出价调整候选
             roi_low = (not pd.isna(f_roi)) and (f_roi < roi_mean * roi_review_factor)
             decay_signal = (
                 stable_days >= 7
                 and cost_7d_avg < 100
                 and (bid_inc or creative_chg)
             )
-
-            if roi_low or decay_signal:
+            # 出价调整候选:高ROI低量(提价)或 ROI偏低(降价)
+            bid_up_candidate = (
+                (not pd.isna(f_roi))
+                and f_roi > roi_mean * BID_UP_ROI_FACTOR
+                and cost_7d_avg < cost_median * BID_UP_LOW_SPEND_FACTOR
+                and bid_amount > 0
+            ) if BID_ADJUSTMENT_ENABLED else False
+            bid_down_candidate = (
+                (not pd.isna(f_roi))
+                and f_roi < roi_mean * BID_DOWN_ROI_FACTOR
+                and f_roi >= roi_mean * 0.5
+                and cost_7d_avg >= 100
+                and bid_amount > 0
+            ) if BID_ADJUSTMENT_ENABLED else False
+
+            if roi_low or decay_signal or bid_up_candidate or bid_down_candidate:
                 class_b.append({
                     "ad_id": int(row["ad_id"]),
                     "ad_name": str(row.get("ad_name", "")),
@@ -695,6 +911,8 @@ async def get_ads_for_review(
                     "bid_increased_7d": bid_inc,
                     "creative_changed_7d": creative_chg,
                     "stable_spend_days_30d": int(stable_days),
+                    "bid_amount": round(bid_amount, 2),
+                    "bid_candidate": "bid_up" if bid_up_candidate else ("bid_down" if bid_down_candidate else None),
                 })
                 continue
 
@@ -714,6 +932,13 @@ async def get_ads_for_review(
                 "p50": round(roi_p50, 4),
                 "p75": round(roi_p75, 4),
                 "p90": round(roi_p90, 4),
+                "cost_7d_avg_median": round(cost_median, 2),
+            },
+            "bid_adjustment": {
+                "enabled": BID_ADJUSTMENT_ENABLED,
+                "bid_down_line": round(roi_mean * BID_DOWN_ROI_FACTOR, 4),
+                "bid_up_line": round(roi_mean * BID_UP_ROI_FACTOR, 4),
+                "low_spend_line": round(cost_median * BID_UP_LOW_SPEND_FACTOR, 2),
             },
             "class_a": class_a,
             "class_b": class_b,
@@ -808,10 +1033,34 @@ async def apply_decisions(
 
         df_out = pd.DataFrame(all_decisions)
 
+        # 过滤:如果决策是 pause,但广告已经是 AD_STATUS_SUSPEND,则排除
+        ad_status_path = _MINI_DIR / "outputs" / "ad_status" / f"ad_status_{end_date}.csv"
+        if ad_status_path.exists():
+            try:
+                df_status = pd.read_csv(ad_status_path)
+                suspended_ads = set(
+                    df_status[df_status["ad_status"] == "AD_STATUS_SUSPEND"]["ad_id"].tolist()
+                )
+
+                # 过滤掉已经暂停的广告(仅针对 action=pause 的决策)
+                before_count = len(df_out)
+                df_out = df_out[
+                    ~((df_out["action"] == "pause") & (df_out["ad_id"].isin(suspended_ads)))
+                ]
+                filtered_count = before_count - len(df_out)
+                if filtered_count > 0:
+                    logger.info(f"过滤掉 {filtered_count} 个已暂停广告(AD_STATUS_SUSPEND)")
+            except Exception as e:
+                logger.warning(f"加载广告状态数据失败,跳过过滤: {e}")
+
         # 确保必要列存在
         for col in ["ad_id", "action", "dimension", "reason", "confidence", "source"]:
             if col not in df_out.columns:
                 df_out[col] = ""
+        # 数值列用 None 而非空字符串,避免 float("") 异常
+        for col in ["recommended_change_pct", "current_bid", "recommended_bid"]:
+            if col not in df_out.columns:
+                df_out[col] = None
 
         # 保存
         reports_dir = _MINI_DIR / "outputs" / "reports"
@@ -821,19 +1070,29 @@ async def apply_decisions(
 
         pause_count = (df_out["action"] == "pause").sum()
         hold_count = (df_out["action"] == "hold").sum()
+        bid_up_count = (df_out["action"] == "bid_up").sum()
+        bid_down_count = (df_out["action"] == "bid_down").sum()
+
+        output_parts = [
+            f"智能引擎决策已保存: {out_path}",
+            f"  关停: {pause_count} 个(含A类自动关停: {len(a_class_rows)} 个)",
+            f"  保持: {hold_count} 个",
+        ]
+        if bid_up_count > 0:
+            output_parts.append(f"  提价: {bid_up_count} 个")
+        if bid_down_count > 0:
+            output_parts.append(f"  降价: {bid_down_count} 个")
 
         return ToolResult(
             title=f"智能引擎决策已保存({len(df_out)}条)",
-            output=(
-                f"智能引擎决策已保存: {out_path}\n"
-                f"  关停: {pause_count} 个(含A类自动关停: {len(a_class_rows)} 个)\n"
-                f"  保持: {hold_count} 个"
-            ),
+            output="\n".join(output_parts),
             metadata={
                 "csv_path": str(out_path),
                 "total": len(df_out),
                 "pause": int(pause_count),
                 "hold": int(hold_count),
+                "bid_up": int(bid_up_count),
+                "bid_down": int(bid_down_count),
                 "class_a_auto": len(a_class_rows),
                 "end_date": end_date,
             },

+ 2 - 2
examples/auto_put_ad_mini/tools/data_query.py

@@ -298,7 +298,7 @@ def _fetch_ad_status(bizdate: str) -> Optional[pd.DataFrame]:
 @tool(description="拉取 30 天创意级别数据(增量,已有 CSV 的日期跳过)")
 async def fetch_creative_data(
     ctx: ToolContext,
-    days: int = 30,
+    days: int = 7,
     end_date: str = "yesterday"
 ) -> ToolResult:
     """
@@ -479,7 +479,7 @@ def _merge_single_day(biz: str) -> Optional[pd.DataFrame]:
 @tool(description="合并创意数据与广告状态(批量)")
 async def merge_creative_data(
     ctx: ToolContext,
-    days: int = 30,
+    days: int = 7,
     force: bool = False,
 ) -> ToolResult:
     """

+ 696 - 0
examples/auto_put_ad_mini/tools/execution_engine.py

@@ -0,0 +1,696 @@
+"""
+执行引擎 — auto_put_ad_mini
+
+职责:
+  1. 加载护栏验证后的决策
+  2. 按自治级别分类(Tier 1 自动 / Tier 2 审批 / Tier 3 阻断)
+  3. 调用腾讯广告 API 执行操作
+  4. 记录审计日志(JSONL)
+  5. 执行后效果检查
+
+依赖:
+  - tools/ad_api.py 的底层 HTTP 函数
+  - tools/guardrails.py 的 AdjustmentHistory
+"""
+
+import asyncio
+import json
+import logging
+import sys
+import time
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import pandas as pd
+
+from agent.tools import tool
+from agent.tools.models import ToolContext, ToolResult
+
+_MINI_DIR = Path(__file__).resolve().parent.parent
+_TOOLS_DIR = Path(__file__).resolve().parent
+if str(_MINI_DIR) not in sys.path:
+    sys.path.insert(0, str(_MINI_DIR))
+if str(_TOOLS_DIR) not in sys.path:
+    sys.path.insert(0, str(_TOOLS_DIR))
+
+from config import (
+    EXECUTION_ENABLED,
+    EXECUTION_LOG_DIR,
+    API_QPS_LIMIT,
+    API_MAX_RETRIES,
+    TIER1_MAX_CHANGE_PCT,
+    TIER3_MIN_DAILY_SPEND,
+    FEEDBACK_CHECK_HOURS,
+    DRY_RUN_MODE,
+    IM_ENABLED,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# ═══════════════════════════════════════════
+# QPS 令牌桶限流
+# ═══════════════════════════════════════════
+
+
+class TokenBucket:
+    """简单令牌桶限流器。"""
+
+    def __init__(self, rate: float = API_QPS_LIMIT):
+        self._rate = rate
+        self._tokens = rate
+        self._last_refill = time.monotonic()
+
+    async def acquire(self):
+        """获取一个令牌,不够时等待。"""
+        while True:
+            now = time.monotonic()
+            elapsed = now - self._last_refill
+            self._tokens = min(self._rate, self._tokens + elapsed * self._rate)
+            self._last_refill = now
+
+            if self._tokens >= 1:
+                self._tokens -= 1
+                return
+            else:
+                wait = (1 - self._tokens) / self._rate
+                await asyncio.sleep(wait)
+
+
+# ═══════════════════════════════════════════
+# 腾讯广告执行器
+# ═══════════════════════════════════════════
+
+
+class TencentAdExecutor:
+    """
+    腾讯广告 API 执行器。
+
+    复用 ad_api.py 的底层 HTTP 函数,添加:
+    - QPS 令牌桶限流
+    - 指数退避重试
+    - 操作前快照
+    """
+
+    def __init__(self):
+        self._bucket = TokenBucket(rate=API_QPS_LIMIT)
+
+    async def get_ad_state(self, ad_id: int, account_id: int) -> Optional[Dict]:
+        """获取广告当前状态快照。"""
+        try:
+            from ad_api import _get, _check
+            await self._bucket.acquire()
+            resp = _get("/adgroups/get", {
+                "account_id": account_id,
+                "filtering": {"adgroup_id_list": [ad_id]},
+                "page": 1,
+                "page_size": 1,
+            })
+            data = _check(resp, "get_ad_state")
+            items = data.get("list", [])
+            return items[0] if items else None
+        except Exception as e:
+            logger.warning("获取广告 %s 状态失败: %s", ad_id, e)
+            return None
+
+    async def update_bid(self, ad_id: int, account_id: int, bid_amount_fen: int) -> Dict:
+        """更新广告出价(分)。"""
+        from ad_api import _post, _check
+
+        for attempt in range(API_MAX_RETRIES):
+            try:
+                await self._bucket.acquire()
+                body = {
+                    "account_id": account_id,
+                    "adgroup_id": ad_id,
+                    "bid_amount": bid_amount_fen,
+                }
+                resp = _post("/adgroups/update", body)
+                _check(resp, "update_bid")
+                return {"code": 0, "message": "success"}
+            except Exception as e:
+                if attempt < API_MAX_RETRIES - 1:
+                    wait = 2 ** attempt
+                    logger.warning("update_bid 重试 %d/%d (等待%ds): %s", attempt + 1, API_MAX_RETRIES, wait, e)
+                    await asyncio.sleep(wait)
+                else:
+                    return {"code": -1, "message": str(e)}
+
+    async def pause_ad(self, ad_id: int, account_id: int) -> Dict:
+        """暂停广告。"""
+        from ad_api import _post, _check
+
+        for attempt in range(API_MAX_RETRIES):
+            try:
+                await self._bucket.acquire()
+                body = {
+                    "account_id": account_id,
+                    "adgroup_id": ad_id,
+                    "configured_status": "AD_STATUS_SUSPEND",
+                }
+                resp = _post("/adgroups/update", body)
+                _check(resp, "pause_ad")
+                return {"code": 0, "message": "success"}
+            except Exception as e:
+                if attempt < API_MAX_RETRIES - 1:
+                    wait = 2 ** attempt
+                    logger.warning("pause_ad 重试 %d/%d: %s", attempt + 1, API_MAX_RETRIES, e)
+                    await asyncio.sleep(wait)
+                else:
+                    return {"code": -1, "message": str(e)}
+
+
+# ═══════════════════════════════════════════
+# 审计日志
+# ═══════════════════════════════════════════
+
+
+class AuditLogger:
+    """JSONL 审计日志。"""
+
+    def __init__(self, log_dir: Path = EXECUTION_LOG_DIR):
+        self._log_dir = log_dir
+        self._log_dir.mkdir(parents=True, exist_ok=True)
+        today = datetime.now().strftime("%Y%m%d")
+        self._path = self._log_dir / f"exec_{today}.jsonl"
+
+    def log(self, entry: Dict):
+        entry["ts"] = datetime.now().isoformat()
+        with open(self._path, "a", encoding="utf-8") as f:
+            f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+    @property
+    def path(self) -> Path:
+        return self._path
+
+
+# ═══════════════════════════════════════════
+# 自治级别分类
+# ═══════════════════════════════════════════
+
+
+def _classify_tier(row: pd.Series) -> int:
+    """
+    自治级别分类。
+
+    Tier 1 (自动执行): hold 或 bid 调幅 ≤ 5%
+    Tier 2 (需审批):   pause 或 bid 调幅 > 5%
+    Tier 3 (高价值阻断): 日消耗 > 1500 元的高价值广告
+    """
+    action = row.get("final_action", row.get("action", "hold"))
+    if action == "hold":
+        return 0  # 无操作
+
+    cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
+    change_pct = row.get("recommended_change_pct", 0)
+    if isinstance(change_pct, str):
+        try:
+            change_pct = float(change_pct)
+        except ValueError:
+            change_pct = 0
+
+    # Tier 3: 高价值广告
+    if cost_7d_avg >= TIER3_MIN_DAILY_SPEND:
+        return 3
+
+    # Tier 2: 暂停 或 大幅调价
+    if action == "pause" or abs(change_pct) > TIER1_MAX_CHANGE_PCT:
+        return 2
+
+    # Tier 1: 小幅调价
+    return 1
+
+
+# ═══════════════════════════════════════════
+# 工具:执行已验证的决策
+# ═══════════════════════════════════════════
+
+
+@tool(description="执行已验证的决策:分级自治 → API调用 → 审计日志")
+async def execute_decisions(
+    ctx: ToolContext,
+    validated_csv: str = "",
+    approval_mode: str = "tiered",
+) -> ToolResult:
+    """
+    执行已通过护栏验证的决策。
+
+    Pipeline:
+    1. 加载 validated_decisions CSV (guardrail_status != blocked)
+    2. 按自治级别分类
+    3. Tier 1 (小调, ≤5%): 直接执行 + 通知
+    4. Tier 2 (大调/暂停): 标记待审批
+    5. Tier 3 (高价值): 标记需人工审批
+    6. 记录审计日志
+
+    Args:
+        validated_csv: 护栏验证后的 CSV 路径
+        approval_mode: "auto"=全部自动执行, "tiered"=分级, "manual"=全部需审批
+    """
+    try:
+        if not EXECUTION_ENABLED:
+            return ToolResult(
+                title="执行引擎未启用",
+                output="EXECUTION_ENABLED=False,跳过执行。修改 config.py 启用。",
+            )
+
+        if DRY_RUN_MODE:
+            return ToolResult(
+                title="干运行模式",
+                output="DRY_RUN_MODE=True,操作不会实际执行。修改 config.py 关闭干运行。",
+            )
+
+        # 查找验证 CSV
+        if not validated_csv:
+            reports_dir = _MINI_DIR / "outputs" / "reports"
+            candidates = sorted(reports_dir.glob("validated_decisions_*.csv"), reverse=True)
+            if not candidates:
+                return ToolResult(title="execute_decisions", output="未找到验证后的决策 CSV")
+            validated_csv = str(candidates[0])
+
+        df = pd.read_csv(validated_csv)
+        if df.empty:
+            return ToolResult(title="execute_decisions", output="决策数据为空")
+
+        # 只执行 approved / modified(非 blocked)
+        df_exec = df[df["guardrail_status"].isin(["approved", "modified"])].copy()
+        df_exec = df_exec[df_exec["final_action"] != "hold"]
+
+        if df_exec.empty:
+            return ToolResult(
+                title="无需执行的操作",
+                output="所有决策要么是 hold,要么被护栏拦截",
+            )
+
+        # 分级
+        df_exec["tier"] = df_exec.apply(_classify_tier, axis=1)
+
+        executor = TencentAdExecutor()
+        audit = AuditLogger()
+
+        # 从 guardrails 导入 AdjustmentHistory 记录操作
+        from guardrails import AdjustmentHistory
+        history = AdjustmentHistory()
+
+        executed = 0
+        failed = 0
+        pending_approval = 0
+        approved_executed = 0
+        rejected_count = 0
+        timeout_count = 0
+        tier_summary = {1: 0, 2: 0, 3: 0}
+
+        # ─── 分离 Tier 1 和 Tier 2/3 ───
+        df_tier1 = df_exec[df_exec["tier"] == 1]
+        df_tier2_3 = df_exec[df_exec["tier"] >= 2]
+
+        for t in [1, 2, 3]:
+            tier_summary[t] = int((df_exec["tier"] == t).sum())
+
+        # ═══ Phase 1: 自动执行 Tier 1 ═══
+        for _, row in df_tier1.iterrows():
+            action = row.get("final_action", row.get("action"))
+            ad_id = int(row["ad_id"])
+            account_id = int(row.get("account_id", 0) or 0)
+
+            pre_state = await executor.get_ad_state(ad_id, account_id)
+
+            if action == "pause":
+                result = await executor.pause_ad(ad_id, account_id)
+            elif action in ("bid_up", "bid_down"):
+                final_bid = row.get("final_bid", row.get("recommended_bid"))
+                if final_bid is None or final_bid == "":
+                    audit.log({
+                        "ad_id": ad_id,
+                        "action": action,
+                        "tier": 1,
+                        "execution_status": "skipped",
+                        "reason": "无出价数据",
+                    })
+                    continue
+                bid_fen = int(float(final_bid) * 100)
+                result = await executor.update_bid(ad_id, account_id, bid_fen)
+            else:
+                continue
+
+            api_code = result.get("code", -1)
+            exec_status = "success" if api_code == 0 else "failed"
+
+            if exec_status == "success":
+                executed += 1
+                change_pct = row.get("recommended_change_pct", 0)
+                if isinstance(change_pct, str):
+                    try:
+                        change_pct = float(change_pct)
+                    except ValueError:
+                        change_pct = 0
+                history.record_adjustment(str(ad_id), action, change_pct)
+            else:
+                failed += 1
+
+            post_state = await executor.get_ad_state(ad_id, account_id) if exec_status == "success" else None
+
+            audit.log({
+                "ad_id": ad_id,
+                "account_id": account_id,
+                "action": action,
+                "tier": 1,
+                "pre_state": {
+                    "bid_amount": pre_state.get("bid_amount") if pre_state else None,
+                    "status": pre_state.get("configured_status") if pre_state else None,
+                } if pre_state else None,
+                "post_state": {
+                    "bid_amount": post_state.get("bid_amount") if post_state else None,
+                    "status": post_state.get("configured_status") if post_state else None,
+                } if post_state else None,
+                "api_code": api_code,
+                "api_message": result.get("message", ""),
+                "execution_status": exec_status,
+                "source": row.get("source", ""),
+            })
+
+        # ═══ Phase 2: Tier 2/3 — 审批 + 执行 ═══
+        if not df_tier2_3.empty:
+            if IM_ENABLED:
+                # 阻塞式审批:调用 send_approval_request(wait_for_reply=True)
+                logger.info("Tier 2/3 共 %d 个操作,发送 IM 审批并等待...", len(df_tier2_3))
+
+                from im_approval import send_approval_request
+                approval_result = await send_approval_request(
+                    ctx=ctx,
+                    validated_csv=validated_csv,
+                    wait_for_reply=True,
+                )
+
+                approval_status = (
+                    approval_result.metadata.get("status", "timeout")
+                    if approval_result.metadata
+                    else "timeout"
+                )
+                approved_ids = (
+                    approval_result.metadata.get("approved_ids", [])
+                    if approval_result.metadata
+                    else []
+                )
+                rejected_ids = (
+                    approval_result.metadata.get("rejected_ids", [])
+                    if approval_result.metadata
+                    else []
+                )
+
+                if approval_status == "timeout":
+                    # 超时:所有 Tier 2/3 标记为 timeout
+                    timeout_count = len(df_tier2_3)
+                    for _, row in df_tier2_3.iterrows():
+                        audit.log({
+                            "ad_id": int(row["ad_id"]),
+                            "account_id": int(row.get("account_id", 0) or 0),
+                            "action": row.get("final_action", row.get("action")),
+                            "tier": int(row.get("tier", 2)),
+                            "execution_status": "timeout",
+                            "source": row.get("source", ""),
+                        })
+                else:
+                    # 执行已批准的广告
+                    approved_set = set(int(x) for x in approved_ids)
+                    rejected_set = set(int(x) for x in rejected_ids)
+
+                    for _, row in df_tier2_3.iterrows():
+                        ad_id = int(row["ad_id"])
+                        account_id = int(row.get("account_id", 0) or 0)
+                        action = row.get("final_action", row.get("action"))
+                        tier = int(row.get("tier", 2))
+
+                        if ad_id in rejected_set:
+                            rejected_count += 1
+                            audit.log({
+                                "ad_id": ad_id,
+                                "account_id": account_id,
+                                "action": action,
+                                "tier": tier,
+                                "execution_status": "rejected",
+                                "source": row.get("source", ""),
+                            })
+                            continue
+
+                        if ad_id not in approved_set:
+                            # 既不在 approved 也不在 rejected(部分审批场景遗漏)
+                            pending_approval += 1
+                            audit.log({
+                                "ad_id": ad_id,
+                                "account_id": account_id,
+                                "action": action,
+                                "tier": tier,
+                                "execution_status": "pending_approval",
+                                "source": row.get("source", ""),
+                            })
+                            continue
+
+                        # 已批准 → 执行
+                        pre_state = await executor.get_ad_state(ad_id, account_id)
+
+                        if action == "pause":
+                            result = await executor.pause_ad(ad_id, account_id)
+                        elif action in ("bid_up", "bid_down"):
+                            final_bid = row.get("final_bid", row.get("recommended_bid"))
+                            if final_bid is None or final_bid == "":
+                                audit.log({
+                                    "ad_id": ad_id,
+                                    "action": action,
+                                    "tier": tier,
+                                    "execution_status": "skipped",
+                                    "reason": "无出价数据",
+                                })
+                                continue
+                            bid_fen = int(float(final_bid) * 100)
+                            result = await executor.update_bid(ad_id, account_id, bid_fen)
+                        else:
+                            continue
+
+                        api_code = result.get("code", -1)
+                        exec_status = "success" if api_code == 0 else "failed"
+
+                        if exec_status == "success":
+                            approved_executed += 1
+                            change_pct = row.get("recommended_change_pct", 0)
+                            if isinstance(change_pct, str):
+                                try:
+                                    change_pct = float(change_pct)
+                                except ValueError:
+                                    change_pct = 0
+                            history.record_adjustment(str(ad_id), action, change_pct)
+                        else:
+                            failed += 1
+
+                        post_state = await executor.get_ad_state(ad_id, account_id) if exec_status == "success" else None
+
+                        audit.log({
+                            "ad_id": ad_id,
+                            "account_id": account_id,
+                            "action": action,
+                            "tier": tier,
+                            "pre_state": {
+                                "bid_amount": pre_state.get("bid_amount") if pre_state else None,
+                                "status": pre_state.get("configured_status") if pre_state else None,
+                            } if pre_state else None,
+                            "post_state": {
+                                "bid_amount": post_state.get("bid_amount") if post_state else None,
+                                "status": post_state.get("configured_status") if post_state else None,
+                            } if post_state else None,
+                            "api_code": api_code,
+                            "api_message": result.get("message", ""),
+                            "execution_status": f"approved_{exec_status}",
+                            "source": row.get("source", ""),
+                        })
+            else:
+                # IM 未启用:Tier 2/3 仅记录不执行
+                logger.info("IM 未启用,Tier 2/3 共 %d 个操作仅记录不执行", len(df_tier2_3))
+                pending_approval = len(df_tier2_3)
+                for _, row in df_tier2_3.iterrows():
+                    audit.log({
+                        "ad_id": int(row["ad_id"]),
+                        "account_id": int(row.get("account_id", 0) or 0),
+                        "action": row.get("final_action", row.get("action")),
+                        "tier": int(row.get("tier", 2)),
+                        "execution_status": "pending_approval",
+                        "note": "IM未启用,操作仅记录",
+                        "source": row.get("source", ""),
+                    })
+
+        total_executed = executed + approved_executed
+
+        output_lines = [
+            f"执行完成,审计日志: {audit.path}",
+            "",
+            "执行结果:",
+            f"  Tier 1 自动执行: {executed} 个成功 / {failed} 个失败",
+        ]
+
+        if IM_ENABLED and not df_tier2_3.empty:
+            output_lines.extend([
+                f"  Tier 2/3 审批后执行: {approved_executed} 个成功",
+                f"  审批拒绝: {rejected_count} 个",
+                f"  审批超时: {timeout_count} 个",
+            ])
+        if pending_approval > 0:
+            output_lines.append(f"  待审批(未执行): {pending_approval} 个")
+
+        output_lines.extend([
+            "",
+            "自治级别分布:",
+            f"  Tier 1 (自动): {tier_summary.get(1, 0)} 个",
+            f"  Tier 2 (审批): {tier_summary.get(2, 0)} 个",
+            f"  Tier 3 (高价值): {tier_summary.get(3, 0)} 个",
+        ])
+
+        return ToolResult(
+            title=f"执行完成(自动{executed}/审批通过{approved_executed}/拒绝{rejected_count}/超时{timeout_count})",
+            output="\n".join(output_lines),
+            metadata={
+                "audit_log": str(audit.path),
+                "tier1_executed": executed,
+                "tier1_failed": failed,
+                "approved_executed": approved_executed,
+                "rejected": rejected_count,
+                "timeout": timeout_count,
+                "pending_approval": pending_approval,
+                "tier_summary": tier_summary,
+            },
+        )
+
+    except Exception as e:
+        logger.error("execute_decisions 失败: %s", e, exc_info=True)
+        return ToolResult(title="execute_decisions 失败", output=str(e))
+
+
+# ═══════════════════════════════════════════
+# 工具:执行后效果检查
+# ═══════════════════════════════════════════
+
+
+@tool(description="执行后效果检查:对比操作前后广告表现")
+async def check_execution_feedback(
+    ctx: ToolContext,
+    execution_log_path: str = "",
+    hours_after: int = FEEDBACK_CHECK_HOURS,
+) -> ToolResult:
+    """
+    读取执行日志,通过 API 获取当前状态,对比操作前后。
+
+    Args:
+        execution_log_path: 执行日志路径(JSONL),默认最新
+        hours_after: 操作后等待时间(小时),仅检查超过此时间的操作
+    """
+    try:
+        # 查找最新执行日志
+        if not execution_log_path:
+            log_dir = EXECUTION_LOG_DIR
+            if not log_dir.exists():
+                return ToolResult(title="check_execution_feedback", output="无执行日志目录")
+            candidates = sorted(log_dir.glob("exec_*.jsonl"), reverse=True)
+            if not candidates:
+                return ToolResult(title="check_execution_feedback", output="无执行日志")
+            execution_log_path = str(candidates[0])
+
+        # 读取日志
+        entries = []
+        with open(execution_log_path, "r", encoding="utf-8") as f:
+            for line in f:
+                line = line.strip()
+                if line:
+                    entries.append(json.loads(line))
+
+        if not entries:
+            return ToolResult(title="check_execution_feedback", output="执行日志为空")
+
+        # 过滤:只看成功执行的且超过等待时间的
+        cutoff = datetime.now() - timedelta(hours=hours_after)
+        check_entries = [
+            e for e in entries
+            if e.get("execution_status") == "success"
+            and datetime.fromisoformat(e["ts"]) < cutoff
+        ]
+
+        if not check_entries:
+            return ToolResult(
+                title="check_execution_feedback",
+                output=f"无需检查(没有超过{hours_after}小时前的成功操作)",
+            )
+
+        # 获取当前状态
+        executor = TencentAdExecutor()
+        results = []
+
+        for entry in check_entries:
+            ad_id = entry.get("ad_id")
+            account_id = entry.get("account_id", 0)
+            action = entry.get("action")
+            pre_state = entry.get("pre_state", {})
+
+            current_state = await executor.get_ad_state(ad_id, account_id)
+
+            result = {
+                "ad_id": ad_id,
+                "action": action,
+                "executed_at": entry.get("ts"),
+                "pre_bid": pre_state.get("bid_amount") if pre_state else None,
+                "pre_status": pre_state.get("status") if pre_state else None,
+                "current_bid": current_state.get("bid_amount") if current_state else None,
+                "current_status": current_state.get("configured_status") if current_state else None,
+            }
+
+            # 状态变化判断
+            if action == "pause":
+                result["effective"] = (
+                    current_state.get("configured_status") == "AD_STATUS_SUSPEND"
+                    if current_state else None
+                )
+            elif action in ("bid_up", "bid_down"):
+                post_bid = entry.get("post_state", {})
+                if post_bid:
+                    expected_bid = post_bid.get("bid_amount")
+                    actual_bid = current_state.get("bid_amount") if current_state else None
+                    result["expected_bid"] = expected_bid
+                    result["effective"] = (actual_bid == expected_bid) if actual_bid is not None else None
+
+            results.append(result)
+
+        # 统计
+        effective = sum(1 for r in results if r.get("effective") is True)
+        ineffective = sum(1 for r in results if r.get("effective") is False)
+        unknown = sum(1 for r in results if r.get("effective") is None)
+
+        output_lines = [
+            f"效果检查完成({len(results)} 个操作)",
+            "",
+            f"  有效: {effective} 个",
+            f"  无效/被覆盖: {ineffective} 个",
+            f"  未知: {unknown} 个",
+        ]
+
+        if ineffective > 0:
+            output_lines.append("")
+            output_lines.append("⚠️ 以下操作可能未生效或被覆盖:")
+            for r in results:
+                if r.get("effective") is False:
+                    output_lines.append(
+                        f"  - 广告{r['ad_id']}: {r['action']} "
+                        f"(执行于 {r['executed_at']})"
+                    )
+
+        return ToolResult(
+            title=f"效果检查(有效{effective}/无效{ineffective})",
+            output="\n".join(output_lines),
+            metadata={
+                "total": len(results),
+                "effective": effective,
+                "ineffective": ineffective,
+                "unknown": unknown,
+                "details": results,
+            },
+        )
+
+    except Exception as e:
+        logger.error("check_execution_feedback 失败: %s", e, exc_info=True)
+        return ToolResult(title="check_execution_feedback 失败", output=str(e))

+ 673 - 0
examples/auto_put_ad_mini/tools/guardrails.py

@@ -0,0 +1,673 @@
+"""
+安全护栏引擎 — auto_put_ad_mini
+
+6 道护栏按顺序执行:
+  1. ColdStartGuardrail    — 冷启动保护
+  2. DataFreshnessGuardrail — 数据新鲜度校验
+  3. BidBoundaryGuardrail  — 出价边界钳位
+  4. RateLimitGuardrail    — 频率限制(每日次数/间隔/累计调幅)
+  5. DailyOpsCapGuardrail  — 每日操作总量上限
+  6. DryRunGuardrail       — 干运行模式
+
+每道护栏输出:approved / blocked / modified
+blocked = 阻止操作,modified = 自动修正参数后放行
+"""
+
+import json
+import logging
+import sys
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Dict, List, Optional
+
+import pandas as pd
+
+from agent.tools import tool
+from agent.tools.models import ToolContext, ToolResult
+
+_MINI_DIR = Path(__file__).resolve().parent.parent
+_TOOLS_DIR = Path(__file__).resolve().parent
+if str(_MINI_DIR) not in sys.path:
+    sys.path.insert(0, str(_MINI_DIR))
+if str(_TOOLS_DIR) not in sys.path:
+    sys.path.insert(0, str(_TOOLS_DIR))
+
+from config import (
+    COLD_START_DAYS,
+    CAUTIOUS_DAYS,
+    BID_FLOOR_YUAN,
+    BID_CEILING_YUAN,
+    MAX_ADJUSTMENTS_PER_AD_PER_DAY,
+    MIN_ADJUSTMENT_INTERVAL_HOURS,
+    MAX_DAILY_CUMULATIVE_CHANGE_PCT,
+    MAX_DAILY_OPS,
+    DATA_FRESHNESS_MAX_HOURS,
+    ADJUSTMENT_HISTORY_PATH,
+    DRY_RUN_MODE,
+    GUARDRAILS_ENABLED,
+    DATA_DIR,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# ═══════════════════════════════════════════
+# 调整历史持久化
+# ═══════════════════════════════════════════
+
+
+class AdjustmentHistory:
+    """广告调整历史记录(JSON 文件持久化)。"""
+
+    def __init__(self, path: Path = ADJUSTMENT_HISTORY_PATH):
+        self._path = path
+        self._data: Dict[str, Dict] = {}
+        self._load()
+
+    def _load(self):
+        if self._path.exists():
+            try:
+                self._data = json.loads(self._path.read_text(encoding="utf-8"))
+            except Exception as e:
+                logger.warning("加载调整历史失败,使用空记录: %s", e)
+                self._data = {}
+
+    def _save(self):
+        self._path.parent.mkdir(parents=True, exist_ok=True)
+        self._path.write_text(
+            json.dumps(self._data, ensure_ascii=False, indent=2),
+            encoding="utf-8",
+        )
+
+    def get_today_adjustments(self, ad_id: str) -> List[Dict]:
+        """获取某广告今天的调整记录。"""
+        today = datetime.now().strftime("%Y-%m-%d")
+        record = self._data.get(str(ad_id), {})
+        adjustments = record.get("adjustments", [])
+        return [a for a in adjustments if a.get("ts", "").startswith(today)]
+
+    def get_last_adjustment_ts(self, ad_id: str) -> Optional[datetime]:
+        """获取某广告最后一次调整的时间。"""
+        record = self._data.get(str(ad_id), {})
+        last_ts = record.get("last_ts")
+        if last_ts:
+            try:
+                return datetime.fromisoformat(last_ts)
+            except ValueError:
+                return None
+        return None
+
+    def get_cumulative_pct_today(self, ad_id: str) -> float:
+        """获取某广告今天的累计调幅绝对值。"""
+        today_adj = self.get_today_adjustments(str(ad_id))
+        return sum(abs(a.get("pct", 0)) for a in today_adj)
+
+    def record_adjustment(self, ad_id: str, action: str, pct: float):
+        """记录一次调整。"""
+        ad_key = str(ad_id)
+        now = datetime.now().isoformat()
+
+        if ad_key not in self._data:
+            self._data[ad_key] = {"adjustments": [], "last_ts": None}
+
+        self._data[ad_key]["adjustments"].append({
+            "ts": now,
+            "action": action,
+            "pct": pct,
+        })
+        self._data[ad_key]["last_ts"] = now
+
+        # 只保留最近 7 天的记录
+        cutoff = (datetime.now() - timedelta(days=7)).isoformat()
+        self._data[ad_key]["adjustments"] = [
+            a for a in self._data[ad_key]["adjustments"]
+            if a.get("ts", "") >= cutoff
+        ]
+
+        self._save()
+
+    def get_today_total_ops(self) -> int:
+        """获取今天已操作的广告总数。"""
+        today = datetime.now().strftime("%Y-%m-%d")
+        count = 0
+        for ad_key, record in self._data.items():
+            adjustments = record.get("adjustments", [])
+            if any(a.get("ts", "").startswith(today) for a in adjustments):
+                count += 1
+        return count
+
+
+# ═══════════════════════════════════════════
+# 护栏检查结果
+# ═══════════════════════════════════════════
+
+
+@dataclass
+class GuardrailResult:
+    """单个护栏的检查结果。"""
+    status: str   # "approved" / "blocked" / "modified"
+    reason: str
+    modified_action: Optional[str] = None
+    modified_bid: Optional[float] = None
+    modified_change_pct: Optional[float] = None
+
+
+# ═══════════════════════════════════════════
+# 护栏基类
+# ═══════════════════════════════════════════
+
+
+class Guardrail(ABC):
+    """护栏基类。"""
+
+    @property
+    @abstractmethod
+    def name(self) -> str:
+        pass
+
+    @abstractmethod
+    def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        """
+        检查单个决策是否通过护栏。
+
+        Args:
+            row: 决策行(包含 action, ad_id, recommended_change_pct 等)
+            history: 调整历史记录
+
+        Returns:
+            GuardrailResult
+        """
+        pass
+
+
+# ═══════════════════════════════════════════
+# 护栏 1: 冷启动保护
+# ═══════════════════════════════════════════
+
+
+class ColdStartGuardrail(Guardrail):
+    """冷启动保护:0-4天不做负向操作,4-7天仅允许小幅降价。"""
+
+    @property
+    def name(self) -> str:
+        return "冷启动保护"
+
+    def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        action = row.get("action", "hold")
+        ad_age = row.get("ad_age_days")
+
+        if ad_age is None or action == "hold":
+            return GuardrailResult(status="approved", reason="")
+
+        # 绝对保护期
+        if ad_age < COLD_START_DAYS:
+            if action in ("pause", "bid_down"):
+                return GuardrailResult(
+                    status="blocked",
+                    reason=f"冷启动绝对保护期({ad_age}天 < {COLD_START_DAYS}天),禁止{action}",
+                    modified_action="hold",
+                )
+
+        # 谨慎期
+        elif ad_age < CAUTIOUS_DAYS:
+            if action == "pause":
+                return GuardrailResult(
+                    status="blocked",
+                    reason=f"谨慎期({ad_age}天 < {CAUTIOUS_DAYS}天),禁止暂停",
+                    modified_action="hold",
+                )
+            elif action == "bid_down":
+                change_pct = row.get("recommended_change_pct", 0)
+                if isinstance(change_pct, str):
+                    try:
+                        change_pct = float(change_pct)
+                    except ValueError:
+                        change_pct = 0
+                if abs(change_pct) > 0.05:
+                    # 修正为最大 5%
+                    current_bid = float(row.get("current_bid", 0) or 0)
+                    if current_bid > 0:
+                        new_bid = round(current_bid * 0.95, 2)
+                        return GuardrailResult(
+                            status="modified",
+                            reason=f"谨慎期限制降幅≤5%,从{abs(change_pct)*100:.1f}%修正为5%",
+                            modified_change_pct=-0.05,
+                            modified_bid=new_bid,
+                        )
+
+        return GuardrailResult(status="approved", reason="")
+
+
+# ═══════════════════════════════════════════
+# 护栏 2: 数据新鲜度
+# ═══════════════════════════════════════════
+
+
+class DataFreshnessGuardrail(Guardrail):
+    """数据新鲜度校验:数据超过 26 小时视为过期。"""
+
+    @property
+    def name(self) -> str:
+        return "数据新鲜度"
+
+    def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        action = row.get("action", "hold")
+        if action == "hold":
+            return GuardrailResult(status="approved", reason="")
+
+        data_date = row.get("_data_date")  # 由工具注入
+        if data_date:
+            try:
+                data_dt = datetime.strptime(str(data_date), "%Y%m%d")
+                hours_old = (datetime.now() - data_dt).total_seconds() / 3600
+                if hours_old > DATA_FRESHNESS_MAX_HOURS:
+                    return GuardrailResult(
+                        status="blocked",
+                        reason=f"数据已过期({hours_old:.0f}小时前,上限{DATA_FRESHNESS_MAX_HOURS}小时),阻止所有操作",
+                        modified_action="hold",
+                    )
+            except ValueError:
+                pass
+
+        return GuardrailResult(status="approved", reason="")
+
+
+# ═══════════════════════════════════════════
+# 护栏 3: 出价边界
+# ═══════════════════════════════════════════
+
+
+class BidBoundaryGuardrail(Guardrail):
+    """出价边界检查:钳位到 [BID_FLOOR, BID_CEILING]。"""
+
+    @property
+    def name(self) -> str:
+        return "出价边界"
+
+    def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        action = row.get("action", "hold")
+        if action not in ("bid_up", "bid_down"):
+            return GuardrailResult(status="approved", reason="")
+
+        recommended_bid = row.get("recommended_bid")
+        if recommended_bid is None or recommended_bid == "":
+            return GuardrailResult(status="approved", reason="")
+
+        recommended_bid = float(recommended_bid)
+        current_bid = float(row.get("current_bid", 0) or 0)
+
+        if recommended_bid < BID_FLOOR_YUAN:
+            new_bid = BID_FLOOR_YUAN
+            new_pct = (new_bid - current_bid) / current_bid if current_bid > 0 else 0
+            return GuardrailResult(
+                status="modified",
+                reason=f"出价{recommended_bid:.2f}元低于下限{BID_FLOOR_YUAN}元,钳位至{new_bid:.2f}元",
+                modified_bid=new_bid,
+                modified_change_pct=round(new_pct, 4),
+            )
+        elif recommended_bid > BID_CEILING_YUAN:
+            new_bid = BID_CEILING_YUAN
+            new_pct = (new_bid - current_bid) / current_bid if current_bid > 0 else 0
+            return GuardrailResult(
+                status="modified",
+                reason=f"出价{recommended_bid:.2f}元超过上限{BID_CEILING_YUAN}元,钳位至{new_bid:.2f}元",
+                modified_bid=new_bid,
+                modified_change_pct=round(new_pct, 4),
+            )
+
+        return GuardrailResult(status="approved", reason="")
+
+
+# ═══════════════════════════════════════════
+# 护栏 4: 频率限制
+# ═══════════════════════════════════════════
+
+
+class RateLimitGuardrail(Guardrail):
+    """频率限制:每日次数/间隔/累计调幅。"""
+
+    @property
+    def name(self) -> str:
+        return "频率限制"
+
+    def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        action = row.get("action", "hold")
+        if action not in ("bid_up", "bid_down", "pause"):
+            return GuardrailResult(status="approved", reason="")
+
+        ad_id = str(row.get("ad_id", ""))
+
+        # 今日已调整次数
+        today_adj = history.get_today_adjustments(ad_id)
+        if len(today_adj) >= MAX_ADJUSTMENTS_PER_AD_PER_DAY:
+            return GuardrailResult(
+                status="blocked",
+                reason=f"今日已调整{len(today_adj)}次(上限{MAX_ADJUSTMENTS_PER_AD_PER_DAY}次)",
+                modified_action="hold",
+            )
+
+        # 距上次调整间隔
+        last_ts = history.get_last_adjustment_ts(ad_id)
+        if last_ts:
+            hours_since = (datetime.now() - last_ts).total_seconds() / 3600
+            if hours_since < MIN_ADJUSTMENT_INTERVAL_HOURS:
+                return GuardrailResult(
+                    status="blocked",
+                    reason=f"距上次调整仅{hours_since:.1f}小时(最小间隔{MIN_ADJUSTMENT_INTERVAL_HOURS}小时)",
+                    modified_action="hold",
+                )
+
+        # 日累计调幅
+        if action in ("bid_up", "bid_down"):
+            change_pct = row.get("recommended_change_pct", 0)
+            if isinstance(change_pct, str):
+                try:
+                    change_pct = float(change_pct)
+                except ValueError:
+                    change_pct = 0
+            cumulative = history.get_cumulative_pct_today(ad_id)
+            if cumulative + abs(change_pct) > MAX_DAILY_CUMULATIVE_CHANGE_PCT:
+                remaining = MAX_DAILY_CUMULATIVE_CHANGE_PCT - cumulative
+                if remaining <= 0:
+                    return GuardrailResult(
+                        status="blocked",
+                        reason=f"日累计调幅已达{cumulative*100:.1f}%(上限{MAX_DAILY_CUMULATIVE_CHANGE_PCT*100:.0f}%)",
+                        modified_action="hold",
+                    )
+                else:
+                    # 修正调幅
+                    direction = 1 if change_pct > 0 else -1
+                    new_pct = direction * remaining
+                    current_bid = float(row.get("current_bid", 0) or 0)
+                    new_bid = round(current_bid * (1 + new_pct), 2) if current_bid > 0 else None
+                    return GuardrailResult(
+                        status="modified",
+                        reason=f"调幅从{abs(change_pct)*100:.1f}%缩减至{abs(new_pct)*100:.1f}%(日累计限制)",
+                        modified_change_pct=round(new_pct, 4),
+                        modified_bid=new_bid,
+                    )
+
+        return GuardrailResult(status="approved", reason="")
+
+
+# ═══════════════════════════════════════════
+# 护栏 5: 每日操作总量上限
+# ═══════════════════════════════════════════
+
+
+class DailyOpsCapGuardrail(Guardrail):
+    """每日操作总量上限:单日最多操作 N 个广告。"""
+
+    @property
+    def name(self) -> str:
+        return "每日操作上限"
+
+    def __init__(self):
+        self._approved_count = 0
+
+    def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        action = row.get("action", "hold")
+        if action == "hold":
+            return GuardrailResult(status="approved", reason="")
+
+        total_ops = history.get_today_total_ops() + self._approved_count
+        if total_ops >= MAX_DAILY_OPS:
+            return GuardrailResult(
+                status="blocked",
+                reason=f"今日已操作{total_ops}个广告(上限{MAX_DAILY_OPS}个),请按ROI严重度排优先级",
+                modified_action="hold",
+            )
+
+        self._approved_count += 1
+        return GuardrailResult(status="approved", reason="")
+
+
+# ═══════════════════════════════════════════
+# 护栏 6: 干运行模式
+# ═══════════════════════════════════════════
+
+
+class DryRunGuardrail(Guardrail):
+    """干运行模式:全部标记为 dry_run。"""
+
+    @property
+    def name(self) -> str:
+        return "干运行模式"
+
+    def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        action = row.get("action", "hold")
+        if action == "hold":
+            return GuardrailResult(status="approved", reason="")
+
+        if DRY_RUN_MODE:
+            return GuardrailResult(
+                status="modified",
+                reason="干运行模式:操作不会实际执行",
+            )
+
+        return GuardrailResult(status="approved", reason="")
+
+
+# ═══════════════════════════════════════════
+# 护栏引擎
+# ═══════════════════════════════════════════
+
+
+def _run_guardrails(
+    df: pd.DataFrame,
+    data_date: str,
+    dry_run: bool = False,
+) -> pd.DataFrame:
+    """
+    对决策 DataFrame 执行 6 道护栏检查。
+
+    新增列:
+      - guardrail_status: approved / blocked / modified
+      - guardrail_reason: 护栏说明
+      - final_action: 护栏修正后的最终动作
+      - final_bid: 护栏修正后的最终出价
+    """
+    history = AdjustmentHistory()
+
+    guardrails = [
+        ColdStartGuardrail(),
+        DataFreshnessGuardrail(),
+        BidBoundaryGuardrail(),
+        RateLimitGuardrail(),
+        DailyOpsCapGuardrail(),
+        DryRunGuardrail() if dry_run or DRY_RUN_MODE else None,
+    ]
+    guardrails = [g for g in guardrails if g is not None]
+
+    # 注入数据日期
+    df["_data_date"] = data_date
+
+    statuses = []
+    reasons = []
+    final_actions = []
+    final_bids = []
+
+    for _, row in df.iterrows():
+        action = row.get("action", "hold")
+        current_status = "approved"
+        current_reasons = []
+        current_action = action
+        current_bid = row.get("recommended_bid")
+        current_change_pct = row.get("recommended_change_pct")
+
+        if action == "hold":
+            statuses.append("approved")
+            reasons.append("")
+            final_actions.append("hold")
+            final_bids.append(None)
+            continue
+
+        for guardrail in guardrails:
+            # 构建可变行用于护栏检查
+            check_row = row.copy()
+            if current_bid is not None:
+                check_row["recommended_bid"] = current_bid
+            if current_change_pct is not None:
+                check_row["recommended_change_pct"] = current_change_pct
+            check_row["action"] = current_action
+
+            result = guardrail.check(check_row, history)
+
+            if result.status == "blocked":
+                current_status = "blocked"
+                current_reasons.append(f"[{guardrail.name}] {result.reason}")
+                current_action = result.modified_action or "hold"
+                break
+            elif result.status == "modified":
+                current_status = "modified"
+                current_reasons.append(f"[{guardrail.name}] {result.reason}")
+                if result.modified_action:
+                    current_action = result.modified_action
+                if result.modified_bid is not None:
+                    current_bid = result.modified_bid
+                if result.modified_change_pct is not None:
+                    current_change_pct = result.modified_change_pct
+
+        statuses.append(current_status)
+        reasons.append("; ".join(current_reasons))
+        final_actions.append(current_action)
+        final_bids.append(current_bid if current_action in ("bid_up", "bid_down") else None)
+
+    df["guardrail_status"] = statuses
+    df["guardrail_reason"] = reasons
+    df["final_action"] = final_actions
+    df["final_bid"] = final_bids
+
+    # 清理临时列
+    df.drop(columns=["_data_date"], inplace=True, errors="ignore")
+
+    return df
+
+
+# ═══════════════════════════════════════════
+# 工具:验证决策安全性
+# ═══════════════════════════════════════════
+
+
+@tool(description="验证决策安全性:冷启动保护、出价边界、频率限制、数据新鲜度")
+async def validate_decisions(
+    ctx: ToolContext,
+    decisions_csv: str = "",
+    end_date: str = "yesterday",
+    dry_run: bool = False,
+) -> ToolResult:
+    """
+    对每个决策执行 6 道护栏检查。
+
+    输入:apply_decisions 输出的 llm_decisions CSV
+    输出:validated_decisions_{date}.csv,新增列 guardrail_status / guardrail_reason / final_action / final_bid
+
+    Args:
+        decisions_csv: 决策 CSV 路径(默认最新的 llm_decisions)
+        end_date: 结束日期
+        dry_run: 是否强制干运行模式
+    """
+    try:
+        if not GUARDRAILS_ENABLED:
+            return ToolResult(
+                title="护栏已禁用",
+                output="GUARDRAILS_ENABLED=False,跳过护栏验证",
+            )
+
+        if end_date == "yesterday":
+            end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+
+        # 自动查找最新决策 CSV
+        if not decisions_csv:
+            reports_dir = _MINI_DIR / "outputs" / "reports"
+            candidates = sorted(reports_dir.glob("llm_decisions_*.csv"), reverse=True)
+            if not candidates:
+                return ToolResult(title="validate_decisions", output="未找到决策 CSV")
+            decisions_csv = str(candidates[0])
+
+        df = pd.read_csv(decisions_csv)
+        if df.empty:
+            return ToolResult(title="validate_decisions", output="决策数据为空")
+
+        # 补充广告年龄(如果缺失)
+        if "ad_age_days" not in df.columns:
+            metrics_csv = _MINI_DIR / "outputs" / "metrics_temp.csv"
+            if metrics_csv.exists():
+                df_metrics = pd.read_csv(metrics_csv)
+                if "create_time" in df_metrics.columns:
+                    from ad_decision import _calculate_ad_age_days
+                    df_metrics["ad_age_days"] = df_metrics["create_time"].apply(_calculate_ad_age_days)
+                    age_map = df_metrics.set_index("ad_id")["ad_age_days"].to_dict()
+                    df["ad_age_days"] = df["ad_id"].map(age_map)
+
+        # 补充当前出价(如果缺失)
+        if "current_bid" not in df.columns or df["current_bid"].isna().all():
+            metrics_csv = _MINI_DIR / "outputs" / "metrics_temp.csv"
+            if metrics_csv.exists():
+                df_metrics = pd.read_csv(metrics_csv)
+                if "bid_amount" in df_metrics.columns:
+                    bid_map = df_metrics.set_index("ad_id")["bid_amount"].to_dict()
+                    df["current_bid"] = df["ad_id"].map(bid_map)
+
+        # 运行护栏链
+        df = _run_guardrails(df, data_date=end_date, dry_run=dry_run)
+
+        # 保存验证结果
+        reports_dir = _MINI_DIR / "outputs" / "reports"
+        reports_dir.mkdir(parents=True, exist_ok=True)
+        out_path = reports_dir / f"validated_decisions_{end_date}.csv"
+        df.to_csv(out_path, index=False, encoding="utf-8-sig")
+
+        # 统计
+        total = len(df)
+        approved = (df["guardrail_status"] == "approved").sum()
+        blocked = (df["guardrail_status"] == "blocked").sum()
+        modified = (df["guardrail_status"] == "modified").sum()
+
+        # 最终动作统计
+        final_pause = (df["final_action"] == "pause").sum()
+        final_hold = (df["final_action"] == "hold").sum()
+        final_bid_up = (df["final_action"] == "bid_up").sum()
+        final_bid_down = (df["final_action"] == "bid_down").sum()
+
+        output_lines = [
+            f"护栏验证完成: {out_path}",
+            "",
+            f"护栏结果:",
+            f"  approved: {approved} 个(直接通过)",
+            f"  modified: {modified} 个(参数修正后通过)",
+            f"  blocked:  {blocked} 个(被拦截→hold)",
+            "",
+            f"最终动作分布:",
+            f"  pause:    {final_pause} 个",
+            f"  bid_down: {final_bid_down} 个",
+            f"  bid_up:   {final_bid_up} 个",
+            f"  hold:     {final_hold} 个",
+        ]
+
+        if DRY_RUN_MODE or dry_run:
+            output_lines.append("")
+            output_lines.append("⚠️ 当前为干运行模式(DRY_RUN),操作不会实际执行")
+
+        return ToolResult(
+            title=f"护栏验证({total}条,拦截{blocked})",
+            output="\n".join(output_lines),
+            metadata={
+                "csv_path": str(out_path),
+                "total": total,
+                "approved": int(approved),
+                "blocked": int(blocked),
+                "modified": int(modified),
+                "final_pause": int(final_pause),
+                "final_hold": int(final_hold),
+                "final_bid_up": int(final_bid_up),
+                "final_bid_down": int(final_bid_down),
+                "dry_run": DRY_RUN_MODE or dry_run,
+            },
+        )
+
+    except Exception as e:
+        logger.error("validate_decisions 失败: %s", e, exc_info=True)
+        return ToolResult(title="validate_decisions 失败", output=str(e))

+ 31 - 3
examples/auto_put_ad_mini/tools/report_generator.py

@@ -39,6 +39,10 @@ OUTPUT_COLUMNS = [
     "stable_spend_days_30d", "creative_count",
     # 决策
     "action", "dimension", "reason",
+    "recommended_change_pct", "current_bid", "recommended_bid",
+    # 护栏 & 执行
+    "guardrail_status", "guardrail_reason", "final_action", "final_bid",
+    "execution_status",
     # 参考
     "f_7日动态ROI_mean_all",
 ]
@@ -73,6 +77,14 @@ CN_COLUMNS = {
     "action": "决策动作",
     "dimension": "命中维度",
     "reason": "决策理由",
+    "recommended_change_pct": "建议调幅(%)",
+    "current_bid": "当前出价(元)",
+    "recommended_bid": "建议出价(元)",
+    "guardrail_status": "护栏状态",
+    "guardrail_reason": "护栏说明",
+    "final_action": "最终动作",
+    "final_bid": "最终出价(元)",
+    "execution_status": "执行状态",
     "f_7日动态ROI_mean_all": "全体动态ROI均值",
 }
 
@@ -103,9 +115,13 @@ def _write_xlsx_with_format(df: pd.DataFrame, path: Path) -> None:
         cell.font = header_font
         cell.alignment = Alignment(horizontal="center")
 
-    # 条件格式:关停行标红
+    # 条件格式:不同动作不同颜色
     red_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
     red_font = Font(color="9C0006")
+    yellow_fill = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
+    yellow_font = Font(color="9C6500")
+    green_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
+    green_font = Font(color="006100")
 
     action_col_idx = None
     for idx, cell in enumerate(ws[1], 1):
@@ -115,10 +131,19 @@ def _write_xlsx_with_format(df: pd.DataFrame, path: Path) -> None:
 
     if action_col_idx:
         for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
-            if row[action_col_idx - 1].value == "pause":
+            action_val = row[action_col_idx - 1].value
+            if action_val == "pause":
                 for cell in row:
                     cell.fill = red_fill
                     cell.font = red_font
+            elif action_val == "bid_down":
+                for cell in row:
+                    cell.fill = yellow_fill
+                    cell.font = yellow_font
+            elif action_val == "bid_up":
+                for cell in row:
+                    cell.fill = green_fill
+                    cell.font = green_font
 
     # 自动列宽
     for col_idx in range(1, ws.max_column + 1):
@@ -169,14 +194,17 @@ async def generate_report(
 
         # 排序:关停在前,按消耗降序
         sort_cols = []
+        ascending_flags = []
         if "action" in df_out.columns:
             df_out["_sort_action"] = (df_out["action"] == "pause").astype(int) * -1
             sort_cols.append("_sort_action")
+            ascending_flags.append(True)
         if "cost_7d_total" in df_out.columns:
             sort_cols.append("cost_7d_total")
+            ascending_flags.append(False)
 
         if sort_cols:
-            df_out = df_out.sort_values(sort_cols, ascending=[True, False])
+            df_out = df_out.sort_values(sort_cols, ascending=ascending_flags)
             if "_sort_action" in df_out.columns:
                 df_out.drop(columns=["_sort_action"], inplace=True)
 

+ 5 - 0
examples/auto_put_ad_mini/tools/roi_calculator.py

@@ -422,6 +422,11 @@ async def calculate_roi_metrics(
         result_df.to_csv(metrics_csv, index=False, encoding="utf-8-sig")
         logger.info("指标 CSV 已保存: %s", metrics_csv)
 
+        # 同时保存为 metrics_temp.csv(最新指标,供下游工具默认路径使用)
+        metrics_temp = metrics_dir / "metrics_temp.csv"
+        result_df.to_csv(metrics_temp, index=False, encoding="utf-8-sig")
+        logger.info("指标临时文件已更新: %s", metrics_temp)
+
         output_lines = [
             f"ROI 计算完成(截至 {end_date_str})",
             f"广告总数: {len(result_df)}",