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

feat: add auto_put_ad_mini — ROI驱动的小程序投流决策 Agent

新增 examples/auto_put_ad_mini/ 完整项目:
- 三维度决策引擎(ROI过低 / 长期无消耗 / 广告衰退)
- f_7日动态ROI 计算(基于裂变效率稳定因子)
- 创意级数据采集 + 广告级聚合(data_query + roi_calculator)
- 30天增量数据拉取,支持断点续跑(fetch_data + merge_data)
- CSV + XLSX 决策报告生成(report_generator)
- 离线验证脚本(verify_decision)

同步更新 examples/auto_put_ad/ prompts 和 run.py

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
刘立冬 3 недель назад
Родитель
Сommit
10b0b28141

+ 9 - 17
examples/auto_put_ad/presets.json

@@ -1,62 +1,54 @@
 {
 {
   "main": {
   "main": {
     "max_iterations": 500,
     "max_iterations": 500,
-    "skills": ["planning", "ad_domain"],
-    "description": "投放决策中枢 - 任务拆解与全局调度"
+    "skills": ["planning", "ad_domain"]
   },
   },
   "audience": {
   "audience": {
     "system_prompt_file": "prompts/audience.prompt",
     "system_prompt_file": "prompts/audience.prompt",
     "max_iterations": 100,
     "max_iterations": 100,
     "temperature": 0.3,
     "temperature": 0.3,
-    "skills": ["planning", "audience_strategy"],
-    "description": "人群定向 Agent - 受众分析与定向策略"
+    "skills": ["planning", "audience_strategy"]
   },
   },
   "creative": {
   "creative": {
     "system_prompt_file": "prompts/creative.prompt",
     "system_prompt_file": "prompts/creative.prompt",
     "max_iterations": 100,
     "max_iterations": 100,
     "temperature": 0.3,
     "temperature": 0.3,
-    "skills": ["planning", "creative_strategy"],
-    "description": "素材管理 Agent - 素材分析与优化建议"
+    "skills": ["planning", "creative_strategy"]
   },
   },
   "budget": {
   "budget": {
     "system_prompt_file": "prompts/budget.prompt",
     "system_prompt_file": "prompts/budget.prompt",
     "max_iterations": 30,
     "max_iterations": 30,
     "temperature": 0.3,
     "temperature": 0.3,
-    "tools": [
+    "allowed_tools": [
       "load_strategy_config", "update_strategy_config", "get_config_history",
       "load_strategy_config", "update_strategy_config", "get_config_history",
       "get_ad_performance", "get_account_summary", "data_query",
       "get_ad_performance", "get_account_summary", "data_query",
       "compute_budget_thresholds", "classify_ads", "compute_bid_adjustment",
       "compute_budget_thresholds", "classify_ads", "compute_bid_adjustment",
       "ad_get_list", "ad_get_report"
       "ad_get_list", "ad_get_report"
     ],
     ],
-    "skills": ["ad_domain", "budget_strategy"],
-    "description": "预算出价 Agent - 预算分配与 ROI 优化"
+    "skills": ["ad_domain", "budget_strategy"]
   },
   },
   "execute": {
   "execute": {
     "system_prompt_file": "prompts/execute.prompt",
     "system_prompt_file": "prompts/execute.prompt",
     "max_iterations": 30,
     "max_iterations": 30,
     "temperature": 0.1,
     "temperature": 0.1,
-    "skills": ["planning", "budget_strategy"],
-    "description": "执行 Agent - 读取确认方案并执行出价调整与广告关停"
+    "skills": ["planning", "budget_strategy"]
   },
   },
   "system_ops": {
   "system_ops": {
     "system_prompt_file": "prompts/system_ops.prompt",
     "system_prompt_file": "prompts/system_ops.prompt",
     "max_iterations": 150,
     "max_iterations": 150,
     "temperature": 0.1,
     "temperature": 0.1,
-    "skills": ["planning", "ad_domain"],
-    "description": "系统操作 Agent - 腾讯广告 API 操作执行"
+    "skills": ["planning", "ad_domain"]
   },
   },
   "monitor": {
   "monitor": {
     "system_prompt_file": "prompts/monitor.prompt",
     "system_prompt_file": "prompts/monitor.prompt",
     "max_iterations": 50,
     "max_iterations": 50,
     "temperature": 0.1,
     "temperature": 0.1,
-    "skills": ["planning", "monitor_rules"],
-    "description": "监控熔断 Agent - 实时异常检测与自动熔断"
+    "skills": ["planning", "monitor_rules"]
   },
   },
   "data_analyst": {
   "data_analyst": {
     "max_iterations": 80,
     "max_iterations": 80,
     "temperature": 0.2,
     "temperature": 0.2,
     "skills": ["planning"],
     "skills": ["planning"],
-    "denied_tools": ["ad_create", "ad_update", "ad_batch_update_status", "creative_create", "creative_update"],
-    "description": "数据分析 Agent - 只读数据查询与分析"
+    "denied_tools": ["ad_create", "ad_update", "ad_batch_update_status", "creative_create", "creative_update"]
   }
   }
 }
 }

+ 4 - 0
examples/auto_put_ad/prompts/audience.prompt

@@ -1,3 +1,7 @@
+---
+---
+
+$system$
 你是人群定向专家 Agent,负责目标受众分析和定向策略制定。
 你是人群定向专家 Agent,负责目标受众分析和定向策略制定。
 
 
 ## 你的职责
 ## 你的职责

+ 4 - 0
examples/auto_put_ad/prompts/budget.prompt

@@ -1,3 +1,7 @@
+---
+---
+
+$system$
 你是预算出价决策 Agent,负责制定预算分配和出价调整的战略方案。
 你是预算出价决策 Agent,负责制定预算分配和出价调整的战略方案。
 
 
 ## 工作流程
 ## 工作流程

+ 4 - 0
examples/auto_put_ad/prompts/creative.prompt

@@ -1,3 +1,7 @@
+---
+---
+
+$system$
 你是素材管理专家 Agent,负责广告素材的选择、组合、效果分析和优化建议。
 你是素材管理专家 Agent,负责广告素材的选择、组合、效果分析和优化建议。
 
 
 ## 你的职责
 ## 你的职责

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

@@ -1,3 +1,7 @@
+---
+---
+
+$system$
 你是腾讯广告自动化投放系统的执行 Agent,负责读取运营确认后的调整方案并执行 API 操作。
 你是腾讯广告自动化投放系统的执行 Agent,负责读取运营确认后的调整方案并执行 API 操作。
 
 
 ## 你的职责
 ## 你的职责

+ 4 - 0
examples/auto_put_ad/prompts/monitor.prompt

@@ -1,3 +1,7 @@
+---
+---
+
+$system$
 你是监控与熔断 Agent,负责实时检测投放异常并触发自动熔断。
 你是监控与熔断 Agent,负责实时检测投放异常并触发自动熔断。
 
 
 ## 你的职责
 ## 你的职责

+ 4 - 0
examples/auto_put_ad/prompts/system_ops.prompt

@@ -1,3 +1,7 @@
+---
+---
+
+$system$
 你是系统操作执行 Agent,负责与腾讯广告 API 的直接交互,执行广告和创意的 CRUD 操作。
 你是系统操作执行 Agent,负责与腾讯广告 API 的直接交互,执行广告和创意的 CRUD 操作。
 
 
 ## 你的职责
 ## 你的职责

+ 22 - 5
examples/auto_put_ad/run.py

@@ -11,9 +11,9 @@ import os
 import sys
 import sys
 from pathlib import Path
 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")
+# 代理设置
+os.environ.setdefault("HTTP_PROXY", "http://127.0.0.1:29758")
+os.environ.setdefault("HTTPS_PROXY", "http://127.0.0.1:29758")
 
 
 # 添加项目根目录到 Python 路径
 # 添加项目根目录到 Python 路径
 sys.path.insert(0, str(Path(__file__).parent.parent.parent))
 sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@@ -64,12 +64,17 @@ async def init_project_env(messages=None):
     if main_prompt_path.exists():
     if main_prompt_path.exists():
         system_prompt = main_prompt_path.read_text(encoding="utf-8")
         system_prompt = main_prompt_path.read_text(encoding="utf-8")
 
 
+    # 加载 presets(必须在创建 runner 之前)
+    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)
     store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
     runner = AgentRunner(
     runner = AgentRunner(
         trace_store=store,
         trace_store=store,
         llm_call=create_openrouter_llm_call(model=MAIN_CONFIG.model),
         llm_call=create_openrouter_llm_call(model=MAIN_CONFIG.model),
         skills_dir=SKILLS_DIR if Path(SKILLS_DIR).exists() else None,
         skills_dir=SKILLS_DIR if Path(SKILLS_DIR).exists() else None,
-        presets_path=base_dir / "presets.json",  # 启用多 Agent 预设
         logger_name="agents.auto_put_ad",
         logger_name="agents.auto_put_ad",
     )
     )
 
 
@@ -81,6 +86,13 @@ async def init_project_env(messages=None):
     if not messages:
     if not messages:
         messages = [{"role": "user", "content": "今天小程序预算10w"}]
         messages = [{"role": "user", "content": "今天小程序预算10w"}]
 
 
+    # 注入 system prompt 到 messages(run_api.py 的 config 合并会丢失 system_prompt,
+    # 通过 messages 传递确保 system prompt 被 agent 接收)
+    if system_prompt:
+        has_system = any(m.get("role") == "system" for m in messages)
+        if not has_system:
+            messages = [{"role": "system", "content": system_prompt}] + messages
+
     return runner, messages, config
     return runner, messages, config
 
 
 
 
@@ -97,13 +109,18 @@ async def main():
     if main_prompt_path.exists():
     if main_prompt_path.exists():
         system_prompt = main_prompt_path.read_text(encoding="utf-8")
         system_prompt = main_prompt_path.read_text(encoding="utf-8")
 
 
+    # 加载 presets(必须在创建 runner 之前)
+    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))
+
     # 创建 Runner(启用多 Agent)
     # 创建 Runner(启用多 Agent)
     store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
     store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
     runner = AgentRunner(
     runner = AgentRunner(
         trace_store=store,
         trace_store=store,
         llm_call=create_openrouter_llm_call(model=MAIN_CONFIG.model),
         llm_call=create_openrouter_llm_call(model=MAIN_CONFIG.model),
         skills_dir=SKILLS_DIR if Path(SKILLS_DIR).exists() else None,
         skills_dir=SKILLS_DIR if Path(SKILLS_DIR).exists() else None,
-        presets_path=base_dir / "presets.json",  # 启用多 Agent 预设
         logger_name="agents.auto_put_ad",
         logger_name="agents.auto_put_ad",
     )
     )
 
 

+ 4 - 0
examples/auto_put_ad_mini/.gitignore

@@ -0,0 +1,4 @@
+outputs/
+__pycache__/
+*.pyc
+.DS_Store

+ 95 - 0
examples/auto_put_ad_mini/config.py

@@ -0,0 +1,95 @@
+"""
+广告决策引擎配置 — auto_put_ad_mini
+
+运营可直接修改此文件调整决策参数。
+V3 阶段:基于 30 天创意级数据 + f_7日动态ROI + 三维度决策引擎
+  - 30 天创意级别数据采集(增量,按日期归档)
+  - f_7日动态ROI 作为核心决策指标
+  - 三维度决策引擎(ROI过低 / 长期无消耗 / 广告衰退)
+  - 所有阈值放 SKILL,不硬编码
+"""
+from pathlib import Path
+from agent.core.runner import RunConfig, KnowledgeConfig
+
+# ═══════════════════════════════════════════
+# Agent 运行配置
+# ═══════════════════════════════════════════
+
+MAIN_CONFIG = RunConfig(
+    model="qwen/qwen3.5-plus-02-15",
+    temperature=0.3,
+    max_iterations=50,
+    name="广告调控助手 V3",
+    tools=[
+        "fetch_creative_data",
+        "merge_creative_data",
+        "calculate_roi_metrics",
+        "analyze_ads",
+        "generate_report",
+    ],
+    skills=["roi-strategy"],
+    extra_llm_params={"extra_body": {"enable_thinking": True}},
+    knowledge=KnowledgeConfig(
+        enable_extraction=False,
+        enable_completion_extraction=False,
+        enable_injection=False,
+        owner="ad_mini_team",
+    ),
+)
+
+SKILLS_DIR = str(Path(__file__).parent / "skills")
+TRACE_STORE_PATH = ".trace"
+LOG_LEVEL = "INFO"
+LOG_FILE = None
+
+# ═══════════════════════════════════════════
+# V3 数据窗口配置
+# ═══════════════════════════════════════════
+DATA_WINDOW_DAYS = 30  # 采集 30 天历史数据
+ROI_CALCULATION_DAYS = 7  # f_7日动态ROI 计算窗口
+
+# ═══════════════════════════════════════════
+# V3 决策阈值(默认值,可被 SKILL 覆盖)
+# ═══════════════════════════════════════════
+MIN_DAILY_COST = 100  # 日消耗 >= 100元才参与 ROI 计算
+MIN_AD_AGE_DAYS = 7  # 广告创建 >= 7天才参与决策
+ROI_LOW_FACTOR = 0.5  # f_7日动态ROI < 全体均值 × 0.5 → 关停
+NO_SPEND_THRESHOLD = 10  # 7日消耗均值 < 10元 → 关停
+STABLE_SPEND_THRESHOLD = 100  # 稳定消耗定义:>100元/天
+
+# ═══════════════════════════════════════════
+# 输出路径配置
+# ═══════════════════════════════════════════
+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"  # 决策报告
+
+# ═══════════════════════════════════════════
+# 人群包系数(保留,用于展示)
+# ═══════════════════════════════════════════
+AUDIENCE_COEFFICIENTS = {
+    "R500":    3.0,
+    "R330+":   2.5,
+    "R330":    2.0,
+    "R180":    1.5,
+    "R100":    1.2,
+    "R50":     1.0,
+    "R10":     1.0,
+    "R2":      1.0,
+    "default": 1.0,
+}
+
+# 从广告名称提取 R 值的匹配顺序
+AUDIENCE_TIER_PATTERNS = [
+    ("R500",   ["R500", "R_500", "r500"]),
+    ("R330+",  ["回流330+", "回流330+-", "回流q330", "330+全品类", "R330+", "R_330+"]),
+    ("R330",   ["回流330", "R330", "R_330", "定向330", "r330", "r300"]),
+    ("R180",   ["回流180", "R180", "R_180", "定向180", "r180",
+                "r180-330", "r180-300", "R100-180", "R_100-180", "r100-180"]),
+    ("R100",   ["回流100", "R100", "R_100", "定向100", "r100", "R50-100"]),
+    ("R50",    ["回流50", "R50", "R_50", "r50"]),
+    ("R10",    ["R_10", "R10", "r10"]),
+    ("R2",     ["R_2", "R2", "r2"]),
+]
+

+ 172 - 0
examples/auto_put_ad_mini/fetch_data.py

