Explorar o código

feat(auto_put_ad_mini): 双引擎架构 — 规则引擎 + 智能引擎并行

新增工具:
- get_ads_for_review:整理待评估广告(A/B/C三类),供LLM推理
- apply_decisions:接收LLM决策JSON,合并A类自动关停,保存llm_decisions_{date}.csv
- compare_decisions:对比两引擎决策差异,输出一致率和分类明细

重写:
- skills/roi_strategy.md:从交互层说明改为LLM推理框架
- prompts/system.prompt:双引擎8步工作流,支持单引擎模式和用户交互

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
刘立冬 hai 4 semanas
pai
achega
c0c8139295

+ 3 - 0
examples/auto_put_ad_mini/config.py

@@ -25,6 +25,9 @@ MAIN_CONFIG = RunConfig(
         "merge_creative_data",
         "merge_creative_data",
         "calculate_roi_metrics",
         "calculate_roi_metrics",
         "analyze_ads",
         "analyze_ads",
+        "get_ads_for_review",
+        "apply_decisions",
+        "compare_decisions",
         "generate_report",
         "generate_report",
     ],
     ],
     skills=["roi-strategy"],
     skills=["roi-strategy"],

+ 31 - 30
examples/auto_put_ad_mini/prompts/system.prompt

@@ -1,45 +1,46 @@
 ---
 ---
 name: auto_put_ad_mini
 name: auto_put_ad_mini
 ---
 ---
-
 $system$
 $system$
-你是广告调控助手 V3。你的工作是按顺序调用工具完成数据采集、ROI 计算、决策分析和报告生成。
+你是广告调控系统,支持两种引擎:规则引擎和智能引擎。
+
+## 双引擎工作流(完整分析)
+
+用户说"分析广告"时,按顺序执行:
+
+1. `fetch_creative_data(days=30)` — 确保数据最新(已有的日期自动跳过)
+
+2. `calculate_roi_metrics()` — 计算 f_7日动态ROI 和汇总指标
+
+3. `analyze_ads(metrics_csv=<上一步输出的csv路径>)` — **规则引擎**:三维度硬规则,输出 rule_decisions
+
+4. `get_ads_for_review(metrics_csv=<步骤2输出的csv路径>)` — **智能引擎第一步**:获取待评估广告数据(A/B/C 三类)
 
 
-## V3 工作流
+5. **你来推理**:阅读 get_ads_for_review 返回的 class_b 广告数据,
+   结合 roi-strategy skill 中的决策框架,对每个广告判断 pause/hold。
+   输出合法 JSON 决策列表(每条必须引用具体数值)。
 
 
-用户说"分析广告"或类似指令时,按以下顺序执行:
+6. `apply_decisions(decisions=<你输出的JSON>, metrics_csv=<步骤2输出的csv路径>)` — 保存智能引擎决策(自动合并 A 类)
 
 
-1. **数据采集** — 调用 `fetch_creative_data(days=30)` 拉取 30 天创意级数据
-   - 已有 CSV 的日期会自动跳过(增量拉取)
-   - 输出到 outputs/raw/ 和 outputs/ad_status/
+7. `compare_decisions()` — 对比两个引擎的差异
 
 
-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日汇总
+8. `generate_report()` — 生成最终报告
 
 
-3. **决策分析** — 调用 `analyze_ads()` 执行三维度决策
-   - 维度 1: ROI 过低(f_7日动态ROI < 全体均值 × 0.5)
-   - 维度 2: 长期无消耗(7日消耗均值 < 10元)
-   - 维度 3: 广告衰退(曾稳定消耗,已干预但仍低)
-   - 第一个命中的维度决定动作
+## 单引擎模式
 
 
-4. **生成报告** — 调用 `generate_report()` 输出 CSV + XLSX
-   - CSV 供程序读取
-   - XLSX 带条件格式(关停行标红)
+- "只用规则引擎分析" → 执行步骤 1-2-3-8
+- "只用智能引擎分析" → 执行步骤 1-2-4-5-6-8
+- "对比两个引擎" → 执行全部步骤
 
 
-## 响应用户查询
+## 用户交互
 
 
-- "分析昨天的广告" → 执行完整工作流
-- "广告 X 为什么被关停" → 从决策结果中查找该广告的命中维度和理由
-- "关停太多了" → 调用 `analyze_ads(roi_low_factor=0.3)` 降低阈值
-- "还不够严格" → 调用 `analyze_ads(roi_low_factor=0.7)` 提高阈值
-- "只看账户 X" → 调用 `analyze_ads(account_id=X)`
-- "这个广告别关停" → 记录用户覆盖,标注在报告中
+- "规则引擎关停太多" → 调用 `analyze_ads(metrics_csv=..., roi_low_factor=0.3)` 重跑
+- "智能引擎太保守" → 重新推理,把置信度阈值从 medium 调到 high,只输出 high 置信度的 pause
+- "广告X为什么被关停" → 从决策结果中查找命中维度和理由,引用具体数值
+- "重新对比" → 调用 `compare_decisions()` 重新生成对比报告
 
 
 ## 注意事项
 ## 注意事项
 
 
