Quellcode durchsuchen

feat(creative-pause): 创意级 pause 细化,广告级 pause 候选下沉到创意维度

- 阶段1: 新增 creative_roi_calculator.py,按 (ad_id, creative_id) 计算动态ROI 7日均值
- 阶段2: ad_decision._analyze_creatives_for_pause 决策树
  - eligible 资格门槛: cost_share_7d≥0.15, creative_age_days≥7, roi_valid_days≥3
  - verdict: ad_pause / creative_pause / downgrade_to_observe
  - 边界保护: 关停后剩余 eligible<2 或关停占比>80% 强制升级 ad_pause
- 阶段3: guardrails.CreativePauseHistory 创意级 7 天频次限制
- 阶段4: execution_engine 路由 ad_pause vs creative_pause,腾讯v3.0要求同 adgroup 串行
- 阶段5: 审批表/报告新增 pause_scope 列(ad/creatives:N)
- 修复: 零消耗规则 pause 跳过创意级细化,显式 pause_scope='ad' 避免 NaN

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
刘立冬 vor 1 Monat
Ursprung
Commit
8cd445be7a

+ 13 - 0
examples/auto_put_ad_mini/config.py

@@ -35,6 +35,7 @@ MAIN_CONFIG = RunConfig(
         "fetch_creative_data",
         "merge_creative_data",
         "calculate_roi_metrics",
+        "calculate_creative_roi",      # 创意级动态 ROI(广告级 pause 候选的二次细化)
         "calculate_portfolio_summary",
         "get_ads_for_review",
         "apply_decisions",
@@ -126,6 +127,18 @@ HIGH_BURN_AGE_THRESHOLD = 3     # 广告年龄>3天才检查
 HIGH_BURN_COST_THRESHOLD = 300  # 昨日消耗>300元触发预警
 ROI_LOW_MIN_YESTERDAY_COST = 300  # 关停消耗门槛:昨日消耗≥300才检查关停(投手经验2.4)
 
+# ═══════════════════════════════════════════
+# 创意级 pause 细化配置
+# ═══════════════════════════════════════════
+# 当广告级判 pause 时,先做创意级二次分析:全员低于阈值才真关广告,部分拖累只关差创意
+CREATIVE_PAUSE_ENABLED = True       # 总开关:False 时全部走广告级 pause(降级路径)
+CREATIVE_MIN_COST_SHARE = 0.15      # 创意 7 日消耗占比 < 此值视为数据稀疏,不纳入"判死刑"
+CREATIVE_MIN_AGE_DAYS = 7           # 创意年龄 < 此值视为冷启动,不纳入"判死刑"(对齐广告级 EARLY_GROWTH_DAYS)
+CREATIVE_MIN_VALID_DAYS = 3         # 创意有效 ROI 数据天数 < 此值视为不充分,不纳入"判死刑"
+CREATIVE_MIN_REMAINING = 2          # 关停后剩余 eligible 创意数 < 此值,升级为广告级 pause
+CREATIVE_MAX_PAUSE_COST_SHARE = 0.80 # pause_targets 总占消耗 > 此值,本质是关广告,升级为广告级 pause
+CREATIVE_RATELIMIT_DAYS = 7         # 同一创意 7 天内不允许重复 pause
+
 # ═══════════════════════════════════════════
 # 安全护栏配置
 # ═══════════════════════════════════════════

+ 1 - 0
examples/auto_put_ad_mini/execute_once.py

@@ -33,6 +33,7 @@ from examples.auto_put_ad_mini.config import (
 # 导入自定义工具
 from examples.auto_put_ad_mini.tools.data_query import fetch_creative_data, merge_creative_data
 from examples.auto_put_ad_mini.tools.roi_calculator import calculate_roi_metrics
+from examples.auto_put_ad_mini.tools.creative_roi_calculator import calculate_creative_roi
 from examples.auto_put_ad_mini.tools.portfolio_metrics import calculate_portfolio_summary
 from examples.auto_put_ad_mini.tools.ad_decision import get_ads_for_review, apply_decisions, query_ad_detail, modify_decisions
 from examples.auto_put_ad_mini.tools.report_generator import generate_report

+ 6 - 3
examples/auto_put_ad_mini/prompts/system.prompt

@@ -45,9 +45,10 @@ $system$
 ## 可用工具列表
 
 **数据获取**(必须按顺序):
-- `fetch_creative_data(days, end_date)` — 从 ODPS 拉原始数据 + 广告状态快照
-- `merge_creative_data(days, force)` — 合并到 outputs/merged/(依赖 fetch)
+- `fetch_creative_data(end_date)` — 从 ODPS 拉原始数据 + 广告状态快照(**省略 days 参数,自动使用配置的 14 天窗口**)
+- `merge_creative_data(days, force)` — 合并到 outputs/merged/(依赖 fetch;**days 参数必须省略或与 fetch 一致**
 - `calculate_roi_metrics(end_date)` — 计算动态 ROI (7日均值)(依赖 merge;**不会自动拉数据**,缺 merge 会得到错误结果)
+- `calculate_creative_roi(end_date)` — 计算创意级动态 ROI(用于 pause 候选的创意级二次细化;依赖 merge;输出 outputs/creative_roi/creative_roi_{end_date}.csv)
 - `get_ads_for_review(metrics_csv)` — 三级分类(零消耗/待评估/正常)
 - `query_ad_detail(ad_id, metrics_csv)` — 单广告详情 + 全局上下文
 
@@ -75,6 +76,8 @@ Step 2: merge_creative_data       ← 合并创意数据+广告状态
 Step 3: calculate_roi_metrics     ← 计算ROI(依赖Step 1+2的数据)
+Step 3.5: calculate_creative_roi  ← 计算创意级动态 ROI(apply_decisions 的创意级 pause 细化依赖此输出;缺失则降级为纯广告级 pause)
+    ↓
 Step 4: get_ads_for_review        ← 分类(零消耗待关停 / 需评估 / 正常运行)
 Step 5: AI推理决策                 ← 对【待评估(候选)】广告推理(按 tier 分批)
@@ -240,7 +243,7 @@ Skill 提供「判断原则」,工具提供「数据」,你负责综合判
 
 ### 连续 2 轮无果 → 主动提议暂停
 
-不要无限反刍。建议本轮停审,主动说"我去拉这条广告近 3 天逐小时数据 + 30 天调价历史,明天再聊",让您选择"提供数据继续"或"结束本轮"。
+不要无限反刍。建议本轮停审,主动说"我去拉这条广告近 3 天逐小时数据 + 调价历史,明天再聊",让您选择"提供数据继续"或"结束本轮"。
 
 ### 工具链映射
 

+ 5 - 19
examples/auto_put_ad_mini/skills/ad_domain.md

@@ -5,8 +5,6 @@ description: 计算ROI、解读裂变指标、理解人群包分层时查阅—
 
 # 业务模型与核心指标
 
-> 本 skill 只描述**我们的业务模型**。平台参数(oCPM、API限制等)见 `platform-rules` skill。
-
 ## 用户增长与变现模型(关键:多阶段并行变现)
 
 ```
@@ -28,7 +26,7 @@ description: 计算ROI、解读裂变指标、理解人群包分层时查阅—
 3. **后续天还会持续产生价值** —— 同一批用户在 7-30 天内持续回流、持续消费
 
 所以不是"获客→种子→传播→变现"的线性漏斗,**每一层都在变现**。
-ROI 不能只用"当日首层收益/消耗"衡量,要综合**当日+后续多日**的累积产出。
+ROI 不能只用"当日收入/消耗"衡量,要综合**多日**的累积产出。
 
 这正是 动态 ROI (7日均值) 被设计出来的原因 —— 通过 7 日滚动均值的裂变稳定因子,
 把"后续几天仍在产生的价值"折算进今天的决策里。
@@ -62,19 +60,13 @@ ROI 不能只用"当日首层收益/消耗"衡量,要综合**当日+后续多
 
 ## ROI 公式(关键)
 
-### 简单 ROI
+###  当日ROI
 ```
-ROI = 收入 / 消耗
+当日ROI = 当日收入 / 当日消耗
 ```
 局限:**不体现未来裂变收益**,高 R 值人群会被低估。
 
-### f_7日动态 ROI(决策用)
-```
-当日裂变收益率 = T0裂变数 × arpu / cost
-当日回流倍数   = 总回流人数 / 首层打开数
-裂变效率稳定因子 = 回流倍数_7日均值 / T0裂变系数_7日均值
-
-动态 ROI (7日均值) = 当日裂变收益率 × 裂变效率稳定因子
+### 动态ROI_7日均值(决策用)
 动态ROI_7日均值 = mean(动态 ROI (7日均值)) over 最近 7 天
 ```
 
@@ -119,13 +111,7 @@ Day 7~30:         持续贡献
 ### 核心判断原则
 
 > R 值高的人群"价贵 + 带人多 + 变现多天分布"。
-> 看 R500/R330+ 广告的**当日单日 ROI** 几乎**必然偏低**——这不是广告差,
-> 而是它的价值主要在后续几天的持续带人和回流变现。
->
+> 看 R500/R330+ 广告的**当日单日 ROI** 几乎**必然偏低**——这不是广告差,而是它的价值主要在后续几天的持续带人和回流变现。
 > **正确看法**:永远用 `动态ROI_7日均值`(动态 ROI (7日均值) 的 7 日滚动均值)做判断,
 > 它已经通过"裂变效率稳定因子"把后续多天的价值折算进来。
 
-**决策建议**:
-- R330+/R500:看 `动态ROI_7日均值`,**单日 ROI 低不是负面信号**
-- R50/R10:当日 ROI 和 7 日均值会比较接近,这类人群价值几乎都落在当天
-- 比较广告时:**同人群包之间比 ROI** 才有意义,跨 R 值硬比会误判

+ 22 - 0
examples/auto_put_ad_mini/skills/decision_strategy.md

@@ -158,6 +158,28 @@ bid_up_candidate=True 也不等于必须 bid_up —— 可能 CTR 其实在下
 
 **禁用于**:`bid_up_candidate=True` 或 `scale_up_candidate=True` 的广告
 
+**🆕 创意级 pause 细化(系统自动后处理,LLM 无需感知)**:
+
+LLM 输出 `action=pause` 后,系统会对每条广告做创意级二次分析(`_analyze_creatives_for_pause`),
+按"3 资格门槛 + 死刑判定 + 边界保护"决策树,在以下三种粒度中择一执行:
+
+- `pause_scope="ad"`(广告级关停):全部 eligible 创意都低于阈值,或数据不足/边界保护触发
+- `pause_scope="creatives"`(创意级关停):仅关停广告内 ROI 低于阈值的具体创意,保留其他创意让系统重新优选
+- `action=observe`(降级观察):全部 eligible 创意 ROI 均达标,广告级 ROI 偏低可能是噪声
+
+**资格门槛**(创意需同时满足才参与判定):
+- `cost_share_7d ≥ 0.15`(占消耗 ≥ 15%,数据稀疏防护)
+- `creative_age_days ≥ 7`(创意冷启动保护)
+- `roi_valid_days ≥ 3`(min_periods 一致性)
+
+**死刑阈值**:`创意动态ROI_7日均值 < 渠道/tier P50 × ROI_LOW_FACTOR(0.75)`
+
+**边界保护**(任一触发则升级回 ad_pause):
+- 关停后剩余 eligible 创意数 < 2(留余地让系统优选)
+- pause_targets 总占消耗 > 80%(本质就是关广告主体)
+
+**速率限制**:同一创意 7 天内不再 pause(避免反复操作,持久化在 `creative_pause_history.json`)
+
 ---
 
 ### 2. `bid_down`(降价)— ROI 偏低 + 裂变偏低,双低确认

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

@@ -20,8 +20,11 @@ from agent.tools import tool
 from agent.tools.models import ToolContext, ToolResult
 
 _MINI_DIR = Path(__file__).resolve().parent.parent
+_TOOLS_DIR = Path(__file__).resolve().parent
 if str(_MINI_DIR) not in sys.path:
     sys.path.insert(0, str(_MINI_DIR))
+if str(_TOOLS_DIR) not in sys.path:
+    sys.path.insert(0, str(_TOOLS_DIR))
 
 from config import (
     AUDIENCE_TIER_PATTERNS,
@@ -45,6 +48,13 @@ from config import (
     HIGH_BURN_COST_THRESHOLD,
     ROI_LOW_FACTOR,
     ROI_LOW_MIN_YESTERDAY_COST,
+    CREATIVE_PAUSE_ENABLED,
+    CREATIVE_MIN_COST_SHARE,
+    CREATIVE_MIN_AGE_DAYS,
+    CREATIVE_MIN_VALID_DAYS,
+    CREATIVE_MIN_REMAINING,
+    CREATIVE_MAX_PAUSE_COST_SHARE,
+    CREATIVE_RATELIMIT_DAYS,
 )
 
 logger = logging.getLogger(__name__)
@@ -804,6 +814,227 @@ async def get_ads_for_review(
         return ToolResult(title="get_ads_for_review 失败", output=str(e))
 
 
+# ═══════════════════════════════════════════
+# 创意级 pause 细化分析
+# ═══════════════════════════════════════════
+
+
+def _analyze_creatives_for_pause(
+    ad_id: int,
+    audience_tier: Optional[str],
+    end_date: str,
+    channel_roi_p50: Optional[float],
+    creative_roi_csv: Optional[Path] = None,
+    portfolio_summary: Optional[dict] = None,
+) -> dict:
+    """
+    对一条广告级 pause 候选广告做创意级二次分析。
+
+    决策树:
+      1. eligible_creatives 为空(数据不足/冷启动)→ ad_pause(继续走广告级关停)
+      2. pause_targets == eligible_creatives(全部判死刑)→ ad_pause(等价于关广告)
+      3. pause_targets 为空(eligible 中没人触发)→ downgrade_to_observe
+      4. 部分触发 → creative_pause(只关具体差创意)
+      5. 边界保护:
+         - 关停后剩余 eligible < CREATIVE_MIN_REMAINING → 升级 ad_pause
+         - pause_targets 总占消耗 > CREATIVE_MAX_PAUSE_COST_SHARE → 升级 ad_pause
+
+    Returns:
+        {
+          "ad_id": int,
+          "verdict": "ad_pause" | "creative_pause" | "downgrade_to_observe",
+          "reason": str,
+          "all_creatives": [...],          # 全量摘要
+          "eligible_creatives": [...],     # 通过资格门槛
+          "pause_targets": [...],          # 应被关停的 creative_id 列表
+          "kept_creatives": [...],         # 保留的 creative_id 列表
+          "channel_p50": float,
+          "threshold": float,
+        }
+    """
+    result = {
+        "ad_id": int(ad_id),
+        "verdict": "ad_pause",
+        "reason": "未做创意级分析(默认走广告级 pause)",
+        "all_creatives": [],
+        "eligible_creatives": [],
+        "pause_targets": [],
+        "kept_creatives": [],
+        "channel_p50": channel_roi_p50,
+        "threshold": None,
+    }
+
+    if not CREATIVE_PAUSE_ENABLED:
+        result["reason"] = "CREATIVE_PAUSE_ENABLED=False,创意级细化功能关闭"
+        return result
+
+    # 选择阈值基线:优先该广告所属 audience_tier 的 P50,缺失退化到 channel_roi_p50
+    p50 = None
+    if portfolio_summary and audience_tier:
+        tier_stats = portfolio_summary.get("by_audience_tier", {}).get(str(audience_tier), {})
+        p50 = tier_stats.get("roi_p50")
+    if p50 is None:
+        p50 = channel_roi_p50
+    if p50 is None or p50 <= 0:
+        result["reason"] = "无可用 channel/tier P50 基线,跳过创意级细化"
+        return result
+
+    threshold = float(p50) * float(ROI_LOW_FACTOR)
+    result["channel_p50"] = float(p50)
+    result["threshold"] = round(threshold, 4)
+
+    # 加载创意级 ROI CSV
+    if creative_roi_csv is None:
+        creative_roi_csv = _MINI_DIR / "outputs" / "creative_roi" / f"creative_roi_{end_date}.csv"
+    if not Path(creative_roi_csv).exists():
+        result["reason"] = f"创意级 ROI CSV 不存在({creative_roi_csv}),无法做细化判定"
+        return result
+
+    try:
+        df_cr = pd.read_csv(creative_roi_csv, dtype={"ad_id": str, "creative_id": str})
+    except Exception as e:
+        result["reason"] = f"加载创意级 ROI CSV 失败:{e}"
+        return result
+
+    ad_str = str(int(ad_id))
+    df_ad = df_cr[df_cr["ad_id"] == ad_str].copy()
+    if df_ad.empty:
+        result["reason"] = f"广告 {ad_id} 在创意级 ROI 表中无记录"
+        return result
+
+    # 创意级 pause 速率限制(同一创意 7 天内不再 pause)
+    try:
+        from guardrails import CreativePauseHistory
+        creative_history = CreativePauseHistory()
+    except Exception as e:
+        logger.warning(f"加载 CreativePauseHistory 失败,跳过创意级速率限制: {e}")
+        creative_history = None
+
+    # 资格门槛 + 死刑判定
+    all_summary = []
+    eligible = []
+    pause_targets = []
+    kept = []
+    rate_limited = []  # 因近期已 pause 被速率限制的(也算 kept)
+    eligible_total_cost_share = 0.0
+    pause_total_cost_share = 0.0
+
+    for _, row in df_ad.iterrows():
+        cid = str(row["creative_id"])
+        cost_share = float(row.get("cost_share_7d") or 0)
+        age = row.get("creative_age_days")
+        valid_days = int(row.get("roi_valid_days") or 0)
+        roi_7d = row.get("创意动态ROI_7日均值")
+        roi_7d_val = float(roi_7d) if pd.notna(roi_7d) else None
+
+        item = {
+            "creative_id": cid,
+            "creative_name": str(row.get("creative_name") or ""),
+            "cost_7d": round(float(row.get("cost_7d") or 0), 2),
+            "cost_share_7d": round(cost_share, 4),
+            "creative_age_days": int(age) if pd.notna(age) else None,
+            "roi_valid_days": valid_days,
+            "creative_dynamic_roi_7d": round(roi_7d_val, 4) if roi_7d_val is not None else None,
+        }
+        all_summary.append(item)
+
+        # 资格门槛
+        is_eligible = (
+            cost_share >= CREATIVE_MIN_COST_SHARE
+            and (item["creative_age_days"] is not None and item["creative_age_days"] >= CREATIVE_MIN_AGE_DAYS)
+            and valid_days >= CREATIVE_MIN_VALID_DAYS
+            and roi_7d_val is not None
+        )
+        if not is_eligible:
+            continue
+        eligible.append(item)
+        eligible_total_cost_share += cost_share
+
+        # 死刑判定
+        if roi_7d_val < threshold:
+            # 速率限制:同一创意 CREATIVE_RATELIMIT_DAYS 天内不再 pause
+            if creative_history is not None and creative_history.was_paused_within(
+                ad_str, cid, CREATIVE_RATELIMIT_DAYS
+            ):
+                item_with_note = dict(item)
+                item_with_note["rate_limited"] = True
+                rate_limited.append(item_with_note)
+                kept.append(item_with_note)  # 速率限制项也算 kept(保留观察)
+                logger.info(
+                    f"创意级速率限制:广告 {ad_id} 创意 {cid} {CREATIVE_RATELIMIT_DAYS} 天内已 pause,跳过"
+                )
+            else:
+                pause_targets.append(item)
+                pause_total_cost_share += cost_share
+        else:
+            kept.append(item)
+
+    result["all_creatives"] = all_summary
+    result["eligible_creatives"] = eligible
+
+    # ===== verdict 决策树 =====
+    n_eligible = len(eligible)
+    n_pause = len(pause_targets)
+
+    if n_eligible == 0:
+        result["verdict"] = "ad_pause"
+        result["reason"] = (
+            f"创意级数据不充分(全 {len(all_summary)} 个创意均未通过资格门槛:"
+            f"cost_share≥{CREATIVE_MIN_COST_SHARE}/age≥{CREATIVE_MIN_AGE_DAYS}d/"
+            f"valid_days≥{CREATIVE_MIN_VALID_DAYS}),沿用广告级 pause"
+        )
+        return result
+
+    if n_pause == 0:
+        result["verdict"] = "downgrade_to_observe"
+        result["reason"] = (
+            f"创意级细化:{n_eligible} 个有效创意 ROI 均 ≥ 阈值 {threshold:.4f}"
+            f"(渠道/tier P50={p50:.4f} × {ROI_LOW_FACTOR}),广告级 ROI 偏低可能是噪声,降级观察"
+        )
+        return result
+
+    if n_pause == n_eligible:
+        result["verdict"] = "ad_pause"
+        result["reason"] = (
+            f"创意级细化:{n_eligible} 个有效创意全部低于阈值 {threshold:.4f},"
+            f"等价于关整广告"
+        )
+        result["pause_targets"] = pause_targets
+        return result
+
+    # 部分触发 + 边界保护
+    n_remaining = n_eligible - n_pause
+    if n_remaining < CREATIVE_MIN_REMAINING:
+        result["verdict"] = "ad_pause"
+        result["reason"] = (
+            f"创意级细化:本可关 {n_pause}/{n_eligible} 个差创意,"
+            f"但关停后剩余 eligible 创意数={n_remaining} < {CREATIVE_MIN_REMAINING},"
+            f"为保系统优选空间升级为广告级 pause"
+        )
+        result["pause_targets"] = pause_targets
+        return result
+
+    if pause_total_cost_share > CREATIVE_MAX_PAUSE_COST_SHARE:
+        result["verdict"] = "ad_pause"
+        result["reason"] = (
+            f"创意级细化:本可关 {n_pause}/{n_eligible} 个差创意,"
+            f"但 pause_targets 总占消耗 {pause_total_cost_share:.2%} > {CREATIVE_MAX_PAUSE_COST_SHARE:.0%},"
+            f"本质就是关广告主体,升级为广告级 pause"
+        )
+        result["pause_targets"] = pause_targets
+        return result
+
+    result["verdict"] = "creative_pause"
+    result["pause_targets"] = pause_targets
+    result["kept_creatives"] = kept
+    result["reason"] = (
+        f"创意级细化:关 {n_pause}/{n_eligible} 个低于阈值 {threshold:.4f} 的创意"
+        f"(占消耗 {pause_total_cost_share:.1%}),保留 {n_remaining} 个 eligible 创意"
+        f"让系统重新优选"
+    )
+    return result
+
+
 # ═══════════════════════════════════════════
 # 智能引擎工具 2:保存 LLM 决策结果
 # ═══════════════════════════════════════════
@@ -881,6 +1112,7 @@ async def apply_decisions(
                     zero_spend_rows.append({
                         "ad_id": int(row["ad_id"]),
                         "action": "pause",
+                        "pause_scope": "ad",  # 零消耗规则 pause 一律广告级,跳过创意级细化
                         "dimension": "长期零消耗",
                         "reason": reason_text,
                         "confidence": "high",
@@ -946,6 +1178,86 @@ async def apply_decisions(
                 item["cost_7d_avg"] = 0.0
                 logger.warning(f"处理广告 {ad_id} 信息时出错: {e}")
 
+        # ===== 创意级 pause 细化(阶段 2)=====
+        # 对所有 LLM 输出 pause 的决策做创意级二次分析,改写为:
+        #   creative_pause(只关差创意) / 保持 ad_pause / downgrade_to_observe
+        if CREATIVE_PAUSE_ENABLED:
+            # 加载 portfolio_summary + channel_p50
+            portfolio_summary = {}
+            channel_p50 = None
+            try:
+                import json as _json
+                portfolio_file = _MINI_DIR / "outputs" / "portfolio_summary" / f"portfolio_summary_{end_date}.json"
+                if portfolio_file.exists():
+                    with open(portfolio_file, "r", encoding="utf-8") as f:
+                        portfolio_summary = _json.load(f)
+                    g = portfolio_summary.get("global", {}) or {}
+                    channel_p50 = g.get("roi_p50")
+            except Exception as e:
+                logger.warning(f"加载 portfolio_summary 失败,创意级细化可能降级:{e}")
+
+            # 创意级 ROI CSV 路径
+            creative_roi_csv = _MINI_DIR / "outputs" / "creative_roi" / f"creative_roi_{end_date}.csv"
+            if not creative_roi_csv.exists():
+                logger.warning(
+                    f"创意级 ROI CSV 不存在({creative_roi_csv}),所有 pause 均沿用广告级。"
+                    f"请确认 calculate_creative_roi 已运行。"
+                )
+
+            verdict_counts = {"ad_pause": 0, "creative_pause": 0, "downgrade_to_observe": 0}
+            for item in llm_list:
+                if item.get("action") != "pause":
+                    continue
+                ad_id = item.get("ad_id")
+                if ad_id is None:
+                    continue
+                # audience_tier 从 metrics 拿
+                tier = None
+                try:
+                    cost_row = df_metrics[df_metrics["ad_id"] == ad_id]
+                    if not cost_row.empty:
+                        tier = cost_row.iloc[0].get("audience_tier")
+                        if pd.isna(tier):
+                            tier = None
+                except Exception:
+                    tier = None
+
+                analysis = _analyze_creatives_for_pause(
+                    ad_id=int(ad_id),
+                    audience_tier=tier,
+                    end_date=end_date,
+                    channel_roi_p50=channel_p50,
+                    creative_roi_csv=creative_roi_csv,
+                    portfolio_summary=portfolio_summary,
+                )
+
+                verdict = analysis["verdict"]
+                verdict_counts[verdict] = verdict_counts.get(verdict, 0) + 1
+                original_reason = item.get("reason", "")
+
+                if verdict == "creative_pause":
+                    item["pause_scope"] = "creatives"
+                    item["pause_creative_ids"] = [c["creative_id"] for c in analysis["pause_targets"]]
+                    item["pause_creative_details"] = analysis["pause_targets"]
+                    item["kept_creative_ids"] = [c["creative_id"] for c in analysis["kept_creatives"]]
+                    item["reason"] = f"{original_reason} | {analysis['reason']}"
+                elif verdict == "downgrade_to_observe":
+                    item["action"] = "observe"
+                    item["pause_scope"] = "ad"
+                    item["confidence"] = "low"
+                    item["recommended_change_pct"] = None
+                    item["reason"] = f"{original_reason} | {analysis['reason']}"
+                else:  # ad_pause
+                    item["pause_scope"] = "ad"
+                    if analysis.get("threshold") is not None:
+                        item["reason"] = f"{original_reason} | {analysis['reason']}"
+
+            logger.info(
+                f"创意级细化结果:ad_pause={verdict_counts.get('ad_pause', 0)},"
+                f"creative_pause={verdict_counts.get('creative_pause', 0)},"
+                f"downgrade_to_observe={verdict_counts.get('downgrade_to_observe', 0)}"
+            )
+
         # 加载正常运行广告(规则判断)
         normal_running_rows = []
         try:

+ 406 - 0
examples/auto_put_ad_mini/tools/creative_roi_calculator.py

@@ -0,0 +1,406 @@
+"""
+创意级动态 ROI 计算器 — auto_put_ad_mini
+
+把 roi_calculator.py 的"动态 ROI 7 日均值"公式按 (ad_id, creative_id) 维度下沉,
+为创意级 pause 决策提供数值基础。
+
+核心公式(与 roi_calculator.py 完全一致,仅聚合维度从 ad_id 改为 (ad_id, creative_id)):
+  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)
+  T0裂变系数_7日均值   = mean(T0裂变系数) over 7 天
+  回流倍数_7日均值     = mean(当日回流倍数) over 7 天
+  裂变效率稳定因子     = 回流倍数_7日均值 / T0裂变系数_7日均值
+  创意动态ROI         = 当日裂变收益率 × 裂变效率稳定因子
+  创意动态ROI_7日均值  = mean(创意动态ROI) over 7 天 ← 决策参考值
+
+前置条件:
+  - 单日 (ad_id, creative_id) 消耗 < 100 元的天数不参与计算(NaN)
+  - min_periods=3:至少 3 天合格数据才计算 7 日滚动均值
+
+⚠️ 归因语义说明:
+  ODPS 表 loghubods.ad_put_tencent_creative_data_day 的 fission0_count / total_return_count /
+  total_revenue 等字段,本期假设按"创意级独立归因"处理(即同一用户被多创意触达时归到首次触达
+  的 creative_id)。如果实际是按曝光数加权拆分到所有创意,需要另外修正聚合逻辑。
+  本期先按现有口径实现,待阶段 6 端到端测试时通过对比"创意 ROI 加权平均"与"广告级 ROI"做交叉验证。
+"""
+
+import logging
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Optional
+
+import numpy as np
+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
+_MERGED_DIR = _MINI_DIR / "outputs" / "merged"
+_CREATIVE_ROI_DIR = _MINI_DIR / "outputs" / "creative_roi"
+
+
+# ===== 内部聚合 =====
+
+def _aggregate_creative_to_creative_day(df: pd.DataFrame) -> pd.DataFrame:
+    """按 (ad_id, creative_id, date) 聚合(同一天可能多分片,需 SUM)。"""
+    if df.empty:
+        return pd.DataFrame()
+
+    df = df.copy()
+
+    # bizdate → date
+    if "bizdate" in df.columns:
+        df["date"] = df["bizdate"].astype(str)
+    elif "date" not in df.columns:
+        logger.warning("creative_roi: DataFrame 缺少 bizdate/date 列")
+        return pd.DataFrame()
+
+    # 列名标准化(与 roi_calculator 对齐)
+    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)
+
+    # 过滤无 creative_id 的行(广告状态行)
+    df = df[df["creative_id"].notna() & (df["creative_id"].astype(str).str.strip() != "")]
+    if df.empty:
+        return pd.DataFrame()
+
+    # 数值字段安全转换
+    numeric_cols = [
+        "cost", "view_count", "valid_click_count",
+        "open_count", "fission0_count", "fission_count", "fission1_count",
+        "total_return_count", "total_revenue",
+    ]
+    for col in numeric_cols:
+        if col in df.columns:
+            df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0)
+
+    agg_dict = {
+        "account_id": "first",
+        "ad_name": "first",
+        "creative_name": "first",
+        "create_time": "first",
+        "configured_status": "first",
+        "package_name": "first",
+    }
+    for col in numeric_cols:
+        if col in df.columns:
+            agg_dict[col] = "sum"
+    agg_dict = {k: v for k, v in agg_dict.items() if k in df.columns}
+
+    grouped = df.groupby(["ad_id", "creative_id", "date"], as_index=False).agg(agg_dict)
+    return grouped
+
+
+# ===== 创意级动态 ROI =====
+
+def _calculate_creative_dynamic_roi(
+    cdf: pd.DataFrame,
+    min_daily_cost: float = 100.0,
+) -> pd.DataFrame:
+    """
+    在 (ad_id, creative_id, date) 粒度上计算动态 ROI。
+    """
+    if cdf.empty:
+        return cdf
+
+    cdf = cdf.sort_values(["ad_id", "creative_id", "date"]).reset_index(drop=True)
+    group_keys = ["ad_id", "creative_id"]
+
+    # 当日基础指标(单日消耗不足时设 NaN)
+    cdf["T0裂变系数"] = np.where(
+        (cdf.get("open_count", 0) > 0) & (cdf.get("cost", 0) >= min_daily_cost),
+        cdf["fission0_count"] / cdf["open_count"].replace(0, np.nan),
+        np.nan,
+    )
+    cdf["arpu"] = np.where(
+        (cdf.get("total_return_count", 0) > 0) & (cdf.get("cost", 0) >= min_daily_cost),
+        cdf["total_revenue"] / cdf["total_return_count"].replace(0, np.nan),
+        np.nan,
+    )
+    cdf["当日裂变收益率"] = np.where(
+        (cdf.get("cost", 0) > 0) & (cdf.get("cost", 0) >= min_daily_cost),
+        cdf["fission0_count"] * cdf["arpu"] / cdf["cost"].replace(0, np.nan),
+        np.nan,
+    )
+    cdf["当日回流倍数"] = np.where(
+        (cdf.get("open_count", 0) > 0) & (cdf.get("cost", 0) >= min_daily_cost),
+        cdf["total_return_count"] / cdf["open_count"].replace(0, np.nan),
+        np.nan,
+    )
+
+    # 7 日滚动均值(按创意分组,min_periods=3)
+    cdf["T0裂变系数_7日均值"] = (
+        cdf.groupby(group_keys)["T0裂变系数"]
+        .transform(lambda x: x.rolling(window=7, min_periods=3).mean())
+    )
+    cdf["回流倍数_7日均值"] = (
+        cdf.groupby(group_keys)["当日回流倍数"]
+        .transform(lambda x: x.rolling(window=7, min_periods=3).mean())
+    )
+
+    cdf["裂变效率稳定因子"] = np.where(
+        cdf["T0裂变系数_7日均值"] > 0,
+        cdf["回流倍数_7日均值"] / cdf["T0裂变系数_7日均值"],
+        np.nan,
+    )
+
+    cdf["创意动态ROI"] = cdf["当日裂变收益率"] * cdf["裂变效率稳定因子"]
+    cdf["创意动态ROI_7日均值"] = (
+        cdf.groupby(group_keys)["创意动态ROI"]
+        .transform(lambda x: x.rolling(window=7, min_periods=3).mean())
+    )
+    cdf["roi_valid_days"] = (
+        cdf.groupby(group_keys)["创意动态ROI"]
+        .transform(lambda x: x.notna().sum())
+    )
+
+    return cdf
+
+
+def _build_creative_summary(
+    cdf: pd.DataFrame,
+    end_date: str,
+) -> pd.DataFrame:
+    """
+    按 (ad_id, creative_id) 汇总最新一天指标 + 7 日累计 + 创意年龄 + 占比。
+    """
+    if cdf.empty:
+        return pd.DataFrame()
+
+    end_dt = datetime.strptime(end_date, "%Y%m%d")
+    start_dt_7d = end_dt - timedelta(days=6)
+    start_date_7d = start_dt_7d.strftime("%Y%m%d")
+
+    # 最近 7 天累计消耗
+    df_7d = cdf[(cdf["date"] >= start_date_7d) & (cdf["date"] <= end_date)].copy()
+    cost_7d = df_7d.groupby(["ad_id", "creative_id"], as_index=False)["cost"].sum()
+    cost_7d.rename(columns={"cost": "cost_7d"}, inplace=True)
+
+    # 广告级 7 日累计(用来算占比)
+    ad_cost_7d = df_7d.groupby("ad_id", as_index=False)["cost"].sum()
+    ad_cost_7d.rename(columns={"cost": "ad_cost_7d"}, inplace=True)
+
+    summary = cost_7d.merge(ad_cost_7d, on="ad_id", how="left")
+    summary["cost_share_7d"] = np.where(
+        summary["ad_cost_7d"] > 0,
+        (summary["cost_7d"] / summary["ad_cost_7d"]).round(4),
+        0.0,
+    )
+
+    # 创意年龄:以创意首次出现日期(min bizdate)为锚点,相对 end_date 计算
+    first_date = (
+        cdf.groupby(["ad_id", "creative_id"], as_index=False)["date"]
+        .min()
+        .rename(columns={"date": "first_date"})
+    )
+
+    def _age(row):
+        try:
+            first_dt = datetime.strptime(str(row["first_date"]), "%Y%m%d")
+            return max((end_dt - first_dt).days, 0)
+        except Exception:
+            return None
+
+    first_date["creative_age_days"] = first_date.apply(_age, axis=1)
+    summary = summary.merge(first_date[["ad_id", "creative_id", "creative_age_days"]],
+                            on=["ad_id", "creative_id"], how="left")
+
+    # 最新一天的创意动态 ROI + 创意属性
+    latest = cdf[cdf["date"] == end_date][[
+        c for c in [
+            "ad_id", "creative_id", "creative_name", "ad_name", "account_id",
+            "configured_status", "创意动态ROI", "创意动态ROI_7日均值", "roi_valid_days",
+        ] if c in cdf.columns
+    ]].copy()
+
+    summary = summary.merge(latest, on=["ad_id", "creative_id"], how="left")
+
+    # 兜底:创意如果在 end_date 当天没数据,从最近一天回填属性
+    missing_mask = summary["creative_name"].isna() if "creative_name" in summary.columns else None
+    if missing_mask is not None and missing_mask.any():
+        last_seen = (
+            cdf.sort_values("date")
+            .groupby(["ad_id", "creative_id"], as_index=False)
+            .last()[[c for c in [
+                "ad_id", "creative_id", "creative_name", "ad_name", "account_id",
+                "configured_status", "创意动态ROI_7日均值", "roi_valid_days",
+            ] if c in cdf.columns]]
+            .rename(columns={
+                "creative_name": "_creative_name_fb",
+                "ad_name": "_ad_name_fb",
+                "account_id": "_account_id_fb",
+                "configured_status": "_configured_status_fb",
+                "创意动态ROI_7日均值": "_roi_7d_fb",
+                "roi_valid_days": "_roi_valid_days_fb",
+            })
+        )
+        summary = summary.merge(last_seen, on=["ad_id", "creative_id"], how="left")
+        for col, fb in [
+            ("creative_name", "_creative_name_fb"),
+            ("ad_name", "_ad_name_fb"),
+            ("account_id", "_account_id_fb"),
+            ("configured_status", "_configured_status_fb"),
+            ("创意动态ROI_7日均值", "_roi_7d_fb"),
+            ("roi_valid_days", "_roi_valid_days_fb"),
+        ]:
+            if col in summary.columns and fb in summary.columns:
+                summary[col] = summary[col].where(summary[col].notna(), summary[fb])
+                summary.drop(columns=[fb], inplace=True)
+
+    summary["roi_valid_days"] = summary["roi_valid_days"].fillna(0).astype(int)
+
+    return summary
+
+
+# ===== 工具入口 =====
+
+@tool(description="计算创意级动态 ROI(7 日均值),用于创意级 pause 决策")
+async def calculate_creative_roi(
+    ctx: ToolContext = None,
+    end_date: str = "yesterday",
+    min_daily_cost: float = 100.0,
+    window_days: int = 30,
+) -> ToolResult:
+    """
+    创意级动态 ROI 计算工具。
+
+    工作流:
+      1. 加载最近 window_days 天的 merged_*.csv
+      2. 按 (ad_id, creative_id, date) 聚合
+      3. 计算每天的 T0 裂变系数 / arpu / 当日裂变收益率 / 当日回流倍数
+      4. 计算 7 日滚动均值 + 裂变效率稳定因子
+      5. 计算创意动态 ROI / 创意动态 ROI 7 日均值
+      6. 输出 outputs/creative_roi/creative_roi_{end_date}.csv
+
+    Args:
+        end_date: 结束日期(YYYYMMDD 或 "yesterday")
+        min_daily_cost: 单日消耗门槛(默认 100 元),低于此值的天数不参与
+        window_days: 加载历史窗口天数(默认 30 天,与 roi_calculator 一致)
+
+    Returns:
+        ToolResult,包含 csv_path / 创意总数 / eligible 创意数 / 全体均值
+    """
+    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")
+
+        # 加载 merged
+        start_dt = end_dt - timedelta(days=window_days - 1)
+        merged_dfs = []
+        for i in range(window_days):
+            d = (start_dt + timedelta(days=i)).strftime("%Y%m%d")
+            csv = _MERGED_DIR / f"merged_{d}.csv"
+            if not csv.exists():
+                continue
+            df = pd.read_csv(csv, dtype={"ad_id": str, "creative_id": str, "account_id": str})
+            merged_dfs.append(df)
+
+        if not merged_dfs:
+            return ToolResult(
+                title="创意级 ROI 计算失败",
+                output=f"未找到任何 merged 数据({_MERGED_DIR})",
+            )
+
+        creative_df = pd.concat(merged_dfs, ignore_index=True)
+        logger.info("创意级 ROI: 加载 merged 数据 %d 行(%d 天)", len(creative_df), len(merged_dfs))
+
+        # 同步 roi_calculator 的"近 7 天累计消耗 = 0 视为已关闭"前置过滤
+        last_7_start = (end_dt - timedelta(days=6)).strftime("%Y%m%d")
+        bz = creative_df["bizdate"].astype(str)
+        recent7 = creative_df[(bz >= last_7_start) & (bz <= end_date_str)]
+        zero_ads = (
+            recent7.groupby("ad_id")["cost"].sum()
+            .pipe(lambda s: s[s.fillna(0) <= 0].index.tolist())
+        )
+        if zero_ads:
+            before = len(creative_df)
+            creative_df = creative_df[~creative_df["ad_id"].isin(zero_ads)].reset_index(drop=True)
+            logger.info(
+                "创意级 ROI: 前置过滤 %d 条近 7 天 0 消耗广告,creative_df %d → %d 行",
+                len(zero_ads), before, len(creative_df),
+            )
+
+        # 聚合到 (ad_id, creative_id, date)
+        cdf = _aggregate_creative_to_creative_day(creative_df)
+        if cdf.empty:
+            return ToolResult(
+                title="创意级 ROI 计算失败",
+                output="creative-day 聚合结果为空(可能 creative_id 全为空)",
+            )
+        logger.info("创意级 ROI: 聚合到 (ad_id, creative_id, date) %d 行", len(cdf))
+
+        # 计算动态 ROI
+        cdf = _calculate_creative_dynamic_roi(cdf, min_daily_cost)
+
+        # 汇总到 (ad_id, creative_id)
+        summary = _build_creative_summary(cdf, end_date_str)
+        if summary.empty:
+            return ToolResult(
+                title="创意级 ROI 计算失败",
+                output="creative summary 为空",
+            )
+
+        # 输出列排序
+        out_cols = [
+            "ad_id", "account_id", "ad_name", "creative_id", "creative_name",
+            "configured_status", "creative_age_days",
+            "cost_7d", "ad_cost_7d", "cost_share_7d",
+            "创意动态ROI", "创意动态ROI_7日均值", "roi_valid_days",
+        ]
+        out_cols = [c for c in out_cols if c in summary.columns]
+        summary = summary[out_cols].copy()
+
+        # 保存
+        _CREATIVE_ROI_DIR.mkdir(parents=True, exist_ok=True)
+        out_path = _CREATIVE_ROI_DIR / f"creative_roi_{end_date_str}.csv"
+        summary.to_csv(out_path, index=False, encoding="utf-8-sig")
+        logger.info("创意级 ROI CSV 已保存: %s", out_path)
+
+        # 统计
+        total = len(summary)
+        roi_col = "创意动态ROI_7日均值"
+        valid = int(summary[roi_col].notna().sum()) if roi_col in summary.columns else 0
+        roi_mean = float(summary[roi_col].mean()) if valid > 0 else float("nan")
+        ad_count = int(summary["ad_id"].nunique())
+
+        lines = [
+            f"✅ 创意级动态 ROI 计算完成(截至 {end_date_str})",
+            f"  输出文件:{out_path}",
+            f"  创意总数:{total}(覆盖 {ad_count} 条广告)",
+            f"  有 ROI 值的创意数(7 日均值非 NaN):{valid}",
+            f"  创意动态 ROI_7 日均值 全体均值:{roi_mean:.4f}",
+        ]
+        return ToolResult(
+            title=f"创意级 ROI 计算完成({total} 个创意)",
+            output="\n".join(lines),
+            metadata={
+                "csv_path": str(out_path),
+                "total_creatives": total,
+                "valid_creatives": valid,
+                "ad_count": ad_count,
+                "roi_mean": roi_mean,
+                "end_date": end_date_str,
+            },
+        )
+
+    except Exception as e:
+        logger.exception("calculate_creative_roi 失败")
+        return ToolResult(title="创意级 ROI 计算异常", output=f"错误:{e}")

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

@@ -311,7 +311,7 @@ async def fetch_creative_data(
     拉取创意级别明细数据(V3)。
 
     Args:
-        days: 回溯天数(默认使用 config.DATA_WINDOW_DAYS
+        days: 回溯天数(**通常省略,自动使用配置的 DATA_WINDOW_DAYS=14 天**
         end_date: 结束日期(默认 yesterday,格式 YYYYMMDD 或 YYYY-MM-DD)
 
     Returns:

+ 204 - 12
examples/auto_put_ad_mini/tools/execution_engine.py

@@ -155,6 +155,44 @@ class TencentAdExecutor:
                 else:
                     return {"code": -1, "message": str(e)}
 
+    async def pause_creative(self, creative_id: int, account_id: int) -> Dict:
+        """暂停单条动态创意 (v3.0: /dynamic_creatives/update)。"""
+        from config import WHITELIST_ENABLED, WHITELIST_ACCOUNTS
+        if WHITELIST_ENABLED and account_id not in WHITELIST_ACCOUNTS:
+            logger.error(
+                f"⚠️ 白名单安全阻断:账户 {account_id} 不在白名单内,拒绝执行 pause_creative。"
+                f"白名单: {WHITELIST_ACCOUNTS}"
+            )
+            return {"code": -1, "message": f"账户 {account_id} 不在白名单内"}
+
+        from ad_api import _post, _check, _get_access_token
+        try:
+            token = _get_access_token(account_id)
+            if not token or len(token) < 10:
+                return {"code": -1, "message": f"获取 access_token 失败:token={token[:20]}"}
+        except Exception as e:
+            logger.error(f"[pause_creative] 获取 access_token 异常: {e}")
+            return {"code": -1, "message": f"获取 access_token 异常: {e}"}
+
+        for attempt in range(API_MAX_RETRIES):
+            try:
+                await self._bucket.acquire()
+                body = {
+                    "account_id": account_id,
+                    "dynamic_creative_id": creative_id,
+                    "configured_status": "AD_STATUS_SUSPEND",
+                }
+                resp = _post("/dynamic_creatives/update", body)
+                _check(resp, "pause_creative")
+                return {"code": 0, "message": "success"}
+            except Exception as e:
+                if attempt < API_MAX_RETRIES - 1:
+                    wait = 2 ** attempt
+                    logger.warning("pause_creative 重试 %d/%d: %s", attempt + 1, API_MAX_RETRIES, e)
+                    await asyncio.sleep(wait)
+                else:
+                    return {"code": -1, "message": str(e)}
+
     async def pause_ad(self, ad_id: int, account_id: int) -> Dict:
         """暂停广告。"""
         # 白名单安全检查(最后防线)
@@ -221,6 +259,132 @@ class AuditLogger:
         return self._path
 
 
+# ═══════════════════════════════════════════
+# 创意级 pause 解析与执行
+# ═══════════════════════════════════════════
+
+
+def _parse_creative_ids(cell) -> List[str]:
+    """从 CSV 单元格解析 pause_creative_ids。
+
+    DataFrame 内是 Python list,写入 CSV 后变 "['c1','c2']" 字符串,
+    再读回时变字符串。统一解析为字符串列表。
+    """
+    if cell is None:
+        return []
+    if isinstance(cell, list):
+        return [str(c) for c in cell]
+    s = str(cell).strip()
+    if not s or s.lower() == "nan" or s == "[]":
+        return []
+    try:
+        import ast
+        v = ast.literal_eval(s)
+        if isinstance(v, (list, tuple)):
+            return [str(c) for c in v]
+        return [str(v)]
+    except Exception:
+        # 兜底:逗号分隔
+        return [c.strip().strip("'\"") for c in s.strip("[]").split(",") if c.strip()]
+
+
+async def _execute_pause(
+    executor: "TencentAdExecutor",
+    row: pd.Series,
+    audit: "AuditLogger",
+    history: "AdjustmentHistory",
+) -> Dict[str, Any]:
+    """根据 pause_scope 路由到广告级或创意级 pause。
+
+    返回: {code, message, exec_status, scope, creative_results}
+    创意级 pause 严格按 ad_id 串行(腾讯 v3.0 同 adgroup 创意更新串行约束)。
+    """
+    ad_id = int(row["ad_id"])
+    account_id = int(row.get("account_id", 0) or 0)
+    scope = str(row.get("pause_scope", "ad") or "ad").strip()
+
+    if scope != "creatives":
+        # 广告级 pause(原路径)
+        result = await executor.pause_ad(ad_id, account_id)
+        return {
+            "code": result.get("code", -1),
+            "message": result.get("message", ""),
+            "exec_status": "success" if result.get("code") == 0 else "failed",
+            "scope": "ad",
+            "creative_results": [],
+        }
+
+    # 创意级 pause:同 ad_id 下逐个串行
+    creative_ids = _parse_creative_ids(row.get("pause_creative_ids"))
+    if not creative_ids:
+        logger.warning(
+            f"广告 {ad_id} pause_scope=creatives 但 pause_creative_ids 为空,降级为广告级 pause"
+        )
+        result = await executor.pause_ad(ad_id, account_id)
+        return {
+            "code": result.get("code", -1),
+            "message": result.get("message", ""),
+            "exec_status": "success" if result.get("code") == 0 else "failed",
+            "scope": "ad_fallback",
+            "creative_results": [],
+        }
+
+    creative_results = []
+    success_count = 0
+    fail_count = 0
+
+    # 持久化:创意级 pause 历史(用于后续速率限制)
+    try:
+        from guardrails import CreativePauseHistory
+        creative_history = CreativePauseHistory()
+    except Exception as e:
+        logger.warning(f"创意级 pause 历史加载失败: {e}")
+        creative_history = None
+
+    for cid in creative_ids:
+        try:
+            cid_int = int(cid)
+        except (ValueError, TypeError):
+            logger.warning(f"广告 {ad_id} 创意 ID 解析失败: {cid}")
+            creative_results.append({
+                "creative_id": cid, "code": -1, "message": "creative_id 非整数",
+            })
+            fail_count += 1
+            continue
+
+        r = await executor.pause_creative(cid_int, account_id)
+        rcode = r.get("code", -1)
+        creative_results.append({
+            "creative_id": cid_int,
+            "code": rcode,
+            "message": r.get("message", ""),
+        })
+        if rcode == 0:
+            success_count += 1
+            if creative_history is not None:
+                try:
+                    creative_history.record_pause(ad_id, cid_int, action="pause")
+                except Exception as e:
+                    logger.warning(f"记录创意 pause 历史失败 ({ad_id}:{cid_int}): {e}")
+        else:
+            fail_count += 1
+
+    if success_count == len(creative_ids):
+        exec_status = "success"
+    elif success_count > 0:
+        exec_status = "partial"
+    else:
+        exec_status = "failed"
+
+    return {
+        "code": 0 if exec_status == "success" else -1,
+        "message": f"创意级 pause: 成功 {success_count}/{len(creative_ids)}, 失败 {fail_count}",
+        "exec_status": exec_status,
+        "scope": "creatives",
+        "creative_results": creative_results,
+    }
+
+
 # ═══════════════════════════════════════════
 # 自治级别分类
 # ═══════════════════════════════════════════
@@ -388,9 +552,16 @@ async def execute_decisions(
             account_id = int(row.get("account_id", 0) or 0)
 
             pre_state = await executor.get_ad_state(ad_id, account_id)
+            pause_extra: Dict[str, Any] = {}
 
             if action == "pause":
-                result = await executor.pause_ad(ad_id, account_id)
+                pause_result = await _execute_pause(executor, row, audit, history)
+                result = {"code": pause_result["code"], "message": pause_result["message"]}
+                pause_extra = {
+                    "pause_scope": pause_result["scope"],
+                    "creative_results": pause_result["creative_results"],
+                }
+                exec_status_override = pause_result["exec_status"]
             elif action in ("bid_up", "bid_down"):
                 final_bid = row.get("final_bid", row.get("recommended_bid"))
                 if final_bid is None or final_bid == "":
@@ -404,13 +575,17 @@ async def execute_decisions(
                     continue
                 bid_fen = int(float(final_bid) * 100)
                 result = await executor.update_bid(ad_id, account_id, bid_fen)
+                exec_status_override = None
             else:
                 continue
 
             api_code = result.get("code", -1)
-            exec_status = "success" if api_code == 0 else "failed"
+            if exec_status_override is not None:
+                exec_status = exec_status_override  # success / partial / failed
+            else:
+                exec_status = "success" if api_code == 0 else "failed"
 
-            if exec_status == "success":
+            if exec_status in ("success", "partial"):
                 executed += 1
                 change_pct = row.get("recommended_change_pct", 0)
                 if isinstance(change_pct, str):
@@ -422,9 +597,9 @@ async def execute_decisions(
             else:
                 failed += 1
 
-            post_state = await executor.get_ad_state(ad_id, account_id) if exec_status == "success" else None
+            post_state = await executor.get_ad_state(ad_id, account_id) if exec_status in ("success", "partial") else None
 
-            audit.log({
+            audit_entry = {
                 "ad_id": ad_id,
                 "account_id": account_id,
                 "action": action,
@@ -441,7 +616,10 @@ async def execute_decisions(
                 "api_message": result.get("message", ""),
                 "execution_status": exec_status,
                 "source": row.get("source", ""),
-            })
+            }
+            if pause_extra:
+                audit_entry.update(pause_extra)
+            audit.log(audit_entry)
 
         # ═══ Phase 2: Tier 2/3 — 审批 + 执行 ═══
         if not df_tier2_3.empty:
@@ -522,9 +700,16 @@ async def execute_decisions(
 
                         # 已批准 → 执行
                         pre_state = await executor.get_ad_state(ad_id, account_id)
+                        pause_extra: Dict[str, Any] = {}
 
                         if action == "pause":
-                            result = await executor.pause_ad(ad_id, account_id)
+                            pause_result = await _execute_pause(executor, row, audit, history)
+                            result = {"code": pause_result["code"], "message": pause_result["message"]}
+                            pause_extra = {
+                                "pause_scope": pause_result["scope"],
+                                "creative_results": pause_result["creative_results"],
+                            }
+                            exec_status_override = pause_result["exec_status"]
                         elif action in ("bid_up", "bid_down"):
                             final_bid = row.get("final_bid", row.get("recommended_bid"))
                             if final_bid is None or final_bid == "":
@@ -538,13 +723,17 @@ async def execute_decisions(
                                 continue
                             bid_fen = int(float(final_bid) * 100)
                             result = await executor.update_bid(ad_id, account_id, bid_fen)
+                            exec_status_override = None
                         else:
                             continue
 
                         api_code = result.get("code", -1)
-                        exec_status = "success" if api_code == 0 else "failed"
+                        if exec_status_override is not None:
+                            exec_status = exec_status_override
+                        else:
+                            exec_status = "success" if api_code == 0 else "failed"
 
-                        if exec_status == "success":
+                        if exec_status in ("success", "partial"):
                             approved_executed += 1
                             change_pct = row.get("recommended_change_pct", 0)
                             if isinstance(change_pct, str):
@@ -556,9 +745,9 @@ async def execute_decisions(
                         else:
                             failed += 1
 
-                        post_state = await executor.get_ad_state(ad_id, account_id) if exec_status == "success" else None
+                        post_state = await executor.get_ad_state(ad_id, account_id) if exec_status in ("success", "partial") else None
 
-                        audit.log({
+                        audit_entry = {
                             "ad_id": ad_id,
                             "account_id": account_id,
                             "action": action,
@@ -575,7 +764,10 @@ async def execute_decisions(
                             "api_message": result.get("message", ""),
                             "execution_status": f"approved_{exec_status}",
                             "source": row.get("source", ""),
-                        })
+                        }
+                        if pause_extra:
+                            audit_entry.update(pause_extra)
+                        audit.log(audit_entry)
             else:
                 # IM 未启用:Tier 2/3 仅记录不执行
                 logger.info("IM 未启用,Tier 2/3 共 %d 个操作仅记录不执行", len(df_tier2_3))

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

@@ -57,8 +57,14 @@ from config import (
     GUARDRAILS_ENABLED,
     DATA_DIR,
     TIMEZONE,
+    CREATIVE_MIN_COST_SHARE,
+    CREATIVE_MIN_AGE_DAYS,
+    CREATIVE_MIN_REMAINING,
+    CREATIVE_RATELIMIT_DAYS,
 )
 
+CREATIVE_PAUSE_HISTORY_PATH = DATA_DIR / "creative_pause_history.json"
+
 logger = logging.getLogger(__name__)
 
 
@@ -161,6 +167,72 @@ class AdjustmentHistory:
         return None
 
 
+# ═══════════════════════════════════════════
+# 创意级 pause 历史(用于创意级速率限制 + 审计回溯)
+# ═══════════════════════════════════════════
+
+
+class CreativePauseHistory:
+    """创意级 pause 历史(JSON 文件持久化,key 为 'ad_id:creative_id')。"""
+
+    def __init__(self, path: Path = CREATIVE_PAUSE_HISTORY_PATH):
+        self._path = path
+        self._data: Dict[str, Dict] = {}
+        self._load()
+
+    def _load(self):
+        if self._path.exists():
+            try:
+                self._data = json.loads(self._path.read_text(encoding="utf-8"))
+            except Exception as e:
+                logger.warning("加载创意 pause 历史失败,使用空记录: %s", e)
+                self._data = {}
+
+    def _save(self):
+        self._path.parent.mkdir(parents=True, exist_ok=True)
+        self._path.write_text(
+            json.dumps(self._data, ensure_ascii=False, indent=2),
+            encoding="utf-8",
+        )
+
+    @staticmethod
+    def _key(ad_id, creative_id) -> str:
+        return f"{ad_id}:{creative_id}"
+
+    def get_last_pause_ts(self, ad_id, creative_id) -> Optional[datetime]:
+        record = self._data.get(self._key(ad_id, creative_id))
+        if not record:
+            return None
+        last_ts = record.get("last_ts")
+        if last_ts:
+            try:
+                return datetime.fromisoformat(last_ts)
+            except ValueError:
+                return None
+        return None
+
+    def was_paused_within(self, ad_id, creative_id, days: int) -> bool:
+        last = self.get_last_pause_ts(ad_id, creative_id)
+        if last is None:
+            return False
+        return (datetime.now() - last) < timedelta(days=days)
+
+    def record_pause(self, ad_id, creative_id, action: str = "pause"):
+        key = self._key(ad_id, creative_id)
+        now = datetime.now().isoformat()
+        if key not in self._data:
+            self._data[key] = {"ad_id": str(ad_id), "creative_id": str(creative_id),
+                               "events": [], "last_ts": None}
+        self._data[key]["events"].append({"ts": now, "action": action})
+        self._data[key]["last_ts"] = now
+        # 只保留 30 天历史
+        cutoff = (datetime.now() - timedelta(days=30)).isoformat()
+        self._data[key]["events"] = [
+            e for e in self._data[key]["events"] if e.get("ts", "") >= cutoff
+        ]
+        self._save()
+
+
 # ═══════════════════════════════════════════
 # 护栏检查结果
 # ═══════════════════════════════════════════

+ 26 - 2
examples/auto_put_ad_mini/tools/im_approval.py

@@ -119,6 +119,8 @@ APPROVAL_COLUMNS = [
     # 决策详情
     "dimension", "reason",
     "recommended_change_pct",
+    # 关停粒度(创意级 pause 细化)
+    "pause_scope",
 ]
 
 
@@ -179,7 +181,17 @@ def _format_project_notification_message(df_tier2: pd.DataFrame, df_tier1: pd.Da
         action_counts = df_tier2.get("final_action", df_tier2.get("action", "")).value_counts().to_dict()
         for action, count in action_counts.items():
             if action == "pause":
-                lines.append(f"  ⏸️  暂停: {count} 个")
+                # 区分广告级 vs 创意级 pause
+                pause_rows = df_tier2[df_tier2.get("final_action", df_tier2.get("action", "")) == "pause"]
+                if "pause_scope" in pause_rows.columns:
+                    creative_count = (pause_rows["pause_scope"].astype(str).str.lower() == "creatives").sum()
+                    ad_count = count - creative_count
+                    if creative_count > 0:
+                        lines.append(f"  ⏸️  暂停: {count} 个(广告级 {ad_count} + 创意级 {creative_count})")
+                    else:
+                        lines.append(f"  ⏸️  暂停: {count} 个")
+                else:
+                    lines.append(f"  ⏸️  暂停: {count} 个")
             elif action == "bid_down":
                 lines.append(f"  ⬇️  降价: {count} 个")
             elif action == "bid_up":
@@ -198,7 +210,19 @@ def _format_project_notification_message(df_tier2: pd.DataFrame, df_tier1: pd.Da
             roi = row.get("动态ROI_7日均值", 0)
 
             if action == "pause":
-                action_label = "⏸️ 暂停"
+                pause_scope = str(row.get("pause_scope", "ad") or "ad").strip().lower()
+                if pause_scope == "creatives":
+                    pause_ids = row.get("pause_creative_ids", [])
+                    if isinstance(pause_ids, str):
+                        try:
+                            import ast
+                            pause_ids = ast.literal_eval(pause_ids)
+                        except Exception:
+                            pause_ids = []
+                    n_creative = len(pause_ids) if hasattr(pause_ids, "__len__") else 0
+                    action_label = f"⏸️ 创意级暂停({n_creative}个)"
+                else:
+                    action_label = "⏸️ 广告级暂停"
             elif action == "bid_down":
                 pct = row.get("recommended_change_pct", 0)
                 if isinstance(pct, str):

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

@@ -34,6 +34,8 @@ OUTPUT_COLUMNS = [
     # 决策详情
     "dimension", "reason",
     "recommended_change_pct",
+    # 关停粒度(创意级 pause 细化)
+    "pause_scope",
 ]
 
 # 中文列名映射
@@ -74,6 +76,7 @@ CN_COLUMNS = {
     "recommended_bid": "建议出价(元)",
     "guardrail_reason": "护栏说明",
     "execution_status": "执行状态",
+    "pause_scope": "关停粒度",
     "f_7日动态ROI_mean_all": "全体动态ROI均值",
     "source": "数据来源",
 }