@@ -0,0 +1,172 @@
+"""
+数据拉取脚本 — auto_put_ad_mini V3
+
+V3 职责:
+  - 拉取 30 天创意级别数据(增量,已有 CSV 的日期跳过)
+  - 拉取 30 天广告状态快照
+  - 输出到 outputs/raw/ 和 outputs/ad_status/
+
+用法:
+  # 拉取最近 30 天
+  .venv/bin/python3 examples/auto_put_ad_mini/fetch_data.py --days 30
+
+  # 拉取单日(验证 SQL)
+  .venv/bin/python3 examples/auto_put_ad_mini/fetch_data.py --bizdate 20260409
+
+  # 拉取指定天数
+  .venv/bin/python3 examples/auto_put_ad_mini/fetch_data.py --days 7
+"""
+
+import argparse
+import asyncio
+import logging
+import sys
+from datetime import datetime, timedelta
+from pathlib import Path
+
+# 把项目根目录加入 path
+ROOT = Path(__file__).resolve().parent.parent.parent
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / "examples" / "auto_put_ad_mini"))
+
+from tools.data_query import _fetch_creative_data, _fetch_ad_status, _parse_bizdate, _merge_single_day
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(message)s",
+    datefmt="%H:%M:%S",
+)
+logger = logging.getLogger(__name__)
+
+_MINI_DIR = Path(__file__).resolve().parent
+_RAW_DIR = _MINI_DIR / "outputs" / "raw"
+_AD_STATUS_DIR = _MINI_DIR / "outputs" / "ad_status"
+
+
+def fetch_single_day(bizdate: str) -> bool:
+    """
+    拉取单日数据。
+
+    Returns:
+        True: 成功拉取或已存在
+        False: 拉取失败
+    """
+    biz, biz_dash = _parse_bizdate(bizdate)
+
+    # 检查是否已存在
+    creative_csv = _RAW_DIR / f"creative_{biz}.csv"
+    ad_status_csv = _AD_STATUS_DIR / f"ad_status_{biz}.csv"
+
+    if creative_csv.exists() and ad_status_csv.exists():
+        logger.info("✓ %s 数据已存在,跳过", biz)
+        return True
+
+    logger.info("→ 开始拉取 %s", biz)
+
+    # 拉取创意数据
+    if not creative_csv.exists():
+        df_creative = _fetch_creative_data(biz)
+        if df_creative is None:
+            logger.error("✗ %s 创意数据拉取失败", biz)
+            return False
+
+        _RAW_DIR.mkdir(parents=True, exist_ok=True)
+        df_creative.to_csv(creative_csv, index=False, encoding="utf-8-sig")
+        logger.info("  ✓ 创意数据: %d 行 → %s", len(df_creative), creative_csv.name)
+    else:
+        logger.info("  ✓ 创意数据已存在")
+
+    # 拉取广告状态
+    if not ad_status_csv.exists():
+        df_status = _fetch_ad_status(biz)
+        if df_status is None:
+            logger.error("✗ %s 广告状态拉取失败", biz)
+            return False
+
+        _AD_STATUS_DIR.mkdir(parents=True, exist_ok=True)
+        df_status.to_csv(ad_status_csv, index=False, encoding="utf-8-sig")
+        logger.info("  ✓ 广告状态: %d 行 → %s", len(df_status), ad_status_csv.name)
+    else:
+        logger.info("  ✓ 广告状态已存在")
+
+    # 合并创意数据与广告状态(可选,自动执行)
+    df_merged = _merge_single_day(biz)
+    if df_merged is not None:
+        logger.info("  ✓ 合并完成: %d 行, %d 列", len(df_merged), len(df_merged.columns))
+
+    return True
+
+
+def fetch_multiple_days(days: int, end_date: str = "yesterday") -> None:
+    """
+    拉取多日数据(增量)。
+
+    Args:
+        days: 拉取天数
+        end_date: 结束日期(yesterday 或 YYYYMMDD)
+    """
+    if end_date == "yesterday":
+        end_dt = datetime.now() - timedelta(days=1)
+    else:
+        end_dt = datetime.strptime(end_date, "%Y%m%d")
+
+    logger.info("=" * 60)
+    logger.info("开始拉取 %d 天数据(结束日期: %s)", days, end_dt.strftime("%Y%m%d"))
+    logger.info("=" * 60)
+
+    success_count = 0
+    fail_count = 0
+    skip_count = 0
+
+    for i in range(days):
+        date_dt = end_dt - timedelta(days=i)
+        bizdate = date_dt.strftime("%Y%m%d")
+
+        creative_csv = _RAW_DIR / f"creative_{bizdate}.csv"
+        ad_status_csv = _AD_STATUS_DIR / f"ad_status_{bizdate}.csv"
+
+        if creative_csv.exists() and ad_status_csv.exists():
+            skip_count += 1
+            continue
+
+        if fetch_single_day(bizdate):
+            success_count += 1
+        else:
+            fail_count += 1
+
+    logger.info("=" * 60)
+    logger.info("拉取完成: 成功 %d, 失败 %d, 跳过 %d", success_count, fail_count, skip_count)
+    logger.info("=" * 60)
+
+    # 列出已有文件
+    creative_files = sorted(_RAW_DIR.glob("creative_*.csv"))
+    status_files = sorted(_AD_STATUS_DIR.glob("ad_status_*.csv"))
+
+    logger.info("创意数据文件 (%d 个):", len(creative_files))
+    for f in creative_files[-5:]:  # 只显示最近 5 个
+        size_kb = f.stat().st_size / 1024
+        logger.info("  %s (%.1f KB)", f.name, size_kb)
+
+    logger.info("广告状态文件 (%d 个):", len(status_files))
+    for f in status_files[-5:]:
+        size_kb = f.stat().st_size / 1024
+        logger.info("  %s (%.1f KB)", f.name, size_kb)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="拉取创意级别数据(V3)")
+    parser.add_argument("--bizdate", default="", help="单日拉取: YYYYMMDD")
+    parser.add_argument("--days", type=int, default=0, help="多日拉取: 天数")
+    parser.add_argument("--end_date", default="yesterday", help="结束日期: yesterday 或 YYYYMMDD")
+    args = parser.parse_args()
+
+    if args.bizdate:
+        # 单日模式
+        success = fetch_single_day(args.bizdate)
+        sys.exit(0 if success else 1)
+    elif args.days > 0:
+        # 多日模式
+        fetch_multiple_days(args.days, args.end_date)
+    else:
+        # 默认:拉取 30 天
+        fetch_multiple_days(30, "yesterday")

+ 135 - 0
examples/auto_put_ad_mini/merge_data.py

@@ -0,0 +1,135 @@
+"""
+数据合并脚本 — auto_put_ad_mini V3
+
+独立工具:合并创意数据与广告状态数据
+
+用法:
+  # 合并最近 30 天
+  .venv/bin/python3 examples/auto_put_ad_mini/merge_data.py --days 30
+
+  # 合并单日
+  .venv/bin/python3 examples/auto_put_ad_mini/merge_data.py --bizdate 20260411
+
+  # 强制重新合并(覆盖已存在的文件)
+  .venv/bin/python3 examples/auto_put_ad_mini/merge_data.py --days 30 --force
+"""
+
+import argparse
+import logging
+import sys
+from datetime import datetime, timedelta
+from pathlib import Path
+
+# 把项目根目录加入 path
+ROOT = Path(__file__).resolve().parent.parent.parent
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / "examples" / "auto_put_ad_mini"))
+
+from tools.data_query import _merge_single_day, _parse_bizdate
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(message)s",
+    datefmt="%H:%M:%S",
+)
+logger = logging.getLogger(__name__)
+
+_MINI_DIR = Path(__file__).resolve().parent
+_MERGED_DIR = _MINI_DIR / "outputs" / "merged"
+
+
+def merge_single_day(bizdate: str, force: bool = False) -> bool:
+    """
+    合并单日数据。
+
+    Returns:
+        True: 成功合并
+        False: 合并失败
+    """
+    biz, _ = _parse_bizdate(bizdate)
+
+    # 检查是否已存在
+    merged_csv = _MERGED_DIR / f"merged_{biz}.csv"
+    if merged_csv.exists() and not force:
+        logger.info("✓ %s 合并文件已存在,跳过(使用 --force 强制重新合并)", biz)
+        return True
+
+    logger.info("→ 开始合并 %s", biz)
+
+    df = _merge_single_day(biz)
+    if df is not None:
+        logger.info("✓ 合并成功: %d 行, %d 列", len(df), len(df.columns))
+        return True
+    else:
+        logger.error("✗ 合并失败(源文件缺失)")
+        return False
+
+
+def merge_multiple_days(days: int, end_date: str = "yesterday", force: bool = False) -> None:
+    """
+    合并多日数据。
+
+    Args:
+        days: 合并天数
+        end_date: 结束日期(yesterday 或 YYYYMMDD)
+        force: 是否强制重新合并
+    """
+    if end_date == "yesterday":
+        end_dt = datetime.now() - timedelta(days=1)
+    else:
+        end_dt = datetime.strptime(end_date, "%Y%m%d")
+
+    logger.info("=" * 60)
+    logger.info("开始合并 %d 天数据(结束日期: %s)", days, end_dt.strftime("%Y%m%d"))
+    logger.info("=" * 60)
+
+    success_count = 0
+    fail_count = 0
+    skip_count = 0
+
+    for i in range(days):
+        date_dt = end_dt - timedelta(days=i)
+        bizdate = date_dt.strftime("%Y%m%d")
+
+        merged_csv = _MERGED_DIR / f"merged_{bizdate}.csv"
+
+        if merged_csv.exists() and not force:
+            skip_count += 1
+            continue
+
+        if merge_single_day(bizdate, force):
+            success_count += 1
+        else:
+            fail_count += 1
+
+    logger.info("=" * 60)
+    logger.info("合并完成: 成功 %d, 失败 %d, 跳过 %d", success_count, fail_count, skip_count)
+    logger.info("=" * 60)
+
+    # 列出已有文件
+    merged_files = sorted(_MERGED_DIR.glob("merged_*.csv"))
+
+    logger.info("合并文件 (%d 个):", len(merged_files))
+    for f in merged_files[-5:]:  # 只显示最近 5 个
+        size_kb = f.stat().st_size / 1024
+        logger.info("  %s (%.1f KB)", f.name, size_kb)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="合并创意数据与广告状态(V3)")
+    parser.add_argument("--bizdate", default="", help="单日合并: YYYYMMDD")
+    parser.add_argument("--days", type=int, default=0, help="多日合并: 天数")
+    parser.add_argument("--end_date", default="yesterday", help="结束日期: yesterday 或 YYYYMMDD")
+    parser.add_argument("--force", action="store_true", help="强制重新合并(覆盖已存在的文件)")
+    args = parser.parse_args()
+
+    if args.bizdate:
+        # 单日模式
+        success = merge_single_day(args.bizdate, args.force)
+        sys.exit(0 if success else 1)
+    elif args.days > 0:
+        # 多日模式
+        merge_multiple_days(args.days, args.end_date, args.force)
+    else:
+        # 默认:合并 30 天
+        merge_multiple_days(30, "yesterday", args.force)

+ 14 - 0
examples/auto_put_ad_mini/presets.json

@@ -0,0 +1,14 @@
+{
+  "main": {
+    "system_prompt_file": "prompts/system.prompt",
+    "max_iterations": 50,
+    "temperature": 0.3,
+    "allowed_tools": [
+      "fetch_creative_data",
+      "calculate_roi_metrics",
+      "analyze_ads",
+      "generate_report"
+    ],
+    "skills": ["roi-strategy"]
+  }
+}

+ 45 - 0
examples/auto_put_ad_mini/prompts/system.prompt

@@ -0,0 +1,45 @@
+---
+name: auto_put_ad_mini
+---
+
+$system$
+你是广告调控助手 V3。你的工作是按顺序调用工具完成数据采集、ROI 计算、决策分析和报告生成。
+
+## V3 工作流
+
+用户说"分析广告"或类似指令时,按以下顺序执行:
+
+1. **数据采集** — 调用 `fetch_creative_data(days=30)` 拉取 30 天创意级数据
+   - 已有 CSV 的日期会自动跳过(增量拉取)
+   - 输出到 outputs/raw/ 和 outputs/ad_status/
+
+2. **ROI 计算** — 调用 `calculate_roi_metrics()` 计算 f_7日动态ROI
+   - 创意→广告聚合(GROUP BY ad_id + date, SUM)
+   - 计算 T0裂变系数、arpu、a、b → 7 日滚动 → f_7日动态ROI
+   - 同时计算昨日 ROI、7日/30日汇总
+
+3. **决策分析** — 调用 `analyze_ads()` 执行三维度决策
+   - 维度 1: ROI 过低(f_7日动态ROI < 全体均值 × 0.5)
+   - 维度 2: 长期无消耗(7日消耗均值 < 10元)
+   - 维度 3: 广告衰退(曾稳定消耗,已干预但仍低)
+   - 第一个命中的维度决定动作
+
+4. **生成报告** — 调用 `generate_report()` 输出 CSV + XLSX
+   - CSV 供程序读取
+   - XLSX 带条件格式(关停行标红)
+
+## 响应用户查询
+
+- "分析昨天的广告" → 执行完整工作流
+- "广告 X 为什么被关停" → 从决策结果中查找该广告的命中维度和理由
+- "关停太多了" → 调用 `analyze_ads(roi_low_factor=0.3)` 降低阈值
+- "还不够严格" → 调用 `analyze_ads(roi_low_factor=0.7)` 提高阈值
+- "只看账户 X" → 调用 `analyze_ads(account_id=X)`
+- "这个广告别关停" → 记录用户覆盖,标注在报告中
+
+## 注意事项
+
+- 所有决策由工具完成,你负责调用工具和呈现结果
+- 解释决策时引用具体数据(f_7日动态ROI、消耗、命中维度)
+- 参考 roi-strategy skill 中的领域知识回答用户提问
+- 阈值调整通过工具参数传入,不修改 config.py

+ 164 - 0
examples/auto_put_ad_mini/run.py

@@ -0,0 +1,164 @@
+"""
+广告调控 Agent — auto_put_ad_mini 入口
+
+运行方式:
+    cd /Users/liulidong/project/agent/Agent
+    python examples/auto_put_ad_mini/run.py
+"""
+
+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,
+)
+
+# 导入自定义工具(触发 @tool 注册)
+from examples.auto_put_ad_mini.tools.data_query import fetch_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
+from examples.auto_put_ad_mini.tools.report_generator import generate_report
+
+
+async def init_project_env(messages=None):
+    """供 api_server 可视化调用:返回 (runner, messages, config)"""
+    base_dir = Path(__file__).parent
+
+    system_prompt = _load_system_prompt(base_dir)
+    _load_presets(base_dir)
+
+    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
+
+    if not messages:
+        messages = [{"role": "user", "content": "分析广告"}]
+
+    if system_prompt:
+        has_system = any(m.get("role") == "system" for m in messages)
+        if not has_system:
+            messages = [{"role": "system", "content": system_prompt}] + messages
+
+    return runner, messages, config
+
+
+def _load_system_prompt(base_dir: Path) -> str:
+    prompt_path = base_dir / "prompts" / "system.prompt"
+    if prompt_path.exists():
+        return prompt_path.read_text(encoding="utf-8")
+    return ""
+
+
+def _load_presets(base_dir: Path):
+    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))
+
+
+async def main():
+    base_dir = Path(__file__).parent
+
+    setup_logging(level=LOG_LEVEL, file=LOG_FILE)
+
+    system_prompt = _load_system_prompt(base_dir)
+    _load_presets(base_dir)
+
+    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("=" * 50)
+    print("  广告调控 Agent V3 已启动")
+    print("=" * 50)
+    print("请输入指令(输入 'exit' 退出):")
+    print("示例:")
+    print("  - 分析广告")
+    print("  - 只看账户 123456")
+    print("  - 广告 X 为什么被关停")
+    print()
+
+    while True:
+        try:
+            user_input = input("\n> ").strip()
+            if not user_input:
+                continue
+            if user_input.lower() in ("exit", "quit", "q"):
+                print("退出系统")
+                break
+
+            messages = [{"role": "user", "content": user_input}]
+            config.trace_id = None
+
+            print(f"\n🚀 执行: {user_input}\n")
+
+            async for item in runner.run(messages=messages, config=config):
+                if isinstance(item, Trace):
+                    print(f"[Trace] 状态: {item.status}")
+                elif isinstance(item, Message):
+                    if item.role == "assistant" and item.content:
+                        content = item.content
+                        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))
+                        else:
+                            text = str(content)
+                        if len(text) > 500:
+                            text = text[:500] + "..."
+                        print(f"  [Tool] {text}")
+
+            print("\n" + "=" * 50)
+            print("✅ 完成")
+            print("=" * 50)
+
+        except KeyboardInterrupt:
+            print("\n用户中断,退出")
+            break
+        except Exception as e:
+            print(f"\n❌ 失败: {e}")
+            import traceback
+            traceback.print_exc()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 159 - 0
examples/auto_put_ad_mini/skills/roi_strategy.md

@@ -0,0 +1,159 @@
+---
+name: roi-strategy
+description: V3 广告 ROI 调控领域知识 — f_7日动态ROI + 三维度决策引擎
+category: ad_optimization
+---
+
+## 你的角色
+
+你是广告调控系统的交互层。代码(工具)已完成所有计算和决策,
+你的职责是:
+1. 调用 fetch_creative_data 拉取 30 天创意级数据
+2. 调用 calculate_roi_metrics 计算 f_7日动态ROI
+3. 调用 analyze_ads 执行三维度决策
+4. 调用 generate_report 生成决策报告(CSV + XLSX)
+5. 向用户清晰呈现决策结果,回答提问
+
+## V3 决策逻辑总览
+
+### 数据流
+
+```
+Phase 1: 采集(fetch_creative_data)
+  30 天创意级别数据 → outputs/raw/creative_{date}.csv
+  广告状态数据 → outputs/ad_status/ad_status_{date}.csv
+
+Phase 2: 聚合 + ROI 计算(calculate_roi_metrics)
+  创意→广告聚合(GROUP BY ad_id + date, SUM)
+  → T0裂变系数、arpu、a、b
+  → 7 日滚动:c、d、e、f_7日动态ROI
+
+Phase 3: 决策(analyze_ads)
+  三维度决策引擎:ROI过低 → 长期无消耗 → 广告衰退
+  第一个命中的维度决定动作
+
+Phase 4: 输出(generate_report)
+  CSV + XLSX 带条件格式
+```
+
+## f_7日动态ROI 计算公式
+
+### 核心计算链路
+
+对每个广告,取最近 7 天中**日消耗 >= 100元**的天数参与计算:
+
+```
+Step 1: 每天每个广告(创意→广告聚合)
+  T0裂变系数 = SUM(裂变0层回流数) / SUM(首层打开数)
+  arpu       = SUM(总收入) / SUM(总回流人数)
+  a          = T0裂变系数 * arpu / SUM(cost)
+  b          = SUM(总回流人数) / SUM(首层打开数)
+
+Step 2: 7 天滚动均值
+  c = mean(b) over 7天
+  d = mean(T0裂变系数) over 7天
+  e = c / d
+
+Step 3: 最终指标
+  f_7日动态ROI = a(当天) * e
+```
+
+### 为什么用动态 ROI 而非简单 ROI
+
+- 简单 ROI = 收入/消耗,只反映当天收入,无法体现裂变延后收益
+- f_7日动态ROI 通过裂变系数 × ARPU 的 7 天滚动均值,预估未来 7 天总回报
+- 对高 R 值人群(R330+/R500)更公平,因为其裂变效应更强
+
+### 前置条件
+
+- 日消耗 < 100 元的天数:**不参与** ROI 计算(数据不具统计意义)
+- 7 天内有效天数不足:标记为"数据不足",不参与决策
+
+## 三维度决策标准
+
+### 决策优先级
+
+```
+维度 1(优先级最高): ROI 过低 → 止损
+维度 2: 长期无消耗 → 释放预算
+维度 3: 广告衰退 → 干预无效
+```
+
+第一个命中的维度决定动作,后续维度不再评估。
+
+### 维度 1: ROI 过低(关停)
+
+| 项目 | 值 |
+|------|------|
+| 条件 | f_7日动态ROI < 全体参与计算广告的均值 × **0.5** |
+| 前置条件 | 广告创建 ≥ **7 天**,且 7 日日均消耗 ≥ **100 元** |
+| 动作 | 关停 |
+| 阈值参数 | `roi_low_factor`(默认 0.5) |
+
+### 维度 2: 长期无消耗(关停)
+
+| 项目 | 值 |
+|------|------|
+| 条件 | 最近 7 日消耗均值 < **10 元** |
+| 前置条件 | 广告存在 ≥ **7 天** |
+| 动作 | 关停 |
+| 阈值参数 | `no_spend_threshold`(默认 10 元) |
+
+### 维度 3: 广告衰退(关停)
+
+| 项目 | 值 |
+|------|------|
+| 条件 | 30 天内曾连续稳定消耗(>**100元**/天),近 7 天已提价或换创意,但消耗仍低(<**100元**) |
+| 前置条件 | 无额外前置条件 |
+| 动作 | 关停 |
+| 阈值参数 | `stable_spend_threshold`(默认 100 元) |
+| 衰退判定 | 比较最近 7 天与前 7-14 天的 creative_id 集合变化(检测换创意),比较出价变化(检测提价) |
+
+### 不参与决策的广告
+
+- 创建不满 7 天的广告:备注"投放不足7日",不做任何操作
+
+## 阈值参数一览
+
+| 参数 | 默认值 | 含义 | 调整建议 |
+|------|--------|------|---------|
+| `min_daily_cost` | 100 元 | 日消耗低于此值的天不参与 ROI 计算 | 降低可纳入更多数据,但噪声增大 |
+| `min_ad_age_days` | 7 天 | 广告创建不足此天数不参与决策 | 缩短=更激进,延长=更保守 |
+| `roi_low_factor` | 0.5 | f_7日动态ROI < 均值×此值 → 关停 | 增大=更宽容,减小=更严格 |
+| `no_spend_threshold` | 10 元 | 7日均值消耗低于此值 → 关停 | 增大=更激进,减小=更保守 |
+| `stable_spend_threshold` | 100 元 | 稳定消耗的定义(元/天) | 定义"曾经正常"的标准 |
+| `data_window_days` | 30 天 | 采集历史数据的天数 | 通常不需调整 |
+| `roi_calculation_days` | 7 天 | f_7日动态ROI 的计算窗口 | 通常不需调整 |
+
+## 昨日 ROI 计算
+
+作为参考指标(非决策指标),按广告维度聚合:
+
+```
+昨日 ROI = SUM(总收入) / SUM(cost),广告维度
+```
+
+其中数据来源为创意级聚合到广告级。
+
+## 参数调整工作流
+
+用户反馈 → 参数调整 → 重新调用工具:
+
+| 用户反馈 | 调整方式 |
+|---------|---------|
+| "关停太多了" | `analyze_ads(roi_low_factor=0.3)` — 降低 ROI 过低阈值 |
+| "还不够严格" | `analyze_ads(roi_low_factor=0.7)` — 提高阈值 |
+| "低消耗广告别动" | `analyze_ads(no_spend_threshold=5)` — 降低无消耗阈值 |
+| "只看某个账户" | `analyze_ads(account_id=xxx)` |
+
+## 人群包与预估 ROI(参考)
+
+本业务按裂变能力将受众分为人群包层级(R50/R100/R180/R330/R330+/R500)。
+V3 不再使用人群包系数做决策(改用 f_7日动态ROI),但在报告中仍展示 audience_tier 供参考。
+
+## 回答用户提问的原则
+
+- 始终引用具体数据(f_7日动态ROI、消耗、命中维度)
+- 解释决策原因时,说明是哪个维度命中的,以及具体数值
+- 用户要求调整阈值时,通过工具参数重新调用,不修改 config.py
+- 用户要求覆盖单条决策时(如"这个广告别关停"),记录并标注

