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

feat(auto_put_ad_mini): 知识层重构+pause裂变硬约束+护栏B系列增强

- 拆分 roi_strategy 为 4 个独立 skill(roi-baseline / action-playbook / age-protection / posterior-wisdom)
- system.prompt 新增 reason 5元组硬模板 + pause 必引裂变对比约束
- action_playbook pause 条件段新增裂变 reason 硬要求
- guardrails.py B 系列护栏增强(渠道P50基准)
- ad_decision.py 决策引擎候选信号对齐(提价双分支/降价裂变门槛)
- config.py 升级 Sonnet 4.5 + 注册新 skills

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

+ 2 - 2
examples/auto_put_ad_mini/API_TEST_GUIDE.md

@@ -24,7 +24,7 @@
    - 合并数据
 
 3. **ROI 计算** (`tools/roi_calculator.py`)
-   - 计算 f_7日动态ROI
+   - 计算 动态 ROI (7日均值)
    - 多维度 ROI 分析
 
 4. **决策引擎** (`tools/ad_decision.py`)
@@ -48,7 +48,7 @@
 
 - **API 版本**: 腾讯广告 Marketing API v3.0
 - **层级结构**: 广告(Ad) → 创意(Dynamic Creative)
-- **决策依据**: f_7日动态ROI + 消耗双维度
+- **决策依据**: 动态 ROI (7日均值) + 消耗双维度
 - **决策范围**: 调整出价、暂停广告
 - **安全模式**: DRY_RUN_MODE 默认开启,不实际执行
 

+ 7 - 7
examples/auto_put_ad_mini/ARCHITECTURE.md

@@ -114,7 +114,7 @@
 处理:
   1. 加载最近30天的 merged 数据(容错缺失)
   2. 聚合到广告级 (GROUP BY ad_id, date)
-  3. 计算 f_7日动态ROI (考虑裂变效率稳定性)
+  3. 计算 动态 ROI (7日均值) (考虑裂变效率稳定性)
   4. 计算 7日汇总 (cost_7d_avg, revenue_7d_total)
   5. 计算 30日汇总 (stable_spend_days_30d)
 
@@ -125,7 +125,7 @@
   当日回流倍数 = total_return_count / open_count
 
   裂变效率稳定因子 = 回流倍数_7日均值 / T0裂变系数_7日均值
-  f_7日动态ROI = 当日裂变收益率 * 裂变效率稳定因子
+  动态 ROI (7日均值) = 当日裂变收益率 * 裂变效率稳定因子
 
 输出:
   - outputs/metrics_20260415.csv (1570行, 每个广告一行)