-- 所有决策由工具完成,你负责调用工具和呈现结果
-- 解释决策时引用具体数据(f_7日动态ROI、消耗、命中维度)
-- 参考 roi-strategy skill 中的领域知识回答用户提问
-- 阈值调整通过工具参数传入,不修改 config.py
+- 步骤 5 推理时,必须逐条引用具体 ROI 值和消耗数据,不能泛泛而谈
+- apply_decisions 的 decisions 参数必须是合法 JSON 字符串
+- 参考 roi-strategy skill 中的决策框架和输出格式要求

+ 2 - 2
examples/auto_put_ad_mini/run.py

@@ -34,8 +34,8 @@ from examples.auto_put_ad_mini.config import (
 # 导入自定义工具(触发 @tool 注册)
 # 导入自定义工具(触发 @tool 注册)
 from examples.auto_put_ad_mini.tools.data_query import fetch_creative_data
 from examples.auto_put_ad_mini.tools.data_query import fetch_creative_data
 from examples.auto_put_ad_mini.tools.roi_calculator import calculate_roi_metrics
 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
+from examples.auto_put_ad_mini.tools.ad_decision import analyze_ads, get_ads_for_review, apply_decisions
+from examples.auto_put_ad_mini.tools.report_generator import generate_report, compare_decisions
 
 
 
 
 async def init_project_env(messages=None):
 async def init_project_env(messages=None):

+ 46 - 136
examples/auto_put_ad_mini/skills/roi_strategy.md

@@ -1,159 +1,69 @@
 ---
 ---
 name: roi-strategy
 name: roi-strategy
-description: V3 广告 ROI 调控领域知识 — f_7日动态ROI + 三维度决策引擎
+description: 广告调控智能引擎决策框架
 category: ad_optimization
 category: ad_optimization
 ---
 ---
 
 
 ## 你的角色
 ## 你的角色
 
 
-你是广告调控系统的交互层。代码(工具)已完成所有计算和决策,
-你的职责是:
-1. 调用 fetch_creative_data 拉取 30 天创意级数据
-2. 调用 calculate_roi_metrics 计算 f_7日动态ROI
-3. 调用 analyze_ads 执行三维度决策
-4. 调用 generate_report 生成决策报告(CSV + XLSX)
-5. 向用户清晰呈现决策结果,回答提问
+你是智能引擎的决策者。`get_ads_for_review` 工具已整理好需要评估的广告数据,
+你的任务是:**阅读数据 → 推理 → 输出决策列表(JSON)→ 调用 apply_decisions**。
 
 
-## V3 决策逻辑总览
+## 决策框架
 
 
-### 数据流
+### 看什么(类别B广告的关键指标)
 
 
-```
-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 计算公式
+- **动态ROI_7日均值**:相对指标,和全体均值比(工具会提供 distribution 分布)
+- **cost_7d_avg**:消耗越高,数据越可信,决策越有把握
+- **ad_age_days**:< 7天的广告不决策(直接 hold)
+- **bid_increased_7d / creative_changed_7d**:已干预但没好转 → 衰退信号
 
 
-### 核心计算链路
+### 判断逻辑(参考,非硬规则)
 
 
-对每个广告,取最近 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 < 均值×0.5,消耗 > 500元/天,年龄 > 14天 | pause | high |
+| ROI < 均值×0.5,消耗 200-500元/天,年龄 7-14天 | pause | medium |
+| ROI < 均值×0.5,消耗 < 200元/天 | hold,数据量不足 | low |
+| 已提价+换创意,消耗仍低,历史曾稳定 | pause(衰退) | high |
+| ROI 在均值×0.5~0.8 之间 | hold,继续观察 | medium |
+| 广告年龄 < 7天 | hold,冷启动保护 | high |
 
 
-### 为什么用动态 ROI 而非简单 ROI
+### 动态调整
 
 
-- 简单 ROI = 收入/消耗,只反映当天收入,无法体现裂变延后收益
-- f_7日动态ROI 通过裂变系数 × ARPU 的 7 天滚动均值,预估未来 7 天总回报
-- 对高 R 值人群(R330+/R500)更公平,因为其裂变效应更强
+- 今日 roi_mean 比历史显著低 → 整体行情差,放宽关停标准
+- roi_p25 > 1.5 → 行情好,可以严格执行阈值
+- 用户说"关停太多" → 把参考阈值从 0.5 调到 0.3,重新输出决策
 
 
-### 前置条件
+## f_7日动态ROI 说明
 
 
-- 日消耗 < 100 元的天数:**不参与** ROI 计算(数据不具统计意义)
-- 7 天内有效天数不足:标记为"数据不足",不参与决策
+这是核心决策指标,综合考虑当日裂变收益和 7 日裂变稳定性:
 
 
-## 三维度决策标准
+- 需要连续 7 天日消耗 ≥ 100 元才有效
+- 相对指标:和全体均值比,而非绝对阈值
+- 比简单 ROI 更能反映高 R 值人群(R330+/R500)的裂变延后收益
 
 
-### 决策优先级
+## 输出格式(必须是合法 JSON,作为参数传给 apply_decisions)
 
 
+```json
+[
+  {
+    "ad_id": 数字,
+    "action": "pause" 或 "hold",
+    "dimension": "ROI过低" 或 "广告衰退" 或 "保持" 等,
+    "reason": "引用具体数值的一句话,如:动态ROI_7日均值=1.23 < 均值2.72×0.5=1.36,消耗1831元/天有统计意义",
+    "confidence": "high" / "medium" / "low"
+  }
+]
 ```
 ```
-维度 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 供参考。
+**重要**:reason 必须引用具体数值,不能只说"ROI偏低"。
 
 
-## 回答用户提问的原则
+## 阈值参数一览(规则引擎参考,智能引擎可灵活调整)
 
 
-- 始终引用具体数据(f_7日动态ROI、消耗、命中维度)
-- 解释决策原因时,说明是哪个维度命中的,以及具体数值
-- 用户要求调整阈值时,通过工具参数重新调用,不修改 config.py
-- 用户要求覆盖单条决策时(如"这个广告别关停"),记录并标注
+| 参数 | 默认值 | 含义 |
+|------|--------|------|
+| `min_daily_cost` | 100 元 | 日消耗低于此值的天不参与 ROI 计算 |
+| `min_ad_age_days` | 7 天 | 广告创建不足此天数不参与决策 |
+| `roi_low_factor` | 0.5 | f_7日动态ROI < 均值×此值 → 关停参考线 |
+| `no_spend_threshold` | 10 元 | 7日均值消耗低于此值 → 关停 |

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

@@ -590,3 +590,255 @@ async def analyze_ads(
     except Exception as e:
     except Exception as e:
         logger.error("analyze_ads 失败: %s", e, exc_info=True)
         logger.error("analyze_ads 失败: %s", e, exc_info=True)
         return ToolResult(title="analyze_ads 失败", output=str(e))
         return ToolResult(title="analyze_ads 失败", output=str(e))
+
+
+# ═══════════════════════════════════════════
+# 智能引擎工具 1:整理待评估广告数据
+# ═══════════════════════════════════════════
+
+
+@tool(description="智能引擎:整理需要关注的广告数据,供LLM推理决策")
+async def get_ads_for_review(
+    ctx: ToolContext,
+    metrics_csv: str = "",
+    end_date: str = "yesterday",
+    roi_review_factor: float = 0.8,
+    min_spend_for_class_a: float = 1.0,
+) -> ToolResult:
+    """
+    不做决策,将广告分为三类,返回结构化摘要供 LLM 推理。
+
+    类别 A【已确认异常,建议直接关停】:7日均消耗 < 1元(几乎零活动)
+    类别 B【待LLM评估】:消耗有意义但指标异常(ROI偏低或衰退信号)
+    类别 C【正常运行】:仅返回摘要统计
+
+    Args:
+        metrics_csv: ROI 指标 CSV 路径(calculate_roi_metrics 输出)
+        end_date: 结束日期
+        roi_review_factor: 动态ROI < 全体均值 × 此值 → 进入 B 类(默认 0.8)
+        min_spend_for_class_a: 7日均消耗低于此值(元)→ A 类(默认 1.0)
+    """
+    try:
+        if not metrics_csv:
+            metrics_csv = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
+
+        df = pd.read_csv(metrics_csv)
+        if df.empty:
+            return ToolResult(title="get_ads_for_review", 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)
+
+        # 检测衰退信号
+        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 分布
+        roi_series = df["动态ROI_7日均值"].dropna()
+        roi_mean = float(roi_series.mean()) if len(roi_series) > 0 else 0.0
+        roi_p25 = float(roi_series.quantile(0.25)) if len(roi_series) > 0 else 0.0
+        roi_p50 = float(roi_series.quantile(0.50)) if len(roi_series) > 0 else 0.0
+        roi_p75 = float(roi_series.quantile(0.75)) if len(roi_series) > 0 else 0.0
+        roi_p90 = float(roi_series.quantile(0.90)) if len(roi_series) > 0 else 0.0
+
+        # 分类
+        class_a = []
+        class_b = []
+        class_c_count = 0
+
+        for _, row in df.iterrows():
+            cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
+            f_roi = row.get("动态ROI_7日均值")
+            ad_age = row.get("ad_age_days")
+            bid_inc = bool(row.get("bid_increased_7d", False))
+            creative_chg = bool(row.get("creative_changed_7d", False))
+            stable_days = float(row.get("stable_spend_days_30d", 0) or 0)
+
+            # A 类:几乎零消耗
+            if cost_7d_avg < min_spend_for_class_a:
+                class_a.append({
+                    "ad_id": int(row["ad_id"]),
+                    "ad_name": str(row.get("ad_name", "")),
+                    "cost_7d_avg": round(cost_7d_avg, 2),
+                })
+                continue
+
+            # B 类:ROI 偏低 或 衰退信号
+            roi_low = (not pd.isna(f_roi)) and (f_roi < roi_mean * roi_review_factor)
+            decay_signal = (
+                stable_days >= 7
+                and cost_7d_avg < 100
+                and (bid_inc or creative_chg)
+            )
+
+            if roi_low or decay_signal:
+                class_b.append({
+                    "ad_id": int(row["ad_id"]),
+                    "ad_name": str(row.get("ad_name", "")),
+                    "动态ROI_7日均值": round(float(f_roi), 4) if not pd.isna(f_roi) else None,
+                    "cost_7d_avg": round(cost_7d_avg, 2),
+                    "cost_7d_total": round(float(row.get("cost_7d_total", 0) or 0), 2),
+                    "ad_age_days": int(ad_age) if ad_age is not None else None,
+                    "bid_increased_7d": bid_inc,
+                    "creative_changed_7d": creative_chg,
+                    "stable_spend_days_30d": int(stable_days),
+                })
+                continue
+
+            class_c_count += 1
+
+        import json
+        result = {
+            "summary": {
+                "total": len(df),
+                "class_a": len(class_a),
+                "class_b": len(class_b),
+                "class_c": class_c_count,
+            },
+            "distribution": {
+                "roi_mean": round(roi_mean, 4),
+                "p25": round(roi_p25, 4),
+                "p50": round(roi_p50, 4),
+                "p75": round(roi_p75, 4),
+                "p90": round(roi_p90, 4),
+            },
+            "class_a": class_a,
+            "class_b": class_b,
+        }
+
+        output_json = json.dumps(result, ensure_ascii=False, indent=2)
+
+        return ToolResult(
+            title=f"待评估广告(A类:{len(class_a)} B类:{len(class_b)} C类:{class_c_count})",
+            output=output_json,
+            metadata={
+                "total": len(df),
+                "class_a": len(class_a),
+                "class_b": len(class_b),
+                "class_c": class_c_count,
+                "roi_mean": roi_mean,
+                "end_date": end_date,
+            },
+        )
+
+    except Exception as e:
+        logger.error("get_ads_for_review 失败: %s", e, exc_info=True)
+        return ToolResult(title="get_ads_for_review 失败", output=str(e))
+
+
+# ═══════════════════════════════════════════
+# 智能引擎工具 2:保存 LLM 决策结果
+# ═══════════════════════════════════════════
+
+
+@tool(description="智能引擎:接收LLM的决策列表,保存为结构化结果")
+async def apply_decisions(
+    ctx: ToolContext,
+    decisions: str,
+    end_date: str = "yesterday",
+    metrics_csv: str = "",
+) -> ToolResult:
+    """
+    接收 LLM 的决策,合并 A 类广告(自动关停),保存到 llm_decisions_{date}.csv。
+
+    Args:
+        decisions: JSON 字符串,LLM 输出的决策列表
+                   格式:[{"ad_id": 123, "action": "pause"/"hold",
+                           "dimension": "...", "reason": "...", "confidence": "high"/"medium"/"low"}]
+        end_date: 结束日期
+        metrics_csv: ROI 指标 CSV 路径(用于获取 A 类广告)
+    """
+    import json
+
+    try:
+        if end_date == "yesterday":
+            end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+
+        # 解析 LLM 决策
+        try:
+            llm_list = json.loads(decisions)
+        except json.JSONDecodeError as e:
+            return ToolResult(title="apply_decisions 失败", output=f"decisions 不是合法 JSON: {e}")
+
+        if not isinstance(llm_list, list):
+            return ToolResult(title="apply_decisions 失败", output="decisions 必须是 JSON 数组")
+
+        # 加载 A 类广告(自动关停)
+        a_class_rows = []
+        if not metrics_csv:
+            metrics_csv = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
+
+        try:
+            df_metrics = pd.read_csv(metrics_csv)
+            for _, row in df_metrics.iterrows():
+                cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
+                if cost_7d_avg < 1.0:
+                    a_class_rows.append({
+                        "ad_id": int(row["ad_id"]),
+                        "action": "pause",
+                        "dimension": "长期零消耗",
+                        "reason": f"7日均消耗={cost_7d_avg:.2f}元,几乎无活动",
+                        "confidence": "high",
+                        "source": "class_a_auto",
+                    })
+        except Exception as e:
+            logger.warning("加载 A 类广告失败(跳过): %s", e)
+
+        # 合并 LLM 决策(标注来源)
+        for item in llm_list:
+            item["source"] = "llm"
+
+        all_decisions = a_class_rows + llm_list
+
+        if not all_decisions:
+            return ToolResult(title="apply_decisions", output="无决策数据")
+
+        df_out = pd.DataFrame(all_decisions)
+
+        # 确保必要列存在
+        for col in ["ad_id", "action", "dimension", "reason", "confidence", "source"]:
+            if col not in df_out.columns:
+                df_out[col] = ""
+
+        # 保存
+        reports_dir = _MINI_DIR / "outputs" / "reports"
+        reports_dir.mkdir(parents=True, exist_ok=True)
+        out_path = reports_dir / f"llm_decisions_{end_date}.csv"
+        df_out.to_csv(out_path, index=False, encoding="utf-8-sig")
+
+        pause_count = (df_out["action"] == "pause").sum()
+        hold_count = (df_out["action"] == "hold").sum()
+
+        return ToolResult(
+            title=f"智能引擎决策已保存({len(df_out)}条)",
+            output=(
+                f"智能引擎决策已保存: {out_path}\n"
+                f"  关停: {pause_count} 个(含A类自动关停: {len(a_class_rows)} 个)\n"
+                f"  保持: {hold_count} 个"
+            ),
+            metadata={
+                "csv_path": str(out_path),
+                "total": len(df_out),
+                "pause": int(pause_count),
+                "hold": int(hold_count),
+                "class_a_auto": len(a_class_rows),
+                "end_date": end_date,
+            },
+        )
+
+    except Exception as e:
+        logger.error("apply_decisions 失败: %s", e, exc_info=True)
+        return ToolResult(title="apply_decisions 失败", output=str(e))

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

@@ -206,3 +206,122 @@ async def generate_report(
     except Exception as e:
     except Exception as e:
         logger.error("报告生成失败: %s", e, exc_info=True)
         logger.error("报告生成失败: %s", e, exc_info=True)
         return ToolResult(title="报告生成失败", output=str(e))
         return ToolResult(title="报告生成失败", output=str(e))
+
+
+# ═══════════════════════════════════════════
+# 双引擎对比工具
+# ═══════════════════════════════════════════
+
+
+@tool(description="对比规则引擎与智能引擎的决策差异")
+async def compare_decisions(
+    ctx: ToolContext,
+    end_date: str = "yesterday",
+) -> ToolResult:
+    """
+    加载同日期的规则引擎和智能引擎决策结果,输出对比报告。
+
+    对比标签:
+      - agree:两个引擎一致(都 pause 或都 hold)
+      - rule_only_pause:只有规则引擎关停
+      - llm_only_pause:只有智能引擎关停
+      - disagree:决策不同(其他情况)
+
+    Args:
+        end_date: 结束日期(YYYYMMDD 或 "yesterday")
+    """
+    try:
+        if end_date == "yesterday":
+            end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+
+        rule_path = _REPORTS_DIR / f"decision_{end_date}.csv"
+        llm_path = _REPORTS_DIR / f"llm_decisions_{end_date}.csv"
+
+        if not rule_path.exists():
+            return ToolResult(
+                title="compare_decisions",
+                output=f"规则引擎结果不存在: {rule_path}",
+            )
+        if not llm_path.exists():
+            return ToolResult(
+                title="compare_decisions",
+                output=f"智能引擎结果不存在: {llm_path}",
+            )
+
+        df_rule = pd.read_csv(rule_path)[["ad_id", "action", "dimension", "reason"]].copy()
+        df_rule.columns = ["ad_id", "rule_action", "rule_dimension", "rule_reason"]
+
+        df_llm = pd.read_csv(llm_path)[["ad_id", "action", "dimension", "reason", "confidence"]].copy()
+        df_llm.columns = ["ad_id", "llm_action", "llm_dimension", "llm_reason", "llm_confidence"]
+
+        # 外连接合并
+        df = pd.merge(df_rule, df_llm, on="ad_id", how="outer")
+
+        # 填充缺失
+        df["rule_action"] = df["rule_action"].fillna("no_data")
+        df["llm_action"] = df["llm_action"].fillna("no_data")
+
+        # 打标签
+        def _label(row):
+            r = row["rule_action"]
+            l = row["llm_action"]
+            if r == l:
+                return "agree"
+            if r == "pause" and l != "pause":
+                return "rule_only_pause"
+            if l == "pause" and r != "pause":
+                return "llm_only_pause"
+            return "disagree"
+
+        df["comparison"] = df.apply(_label, axis=1)
+
+        # 保存对比报告
+        _REPORTS_DIR.mkdir(parents=True, exist_ok=True)
+        out_path = _REPORTS_DIR / f"comparison_{end_date}.csv"
+        df.to_csv(out_path, index=False, encoding="utf-8-sig")
+
+        # 统计摘要
+        total = len(df)
+        counts = df["comparison"].value_counts().to_dict()
+        agree = counts.get("agree", 0)
+        rule_only = counts.get("rule_only_pause", 0)
+        llm_only = counts.get("llm_only_pause", 0)
+        disagree = counts.get("disagree", 0)
+
+        rule_pause = (df["rule_action"] == "pause").sum()
+        llm_pause = (df["llm_action"] == "pause").sum()
+        agree_rate = round(agree / total * 100, 1) if total > 0 else 0.0
+
+        summary = (
+            f"双引擎对比报告: {out_path}\n\n"
+            f"总广告数: {total}\n"
+            f"一致率: {agree_rate}%({agree}/{total})\n\n"
+            f"规则引擎关停: {rule_pause} 个\n"
+            f"智能引擎关停: {llm_pause} 个\n\n"
+            f"分类明细:\n"
+            f"  agree(两者一致): {agree}\n"
+            f"  rule_only_pause(仅规则关停): {rule_only}\n"
+            f"  llm_only_pause(仅智能关停): {llm_only}\n"
+            f"  disagree(其他差异): {disagree}"
+        )
+
+        return ToolResult(
+            title=f"双引擎对比(一致率 {agree_rate}%)",
+            output=summary,
+            metadata={
+                "csv_path": str(out_path),
+                "total": total,
+                "agree_rate": agree_rate,
+                "rule_pause": int(rule_pause),
+                "llm_pause": int(llm_pause),
+                "agree": agree,
+                "rule_only_pause": rule_only,
+                "llm_only_pause": llm_only,
+                "disagree": disagree,
+                "end_date": end_date,
+            },
+        )
+
+    except Exception as e:
+        logger.error("compare_decisions 失败: %s", e, exc_info=True)
+        return ToolResult(title="compare_decisions 失败", output=str(e))