+ 0 - 0
examples/auto_put_ad_mini/tools/__init__.py


+ 702 - 0
examples/auto_put_ad_mini/tools/ad_api.py

@@ -0,0 +1,702 @@
+"""
+腾讯广告 Marketing API v3.0 封装工具
+
+层级结构(3.0,仅2层):
+  广告(Ad) → 创意(Dynamic Creative)
+
+⚠️ 重要:
+- 业务概念是"广告",但 API 端点技术上仍叫 adgroups
+- POST 请求:公共参数(access_token/timestamp/nonce)在 URL query,业务参数在 JSON body
+- GET 请求:所有参数(含公共参数)在 URL query,复杂对象需 JSON 序列化后 URL 编码
+
+环境变量:
+  TENCENT_AD_ACCESS_TOKEN   OAuth2 access token
+  TENCENT_AD_ACCOUNT_ID     默认广告账户 ID(可被参数覆盖)
+  TENCENT_AD_BASE_URL       API base,默认 https://api.e.qq.com/v3.0
+"""
+
+import json
+import logging
+import os
+import time
+import uuid
+from typing import Any, Dict, List, Optional
+from urllib.parse import urlencode
+
+import httpx
+
+from agent.tools import tool
+from agent.tools.models import ToolResult
+
+logger = logging.getLogger(__name__)
+
+# ===== 基础配置 =====
+
+BASE_URL = os.getenv("TENCENT_AD_BASE_URL", "https://api.e.qq.com/v3.0")
+DEFAULT_ACCOUNT_ID = int(os.getenv("TENCENT_AD_ACCOUNT_ID", "0") or 0)
+TIMEOUT = 30  # 秒
+
+
+def _get_access_token() -> str:
+    token = os.getenv("TENCENT_AD_ACCESS_TOKEN", "")
+    if not token:
+        raise ValueError("未配置 TENCENT_AD_ACCESS_TOKEN 环境变量")
+    return token
+
+
+def _common_params() -> Dict[str, str]:
+    """公共查询参数:access_token / timestamp / nonce"""
+    return {
+        "access_token": _get_access_token(),
+        "timestamp": str(int(time.time())),
+        "nonce": uuid.uuid4().hex,
+    }
+
+
+def _get(path: str, params: Dict[str, Any]) -> Dict[str, Any]:
+    """
+    发送 GET 请求。
+    复杂对象(list/dict)自动 JSON 序列化后作为 query string 参数传递。
+    """
+    query = dict(_common_params())
+    for k, v in params.items():
+        if v is None:
+            continue
+        if isinstance(v, (dict, list)):
+            query[k] = json.dumps(v, ensure_ascii=False)
+        else:
+            query[k] = str(v)
+
+    url = f"{BASE_URL}{path}?{urlencode(query)}"
+    logger.debug("[TencentAPI] GET %s", url)
+
+    resp = httpx.get(url, timeout=TIMEOUT)
+    resp.raise_for_status()
+    return resp.json()
+
+
+def _post(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
+    """
+    发送 POST 请求。
+    公共参数在 URL query,业务参数在 JSON body。
+    """
+    query = urlencode(_common_params())
+    url = f"{BASE_URL}{path}?{query}"
+    logger.debug("[TencentAPI] POST %s body=%s", url, json.dumps(body, ensure_ascii=False)[:200])
+
+    resp = httpx.post(url, json=body, timeout=TIMEOUT)
+    resp.raise_for_status()
+    return resp.json()
+
+
+def _check(resp: Dict[str, Any], op: str) -> Dict[str, Any]:
+    """统一检查 API 响应,code != 0 时抛异常"""
+    code = resp.get("code", -1)
+    if code != 0:
+        msg = resp.get("message_cn") or resp.get("message", "未知错误")
+        raise RuntimeError(f"[{op}] 腾讯广告 API 错误 code={code}: {msg}")
+    return resp.get("data") or {}
+
+
+# ===== 广告(Ad)— 3.0 顶层单位 =====
+
+@tool(description="创建广告(腾讯广告3.0顶层单位,含营销目标/定向/出价/预算,对应API: /adgroups/add)")
+async def ad_create(
+    adgroup_name: str,
+    marketing_goal: str = "MARKETING_GOAL_USER_GROWTH",
+    marketing_carrier_type: str = "MARKETING_CARRIER_TYPE_MINI_PROGRAM_WECHAT",
+    marketing_carrier_id: str = "",
+    begin_date: str = "",
+    end_date: str = "",
+    time_series: str = "1" * 336,
+    bid_mode: str = "BID_MODE_OCPM",
+    optimization_goal: str = "OPTIMIZATIONGOAL_PAGE_VIEW",
+    bid_amount: int = 0,
+    daily_budget: int = 0,
+    automatic_site_enabled: bool = True,
+    targeting: Optional[Dict[str, Any]] = None,
+    configured_status: str = "AD_STATUS_NORMAL",
+    account_id: int = 0,
+) -> ToolResult:
+    """创建广告(3.0 顶层单位,API 端点: /v3.0/adgroups/add)
+
+    本业务固定参数:
+    - marketing_goal: MARKETING_GOAL_USER_GROWTH(用户增长)
+    - bid_mode: BID_MODE_OCPM(oCPM 出价,固定)
+    - optimization_goal: OPTIMIZATIONGOAL_PAGE_VIEW 或 OPTIMIZATIONGOAL_CLICK
+
+    targeting 结构示例:
+    {
+        "age": [{"min": 25, "max": 35}],
+        "custom_audience": [人群包ID列表],
+        "excluded_custom_audience": [排除人群包ID列表],
+        "geo_location": {"regions": [省市区县ID列表]},
+        "gender": "MALE",  // 可选,不传则不限性别
+        "user_os": ["IOS", "ANDROID"]  // 可选
+    }
+
+    Args:
+        adgroup_name: 广告名称(1-60个等宽字符)
+        marketing_goal: 营销目的,固定 MARKETING_GOAL_USER_GROWTH
+        marketing_carrier_type: 推广载体,MARKETING_CARRIER_TYPE_MINI_PROGRAM_WECHAT 或 MARKETING_CARRIER_TYPE_WECHAT_OFFICIAL_ACCOUNT
+        marketing_carrier_id: 载体ID(小程序AppID或公众号ID)
+        begin_date: 投放开始日期,格式 YYYY-MM-DD
+        end_date: 投放结束日期,格式 YYYY-MM-DD
+        time_series: 投放时段,336位字符串(48段×7天),"1"=投放,"0"=不投,全1表示全时段
+        bid_mode: 出价方式,固定 BID_MODE_OCPM
+        optimization_goal: 优化目标,OPTIMIZATIONGOAL_PAGE_VIEW 或 OPTIMIZATIONGOAL_CLICK
+        bid_amount: 出价(单位:分),如 5000 = 50元
+        daily_budget: 日预算(单位:分),0=不限
+        automatic_site_enabled: 是否开启智能版位(建议 True)
+        targeting: 定向设置(见上方说明)
+        configured_status: AD_STATUS_NORMAL(投放中)或 AD_STATUS_SUSPEND(暂停)
+        account_id: 广告主账号ID,0则使用环境变量
+    """
+    acct = account_id or DEFAULT_ACCOUNT_ID
+    if not acct:
+        return ToolResult(title="ad_create 失败", output="account_id 未指定且未配置 TENCENT_AD_ACCOUNT_ID")
+
+    body: Dict[str, Any] = {
+        "account_id": acct,
+        "adgroup_name": adgroup_name,
+        "marketing_goal": marketing_goal,
+        "marketing_carrier_type": marketing_carrier_type,
+        "bid_mode": bid_mode,
+        "optimization_goal": optimization_goal,
+        "configured_status": configured_status,
+        "automatic_site_enabled": automatic_site_enabled,
+    }
+    if marketing_carrier_id:
+        body["marketing_carrier_detail"] = {"marketing_carrier_id": marketing_carrier_id}
+    if begin_date:
+        body["begin_date"] = begin_date
+    if end_date:
+        body["end_date"] = end_date
+    if time_series:
+        body["time_series"] = time_series
+    if bid_amount:
+        body["bid_amount"] = bid_amount
+    if daily_budget:
+        body["daily_budget"] = daily_budget
+    if targeting:
+        body["targeting"] = targeting
+
+    try:
+        resp = _post("/adgroups/add", body)
+        data = _check(resp, "ad_create")
+        adgroup_id = data.get("adgroup_id")
+        return ToolResult(
+            title=f"广告创建成功",
+            output=f"广告已创建,adgroup_id={adgroup_id},名称:{adgroup_name}",
+            metadata={"adgroup_id": adgroup_id, "adgroup_name": adgroup_name},
+        )
+    except Exception as e:
+        logger.error("ad_create 失败: %s", e)
+        return ToolResult(title="ad_create 失败", output=str(e))
+
+
+@tool(description="更新广告设置(出价/预算/定向/状态/名称),对应API: /adgroups/update")
+async def ad_update(
+    adgroup_id: int,
+    adgroup_name: Optional[str] = None,
+    bid_amount: Optional[int] = None,
+    daily_budget: Optional[int] = None,
+    targeting: Optional[Dict[str, Any]] = None,
+    configured_status: Optional[str] = None,
+    account_id: int = 0,
+) -> ToolResult:
+    """更新广告设置。只传需要修改的字段,未传字段保持不变。
+
+    Args:
+        adgroup_id: 广告ID(API字段名,实际是3.0的广告ID)
+        adgroup_name: 新名称(可选)
+        bid_amount: 新出价,单位分(可选)
+        daily_budget: 新日预算,单位分,0=不限(可选)
+        targeting: 新定向设置(可选)
+        configured_status: 新状态 AD_STATUS_NORMAL / AD_STATUS_SUSPEND(可选)
+        account_id: 广告主账号ID
+    """
+    acct = account_id or DEFAULT_ACCOUNT_ID
+    body: Dict[str, Any] = {"account_id": acct, "adgroup_id": adgroup_id}
+    if adgroup_name is not None:
+        body["adgroup_name"] = adgroup_name
+    if bid_amount is not None:
+        body["bid_amount"] = bid_amount
+    if daily_budget is not None:
+        body["daily_budget"] = daily_budget
+    if targeting is not None:
+        body["targeting"] = targeting
+    if configured_status is not None:
+        body["configured_status"] = configured_status
+
+    try:
+        resp = _post("/adgroups/update", body)
+        _check(resp, "ad_update")
+        changes = [k for k in ["adgroup_name", "bid_amount", "daily_budget", "targeting", "configured_status"] if k in body]
+        return ToolResult(
+            title="广告更新成功",
+            output=f"广告 {adgroup_id} 已更新字段:{', '.join(changes)}",
+        )
+    except Exception as e:
+        return ToolResult(title="ad_update 失败", output=str(e))
+
+
+@tool(description="批量修改广告状态(开启/暂停),一次最多50个广告")
+async def ad_batch_update_status(
+    adgroup_ids: List[int],
+    configured_status: str,
+    account_id: int = 0,
+) -> ToolResult:
+    """批量开启或暂停广告,单次最多50个。
+
+    Args:
+        adgroup_ids: 广告ID列表,最多50个
+        configured_status: AD_STATUS_NORMAL(开启)或 AD_STATUS_SUSPEND(暂停)
+        account_id: 广告主账号ID
+    """
+    acct = account_id or DEFAULT_ACCOUNT_ID
+    if len(adgroup_ids) > 50:
+        return ToolResult(title="ad_batch_update_status 失败", output="单次最多操作50个广告(API限制)")
+
+    results = []
+    errors = []
+    for adgroup_id in adgroup_ids:
+        try:
+            body = {"account_id": acct, "adgroup_id": adgroup_id, "configured_status": configured_status}
+            resp = _post("/adgroups/update", body)
+            _check(resp, "ad_batch_update_status")
+            results.append(adgroup_id)
+        except Exception as e:
+            errors.append(f"{adgroup_id}: {e}")
+
+    status_label = "开启" if configured_status == "AD_STATUS_NORMAL" else "暂停"
+    summary = f"成功{status_label} {len(results)} 个广告"
+    if errors:
+        summary += f",失败 {len(errors)} 个:{'; '.join(errors)}"
+    return ToolResult(title=f"批量{status_label}广告", output=summary)
+
+
+@tool(description="查询广告列表,支持按ID/状态/营销目标过滤")
+async def ad_get_list(
+    adgroup_ids: Optional[List[int]] = None,
+    configured_status: Optional[List[str]] = None,
+    marketing_goal: Optional[str] = None,
+    page: int = 1,
+    page_size: int = 20,
+    account_id: int = 0,
+) -> ToolResult:
+    """查询广告列表。
+
+    Args:
+        adgroup_ids: 按广告ID过滤(可选)
+        configured_status: 按状态过滤,如 ["AD_STATUS_NORMAL", "AD_STATUS_SUSPEND"]
+        marketing_goal: 按营销目标过滤(可选)
+        page: 页码,从1开始
+        page_size: 每页数量,最大100
+        account_id: 广告主账号ID
+    """
+    acct = account_id or DEFAULT_ACCOUNT_ID
+    params: Dict[str, Any] = {"account_id": acct, "page": page, "page_size": page_size}
+
+    filtering: Dict[str, Any] = {}
+    if adgroup_ids:
+        filtering["adgroup_id_list"] = adgroup_ids
+    if configured_status:
+        filtering["configured_status_list"] = configured_status
+    if marketing_goal:
+        filtering["marketing_goal"] = marketing_goal
+    if filtering:
+        params["filtering"] = filtering
+
+    try:
+        resp = _get("/adgroups/get", params)
+        data = _check(resp, "ad_get_list")
+        items = data.get("list", [])
+        page_info = data.get("page_info", {})
+
+        summary_lines = []
+        for item in items:
+            summary_lines.append(
+                f"- [{item.get('adgroup_id')}] {item.get('adgroup_name')} "
+                f"| 状态:{item.get('configured_status')} "
+                f"| 出价:{item.get('bid_amount', 0)/100:.2f}元 "
+                f"| 日预算:{item.get('daily_budget', 0)/100:.0f}元"
+            )
+
+        output = f"共 {page_info.get('total_number', len(items))} 个广告,当前第{page}页:\n" + "\n".join(summary_lines)
+        return ToolResult(title=f"查询广告列表({len(items)}条)", output=output, metadata={"list": items, "page_info": page_info})
+    except Exception as e:
+        return ToolResult(title="ad_get_list 失败", output=str(e))
+
+
+# ===== 创意(Dynamic Creative)=====
+
+@tool(description="创建动态创意(绑定素材组件到广告),对应API: /dynamic_creatives/add")
+async def creative_create(
+    adgroup_id: int,
+    creative_name: str,
+    page_id: Optional[int] = None,
+    title_list: Optional[List[str]] = None,
+    description_list: Optional[List[str]] = None,
+    image_id_list: Optional[List[str]] = None,
+    video_id: Optional[str] = None,
+    call_to_action: Optional[str] = None,
+    configured_status: str = "AD_STATUS_NORMAL",
+    account_id: int = 0,
+) -> ToolResult:
+    """创建动态创意,系统自动组合素材组件并优化投放。
+
+    Args:
+        adgroup_id: 广告ID(绑定到哪个广告)
+        creative_name: 创意名称
+        page_id: 落地页ID(小程序页面或H5)
+        title_list: 标题列表,系统从中优选(≤30字/条)
+        description_list: 描述列表(≤60字/条)
+        image_id_list: 图片素材ID列表(从素材库获取)
+        video_id: 视频素材ID
+        call_to_action: 行动号召按钮文案,如"立即体验"
+        configured_status: AD_STATUS_NORMAL 或 AD_STATUS_SUSPEND
+        account_id: 广告主账号ID
+    """
+    acct = account_id or DEFAULT_ACCOUNT_ID
+    body: Dict[str, Any] = {
+        "account_id": acct,
+        "adgroup_id": adgroup_id,
+        "dynamic_creative_name": creative_name,
+        "configured_status": configured_status,
+    }
+    if page_id:
+        body["page_id"] = page_id
+    if title_list:
+        body["title_list"] = title_list
+    if description_list:
+        body["description_list"] = description_list
+    if image_id_list:
+        body["image_id_list"] = image_id_list
+    if video_id:
+        body["video_id"] = video_id
+    if call_to_action:
+        body["call_to_action"] = call_to_action
+
+    try:
+        resp = _post("/dynamic_creatives/add", body)
+        data = _check(resp, "creative_create")
+        creative_id = data.get("dynamic_creative_id")
+        return ToolResult(
+            title="创意创建成功",
+            output=f"创意已创建,dynamic_creative_id={creative_id},绑定广告 {adgroup_id}",
+            metadata={"dynamic_creative_id": creative_id},
+        )
+    except Exception as e:
+        return ToolResult(title="creative_create 失败", output=str(e))
+
+
+@tool(description="查询创意列表,支持按广告ID或状态过滤")
+async def creative_get_list(
+    adgroup_id: Optional[int] = None,
+    creative_ids: Optional[List[int]] = None,
+    configured_status: Optional[List[str]] = None,
+    page: int = 1,
+    page_size: int = 20,
+    account_id: int = 0,
+) -> ToolResult:
+    """查询动态创意列表。
+
+    Args:
+        adgroup_id: 按广告ID过滤
+        creative_ids: 按创意ID过滤
+        configured_status: 按状态过滤
+        page: 页码
+        page_size: 每页数量
+        account_id: 广告主账号ID
+    """
+    acct = account_id or DEFAULT_ACCOUNT_ID
+    params: Dict[str, Any] = {"account_id": acct, "page": page, "page_size": page_size}
+
+    filtering: Dict[str, Any] = {}
+    if adgroup_id:
+        filtering["adgroup_id"] = adgroup_id
+    if creative_ids:
+        filtering["dynamic_creative_id_list"] = creative_ids
+    if configured_status:
+        filtering["configured_status_list"] = configured_status
+    if filtering:
+        params["filtering"] = filtering
+
+    try:
+        resp = _get("/dynamic_creatives/get", params)
+        data = _check(resp, "creative_get_list")
+        items = data.get("list", [])
+        page_info = data.get("page_info", {})
+        output = f"共 {page_info.get('total_number', len(items))} 个创意"
+        return ToolResult(title=f"查询创意列表({len(items)}条)", output=output, metadata={"list": items})
+    except Exception as e:
+        return ToolResult(title="creative_get_list 失败", output=str(e))
+
+
+@tool(description="更新创意状态或素材(对应API: /dynamic_creatives/update)")
+async def creative_update(
+    creative_id: int,
+    creative_name: Optional[str] = None,
+    configured_status: Optional[str] = None,
+    title_list: Optional[List[str]] = None,
+    image_id_list: Optional[List[str]] = None,
+    account_id: int = 0,
+) -> ToolResult:
+    """更新动态创意。只传需要修改的字段。"""
+    acct = account_id or DEFAULT_ACCOUNT_ID
+    body: Dict[str, Any] = {"account_id": acct, "dynamic_creative_id": creative_id}
+    if creative_name is not None:
+        body["dynamic_creative_name"] = creative_name
+    if configured_status is not None:
+        body["configured_status"] = configured_status
+    if title_list is not None:
+        body["title_list"] = title_list
+    if image_id_list is not None:
+        body["image_id_list"] = image_id_list
+
+    try:
+        resp = _post("/dynamic_creatives/update", body)
+        _check(resp, "creative_update")
+        return ToolResult(title="创意更新成功", output=f"创意 {creative_id} 已更新")
+    except Exception as e:
+        return ToolResult(title="creative_update 失败", output=str(e))
+
+
+# ===== 数据报表 =====
+
+@tool(description="获取广告数据报表(消耗/点击/转化/CTR等),支持广告和创意两个维度")
+async def ad_get_report(
+    date_range: Dict[str, str],
+    level: str = "adgroup",
+    fields: Optional[List[str]] = None,
+    adgroup_ids: Optional[List[int]] = None,
+    group_by: Optional[List[str]] = None,
+    page: int = 1,
+    page_size: int = 100,
+    account_id: int = 0,
+) -> ToolResult:
+    """查询广告数据报表。
+
+    Args:
+        date_range: {"start_date": "2026-04-01", "end_date": "2026-04-07"}
+        level: 报表维度,"adgroup"(广告级)或 "dynamic_creative"(创意级)
+        fields: 指标字段列表,默认 ["cost", "impression", "click", "ctr", "cpc", "cpm", "conversion", "cvr", "cpa"]
+        adgroup_ids: 按广告ID过滤(可选)
+        group_by: 额外分组维度,如 ["date", "adgroup_id"]
+        page: 页码
+        page_size: 每页数量
+        account_id: 广告主账号ID
+    """
+    acct = account_id or DEFAULT_ACCOUNT_ID
+    default_fields = ["cost", "impression", "click", "ctr", "cpc", "cpm", "conversion", "cvr", "cpa"]
+    report_fields = fields or default_fields
+
+    params: Dict[str, Any] = {
+        "account_id": acct,
+        "level": level.upper() if level == "adgroup" else "DYNAMIC_CREATIVE",
+        "date_range": date_range,
+        "fields": report_fields,
+        "page": page,
+        "page_size": page_size,
+    }
+    if group_by:
+        params["group_by"] = group_by
+    filtering: Dict[str, Any] = {}
+    if adgroup_ids:
+        filtering["adgroup_id_list"] = adgroup_ids
+    if filtering:
+        params["filtering"] = filtering
+
+    # 报表 API 路径根据 level 不同
+    path_map = {"adgroup": "/daily_reports/adgroups/get", "dynamic_creative": "/daily_reports/dynamic_creatives/get"}
+    path = path_map.get(level, "/daily_reports/adgroups/get")
+
+    try:
+        resp = _get(path, params)
+        data = _check(resp, "ad_get_report")
+        items = data.get("list", [])
+
+        if not items:
+            return ToolResult(title="广告报表(无数据)", output="该时间段内无数据")
+
+        # 格式化输出
+        lines = [f"报表维度: {level},时间: {date_range['start_date']} ~ {date_range['end_date']}"]
+        for item in items[:10]:  # 最多显示10条
+            cost = item.get("cost", 0)
+            lines.append(
+                f"- 广告{item.get('adgroup_id', '')}: "
+                f"消耗{cost/100:.2f}元 "
+                f"| 展示{item.get('impression', 0):,} "
+                f"| 点击{item.get('click', 0):,} "
+                f"| CTR{item.get('ctr', 0):.2%} "
+                f"| 转化{item.get('conversion', 0)} "
+                f"| CPA{item.get('cpa', 0)/100:.2f}元"
+            )
+        if len(items) > 10:
+            lines.append(f"...共 {len(items)} 条,仅显示前10条")
+
+        return ToolResult(title=f"广告报表({len(items)}条)", output="\n".join(lines), metadata={"list": items})
+    except Exception as e:
+        return ToolResult(title="ad_get_report 失败", output=str(e))
+
+
+@tool(description="获取单个创意的效果报表(CTR/CVR/消耗/转化),按日汇总")
+async def creative_get_report(
+    adcreative_id: int,
+    date_range: Dict[str, str],
+    fields: Optional[List[str]] = None,
+    account_id: int = 0,
+) -> ToolResult:
+    """获取创意效果报告,用于素材衰退检测和优化决策。
+
+    Args:
+        adcreative_id: 创意ID(dynamic_creative_id)
+        date_range: {"start_date": "2026-04-01", "end_date": "2026-04-07"}
+        fields: 指标字段列表,默认 ["cost", "impression", "click", "ctr", "conversion", "cvr", "cpa"]
+        account_id: 广告主账号ID
+    """
+    acct = account_id or DEFAULT_ACCOUNT_ID
+    default_fields = ["cost", "impression", "click", "ctr", "conversion", "cvr", "cpa"]
+    report_fields = fields or default_fields
+
+    params: Dict[str, Any] = {
+        "account_id": acct,
+        "level": "DYNAMIC_CREATIVE",
+        "date_range": date_range,
+        "fields": report_fields,
+        "filtering": {"dynamic_creative_id_list": [adcreative_id]},
+        "group_by": ["date"],
+    }
+
+    try:
+        resp = _get("/daily_reports/dynamic_creatives/get", params)
+        data = _check(resp, "creative_get_report")
+        items = data.get("list", [])
+
+        if not items:
+            return ToolResult(title="创意报表(无数据)", output=f"创意 {adcreative_id} 在该时间段无数据")
+
+        lines = [f"创意 {adcreative_id} 报表:{date_range['start_date']} ~ {date_range['end_date']}"]
+        total_cost = sum(r.get("cost", 0) for r in items)
+        total_click = sum(r.get("click", 0) for r in items)
+        total_conv = sum(r.get("conversion", 0) for r in items)
+        avg_ctr = (total_click / max(sum(r.get("impression", 0) for r in items), 1))
+        lines.append(
+            f"汇总: 消耗{total_cost/100:.2f}元 | 点击{total_click:,} | 转化{total_conv} "
+            f"| 均CTR{avg_ctr:.2%} | 均CPA{(total_cost/max(total_conv,1))/100:.2f}元"
+        )
+        for item in items:
+            lines.append(
+                f"  {item.get('date', '-')}: 消耗{item.get('cost', 0)/100:.2f}元"
+                f" | CTR{item.get('ctr', 0):.2%}"
+                f" | 转化{item.get('conversion', 0)}"
+            )
+
+        return ToolResult(
+            title=f"创意报表({len(items)}天)",
+            output="\n".join(lines),
+            metadata={"list": items, "adcreative_id": adcreative_id},
+        )
+    except Exception as e:
+        return ToolResult(title="creative_get_report 失败", output=str(e))
+
+
+# ===== 素材库 =====
+
+@tool(description="查询账户素材库列表(图片/视频)")
+async def asset_get_list(
+    material_type: Optional[str] = None,
+    page: int = 1,
+    page_size: int = 20,
+    account_id: int = 0,
+) -> ToolResult:
+    """查询账户下的素材库。
+
+    Args:
+        material_type: "IMAGE" 或 "VIDEO",不传则查全部
+        page: 页码
+        page_size: 每页数量
+        account_id: 广告主账号ID
+    """
+    acct = account_id or DEFAULT_ACCOUNT_ID
+    params: Dict[str, Any] = {"account_id": acct, "page": page, "page_size": page_size}
+    if material_type:
+        params["material_type"] = material_type
+
+    try:
+        resp = _get("/material_infos/get", params)
+        data = _check(resp, "asset_get_list")
+        items = data.get("list", [])
+        output = f"素材库共 {len(items)} 条:\n" + "\n".join(
+            f"- [{m.get('material_id')}] {m.get('material_type')} {m.get('material_name', '')}"
+            for m in items
+        )
+        return ToolResult(title=f"素材库({len(items)}条)", output=output, metadata={"list": items})
+    except Exception as e:
+        return ToolResult(title="asset_get_list 失败", output=str(e))
+
+
+# ===== 人群包 =====
+
+@tool(description="查询账户下可用的自定义人群包列表")
+async def audience_get_list(
+    page: int = 1,
+    page_size: int = 50,
+    account_id: int = 0,
+) -> ToolResult:
+    """查询账户下的自定义人群包(用于 targeting.custom_audience 字段)。
+
+    Args:
+        page: 页码
+        page_size: 每页数量
+        account_id: 广告主账号ID
+    """
+    acct = account_id or DEFAULT_ACCOUNT_ID
+    params: Dict[str, Any] = {"account_id": acct, "page": page, "page_size": page_size}
+
+    try:
+        resp = _get("/custom_audiences/get", params)
+        data = _check(resp, "audience_get_list")
+        items = data.get("list", [])
+        output = f"共 {len(items)} 个人群包:\n" + "\n".join(
+            f"- [{a.get('audience_id')}] {a.get('name')} "
+            f"| 状态:{a.get('status')} "
+            f"| 人数:{a.get('user_count', 0):,}"
+            for a in items
+        )
+        return ToolResult(title=f"人群包列表({len(items)}个)", output=output, metadata={"list": items})
+    except Exception as e:
+        return ToolResult(title="audience_get_list 失败", output=str(e))
+
+
+# ===== 账户信息 =====
+
+@tool(description="获取广告账户基本信息(余额、日限额、账户状态等)")
+async def account_get_info(account_id: int = 0) -> ToolResult:
+    """获取广告账户基本信息。
+
+    Args:
+        account_id: 广告主账号ID,0则使用环境变量
+    """
+    acct = account_id or DEFAULT_ACCOUNT_ID
+    params: Dict[str, Any] = {
+        "account_id": acct,
+        "fields": ["balance", "daily_budget", "configured_status"],
+    }
+
+    try:
+        resp = _get("/accounts/get", params)
+        data = _check(resp, "account_get_info")
+        items = data.get("list", [data])
+        info = items[0] if items else {}
+        balance = info.get("balance", 0)
+        output = (
+            f"账户 {acct} 信息:\n"
+            f"- 余额:{balance/100:.2f} 元\n"
+            f"- 日限额:{info.get('daily_budget', 0)/100:.0f} 元\n"
+            f"- 状态:{info.get('configured_status', '未知')}"
+        )
+        return ToolResult(title="账户信息", output=output, metadata=info)
+    except Exception as e:
+        return ToolResult(title="account_get_info 失败", output=str(e))

+ 592 - 0
examples/auto_put_ad_mini/tools/ad_decision.py

@@ -0,0 +1,592 @@
+"""
+广告决策引擎 — auto_put_ad_mini V3
+
+V3 三维度决策引擎:
+  维度 1(最高优先级): ROI 过低 → 关停
+  维度 2: 长期无消耗 → 关停
+  维度 3: 广告衰退 → 关停
+
+设计:
+  - DecisionDimension 基类 + 优先级注册
+  - 第一个命中的维度决定动作,后续不再评估
+  - 不满 7 天的广告不参与决策
+  - 所有阈值通过参数传入(来自 SKILL)
+"""
+
+import logging
+import sys
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+import numpy as np
+import pandas as pd
+
+from agent.tools import tool
+from agent.tools.models import ToolContext, ToolResult
+
+_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
+
+logger = logging.getLogger(__name__)
+
+
+# ═══════════════════════════════════════════
+# 辅助函数
+# ═══════════════════════════════════════════
+
+
+def _extract_audience_tier(ad_name: str) -> str:
+    """从广告名称提取人群包 R 层级(保留自 V2)。"""
+    if not ad_name:
+        return "default"
+    for tier, patterns in AUDIENCE_TIER_PATTERNS:
+        for pat in patterns:
+            if pat.lower() in str(ad_name).lower():
+                return tier
+    return "default"
+
+
+def _calculate_ad_age_days(create_time) -> Optional[int]:
+    """计算广告从创建到现在的天数。"""
+    if pd.isna(create_time):
+        return None
+    try:
+        if isinstance(create_time, str):
+            ct = datetime.strptime(create_time[:19], "%Y-%m-%d %H:%M:%S")
+        else:
+            ct = pd.Timestamp(create_time).to_pydatetime()
+        return (datetime.now() - ct).days
+    except Exception:
+        return None
+
+
+# ═══════════════════════════════════════════
+# 决策结果数据类
+# ═══════════════════════════════════════════
+
+
+@dataclass
+class Decision:
+    """单个广告的决策结果。"""
+    ad_id: int
+    action: str  # "pause" / "hold"
+    dimension: str  # "ROI过低" / "长期无消耗" / "广告衰退" / "保持"
+    reason: str  # 详细原因
+
+
+# ═══════════════════════════════════════════
+# 决策维度基类(可扩展)
+# ═══════════════════════════════════════════
+
+
+class DecisionDimension(ABC):
+    """决策维度基类。"""
+
+    def __init__(self, priority: int):
+        self.priority = priority
+
+    @abstractmethod
+    def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
+        """
+        评估单个广告是否命中该维度。
+
+        Args:
+            row: 广告数据行(包含所有指标)
+            context: 全局上下文(如全体均值、阈值参数)
+
+        Returns:
+            Decision 对象(命中)或 None(不命中)
+        """
+        pass
+
+
+# ═══════════════════════════════════════════
+# 维度 1: ROI 过低
+# ═══════════════════════════════════════════
+
+
+class ROITooLowDimension(DecisionDimension):
+    """维度 1: 动态ROI_7日均值 < 全体均值 × 0.5 → 关停。"""
+
+    def __init__(self):
+        super().__init__(priority=1)
+
+    def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
+        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")
+
+        min_ad_age = context.get("min_ad_age_days", 7)
+        min_daily_cost = context.get("min_daily_cost", 100)
+        roi_low_factor = context.get("roi_low_factor", 0.5)
+
+        # 前置条件
+        if ad_age_days is None or ad_age_days < min_ad_age:
+            return None
+        if cost_7d_avg < min_daily_cost:
+            return None
+        if pd.isna(f_roi_7d) or pd.isna(f_roi_mean_all):
+            return None
+
+        # 判断
+        threshold = f_roi_mean_all * roi_low_factor
+        if f_roi_7d < threshold:
+            return Decision(
+                ad_id=int(row["ad_id"]),
+                action="pause",
+                dimension="ROI过低",
+                reason=f"动态ROI_7日均值={f_roi_7d:.4f} < 全体均值×{roi_low_factor}={threshold:.4f}"
+            )
+
+        return None
+
+
+# ═══════════════════════════════════════════
+# 维度 2: 长期无消耗
+# ═══════════════════════════════════════════
+
+
+class NoSpendDimension(DecisionDimension):
+    """维度 2: 7日消耗均值 < 10元 → 关停。"""
+
+    def __init__(self):
+        super().__init__(priority=2)
+
+    def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
+        ad_age_days = row.get("ad_age_days")
+        cost_7d_avg = row.get("cost_7d_avg", 0)
+
+        min_ad_age = context.get("min_ad_age_days", 7)
+        no_spend_threshold = context.get("no_spend_threshold", 10)
+
+        # 前置条件
+        if ad_age_days is None or ad_age_days < min_ad_age:
+            return None
+
+        # 判断
+        if cost_7d_avg < no_spend_threshold:
+            return Decision(
+                ad_id=int(row["ad_id"]),
+                action="pause",
+                dimension="长期无消耗",
+                reason=f"7日消耗均值={cost_7d_avg:.2f}元 < {no_spend_threshold}元"
+            )
+
+        return None
+
+
+# ═══════════════════════════════════════════
+# 维度 3: 广告衰退
+# ═══════════════════════════════════════════
+
+
+class AdDecayDimension(DecisionDimension):
+    """
+    维度 3: 广告衰退 → 关停。
+
+    条件:
+      - 30 天内曾连续稳定消耗(>100元/天)
+      - 近 7 天已提价或换创意
+      - 但消耗仍低(<100元)
+    """
+
+    def __init__(self):
+        super().__init__(priority=3)
+
+    def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
+        stable_spend_days_30d = row.get("stable_spend_days_30d", 0)
+        cost_7d_avg = row.get("cost_7d_avg", 0)
+        bid_increased_7d = row.get("bid_increased_7d", False)
+        creative_changed_7d = row.get("creative_changed_7d", False)
+
+        stable_threshold = context.get("stable_spend_threshold", 100)
+
+        # 判断
+        if stable_spend_days_30d >= 7:  # 曾稳定消耗
+            if cost_7d_avg < stable_threshold:  # 现在消耗低
+                if bid_increased_7d or creative_changed_7d:  # 已干预
+                    reason_parts = []
+                    if bid_increased_7d:
+                        reason_parts.append("已提价")
+                    if creative_changed_7d:
+                        reason_parts.append("已换创意")
+                    reason = f"30天内曾稳定消耗{stable_spend_days_30d}天,近7天{'+'.join(reason_parts)},但消耗仍低({cost_7d_avg:.2f}元)"
+
+                    return Decision(
+                        ad_id=int(row["ad_id"]),
+                        action="pause",
+                        dimension="广告衰退",
+                        reason=reason
+                    )
+
+        return None
+
+
+# ═══════════════════════════════════════════
+# 决策引擎
+# ═══════════════════════════════════════════
+
+
+def _run_decision_engine(
+    df: pd.DataFrame,
+    context: Dict
+) -> pd.DataFrame:
+    """
+    运行三维度决策引擎。
+
+    流程:
+      1. 注册所有维度(按优先级排序)
+      2. 对每个广告,按优先级评估维度
+      3. 第一个命中的维度决定动作
+      4. 不满 7 天的广告标记为"投放不足7日"
+
+    输入:
+      df: 广告级指标表(包含 动态ROI, cost_7d_avg, ad_age_days 等)
+      context: 全局上下文(阈值参数、全体均值)
+
+    输出:
+      添加 action, dimension, reason 列的 DataFrame
+    """
+    # 注册维度
+    dimensions = [
+        ROITooLowDimension(),
+        NoSpendDimension(),
+        AdDecayDimension(),
+    ]
+    dimensions.sort(key=lambda d: d.priority)
+
+    decisions = []
+
+    for _, row in df.iterrows():
+        ad_age_days = row.get("ad_age_days")
+        min_ad_age = context.get("min_ad_age_days", 7)
+
+        # 不满 7 天的广告
+        if ad_age_days is None or ad_age_days < min_ad_age:
+            decisions.append(Decision(
+                ad_id=int(row["ad_id"]),
+                action="hold",
+                dimension="保持",
+                reason=f"投放不足{min_ad_age}日(当前{ad_age_days}日)"
+            ))
+            continue
+
+        # 按优先级评估维度
+        decision = None
+        for dim in dimensions:
+            decision = dim.evaluate(row, context)
+            if decision is not None:
+                break
+
+        # 无维度命中 → 保持
+        if decision is None:
+            decision = Decision(
+                ad_id=int(row["ad_id"]),
+                action="hold",
+                dimension="保持",
+                reason="各项指标正常"
+            )
+
+        decisions.append(decision)
+
+    # 转换为 DataFrame
+    decision_df = pd.DataFrame([
+        {
+            "ad_id": d.ad_id,
+            "action": d.action,
+            "dimension": d.dimension,
+            "reason": d.reason,
+        }
+        for d in decisions
+    ])
+
+    # 合并回原 DataFrame
+    df = df.merge(decision_df, on="ad_id", how="left")
+
+    return df
+
+
+# ═══════════════════════════════════════════
+# 衰退检测辅助函数
+# ═══════════════════════════════════════════
+
+
+def _detect_decay_signals(
+    ad_ids: List[int],
+    raw_dir: Path,
+    ad_status_dir: Path,
+    end_date: str
+) -> pd.DataFrame:
+    """
+    检测广告衰退信号(提价、换创意)。
+
+    输入:
+      ad_ids: 需要检测的广告 ID 列表
+      raw_dir: 创意级原始 CSV 目录
+      ad_status_dir: 广告状态 CSV 目录
+      end_date: 结束日期(YYYYMMDD)
+
+    输出:
+      DataFrame,列:ad_id, bid_increased_7d, creative_changed_7d, stable_spend_days_30d
+    """
+    end_dt = datetime.strptime(end_date, "%Y%m%d")
+
+    # 加载近 14 天创意数据(用于检测创意变化)
+    creative_dfs = []
+    for i in range(14):
+        date = (end_dt - timedelta(days=i)).strftime("%Y%m%d")
+        csv_path = raw_dir / f"creative_{date}.csv"
+        if csv_path.exists():
+            df = pd.read_csv(csv_path)
+            df["date"] = date
+            creative_dfs.append(df)
+
+    if not creative_dfs:
+        logger.warning("无创意数据,无法检测衰退信号")
+        return pd.DataFrame(columns=["ad_id", "bid_increased_7d", "creative_changed_7d", "stable_spend_days_30d"])
+
+    creative_df = pd.concat(creative_dfs, ignore_index=True)
+    creative_df = creative_df[creative_df["ad_id"].isin(ad_ids)]
+
+    # 加载近 14 天广告状态(用于检测提价)
+    status_dfs = []
+    for i in range(14):
+        date = (end_dt - timedelta(days=i)).strftime("%Y%m%d")
+        csv_path = ad_status_dir / f"ad_status_{date}.csv"
+        if csv_path.exists():
+            df = pd.read_csv(csv_path)
+            df["date"] = date
+            status_dfs.append(df)
+
+    if not status_dfs:
+        logger.warning("无广告状态数据,无法检测提价")
+        status_df = pd.DataFrame()
+    else:
+        status_df = pd.concat(status_dfs, ignore_index=True)
+        status_df = status_df[status_df["ad_id"].isin(ad_ids)]
+
+    # 检测创意变化(近 7 天 vs 前 7-14 天)
+    recent_7d_start = (end_dt - timedelta(days=6)).strftime("%Y%m%d")
+    prior_7d_start = (end_dt - timedelta(days=13)).strftime("%Y%m%d")
+    prior_7d_end = (end_dt - timedelta(days=7)).strftime("%Y%m%d")
+
+    recent_creatives = (
+        creative_df[creative_df["date"] >= recent_7d_start]
+        .groupby("ad_id")["creative_id"]
+        .apply(set)
+    )
+
+    prior_creatives = (
+        creative_df[
+            (creative_df["date"] >= prior_7d_start) & (creative_df["date"] <= prior_7d_end)
+        ]
+        .groupby("ad_id")["creative_id"]
+        .apply(set)
+    )
+
+    creative_changed = {}
+    for ad_id in ad_ids:
+        recent_set = recent_creatives.get(ad_id, set())
+        prior_set = prior_creatives.get(ad_id, set())
+        creative_changed[ad_id] = (recent_set != prior_set) and len(recent_set) > 0 and len(prior_set) > 0
+
+    # 检测提价(近 7 天最大出价 > 前 7-14 天最大出价)
+    bid_increased = {}
+    if not status_df.empty:
+        recent_bids = (
+            status_df[status_df["date"] >= recent_7d_start]
+            .groupby("ad_id")["bid_amount"]
+            .max()
+        )
+
+        prior_bids = (
+            status_df[
+                (status_df["date"] >= prior_7d_start) & (status_df["date"] <= prior_7d_end)
+            ]
+            .groupby("ad_id")["bid_amount"]
+            .max()
+        )
+
+        for ad_id in ad_ids:
+            recent_bid = recent_bids.get(ad_id, 0)
+            prior_bid = prior_bids.get(ad_id, 0)
+            bid_increased[ad_id] = recent_bid > prior_bid
+    else:
+        bid_increased = {ad_id: False for ad_id in ad_ids}
+
+    # 计算 30 天稳定消耗天数(加载 30 天创意数据)
+    creative_30d_dfs = []
+    for i in range(30):
+        date = (end_dt - timedelta(days=i)).strftime("%Y%m%d")
+        csv_path = raw_dir / f"creative_{date}.csv"
+        if csv_path.exists():
+            df = pd.read_csv(csv_path)
+            df["date"] = date
+            creative_30d_dfs.append(df)
+
+    if creative_30d_dfs:
+        creative_30d_df = pd.concat(creative_30d_dfs, ignore_index=True)
+        creative_30d_df = creative_30d_df[creative_30d_df["ad_id"].isin(ad_ids)]
+
+        # 按 ad_id + date 聚合消耗
+        daily_cost = (
+            creative_30d_df.groupby(["ad_id", "date"])["cost"]
+            .sum()
+            .reset_index()
+        )
+
+        stable_days = {}
+        for ad_id in ad_ids:
+            ad_cost = daily_cost[daily_cost["ad_id"] == ad_id]
+            stable_days[ad_id] = (ad_cost["cost"] >= 100).sum()
+    else:
+        stable_days = {ad_id: 0 for ad_id in ad_ids}
+
+    # 组装结果(不含 stable_spend_days_30d,该值已在 metrics CSV 中)
+    result = pd.DataFrame({
+        "ad_id": ad_ids,
+        "bid_increased_7d": [bid_increased.get(ad_id, False) for ad_id in ad_ids],
+        "creative_changed_7d": [creative_changed.get(ad_id, False) for ad_id in ad_ids],
+    })
+
+    return result
+
+
+# ═══════════════════════════════════════════
+# V3 工具:三维度决策
+# ═══════════════════════════════════════════
+
+
+@tool(description="V3 三维度决策引擎:ROI过低 / 长期无消耗 / 广告衰退")
+async def analyze_ads(
+    ctx: ToolContext,
+    metrics_csv: str,
+    end_date: str = "yesterday",
+    min_ad_age_days: int = 7,
+    min_daily_cost: float = 100.0,
+    roi_low_factor: float = 0.5,
+    no_spend_threshold: float = 10.0,
+    stable_spend_threshold: float = 100.0,
+) -> ToolResult:
+    """
+    V3 三维度决策引擎。
+
+    Args:
+        ctx: 工具上下文
+        metrics_csv: ROI 指标 CSV 路径(calculate_roi_metrics 输出)
+        end_date: 结束日期(YYYYMMDD 或 "yesterday")
+        min_ad_age_days: 最小广告年龄(天)
+        min_daily_cost: 最小日消耗(元)
+        roi_low_factor: ROI 过低因子(< 全体均值 × factor)
+        no_spend_threshold: 长期无消耗阈值(元)
+        stable_spend_threshold: 稳定消耗阈值(元/天)
+
+    Returns:
+        ToolResult,包含决策结果 DataFrame
+    """
+    try:
+        # 加载指标数据
+        df = pd.read_csv(metrics_csv)
+
+        if df.empty:
+            return ToolResult(
+                title="决策引擎",
+                output="指标数据为空,无法决策",
+            )
+
+        # 解析日期
+        if end_date == "yesterday":
+            end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+
+        # 计算广告年龄
+        df["ad_age_days"] = df["create_time"].apply(_calculate_ad_age_days)
+
+        # 提取人群包层级
+        df["audience_tier"] = df["ad_name"].apply(_extract_audience_tier)
+
+        # 检测衰退信号
+        raw_dir = _MINI_DIR / "outputs" / "raw"
+        ad_status_dir = _MINI_DIR / "outputs" / "ad_status"
+
+        decay_signals = _detect_decay_signals(
+            ad_ids=df["ad_id"].tolist(),
+            raw_dir=raw_dir,
+            ad_status_dir=ad_status_dir,
+            end_date=end_date
+        )
+
+        df = df.merge(decay_signals, on="ad_id", how="left")
+
+        # 填充缺失值
+        df["bid_increased_7d"] = df["bid_increased_7d"].fillna(False)
+        df["creative_changed_7d"] = df["creative_changed_7d"].fillna(False)
+        df["stable_spend_days_30d"] = df["stable_spend_days_30d"].fillna(0)
+
+        # 计算全体 动态ROI_7日均值 的均值(决策基准线)
+        f_roi_mean_all = df["动态ROI_7日均值"].mean()
+
+        # 构建上下文
+        context = {
+            "动态ROI_mean_all": f_roi_mean_all,
+            "min_ad_age_days": min_ad_age_days,
+            "min_daily_cost": min_daily_cost,
+            "roi_low_factor": roi_low_factor,
+            "no_spend_threshold": no_spend_threshold,
+            "stable_spend_threshold": stable_spend_threshold,
+        }
+
+        # 运行决策引擎
+        df = _run_decision_engine(df, context)
+
+        # 统计
+        total_ads = len(df)
+        pause_ads = (df["action"] == "pause").sum()
+        hold_ads = (df["action"] == "hold").sum()
+
+        dimension_counts = df["dimension"].value_counts().to_dict()
+
+        output_lines = [
+            f"决策完成(共 {total_ads} 个广告)",
+            f"  - 关停: {pause_ads} 个",
+            f"  - 保持: {hold_ads} 个",
+            "",
+            "维度分布:",
+        ]
+
+        for dim, count in dimension_counts.items():
+            output_lines.append(f"  - {dim}: {count} 个")
+
+        output_lines.extend([
+            "",
+            f"全体 动态ROI_7日均值 均值: {f_roi_mean_all:.4f}",
+            f"ROI 过低阈值: {f_roi_mean_all * roi_low_factor:.4f}",
+        ])
+
+        # 保存决策结果(临时 CSV,供 generate_report 使用)
+        decision_csv = _MINI_DIR / "outputs" / "decision_temp.csv"
+        decision_csv.parent.mkdir(parents=True, exist_ok=True)
+        df.to_csv(decision_csv, index=False)
+
+        return ToolResult(
+            title=f"决策引擎({total_ads}个广告)",
+            output="\n".join(output_lines),
+            metadata={
+                "total_ads": total_ads,
+                "pause_ads": pause_ads,
+                "hold_ads": hold_ads,
+                "dimension_counts": dimension_counts,
+                "动态ROI_mean_all": f_roi_mean_all,
+                "decision_csv": str(decision_csv),
+            },
+        )
+
+    except Exception as e:
+        logger.error("analyze_ads 失败: %s", e, exc_info=True)
+        return ToolResult(title="analyze_ads 失败", output=str(e))

+ 554 - 0
examples/auto_put_ad_mini/tools/data_query.py

@@ -0,0 +1,554 @@
+"""
+数据查询工具 — auto_put_ad_mini V3
+
+V3 职责:
+  - 创意级别明细数据采集(非广告级聚合)
+  - 30 天增量拉取(已有 CSV 的日期跳过)
+  - 广告状态按日快照
+
+数据来源:
+  - 创意效率:ODPS touliu_data + creative_data_day(创意级明细)
+  - 广告状态:ODPS ad_put_tencent_ad(出价/预算/状态)
+
+环境变量:
+  ODPS_ACCESS_ID / ODPS_ACCESS_SECRET / ODPS_PROJECT
+"""
+
+import logging
+import os
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import List, Optional
+
+import pandas as pd
+
+from agent.tools import tool
+from agent.tools.models import ToolContext, ToolResult
+
+logger = logging.getLogger(__name__)
+
+_MINI_DIR = Path(__file__).resolve().parent.parent
+_RAW_DIR = _MINI_DIR / "outputs" / "raw"
+_AD_STATUS_DIR = _MINI_DIR / "outputs" / "ad_status"
+_MERGED_DIR = _MINI_DIR / "outputs" / "merged"
+
+
+# ===== ODPS 客户端(懒加载) =====
+
+_odps_client = None
+
+
+def _get_odps_client():
+    """懒加载 ODPS 客户端,首次调用时初始化。"""
+    global _odps_client
+    if _odps_client is None:
+        try:
+            import sys
+            autoput_ad_dir = _MINI_DIR.parent / "auto_put_ad"
+            if str(autoput_ad_dir) not in sys.path:
+                sys.path.insert(0, str(autoput_ad_dir))
+            from odps_module import ODPSClient
+
+            project = os.getenv("ODPS_PROJECT", "loghubods")
+            _odps_client = ODPSClient(project=project)
+            logger.info("ODPS 客户端已初始化,项目: %s", project)
+        except ImportError as e:
+            logger.warning("odps_module 导入失败: %s", e)
+            _odps_client = None
+        except Exception as e:
+            logger.warning("ODPS 客户端初始化失败: %s", e)
+            _odps_client = None
+    return _odps_client
+
+
+# ===== 日期解析 =====
+
+def _parse_bizdate(bizdate: str) -> tuple:
+    """解析业务日期,返回 (YYYYMMDD, YYYY-MM-DD)。"""
+    if bizdate in ("yesterday", ""):
+        biz = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+    else:
+        biz = bizdate.replace("-", "")
+    biz_dash = f"{biz[:4]}-{biz[4:6]}-{biz[6:]}"
+    return biz, biz_dash
+
+
+# ===== V3 创意级别 SQL =====
+
+def _build_creative_sql(biz: str, biz_dash: str) -> str:
+    """
+    构建创意级别明细 SQL(V3)。
+
+    基于用户提供的原始 SQL,保留创意级明细(不做广告级聚合)。
+
+    返回字段:
+      - 广告维度:account_id, ad_id, ad_name, create_time
+      - 创意维度:creative_id, creative_name, rootsourceid, videoid, title, image_url
+      - 业务维度:agent_name, package_name, 广告优化目标, 人群包人数
+      - 效率指标:cost(元), valid_click_count, conversions_count,
+                  首层小程序打开数, 裂变0层回流数, 裂变1层回流数, 总回流人数, 总收入,
+                  view_count, key_page_view_count, key_page_uv, thousand_display_price
+    """
+    return f"""
+SELECT  '{biz}' AS bizdate
+        ,a.account_id
+        ,d.agent_name
+        ,a.ad_id
+        ,c.ad_name
+        ,c.create_time
+        ,CASE   WHEN t2.event_name IS NULL THEN '无'
+                ELSE t2.event_name
+        END AS 广告优化目标
+        ,t1.package_name
+        ,t4.人群包人数
+        ,a.creative_id
+        ,t.creative_name
+        ,SPLIT(t.rootsourceid,'_')[3] AS videoid
+        ,t.rootsourceid
+        ,t3.title
+        ,t3.image_url
+        ,b.view_count
+        ,b.valid_click_count
+        ,b.key_page_view_count
+        ,b.key_page_uv
+        ,b.thousand_display_price
+        ,b.cost / 100 AS cost
+        ,b.cost / 100 / b.valid_click_count AS 单点击成本
+        ,b.conversions_count
+        ,t.首层小程序打开数
+        ,t.首层小程序打开数 / b.valid_click_count AS 点击转化率
+        ,b.cost / 100 / t.首层小程序打开数 AS 单UV成本
+        ,t.裂变层回流数
+        ,t.裂变0层回流数
+        ,t.裂变0层回流数 / t.首层小程序打开数 AS T0裂变系数
+        ,t.裂变1层回流数
+        ,t.裂变1层回流数 / t.首层小程序打开数 AS T1裂变系数
+        ,t.总回流人数
+        ,t.总收入
+FROM    (
+            SELECT  IF(c.creative_name IS NOT NULL,c.creative_name,t.rootsourceid) AS creative_name
+                    ,t.*
+            FROM    loghubods.touliu_data t
+            LEFT JOIN   (
+                            SELECT  DISTINCT creative_name
+                                    ,SPLIT(GET_JSON_OBJECT(page_spec,'$.wechat_mini_program_spec.mini_program_path'),'rootSourceId%3D')[1] AS rootsourceid
+                                    ,SPLIT(SPLIT(GET_JSON_OBJECT(page_spec,'$.wechat_mini_program_spec.mini_program_path'),'rootSourceId%3D')[1],'_')[3] AS videoid
+                                    ,b.creative_id
+                            FROM    loghubods.ad_put_tencent_creative_components a
+                            LEFT JOIN loghubods.ad_put_tencent_creative_day b
+                            ON      a.creative_id = b.creative_id
+                            WHERE   page_type = 'PAGE_TYPE_WECHAT_MINI_PROGRAM'
+                        ) c
+            ON      c.rootsourceid = t.rootsourceid
+            WHERE   t.dt = '{biz}'
+        ) t
+LEFT JOIN loghubods.ad_put_tencent_creative_day a
+ON      t.creative_name = a.creative_name
+LEFT JOIN loghubods.ad_put_tencent_ad c
+ON      a.ad_id = c.ad_id
+LEFT JOIN   (
+                SELECT  creative_id
+                        ,valid_click_count
+                        ,view_count
+                        ,cost
+                        ,conversions_count
+                        ,key_page_view_count
+                        ,key_page_uv
+                        ,thousand_display_price
+                FROM    (
+                            SELECT  creative_id
+                                    ,valid_click_count
+                                    ,view_count
+                                    ,cost
+                                    ,conversions_count
+                                    ,key_page_view_count
+                                    ,key_page_uv
+                                    ,thousand_display_price
+                                    ,ROW_NUMBER() OVER (PARTITION BY creative_id ORDER BY update_time DESC ) AS rank
+                            FROM    loghubods.ad_put_tencent_creative_data_day
+                            WHERE   dt = REGEXP_REPLACE('{biz}','^(\\\\d{{4}})(\\\\d{{2}})(\\\\d{{2}})$','\\\\1-\\\\2-\\\\3')
+                        ) t
+                WHERE   rank = 1
+            ) b
+ON      a.creative_id = b.creative_id
+LEFT JOIN   (
+                SELECT  account_id
+                        ,MAX(agent_name) AS agent_name
+                FROM    loghubods.ad_put_tencent_account
+                GROUP BY account_id
+            ) d
+ON      a.account_id = d.account_id
+LEFT JOIN   (
+                SELECT  t1.ad_id
+                        ,t1.package_id
+                        ,t1.package_name
+                        ,t1.min_people
+                FROM    (
+                            SELECT  a.ad_id
+                                    ,a.package_id
+                                    ,b.package_name
+                                    ,b.min_people
+                                    ,ROW_NUMBER() OVER (PARTITION BY a.ad_id ORDER BY CAST(b.min_people AS BIGINT) ASC ) AS rank
+                            FROM    loghubods.ad_put_tencent_ad_package_mapping a
+                            LEFT JOIN loghubods.ad_put_tencent_package b
+                            ON      a.package_id = b.tencent_audience_id
+                            WHERE   a.is_delete = 0
+                        ) t1
+                WHERE   t1.rank = 1
+            ) t1
+ON      a.ad_id = t1.ad_id
+LEFT JOIN loghubods.dim_ad_event_enum t2
+ON      c.optimization_goal = t2.event_id
+LEFT JOIN loghubods.ad_put_tencent_creative_analysis t3
+ON      a.creative_id = t3.creative_id
+LEFT JOIN   (
+                SELECT  type
+                        ,COUNT(DISTINCT union_id) AS 人群包人数
+                FROM    loghubods.mid_share_return_people_1year
+                WHERE   dt = MAX_PT('loghubods.mid_share_return_people_1year')
+                GROUP BY type
+            ) t4
+ON      t1.package_name = t4.type
+WHERE   t.dt = '{biz}'
+AND     (
+            总回流人数 >= 30
+            OR      a.account_id IS NOT NULL
+        )
+"""
+
+
+def _fetch_creative_data(bizdate: str) -> Optional[pd.DataFrame]:
+    """拉取单日创意级别数据。"""
+    client = _get_odps_client()
+    if client is None:
+        logger.error("ODPS 客户端未初始化")
+        return None
+
+    biz, biz_dash = _parse_bizdate(bizdate)
+    sql = _build_creative_sql(biz, biz_dash)
+
+    try:
+        logger.info("开始拉取创意数据: %s", biz)
+        df = client.execute_sql(sql)
+
+        if df.empty:
+            logger.warning("创意数据为空: %s", biz)
+            return pd.DataFrame()
+
+        # 类型转换(cost 已经是元,不需要再除以100)
+        for col in ["cost", "单UV成本", "单点击成本", "总收入"]:
+            if col in df.columns:
+                df[col] = pd.to_numeric(df[col], errors="coerce")
+
+        for col in ["首层小程序打开数", "裂变层回流数", "裂变0层回流数", "裂变1层回流数",
+                    "总回流人数", "valid_click_count", "view_count",
+                    "key_page_view_count", "key_page_uv", "conversions_count"]:
+            if col in df.columns:
+                df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype(int)
+
+        logger.info("创意数据拉取成功: %s, %d 行", biz, len(df))
+        return df
+
+    except Exception as e:
+        logger.error("创意数据拉取失败 (%s): %s", biz, e, exc_info=True)
+        return None
+
+
+# ===== 广告状态拉取 =====
+
+def _fetch_ad_status(bizdate: str) -> Optional[pd.DataFrame]:
+    """拉取单日广告状态快照。"""
+    client = _get_odps_client()
+    if client is None:
+        return None
+
+    biz, biz_dash = _parse_bizdate(bizdate)
+    sql = f"""
+    SELECT
+        '{biz}' AS bizdate,
+        ad_id,
+        account_id,
+        ad_name,
+        create_time,
+        ad_status,
+        bid_amount,
+        day_amount,
+        optimization_goal,
+        targeting
+    FROM loghubods.ad_put_tencent_ad
+    """
+
+    try:
+        logger.info("开始拉取广告状态: %s", biz)
+        df = client.execute_sql(sql)
+
+        if not df.empty:
+            df["bid_amount"] = pd.to_numeric(df["bid_amount"], errors="coerce") / 100
+
+        logger.info("广告状态拉取成功: %s, %d 行", biz, len(df))
+        return df
+
+    except Exception as e:
+        logger.error("广告状态拉取失败 (%s): %s", biz, e, exc_info=True)
+        return None
+
+
+# ===== V3 工具:30 天增量采集 =====
+
+@tool(description="拉取 30 天创意级别数据(增量,已有 CSV 的日期跳过)")
+async def fetch_creative_data(
+    ctx: ToolContext,
+    days: int = 30,
+    end_date: str = "yesterday"
+) -> ToolResult:
+    """
+    拉取创意级别明细数据(V3)。
+
+    Args:
+        days: 回溯天数(默认 30)
+        end_date: 结束日期(默认 yesterday,格式 YYYYMMDD 或 YYYY-MM-DD)
+
+    Returns:
+        ToolResult 包含采集摘要
+    """
+    _RAW_DIR.mkdir(parents=True, exist_ok=True)
+    _AD_STATUS_DIR.mkdir(parents=True, exist_ok=True)
+
+    # 解析日期范围
+    _, end_dash = _parse_bizdate(end_date)
+    end_dt = datetime.strptime(end_dash, "%Y-%m-%d")
+
+    dates_to_fetch = []
+    for i in range(days):
+        dt = end_dt - timedelta(days=i)
+        biz = dt.strftime("%Y%m%d")
+
+        creative_csv = _RAW_DIR / f"creative_{biz}.csv"
+        status_csv = _AD_STATUS_DIR / f"ad_status_{biz}.csv"
+
+        if creative_csv.exists() and status_csv.exists():
+            logger.info("跳过已有数据: %s", biz)
+            continue
+
+        dates_to_fetch.append(biz)
+
+    if not dates_to_fetch:
+        return ToolResult(
+            title="数据已完整",
+            output=f"最近 {days} 天数据已全部存在,无需拉取"
+        )
+
+    # 拉取缺失日期
+    success_count = 0
+    failed_dates = []
+
+    for biz in sorted(dates_to_fetch):
+        # 创意数据
+        creative_csv = _RAW_DIR / f"creative_{biz}.csv"
+        if not creative_csv.exists():
+            df_creative = _fetch_creative_data(biz)
+            if df_creative is not None:
+                df_creative.to_csv(creative_csv, index=False, encoding="utf-8-sig")
+                logger.info("已保存: %s (%d 行)", creative_csv.name, len(df_creative))
+            else:
+                failed_dates.append(biz)
+                continue
+
+        # 广告状态
+        status_csv = _AD_STATUS_DIR / f"ad_status_{biz}.csv"
+        if not status_csv.exists():
+            df_status = _fetch_ad_status(biz)
+            if df_status is not None:
+                df_status.to_csv(status_csv, index=False, encoding="utf-8-sig")
+                logger.info("已保存: %s (%d 行)", status_csv.name, len(df_status))
+            else:
+                failed_dates.append(biz)
+                continue
+
+        success_count += 1
+
+    # 汇总
+    lines = [
+        f"数据采集完成(最近 {days} 天)",
+        f"需拉取: {len(dates_to_fetch)} 天",
+        f"成功: {success_count} 天",
+        f"失败: {len(failed_dates)} 天",
+        "",
+        f"数据目录:",
+        f"  创意数据: {_RAW_DIR}",
+        f"  广告状态: {_AD_STATUS_DIR}",
+    ]
+
+    if failed_dates:
+        lines.append("")
+        lines.append("失败日期:")
+        for d in failed_dates:
+            lines.append(f"  - {d}")
+
+    return ToolResult(
+        title=f"数据采集完成({success_count}/{len(dates_to_fetch)})",
+        output="\n".join(lines),
+        metadata={
+            "total_days": days,
+            "to_fetch": len(dates_to_fetch),
+            "success": success_count,
+            "failed": len(failed_dates),
+            "failed_dates": failed_dates,
+        }
+    )
+
+
+# ===== 合并创意数据与广告状态(独立工具)=====
+
+# 合并后的列顺序(共 38 列)
+_MERGED_COLUMNS = [
+    # 时间
+    "bizdate",
+    # 账户
+    "account_id", "agent_name",
+    # 广告基本信息
+    "ad_id", "ad_name", "create_time", "广告优化目标", "package_name", "人群包人数",
+    # 广告状态(来自状态表)
+    "ad_status", "bid_amount", "day_amount", "optimization_goal_raw", "targeting",
+    # 创意维度
+    "creative_id", "creative_name", "videoid", "rootsourceid", "title", "image_url",
+    # 曝光 & 点击
+    "view_count", "valid_click_count", "key_page_view_count", "key_page_uv", "thousand_display_price",
+    # 成本
+    "cost", "单点击成本", "conversions_count",
+    # 回流 & 收入
+    "首层小程序打开数", "点击转化率", "单uv成本",
+    "裂变层回流数", "裂变0层回流数", "t0裂变系数",
+    "裂变1层回流数", "t1裂变系数",
+    "总回流人数", "总收入",
+]
+
+
+def _merge_single_day(biz: str) -> Optional[pd.DataFrame]:
+    """
+    合并单日创意数据与广告状态数据(内部函数)。
+
+    - 以创意表为主表(left join)
+    - 统一 ad_id 为 Int64(创意表是 float,状态表是 int)
+    - 状态表的 optimization_goal 重命名为 optimization_goal_raw
+    - 按 _MERGED_COLUMNS 顺序输出,保存到 outputs/merged/merged_{biz}.csv
+
+    Returns:
+        合并后的 DataFrame,或 None(文件不存在时)
+    """
+    creative_csv = _RAW_DIR / f"creative_{biz}.csv"
+    status_csv = _AD_STATUS_DIR / f"ad_status_{biz}.csv"
+
+    if not creative_csv.exists():
+        logger.warning("创意数据不存在,跳过合并: %s", creative_csv)
+        return None
+    if not status_csv.exists():
+        logger.warning("广告状态不存在,跳过合并: %s", status_csv)
+        return None
+
+    df_creative = pd.read_csv(creative_csv)
+    df_status = pd.read_csv(status_csv)
+
+    # 统一 ad_id 类型为 Int64(可空整数,避免 float 精度问题)
+    df_creative["ad_id"] = pd.to_numeric(df_creative["ad_id"], errors="coerce").astype("Int64")
+    df_status["ad_id"] = pd.to_numeric(df_status["ad_id"], errors="coerce").astype("Int64")
+
+    # 状态表只保留需要引入的列,重命名 optimization_goal 避免与创意表冲突
+    status_cols = ["ad_id", "ad_status", "bid_amount", "day_amount", "optimization_goal", "targeting"]
+    df_status = df_status[[c for c in status_cols if c in df_status.columns]].copy()
+    if "optimization_goal" in df_status.columns:
+        df_status = df_status.rename(columns={"optimization_goal": "optimization_goal_raw"})
+
+    # Left join(保留所有广告,包括 SUSPEND 状态)
+    df_merged = df_creative.merge(df_status, on="ad_id", how="left")
+    logger.info("合并后总行数: %d", len(df_merged))
+
+    # 按指定列顺序输出(只保留存在的列,保持顺序)
+    final_cols = [c for c in _MERGED_COLUMNS if c in df_merged.columns]
+    df_merged = df_merged[final_cols]
+
+    # 保存
+    _MERGED_DIR.mkdir(parents=True, exist_ok=True)
+    out_path = _MERGED_DIR / f"merged_{biz}.csv"
+    df_merged.to_csv(out_path, index=False, encoding="utf-8-sig")
+    logger.info("合并完成: %s (%d 行, %d 列)", out_path.name, len(df_merged), len(df_merged.columns))
+
+    return df_merged
+
+
+@tool(description="合并创意数据与广告状态(批量)")
+async def merge_creative_data(
+    ctx: ToolContext,
+    days: int = 30,
+    force: bool = False,
+) -> ToolResult:
+    """
+    合并创意级别数据与广告状态数据。
+
+    职责:
+      - 读取 outputs/raw/creative_{date}.csv
+      - 读取 outputs/ad_status/ad_status_{date}.csv
+      - Left join on ad_id,输出到 outputs/merged/merged_{date}.csv
+      - 支持批量合并(最近 N 天)
+      - 幂等操作:已存在的合并文件默认跳过(force=True 强制重新合并)
+
+    Args:
+        days: 合并最近 N 天的数据(默认 30 天)
+        force: 是否强制重新合并已存在的文件(默认 False)
+
+    Returns:
+        合并结果摘要
+    """
+    try:
+        # 确定日期范围
+        end_dt = datetime.now() - timedelta(days=1)
+        dates_to_merge = []
+        for i in range(days):
+            date_dt = end_dt - timedelta(days=i)
+            biz = date_dt.strftime("%Y%m%d")
+            dates_to_merge.append(biz)
+
+        success_count = 0
+        skip_count = 0
+        fail_count = 0
+
+        for biz in dates_to_merge:
+            merged_csv = _MERGED_DIR / f"merged_{biz}.csv"
+
+            # 检查是否已存在
+            if merged_csv.exists() and not force:
+                skip_count += 1
+                continue
+
+            # 执行合并
+            df = _merge_single_day(biz)
+            if df is not None:
+                success_count += 1
+            else:
+                fail_count += 1
+
+        # 汇总
+        lines = [
+            f"数据合并完成(最近 {days} 天)",
+            f"成功: {success_count} 天",
+            f"跳过: {skip_count} 天(已存在)",
+            f"失败: {fail_count} 天(源文件缺失)",
+            "",
+            f"输出目录: {_MERGED_DIR}",
+            f"列数: 38 列",
+        ]
+
+        return ToolResult(
+            title=f"合并完成({success_count}/{days})",
+            output="\n".join(lines),
+            metadata={
+                "success": success_count,
+                "skip": skip_count,
+                "fail": fail_count,
+                "output_dir": str(_MERGED_DIR),
+            }
+        )
+
+    except Exception as e:
+        logger.error("merge_creative_data 失败: %s", e, exc_info=True)
+        return ToolResult(title="merge_creative_data 失败", output=str(e))

+ 208 - 0
examples/auto_put_ad_mini/tools/report_generator.py

@@ -0,0 +1,208 @@
+"""
+报告生成工具 — auto_put_ad_mini V3
+
+职责:
+  - 读取决策引擎输出
+  - 生成 CSV(程序用)+ XLSX(人工查阅,带条件格式)
+  - 输出到 outputs/reports/
+"""
+
+import logging
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Optional
+
+import pandas as pd
+
+from agent.tools import tool
+from agent.tools.models import ToolContext, ToolResult
+
+logger = logging.getLogger(__name__)
+
+_MINI_DIR = Path(__file__).resolve().parent.parent
+_REPORTS_DIR = _MINI_DIR / "outputs" / "reports"
+
+# 最终输出列顺序
+OUTPUT_COLUMNS = [
+    "ad_id", "account_id", "ad_name", "audience_tier",
+    "create_time", "ad_age_days", "configured_status", "bid_amount",
+    # 昨日表现
+    "yesterday_cost", "yesterday_revenue", "yesterday_roi",
+    # 7日汇总
+    "cost_7d_total", "cost_7d_avg", "revenue_7d_total",
+    # f_7日动态ROI 组成
+    "T0裂变系数_latest", "arpu_latest", "a_latest",
+    "b_7d_mean", "T0裂变系数_7d_mean", "e_factor",
+    "f_7日动态ROI",
+    # 30日上下文
+    "cost_30d_total", "cost_30d_avg",
+    "stable_spend_days_30d", "creative_count",
+    # 决策
+    "action", "dimension", "reason",
+    # 参考
+    "f_7日动态ROI_mean_all",
+]
+
+# 中文列名映射
+CN_COLUMNS = {
+    "ad_id": "广告ID",
+    "account_id": "账户ID",
+    "ad_name": "广告名称",
+    "audience_tier": "人群包",
+    "create_time": "创建时间",
+    "ad_age_days": "投放天数",
+    "configured_status": "广告状态",
+    "bid_amount": "出价(元)",
+    "yesterday_cost": "昨日消耗(元)",
+    "yesterday_revenue": "昨日收入(元)",
+    "yesterday_roi": "昨日ROI",
+    "cost_7d_total": "7日总消耗(元)",
+    "cost_7d_avg": "7日日均消耗(元)",
+    "revenue_7d_total": "7日总收入(元)",
+    "T0裂变系数_latest": "T0裂变系数(最新)",
+    "arpu_latest": "ARPU(最新)",
+    "a_latest": "a值(最新)",
+    "b_7d_mean": "b值(7日均值)",
+    "T0裂变系数_7d_mean": "T0裂变系数(7日均值)",
+    "e_factor": "e因子",
+    "f_7日动态ROI": "f_7日动态ROI",
+    "cost_30d_total": "30日总消耗(元)",
+    "cost_30d_avg": "30日日均消耗(元)",
+    "stable_spend_days_30d": "稳定消耗天数(30日)",
+    "creative_count": "创意数",
+    "action": "决策动作",
+    "dimension": "命中维度",
+    "reason": "决策理由",
+    "f_7日动态ROI_mean_all": "全体动态ROI均值",
+}
+
+
+def _write_xlsx_with_format(df: pd.DataFrame, path: Path) -> None:
+    """生成带条件格式的 XLSX 文件。"""
+    try:
+        import openpyxl
+        from openpyxl.styles import Font, PatternFill, Alignment
+        from openpyxl.utils import get_column_letter
+    except ImportError:
+        logger.warning("openpyxl 未安装,跳过 XLSX 生成")
+        return
+
+    # 中文列名
+    df_cn = df.rename(columns=CN_COLUMNS)
+    df_cn.to_excel(path, index=False, engine="openpyxl")
+
+    wb = openpyxl.load_workbook(path)
+    ws = wb.active
+
+    # 表头样式
+    header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
+    header_font = Font(color="FFFFFF", bold=True, size=10)
+
+    for cell in ws[1]:
+        cell.fill = header_fill
+        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")
+
+    action_col_idx = None
+    for idx, cell in enumerate(ws[1], 1):
+        if cell.value == "决策动作":
+            action_col_idx = idx
+            break
+
+    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":
+                for cell in row:
+                    cell.fill = red_fill
+                    cell.font = red_font
+
+    # 自动列宽
+    for col_idx in range(1, ws.max_column + 1):
+        col_letter = get_column_letter(col_idx)
+        max_len = max(
+            len(str(ws.cell(row=r, column=col_idx).value or ""))
+            for r in range(1, min(ws.max_row + 1, 50))
+        )
+        ws.column_dimensions[col_letter].width = min(max_len + 4, 30)
+
+    wb.save(path)
+    logger.info("XLSX 已生成: %s", path)
+
+
+@tool(description="生成决策报告(CSV + XLSX 带条件格式)")
+async def generate_report(
+    ctx: ToolContext,
+    decision_csv: str = "",
+    end_date: str = "yesterday",
+) -> ToolResult:
+    """
+    生成决策报告。
+
+    Args:
+        decision_csv: 决策结果 CSV 路径(analyze_ads 输出)
+        end_date: 结束日期
+
+    Returns:
+        ToolResult,包含报告路径
+    """
+    try:
+        if not decision_csv:
+            decision_csv = str(_MINI_DIR / "outputs" / "decision_temp.csv")
+
+        df = pd.read_csv(decision_csv)
+
+        if df.empty:
+            return ToolResult(title="报告生成", output="决策数据为空")
+
+        if end_date == "yesterday":
+            end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+
+        _REPORTS_DIR.mkdir(parents=True, exist_ok=True)
+
+        # 选择输出列(存在的列)
+        cols = [c for c in OUTPUT_COLUMNS if c in df.columns]
+        df_out = df[cols].copy()
+
+        # 排序:关停在前,按消耗降序
+        sort_cols = []
+        if "action" in df_out.columns:
+            df_out["_sort_action"] = (df_out["action"] == "pause").astype(int) * -1
+            sort_cols.append("_sort_action")
+        if "cost_7d_total" in df_out.columns:
+            sort_cols.append("cost_7d_total")
+
+        if sort_cols:
+            df_out = df_out.sort_values(sort_cols, ascending=[True, False])
+            if "_sort_action" in df_out.columns:
+                df_out.drop(columns=["_sort_action"], inplace=True)
+
+        # CSV
+        csv_path = _REPORTS_DIR / f"decision_{end_date}.csv"
+        df_out.to_csv(csv_path, index=False, encoding="utf-8-sig")
+        logger.info("CSV 已生成: %s", csv_path)
+
+        # XLSX
+        xlsx_path = _REPORTS_DIR / f"decision_{end_date}.xlsx"
+        _write_xlsx_with_format(df_out, xlsx_path)
+
+        pause_count = (df_out["action"] == "pause").sum() if "action" in df_out.columns else 0
+        total_count = len(df_out)
+
+        return ToolResult(
+            title=f"报告已生成({total_count}个广告)",
+            output=f"报告已生成:\n  CSV: {csv_path}\n  XLSX: {xlsx_path}\n\n共 {total_count} 个广告,其中 {pause_count} 个建议关停",
+            metadata={
+                "csv_path": str(csv_path),
+                "xlsx_path": str(xlsx_path),
+                "total_ads": total_count,
+                "pause_ads": pause_count,
+            },
+        )
+
+    except Exception as e:
+        logger.error("报告生成失败: %s", e, exc_info=True)
+        return ToolResult(title="报告生成失败", output=str(e))

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

@@ -0,0 +1,457 @@
+"""
+ROI 计算工具 — auto_put_ad_mini V3
+
+职责:
+  1. 创意级数据 → 广告级聚合(GROUP BY ad_id + date, SUM)
+  2. 计算 动态ROI(7 天滚动窗口)
+  3. 计算昨日 ROI(简单 ROI)
+  4. 输出广告级指标表(供决策引擎使用)
+
+核心公式:
+  T0裂变系数    = SUM(fission0_count) / SUM(open_count)
+  arpu          = SUM(total_revenue) / SUM(total_return_count)
+  当日裂变收益率 = SUM(fission0_count) * arpu / SUM(cost)
+  当日回流倍数   = SUM(total_return_count) / SUM(open_count)
+  回流倍数_7日均值    = mean(当日回流倍数) over 7天
+  T0裂变系数_7日均值  = mean(T0裂变系数) over 7天
+  裂变效率稳定因子    = 回流倍数_7日均值 / T0裂变系数_7日均值
+  动态ROI       = 当日裂变收益率(当天) * 裂变效率稳定因子
+  动态ROI_7日均值 = mean(动态ROI) over 7天  ← 决策参考值
+
+前置条件:
+  - 日消耗 < 100 元的天数不参与 ROI 计算
+  - 广告创建 < 7 天的不参与决策
+"""
+
+import logging
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Dict, List, Optional
+
+import pandas as pd
+import numpy as np
+
+from agent.tools import tool
+from agent.tools.models import ToolContext, ToolResult
+
+logger = logging.getLogger(__name__)
+
+_MINI_DIR = Path(__file__).resolve().parent.parent
+_RAW_DIR = _MINI_DIR / "outputs" / "raw"
+_AD_STATUS_DIR = _MINI_DIR / "outputs" / "ad_status"
+_MERGED_DIR = _MINI_DIR / "outputs" / "merged"
+
+
+# ===== 创意 → 广告聚合 =====
+
+def _aggregate_creative_to_ad(df: pd.DataFrame) -> pd.DataFrame:
+    """
+    创意级数据聚合到广告级(按 ad_id + date)。
+
+    输入:创意级 DataFrame(多日数据,包含 creative_id)
+    输出:广告级 DataFrame(每个 ad_id 每天一行)
+
+    聚合规则:
+      - 数值字段:SUM(cost, revenue, open_count, fission0_count, total_return_count, view_count, etc.)
+      - 广告属性:取 FIRST(ad_name, account_id, create_time, bid_amount, configured_status)
+      - 创意计数:COUNT(DISTINCT creative_id)
+    """
+    if df.empty:
+        return pd.DataFrame()
+
+    # 添加 date 列(从文件名或 create_time 推断,这里假设已有 bizdate 列)
+    # 如果没有,需要从外部传入或从文件名解析
+    # 将 bizdate 映射为 date 列(bizdate 格式: 20260412)
+    if "bizdate" in df.columns:
+        df = df.copy()
+        df["date"] = df["bizdate"].astype(str)
+    elif "date" not in df.columns:
+        logger.warning("DataFrame 缺少 bizdate/date 列,无法按日聚合")
+        return pd.DataFrame()
+
+    # 列名映射:CSV 实际列名 → 内部标准名
+    COLUMN_RENAME = {
+        "首层小程序打开数": "open_count",
+        "裂变0层回流数": "fission0_count",
+        "裂变层回流数": "fission_count",
+        "裂变1层回流数": "fission1_count",
+        "总回流人数": "total_return_count",
+        "总收入": "total_revenue",
+        "ad_status": "configured_status",
+    }
+    # 只重命名存在的列
+    rename_map = {k: v for k, v in COLUMN_RENAME.items() if k in df.columns}
+    df = df.rename(columns=rename_map)
+
+    agg_dict = {
+        # 广告属性(取第一个值)
+        "account_id": "first",
+        "ad_name": "first",
+        "create_time": "first",
+        "configured_status": "first",
+        "bid_amount": "first",
+        "广告优化目标": "first",
+        "人群包人数": "first",
+        # 数值指标(SUM — 聚合后再计算派生比值,不能直接平均)
+        "cost": "sum",
+        "view_count": "sum",
+        "key_page_view_count": "sum",
+        "key_page_uv": "sum",
+        "valid_click_count": "sum",
+        "conversions_count": "sum",
+        "open_count": "sum",       # 首层小程序打开数
+        "fission0_count": "sum",   # 裂变0层回流数
+        "fission_count": "sum",    # 裂变层回流数(总)
+        "fission1_count": "sum",   # 裂变1层回流数
+        "total_return_count": "sum",  # 总回流人数
+        "total_revenue": "sum",       # 总收入
+        # 创意计数
+        "creative_id": "nunique",
+    }
+
+    # 过滤掉不存在的列
+    agg_dict = {k: v for k, v in agg_dict.items() if k in df.columns}
+
+    grouped = df.groupby(["ad_id", "date"], as_index=False).agg(agg_dict)
+    grouped.rename(columns={"creative_id": "creative_count"}, inplace=True)
+
+    return grouped
+
+
+# ===== 动态ROI 计算 =====
+
+def _calculate_f7_dynamic_roi(
+    ad_df: pd.DataFrame,
+    min_daily_cost: float = 100.0
+) -> pd.DataFrame:
+    """
+    计算 动态ROI(每个广告每天一个值)。
+
+    输入:广告级 DataFrame(ad_id + date + 聚合指标)
+    输出:添加以下列的 DataFrame
+      - T0裂变系数, arpu(每天基础指标)
+      - 当日裂变收益率, 当日回流倍数(每天派生指标)
+      - T0裂变系数_7日均值, 回流倍数_7日均值(7 天滚动均值)
+      - 裂变效率稳定因子(= 回流倍数_7日均值 / T0裂变系数_7日均值)
+      - 动态ROI(= 当日裂变收益率 × 裂变效率稳定因子,单日值)
+      - 动态ROI_7日均值(决策参考值)
+
+    前置条件:
+      - 日消耗 < min_daily_cost 的天数:T0裂变系数/arpu/a/b 设为 NaN,不参与 7 日均值计算
+    """
+    if ad_df.empty:
+        return ad_df
+
+    # 确保按 ad_id + date 排序
+    ad_df = ad_df.sort_values(["ad_id", "date"]).reset_index(drop=True)
+
+    # 计算每天的基础指标
+    ad_df["T0裂变系数"] = np.where(
+        (ad_df["open_count"] > 0) & (ad_df["cost"] >= min_daily_cost),
+        ad_df["fission0_count"] / ad_df["open_count"],
+        np.nan
+    )
+
+    ad_df["arpu"] = np.where(
+        (ad_df["total_return_count"] > 0) & (ad_df["cost"] >= min_daily_cost),
+        ad_df["total_revenue"] / ad_df["total_return_count"],
+        np.nan
+    )
+
+    # 当日裂变收益率 = T0裂变数 × arpu / cost
+    ad_df["当日裂变收益率"] = np.where(
+        (ad_df["cost"] > 0) & (ad_df["cost"] >= min_daily_cost),
+        ad_df["fission0_count"] * ad_df["arpu"] / ad_df["cost"],
+        np.nan
+    )
+
+    # 当日回流倍数 = 总回流人数 / 首层打开数
+    ad_df["当日回流倍数"] = np.where(
+        (ad_df["open_count"] > 0) & (ad_df["cost"] >= min_daily_cost),
+        ad_df["total_return_count"] / ad_df["open_count"],
+        np.nan
+    )
+
+    # 7 天滚动均值(按 ad_id 分组,严格要求 7 天完整数据才计算)
+    ad_df["T0裂变系数_7日均值"] = (
+        ad_df.groupby("ad_id")["T0裂变系数"]
+        .transform(lambda x: x.rolling(window=7, min_periods=7).mean())
+    )
+
+    ad_df["回流倍数_7日均值"] = (
+        ad_df.groupby("ad_id")["当日回流倍数"]
+        .transform(lambda x: x.rolling(window=7, min_periods=7).mean())
+    )
+
+    # 裂变效率稳定因子 = 回流倍数_7日均值 / T0裂变系数_7日均值
+    ad_df["裂变效率稳定因子"] = np.where(
+        ad_df["T0裂变系数_7日均值"] > 0,
+        ad_df["回流倍数_7日均值"] / ad_df["T0裂变系数_7日均值"],
+        np.nan
+    )
+
+    # 动态ROI = 当日裂变收益率 × 裂变效率稳定因子
+    ad_df["动态ROI"] = ad_df["当日裂变收益率"] * ad_df["裂变效率稳定因子"]
+
+    # 动态ROI_7日均值(决策参考值)
+    ad_df["动态ROI_7日均值"] = (
+        ad_df.groupby("ad_id")["动态ROI"]
+        .transform(lambda x: x.rolling(window=7, min_periods=7).mean())
+    )
+
+    return ad_df
+
+
+# ===== 昨日 ROI 计算 =====
+
+def _calculate_yesterday_roi(ad_df: pd.DataFrame, yesterday: str) -> pd.DataFrame:
+    """
+    计算昨日 ROI(简单 ROI = revenue / cost)。
+
+    输入:广告级 DataFrame
+    输出:添加 yesterday_roi 列
+    """
+    if ad_df.empty:
+        return ad_df
+
+    # 筛选昨日数据
+    yesterday_df = ad_df[ad_df["date"] == yesterday].copy()
+
+    yesterday_df["yesterday_roi"] = np.where(
+        yesterday_df["cost"] > 0,
+        yesterday_df["total_revenue"] / yesterday_df["cost"],
+        np.nan
+    )
+
+    # 合并回原 DataFrame(只保留昨日的 ROI)
+    ad_df = ad_df.merge(
+        yesterday_df[["ad_id", "yesterday_roi"]],
+        on="ad_id",
+        how="left"
+    )
+
+    return ad_df
+
+
+# ===== 7 日汇总指标 =====
+
+def _calculate_7d_summary(ad_df: pd.DataFrame, end_date: str) -> pd.DataFrame:
+    """
+    计算最近 7 天汇总指标(供决策引擎使用)。
+
+    输出列:
+      - cost_7d_total, cost_7d_avg
+      - revenue_7d_total
+      - 动态ROI_latest(单日值,最新一天)
+      - 动态ROI_7日均值_latest(决策参考值,最新一天的7日均值)
+    """
+    if ad_df.empty:
+        return pd.DataFrame()
+
+    end_dt = datetime.strptime(end_date, "%Y%m%d")
+    start_dt = end_dt - timedelta(days=6)
+    start_date = start_dt.strftime("%Y%m%d")
+
+    # 筛选 7 天数据
+    df_7d = ad_df[
+        (ad_df["date"] >= start_date) & (ad_df["date"] <= end_date)
+    ].copy()
+
+    # 按 ad_id 聚合
+    summary = df_7d.groupby("ad_id", as_index=False).agg({
+        "cost": "sum",
+        "total_revenue": "sum",
+    })
+
+    summary.rename(columns={
+        "cost": "cost_7d_total",
+        "total_revenue": "revenue_7d_total",
+    }, inplace=True)
+
+    summary["cost_7d_avg"] = summary["cost_7d_total"] / 7
+
+    # 获取最新一天的 动态ROI(单日值)和 动态ROI_7日均值(决策参考值)
+    latest_df = ad_df[ad_df["date"] == end_date][["ad_id", "动态ROI", "动态ROI_7日均值"]].copy()
+    latest_df.rename(columns={
+        "动态ROI": "动态ROI_latest",
+        "动态ROI_7日均值": "动态ROI_7日均值_latest"
+    }, inplace=True)
+
+    summary = summary.merge(latest_df, on="ad_id", how="left")
+
+    return summary
+
+
+# ===== 30 日汇总指标 =====
+
+def _calculate_30d_summary(ad_df: pd.DataFrame) -> pd.DataFrame:
+    """
+    计算 30 天汇总指标。
+
+    输出列:
+      - cost_30d_total, cost_30d_avg
+      - stable_spend_days_30d(消耗 > 100 元的天数)
+    """
+    if ad_df.empty:
+        return pd.DataFrame()
+
+    summary = ad_df.groupby("ad_id", as_index=False).agg({
+        "cost": ["sum", "mean"],
+    })
+
+    summary.columns = ["ad_id", "cost_30d_total", "cost_30d_avg"]
+
+    # 稳定消耗天数(cost > 100)
+    stable_days = (
+        ad_df[ad_df["cost"] > 100]
+        .groupby("ad_id", as_index=False)
+        .size()
+        .rename(columns={"size": "stable_spend_days_30d"})
+    )
+
+    summary = summary.merge(stable_days, on="ad_id", how="left")
+    summary["stable_spend_days_30d"] = summary["stable_spend_days_30d"].fillna(0).astype(int)
+
+    return summary
+
+
+# ===== 工具:计算 ROI 指标 =====
+
+@tool(description="计算 动态ROI + 昨日 ROI + 7日/30日汇总指标")
+async def calculate_roi_metrics(
+    ctx: ToolContext,
+    end_date: str = "yesterday",
+    min_daily_cost: float = 100.0
+) -> ToolResult:
+    """
+    计算广告级 ROI 指标(V3)。
+
+    工作流:
+      1. 加载 30 天创意级 CSV
+      2. 聚合到广告级(ad_id + date)
+      3. 计算 动态ROI
+      4. 计算昨日 ROI
+      5. 计算 7 日/30 日汇总
+      6. 输出广告级指标表
+
+    Args:
+        end_date: 结束日期(YYYYMMDD 或 "yesterday")
+        min_daily_cost: 日消耗阈值(元),低于此值的天数不参与 ROI 计算
+
+    Returns:
+        ToolResult 包含广告级指标 DataFrame
+    """
+    try:
+        # 解析日期
+        if end_date == "yesterday":
+            end_dt = datetime.now() - timedelta(days=1)
+        else:
+            end_dt = datetime.strptime(end_date.replace("-", ""), "%Y%m%d")
+
+        end_date_str = end_dt.strftime("%Y%m%d")
+        start_dt = end_dt - timedelta(days=29)
+
+        # 加载 30 天 merged 数据(已包含创意+广告状态)
+        merged_dfs = []
+        for i in range(30):
+            date = (start_dt + timedelta(days=i)).strftime("%Y%m%d")
+            csv_path = _MERGED_DIR / f"merged_{date}.csv"
+
+            if not csv_path.exists():
+                logger.warning("merged 数据缺失: %s", date)
+                continue
+
+            df = pd.read_csv(csv_path, dtype={"ad_id": str, "account_id": str})
+            # bizdate 列已存在,无需手动添加 date
+            merged_dfs.append(df)
+
+        if not merged_dfs:
+            return ToolResult(
+                title="ROI 计算失败",
+                output=f"未找到任何 merged 数据 CSV({_MERGED_DIR})"
+            )
+
+        creative_df = pd.concat(merged_dfs, ignore_index=True)
+        logger.info("加载 merged 数据: %d 行(%d 天)", len(creative_df), len(merged_dfs))
+
+        # Step 1: 聚合到广告级
+        ad_df = _aggregate_creative_to_ad(creative_df)
+        logger.info("聚合到广告级: %d 行", len(ad_df))
+
+        # Step 2: 计算 动态ROI
+        ad_df = _calculate_f7_dynamic_roi(ad_df, min_daily_cost)
+
+        # Step 3: 计算昨日 ROI
+        ad_df = _calculate_yesterday_roi(ad_df, end_date_str)
+
+        # Step 4: 计算 7 日汇总
+        summary_7d = _calculate_7d_summary(ad_df, end_date_str)
+
+        # Step 5: 计算 30 日汇总
+        summary_30d = _calculate_30d_summary(ad_df)
+
+        # Step 6: 合并所有指标(取最新一天的广告属性)
+        latest_ad = ad_df[ad_df["date"] == end_date_str][[
+            "ad_id", "account_id", "ad_name", "create_time",
+            "configured_status", "bid_amount", "creative_count"
+        ]].copy()
+
+        result_df = latest_ad.merge(summary_7d, on="ad_id", how="left")
+        result_df = result_df.merge(summary_30d, on="ad_id", how="left")
+
+        # 计算广告年龄(天)
+        result_df["ad_age_days"] = (
+            (end_dt - pd.to_datetime(result_df["create_time"])).dt.days
+        )
+
+        # 重命名输出列,统一供决策引擎使用
+        # 动态ROI_latest → 动态ROI(单日值,反映当日效率)
+        # 动态ROI_7日均值_latest → 动态ROI_7日均值(决策参考值)
+        if "动态ROI_latest" in result_df.columns:
+            result_df.rename(columns={"动态ROI_latest": "动态ROI"}, inplace=True)
+        if "动态ROI_7日均值_latest" in result_df.columns:
+            result_df.rename(columns={"动态ROI_7日均值_latest": "动态ROI_7日均值"}, inplace=True)
+
+        # 计算全体 动态ROI_7日均值 的均值(供决策引擎做相对比较)
+        f7_7d_mean_all = result_df["动态ROI_7日均值"].mean() if "动态ROI_7日均值" in result_df.columns else float("nan")
+
+        # 保存指标 CSV(供 analyze_ads 读取)
+        metrics_dir = _MINI_DIR / "outputs"
+        metrics_dir.mkdir(parents=True, exist_ok=True)
+        metrics_csv = metrics_dir / f"metrics_{end_date_str}.csv"
+        result_df.to_csv(metrics_csv, index=False, encoding="utf-8-sig")
+        logger.info("指标 CSV 已保存: %s", metrics_csv)
+
+        output_lines = [
+            f"ROI 计算完成(截至 {end_date_str})",
+            f"广告总数: {len(result_df)}",
+            f"动态ROI_7日均值 全体均值: {f7_7d_mean_all:.4f}(决策参考)",
+            "",
+            f"指标 CSV: {metrics_csv}",
+            "",
+            "指标列:",
+            "  - 动态ROI(单日值,反映当日效率)",
+            "  - 动态ROI_7日均值(决策参考值)",
+            "  - yesterday_roi(昨日简单 ROI)",
+            "  - cost_7d_total, cost_7d_avg, revenue_7d_total",
+            "  - cost_30d_total, cost_30d_avg, stable_spend_days_30d",
+            "  - ad_age_days, creative_count",
+        ]
+
+        return ToolResult(
+            title=f"ROI 计算完成({len(result_df)} 个广告)",
+            output="\n".join(output_lines),
+            metadata={
+                "metrics_csv": str(metrics_csv),
+                "动态ROI_7日均值_mean_all": f7_7d_mean_all,
+                "end_date": end_date_str,
+                "min_daily_cost": min_daily_cost,
+            }
+        )
+
+    except Exception as e:
+        logger.error("ROI 计算失败: %s", e, exc_info=True)
+        return ToolResult(
+            title="ROI 计算失败",
+            output=f"错误: {e}"
+        )

+ 203 - 0
examples/auto_put_ad_mini/verify_decision.py

@@ -0,0 +1,203 @@
+"""
+离线验证脚本 — auto_put_ad_mini V3
+
+用本地 30 天 CSV 数据跑完整 V3 流程:
+  1. 加载创意级数据(outputs/raw/creative_*.csv)
+  2. 聚合创意→广告
+  3. 计算 动态ROI
+  4. 运行三维度决策引擎
+  5. 生成决策报告(CSV + XLSX)
+
+用法:
+    cd /Users/liulidong/project/agent/Agent
+    .venv/bin/python3 examples/auto_put_ad_mini/verify_decision.py
+"""
+
+import sys
+from datetime import datetime, timedelta
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+import pandas as pd
+
+from examples.auto_put_ad_mini.config import (
+    MIN_DAILY_COST,
+    MIN_AD_AGE_DAYS,
+    ROI_LOW_FACTOR,
+    NO_SPEND_THRESHOLD,
+    STABLE_SPEND_THRESHOLD,
+)
+from examples.auto_put_ad_mini.tools.roi_calculator import (
+    _aggregate_creative_to_ad,
+    _calculate_f7_dynamic_roi,
+    _calculate_yesterday_roi,
+    _calculate_7d_summary,
+    _calculate_30d_summary,
+)
+from examples.auto_put_ad_mini.tools.ad_decision import (
+    _extract_audience_tier,
+    _calculate_ad_age_days,
+    _run_decision_engine,
+    _detect_decay_signals,
+)
+from examples.auto_put_ad_mini.tools.report_generator import (
+    _write_xlsx_with_format,
+    OUTPUT_COLUMNS,
+)
+
+base_dir = Path(__file__).parent
+raw_dir = base_dir / "outputs" / "raw"
+ad_status_dir = base_dir / "outputs" / "ad_status"
+reports_dir = base_dir / "outputs" / "reports"
+
+print("=" * 60)
+print("V3 离线验证 — 完整流程测试")
+print("=" * 60)
+
+# ① 加载 30 天创意级数据
+print("\n① 加载创意级数据...")
+creative_files = sorted(raw_dir.glob("creative_*.csv"))
+
+if not creative_files:
+    print(f"错误: {raw_dir} 下没有找到 creative_*.csv 文件")
+    print("请先运行: .venv/bin/python3 examples/auto_put_ad_mini/fetch_data.py --days 30")
+    sys.exit(1)
+
+print(f"   找到 {len(creative_files)} 个文件")
+
+dfs = []
+for f in creative_files:
+    df = pd.read_csv(f)
+    # 从文件名提取日期
+    date_str = f.stem.replace("creative_", "")
+    df["date"] = date_str
+    dfs.append(df)
+    print(f"   {f.name}: {len(df)} 行")
+
+df_creative = pd.concat(dfs, ignore_index=True)
+print(f"   合并后: {len(df_creative)} 行")
+
+# ② 聚合创意→广告
+print("\n② 聚合创意→广告...")
+df_ad = _aggregate_creative_to_ad(df_creative)
+print(f"   广告级数据: {len(df_ad)} 行(ad_id × date)")
+
+# ③ 计算 动态ROI
+print("\n③ 计算 动态ROI...")
+df_ad = _calculate_f7_dynamic_roi(df_ad, min_daily_cost=MIN_DAILY_COST)
+print(f"   已添加: T0裂变系数, arpu, 当日裂变收益率, 当日回流倍数, 裂变效率稳定因子, 动态ROI, 动态ROI_7日均值")
+
+# ④ 计算昨日 ROI(使用数据中最新的日期,而非系统昨日)
+yesterday = df_ad["date"].max()
+print(f"\n④ 计算昨日 ROI ({yesterday})...")
+df_ad = _calculate_yesterday_roi(df_ad, yesterday)
+
+# ⑤ 计算 7 日汇总
+print("\n⑤ 计算 7 日汇总...")
+df_7d = _calculate_7d_summary(df_ad, yesterday)
+print(f"   7 日汇总: {len(df_7d)} 个广告")
+
+# ⑥ 计算 30 日汇总
+print("\n⑥ 计算 30 日汇总...")
+df_30d = _calculate_30d_summary(df_ad)
+
+# ⑦ 检测衰退信号(出价变化 + 创意变化)
+print("\n⑦ 检测衰退信号...")
+ad_ids = df_ad["ad_id"].unique().tolist()
+df_decay = _detect_decay_signals(ad_ids, raw_dir, ad_status_dir, yesterday)
+print(f"   衰退信号检测: {len(df_decay)} 个广告")
+
+# ⑧ 合并所有指标
+print("\n⑧ 合并所有指标...")
+
+# 提取最新一天的广告属性(ad_name, create_time 等)
+# 注意:verify 脚本加载的是 raw creative CSV,不含 configured_status/bid_amount
+_attr_cols = ["ad_id", "ad_name", "account_id", "create_time", "configured_status", "bid_amount"]
+_attr_cols = [c for c in _attr_cols if c in df_ad.columns]
+latest_ad_attrs = df_ad[df_ad["date"] == yesterday][_attr_cols].drop_duplicates(subset=["ad_id"])
+
+df_final = latest_ad_attrs.merge(df_7d, on="ad_id", how="left")
+df_final = df_final.merge(df_30d, on="ad_id", how="left")
+df_final = df_final.merge(df_decay, on="ad_id", how="left")
+
+# 重命名 动态ROI_latest → 动态ROI,动态ROI_7日均值_latest → 动态ROI_7日均值
+if "动态ROI_latest" in df_final.columns:
+    df_final.rename(columns={"动态ROI_latest": "动态ROI"}, inplace=True)
+if "动态ROI_7日均值_latest" in df_final.columns:
+    df_final.rename(columns={"动态ROI_7日均值_latest": "动态ROI_7日均值"}, inplace=True)
+
+# 填充缺失值
+df_final["bid_increased_7d"] = df_final["bid_increased_7d"].fillna(False)
+df_final["creative_changed_7d"] = df_final["creative_changed_7d"].fillna(False)
+
+# 添加人群包和广告年龄
+df_final["audience_tier"] = df_final["ad_name"].apply(_extract_audience_tier)
+df_final["ad_age_days"] = df_final["create_time"].apply(_calculate_ad_age_days)
+
+print(f"   最终数据: {len(df_final)} 个广告, {len(df_final.columns)} 列")
+
+# ⑨ 运行决策引擎
+print("\n⑨ 运行三维度决策引擎...")
+f_roi_mean_all = df_final["动态ROI_7日均值"].mean()
+print(f"   全体 动态ROI_7日均值 均值: {f_roi_mean_all:.4f}")
+
+context = {
+    "动态ROI_mean_all": f_roi_mean_all,
+    "min_ad_age_days": MIN_AD_AGE_DAYS,
+    "min_daily_cost": MIN_DAILY_COST,
+    "roi_low_factor": ROI_LOW_FACTOR,
+    "no_spend_threshold": NO_SPEND_THRESHOLD,
+    "stable_spend_threshold": STABLE_SPEND_THRESHOLD,
+}
+
+df_decision = _run_decision_engine(df_final, context)
+print(f"   决策完成: {len(df_decision)} 个广告")
+
+# ⑩ 统计
+print("\n⑩ 决策统计:")
+action_counts = df_decision["action"].value_counts()
+for action, count in action_counts.items():
+    print(f"   {action}: {count} 个")
+
+dimension_counts = df_decision["dimension"].value_counts()
+print("\n   命中维度:")
+for dim, count in dimension_counts.items():
+    print(f"   {dim}: {count} 个")
+
+# ⑪ 生成报告
+print("\n⑪ 生成报告...")
+reports_dir.mkdir(parents=True, exist_ok=True)
+
+# 选择输出列
+cols = [c for c in OUTPUT_COLUMNS if c in df_decision.columns]
+df_out = df_decision[cols].copy()
+
+# 排序:关停在前
+df_out["_sort"] = (df_out["action"] == "pause").astype(int) * -1
+df_out = df_out.sort_values(["_sort", "cost_7d_total"], ascending=[True, False])
+df_out.drop(columns=["_sort"], inplace=True)
+
+# CSV
+csv_path = reports_dir / f"decision_{yesterday}_verify.csv"
+df_out.to_csv(csv_path, index=False, encoding="utf-8-sig")
+print(f"   CSV: {csv_path}")
+
+# XLSX
+xlsx_path = reports_dir / f"decision_{yesterday}_verify.xlsx"
+_write_xlsx_with_format(df_out, xlsx_path)
+print(f"   XLSX: {xlsx_path}")
+
+print("\n=" * 60)
+print("验证完成!")
+print("=" * 60)
+
+# 显示前 10 个关停广告
+pause_ads = df_out[df_out["action"] == "pause"].head(10)
+if not pause_ads.empty:
+    print("\n前 10 个关停广告:")
+    for _, row in pause_ads.iterrows():
+        print(f"  {int(row['ad_id'])}: {row['ad_name'][:30]}")
+        print(f"    维度: {row['dimension']}, 理由: {row['reason']}")
+        print(f"    动态ROI: {row.get('动态ROI', 'N/A')}, 7日消耗: {row.get('cost_7d_avg', 0):.2f}元")
+        print()