@@ -160,7 +160,7 @@
 输出: JSON结构化数据
   {
     "summary": {total, zero_spend_ads, need_review_ads, normal_ads},
-    "distribution": {roi_mean, p25, p50, p75, p90},
+    "distribution": {channel_roi_p50, p25, p50, p75, p90},
     "bid_adjustment": {bid_down_line, bid_up_line},
     "zero_spend_ads": [...],  # 自动关停列表
     "need_review_ads": [...], # 需要推理的广告(含详细指标)
@@ -198,7 +198,7 @@
       "ad_id": 90289631207,
       "action": "pause",
       "dimension": "ROI过低",
-      "reason": "f_7日动态ROI=1.18 < 均值3.29×0.5=1.64, 日消耗1524元, 持续亏损",
+      "reason": "动态 ROI (7日均值)=1.18 < 均值3.29×0.5=1.64, 日消耗1524元, 持续亏损",
       "confidence": "high"
     },
     {
@@ -342,7 +342,7 @@
               过滤 action=pause → rejected
 
 运营说: "为什么要暂停广告90289631207?"
-  → Agent回答: "该广告f_7日动态ROI=1.18 < 关停线1.64,日消耗1524元,
+  → Agent回答: "该广告动态 ROI (7日均值)=1.18 < 关停线1.64,日消耗1524元,
                 已持续亏损,建议暂停止损"
   → 等待运营最终确认
 ```
@@ -699,7 +699,7 @@ outputs/merged/merged_YYYYMMDD.csv (创意+广告状态合并)
 [roi_calculator.py] calculate_roi_metrics
   • 加载最近30天 merged 数据
   • 聚合到广告级 (GROUP BY ad_id, date)
-  • 计算 f_7日动态ROI
+  • 计算 动态 ROI (7日均值)
   • 计算 7日/30日汇总指标
 outputs/metrics_20260415.csv (1570行广告级指标)
@@ -1094,7 +1094,7 @@ examples/auto_put_ad_mini/
 ├── tools/
 │   ├── data_query.py        # 数据拉取+合并 (ODPS → CSV)
-│   ├── roi_calculator.py    # ROI计算 (f_7日动态ROI核心算法)
+│   ├── roi_calculator.py    # ROI计算 (动态 ROI (7日均值)核心算法)
 │   ├── ad_decision.py       # 决策引擎 (A/B/C分类 + AI决策保存)
 │   ├── guardrails.py        # 安全护栏 (6道检查)
 │   ├── execution_engine.py  # 执行引擎 (调用腾讯广告API)

+ 13 - 4
examples/auto_put_ad_mini/config.py

@@ -3,7 +3,7 @@
 
 运营可直接修改此文件调整决策参数。
 当前模式:智能判断
-  - 基于 f_7日动态ROI 的精细化决策
+  - 基于 动态 ROI (7日均值) 的精细化决策
   - AI 推理结合领域知识
   - 三级分类:零消耗待关停(规则)+ 待优化评估(智能)+ 正常运行(规则)
 """
@@ -50,7 +50,16 @@ MAIN_CONFIG = RunConfig(
         # 但框架的 agent 工具只返回文本 summary,主 Agent 拿不回结构化决策,
         # 会陷入"无法 apply"的死循环。直接在主 Agent 单次输出完成全部 decisions 更可靠。
     ],
-    skills=["ad-domain", "roi-strategy", "guardrail-rules", "tencent-ad-playbook"],
+    skills=[
+        "ad-domain",
+        "roi-baseline",        # ★ 新:对比基准口径 + 阈值线定义(每次决策前必读)
+        "action-playbook",     # ★ 新:7 种 action 决策树 + 判断矩阵
+        "age-protection",      # ★ 新:三段年龄策略
+        "posterior-wisdom",    # ★ 新:后验经验(学习中断/置信度分级)
+        "roi-strategy",        # 保留:总索引(指向上面 4 个子 skill,兼容历史引用)
+        "guardrail-rules",
+        "tencent-ad-playbook",
+    ],
     extra_llm_params={"max_tokens": 32000},
     knowledge=KnowledgeConfig(
         enable_extraction=False,
@@ -69,14 +78,14 @@ LOG_FILE = None
 # V3 数据窗口配置
 # ═══════════════════════════════════════════
 DATA_WINDOW_DAYS = 7  # 测试阶段:采集 7 天历史数据
-ROI_CALCULATION_DAYS = 7  # f_7日动态ROI 计算窗口
+ROI_CALCULATION_DAYS = 7  # 动态 ROI (7日均值) 计算窗口
 
 # ═══════════════════════════════════════════
 # V3 决策阈值(默认值,可被 SKILL 覆盖)
 # ═══════════════════════════════════════════
 MIN_DAILY_COST = 100  # 日消耗 >= 100元才参与 ROI 计算
 MIN_AD_AGE_DAYS = 3  # 广告创建 >= 3天才参与决策(与 min_periods 对齐)
-ROI_LOW_FACTOR = 0.75  # f_7日动态ROI < 全体均值 × 0.75 → 关停
+ROI_LOW_FACTOR = 0.75  # 动态 ROI (7日均值) < 全体均值 × 0.75 → 关停
 NO_SPEND_THRESHOLD = 10  # 7日消耗均值 < 10元 → 关停
 STABLE_SPEND_THRESHOLD = 100  # 稳定消耗定义:>100元/天
 

+ 6 - 6
examples/auto_put_ad_mini/docs/不走LLM的规则盘点.md

@@ -17,7 +17,7 @@
 ```
 fetch_creative_data → merge_creative_data → calculate_roi_metrics → get_ads_for_review
-                                   产出 metrics CSV(含 f_7日动态ROI、ad_age_days、
+                                   产出 metrics CSV(含 动态 ROI (7日均值)、ad_age_days、
                                    cost_7d_avg、stable_spend_days_30d、audience_tier、
                                    tier_roi_p25/p50/p75、tier_fission_mean 等)
 ```
@@ -27,11 +27,11 @@ fetch_creative_data → merge_creative_data → calculate_roi_metrics → get_ad
 
 | 标记 | 含义 | 条件(核心) |
 |------|------|------|
-| `roi_low` | ROI 偏低,需评估关停 | `f_roi < roi_mean × 0.8` 且 `yesterday_cost ≥ 300` 且 `ad_age > 3` |
+| `roi_low` | ROI 偏低,需评估关停 | `dynamic_roi_7d < channel_roi_p50 × 0.8` 且 `yesterday_cost ≥ 300` 且 `ad_age > 3` |
 | `decay_signal` | 衰退(调过价/换过创意但仍低迷) | `stable_days ≥ 7` 且 `cost_7d_avg < 100` 且(7 天内调过价 或 换过创意) |
-| `bid_up_candidate` | 可提价(A 或 B 命中即可) | **A 唤醒沉默**:`ad_age ∈ [4,7]` 且 `cost_7d_avg < 10` 且 CTR ≥ 同类均值×0.80<br>**B 优质放量**:`ad_age ∈ [4,7]` 且 `f_roi > roi_mean × 1.05` 且 `cost_7d_avg < 1000` 且裂变 > 同类均值×1.10 且 CTR ≥ 同类均值×0.80 |
-| `bid_down_candidate` | 可降价 | `roi_mean × 0.75 ≤ f_roi < roi_mean × 0.9` 且 `cost_7d_avg ≥ 500` 且裂变 < 同类均值×0.90 |
-| `scale_up_candidate` | 可扩量 | `ad_age > 7` 且 `stable_days ≥ 7` 且 `cost_7d_avg > 1000` 且 `f_roi ≥ roi_mean × 0.9` |
+| `bid_up_candidate` | 可提价(A 或 B 命中即可) | **A 唤醒沉默**:`ad_age ∈ [4,7]` 且 `cost_7d_avg < 10` 且 CTR ≥ 同类均值×0.80<br>**B 优质放量**:`ad_age ∈ [4,7]` 且 `dynamic_roi_7d > channel_roi_p50 × 1.05` 且 `cost_7d_avg < 1000` 且裂变 > 同类均值×1.10 且 CTR ≥ 同类均值×0.80 |
+| `bid_down_candidate` | 可降价 | `channel_roi_p50 × 0.75 ≤ dynamic_roi_7d < channel_roi_p50 × 0.9` 且 `cost_7d_avg ≥ 500` 且裂变 < 同类均值×0.90 |
+| `scale_up_candidate` | 可扩量 | `ad_age > 7` 且 `stable_days ≥ 7` 且 `cost_7d_avg > 1000` 且 `dynamic_roi_7d ≥ channel_roi_p50 × 0.9` |
 
 另外有一个升级路径 `persistent_low_roi`(`ad_decision.py:464-482`):ROI 在 0.75~0.90 之间、距上次降价 ≥ 7 天 → 把 `roi_low` 从 False 升级为 True(从"降价候选"升级为"关停候选")。
 
@@ -149,7 +149,7 @@ Agent 读 Skills(roi_strategy.md / guardrail_rules.md / ad_domain.md)+ `need
 
 | # | 规则 | 条件 | 动作 | 位置 |
 |---|------|------|------|------|
-| 1.5 | **ROI 低消耗门槛** | 即使 `f_roi < roi_mean × 0.8`,若 `yesterday_cost < 300` 或 `ad_age ≤ 3` → **`roi_low` 置 False** | 不触发 LLM 评估,归入"正常运行" | `ad_decision.py:405-410` |
+| 1.5 | **ROI 低消耗门槛** | 即使 `dynamic_roi_7d < channel_roi_p50 × 0.8`,若 `yesterday_cost < 300` 或 `ad_age ≤ 3` → **`roi_low` 置 False** | 不触发 LLM 评估,归入"正常运行" | `ad_decision.py:405-410` |
 | 1.6 | **成熟期消耗不稳定清负向** | `ad_age > 7` 且 `stable_days < 7` 且有负向标记 | 强制把 `roi_low/decay/bid_down_candidate` 全置 False | `ad_decision.py:495-504` |
 
 涉及常量:`ROI_LOW_MIN_YESTERDAY_COST=300`、`STABLE_SPEND_THRESHOLD=100`。

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

@@ -47,7 +47,7 @@ $system$
 **数据获取**(必须按顺序):
 - `fetch_creative_data(days, end_date)` — 从 ODPS 拉原始数据 + 广告状态快照
 - `merge_creative_data(days, force)` — 合并到 outputs/merged/(依赖 fetch)
-- `calculate_roi_metrics(end_date)` — 计算 f_7日动态ROI(依赖 merge;**不会自动拉数据**,缺 merge 会得到错误结果)
+- `calculate_roi_metrics(end_date)` — 计算动态 ROI (7日均值)(依赖 merge;**不会自动拉数据**,缺 merge 会得到错误结果)
 - `get_ads_for_review(metrics_csv)` — 三级分类(零消耗/待评估/正常)
 - `query_ad_detail(ad_id, metrics_csv)` — 单广告详情 + 全局上下文
 
@@ -101,7 +101,7 @@ Step 10: generate_report          ← 生成报告
 **`apply_decisions` 是覆盖式工具,只调一次,必须包含所有候选**——遗漏的会被默认 `hold` 覆盖(已实测 bid_down 被吞 bug)。**宁可 reason 写短,也要全部覆盖**。
 
 **reason 写法**对齐范例风格(紧凑、单句、含核心数值即可):
-> "ROI 4.42,高于 R330 组中位数 3.48 的 27%;投放 266 天,消耗稳定 21 天;建议扩量。"
+> "动态 ROI 为 4.42,高于渠道P50 3.48 的 27%;投放 266 天,消耗稳定 21 天;建议扩量。"
 
 **禁止**:多次调 `apply_decisions`(后调吞前调)、`agent(task=...)` 委托子 Agent(拿不回结构化决策)。
 
@@ -131,6 +131,34 @@ Step 10: generate_report          ← 生成报告
 - 引用具体数值(ROI/阈值/消耗),用分号连接多个判断
 - `confidence` 与数据支撑度一致;`recommended_change_pct` 为小数(+0.05=提5%),单次绝对值 ≤ 0.10
 
+### 🔒 reason 5 元组硬模板(每条 reason 必含)
+
+**每条 reason 必须显式包含以下 5 个语义元素**,缺任一项视为不合格:
+
+| # | 元素 | 说明 | 正例措辞 |
+|---|------|------|---------|
+| 1 | **ROI 数值** | 给出具体动态 ROI 值(保留 2 位小数)| "动态 ROI 为 2.18" |
+| 2 | **对比基准** | 明确是渠道P50(ROI)或同类均值(裂变/CTR)| "低于渠道P50 2.50" |
+| 3 | **偏离百分比** | 偏离基准的比例(±X%)| "低 13%" |
+| 4 | **辅助信号** | 调价历史 / 创意变化 / 广告年龄 / 7日均消耗 / 置信度 至少一项 | "7 天内已提价但 ROI 仍低迷;投放 14 天" |
+| 5 | **行动建议** | action 的业务解释(不是重复 action 名)| "建议降 5% 优化成本" |
+
+**标准模板**:
+
+```
+{动态 ROI 数值},{对比基准 + 偏离百分比};{辅助信号};{行动建议}
+```
+
+**合格样本**:
+> "动态 ROI 为 1.62,低于渠道P50 2.50 的 35%;7 天内已提价但 ROI 仍低迷,广告已投放 9 天、7 日日均消耗 4438 元属于高消耗;综合判断调价无效,建议关停释放预算"
+
+(✅ 元素齐全:ROI=1.62 / 对比渠道P50 2.50 / 偏离 -35% / 辅助信号=已提价+年龄+消耗 / 建议=关停释放预算)
+
+**不合格样本**:
+> ❌ "ROI 低于关停线,建议关停"(缺元素 1/2/3/4,只有行动)
+> ❌ "动态ROI=1.62 < pause_line(1.66), bid_increased_7d=true"(用英文变量名,违反硬约束)
+> ❌ "ROI 不好,建议降价"(缺数值、基准、偏离%、辅助信号)
+
 ## 🚨 决策一致性自检表(发出决策前必过)
 
 **每次写完一条决策,在提交前务必对照这张表自检。任何一条不满足就把决策改对,不要交差。**
@@ -154,6 +182,7 @@ Step 10: generate_report          ← 生成报告
 | 动态ROI ≥ 渠道P50 × 1.20 且 7日均消耗 ≥ 1000 | `hold`/`bid_up`/`scale_up` | ❌ `pause`/`bid_down` | 超高 ROI + 高消耗是明星广告,严禁误伤 |
 | 动态ROI < 渠道P50 × 0.50(关停线以下)| `pause` 首选 | ❌ `bid_up`/`scale_up` | 低效广告不能加码 |
 | 裂变率 fission_rate < 同类均值 × 0.5 且 ROI 也低 | `pause` 优先 | ❌ `bid_up` | 裂变弱 + ROI 低的双低广告无挽救价值 |
+| action = `pause` | reason 必须引「裂变率 vs 同类均值」对比 | ❌ 只写 ROI 就 `pause` | 关停最激进,单靠 ROI 证据不足;数据缺失时也要显式写"裂变数据缺失" |
 
 ### C. reason 与 action 语义一致
 
@@ -191,16 +220,35 @@ Step 10: generate_report          ← 生成报告
 
 # 第七部分:投放经验知识库(Skills)
 
-你有 4 份 skill 可以依据(由框架自动注入,不需要主动查询):
+你有 7 份 skill 可以依据(由框架自动注入,不需要主动查询)。按使用时机分三类
 
+### 📘 基础概念(始终可查)
 - **ad-domain** — 业务概念与指标(ROI 公式、人群包 R 值含义、关键字段映射)
-- **roi-strategy** — 决策经验(同类对比原则、年龄保护、置信度评估、6 种 action 判断、后验经验、反例警示)⭐
-- **guardrail-rules** — 安全护栏(冷启动保护、调价频率、出价上下限)
 - **tencent-ad-playbook** — 腾讯广告平台硬规则(oCPM 学习期、降价 ≤30%、少广告多素材、数据口径)
 
+### 🎯 决策四件套(每次决策必读)
+| skill | 何时查阅 | 核心内容 |
+|-------|---------|---------|
+| **roi-baseline** ⭐ | **每次决策开始前**(一次就够)| 对比基准口径(ROI 看渠道P50,裂变/CTR 看同类均值)+ 三条阈值线符号定义 |
+| **action-playbook** ⭐ | **选 action 前** | 7 种 action 决策树 + 判断矩阵 + pct 幅度规则 |
+| **age-protection** | 看到 `ad_age_days ≤ 7` 时 | 三段式(新生/冷启动/成熟)允许与禁止操作 |
+| **posterior-wisdom** | reason 要解释"为什么观察而非操作"时 | 学习中断/降价恢复期/创意冷启动/ROI 置信度分级 |
+
+### 🛡️ 系统护栏(了解即可,不主动查)
+- **guardrail-rules** — 安全护栏(冷启动保护、调价频率、出价上下限)
+- **roi-strategy** — 总索引(历史入口,已拆分为上面 4 份,直接查对应子 skill)
+
 Skill 提供「判断原则」,工具提供「数据」,你负责综合判断。
 
-**冲突优先级**:当两份 skill 说法冲突时,按 `tencent-ad-playbook > roi-strategy > ad-domain` 取舍(平台硬规则 > 业务经验 > 基础概念)。
+**冲突优先级**(越上越硬):
+```
+tencent-ad-playbook  (平台硬规则,违反直接掉量)
+  > age-protection    (年龄保护,代码三层实现)
+  > roi-baseline      (业务阈值线定义)
+  > action-playbook   (action 选择规则)
+  > posterior-wisdom  (经验参考)
+  > ad-domain         (基础概念)
+```
 
 # 第八部分:与您的对话
 

+ 230 - 0
examples/auto_put_ad_mini/skills/action_playbook.md

@@ -0,0 +1,230 @@
+---
+name: action-playbook
+description: 7 种 action 的决策树与判断矩阵——pause / bid_down / bid_up / scale_up / creative_adjust / observe / hold 的触发条件与互斥边界
+---
+
+# 决策动作手册
+
+> **前置阅读**:`roi-baseline`(对比基准与阈值线定义)、`age-protection`(年龄保护三段式)
+
+---
+
+## 一、action 决策树(提交决策前按这棵树走)
+
+```
+数据到手
+  │
+  ├─ 年龄 ≤ 3 天(新生期)?
+  │   └─ 是 → hold(系统已代管,你不会看到这类广告)
+  │
+  ├─ 年龄 4-7 天(冷启动期)?
+  │   ├─ 满足提价分支 A 或 B? → bid_up
+  │   ├─ CTR 异常差? → observe 或 creative_adjust
+  │   └─ 其他 → hold(年龄保护,不降价不关停)
+  │
+  └─ 年龄 > 7 天(成熟期)
+      │
+      ├─ 创意数 < 5 ? → observe(少素材期)
+      │
+      ├─ ROI 置信度低(roi_valid_days < 3)? → observe 或 hold
+      │
+      ├─ 动态ROI < 关停线(渠道P50 × 0.75)?
+      │   ├─ 昨日消耗 ≥ 300 元 → pause(明确低效)
+      │   └─ 否 → hold(消耗太低数据不可靠)
+      │
+      ├─ 动态ROI < 降价线(渠道P50 × 0.90)?
+      │   ├─ 7日均消耗 ≥ 500 元
+      │   │   ├─ 7 天内已调价? → observe(避免频繁)
+      │   │   ├─ 7 天内换过创意? → observe(等稳定)
+      │   │   └─ 否 → bid_down(3%-5%)
+      │   └─ 否 → hold 或 creative_adjust
+      │
+      ├─ 动态ROI ≥ 提价线(渠道P50 × 1.05)?
+      │   ├─ 成熟稳定 + 消耗高(>1000/d)+ ROI 正常 → scale_up(建议扩量)
+      │   ├─ 成熟期(>7d)不再提价(投手经验:稳定期不调出价)→ hold
+      │   └─ (提价仅在冷启动期 4-7d 使用)
+      │
+      └─ 其他 → hold
+```
+
+---
+
+## 二、七种 action 判定矩阵
+
+### 1. `pause`(关停)
+
+**触发**:明确低效,需释放预算
+
+**条件(全部满足)**:
+- ✅ 年龄 > 7 天(硬规则:冷启动期不评估关停)
+- ✅ 昨日消耗 ≥ 300 元(数据可信度门槛)
+- ✅ 动态ROI < 关停线(渠道P50 × 0.75)
+- ✅ (强化信号,可选)`persistent_low_roi=True`:之前降过价但 ROI 仍低迷
+- 🔒 **reason 硬要求**:必须包含「裂变率 X.XX vs 同类均值 Y.YY(偏离 Z%)」对比;若 `ad_fission` 或 `tier_fission_mean` 缺失,显式写"裂变数据缺失",不得省略
+
+**禁用于**:
+- ❌ 年龄 ≤ 7 天(年龄保护)
+- ❌ 昨日消耗 < 300 元(数据不可信)
+- ❌ 冷启动期的任何广告
+
+**pct 要求**:= 0(pause 不改出价)
+
+---
+
+### 2. `bid_down`(降价)
+
+**触发**:ROI 偏低但未到关停线,有优化空间
+
+**条件(全部满足)**:
+- ✅ 年龄 > 7 天
+- ✅ 7 日均消耗 ≥ 500 元
+- ✅ 关停线 ≤ 动态ROI < 降价线(在 [渠道P50×0.75, 渠道P50×0.90) 区间)
+- ✅ 裂变率低于同类均值 10% 或更多(辅助信号)
+
+**禁用于**:
+- ❌ 动态ROI ≥ 提价线(**代码护栏 B1 硬拦**)
+- ❌ 年龄 ≤ 7 天(冷启动保护)
+- ❌ 7 日均消耗 < 500 元(数据不可信,代码已过滤)
+- ❌ 近 7 天已降过价(观察期未过)
+- ❌ 近 7 天换过创意(数据仍在波动)
+
+**pct 要求**:负数,绝对值在 [3%, 5%](硬边界,护栏会钳位)
+
+**幅度选择**:
+- 低于渠道P50 10-12% → 降价 3%
+- 低于渠道P50 12-15% → 降价 4%
+- 低于渠道P50 15-25%(接近关停线)→ 降价 5%(上限)
+
+> ⚠️ 更严重的低效走 pause,不要用"大幅降价"代替关停。
+
+---
+
+### 3. `bid_up`(提价)
+
+**触发**:有两条独立分支(OR 关系),命中任一即可
+
+**分支 A:唤醒沉默**(低消耗角度)
+- ✅ 年龄 4-7 天
+- ✅ 7 日均消耗 < 10 元(系统冷处理)
+- ✅ CTR ≥ 同类均值 × 0.8
+
+**分支 B:优质放量**(ROI + 裂变角度)
+- ✅ 年龄 4-7 天
+- ✅ 动态ROI > 提价线(渠道P50 × 1.05)
+- ✅ 裂变率 > 同类均值 × 1.10
+- ✅ 7 日均消耗 < 1000 元
+- ✅ CTR ≥ 同类均值 × 0.8
+
+**禁用于**:
+- ❌ 动态ROI < 关停线(**代码护栏 B2 硬拦**)
+- ❌ 年龄 > 7 天(投手经验:成熟期不再提价,改走 scale_up)
+- ❌ 年龄 ≤ 3 天(冷启动保护)
+
+**pct 要求**:正数,绝对值在 [5%, 10%]
+
+**幅度选择**:
+- 高于渠道P50 5-7% → 提价 5%
+- 高于渠道P50 7-10% → 提价 8%
+- 高于渠道P50 10% 以上 → 提价 10%
+- 分支 A 默认下限 5%(数据不足,试探性)
+
+---
+
+### 4. `scale_up`(扩量建议)
+
+**触发**:成熟优质广告,建议复制或增加资源
+
+**条件(全部满足)**:
+- ✅ 年龄 > 7 天
+- ✅ 稳定消耗天数(30 天内)≥ 7
+- ✅ 7 日均消耗 > 1000 元
+- ✅ 动态ROI ≥ 降价线(渠道P50 × 0.90)
+
+**禁用于**:
+- ❌ 动态ROI < 降价线(**代码护栏 B3 硬拦,改 observe**)
+- ❌ 新广告或稳定性不足
+
+**pct 要求**:= 0(scale_up 不直接改出价,由运营新增广告/创意)
+
+---
+
+### 5. `creative_adjust`(调整素材)
+
+**触发**:ROI 正常但素材吸引力不足
+
+**典型场景**:
+- ROI 达标(≥ 降价线)但日均消耗 < 100 元 → 素材吸引力弱
+- 7 天内已换创意但裂变率仍明显低于同类均值(低 15%+)
+- CTR + CVR 同时下跌 > 25%(素材疲劳)
+
+**与其他 action 区别**:
+- vs `hold`:hold 认可现状,creative_adjust 认为 ROI 可以但需改素材
+- vs `bid_up`:bid_up 提价拉量,creative_adjust 优化素材本身
+- vs `pause`:素材疲劳优先 creative_adjust,避免丢掉 oCPM 学习资产
+
+**pct 要求**:= 0(不改出价)
+**执行方式**:⚠️ 不调 API,仅在审批表标识,由运营人工换素材
+
+---
+
+### 6. `observe`(观察等待)
+
+**触发**:数据不稳定或接近阈值边界
+
+**典型场景**:
+- ROI 有效天数 < 7(置信度低)
+- ROI 在阈值线 ±5% 范围内徘徊
+- 年龄 4-7 天 + 消耗波动大
+- 近 7 天已调价或换创意
+
+**与其他 action 区别**:
+- vs `hold`:hold 长期保持,observe 短期复查(2-3 天后系统自动重新评估)
+
+**pct 要求**:= 0(**代码护栏 A3 硬拦**)
+
+---
+
+### 7. `hold`(保持不变)
+
+**触发**:无异常信号,或硬规则已过滤的场景
+
+**典型场景**:
+- 无调整信号,保持现状
+- 冷启动期(≤3 天)— 默认 hold
+- 数据不足决策 — 默认 hold
+
+**pct 要求**:= 0(**代码护栏 A3 硬拦**)
+
+---
+
+## 三、同一场景的 action 选择优先级
+
+当多个 action 都"合理"时,按以下优先级取:
+
+```
+pause(明确低效)
+  > bid_down(有改善空间)
+  > creative_adjust(素材问题)
+  > observe(数据待稳定)
+  > hold(无异常)
+
+bid_up(冷启动期优质)
+  > scale_up(成熟期优质)
+```
+
+**经验法则**:
+- 不确定就选保守的(`observe`/`hold` 优于 `pause`/`bid_down`)
+- 能用 `creative_adjust` 解决的问题,不要用 `pause`(保留学习资产)
+- 降价和关停之间,优先关停(干净的止损),不要用"大幅降价"代替
+
+---
+
+## 四、决策前的 5 秒自检(内心默问)
+
+1. 我这条 action 和 pct 数字方向对得上吗?(降价↔负数,提价↔正数,维持↔零)
+2. 我的 reason 结论和 action 语义一致吗?(说"优秀"还建议降价吗?)
+3. 如果这个广告 ROI > 提价线,我真的要降价/暂停吗?
+4. 不确定时有没有更保守的选择(hold/observe)?
+5. 我的 reason 里包含了 5 元素吗?(ROI 值 / 对比基准 / 偏离% / 辅助信号 / 行动建议)
+
+> 任一条不满足就把决策改对,不要依赖护栏兜底——让护栏 0 告警是目标。

+ 6 - 6
examples/auto_put_ad_mini/skills/ad_domain.md

@@ -35,7 +35,7 @@ category: ad_optimization
 所以不是"获客→种子→传播→变现"的线性漏斗,**每一层都在变现**。
 ROI 不能只用"当日首层收益/消耗"衡量,要综合**当日+后续多日**的累积产出。
 
-这正是 f_7日动态ROI 被设计出来的原因 —— 通过 7 日滚动均值的裂变稳定因子,
+这正是 动态 ROI (7日均值) 被设计出来的原因 —— 通过 7 日滚动均值的裂变稳定因子,
 把"后续几天仍在产生的价值"折算进今天的决策里。
 
 ## 核心字段定义
@@ -44,7 +44,7 @@ ROI 不能只用"当日首层收益/消耗"衡量,要综合**当日+后续多
 
 | 字段 | 含义 | 单位 | 来源 |
 |------|------|------|------|
-| 动态ROI_7日均值 | 7日滚动均值的 f_7日动态ROI,决策参考值 | 比率 | roi_calculator |
+| 动态ROI_7日均值 | 7日滚动均值的 动态 ROI (7日均值),决策参考值 | 比率 | roi_calculator |
 | cost_7d_avg | 近 7 日日均消耗 | 元 | 计算 |
 | ad_age_days | 广告投放天数 | 天 | create_time 计算 |
 | bid_amount | 当前出价 | 元 | 广告属性 |
@@ -79,8 +79,8 @@ ROI = 收入 / 消耗
 当日回流倍数   = 总回流人数 / 首层打开数
 裂变效率稳定因子 = 回流倍数_7日均值 / T0裂变系数_7日均值
 
-f_7日动态ROI = 当日裂变收益率 × 裂变效率稳定因子
-动态ROI_7日均值 = mean(f_7日动态ROI) over 最近 7 天
+动态 ROI (7日均值) = 当日裂变收益率 × 裂变效率稳定因子
+动态ROI_7日均值 = mean(动态 ROI (7日均值)) over 最近 7 天
 ```
 
 **为什么用这个**:综合考虑了当日收益 + 7 日裂变稳定性,能反映高 R 值人群的长期价值。
@@ -108,7 +108,7 @@ Day 7~30:         持续贡献
 ```
 
 如果只统计**当天 T0** 的收益除以消耗,会**严重低估** R 值高的广告 —— 因为它们大部分价值还没发生。
-这就是为什么决策必须用 **f_7日动态ROI 的 7 日滚动均值**,把后续多天的持续带人和回流变现折算进来。
+这就是为什么决策必须用 **动态 ROI (7日均值) 的 7 日滚动均值**,把后续多天的持续带人和回流变现折算进来。
 
 ### R 值对照表
 
@@ -127,7 +127,7 @@ Day 7~30:         持续贡献
 > 看 R500/R330+ 广告的**当日单日 ROI** 几乎**必然偏低**——这不是广告差,
 > 而是它的价值主要在后续几天的持续带人和回流变现。
 >
-> **正确看法**:永远用 `动态ROI_7日均值`(f_7日动态ROI 的 7 日滚动均值)做判断,
+> **正确看法**:永远用 `动态ROI_7日均值`(动态 ROI (7日均值) 的 7 日滚动均值)做判断,
 > 它已经通过"裂变效率稳定因子"把后续多天的价值折算进来。
 
 **决策建议**:

+ 128 - 0
examples/auto_put_ad_mini/skills/age_protection.md

@@ -0,0 +1,128 @@
+---
+name: age-protection
+description: 广告三段式年龄保护机制——新生期/冷启动期/成熟期的允许操作与禁止操作
+---
+
+# 广告年龄保护机制
+
+> **配置源**:`config.py::COLD_START_DAYS` (默认 3)、`EARLY_GROWTH_DAYS` (默认 7)
+> **代码实现**:`tools/ad_decision.py::get_ads_for_review` 的年龄分层逻辑 + `tools/guardrails.py::ColdStartGuardrail` 兜底
+
+---
+
+## 三段式架构一览
+
+| 年龄段 | 天数 | age_segment | 允许操作 | 禁止操作 | 保护级别 |
+|--------|------|-------------|---------|---------|---------|
+| 新生期 | ≤3 | `newborn` | ⚠️ 零消耗自动关停(硬规则)| 所有主动调整 | 极度保护 |
+| 冷启动期 | 4-7 | `cold_start` | bid_up / observe / creative_adjust | bid_down / pause | 仅允许提价 |
+| 成熟期 | >7 | `mature` | bid_down / pause / scale_up / hold / observe | **bid_up**(走 scale_up 代替)| 正常调控 |
+
+---
+
+## 一、新生期(≤3 天)— 极度保护
+
+### 特征
+- 系统刚开始学习用户画像
+- 数据量少,ROI 波动大
+- 任何调整都会打断学习周期
+
+### 策略
+- ✅ 极度保护,几乎不干预
+- ❌ 不降价(避免打断学习)
+- ❌ 不提价(避免加速消耗)
+- ❌ 不关停(给予充分时间)
+- ⚠️ **唯一例外**:零消耗 7 日均值 < 10 元 → 自动关停(代码硬规则)
+
+### 数据标识
+- `age_segment`: `newborn`
+- `age_protection_level`: `极度保护`
+
+---
+
+## 二、冷启动期(4-7 天)— 仅允许提价
+
+### 特征
+- 系统正在学习用户画像
+- ROI 开始收敛,但仍有波动
+- **降价会打断学习,重新起跑**
+
+### 策略
+- ✅ **允许提价**(若 动态ROI > 提价线 且 裂变率 > 同类均值 × 1.10)
+- ❌ **不允许降价**(避免打断系统学习)
+- ❌ **不关停**(给予充分学习时间)
+- ✅ 允许 `observe`(数据不稳定时)
+- ✅ 允许 `creative_adjust`(素材优化不影响出价)
+
+### 数据标识
+- `age_segment`: `cold_start`
+- `age_protection_level`: `仅允许提价`
+- `allow_bid_down`: `False`
+- `allow_bid_up`: `True`
+
+### 系统自动保护
+如果 LLM 违规建议降价或关停,`ad_decision.py` 的年龄保护逻辑会强制清除这些负向候选标记,使该广告不进入 LLM 评估;即使进入评估,`ColdStartGuardrail` 会在护栏阶段拦截并转 `observe`:
+
+```
+LLM 建议: bid_down -5%
+系统转换: observe(年龄保护规则不允许降价/关停,改为观察)
+```
+
+---
+
+## 三、成熟期(>7 天)— 全面调控但不再提价
+
+### 特征
+- 数据充分(≥7 天),ROI 稳定
+- 系统学习完成
+- **稳定期不调出价**,通过新增广告/创意拿消耗(投手经验 1.2)
+
+### 策略
+- ✅ **可降价**,最大 5%(`BID_DOWN_MAX_PCT=0.05`)
+- ❌ **不提价**(稳定期不调出价,改走 scale_up 复制广告/增创意)
+- ✅ **可扩量**(scale_up,建议新增广告或创意拿消耗)
+- ✅ **可关停**(动态ROI < 关停线 且 昨日消耗 ≥ 300 元)
+- ✅ 可观察、可调整素材方向
+
+### 数据标识
+- `age_segment`: `mature`
+- `age_protection_level`: `正常调控`
+- `allow_bid_down`: `True`
+- `allow_bid_up`: `True`(但不推荐用,推荐 scale_up)
+
+---
+
+## 四、特殊情况:高燃烧预警 🔥
+
+**触发条件**:`high_burn_alert=True`
+- 年龄 > 3 天
+- 昨日消耗 > 300 元(单广告维度)
+
+### 说明
+即使在成熟期,单日消耗过高需要重点关注,可能存在:
+- 定向过宽,流量失控
+- 出价过高,竞争力过强
+- 创意吸引过度点击但转化低
+
+### 策略
+- 优先评估 ROI 是否正常
+- ROI 正常 + 高燃烧 → 可能是优质广告,考虑适当降价控制节奏
+- ROI 偏低 + 高燃烧 → 立即降价或关停
+
+---
+
+## 五、年龄保护的三层实现(避免漏洞)
+
+1. **第 1 层:候选标记阶段**(`ad_decision.py` L553-580)
+   - 冷启动期(≤3d):所有广告 skip,不进入 LLM 评估
+   - 早期成长期(4-7d):检查是否仅有正向候选(bid_up/scale_up),否则 skip
+
+2. **第 2 层:LLM 决策阶段**(prompt + action-playbook skill)
+   - Prompt 明确告诉 LLM "冷启动期只允许 bid_up/observe/creative_adjust"
+   - action-playbook 的决策树第一分支就走年龄检查
+
+3. **第 3 层:护栏兜底阶段**(`guardrails.py::ColdStartGuardrail`)
+   - 即使前两层漏过,护栏会把 cold_start 期的 pause/bid_down 强制拦截改 hold
+   - 输出日志告警供事后复盘
+
+**三层冗余设计的目的**:任何一层漏洞都不会导致事故。这是广告投放系统的"故障容忍"架构,与飞机的多重液压系统同理。

+ 136 - 0
examples/auto_put_ad_mini/skills/posterior_wisdom.md

@@ -0,0 +1,136 @@
+---
+name: posterior-wisdom
+description: 投放后验经验——连续调价的学习中断/降价后的流量恢复期/创意更换的冷启动/ROI 数据置信度分级
+---
+
+# 投放后验经验(解释"为什么不立即操作")
+
+> **使用时机**:reason 里需要解释"为什么建议观察而非立即调价/关停"时查阅本 skill。
+
+---
+
+## 一、连续调价的学习中断(系统稳定性原理)
+
+### 理论基础
+每次调价都会触发系统重新学习,频繁调价会导致:
+- 学习周期被打断,无法收敛到最优状态
+- 系统在多个出价点之间徘徊,效率降低
+
+### 决策框架
+1. **避免短期内(7 天)频繁调价**(≥3 次)
+2. 每次调价后,**至少观察 5-7 天**再考虑下次调价
+3. 广告 ROI 好但已多次提价 → **暂缓提价,观察稳定性**
+4. 连续调价效果递减是 **系统原理**,不是偶然现象
+
+### 判断要点
+- 检查近 7 天调价历史(字段 `bid_increased_7d`、`adjustment_count_7d`)
+- 已调价 ≥2 次 → 倾向 observe 而非"继续调价"
+- 给系统足够的稳定周期
+
+---
+
+## 二、降价后的流量恢复期(竞价机制原理)
+
+### 理论基础
+降价后,系统需要重新分配流量(CPM 降低 → 曝光机会减少),ROI 改善需要时间:
+- 系统调整投放策略(人群、时段、版位)
+- 流量质量重新筛选
+
+### 理论预期
+- **即时效果**:降价不会立即改善 ROI(第 1 天可能没变化)
+- **延迟显现**:需要 2-5 天,ROI 才能稳定改善
+- **消耗下降**:降价后消耗通常会同步下降
+
+### 决策框架
+1. 降价后 **1-2 天内 ROI 未改善** → 正常,继续观察
+2. 降价后 **3-5 天仍无改善** → 可能问题不在出价,考虑其他因素(创意/定向)
+3. 降价后 **消耗大幅下降但 ROI 仍低** → 建议关停(低效广告)
+
+---
+
+## 三、创意更换的冷启动期(新素材学习原理)
+
+### 理论基础
+新创意素材需要冷启动学习期,系统需要:
+- 测试创意对不同人群的吸引力
+- 探索最优投放策略
+
+### 理论预期
+- **前 3-7 天**:数据波动大,ROI 可能忽高忽低
+- **7 天后**:效果趋于稳定,可以评估创意质量
+
+### 决策框架
+1. 广告**近 7 天内换过创意** → 所有数据仅供参考
+2. 创意冷启动期内:
+   - ROI 突然很高 → 不要立即提价(可能是偶然)
+   - ROI 突然很低 → 不要立即关停(系统还在学习)
+3. **至少观察 7 天**后,再基于新创意数据做决策
+
+### 判断要点
+- 检查字段 `creative_changed_7d`(布尔)或 `creative_days_since_change`
+- < 7 天 → 谨慎决策,不要大幅调整
+- ≥ 7 天 → 数据已稳定,可以正常决策
+
+---
+
+## 四、ROI 数据置信度分级
+
+由于支持"不足 7 天用几天"的 ROI 计算(`min_periods=3`),需要根据有效数据天数评估置信度。
+
+### 置信度分级
+
+| roi_valid_days | 置信度 | 决策建议 |
+|---------------|--------|---------|
+| ≥ 7 天 | 高 | 可正常决策 |
+| 4-6 天 | 中 | 谨慎决策,避免激进操作 |
+| 3 天 | 低 | 仅做保守决策(明显异常才关停) |
+| < 3 天 | 无 | ROI=NaN,无法决策 |
+
+### 置信度对决策的影响
+
+**高置信度(≥7 天)**:
+- 可执行正常降价幅度(3%-5%)
+- 可执行正常提价幅度(5%-10%)
+- 可做关停决策
+
+**中等置信度(4-6 天)**:
+- 降价幅度取下限(3%)而非上限(5%)
+- 提价谨慎(优先 5%,不用 8%-10%)
+- 关停需更严格条件(远低于 25%,而非刚到关停线)
+
+**低置信度(3 天)**:
+- 优先 `observe` 而非 bid_down/bid_up
+- 仅明显异常才关停(动态ROI 低于渠道P50 × 0.5 以下)
+- reason 中必须说明"数据不足"
+
+---
+
+## 五、周期性效应(尚未验证但值得警觉)
+
+### 周末效应
+- 周六日流量构成与工作日不同,ROI 可能自然偏高或偏低
+- 不要基于单个周末的数据做关停/大幅降价决策
+- 建议看"过去 2 个周末 + 5 个工作日" 的整体趋势
+
+### 月初/月末效应
+- 月初广告主预算充足,竞争激烈 → CPM 偏高
+- 月末预算紧张,竞争降低 → CPM 偏低
+- 这些是行业普遍现象,不是单广告问题
+
+**当前系统态**:以上周期性规律已在 reason 写作中提及,但尚无代码自动折算。判断时主动考虑日历因素。
+
+---
+
+## 六、反例警示(避免模板化的 reason)
+
+### 反例 1:只写结论不写依据
+**❌ 错**:"ROI 为 1.80,低于降价线 2.65,建议降 5%"
+**✅ 对**:"动态 ROI 为 1.80,低于渠道P50 2.65 的 32%;7 天内已提价但 ROI 仍低迷,判断调价无效;7 日日均消耗 4438 元属于高消耗,建议关停"
+
+### 反例 2:忽略数据置信度
+**❌ 错**:"ROI=1.25 < 关停线(1.36),建议关停"
+**✅ 对**:"动态 ROI 为 1.25,低于关停线 1.36;但 30 天内仅 3 天消耗稳定数据波动较大,且广告仅投放 7 天仍在学习期,建议观察而非立即关停"
+
+### 反例 3:混用英文变量名
+**❌ 错**:"动态ROI=1.62 略低于 pause_line(1.66),bid_increased_7d=true 但 ROI 仍低迷,ad_age=9 天,判断为调价无效"
+**✅ 对**:"动态 ROI 为 1.62,略低于关停线 1.66;7 天内已提价但 ROI 仍低迷;广告已投放 9 天,7 日日均消耗 4438 元属于高消耗广告;综合判断调价无效,建议关停"

+ 80 - 0
examples/auto_put_ad_mini/skills/roi_baseline.md

@@ -0,0 +1,80 @@
+---
+name: roi-baseline
+description: ROI 与裂变率的对比基准口径——哪些维度对比渠道 vs 哪些对比同类,以及三条阈值线(关停/降价/提价)的定义
+---
+
+# ROI 基准口径(决策前必读)
+
+> **单一真源(SSOT)**:所有阈值系数以 `config.py` 为准。本文档只描述"概念"与"命名",数值请以代码为准,不在此硬编码。
+
+---
+
+## 一、核心口径(最重要)
+
+**不同维度对比的基准不同,严禁混用:**
+
+| 维度 | 对比基准 | 数据字段 | 业务含义 |
+|------|---------|---------|---------|
+| 动态 ROI | **渠道P50** | `channel_roi_p50` | 全体广告"动态ROI_7日均值"的中位数 = 全渠道整体水位 |
+| 裂变率(fission_rate) | **同类均值** | `tier_fission_mean` | 同人群包 R 值的裂变水位 |
+| CTR | **同类均值** | 同类 | 同人群的曝光质量 |
+
+**"渠道P50" 的两层聚合结构(理解这层对 reason 措辞至关重要)**:
+
+1. **第 1 层(广告内部,时间维度)**:每个广告先把每日动态ROI 做 7 日滚动均值
+2. **第 2 层(渠道整体,跨广告维度)**:全体广告的 7 日均值取**中位数(P50)**
+
+---
+
+## 二、为什么 ROI 必须看渠道
+
+- 渠道整体水位(`channel_roi_p50`)反映当前大盘的"合理回报"
+- 某人群同类中位数 **不是**业务意义上的"达标线"——低价值人群同类中位数低,不代表这类广告"达标"
+- 运营视角:预算是跨人群共享的,低于渠道P50 就是跑不出渠道平均效率,应优化或淘汰
+
+## 三、为什么裂变率必须看同类
+
+- 不同人群的裂变天然不同(R500 高价值人群裂变弱、R50 宽泛人群裂变强)
+- 跨人群比裂变率等于"比苹果和橘子"
+- 只有同 R 值人群的裂变均值才能衡量"这条广告在它的人群里裂变强不强"
+
+---
+
+## 四、三条阈值线(以符号表述,数值见 config.py)
+
+| 线名 | 定义 | 对应 config.py 系数 | 业务含义 |
+|------|------|---------------------|---------|
+| **关停线** | 渠道P50 × `ROI_LOW_FACTOR` | 0.75 | ROI 低于此线→考虑 pause |
+| **降价线** | 渠道P50 × `BID_DOWN_ROI_FACTOR` | 0.90 | ROI 低于此线→考虑 bid_down |
+| **提价线** | 渠道P50 × `BID_UP_ROI_FACTOR` | 1.05 | ROI 高于此线→考虑 bid_up |
+
+**硬约束**(代码护栏已实现,违反会被 `ActionConsistencyGuardrail` 拦截):
+- ✅ `bid_down` 要求:动态ROI **< 提价线**(否则矛盾:优于渠道还降价)
+- ✅ `bid_up` 要求:动态ROI **≥ 关停线**(否则矛盾:低效还加码)
+- ✅ `scale_up` 要求:动态ROI **≥ 降价线**(扩量先证明效率达标)
+
+---
+
+## 五、常见误用与反例
+
+> ❌ 错:「动态 ROI 低于 R330 组中位数 X%,建议降价」(基准对象错:ROI 不看同类)
+> ❌ 错:「动态 ROI 低于渠道单日 ROI 均值 X%,建议降价」(聚合方式错:单日 ≠ 7日均)
+> ❌ 错:「裂变率低于渠道平均 Y%,建议暂停」(基准对象错:裂变率应看同类)
+> ✅ 对:「动态 ROI 低于渠道P50 X%,且裂变率低于同类均值 Y%,建议降价」
+
+---
+
+## 六、reason 中的中文术语约定
+
+**禁止**在 reason 中出现英文变量名,统一用中文术语:
+
+| 英文变量(禁用) | 中文术语(必用) |
+|-----------------|-----------------|
+| `pause_line` | 关停线 |
+| `bid_down_line` | 降价线 |
+| `bid_up_line` | 提价线 |
+| `channel_roi_p50` / `roi_mean` / `tier_roi_p50` | 渠道P50 / 渠道中位数 |
+| `tier_fission_mean` | 同类均值 / 同类裂变均值 |
+| `bid_increased_7d` | 7 天内已提价 |
+| `creative_changed_7d` | 7 天内已换创意 |
+| `roi_valid_days` | ROI 有效天数 |

+ 30 - 694
examples/auto_put_ad_mini/skills/roi_strategy.md

@@ -1,718 +1,54 @@
 ---
 name: roi-strategy
-description: 广告调控决策手册 - 同类对比/年龄保护/置信度/6 种 action 判断原则与后验经验
+description: [已拆分] ROI 调控策略总索引——指向 roi-baseline / action-playbook / age-protection / posterior-wisdom 四份子 skill
 ---
 
-# 广告投放 ROI 调控策略
+# ROI 调控策略(已拆分为 4 份子 skill)
 
-> **核心原则**:不同人群包各自对比,基于后验经验灵活判断
+> **本 skill 已于 2026-04-21 拆分**,保留此索引文件是为了不破坏历史引用。
+> 实际内容已分布到 4 份专门 skill 中:
+
+| 子 skill | 关注点 | 何时查阅 |
+|---------|--------|---------|
+| `roi-baseline` | 对比基准口径(渠道 vs 同类)+ 三条阈值线定义 | **每次决策开始前**(读一次就够) |
+| `action-playbook` | 7 种 action 的决策树 + 判断矩阵 | **选 action 前** |
+| `age-protection` | 三段年龄策略(新生/冷启动/成熟) | 看到 `ad_age_days ≤ 7` 时 |
+| `posterior-wisdom` | 后验经验(学习中断/降价恢复/置信度分级) | reason 里需要解释"为什么观察而非操作"时 |
 
 ---
 
-## 决策纪律(必读,每次决策前复盘)
+## 决策纪律摘要(6 维度
 
-每条决策的 reason 必须体现 6 个维度的综合判断(与 prompt 第六部分对齐):
+每条决策的 reason 必须体现以下 6 个维度的综合判断:
 
 1. **调价历史** — 7 天内是否已调价?是否已证明无效?
 2. **创意变化** — 7 天内是否换过创意?消耗是否改善?
 3. **数据稳定性** — 30 天内稳定消耗天数是否 ≥ 7?
-4. **广告年龄** — 新生期(≤3) / 冷启动(4-7) / 成熟期(>7) 三段式
-5. **对比基准分离** — ROI 看渠道P50(`roi_mean`),裂变/CTR 看同类均值(`tier_fission_mean`),严禁用同类中位数判 ROI
-6. **ROI 数据置信度** — 基于 roi_valid_days 分级(≥7 高 / 4-6 中 / ≤3 低)
-
-### 反例警示(避免模板化)
-
-**❌ 错**:"ROI 为 1.80,低于降价线 2.65,建议降 5%"
-**✅ 对**:"动态 ROI 为 1.80,低于渠道P50 2.65 的 32%;7 天内已提价但 ROI 仍低迷,判断调价无效;7 日日均消耗 4438 元属于高消耗,建议关停"
-
-**❌ 错**:"ROI=1.25 < pause_line(1.36),建议关停"
-**✅ 对**:"动态 ROI 为 1.25,低于关停线 1.36;但 30 天内仅 3 天消耗稳定数据波动较大,且广告仅投放 7 天仍在学习期,建议观察而非立即关停"
-
-**❌ 错**(含英文变量名):"动态ROI=1.62 略低于 pause_line(1.66),bid_increased_7d=true 但 ROI 仍低迷,ad_age=9 天,判断为调价无效"
-**✅ 对**:"动态 ROI 为 1.62,略低于关停线 1.66;7 天内已提价但 ROI 仍低迷;广告已投放 9 天,7 日日均消耗 4438 元属于高消耗广告;综合判断调价无效,建议关停"
-
-> **硬约束**:reason 中禁止出现英文变量名(pause_line、bid_down_line、tier_roi_p50、bid_increased_7d 等),改用中文术语;reason 不得模板化(套用同一句式回答所有广告)。
-
-### 阈值基准的对比口径(关键)
-
-不同维度对比的基准不同,**严禁混用**:
-
-| 维度 | 对比基准 | 字段含义 |
-|------|---------|---------|
-| 动态ROI | **渠道P50(全体广告"动态ROI 7日均值"的中位数)** | 全渠道整体水位 |
-| 裂变率(fission_rate) | **同类均值(同人群包 tier_fission_mean)** | 同 R 值人群的裂变水位 |
-| CTR | **同类均值** | 同人群的曝光质量 |
-
-**"渠道P50" 完整含义(两层聚合,重要)**:
-1. **第 1 层(广告内部,时间)**:每个广告先把每日 f_动态ROI 做 7 日滚动均值
-2. **第 2 层(渠道整体,跨广告)**:全体广告的 7 日均值取**中位数(P50)**
-
-> ❌ 错:「动态 ROI 低于 R330 组中位数 X%,建议降价」(基准对象错)
-> ❌ 错:「动态 ROI 低于渠道单日 ROI 均值 X%,建议降价」(聚合方式错)
-> ✅ 对:「动态 ROI 低于渠道P50 X%,且裂变率低于同类均值 Y%,建议降价」
-
-理由:动态ROI 受广告整体竞争环境影响,必须看全渠道;裂变率反映人群质量,必须看同类才公平。
+4. **广告年龄** — 三段式(详见 `age-protection` skill)
+5. **对比基准** — ROI 看渠道P50,裂变/CTR 看同类均值(详见 `roi-baseline` skill)
+6. **ROI 置信度** — 基于 roi_valid_days 分级(详见 `posterior-wisdom` skill)
 
 ---
 
-## 一、对比基准分离原则(必须优先)⭐
-
-### 两类指标,两套基准
-
-| 指标 | 对比基准 | 原因 |
-|------|---------|------|
-| **动态 ROI** | **渠道P50**(`roi_mean` = 全体广告"7日均值"的中位数)| ROI 反映广告在整个竞价市场的效率,必须跨人群统一衡量 |
-| **裂变率 fission_rate** | **同类均值**(`tier_fission_mean` = 同人群包的裂变均值)| 裂变反映人群质量,不同 R 值人群裂变水位天然不同 |
-| **CTR** | **同类均值** | 曝光质量受人群影响 |
-
-> ⚠️ **常见误区**:历史上曾用"同人群 ROI 中位数"做 ROI 基准,导致 R500 广告 ROI 3.0(优于渠道但低于 R500 同类)被误判降价。现已全面修正为**渠道P50**。
-
-### 为什么 ROI 必须看渠道
-
-- 渠道整体水位(`roi_mean`)反映当前大盘的"合理回报"
-- 某人群同类中位数 **不是**业务意义上的"达标线" —— 低价值人群同类中位数低,不代表这类广告"达标"
-- 运营视角:预算是跨人群共享的,低于渠道P50 就是跑不出渠道平均效率,应优化或淘汰
-
-### 为什么裂变率必须看同类
-
-- 不同人群的裂变天然不同(R500 高价值人群裂变弱、R50 宽泛人群裂变强)
-- 跨人群比裂变率等于"比苹果和橘子"
-- 只有同 R 值人群的裂变均值才能衡量"这条广告在它的人群里裂变强不强"
-
----
-
-### 如何做对比(关键字段)
-
-**数据字段**:
-- `audience_tier`: 人群包类型(R500、R330、R100、R50 等)
-- `roi_mean`: **渠道P50**(全体广告"动态ROI 7日均值"的中位数)— ROI 唯一基准
-- `tier_fission_mean`: 同人群包的裂变率均值 — 裂变唯一基准
-- `dynamic_roi_7d`: 广告自身的 7 日动态 ROI
-
-**判断方法**:
-1. ROI 判断:`dynamic_roi_7d` vs `roi_mean`(渠道P50)
-2. 裂变判断:广告裂变率 vs `tier_fission_mean`(同类均值)
-3. 两个维度独立判断,不互相替代
-
-**案例理解 1:提价判断**
-
-场景:
-- 人群包:R500
-- 渠道动态ROI中位数(P50):2.50
-- 同类裂变均值:0.60
-- 广告 ROI:3.20
-- 广告裂变率:0.72
-
-分析过程:
-- ROI 对比:3.20 vs 渠道P50 2.50 → 高出 28%(远超 +5% 提价线)
-- 裂变率对比:0.72 vs 同类均值 0.60 → 高出 20%
-- 判断:ROI 在渠道里属优质表现,裂变率在同类中也优秀
-
-决策:**提价 8%**
-
-理由模板:
-> "动态 ROI 为 3.20,高于渠道P50 2.50 的 28%,在全渠道里属优质表现;
-> 裂变率 0.72 高于同类均值 0.60 的 20%,长期价值优秀;
-> 广告数据稳定,建议提价 8% 放大优质流量"
-
----
-
-**案例理解 2:降价判断**
-
-场景:
-- 人群包:R330
-- 渠道动态ROI中位数(P50):2.50
-- 同类裂变均值:0.58
-- 广告 ROI:2.20
-- 广告裂变率:0.50
-
-分析过程:
-- ROI 对比:2.20 vs 渠道P50 2.50 → 低 12%(落入 [-15%, -10%] 降价区间)
-- 裂变率对比:0.50 vs 同类均值 0.58 → 低 14%(同类裂变也偏低)
-- 判断:ROI 低于渠道但未到关停线,且裂变率在同类中偏低 → 降价优化
-
-决策:**降价 4%**
-
-理由模板:
-> "动态 ROI 为 2.20,低于渠道P50 2.50 的 12%,在全渠道中表现偏低;
-> 裂变率 0.50 低于同类均值 0.58 的 14%,长期价值一般;
-> 7 日均消耗 ≥ 500 元数据可信,建议降价 4%(硬边界 3%-5% 内)优化成本"
-
----
-
-**案例理解 3:关停判断**
-
-场景:
-- 人群包:R100
-- 渠道动态ROI中位数(P50):2.50
-- 广告 ROI:1.50
-- 消耗:日均 200 元
-
-分析过程:
-- ROI 对比:1.50 vs 渠道P50 2.50 → 低 40%(远低于关停线 ×0.75)
-- 判断:ROI 仅为渠道P50 的 60%,明显低效
-
-决策:**关停**
-
-理由模板:
-> "动态 ROI 为 1.50,低于渠道P50 2.50 的 40%,仅为渠道中位数的 60%;
-> 已远低于关停线(渠道P50 × 0.75 = 1.88),建议关停释放预算"
-
----
-
-## 二、决策判断阈值参考
-
-### 提价参考范围
-
-> **提价是「扩量」的一种**(另一种是 `scale_up` 新增资源)。本节定义"何时该用提价拉量"。
-> 提价候选有 **A、B 两条独立路径**(OR 关系),命中任一即标记 `bid_up_candidate=True`。
-
-#### 分支 A:唤醒沉默(低消耗角度)
-
-**适用场景**:广告还没跑起来,用提价信号试探系统是否愿意分发
-
-**触发条件**(4-7天窗口):
-- 广告年龄:4-7天(早期成长期)
-- 消耗信号:7日均消耗 < 10元(系统冷处理,量少到几乎为零)
-- 健康闸门:CTR 不低于同类均值的 80%(曝光本身没问题)
-
-**特点**:
-- 不看 ROI / 不看裂变(数据本来就不够,看了也是噪声)
-- 只要 CTR 健康 → 提价 5-10% 唤醒分发
-- 如果提价后仍无消耗,下一轮会自然被 `>7天 + 低消耗` 关停规则收掉
-
-#### 分支 B:优质放量(ROI + 裂变角度)
+## 冲突优先级
 
-**适用场景**:数据已证明这条广告优质,提价拉更多量
+当多份 skill 说法冲突时,按以下优先级取舍:
 
-**触发条件**(4-7天窗口):
-- 广告年龄:4-7天(早期成长期)
-- ROI:高于渠道均值 5%(`f_7日动态ROI > roi_mean × 1.05`)
-- 裂变率:高于同类均值 10%
-- 消耗:7日均消耗 < 1000元(已经很高的不需要再加)
-- 健康闸门:CTR 不低于同类均值的 80%
-
-**综合判断**:
-- ROI 和裂变率都优秀,且数据稳定 → **积极提价**
-- 只有 ROI 高但裂变率一般 → **谨慎提价**(可能短期波动)
-- 近期换过创意(< 7 天)→ **暂缓提价**(数据不稳定)
-
-#### 提价幅度(A、B 共用)
-
-- 高于**渠道P50** 5-7% → 提价 5%
-- 高于**渠道P50** 7-10% → 提价 8%
-- 高于**渠道P50** 10% 以上 → 提价 10%
-- 分支 A 默认取下限(5%),因为数据不足、试探性更强
-
----
-
-### 降价参考范围
-
-**什么时候考虑降价**:
-当广告表现低于同类,但还有优化空间时
-
-**参考标准**:
-- ROI 低于**渠道P50** 10-15%
-- 7 日均消耗 ≥ 500 元(消耗太低数据不可信,硬规则已过滤)
-- 裂变率低于**同类均值** 10% 或更多
-
-**综合考虑**:
-- 如果 ROI 低且消耗高 → **积极降价**(优化成本)
-- 如果近期换过创意(< 7 天)→ **观察 2-3 天再降价**
-- 如果近期已降价(7 天内)→ **避免频繁调整**,改 hold/observe
-- 降价时同时建议运营调整素材方向(不自动执行,仅在理由中输出)
-
-**降价幅度**(基于 ROI 偏离渠道P50 的程度,硬边界 3%-5%):
-- 低于渠道P50 10-12% → 降价 3%
-- 低于渠道P50 12-15% → 降价 4%
-- 低于渠道P50 15-25%(接近关停线)→ 降价 5%(上限,严禁超过)
-
-> ⚠️ 降价幅度**硬边界 3% 至 5%**(与 config 的 `BID_DOWN_MIN_PCT=0.03` / `BID_DOWN_MAX_PCT=0.05` 对齐)。再低效也只降到 5%,更严重的问题走 `pause` 路径,不要用"大幅降价"去代替关停。
-
----
-
-### 关停参考范围
-
-**什么时候考虑关停**:
-当广告表现远低于同类,明显低效时
-
-**前提条件**(硬规则已在代码中实现):
-- 昨日消耗 ≥ 300 元(消耗太低的广告数据不可靠)
-- 广告年龄 > 3 天(冷启动期不评估关停)
-- 不满足以上条件的低 ROI 广告会被归入 hold
-
-**参考标准**:
-- ROI 低于**渠道P50** 25% 或更多(即 ROI < 渠道P50 × 0.75)
-- 已处于全渠道下分位水平
-- (裂变率作为辅助信号,不作为关停硬阈值)
-
-**持续低 ROI 升级关停**:
-- 如果广告之前被降价过,且降价后 ≥ 7 天 ROI 仍低于渠道均值 → 自动升级为关停候选
-- 这类广告会带有 `persistent_low_roi=True` 标记
-
-**重要**:
-以下情况已被硬规则直接关停,不会到达你这里:
-- 7 天无消耗(< 10 元)
-- 预算耗尽
-- 明显衰退(提价后仍低)
-
-你需要判断的是:**ROI 低但有一定消耗(昨日≥300元)的广告**
-
----
-
-## 三、广告年龄分段策略(重要保护机制)
-
-系统根据广告年龄将广告分为3个生命周期,每个阶段有不同的决策策略。
-
-### 第1段:新生期(≤3天)
-
-**特征**:
-- 系统刚开始学习用户画像
-- 数据量少,ROI波动大
-- 出价调整可能导致重新学习
-
-**策略**:
-- ✅ 极度保护,几乎不干预
-- ❌ 不降价(避免打断学习)
-- ❌ 不提价(避免加速消耗)
-- ❌ 不关停(给予充分时间)
-- ⚠️ 例外:零消耗7日均值<10元 → 自动关停(强规则)
-
-**数据标识**:
-- `age_segment`: `newborn`
-- `age_protection_level`: `极度保护`
-
----
-
-### 第2段:冷启动期(4-7天)— 仅允许提价
-
-**特征**:
-- 系统正在学习用户画像
-- ROI开始收敛,但仍有波动
-- **降价会打断学习,重新起跑**
-
-**策略**:
-- ✅ **允许提价**(如果 ROI 高于**渠道P50** 5-10% 且 裂变率高于**同类均值** 10-15%)
-- ❌ **不允许降价**(避免打断系统学习)
-- ❌ **不关停**(给予充分学习时间)
-- ✅ 允许 `observe`(数据不稳定时)
-- ✅ 允许 `creative_adjust`(素材优化不影响出价)
-
-**数据标识**:
-- `age_segment`: `cold_start`
-- `age_protection_level`: `仅允许提价`
-- `allow_bid_down`: `False`
-- `allow_bid_up`: `True`
-
-**系统自动保护**:
-如果 LLM 建议降价或关停,系统会自动转换为 `observe`:
 ```
-LLM建议: bid_down -5%
-系统转换: observe(LLM建议bid_down,但广告处于早期成长期6天,年龄保护规则不允许降价/关停,改为观察)
+tencent-ad-playbook  (平台硬规则,违反直接掉量)
+  > age-protection    (年龄保护,代码三层实现)
+  > roi-baseline      (业务阈值线定义)
+  > action-playbook   (action 选择规则)
+  > posterior-wisdom  (经验参考)
+  > ad-domain         (基础概念)
 ```
 
 ---
 
-### 第3段:成熟期(>7天)— 可降价/关停/扩量,不提价
-
-**特征**:
-- 数据充分(≥7天),ROI稳定
-- 系统学习完成
-- 稳定期不调出价,通过新增广告/创意拿消耗(投手经验1.2)
-
-**策略**:
-- ✅ **可降价**,最大5%(`max_bid_down_pct=0.05`)— 决策树明确3-5%上限
-- ❌ **不提价**(稳定期不调出价,通过新增广告/创意扩量)
-- ✅ **可扩量**(scale_up,建议新增广告或创意拿消耗)
-- ✅ **可关停**(ROI 低于**渠道P50** 25-30%,即 < 渠道P50 × 0.75)
-- ✅ 可降价、观察、调整素材方向
-
-**数据标识**:
-- `age_segment`: `mature`
-- `age_protection_level`: `正常调控`
-- `allow_bid_down`: `True`
-- `allow_bid_up`: `True`
-
----
-
-### 特殊情况:高燃烧预警 🔥
-
-**触发条件**:`high_burn_alert=True`
-- 广告年龄 > 3天
-- 昨日消耗 > 300元(单广告维度)
-
-**说明**:
-即使在成熟期,单日消耗过高需要重点关注,可能存在:
-- 定向过宽,流量失控
-- 出价过高,竞争力过强
-- 创意吸引过度点击但转化低
-
-**策略**:
-- 优先评估ROI是否正常
-- 如果ROI正常且高燃烧 → 可能是优质广告,考虑适当降价控制节奏
-- 如果ROI偏低且高燃烧 → 立即降价或关停
-
----
-
-## 四、投放经验规律
-
-### 4.1 连续调价的学习中断(系统稳定性原理)
-
-**理论基础**:
-每次调价都会触发系统重新学习,频繁调价会导致:
-- 学习周期被打断,无法收敛到最优状态
-- 系统在多个出价点之间徘徊,效率降低
-
-**决策框架**:
-1. **避免短期内(7天)频繁调价**(≥3次)
-2. 每次调价后,**至少观察5-7天** 再考虑下一次调价
-3. 如果广告ROI很好,但已多次提价 → **暂缓提价,观察稳定性**
-4. 连续调价效果递减是 **系统原理**,不是偶然现象
-
-**判断要点**:
-- 检查近7天调价历史
-- 如果已调价 ≥2次 → 倾向于"观察"而非"继续调价"
-- 给系统足够的稳定周期
-
----
-
-### 4.2 降价后的流量恢复期(竞价机制原理)
-
-**理论基础**:
-降价后,系统需要重新分配流量(CPM降低 → 曝光机会减少),ROI改善需要时间:
-- 系统调整投放策略(人群、时段、版位)
-- 流量质量重新筛选
-
-**理论预期**:
-- **即时效果**:降价不会立即改善ROI(第1天可能没变化)
-- **延迟显现**:需要2-5天,ROI才能稳定改善
-- **消耗下降**:降价后消耗通常会同步下降
-
-**决策框架**:
-1. 降价后 **1-2天内ROI未改善** → 正常,继续观察
-2. 降价后 **3-5天仍无改善** → 可能问题不在出价,考虑其他因素(创意/定向)
-3. 降价后 **消耗大幅下降但ROI仍低** → 建议关停(低效广告)
-
----
-
-### 4.3 创意更换的冷启动期(新素材学习原理)
-
-**理论基础**:
-新创意素材需要冷启动学习期,系统需要:
-- 测试创意对不同人群的吸引力
-- 探索最优投放策略
-
-**理论预期**:
-- **前3-7天**:数据波动大,ROI可能忽高忽低
-- **7天后**:效果趋于稳定,可以评估创意质量
-
-**决策框架**:
-1. 如果广告 **近7天内换过创意** → 所有数据仅供参考
-2. 创意冷启动期内:
-   - ROI突然很高 → 不要立即提价(可能是偶然)
-   - ROI突然很低 → 不要立即关停(系统还在学习)
-3. **至少观察7天** 后,再基于新创意数据做决策
-
-**判断要点**:
-- 检查 `creative_days_since_change` 字段
-- < 7天 → 谨慎决策,不要大幅调整
-- ≥ 7天 → 数据已稳定,可以正常决策
-
----
-
-## 五、ROI 数据置信度
-
-由于支持"不足7天用几天"的ROI计算,需要根据有效数据天数评估置信度。
-
-### 置信度分级
-
-| roi_valid_days | 置信度 | 决策建议 |
-|---------------|--------|---------|
-| ≥7天 | 高 | 可正常决策 |
-| 4-6天 | 中 | 谨慎决策,避免激进操作 |
-| 3天 | 低 | 仅做保守决策(如明显异常才关停) |
-| <3天 | 无 | ROI=NaN,无法决策 |
-
-### 理由表达要求
-
-**示例**:
-- **7天数据**:"基于7天数据,动态ROI为2.5..."
-- **5天数据**:"基于5天数据(置信度中等),动态ROI为2.5;建议降价3%而非5%..."
-- **3天数据**:"基于3天数据(置信度较低),动态ROI为2.5;数据不足,建议观察..."
-
-### 置信度对决策的影响
-
-**高置信度(≥7天)**:
-- 可执行正常降价幅度(3-5%)
-- 可执行正常提价幅度(5-10%)
-- 可做关停决策
-
-**中等置信度(4-6天)**:
-- 降价幅度减半(3% → 1.5-2%)
-- 提价谨慎(5% → 3%)
-- 关停需更严格条件(低于30%而非25%)
-
-**低置信度(3天)**:
-- 优先选择 `observe` 而非 `bid_down`/`bid_up`
-- 仅明显异常才关停(低于40%)
-- 理由中必须说明数据不足
-
----
-
-## 六、决策动作详解
-
-系统新增两个决策动作,用于处理传统4个动作(pause/bid_down/bid_up/hold)无法覆盖的场景。
-
-### creative_adjust(调整素材方向)
-
-**核心定位**:ROI正常但消耗不足,需要人工优化素材
-
-**适用场景**:
-1. ROI正常(> bid_down_line),但消耗过低(<100元/天)
-2. 7天内已更换创意(creative_changed_7d=true),但效果仍不佳
-3. 裂变率偏低,但ROI尚可
-
-**与其他action的区别**:
-- vs **hold**:hold是认可当前状态,creative_adjust是认为ROI可以但需改进素材
-- vs **bid_up**:bid_up是提价拉量,creative_adjust是优化素材吸引力
-
-**什么时候选择 creative_adjust**:
-
-当满足以下情况时,应优先考虑调整素材而非调整出价:
-
-1. **ROI 正常但量不足**:
-   - ROI 未达到降价线(表现可接受)
-   - 但日均消耗过低(通常 < 100元)
-   - 说明素材吸引力不足,需优化而非提价
-
-2. **创意更换后效果仍不理想**:
-   - 7天内已更换过创意
-   - 但裂变率仍明显低于同类均值(如低15%以上)
-   - 说明素材方向可能不对,需进一步调整
-
-3. **ROI 可以但裂变率低**:
-   - ROI 达标(高于降价线)
-   - 但裂变率偏低(低于同类均值)
-   - 说明短期效果可以,但长期价值不足,需优化素材突出裂变激励
-
-**判断原则**:
-- 素材问题 → creative_adjust
-- 出价问题 → bid_up/bid_down
-- 当 ROI 正常但消耗低时,优先考虑是素材吸引力问题,而非出价问题
-
-**执行方式**:
-- ⚠️ **不调用API**,仅在审批表中标识
-- 由运营人员根据理由人工调整素材
-
-**理由示例**:
-> "动态ROI为2.8,高于渠道P50 2.50 的 12%,但7日均消耗仅65元;裂变率0.45低于同类均值0.62;建议调整素材方向,突出裂变激励"
-
----
-
-### observe(观察等待)
-
-**核心定位**:数据不稳定或接近阈值边界,需要短期观察后再决策
-
-**适用场景**:
-1. ROI数据天数不足(roi_valid_days < 7),置信度低
-2. ROI接近阈值边界(在bid_down_line ± 5%范围内)
-3. 广告处于冷启动期(4-7天),数据波动大
-4. 消耗稳定天数不足(stable_spend_days < 7)
-
-**与其他action的区别**:
-- vs **hold**:hold是认可当前状态长期保持,observe是数据存疑需短期复查
-- vs **pause**:pause是明确低效,observe是不确定需要更多数据
-
-**什么时候选择 observe**:
-
-当存在以下不确定因素时,应选择观察等待,而非立即操作:
-
-1. **数据天数不足(置信度低)**:
-   - ROI 数据天数少于7天(如3-6天)
-   - ROI 虽然显示某个值,但数据不足以支撑可靠决策
-   - 此时应等待更多数据,避免基于不稳定数据做错误操作
-
-2. **ROI 接近阈值边界**:
-   - ROI 在降价线或提价线附近徘徊(上下浮动5%以内)
-   - 小幅波动就可能改变决策
-   - 此时应观察1-2天,确认是趋势还是波动
-
-3. **冷启动期且数据波动大**:
-   - 广告处于4-7天冷启动期
-   - 消耗稳定天数不足(如 < 5天)
-   - 每日消耗波动较大(如从50元到180元)
-   - 此时应等待系统学习稳定后再评估
-
-4. **近期调价或换创意**:
-   - 近7天内已调价,尚未看到稳定效果
-   - 近7天内已换创意,数据仍在波动期
-   - 此时应避免再次操作,给系统学习时间
-
-**判断原则**:
-- 数据不足 → observe(等数据充分)
-- 接近边界 → observe(等趋势明确)
-- 刚调整过 → observe(等效果稳定)
-- 明确低效 → pause/bid_down(不观察)
-
-**执行方式**:
-- 在审批表中标识为 "观察等待"
-- 系统2-3天后自动重新评估该广告
-
-**理由示例**:
-- "基于4天数据(置信度中等),动态ROI为2.2;接近降价线2.38,建议观察2天后确认趋势"
-- "广告处于冷启动期第5天,消耗波动从50元至180元;虽ROI为2.5,但稳定性不足,建议观察"
-
----
-
-### scale_up(扩量:建议新增广告/创意)
-
-**核心定位**:成熟期优质广告,建议扩大投放规模(新增广告或创意)
-
-**适用场景**:
-1. 广告已投放 >7天(成熟期)
-2. 消耗稳定(stable_spend_days_30d ≥ 7)
-3. 日均消耗高(cost_7d_avg > 1000元)
-4. ROI 正常或优秀(≥ 渠道P50 的 90%)
-
-**与其他action的区别**:
-- vs **bid_up**:bid_up是提高单个广告出价拉量,scale_up是建议复制成功经验新增资源
-- vs **hold**:hold是保持现状,scale_up是识别成功案例建议扩大规模
-- vs **creative_adjust**:creative_adjust是优化现有素材,scale_up是建议增加新素材
-
-**什么时候选择 scale_up**:
-
-当满足以下**所有**条件时,应建议扩量:
-
-1. **广告已成熟且稳定**:
-   - 广告年龄 > 7天(ad_age_days > 7)
-   - 消耗稳定天数 ≥ 7天(stable_spend_days_30d ≥ 7)
-   - 说明广告已度过学习期,进入稳定运营阶段
-
-2. **消耗达到扩量阈值**:
-   - 7日日均消耗 > 1000元(cost_7d_avg > 1000)
-   - 说明广告已充分跑量,有扩量空间
-
-3. **ROI 表现正常或优秀**:
-   - 动态ROI ≥ 渠道P50 的 90%(`roi_mean * 0.9`)
-   - 说明投放效率可以接受,值得扩大规模
-
-4. **标记为扩量候选**:
-   - scale_up_candidate = True
-   - 系统已识别为扩量候选
-
-**判断原则**:
-- 成熟稳定 + 高消耗 + ROI正常 → scale_up(建议扩量)
-- 新广告或不稳定 → 不建议扩量(先优化再考虑)
-- ROI偏低 → 不建议扩量(先提升效率)
-- 消耗不足 → bid_up(提价拉量)或 creative_adjust(优化素材)
-
-**执行方式**:
-- ⚠️ **不调用API**,仅在审批表中标识"建议扩量"
-- 由运营人员根据建议决定:
-  - 复制该广告配置,创建新广告(新定向/新人群包)
-  - 为该广告增加新创意(新素材方向)
-  - 增加账户预算配额
-
-**理由示例**:
-- "广告已投放12天,消耗稳定(30日内稳定10天),7日日均消耗1250元;动态ROI为2.8,高于渠道P50 2.50 的 12%;建议扩量:复制该广告配置或新增创意"
-- "成熟期广告,日均消耗1500元,ROI稳定在2.5(高于渠道P50 2.30 的 8%);已验证投放效果,建议扩大规模"
-
----
-
-## 七、决策流程检查清单
-
-### 决策前检查清单
-
-在做任何调价决策前,依次检查:
-
-**1. 硬规则已处理的情况(你不需要判断)**:
-- ✓ 冷启动期(≤ 7 天)→ 已 hold
-- ✓ 零消耗(< 10 元)→ 已 pause
-- ✓ 预算耗尽 → 已 pause
-- ✓ 明显衰退 → 已 pause
-
-**2. 数据稳定性检查**:
-- ✓ 近 7 天是否调价 ≥ 2 次?(避免频繁调整)
-- ✓ 近 7 天是否换创意?(数据可能不稳定)
-- ✓ 今天是周几?(周末数据需要折扣理解)
-- ✓ 消耗是否异常波动?(可能是竞争因素)
-
-**3. 对比基准分析(ROI vs 渠道,裂变 vs 同类)**:
-- ✓ ROI 对比:广告动态 ROI vs 渠道P50(`roi_mean`),计算偏离百分比
-- ✓ 裂变对比:广告裂变率 vs 同类均值(`tier_fission_mean`),计算偏离百分比
-- ✓ 人群包类型(`audience_tier`)仅用于定位同类裂变基准,不用于 ROI 判断
-
-**4. 后验经验应用**:
-- ✓ 如果近期调价,ROI 波动是否在正常范围?
-- ✓ 如果近期换创意,是否需要等待稳定?
-- ✓ 如果是周一,ROI 下降是否正常?
-
----
-
-### 决策理由模板
-
-**提价理由**:
-```
-"动态 ROI 为 {roi},高于渠道P50 {channel_p50} 的 {pct}%,在全渠道中表现{优秀/良好};
-裂变率 {fission} {高于/低于} 同类均值 {tier_mean} 的 {f_pct}%;
-{数据稳定性说明};
-{后验经验考虑};
-建议提价 {pct}%"
-```
-
-**降价理由**:
-```
-"动态 ROI 为 {roi},低于渠道P50 {channel_p50} 的 {pct}%,在全渠道中表现偏低;
-{裂变率说明:对比同类均值 tier_fission_mean};
-{消耗情况};
-建议降价 {pct}% 优化成本"
-```
-
-**观察理由**:
-```
-"虽然 ROI {高/低},但考虑到:
-1. {因素 1:如近期换创意}
-2. {因素 2:如提价后正常波动}
-3. {因素 3:如周末效应}
-建议观察 {days} 天后再评估"
-```
-
-**关停理由**:
-```
-"动态 ROI 为 {roi},低于渠道P50 {channel_p50} 的 {pct}%,
-已低于关停线(渠道P50 × 0.75 = {pause_line}),在全渠道中处于明显低效水平;
-{其他判断依据};
-建议关停释放预算"
-```
-
----
-
-## 八、灵活判断(不要机械套用)
-
-### 灵活判断,不要机械套用
-
-1. **阈值是参考,不是铁律**
-   - 决策阈值(5-10%、10-15%、25-30%)是 **ROI 相对"渠道P50"的偏离区间**
-   - 裂变率阈值(±10-15%)是相对"同类均值"的偏离区间
-   - 关键是理解偏离程度的业务含义,不是精确计算
-   - 需结合多个因素综合判断
-
-2. **综合考虑多个因素**
-   - 不要只看 ROI 一个指标
-   - 结合裂变率、数据稳定性、广告年龄、调价历史
-   - 理解广告平台的系统原理(学习期、竞价机制)
-
-3. **理论框架指导决策**(当前无实测数据)
-   - 基于 oCPM 系统原理:调价后需要学习期
-   - 基于竞价机制原理:流量分配受竞争影响
-   - 基于广告平台通用规律:创意冷启动、时间周期性
-   - **待系统运行后,用真实数据验证和优化**
-
-4. **场景化决策**
-   - 相同 ROI,不同场景可能有不同决策
-   - 例如:ROI 4.0 + 刚换创意(<7天)→ 观察
-   - 例如:ROI 4.0 + 数据稳定(>7天)+ 无调价历史 → 可考虑提价
-   - 例如:ROI 4.0 + 近7天已提价2次 → 观察
-
----
+## 查找指南
 
+- "这个广告该选哪个 action?" → `action-playbook` §一决策树
+- "ROI 2.5 对比什么?" → `roi-baseline` §一核心口径
+- "4-7 天的广告能降价吗?" → `age-protection` §二冷启动期
+- "刚换过创意要怎么判?" → `posterior-wisdom` §三创意冷启动
+- "数据只有 4 天够不够?" → `posterior-wisdom` §四置信度分级

+ 2 - 2
examples/auto_put_ad_mini/skills/tencent_ad_playbook.md

@@ -6,7 +6,7 @@ category: ad_optimization
 
 ## 这份 Skill 的定位
 
-这份知识**不是**业务阈值(ROI/出价步长在 roi-strategy),**不是**业务基础概念(f_7日动态ROI 在 ad-domain),**不是**系统护栏(冷启动/频率在 guardrail-rules)。
+这份知识**不是**业务阈值(ROI/出价步长在 roi-strategy),**不是**业务基础概念(动态 ROI (7日均值) 在 ad-domain),**不是**系统护栏(冷启动/频率在 guardrail-rules)。
 
 这里是**腾讯广告 3.0 平台本身的投放规则**:哪些操作会被平台惩罚?哪些节奏是 oCPM 学习机制硬要求的?什么情况下数据本身不可信?
 
@@ -97,7 +97,7 @@ category: ad_optimization
 | T+1 权威 | `daily_reports`(日报) | 次日凌晨 | **唯一可用于 ROI 计算和调价决策的口径** |
 
 **硬规则:**
-- **ROI 计算、`f_7日动态ROI`、调价决策 → 必须用 T+1 `daily_reports`**。
+- **ROI 计算、`动态ROI_7日均值`、调价决策 → 必须用 T+1 `daily_reports`**。
 - 实时数据只回答"现在是否在烧钱",不回答"投放效率如何"。
 - 转化数据延迟 1~2 小时,归因还要 15 天,任何"今日 ROI"都是假数据。
 

+ 80 - 0
examples/auto_put_ad_mini/tools/_names.py

@@ -0,0 +1,80 @@
+"""
+术语与字段名常量 —— 避免字符串魔法值,保证命名单一真源。
+
+## 背景
+
+历史上本项目对同一概念有多种叫法(代码变量 / CSV 列名 / 文档术语各不相同),
+已在 2026-04-21 重构中统一为:
+
+| 概念 | CSV 列名(保留中文,不改) | Python 变量(全英文) | 文档术语 |
+|------|--------------------------|---------------------|---------|
+| 广告 7 日均值动态 ROI | `动态ROI_7日均值` | `dynamic_roi_7d` | "动态 ROI (7日均值)" |
+| 广告单日动态 ROI | `动态ROI` | `dynamic_roi_daily` | "动态 ROI (单日)" |
+| 渠道 P50 基准 | (计算值,无列)| `channel_roi_p50` | "渠道P50" |
+| 同类裂变均值 | `tier_fission_mean` | `tier_fission_mean` | "同类均值" |
+
+## 使用方式
+
+```python
+from tools._names import COL_DYNAMIC_ROI_7D
+
+row.get(COL_DYNAMIC_ROI_7D)  # 代替硬编码字符串 "动态ROI_7日均值"
+```
+
+## 设计约束
+
+- **CSV 列名不动**:保留中文列名,零破坏历史 metrics_*.csv 数据
+- **Python 变量规范**:全英文短名(小写下划线),与中文术语在 reason 展示层解耦
+- **禁用变量名进入 LLM reason**:所有 Python 变量名都属"内部实现",reason 必须用中文术语
+"""
+
+# ═══════════════════════════════════════════
+# CSV 列名常量(保留中文,零破坏历史数据)
+# ═══════════════════════════════════════════
+
+# 广告级动态 ROI
+COL_DYNAMIC_ROI_DAILY = "动态ROI"              # 单日值
+COL_DYNAMIC_ROI_7D = "动态ROI_7日均值"          # 7 日滚动均值(决策参考)
+
+# 裂变相关
+COL_T0_FISSION_COEFF = "T0裂变系数"             # 单日值
+COL_T0_FISSION_COEFF_7D = "T0裂变系数_7日均值"  # 7 日滚动均值
+
+# 护栏 B 系列用的临时列(在 validate_decisions 里注入)
+COL_CHANNEL_ROI_P50 = "_channel_roi_p50"        # 带下划线前缀表示"内部临时列"
+
+# ═══════════════════════════════════════════
+# 文档术语(reason 展示层)
+# ═══════════════════════════════════════════
+
+TERM_DYNAMIC_ROI_7D = "动态 ROI (7日均值)"
+TERM_CHANNEL_P50 = "渠道P50"
+TERM_TIER_FISSION_MEAN = "同类均值"
+TERM_PAUSE_LINE = "关停线"
+TERM_BID_DOWN_LINE = "降价线"
+TERM_BID_UP_LINE = "提价线"
+
+# ═══════════════════════════════════════════
+# 禁用变量名黑名单(LLM reason 里若出现则违规)
+# ═══════════════════════════════════════════
+
+FORBIDDEN_IN_REASON = [
+    # 旧版 Python 变量名
+    "f_roi",
+    "roi_mean",
+    "roi_p50",
+    "channel_p50",
+    "tier_roi_p50",
+    # 新版 Python 变量名(Phase 2 引入后也禁止出现在 reason)
+    "dynamic_roi_7d",
+    "channel_roi_p50",
+    # 阈值线英文名
+    "pause_line",
+    "bid_down_line",
+    "bid_up_line",
+    # 信号字段
+    "bid_increased_7d",
+    "creative_changed_7d",
+    "roi_valid_days",
+    "tier_fission_mean",
+]

+ 43 - 45
examples/auto_put_ad_mini/tools/ad_decision.py

@@ -360,9 +360,9 @@ async def get_ads_for_review(
 
         # 全体 ROI 分布
         roi_series = df["动态ROI_7日均值"].dropna()
-        roi_mean = float(roi_series.median()) if len(roi_series) > 0 else 0.0
+        # channel_roi_p50 = 渠道P50(全体广告"动态ROI 7日均值"的中位数),决策基准
+        channel_roi_p50 = float(roi_series.median()) 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
 
@@ -377,7 +377,7 @@ async def get_ads_for_review(
 
         for _, row in df.iterrows():
             cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
-            f_roi = row.get("动态ROI_7日均值")
+            dynamic_roi_7d = 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))
@@ -422,8 +422,8 @@ async def get_ads_for_review(
             # 待优化评估:ROI 偏低 或 衰退信号 或 出价调整候选(需要智能判断)
             # ★ 关停条件对齐投手经验2.4:需要昨日消耗≥300 且 广告年龄>3天
             roi_low = (
-                (not pd.isna(f_roi))
-                and (f_roi < roi_mean * roi_review_factor)
+                (not pd.isna(dynamic_roi_7d))
+                and (dynamic_roi_7d < channel_roi_p50 * roi_review_factor)
                 and yesterday_cost >= ROI_LOW_MIN_YESTERDAY_COST  # 昨日消耗≥300
                 and (ad_age is not None and ad_age > COLD_START_DAYS)  # 广告年龄>3天
             )
@@ -468,8 +468,8 @@ async def get_ads_for_review(
             #   3-7天 + 后端数据好 + 均值消耗 <1000 + ROI>渠道均值5% + 裂变>同类10% + CTR 正常 → 提价 5-10%
             #   含义:"数据已证明这条广告优质,提价拉更多量"
             bid_up_candidate_b = (
-                (not pd.isna(f_roi))
-                and f_roi > roi_mean * params["BID_UP_ROI_FACTOR"]         # ROI 高于渠道均值5%
+                (not pd.isna(dynamic_roi_7d))
+                and dynamic_roi_7d > channel_roi_p50 * params["BID_UP_ROI_FACTOR"]         # ROI 高于渠道均值5%
                 and cost_7d_avg < BID_UP_MAX_SPEND                         # 消耗<1000(固定阈值)
                 and bid_amount > 0
                 and (ad_age is not None and ad_age <= EARLY_GROWTH_DAYS)   # 仅4-7天可提价(≤3天已被冷启动排除)
@@ -484,9 +484,9 @@ async def get_ads_for_review(
 
             # 降价:ROI低于渠道均值10% + 裂变低于同类10% + 消耗≥500元/天
             bid_down_candidate = (
-                (not pd.isna(f_roi))
-                and f_roi < roi_mean * params["BID_DOWN_ROI_FACTOR"]      # ROI低于渠道均值10%
-                and f_roi >= roi_mean * params["ROI_LOW_FACTOR"]          # 但未达关停线
+                (not pd.isna(dynamic_roi_7d))
+                and dynamic_roi_7d < channel_roi_p50 * params["BID_DOWN_ROI_FACTOR"]      # ROI低于渠道均值10%
+                and dynamic_roi_7d >= channel_roi_p50 * params["ROI_LOW_FACTOR"]          # 但未达关停线
                 and cost_7d_avg >= BID_DOWN_MIN_SPEND                     # 消耗≥500元/天
                 and bid_amount > 0
                 and (tier_fission_mean is None or ad_fission is None      # 裂变低于同类均值10%(无数据时跳过)
@@ -497,8 +497,8 @@ async def get_ads_for_review(
             persistent_low_roi = False
             if (
                 not roi_low  # 当前未达关停线(ROI在0.75~0.90之间)
-                and (not pd.isna(f_roi))
-                and f_roi < roi_mean * params["BID_DOWN_ROI_FACTOR"]  # ROI仍低于渠道均值10%
+                and (not pd.isna(dynamic_roi_7d))
+                and dynamic_roi_7d < channel_roi_p50 * params["BID_DOWN_ROI_FACTOR"]  # ROI仍低于渠道均值10%
                 and yesterday_cost >= ROI_LOW_MIN_YESTERDAY_COST  # 昨日消耗≥300
                 and (ad_age is not None and ad_age > COLD_START_DAYS)  # 年龄>3天
             ):
@@ -511,7 +511,7 @@ async def get_ads_for_review(
                         roi_low = True  # 升级!
                         logger.info(
                             f"广告 {row['ad_id']} 降价后{days_since_bd}天ROI仍低"
-                            f"({f_roi:.4f}<{roi_mean * params['BID_DOWN_ROI_FACTOR']:.4f}),升级为关停候选"
+                            f"({dynamic_roi_7d:.4f}<{channel_roi_p50 * params['BID_DOWN_ROI_FACTOR']:.4f}),升级为关停候选"
                         )
 
             # 扩量候选:成熟期 + 消耗稳定 + 高消耗 + ROI正常(基于决策树)
@@ -520,8 +520,8 @@ async def get_ads_for_review(
                 and ad_age > 7  # 成熟期(>7天)
                 and stable_days >= 7  # 消耗稳定(≥7天)
                 and cost_7d_avg > 1000  # 高消耗(>1000元/天)
-                and (not pd.isna(f_roi))
-                and f_roi >= roi_mean * 0.9  # ROI正常(≥均值的90%)
+                and (not pd.isna(dynamic_roi_7d))
+                and dynamic_roi_7d >= channel_roi_p50 * 0.9  # ROI正常(≥均值的90%)
             )
 
             # ===== 消耗稳定性前置门控(决策树:成熟期+不稳定→observe)=====
@@ -591,7 +591,7 @@ async def get_ads_for_review(
                 ad_dict = {
                     "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,
+                    "动态ROI_7日均值": round(float(dynamic_roi_7d), 4) if not pd.isna(dynamic_roi_7d) 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,
@@ -615,7 +615,7 @@ async def get_ads_for_review(
                 tier = ad_dict.get("audience_tier", "default")
                 tier_stats = by_tier_stats.get(tier, {})
 
-                # ROI 对比走"渠道整体"(roi_mean),故此处不注入 tier_roi_* 字段
+                # ROI 对比走"渠道整体"(channel_roi_p50),故此处不注入 tier_roi_* 字段
 
                 # 裂变率同类对比数据(裂变必须对比同人群)
                 ad_dict["tier_fission_mean"] = tier_stats.get("fission_mean")
@@ -632,20 +632,18 @@ async def get_ads_for_review(
                 ad_dict["bid_up_target_min"] = round(tier_bid_mean * 1.05, 4) if tier_bid_mean else None
                 ad_dict["bid_up_target_max"] = round(tier_bid_mean * 1.10, 4) if tier_bid_mean else None
 
-                # ROI 阈值线:基于"渠道P50"(roi_mean,全体广告7日均值的中位数),严禁用同类
-                channel_p50 = roi_mean
-
+                # ROI 阈值线:基于"渠道P50"(channel_roi_p50,全体广告7日均值的中位数),严禁用同类
                 # 关停线:渠道P50 的 70-75%(低于25-30%)
-                ad_dict["pause_line_min"] = round(channel_p50 * 0.70, 4) if channel_p50 else None
-                ad_dict["pause_line_max"] = round(channel_p50 * 0.75, 4) if channel_p50 else None
+                ad_dict["pause_line_min"] = round(channel_roi_p50 * 0.70, 4) if channel_roi_p50 else None
+                ad_dict["pause_line_max"] = round(channel_roi_p50 * 0.75, 4) if channel_roi_p50 else None
 
                 # 降价线:渠道P50 的 85-90%(低于10-15%)
-                ad_dict["bid_down_line_min"] = round(channel_p50 * 0.85, 4) if channel_p50 else None
-                ad_dict["bid_down_line_max"] = round(channel_p50 * 0.90, 4) if channel_p50 else None
+                ad_dict["bid_down_line_min"] = round(channel_roi_p50 * 0.85, 4) if channel_roi_p50 else None
+                ad_dict["bid_down_line_max"] = round(channel_roi_p50 * 0.90, 4) if channel_roi_p50 else None
 
                 # 提价线:渠道P50 的 105-110%(高于5-10%)
-                ad_dict["bid_up_line_min"] = round(channel_p50 * 1.05, 4) if channel_p50 else None
-                ad_dict["bid_up_line_max"] = round(channel_p50 * 1.10, 4) if channel_p50 else None
+                ad_dict["bid_up_line_min"] = round(channel_roi_p50 * 1.05, 4) if channel_roi_p50 else None
+                ad_dict["bid_up_line_max"] = round(channel_roi_p50 * 1.10, 4) if channel_roi_p50 else None
 
                 # ===== 新增:年龄分段标签(基于决策树图片)=====
                 if ad_age is not None:
@@ -731,16 +729,16 @@ async def get_ads_for_review(
                 "max_batch_size": max((b["count"] for b in tier_batches), default=0),
             },
             "distribution": {
-                "roi_mean": round(roi_mean, 4),
+                "channel_roi_p50": round(channel_roi_p50, 4),
                 "p25": round(roi_p25, 4),
-                "p50": round(roi_p50, 4),
+                "p50": round(channel_roi_p50, 4),
                 "p75": round(roi_p75, 4),
                 "p90": round(roi_p90, 4),
             },
             "bid_adjustment": {
                 "enabled": BID_ADJUSTMENT_ENABLED,
-                "bid_down_line": round(roi_mean * params["BID_DOWN_ROI_FACTOR"], 4),
-                "bid_up_line": round(roi_mean * params["BID_UP_ROI_FACTOR"], 4),
+                "bid_down_line": round(channel_roi_p50 * params["BID_DOWN_ROI_FACTOR"], 4),
+                "bid_up_line": round(channel_roi_p50 * params["BID_UP_ROI_FACTOR"], 4),
                 "bid_up_max_spend": BID_UP_MAX_SPEND,
                 "roi_low_min_yesterday_cost": ROI_LOW_MIN_YESTERDAY_COST,
             },
@@ -750,10 +748,10 @@ async def get_ads_for_review(
                 "BID_UP_ROI_FACTOR": params["BID_UP_ROI_FACTOR"],
                 "BID_UP_MAX_SPEND": BID_UP_MAX_SPEND,
                 "ROI_LOW_MIN_YESTERDAY_COST": ROI_LOW_MIN_YESTERDAY_COST,
-                "roi_mean": round(roi_mean, 4),
-                "pause_line": round(roi_mean * params["ROI_LOW_FACTOR"], 4),
-                "bid_down_line": round(roi_mean * params["BID_DOWN_ROI_FACTOR"], 4),
-                "bid_up_line": round(roi_mean * params["BID_UP_ROI_FACTOR"], 4),
+                "channel_roi_p50": round(channel_roi_p50, 4),
+                "pause_line": round(channel_roi_p50 * params["ROI_LOW_FACTOR"], 4),
+                "bid_down_line": round(channel_roi_p50 * params["BID_DOWN_ROI_FACTOR"], 4),
+                "bid_up_line": round(channel_roi_p50 * params["BID_UP_ROI_FACTOR"], 4),
             },
             # 零消耗广告由规则全自动处理,LLM 无需逐条决策
             # 仅传入规模 + 10 条样本(供 LLM 追溯形态,避免 1000+ 条名单挤占 context)
@@ -779,7 +777,7 @@ async def get_ads_for_review(
                 "need_review_ads": len(need_review_ads),
                 "normal_ads": normal_ads_count,
                 "tier_groups": len(review_by_tier),
-                "roi_mean": roi_mean,
+                "channel_roi_p50": channel_roi_p50,
                 "end_date": end_date,
             },
         )
@@ -938,12 +936,12 @@ async def apply_decisions(
                 ad_id = int(row["ad_id"])
                 if ad_id not in zero_spend_ad_ids and ad_id not in need_review_ad_ids:
                     cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
-                    f_roi = row.get("动态ROI_7日均值")
+                    dynamic_roi_7d = row.get("动态ROI_7日均值")
                     ad_age_days = row.get("ad_age_days")
 
                     # 冷启动保护:广告年龄 ≤ 3天(基于决策树)
                     if ad_age_days is not None and ad_age_days <= COLD_START_DAYS:
-                        roi_str = f"{f_roi:.2f}" if not pd.isna(f_roi) else "数据不足"
+                        roi_str = f"{dynamic_roi_7d:.2f}" if not pd.isna(dynamic_roi_7d) else "数据不足"
                         normal_running_rows.append({
                             "ad_id": ad_id,
                             "action": "hold",
@@ -955,7 +953,7 @@ async def apply_decisions(
                         })
                     else:
                         # 正常运行
-                        roi_str = f"{f_roi:.2f}" if not pd.isna(f_roi) else "数据不足"
+                        roi_str = f"{dynamic_roi_7d:.2f}" if not pd.isna(dynamic_roi_7d) else "数据不足"
                         normal_running_rows.append({
                             "ad_id": ad_id,
                             "action": "hold",
@@ -1167,19 +1165,19 @@ async def query_ad_detail(
 
         # 全局 ROI 分布(使用中位数作为基准,避免被少数高ROI广告拉高)
         roi_series = df["动态ROI_7日均值"].dropna()
-        roi_mean = float(roi_series.median()) if len(roi_series) > 0 else 0.0
+        channel_roi_p50 = float(roi_series.median()) if len(roi_series) > 0 else 0.0
 
-        roi_low_line = roi_mean * ROI_LOW_FACTOR
-        bid_down_line = roi_mean * BID_DOWN_ROI_FACTOR
-        bid_up_line = roi_mean * BID_UP_ROI_FACTOR
+        roi_low_line = channel_roi_p50 * ROI_LOW_FACTOR
+        bid_down_line = channel_roi_p50 * BID_DOWN_ROI_FACTOR
+        bid_up_line = channel_roi_p50 * BID_UP_ROI_FACTOR
 
         # 构建广告详情
-        f_roi = row.get("动态ROI_7日均值")
+        dynamic_roi_7d = row.get("动态ROI_7日均值")
         ad_detail = {
             "ad_id": ad_id_int,
             "ad_name": str(row.get("ad_name", "")),
             "bid_amount": round(float(row.get("bid_amount", 0) or 0), 2),
-            "动态ROI_7日均值": round(float(f_roi), 4) if not pd.isna(f_roi) else None,
+            "动态ROI_7日均值": round(float(dynamic_roi_7d), 4) if not pd.isna(dynamic_roi_7d) else None,
             "cost_7d_avg": round(float(row.get("cost_7d_avg", 0) or 0), 2),
             "cost_7d_total": round(float(row.get("cost_7d_total", 0) or 0), 2),
             "ad_age_days": ad_age_days,
@@ -1206,7 +1204,7 @@ async def query_ad_detail(
 
         # 全局上下文
         global_context = {
-            "全体动态ROI基准(中位数)": round(roi_mean, 4),
+            "全体动态ROI基准(中位数)": round(channel_roi_p50, 4),
             "ROI关停线": round(roi_low_line, 4),
             "ROI降价线": round(bid_down_line, 4),
             "ROI提价线": round(bid_up_line, 4),

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

@@ -2,7 +2,7 @@
 创意级指标工具 — auto_put_ad_mini (Step 2)
 
 职责:
-  - 按 creative_id 聚合最近 N 天 ROI(简单 ROI,非 f_7日动态ROI)
+  - 按 creative_id 聚合最近 N 天 ROI(简单 ROI,非 动态 ROI (7日均值)
   - 为"负向决策前做创意归因检查"提供数据支撑
 
 使用定位(重要):
@@ -223,7 +223,7 @@ async def calculate_creative_metrics(
 
     数据源:outputs/merged/merged_*.csv(最近 N 天),按 (ad_id, creative_id) 聚合:
       - cost_{N}d, revenue_{N}d, open_count_{N}d, fission0_{N}d
-      - roi_{N}d = revenue_{N}d / cost_{N}d (简单 ROI,不是 f_7日动态ROI)
+      - roi_{N}d = revenue_{N}d / cost_{N}d (简单 ROI,不是 动态 ROI (7日均值)
       - cost_share = 该创意在所属广告中的消耗占比
       - creative_age_days = 该创意首次出现至今天数
       - active_days_{N}d = 近 N 天有消耗的天数

+ 119 - 29
examples/auto_put_ad_mini/tools/guardrails.py

@@ -44,6 +44,8 @@ from config import (
     BID_UP_MAX_PCT,
     BID_DOWN_MIN_PCT,
     BID_DOWN_MAX_PCT,
+    BID_UP_ROI_FACTOR,
+    ROI_LOW_FACTOR,
     MAX_ADJUSTMENTS_PER_AD_PER_DAY,
     MIN_ADJUSTMENT_INTERVAL_HOURS,
     MAX_DAILY_CUMULATIVE_CHANGE_PCT,
@@ -208,31 +210,50 @@ class Guardrail(ABC):
 class ActionConsistencyGuardrail(Guardrail):
     """决策一致性护栏:拦住 LLM 的自相矛盾输出。
 
-    典型问题(实测事故):
-      1. action=bid_down but pct=0 → 显示为 "降价 0%",人类看着就困惑
-      2. action=hold/observe/pause but pct != 0 → 维持类动作不应改出价
+    分两个子系列:
 
-    注:高 ROI 保护(ROI ≥ 渠道P50 × 1.05 禁止 bid_down)已由 prompt 硬约束承担,
-    这里不再做规则化拦截,避免和 LLM 判断重复。
+    A. 方向一致性(action ↔ pct 符号)
+       A1: action=bid_down 必须 pct < 0(否则改 hold)
+       A2: action=bid_up 必须 pct > 0
+       A3: action=hold/observe 必须 pct = 0
+
+    B. ROI 水位一致性(action ↔ 广告 ROI vs 渠道P50)
+       B1: bid_down 且 dynamic_roi_7d >= 渠道P50 × 1.05   → 改 hold(Top 1 实测事故)
+       B2: bid_up   且 dynamic_roi_7d <  渠道P50 × 0.75   → 改 hold(低效广告不能加码)
+       B3: scale_up 且 dynamic_roi_7d <  渠道P50 × 0.90   → 改 observe(扩量先证明效率)
+
+    依赖:
+       - row["动态ROI_7日均值"]:广告自身 ROI(由 ad_decision 合并到 decisions CSV)
+       - row["_channel_roi_p50"]:渠道P50(由 _run_guardrails 从 metrics 算后注入)
+
+    任一值缺失时 B 系列自动跳过(降级为纯 A 系列),保证旧数据仍可通过。
     """
 
     @property
     def name(self) -> str:
         return "决策一致性"
 
+    @staticmethod
+    def _to_float(val) -> Optional[float]:
+        """安全转 float:None/NaN/空串/非数字 → None。"""
+        if val is None or val == "":
+            return None
+        try:
+            f = float(val)
+            if pd.isna(f):
+                return None
+            return f
+        except (ValueError, TypeError):
+            return None
+
     def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
         action = str(row.get("action", "hold")).strip()
 
         # pct 标准化(None/NaN/非数字 → 0)
-        raw_pct = row.get("recommended_change_pct")
-        try:
-            pct = float(raw_pct) if raw_pct not in (None, "") else 0.0
-            if pd.isna(pct):
-                pct = 0.0
-        except (ValueError, TypeError):
-            pct = 0.0
+        pct = self._to_float(row.get("recommended_change_pct")) or 0.0
+
+        # ==================== A 系列:方向一致性 ====================
 
-        # ---- 规则 A1:bid_down 必须 pct < 0 ----
         if action == "bid_down" and pct >= 0:
             return GuardrailResult(
                 status="modified",
@@ -242,7 +263,6 @@ class ActionConsistencyGuardrail(Guardrail):
                 modified_bid=None,
             )
 
-        # ---- 规则 A2:bid_up 必须 pct > 0 ----
         if action == "bid_up" and pct <= 0:
             return GuardrailResult(
                 status="modified",
@@ -252,7 +272,6 @@ class ActionConsistencyGuardrail(Guardrail):
                 modified_bid=None,
             )
 
-        # ---- 规则 A3:hold/observe pct 必须为 0 ----
         if action in ("hold", "observe") and abs(pct) > 1e-6:
             return GuardrailResult(
                 status="modified",
@@ -262,6 +281,55 @@ class ActionConsistencyGuardrail(Guardrail):
                 modified_bid=None,
             )
 
+        # ==================== B 系列:ROI 水位一致性 ====================
+        # 缺数据时降级跳过(保护旧 CSV / 缺字段场景)
+        dynamic_roi_7d = self._to_float(row.get("动态ROI_7日均值"))
+        channel_roi_p50 = self._to_float(row.get("_channel_roi_p50"))
+        if dynamic_roi_7d is None or channel_roi_p50 is None or channel_roi_p50 <= 0:
+            return GuardrailResult(status="approved", reason="")
+
+        # B1: bid_down 但 ROI 优于渠道P50 × 提价线(1.05)→ 矛盾,改 hold
+        bid_up_line = channel_roi_p50 * BID_UP_ROI_FACTOR
+        if action == "bid_down" and dynamic_roi_7d >= bid_up_line:
+            return GuardrailResult(
+                status="modified",
+                reason=(
+                    f"ROI 水位矛盾:动态ROI={dynamic_roi_7d:.3f} ≥ 渠道P50×{BID_UP_ROI_FACTOR}"
+                    f"={bid_up_line:.3f}(优于渠道),不应 bid_down → 改 hold"
+                ),
+                modified_action="hold",
+                modified_change_pct=0.0,
+                modified_bid=None,
+            )
+
+        # B2: bid_up 但 ROI 低于关停线(×0.75)→ 矛盾,改 hold(低效不能加码)
+        pause_line = channel_roi_p50 * ROI_LOW_FACTOR
+        if action == "bid_up" and dynamic_roi_7d < pause_line:
+            return GuardrailResult(
+                status="modified",
+                reason=(
+                    f"ROI 水位矛盾:动态ROI={dynamic_roi_7d:.3f} < 渠道P50×{ROI_LOW_FACTOR}"
+                    f"={pause_line:.3f}(低效广告),不应 bid_up → 改 hold"
+                ),
+                modified_action="hold",
+                modified_change_pct=0.0,
+                modified_bid=None,
+            )
+
+        # B3: scale_up 但 ROI 低于渠道P50 × 0.90(降价线)→ 改 observe
+        scale_up_min_line = channel_roi_p50 * 0.90
+        if action == "scale_up" and dynamic_roi_7d < scale_up_min_line:
+            return GuardrailResult(
+                status="modified",
+                reason=(
+                    f"ROI 水位矛盾:动态ROI={dynamic_roi_7d:.3f} < 渠道P50×0.90"
+                    f"={scale_up_min_line:.3f}(效率未达标),不应扩量 → 改 observe"
+                ),
+                modified_action="observe",
+                modified_change_pct=0.0,
+                modified_bid=None,
+            )
+
         return GuardrailResult(status="approved", reason="")
 
 
@@ -575,6 +643,7 @@ def _run_guardrails(
     df: pd.DataFrame,
     data_date: str,
     dry_run: bool = False,
+    channel_roi_p50: Optional[float] = None,
 ) -> pd.DataFrame:
     """
     对决策 DataFrame 执行 6 道护栏检查。
@@ -584,6 +653,13 @@ def _run_guardrails(
       - guardrail_reason: 护栏说明
       - final_action: 护栏修正后的最终动作
       - final_bid: 护栏修正后的最终出价
+
+    Args:
+        df: 决策 DataFrame
+        data_date: 数据日期(YYYYMMDD)
+        dry_run: 是否强制干运行
+        channel_roi_p50: 渠道P50(全体广告动态ROI_7日均值的中位数),
+                     用于 ActionConsistencyGuardrail B 系列规则。缺省时 B 系列跳过。
     """
     history = AdjustmentHistory()
 
@@ -599,8 +675,10 @@ def _run_guardrails(
     ]
     guardrails = [g for g in guardrails if g is not None]
 
-    # 注入数据日期
+    # 注入数据日期 + 渠道P50(给 ActionConsistencyGuardrail B 系列用)
     df["_data_date"] = data_date
+    if channel_roi_p50 is not None and channel_roi_p50 > 0:
+        df["_channel_roi_p50"] = channel_roi_p50
 
     statuses = []
     reasons = []
@@ -659,7 +737,7 @@ def _run_guardrails(
     df["final_bid"] = final_bids
 
     # 清理临时列
-    df.drop(columns=["_data_date"], inplace=True, errors="ignore")
+    df.drop(columns=["_data_date", "_channel_roi_p50"], inplace=True, errors="ignore")
 
     return df
 
@@ -713,28 +791,40 @@ async def validate_decisions(
         if df.empty:
             return ToolResult(title="validate_decisions", output="决策数据为空")
 
-        # 补充广告年龄(如果缺失)
-        if "ad_age_days" not in df.columns:
-            metrics_csv = _MINI_DIR / "outputs" / "metrics_temp.csv"
-            if metrics_csv.exists():
+        # 读取一次 metrics 做补字段和渠道P50(供 ActionConsistencyGuardrail B 系列用)
+        metrics_csv = _MINI_DIR / "outputs" / "metrics_temp.csv"
+        channel_roi_p50: Optional[float] = None
+        if metrics_csv.exists():
+            try:
                 df_metrics = pd.read_csv(metrics_csv)
-                if "create_time" in df_metrics.columns:
+
+                # 补充广告年龄(如果缺失)
+                if "ad_age_days" not in df.columns and "create_time" in df_metrics.columns:
                     from ad_decision import _calculate_ad_age_days
                     df_metrics["ad_age_days"] = df_metrics["create_time"].apply(_calculate_ad_age_days)
                     age_map = df_metrics.set_index("ad_id")["ad_age_days"].to_dict()
                     df["ad_age_days"] = df["ad_id"].map(age_map)
 
-        # 补充当前出价(如果缺失)
-        if "current_bid" not in df.columns or df["current_bid"].isna().all():
-            metrics_csv = _MINI_DIR / "outputs" / "metrics_temp.csv"
-            if metrics_csv.exists():
-                df_metrics = pd.read_csv(metrics_csv)
-                if "bid_amount" in df_metrics.columns:
+                # 补充当前出价(如果缺失)
+                if ("current_bid" not in df.columns or df["current_bid"].isna().all()) \
+                        and "bid_amount" in df_metrics.columns:
                     bid_map = df_metrics.set_index("ad_id")["bid_amount"].to_dict()
                     df["current_bid"] = df["ad_id"].map(bid_map)
 
+                # 计算渠道P50:全体广告"动态ROI_7日均值"的中位数
+                if "动态ROI_7日均值" in df_metrics.columns:
+                    roi_series = df_metrics["动态ROI_7日均值"].dropna()
+                    if len(roi_series) > 0:
+                        channel_roi_p50 = float(roi_series.median())
+                        logger.info(f"护栏 B 系列启用:渠道P50={channel_roi_p50:.4f}")
+            except Exception as e:
+                logger.warning(f"读取 metrics 失败(护栏 B 系列将跳过): {e}")
+
+        if channel_roi_p50 is None:
+            logger.warning("未能计算渠道P50,ActionConsistencyGuardrail B 系列规则跳过")
+
         # 运行护栏链
-        df = _run_guardrails(df, data_date=end_date, dry_run=dry_run)
+        df = _run_guardrails(df, data_date=end_date, dry_run=dry_run, channel_roi_p50=channel_roi_p50)
 
         # 保存验证结果
         reports_dir = _MINI_DIR / "outputs" / "reports"