Explorar el Código

feat(auto_put_ad_mini): 提价双分支+删除广告防御+飞书表降噪+测试基建

决策准确性:
- 提价候选 bid_up_candidate 拆为 A/B 双分支 OR 聚合(接口对下游 5 处引用零侵入)
  - 分支 A(唤醒沉默):4-7 天 + 日均消耗 < 10 元 + CTR ≥ 同类×0.80
  - 分支 B(优质放量):4-7 天 + ROI > 均值×1.05 + 裂变 > 同类×1.10 + CTR 健康
- 4-7 天 + 低消耗广告从「自动 hold」改为「放行 LLM 评估」,对齐投手经验 §1.1
- 三段式年龄保护:≤3 天 hold / 4-7 天放行 / >7 天 pause

数据完整性:
- 新增 sync_ad_status.py:同步腾讯侧 SUSPEND/DELETED 状态到本地 ad_status CSV
- apply_decisions 增加 is_deleted 二次过滤(防止历史脏数据进入审批表)
- 入口新增 AD_STATUS_DELETED 过滤,候选漏斗更干净

体验降噪:
- 飞书审批表过滤 hold/observe 决策(不需运营立即干预的弱信号)
- xlsx 文件体积下降约 83%(99847 → 17085 bytes),运营单屏可读

测试基建:
- 新增 execute_once_test.py:无运营场景的 pipeline 端到端验证(wait_for_reply=False)

文档化:
- 新增 投手经验.md:双分支提价等业务规则的 ground truth
- 新增 docs/不走LLM的规则盘点.md:候选标记/年龄保护/进 LLM 路径的分析报告
- skills/roi_strategy.md §二·提价参考范围:补充 A/B 双分支说明
- prompts/system.prompt:去除冗余 token 预算细节

配套:
- tools/guardrails.py 新增 Guardrail 类
- tools/roi_calculator.py 多处兜底加固
- tools/feishu_doc.py 权限相关调整
- config.py / execute_once.py / run_decision_test.py / ARCHITECTURE.md 同步

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
刘立冬 hace 3 semanas
padre
commit
c525973d3f

+ 14 - 14
examples/auto_put_ad_mini/ARCHITECTURE.md

@@ -145,32 +145,32 @@
 处理:
   1. 计算全体 ROI 分布 (mean, p25, p50, p75, p90)
   2. 检测衰退信号 (提价、换创意、消耗下降)
-  3. 分为 A/B/C 三类:
+  3. 分为三类(语义化命名):
 
-     A类 (极端差, 自动关停):
+     【零消耗待关停】(极端差, 自动关停):
        • 7日均消耗 < 1元 (几乎零活动)
 
-     B类 (边缘, 需AI推理):
+     【待评估(候选)】(边缘, 需AI推理):
        • ROI < 全体均值 × 0.8 (偏低)
        • 或检测到衰退信号
 
-     C类 (正常, 自动保持):
+     【正常运行】(自动保持):
        • 其余广告
 
 输出: JSON结构化数据
   {
-    "summary": {total, class_a, class_b, class_c},
+    "summary": {total, zero_spend_ads, need_review_ads, normal_ads},
     "distribution": {roi_mean, p25, p50, p75, p90},
     "bid_adjustment": {bid_down_line, bid_up_line},
-    "class_a": [...],  # 自动关停列表
-    "class_b": [...],  # 需要推理的广告(含详细指标)
-    "class_c_summary": {...}
+    "zero_spend_ads": [...],  # 自动关停列表
+    "need_review_ads": [...], # 需要推理的广告(含详细指标)
+    "normal_ads_summary": {...}
   }
 ```
 
 #### 第5步:AI推理决策 (Agent自主推理)
 ```python
-# Agent读取B类广告数据 + roi-strategy.md技能
+# Agent读取【待评估(候选)】广告数据 + roi-strategy.md技能
 # 对每个广告进行推理,输出决策JSON
 
 决策逻辑框架:
@@ -220,7 +220,7 @@
 输入: decisions (AI推理的JSON) + metrics_csv
 处理:
   1. 解析AI输出的JSON
-  2. 合并 A类自动关停 + B类AI决策 + C类自动保持
+  2. 合并【零消耗待关停】自动关停 + 【待评估】AI决策 + 【正常运行】自动保持
   3. 过滤已暂停广告 (AD_STATUS_SUSPEND)
   4. 计算出价变更(current_bid × (1 + recommended_change_pct))
 
@@ -707,16 +707,16 @@ outputs/metrics_20260415.csv (1570行广告级指标)
 [ad_decision.py] get_ads_for_review
   • 计算全体ROI分布
   • 检测衰退信号
-  • 分类 A/B/C
+  • 分类(零消耗待关停 / 待评估 / 正常运行)
 JSON结构化数据 (发给AI)
-[Agent AI推理] 52个B类广告逐个分析
+[Agent AI推理] 52个【待评估(候选)】广告逐个分析
 decisions JSON (AI输出)
 [ad_decision.py] apply_decisions
-  • 合并A/B/C类决策
+  • 合并三类决策(零消耗关停 + 待评估AI + 正常保持)
   • 过滤已暂停广告
   • 计算出价变更
@@ -1060,7 +1060,7 @@ ERROR - fetch_creative_data失败: No columns to parse from file
 ### 常见问题4: AI推理偏保守
 ```bash
 # 症状
-52个B类广告, AI只建议暂停2个, 其余全hold
+52个【待评估(候选)】广告, AI只建议暂停2个, 其余全hold
 
 # 原因
 • ROI阈值设置过严格

+ 5 - 1
examples/auto_put_ad_mini/config.py

@@ -31,6 +31,7 @@ MAIN_CONFIG = RunConfig(
         "fetch_creative_data",
         "merge_creative_data",
         "calculate_roi_metrics",
+        "calculate_portfolio_summary",
         "get_ads_for_review",
         "apply_decisions",
         "query_ad_detail",      # Mode 2: 查询广告详情
@@ -42,9 +43,12 @@ MAIN_CONFIG = RunConfig(
         "check_execution_feedback",
         "send_approval_request",
         "check_approval_status",
-        "send_feishu_text_message",  # 执行后向运营汇报 diff / 确认 / 质疑回应
+        "send_feishu_text_message",  # 执行后向您同步 diff / 确认 / 质疑回应
         # 飞书文档(报告导入 & 分享):
         "import_to_feishu",
+        # 注:曾考虑用内置 "agent" 工具按 tier 并行委托子 Agent,
+        # 但框架的 agent 工具只返回文本 summary,主 Agent 拿不回结构化决策,
+        # 会陷入"无法 apply"的死循环。直接在主 Agent 单次输出完成全部 decisions 更可靠。
     ],
     skills=["ad-domain", "roi-strategy", "guardrail-rules", "tencent-ad-playbook"],
     # extra_llm_params={"extra_body": {"enable_thinking": True}},  # 禁用:千问不支持 thinking

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

@@ -0,0 +1,378 @@
+# auto_put_ad_mini:不走 LLM 的规则盘点(分析报告)
+
+## Context
+
+用户想理清:`examples/auto_put_ad_mini` 里一条广告从数据进来到决策输出,**除了"零消耗"这一条规则外,还有哪些规则会让它绕过 LLM 推理**。目的是让运营/开发方能明确知道"什么数据根本走不到 LLM,什么数据 LLM 的输出会被事后覆盖"。
+
+这是一个**纯分析任务,不修改任何代码**。结论写入本文件即可。
+
+---
+
+## 一、走 LLM 的流程是什么(一条广告如何被"喂"给 LLM)
+
+走 LLM 就是一条广告被放进 `need_review_ads` 列表 → `apply_decisions` 在这份列表上拿 LLM 的 JSON 决策。流程分四步:
+
+### 步骤 1:数据就绪(前置依赖)
+由 `execute_once.py` 按顺序跑:
+```
+fetch_creative_data → merge_creative_data → calculate_roi_metrics → get_ads_for_review
+                                              ↑
+                                   产出 metrics CSV(含 f_7日动态ROI、ad_age_days、
+                                   cost_7d_avg、stable_spend_days_30d、audience_tier、
+                                   tier_roi_p25/p50/p75、tier_fission_mean 等)
+```
+
+### 步骤 2:计算 5 个候选标记(`ad_decision.py:405-492`)
+对 metrics 里每条广告,用规则计算这 5 个 bool:
+
+| 标记 | 含义 | 条件(核心) |
+|------|------|------|
+| `roi_low` | ROI 偏低,需评估关停 | `f_roi < roi_mean × 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` |
+
+另外有一个升级路径 `persistent_low_roi`(`ad_decision.py:464-482`):ROI 在 0.75~0.90 之间、距上次降价 ≥ 7 天 → 把 `roi_low` 从 False 升级为 True(从"降价候选"升级为"关停候选")。
+
+### 步骤 3:两道过滤(前置过滤,不经 LLM)
+标记算完后,**进 LLM 前**还要过两道硬规则:
+
+1. **消耗稳定性前置门控**(`ad_decision.py:495-504`):成熟期(>7天)但 `stable_days < 7` → 清空 `roi_low/decay/bid_down`。
+2. **年龄保护**(`ad_decision.py:510-547`):
+   - `ad_age ≤ 3`:**整条跳过**,不进 LLM(归入正常运行)。
+   - `ad_age ≤ 7`:清空 `roi_low/decay/bid_down`;若清完后没有 `bid_up/scale_up` 任何一个也跳过。
+
+### 步骤 4:进入 LLM 的门槛(`ad_decision.py:556`)
+最终条件:**5 个候选标记里有任意 1 个为 True**
+```python
+if roi_low or decay_signal or bid_up_candidate or bid_down_candidate or scale_up_candidate:
+    need_review_ads.append(ad_dict)   # 进 LLM
+```
+
+这时会构造一个 `ad_dict`(`ad_decision.py:558-589`),除基础指标外还带上**同人群包的统计对比基准**(`tier_roi_p25/p50/p75/mean`、`tier_fission_mean`、`tier_ctr_mean`、`roi_valid_days` 等),给 LLM 做"同类对比"。
+
+### 步骤 5:LLM 决策(由 Agent Runner 驱动)
+Agent 读 Skills(roi_strategy.md / guardrail_rules.md / ad_domain.md)+ `need_review_ads` 里的广告列表,对**每条广告**逐一推理产出:
+```json
+{
+  "ad_id": ...,
+  "action": "pause|bid_down|bid_up|scale_up|observe|hold",
+  "dimension": "...",
+  "reason": "自然语言解释",
+  "confidence": "high|medium|low",
+  "recommended_change_pct": 0.05
+}
+```
+然后调用 `apply_decisions(decisions=[...], metrics_csv=...)`。
+
+### 步骤 6:LLM 输出后再过滤(仍可能被改写)
+`apply_decisions` 会把 LLM 的 JSON 与 metrics CSV merge,并执行兜底:
+- 冷启动/成长期的 bid_down/pause/bid_up 一律强制改为 `observe`(`ad_decision.py:831-858`)
+- 与规则产出的 `zero_spend`、`hold` 合并成最终决策表
+- 最终再走一遍 `guardrails.py` 护栏(block/钳位)
+
+---
+
+## 一补、进 LLM 的"最小充分条件"速查
+
+一条广告能进 LLM ⇔ 同时满足:
+
+1. **不是"成熟期 (>7天) + 零消耗"**(成熟期 + cost_7d_avg<10 → 规则直接 pause;4-7天 + 零消耗仍可进 LLM 走"提价分支A")
+2. `ad_age > 3`(过了冷启动)
+3. 不属于"成熟期但消耗不稳定"
+4. 至少命中以下之一:
+   - (成熟期 >7 天)ROI 低且昨日消耗 ≥300 → `roi_low`
+   - (成熟期 >7 天)消耗低迷但调过价/换过创意 → `decay_signal`
+   - (4-7 天)任一分支 → `bid_up_candidate`:
+     - **A**:日均消耗 < 10元 + CTR 正常(唤醒沉默)
+     - **B**:ROI 高 + 消耗<1000 + 裂变/CTR 正常(优质放量)
+   - (任何年龄>3,不在早期成长期)ROI 中低 + 消耗≥500 + 裂变偏低 → `bid_down_candidate`
+   - (成熟期 >7 天)ROI 正常 + 高消耗稳定 → `scale_up_candidate`
+
+反过来,以下广告**一定不会走 LLM**(属于规则自动判决或被 hold):
+- **成熟期(>7天)+ 零消耗(cost_7d_avg < 10)** → 规则 pause
+- **冷启动期(ad_age ≤ 3)+ 零消耗** → hold 保护
+- 冷启动期(ad_age ≤ 3)所有广告(不区分消耗)
+- 昨日消耗 < 300 且 ROI 低(低消耗广告的 ROI 不具统计意义)
+- 成熟期但 stable_days < 7 且所有信号都被清
+- 5 个候选都是 False(ROI 在同类中轴附近、消耗平稳)
+- 状态已经是 AD_STATUS_SUSPEND
+
+---
+
+## 二、整体分流结构(三阶段)
+
+```
+数据 → 【阶段 1】get_ads_for_review  分三类:零消耗 / 候选 / 正常
+            │
+            ├─ 零消耗  → 规则产出 pause(不走 LLM)
+            ├─ 候选    → 进 LLM 推理
+            └─ 正常    → 规则产出 hold(不走 LLM)
+            ↓
+       【阶段 2】apply_decisions  合并 LLM 输出 + 规则决策 + 兜底过滤
+            ↓
+       【阶段 3】guardrails  护栏拦截/钳位(LLM 后过滤)
+```
+
+**走 LLM 的只有"候选"这一条线**。其他都是规则。
+
+关键文件:
+- `Agent/examples/auto_put_ad_mini/tools/ad_decision.py`
+- `Agent/examples/auto_put_ad_mini/tools/guardrails.py`
+- `Agent/examples/auto_put_ad_mini/config.py`
+
+---
+
+## 二、阶段 1 — `get_ads_for_review()` 里的分流规则
+
+### A. 零消耗类(规则直接出决策,不走 LLM)
+
+| # | 规则 | 条件 | 动作 | 位置 |
+|---|------|------|------|------|
+| 1.1a | **零消耗 + 冷启动保护** | `cost_7d_avg < 10` 且 `ad_age ≤ 3` | 归入"正常运行"(hold),**完全保护不评估** | `ad_decision.py:394-402` |
+| 1.1b | **零消耗 + 早期成长期放行** | `cost_7d_avg < 10` 且 `4 ≤ ad_age ≤ 7` | **不归入 hold,放行进入候选评估**(命中"提价分支A"则提价唤醒) | `ad_decision.py:403-409` |
+| 1.2 | **零消耗 + 长期不跑** | `cost_7d_avg < 10` 且 `ad_age > 7` | **规则产出 pause**,进 `zero_spend_ads` | `ad_decision.py:410-417` |
+
+涉及常量:`NO_SPEND_THRESHOLD=10`、`EARLY_GROWTH_DAYS=7`。
+
+### B. 年龄保护类(直接 skip LLM 或清除负向信号)
+
+| # | 规则 | 条件 | 动作 | 位置 |
+|---|------|------|------|------|
+| 1.3 | **冷启动期极度保护** | `ad_age ≤ 3` | `continue` 跳过,归入"正常运行"(hold) | `ad_decision.py:512-518` |
+| 1.4 | **早期成长期清除负向** | `4 ≤ ad_age ≤ 7` 且有 `roi_low/decay/bid_down` 任一 | 强制把 `roi_low/decay_signal/bid_down_candidate` 全置 False;若没有提价/扩量候选 → 直接 skip | `ad_decision.py:522-547` |
+
+涉及常量:`COLD_START_DAYS=3`、`EARLY_GROWTH_DAYS=7`。
+
+### C. 数据质量类(不满足消耗门槛就不评估)
+
+| # | 规则 | 条件 | 动作 | 位置 |
+|---|------|------|------|------|
+| 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.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`。
+
+### D. 候选门控
+
+| # | 规则 | 条件 | 动作 | 位置 |
+|---|------|------|------|------|
+| 1.7 | **无候选即 hold** | 前面所有标记清洗后 `roi_low / decay_signal / bid_up_candidate / bid_down_candidate / scale_up_candidate` **全为 False** | 不进 LLM,归入"正常运行"(hold) | `ad_decision.py:556` |
+
+**换言之:能进 LLM 的前提 = 至少带一个候选标记,并且在上面 B/C 类没被洗掉。**
+
+---
+
+## 三、阶段 2 — `apply_decisions()` 的补洞与后过滤
+
+这一层既会**补充规则决策**(给没进 LLM 的广告兜底动作),也会**覆盖 LLM 的错误输出**(兜底检查)。
+
+| # | 规则 | 条件 | 动作 | 位置 |
+|---|------|------|------|------|
+| 2.1 | **零消耗再检** | `cost_7d_avg < 10` 且 `ad_age > 7`(无论 LLM 有没有给) | 强制 `action=pause, dimension=长期零消耗, source=规则判断` | `ad_decision.py:782-805` |
+| 2.2 | **冷启动兜底覆盖** | LLM 对 `ad_age ≤ 3` 的广告给了 `bid_down / pause / bid_up` | **强制改写 `action=observe`**,并打 `logger.error("⚠️ 兜底检查触发!")` | `ad_decision.py:831-844` |
+| 2.3 | **早期成长期兜底覆盖** | LLM 对 `4 ≤ ad_age ≤ 7` 的广告给了 `bid_down / pause` | 强制改写 `action=observe`,打 error 日志 | `ad_decision.py:845-858` |
+| 2.4 | **正常运行自动 hold** | 广告既不在 `zero_spend` 也不在 `need_review` | 自动补一条 `action=hold`;若 `ad_age ≤ 3` 则 `dimension=冷启动保护`,否则 `正常运行` | `ad_decision.py:865-903` |
+| 2.5 | **已暂停广告过滤** | `ad_status == AD_STATUS_SUSPEND` | 从决策表中 drop 掉 | `ad_decision.py:948-964` |
+
+**2.2 / 2.3 是对 LLM 输出的硬约束**:即使 LLM 推理错了,阶段 2 也会把不符合年龄保护的动作改成 observe。
+
+---
+
+## 四、阶段 3 — 护栏层 `guardrails.py`
+
+护栏在决策下发前再过一遍,可 `block`(改为 hold)或 `modified`(钳位数值)。
+
+| # | 护栏 | 触发条件 | 结果 | 位置 |
+|---|------|---------|------|------|
+| 3.1 | **冷启动护栏** | `age ≤ 3` 且 action∈{pause, bid_down, bid_up};或 `4-7 天` 且 action∈{pause, bid_down} | **block → hold** | `guardrails.py:208-241` |
+| 3.2 | **数据新鲜度** | `_data_date` 距今 > 96h 且 action ≠ hold | **block → hold** | `guardrails.py:249-275` |
+| 3.3 | **出价边界** | 推荐出价 < 0.05 元 或 > 1.00 元 | **modified**(钳位到边界值) | `guardrails.py:283-321` |
+| 3.4 | **调幅范围** | 提价不在 5-10%、降价不在 3-5% | **modified**(钳位到区间内) | `guardrails.py:329-371` |
+| 3.5 | **频率限制** | 当日调整 ≥ 2 次 / 距上次 < 6h / 日累计调幅 > 20% | **block → hold** | `guardrails.py:379-419` |
+| 3.6 | **单日操作上限** | 累计操作数 ≥ `MAX_DAILY_OPS` | **block** | `guardrails.py:420+` |
+| 3.7 | **Dry-run 模式** | `DRY_RUN_MODE=True` | **标记 dry_run**,不真正执行(不改 action) | `guardrails.py:420+` |
+
+涉及常量:`BID_FLOOR_YUAN=0.05`、`BID_CEILING_YUAN=1.00`、`BID_UP_MIN/MAX_PCT=0.05/0.10`、`BID_DOWN_MIN/MAX_PCT=0.03/0.05`、`MAX_ADJUSTMENTS_PER_AD_PER_DAY=2`、`MIN_ADJUSTMENT_INTERVAL_HOURS=6`、`MAX_DAILY_CUMULATIVE_CHANGE_PCT=0.20`、`DATA_FRESHNESS_MAX_HOURS=96`。
+
+---
+
+## 五、按"为什么不走 LLM"维度归纳
+
+把所有规则重新组织成 5 类,便于记忆:
+
+### 1) 零消耗直接判定(2 条)
+- `1.1`:低消耗 + 年龄≤7 → hold(保护)
+- `1.2`、`2.1`:低消耗 + 年龄>7 → pause(规则关停)
+
+### 2) 年龄保护(5 条)
+- `1.3`、`3.1`:冷启动 ≤3 天 → 任何动作都被阻断,只能 hold
+- `1.4`、`2.3`、`3.1`:成长期 4-7 天 → 不允许 bid_down / pause,越界全部改 observe 或 block
+
+### 3) 数据质量门槛(3 条)
+- `1.5`:昨日消耗 <300 元、年龄 ≤3 → 不被判为 "roi_low"
+- `1.6`:成熟期稳定天数 <7 → 清除所有负向信号
+- `3.2`:数据 >96 小时 → block
+
+### 4) 候选门控(1 条)
+- `1.7`:所有候选标记都为 False → 默认 hold,不进 LLM
+
+### 5) 状态过滤 / 操作频次 / 边界钳位(5 条)
+- `2.5`:已暂停广告整体移出决策表
+- `3.3`、`3.4`:出价和调幅钳到业务区间
+- `3.5`、`3.6`:频次与日操作上限
+- `3.7`:干运行模式
+
+---
+
+## 六、关键洞察
+
+1. **LLM 实际能看到的广告集合很小**:必须同时满足 — 不是零消耗、年龄在 4 天以上、昨日消耗达标(或有提价信号)、消耗稳定、至少带一个候选标记。
+2. **年龄保护是贯穿三阶段的冗余防御**:阶段 1 排除 → 阶段 2 兜底改写 + error 日志 → 阶段 3 护栏 block。任一层漏掉都能被下一层兜住。
+3. **"兜底检查触发"是个错误信号**:CLAUDE.md 明确说阶段 2 的 2.2/2.3 理论上不应触发,一旦触发说明阶段 1 的年龄保护漏了。可以靠 `grep "兜底检查触发" outputs/decision_log_*.log` 监控。
+4. **`source=规则判断` 标签**是一个重要的可观测字段:报告里凡带这个标签的都不是 LLM 出的决策。
+5. **硬约束的优先级**:规则 > LLM。即使 LLM 说 pause,只要碰到 2.2/2.3/3.1 这类硬规则,最终一定会被改写。
+
+---
+
+## 七、验证方法(只读检查,不改代码)
+
+```bash
+cd Agent/examples/auto_put_ad_mini
+
+# 1. 查看最近一次运行中,各分类广告数量
+grep -E "零消耗|候选|正常运行|need_review|zero_spend" outputs/decision_log_*.log | tail -30
+
+# 2. 查 LLM 后过滤是否触发(应该永远为 0)
+grep "兜底检查触发" outputs/decision_log_*.log | wc -l
+
+# 3. 看护栏拦截记录
+grep -E "blocked|modified" outputs/execution_log/*.json | head
+
+# 4. 报表里的决策来源分布
+python3 -c "import pandas as pd; df=pd.read_csv(sorted(__import__('glob').glob('outputs/reports/decisions_*.csv'))[-1]); print(df.groupby(['source','action']).size())"
+
+# 5. 核对代码行号是否与报告一致
+sed -n '370,560p' tools/ad_decision.py
+sed -n '780,910p' tools/ad_decision.py
+sed -n '200,420p' tools/guardrails.py
+```
+
+---
+
+## 八、结论(一句话版)
+
+> 除"零消耗"外,还有 **年龄保护(冷启动 ≤3 / 成长期 4-7)**、**数据质量门槛(昨日消耗<300、稳定天数<7、数据>96h)**、**候选门控(无任何候选标记)**、**状态过滤(已 SUSPEND)**、**护栏约束(出价边界、调幅范围、频率上限、日累计、干运行)**,合计 17 条规则让一条广告绕开 LLM、或覆盖 LLM 的输出。
+
+---
+
+*本文件为分析报告,不涉及代码改动。*
+
+---
+
+## 附录 A:运行时 Trace 可视化(框架原生支持)
+
+确认:**框架原生支持运行时实时可视化**,不是只能事后看。组件分三层:
+
+### A.1 后端:FastAPI + WebSocket(`Agent/api_server.py`)
+
+启动命令(默认监听 `0.0.0.0:8000`):
+```bash
+cd Agent
+python3 api_server.py
+```
+
+提供的接口(聚合在 `agent/trace/` 下):
+
+| 类型 | 路由 | 说明 |
+|------|------|------|
+| REST | `GET /api/traces` | 列表(含运行中 trace) |
+| REST | `GET /api/traces/{id}` | 单 trace 详情(GoalTree + Messages + Sub-Traces) |
+| REST | `GET /api/traces/running` | 正在跑的 trace |
+| REST | `POST /api/traces` | 启动 / 停止 / 反思 |
+| REST | `GET /api/experiences` | 经验沉淀 |
+| REST | `GET /api/examples` | 示例项目枚举 |
+| **WS** | `ws://host:8000/api/traces/{id}/watch?since_event_id=N` | **实时推送**(断线续传) |
+| WS | `ws://host:8000/logs` | 实时日志流 |
+
+WebSocket 事件类型(`agent/trace/websocket.py:41-107`):
+- `connected` — 连接时返回 `goal_tree` + `sub_traces` 快照 + `is_running`
+- `goal_added` / `goal_updated` — 目标树节点增加或状态变化
+- `message_added` — 新 assistant/tool message(含 `affected_goals`)
+- `sub_trace_started` / `sub_trace_completed` — 子 agent 生命周期
+- `trace_completed` — 整条 trace 执行结束
+
+断线续传靠 `since_event_id` 参数,服务端从 `events.jsonl` 补发缺失事件(最多 100 条)。
+
+### A.2 前端:React(`Agent/frontend/react-template/`)
+
+已经实现了完整可视化 UI:
+
+```
+src/components/
+├── AgentControlPanel     # 启停 agent
+├── FlowChart             # 目标树 + 流程图(主 trace + sub trace 嵌套)
+├── MainContent           # 主视图
+├── DetailPanel           # 单条 message / tool 调用的入参出参详情
+├── TopBar
+├── CreateKnowledgeModal / KnowledgeFeedbackModal  # 知识沉淀
+└── ImagePreview
+```
+
+启动:
+```bash
+cd Agent/frontend/react-template
+npm install           # 首次
+npm run dev           # Vite dev server(默认 3000 或 5173)
+```
+
+### A.3 Trace 文件落盘(即使不开前端也能直接读)
+
+每次 `execute_once.py` 运行都会写到:
+```
+examples/auto_put_ad_mini/.trace/<trace_id>/
+├── meta.json          # trace 元数据(status、时间、config)
+├── goal.json          # GoalTree 结构
+├── events.jsonl       # 事件流(WebSocket 的来源)
+├── messages/          # LLM 消息体(assistant / tool / user)
+└── model_usage.json   # token 消耗
+```
+
+子 agent 的 trace 走 `<parent>@delegate-时间戳-序号` 或 `<parent>@explore-时间戳-序号` 命名,在同一目录下同级落盘,前端会自动串成嵌套树。
+
+### A.4 配合 auto_put_ad_mini 的一个注意点
+
+`api_server.py:62` 写死 `FileSystemTraceStore(base_path=".trace")`——按**启动时的工作目录**找 trace。而 mini 项目的 trace 默认写到 `examples/auto_put_ad_mini/.trace`(`config.py:59` 的 `TRACE_STORE_PATH=".trace"`)。三种做法任选:
+
+1. 在 `examples/auto_put_ad_mini/` 目录下启动 `api_server.py`
+2. 改 `api_server.py` 的 `base_path` 指向 mini 的 `.trace/`
+3. 软链接:`ln -s ../../examples/auto_put_ad_mini/.trace/* Agent/.trace/`
+
+### A.5 实时可视化推荐姿势(三终端)
+
+```bash
+# 终端 A — 起 API(从 mini 项目目录启动,base_path 指到它的 .trace)
+cd Agent/examples/auto_put_ad_mini
+python3 ../../api_server.py
+
+# 终端 B — 起前端
+cd Agent/frontend/react-template && npm run dev
+# 浏览器打开 http://localhost:5173 (或 3000)
+
+# 终端 C — 跑 Agent,就能在浏览器里实时看流程图推进
+cd Agent/examples/auto_put_ad_mini
+.venv/bin/python3 execute_once.py
+```
+
+跑起来后浏览器里能看到:
+- 主 agent 的目标树节点逐个亮起
+- 每个 tool 调用的入参 / 出参 / 耗时
+- LLM 的每一步思考(assistant message)
+- 子 agent(通过 delegate/explore 创建)以嵌套子树展示
+- token 消耗实时累计
+- 断开 WebSocket 重连会自动补历史事件
+
+### A.6 结论
+
+> **支持。** 框架自带 REST + WebSocket + React 前端三件套,可以实时观察 `auto_put_ad_mini` 10 步 pipeline 的每一步,包括子 agent 的嵌套执行与每次工具调用的完整入参出参。只要启动顺序正确(API 指向项目的 `.trace` 目录、前端 dev server、agent 执行),浏览器里能看到活动的目标树与消息流。

+ 1 - 1
examples/auto_put_ad_mini/execute_once.py

@@ -83,7 +83,7 @@ async def main():
     print()
 
     # 让Agent自动决定使用哪天的数据(默认yesterday)
-    messages = [{"role": "user", "content": "分析广告,执行完整的ROI计算和决策流程。"}]
+    messages = [{"role": "user", "content": "分析广告,执行完整的ROI计算和决策流程。请使用 2026-04-19(end_date=20260419)作为数据截止日期,因为 2026-04-20 的数据尚未回流。"}]
     config.trace_id = None
 
     step_count = 0

+ 142 - 0
examples/auto_put_ad_mini/execute_once_test.py

@@ -0,0 +1,142 @@
+"""
+端到端验证脚本 — 用于无运营场景的 pipeline 测试
+
+与 execute_once.py 的区别:
+  - 用户消息里明确告诉 Agent"运营当前不在,发完审批请求不要阻塞等待"
+  - send_approval_request 用 wait_for_reply=False
+  - 跳过 execute_decisions(没审批结果不执行)
+  - 生成报告收尾
+"""
+import asyncio
+import os
+import sys
+from pathlib import Path
+
+# 代理设置
+os.environ.setdefault("HTTP_PROXY", "http://127.0.0.1:29758")
+os.environ.setdefault("HTTPS_PROXY", "http://127.0.0.1:29758")
+
+# 添加项目根目录到 Python 路径
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from agent.core.runner import AgentRunner
+from agent.trace import FileSystemTraceStore, Trace, Message
+from agent.llm import create_openrouter_llm_call
+from agent.utils import setup_logging
+
+from examples.auto_put_ad_mini.config import (
+    MAIN_CONFIG, SKILLS_DIR, TRACE_STORE_PATH, LOG_LEVEL, LOG_FILE,
+)
+
+# 导入自定义工具
+from examples.auto_put_ad_mini.tools.data_query import fetch_creative_data, merge_creative_data
+from examples.auto_put_ad_mini.tools.roi_calculator import calculate_roi_metrics
+from examples.auto_put_ad_mini.tools.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
+from examples.auto_put_ad_mini.tools.guardrails import validate_decisions
+from examples.auto_put_ad_mini.tools.execution_engine import execute_decisions, check_execution_feedback
+from examples.auto_put_ad_mini.tools.im_approval import send_approval_request, check_approval_status, send_feishu_text_message
+
+try:
+    from examples.auto_put_ad_mini.tools.feishu_doc import import_to_feishu
+except ImportError:
+    pass
+
+
+async def main():
+    base_dir = Path(__file__).parent
+    setup_logging(level=LOG_LEVEL, file=LOG_FILE)
+
+    prompt_path = base_dir / "prompts" / "system.prompt"
+    system_prompt = prompt_path.read_text(encoding="utf-8") if prompt_path.exists() else ""
+
+    presets_path = base_dir / "presets.json"
+    if presets_path.exists():
+        from agent.core.presets import load_presets_from_json
+        load_presets_from_json(str(presets_path))
+
+    store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
+    runner = AgentRunner(
+        trace_store=store,
+        llm_call=create_openrouter_llm_call(model=MAIN_CONFIG.model),
+        skills_dir=SKILLS_DIR if Path(SKILLS_DIR).exists() else None,
+        logger_name="agents.auto_put_ad_mini_test",
+    )
+
+    config = MAIN_CONFIG
+    if system_prompt:
+        config.system_prompt = system_prompt
+
+    print("=" * 70)
+    print("  [端到端验证] 广告智能调控助手 — 无运营模式")
+    print("=" * 70)
+    print()
+
+    # 关键指令(测试用):告诉 Agent 跳过阻塞等待
+    user_message = (
+        "分析广告,执行完整的ROI计算和决策流程。请使用 2026-04-19(end_date=20260419)"
+        "作为数据截止日期,因为 2026-04-20 的数据尚未回流。\n\n"
+        "⚠️ 本次为端到端验证运行,运营当前不在:\n"
+        "1. send_approval_request 必须用 wait_for_reply=False(不要阻塞等待飞书回复)\n"
+        "2. 发送完审批请求后,直接调用 generate_report 生成报告收尾\n"
+        "3. 不要调用 execute_decisions,因为没有实际审批通过\n"
+        "4. 决策数量较多时,请严格按第四部分的 agent(task=[...]) 并发模式按 tier 拆分"
+    )
+    messages = [{"role": "user", "content": user_message}]
+    config.trace_id = None
+
+    step_count = 0
+    last_approval_path = None
+
+    try:
+        async for item in runner.run(messages=messages, config=config):
+            if isinstance(item, Trace):
+                if item.status == "completed":
+                    print(f"\n✅ [Trace] 完成 id={item.trace_id}")
+                elif item.status == "failed":
+                    print(f"\n❌ [Trace] 失败 id={item.trace_id}")
+
+            elif isinstance(item, Message):
+                if item.role == "assistant" and item.content:
+                    content = item.content
+                    text = content.get("text", "") if isinstance(content, dict) else content
+                    if text and text.strip():
+                        truncated = text[:300] + ("..." if len(text) > 300 else "")
+                        print(f"\n💭 {truncated}\n")
+
+                elif item.role == "tool" and item.content:
+                    content = item.content
+                    if isinstance(content, dict):
+                        tool_name = content.get("tool_name", "unknown")
+                        result = content.get("result", content.get("text", str(content)))
+                        step_count += 1
+                        marker = f"📌 步骤 {step_count}: {tool_name}"
+                        print(f"\n{'='*70}\n{marker}\n{'='*70}")
+                        text = result if isinstance(result, str) else str(result)
+                        print(text[:400] + ("..." if len(text) > 400 else ""))
+
+                        # 记录最后一个审批消息文件路径
+                        if tool_name == "send_approval_request" and isinstance(result, str) and "req_" in result:
+                            import re
+                            m = re.search(r"req_\d+_\d+_[a-f0-9]+", result)
+                            if m:
+                                last_approval_path = f"outputs/approvals/{m.group(0)}.txt"
+
+        print("\n" + "=" * 70)
+        print("✅ 执行完成")
+        print("=" * 70)
+        if last_approval_path:
+            print(f"📂 审批消息文件: {last_approval_path}")
+
+    except Exception as e:
+        print(f"\n❌ 执行失败: {e}")
+        import traceback
+        traceback.print_exc()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 136 - 437
examples/auto_put_ad_mini/prompts/system.prompt

@@ -5,19 +5,13 @@ $system$
 
 # 第一部分:你是谁
 
-**你是经验丰富的广告投放优化师**,专注微信小程序投流场景的ROI优化。
+**你是经验丰富的广告投放优化师**,专注微信小程序投流场景的 ROI 优化。
 
-**思维方式**:
-- 像投放专家一样思考,而不是像计算器一样计算
-- 基于业务目标(最大化总收益)而非单纯优化ROI数值
-- 综合运用投放经验、多维度信号、数据分析做决策
-- 每个决策都能清晰解释背后的业务逻辑
-
-**核心能力**:
-- 识别广告生命周期(冷启动/成长/成熟/衰退)
-- 判断健康/预警/危险信号
-- 识别特殊场景(调价无效、创意问题、数据不稳定)
-- 平衡止损与放量,追求总收益最大化
+**风格**:
+- 像投放专家思考,不像计算器算公式
+- 综合多维度信号 + 投放经验做决策,每个决策都能清晰解释业务逻辑
+- 平衡止损与放量,目标是最大化总收益(而非单纯优化 ROI 数值)
+- 识别广告生命周期、健康/预警/危险信号、特殊场景(调价无效/创意问题/数据不稳定)
 
 # 第二部分:运行前提
 
@@ -28,31 +22,9 @@ $system$
 
 ## 工具、Skill、你的关系
 
-**工具(Tools)** — 数据提供者:
-- 负责提供广告数据和统计信息
-- 例如:`get_ads_for_review` 返回广告数据、同类对比统计(tier_roi_p50等)、阈值参考值
-- 工具只负责"提供事实",不做决策判断
-
-**Skill(投放经验知识库)** — 决策核心:
-- **Skill是决策的核心依据**,包含真实的投放经验、判断逻辑、后验观察
-- 用**自然语言**描述决策原则,而非代码或公式
-- 告诉你"什么情况下应该怎么做","为什么这样做","过去观察到什么规律"
-- 例如:roi-strategy skill 包含人群包对比原则、广告年龄策略、后验经验观察等
+**工具提供事实**(广告数据、tier 统计、阈值参考),**Skill 提供判断原则**(决策经验、年龄策略、后验观察),**你像法官一样综合判断**:读证据 → 依原则 → 出决策 → 用中文解释。
 
-**你的角色** — 决策者(像法官一样):
-- 读取工具提供的"证据"(广告数据、统计信息)
-- 依据Skill中的"法律"(决策原则、经验知识)
-- 综合分析多维度信号
-- 做出符合投放经验的决策,并用自然语言清晰解释理由
-
-```
-工具提供数据(证据)+ Skill提供决策原则(法律)→ 你综合判断(法官)→ 输出决策
-```
-
-**关键理解**:
-- Skill不是"模板"或"解释生成器",而是**决策逻辑的载体**
-- 你应该深入理解Skill中的经验原则,而非机械套用数值
-- 阈值(如tier_roi_p50 × 1.05)只是参考,真正的决策依据是Skill中的业务洞察
+阈值(如 `tier_roi_p50 × 1.05`)只是参考,**真正的决策依据是 Skill 中的业务洞察**——别机械套数值。
 
 # 第三部分:意图理解(理解语义,非关键词匹配)
 
@@ -72,62 +44,22 @@ $system$
 
 ## 可用工具列表
 
-### 数据获取类
-- **fetch_creative_data(days, end_date)**
-  - 功能:从ODPS拉取创意级原始数据 + 广告状态快照
-  - 输出:outputs/raw/ 和 outputs/ad_status/ 下的CSV
-  - ⚠️ **必须在 calculate_roi_metrics 之前调用**
-
-- **merge_creative_data(days, force)**
-  - 功能:合并创意数据与广告状态
-  - 输出:outputs/merged/ 下的CSV
-  - 依赖:需要先执行 fetch_creative_data
-  - ⚠️ **必须在 calculate_roi_metrics 之前调用**
-
-- **calculate_roi_metrics(end_date)**
-  - 功能:计算f_7日动态ROI、汇总指标
-  - 输出:metrics CSV路径
-  - 依赖:需要 outputs/merged/ 目录下有最新数据(不会自动拉取!)
-  - ⚠️ **必须先完成 fetch + merge,否则会因数据缺失得到错误结果**
-
-- **get_ads_for_review(metrics_csv)**
-  - 功能:分类广告(零消耗/待评估/正常)
-  - 输入:metrics CSV路径
-  - 输出:分类结果 + thresholds_used
-  - 依赖:需要先执行 calculate_roi_metrics
-
-- **query_ad_detail(ad_id, metrics_csv)**
-  - 功能:查询单个广告详情
-  - 输入:广告ID
-  - 输出:该广告数据 + 全局上下文
-
-### 决策生成类
-- **apply_decisions(decisions, metrics_csv)**
-  - 功能:保存AI决策(自动合并零消耗/正常运行广告)
-  - 输入:AI生成的决策JSON
-  - 依赖:需要先有get_ads_for_review或query_ad_detail的数据
-
-- **modify_decisions(modifications)**
-  - 功能:修改已保存的决策
-  - 输入:修改指令JSON
-  - 用于:运营反馈修改场景
-
-### 验证执行类
-- **validate_decisions()**
-  - 功能:护栏验证(冷启动、频率、出价边界)
-  - 依赖:需要先有决策文件
-
-- **send_approval_request(wait_for_reply)**
-  - 功能:发飞书审批消息,等待运营回复
-  - 依赖:需要先通过validate_decisions
-
-- **execute_decisions()**
-  - 功能:执行审批通过的决策
-  - 依赖:需要先获得审批
-
-- **generate_report()**
-  - 功能:生成最终报告
-  - 时机:执行后或不执行时
+**数据获取**(必须按顺序):
+- `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 会得到错误结果)
+- `get_ads_for_review(metrics_csv)` — 三级分类(零消耗/待评估/正常)
+- `query_ad_detail(ad_id, metrics_csv)` — 单广告详情 + 全局上下文
+
+**决策生成**:
+- `apply_decisions(decisions, metrics_csv)` — 保存决策(自动合并零消耗/正常广告)
+- `modify_decisions(modifications)` — 修改已保存决策(运营反馈场景)
+
+**验证执行**:
+- `validate_decisions()` — 护栏验证(冷启动/频率/出价边界)
+- `send_approval_request(wait_for_reply)` — 发飞书审批,阻塞等回复
+- `execute_decisions()` — 执行审批通过的决策
+- `generate_report()` — 生成最终报告
 
 ## 工具编排原则
 
@@ -145,8 +77,10 @@ Step 3: calculate_roi_metrics     ← 计算ROI(依赖Step 1+2的数据)
 Step 4: get_ads_for_review        ← 三级分类(零消耗待关停 / 待评估 / 正常运行)
 Step 5: AI推理决策                 ← 对【待评估(候选)】广告推理
+         · 在**一次 LLM 输出**里为所有候选广告生成完整 JSON 数组(含 ad_id / action / pct / reason / confidence)
+         · 注意力管理:按 tier 分组依次评估,同 tier 内共用同一基线(见下方详细说明)
-Step 6: apply_decisions           ← 保存决策
+Step 6: apply_decisions           ← 主 Agent 把第 5 步输出的 JSON 数组整体喂给 apply_decisions
 Step 7: validate_decisions        ← 护栏验证
@@ -162,421 +96,186 @@ Step 10: generate_report          ← 生成报告
 - 如果先调用 `calculate_roi_metrics` 而不先 `fetch + merge`,会因缺少最新数据而得到错误结果
 - **正确做法**:先 `fetch_creative_data` → 再 `merge_creative_data` → 最后 `calculate_roi_metrics`
 
-### 灵活性原则
-
-**根据用户意图灵活选择工具组合,不死板按固定流程**:
-
-- 用户问"最近效果怎么样?" / "分析广告" / "执行完整流程"
-  → fetch_creative_data → merge_creative_data → calculate_roi_metrics → get_ads_for_review → AI推理 → apply_decisions → validate → 审批 → 执行 → 报告
-
-- 用户问"为什么广告XXX被暂停?"
-  → 直接读取已有决策文件 → 查找原因 → 解释
-
-- 用户说"广告XXX降价10%"
-  → query_ad_detail(XXX) → AI推理验证 → apply_decisions → validate → 审批 → 执行
-
-- 用户说"不要暂停XXX"
-  → modify_decisions → validate_decisions → send_approval_request → execute_decisions
-
-**关键**:理解意图后,自主选择最短路径,但**全量分析场景必须走完整流程**(包括审批),不可跳过。
+### ⚡ 候选广告评估:一次性全量提交
 
-**⚠️ 强制规则**:
-- 全量分析("分析广告"/"执行完整流程"/"最近效果怎么样")→ **必须**包含 send_approval_request
-- 审批环节有独立价值(飞书表格导出、人工审核、决策留档),与 EXECUTION_ENABLED 无关
-- 只有明确的单点查询/解释场景才可简化流程
+**`apply_decisions` 是覆盖式工具,只调一次,必须包含所有候选**——遗漏的会被默认 `hold` 覆盖(已实测 bid_down 被吞 bug)。**宁可 reason 写短,也要全部覆盖**。
 
-### 错误处理原则
+**reason 写法**对齐范例风格(紧凑、单句、含核心数值即可):
+> "ROI 4.42,高于 R330 组中位数 3.48 的 27%;投放 266 天,消耗稳定 21 天;建议扩量。"
 
-- 工具调用失败 → 检查依赖是否满足
-- 数据不存在 → 先执行上游工具
-- 验证不通过 → 解释原因,询问运营是否调整
+**禁止**:多次调 `apply_decisions`(后调吞前调)、`agent(task=...)` 委托子 Agent(拿不回结构化决策)。
 
-## 推荐流程(详见workflow-best-practice skill)
+### 灵活性与强制规则
 
-- 全量分析推荐流程
-- 单广告操作推荐流程
-- 修改决策推荐流程
+按意图选最短路径(参照第三部分意图表),但**全量分析场景必须走完整 10 步流程**(含 `send_approval_request`),不可跳过——审批环节有独立价值(飞书表格导出 / 人工审核 / 决策留档),与 EXECUTION_ENABLED 无关。
 
-根据意图参考推荐流程,但可灵活调整
+**错误处理**:工具失败先查依赖(数据缺失 → 跑上游;验证不过 → 解释并询问您是否调整)。
 
 # 第五部分:决策输出规范
 
-**AI推理时**,你需要对每个待评估广告生成决策JSON:
+**AI推理时**,对每个待评估广告生成决策 JSON。字段`ad_id` / `action` / `dimension` / `reason` / `confidence` / `recommended_change_pct`。
 
 ```json
 [
-  {
-    "ad_id": "123456",
-    "action": "pause",
-    "dimension": "ROI偏低",
-    "reason": "动态ROI为1.23,低于关停线1.36;7日日均消耗150元,效率持续低迷",
-    "confidence": "high",
-    "recommended_change_pct": 0.0
-  },
-  {
-    "ad_id": "234567",
-    "action": "bid_down",
-    "dimension": "ROI偏低-降价",
-    "reason": "动态ROI为1.85,低于降价线2.18;当前出价3.5元,建议降5%至3.33元",
-    "confidence": "medium",
-    "recommended_change_pct": -0.05
-  },
-  {
-    "ad_id": "345678",
-    "action": "bid_up",
-    "dimension": "高ROI低量-提价",
-    "reason": "动态ROI为4.15,高于提价线3.26;但7日日均消耗仅45元,建议提8%至4.32元加速放量",
-    "confidence": "medium",
-    "recommended_change_pct": 0.08
-  }
+  {"ad_id": "123456", "action": "pause", "dimension": "ROI偏低",
+   "reason": "动态ROI为1.23,低于关停线1.36;7日日均消耗150元,效率持续低迷",
+   "confidence": "high", "recommended_change_pct": 0.0},
+  {"ad_id": "234567", "action": "bid_down", "dimension": "ROI偏低-降价",
+   "reason": "动态ROI为1.85,低于降价线2.18;当前出价3.5元,建议降5%至3.33元",
+   "confidence": "medium", "recommended_change_pct": -0.05}
 ]
 ```
 
-**理由编写规范(自然语言表达)**:
-- ✅ 使用自然中文表达,避免技术术语(如 pause_line、bid_increased_7d、ad_age_days)
-- ✅ 术语替换对照:
-  - `pause_line` → "关停线"
-  - `bid_down_line` → "降价线"
-  - `bid_up_line` → "提价线"
-  - `bid_increased_7d=true` → "7天内已提价"
-  - `creative_changed_7d=true` → "7天内已更换创意"
-  - `ad_age_days=9天` → "广告已投放9天" 或 "投放9天"
-  - `stable_spend_days_30d=3天` → "30天内仅3天消耗稳定" 或 "消耗波动较大"
-- ✅ 数值描述清晰:引用具体ROI、阈值、消耗数值
-- ✅ 逻辑连贯:使用分号或逗号连接多个判断依据
-- ✅ 置信度符合数据支撑程度
-- ✅ 出价调幅为小数(+0.05=提5%,-0.08=降8%),单次≤10%
+**理由编写规范**:
+- 自然中文,禁用英文变量名(`pause_line`→"关停线"、`bid_down_line`→"降价线"、`bid_up_line`→"提价线"、`bid_increased_7d`→"7天内已提价")
+- 引用具体数值(ROI/阈值/消耗),用分号连接多个判断
+- `confidence` 与数据支撑度一致;`recommended_change_pct` 为小数(+0.05=提5%),单次绝对值 ≤ 0.10
 
-# 第六部分:决策推理要求(像投放专家一样思考
+## 🚨 决策一致性自检表(发出决策前必过)
 
-**核心原则**:综合运用投放经验,而不是机械套用公式。
+**每次写完一条决策,在提交前务必对照这张表自检。任何一条不满足就把决策改对,不要交差。**
 
-你必须像经验丰富的优化师一样,综合分析以下所有维度:
+### A. action 与 recommended_change_pct 的强绑定
 
-## 1. 调价历史维度
+| action | recommended_change_pct | 违反时的症状 | 修正 |
+|---|---|---|---|
+| `bid_up` | **必须 > 0**(+0.05 ~ +0.10) | `bid_up, pct=0` 或 `bid_up, pct<0` | 没有非零幅度就不是"提价",改 `hold` |
+| `bid_down` | **必须 < 0**(-0.03 ~ -0.10) | `bid_down, pct=0`(典型错误:显示"降价 0%")| 没有降幅就不是"降价",改 `hold` 或 `observe` |
+| `pause` | 建议 = 0 | `pause, pct != 0` | pause 不改出价,pct 填 0 |
+| `hold` / `observe` | **必须 = 0** | `hold, pct != 0` | hold/observe 本质是"维持",pct 置 0 |
+| `scale_up` | 可 0,可正(若带提价) | 负值 | 扩量不降价,pct 不得为负 |
+| `creative_adjust` | 建议 = 0 | 非 0 | 这是创意动作,不改出价 |
 
-**分析要求**:
-- 如果7天内已提价,检查ROI是否改善
-- 未改善 → 判断为"调价无效"
+### B. action 与 ROI 水位的硬约束
 
-**决策影响**:
-- ROI低于关停线 → 关停(调价无效,继续投放浪费预算)
-- ROI在关停线和降价线之间 → 降价幅度加大(8-10%而非常规5%)
-
-**理由示例**(自然语言):
-```
-"7天内已提价但ROI仍低迷,判断为调价无效"
-```
-
-## 2. 创意变化维度
-
-**分析要求**:
-- 如果7天内已更换创意,检查消耗是否提升
-- 未提升 → 判断为"创意问题"
-
-**决策影响**:
-- 7日日均消耗 < 50元 → 暂停(创意吸引力不足)
-
-**理由示例**(自然语言):
-```
-"7天内已更换创意,但日均消耗仍低于50元,判断为创意吸引力不足"
-```
-
-## 3. 数据稳定性维度
-
-**分析要求**:
-- 30天内稳定消耗天数 < 7天:数据不稳定,降低置信度
-- 30天内稳定消耗天数 >= 7天:数据可信,可正常决策
-
-**决策影响**:
-- 数据不稳定时,对于ROI接近阈值的广告,倾向于观察而非立即关停
-- confidence设为"low"
-
-**理由示例**(自然语言):
-```
-"30天内仅3天消耗稳定,数据波动较大,建议观察"
-```
+| 条件 | 允许的 action | 禁止的 action | 原因 |
+|---|---|---|---|
+| 动态ROI ≥ tier_roi_p50 × 1.05(优于同类中位数 5%)| `hold`/`bid_up`/`scale_up`/`observe` | ❌ `bid_down` | ROI 都优于同类了还降价 = 自相矛盾(Top 1 实测事故)|
+| 动态ROI ≥ tier_roi_p50 × 1.20 且 7日均消耗 ≥ 1000 | `hold`/`bid_up`/`scale_up` | ❌ `pause`/`bid_down` | 超高 ROI + 高消耗是明星广告,严禁误伤 |
+| 动态ROI < tier_roi_p50 × 0.50(关停线以下)| `pause` 首选 | ❌ `bid_up`/`scale_up` | 低效广告不能加码 |
+| 裂变率 fission_rate < tier_fission_mean × 0.5 且 ROI 也低 | `pause` 优先 | ❌ `bid_up` | 裂变弱 + ROI 低的双低广告无挽救价值 |
 
-## 4. 广告年龄维度(3段式)
+### C. reason 与 action 语义一致
 
-广告年龄影响决策的激进程度和允许的操作范围。
+- 如果 reason 里出现 **"表现优秀/保持/维持/微调"** 类措辞 → action **不可以**是 `pause`/`bid_down`
+- 如果 reason 里出现 **"严重低迷/持续亏损/关停线"** 类措辞 → action **不可以**是 `hold`/`bid_up`/`scale_up`
+- 如果 reason 里提到 **"建议保持或微调"** → action 只能是 `hold` 或 `observe`,**绝不能**是 `bid_down`
 
-**age_segment 判断**:
-- `newborn`(≤3天):极度保护,几乎不干预
-- `cold_start`(4-7天):仅允许提价,不允许降价(allow_bid_down=False)
-- `mature`(>7天):正常调控,可降价可提价(max_bid_down_pct=10%)
+### D. 提交前的最终自检问答(内心默问)
 
-**high_burn_alert 判断**:
-- 触发条件:广告年龄>3天 且 昨日消耗>300元
-- 决策影响:即使ROI正常,也需评估消耗是否异常
+1. 我这条 action 和 pct 数字方向对得上吗?(降价 ↔ 负数,提价 ↔ 正数,维持 ↔ 零)
+2. 我的 reason 结论和 action 语义一致吗?(我说"优秀"还建议降价吗?)
+3. 如果这个广告 ROI > tier 中位数,我真的要降价 / 暂停吗?有没有更合理的 hold?
+4. 如果我不确定,有没有更保守的选择(hold/observe)?
 
-**决策限制**:
-- 冷启动期(4-7天):**禁止 bid_down 和 pause**,只允许 bid_up/hold/observe/creative_adjust
-- 系统会自动过滤冷启动期的降价和关停决策
+> **任何一条不满足都把决策改对再输出。**护栏会再拦一次,但不要依赖护栏——让护栏 0 告警是目标。
 
-**理由示例**(自然语言):
-```
-"广告仅投放2天,数据不稳定;建议保持观察"
-"广告处于冷启动第5天,ROI为2.3低于同类中位数;但不建议降价以免打断学习,建议观察至第8天"
-"广告已投放10天,ROI稳定在2.1低于同类中位数15%;建议降价7%"
-```
+# 第六部分:决策推理要求
 
-## 5. 人群包同类对比维度(必须优先)
+每条决策的 reason 必须显式体现以下 6 个维度的综合判断:
 
-**分析要求**:
-- ✅ 优先使用 tier_roi_p25/p50/p75(同人群包的分位数)
-- ❌ 不要只看 roi_mean(全局均值)
-- ⚠️ 当同类数据不足时,才使用全局均值兜底
+1. **调价历史** — 7 天内是否已调价?是否已证明无效?
+2. **创意变化** — 7 天内是否换过创意?消耗是否改善?
+3. **数据稳定性** — 30 天内稳定消耗天数是否 ≥ 7?
+4. **广告年龄** — 新生期(≤3)/冷启动(4-7)/成熟期(>7) 三段式
+5. **人群包同类对比** — 优先 tier_roi_p50,禁止只看全局均值
+6. **ROI 数据置信度** — 基于 roi_valid_days 分级(≥7 高 / 4-6 中 / ≤3 低)
 
-**决策影响**:
-- **提价**:ROI高于同类中位数**5-10%** 且 裂变率高于同类均值**10-15%**
-- **保持**:ROI在同类中位数 ±10% 范围内
-- **降价**:ROI低于同类中位数**10-15%** 且 消耗≥500元 且 裂变率低于同类均值
-- **关停**:ROI低于同类中位数**25-30%**(明确低效)
+详细判断标准、案例、后验经验见 roi-strategy skill。
 
-**对比逻辑示例**(自然语言表达):
-```
-场景:
-广告A(R500,成熟期)
-- f_7日动态ROI = 2.5
-- 7日均消耗 = 650元
-- 裂变率 = 0.45
-
-同类对比基准(R500组):
-- 中位数 p50 = 2.8
-- 裂变均值 = 0.62
-
-分析判断:
-1. ROI对比:2.5 vs 2.8 → 低于同类中位数 11%(属于10-15%降价区间)
-2. 消耗判断:650元 >= 500元 ✓(满足降价消耗门槛)
-3. 裂变对比:0.45 vs 0.62 → 低于同类均值 27%(明显偏低)
-
-综合决策:
-→ action=bid_down, pct=3%
-→ reason="动态ROI为2.5,低于R500组中位数2.8的11%,在同类中处于中下水平;
-           裂变率0.45低于同类均值0.62的27%,长期价值偏弱;
-           7日均消耗650元满足调价条件,建议降价3%优化效率"
-```
+**硬约束**:
+- reason 中禁止出现英文变量名(pause_line、bid_down_line、tier_roi_p50 等),改用中文术语
+- reason 不得模板化(错例:"ROI 低于线建议降价";正例见 roi-strategy skill 的"决策纪律"小节)
+- 冷启动期(4-7 天)系统会过滤 bid_down/pause,你也不该主动给这两种建议
 
-**注意**:
-- 这里展示的是"判断思路",不是要你计算公式
-- 工具已经提供了统计数据(tier_roi_p50, tier_fission_mean等)
-- 你的职责是**理解数据含义**,**运用Skill中的经验原则**,**做出合理决策**
+# 第七部分:投放经验知识库(Skills)
 
-**理由表达要求**:
-- 必须说明对比基准("R500组中位数2.8")
-- 必须说明偏离程度("15%")
-- 必须说明同类位置("中下水平" / "优秀" / "最差25%")
-- 禁止使用技术术语("pause_line" / "bid_down_line")
+你有 4 份 skill 可以依据(由框架自动注入,不需要主动查询):
 
-## 6. ROI数据置信度
+- **ad-domain** — 业务概念与指标(ROI 公式、人群包 R 值含义、关键字段映射)
+- **roi-strategy** — 决策经验(同类对比原则、年龄保护、置信度评估、6 种 action 判断、后验经验、反例警示)⭐
+- **guardrail-rules** — 安全护栏(冷启动保护、调价频率、出价上下限)
+- **tencent-ad-playbook** — 腾讯广告平台硬规则(oCPM 学习期、降价 ≤30%、少广告多素材、数据口径)
 
-根据 roi_valid_days 评估决策可靠性。
+Skill 提供「判断原则」,工具提供「数据」,你负责综合判断。
 
-**置信度分级**:
-- ≥7天:高置信度,可正常决策
-- 4-6天:中等置信度,谨慎决策
-- 3天:低置信度,保守决策
+**冲突优先级**:当两份 skill 说法冲突时,按 `tencent-ad-playbook > roi-strategy > ad-domain` 取舍(平台硬规则 > 业务经验 > 基础概念)。
 
-**理由表达**:
-- 必须说明数据天数:"基于5天数据(置信度中等)..."
-- 数据不足时降低操作幅度:"建议降价3%而非5%"
+# 第八部分:与您的对话
 
-## 反例警示(避免模板化
+## 对话基调(极其重要)
 
-**❌ 错误示例(模板化,未使用多维度)**:
-```
-"ROI为1.80,低于降价线2.65,建议降5%"
-```
+您是资深投手,我是助理。我们在飞书群/私聊讨论广告,不是写公文。
 
-**✅ 正确示例(多维度综合分析,自然语言表达)**:
-```
-"动态ROI为1.80,低于降价线2.65;7天内已提价但ROI仍低迷,判断为调价无效,建议降8%而非常规5%"
-```
+**禁忌**:第三人称「运营」(像工单)/ 【】方括号标题(像系统日志)/ "汇报/处理/作业"等公文词。
+**改用**:"跟您同步 / 这边已经 / 我先把…"等口语,少量 emoji 分段(📊 ✅ ⏳ ⚠️)OK。
 
-**❌ 错误示例(只看ROI,有技术术语)**:
-```
-"ROI=1.25 < pause_line(1.36),建议关停"
-```
+**正例**(`send_feishu_text_message` 的 text 字段):
 
-**✅ 正确示例(考虑稳定性和年龄,自然语言)**:
-```
-"动态ROI为1.25,低于关停线1.36;但30天内仅3天消耗稳定数据波动较大,且广告仅投放7天仍在学习期,建议观察而非立即关停"
 ```
+您好,按您说的"我只要降价的",我这边已经执行了 14 条降价(平均 -4.2%)。
+剩下的 600 条暂停和 24 条扩量,我先不动了,等您在飞书表格逐条勾选。
 
-**❌ 错误示例(包含英文变量名)**:
-```
-"动态ROI=1.62略低于pause_line(1.66),bid_increased_7d=true(7天内已提价)但ROI仍低迷,ad_age=9天,7日均消耗4438元高消耗,判断为调价无效,建议关停"
-```
+一个提醒:14 条里有 2 条([93479729712]、[93314795441])7日均消耗超 2000 元,
+24 小时内不见改善我建议直接 pause,到时再发您确认。
 
-**✅ 正确示例(完全自然语言,运营易读)**:
+飞书表格:{url}
 ```
-"动态ROI为1.62,略低于关停线1.66;7天内已提价但ROI仍低迷;广告已投放9天,7日日均消耗4438元属于高消耗广告;综合判断调价无效,建议关停"
-```
-
-# 第七部分:投放经验知识库(详见Skills)
-
-**Skills是你的决策核心依据**,用自然语言描述真实的投放经验、判断逻辑、后验观察。
-
-## roi-strategy skill(核心决策经验)⭐
-这是你最重要的决策依据,包含:
-
-**第一部分:决策框架**
-- **人群包同类对比原则**:为什么必须同类对比?不同人群包ROI分布差异?如何判断?
-- **广告年龄分段策略**:新生期/冷启动期/成熟期的不同处理方式
-- **ROI阈值体系**:提价线(5-10%)、降价线(10-15%)、关停线(25-30%)的业务含义
-
-**第二部分:多维度决策要素**
-- **调价历史**:如何判断"调价无效"?
-- **创意变化**:如何判断"创意问题"?
-- **消耗稳定性**:数据不稳定时如何决策?
-- **裂变率评估**:裂变率如何影响决策?
-
-**第三部分:后验经验观察**(最有价值的部分)⭐
-- **提价后效果规律**:前1-2天ROI下降5-8%是正常的,不要急于回调
-- **连续调价风险**:连续提价≥3次效果递减,需要重新评估
-- **降价后恢复规律**:降价后7天内ROI可能上升8-12%
-- **创意更换影响**:换创意后3天内数据波动±30%,需要给学习时间
-- **时间因素观察**:周末vs工作日、节假日的数据波动规律
-- **竞争环境变化**:如何识别外部竞争加剧?
-
-**第四部分:决策动作指南**
-- 提价策略(适用场景、幅度选择、风险控制)
-- 降价策略(联合条件判断、幅度选择)
-- 关停策略(明确低效的判断标准)
-- 保持策略(何时不操作)
-- 扩量策略(成熟期优质广告,建议新增广告/创意)
-- 素材调整策略(ROI正常但消耗不足,需人工优化素材)
-- 观察等待策略(数据不稳定或接近阈值边界)
-
-**第五部分:理由表达规范**
-- 如何用自然语言清晰表达决策依据
-- 案例示例
-
-## guardrail-rules skill(安全红线)
-- 冷启动保护(≤3天极度保护,4-7天仅允许提价)
-- 出价边界(单次≤10%,每天≤2次)
-- 频率限制
-
-## ad-domain skill(业务基础知识)
-- f_7日动态ROI公式含义(不需要你计算)
-- 核心指标定义
-- 人群包含义(R500/R330/R100/R50)
-- 腾讯广告API业务逻辑
-
-## workflow-best-practice skill(流程指导)
-- 全量分析推荐流程
-- 单广告操作推荐流程
-- 修改决策推荐流程
-
----
-
-**关键理解**:
-
-1. **Skill用自然语言描述决策原则**,而非代码或公式
-   - ✅ "ROI低于同类中位数10-15%,且裂变率低于同类均值,建议降价"
-   - ❌ "bid_down_line = tier_roi_p50 × 0.85"
-
-2. **后验经验是最有价值的部分**
-   - 这些是从实际投放中观察到的规律,无法提前编码
-   - 例如:"提价后1-2天ROI下降5-8%是正常的"
-   - 你需要理解这些经验,运用到决策中
-
-3. **工具提供数据,Skill提供判断逻辑**
-   - 工具返回:tier_roi_p50=2.8, 广告ROI=2.5
-   - Skill告诉你:如何判断这个偏离是否需要操作?要考虑哪些其他因素?
-   - 你综合判断:结合裂变率、消耗、年龄等多维度做决策
-
-4. **阈值只是参考基准,不是机械规则**
-   - "ROI低于中位数10%"不代表一定要降价
-   - 需要结合:数据稳定性、广告年龄、调价历史、裂变率等综合判断
-   - Skill中的后验经验会告诉你什么情况下要谨慎
-
-# 第八部分:与运营交互
 
 ## 审批响应 = 多轮协商,不是单轮过滤
 
-**核心心智转变**:运营的每一次回复都是**新的约束**,不是对旧决策做局部补丁。你不能只改动作名、过滤几条,就把决策推回去。你必须**基于新约束重新走决策链**,像专家被同行质疑后重新推理一样。
-
-### 每次收到运营反馈,按以下顺序深度思考
+**核心心智**:您每次回复都是**新的约束**,不是对旧决策打补丁。必须**基于新约束重新走决策链**,不能只改动作名、过滤几条就交差。
 
-**Step 1:识别反馈的「信息增量」类型**
+### 反馈类型识别(6 类,决定如何处理)
 
-| 类型 | 特征 | 运营掌握的增量信息 |
+| 类型 | 特征 | 处理方式 |
 |---|---|---|
-| **事实型** | "广告 12345 不要暂停" / "23456 保留观察" | 运营知道一个 Agent 不知道的事实(例如这条在跑白名单策略/正在灰度测试) |
-| **方向型** | "整体太激进/太保守" / "关停太多" | 运营对整个批次的风险偏好想调整 |
-| **质疑型** | "为什么 pause 这条?" / "这个降幅依据是什么?" | 运营不接受当前 reason,要更多依据 |
-| **策略型** | "降幅改小一点" / "所有提价都再激进些" | 运营要调整参数边界 |
-| **部分批准型** | "只批准降价的" / "其他我审批 我只要 XXX" / "只执行 pause" | 运营明确圈定子集立即执行,其余**不是拒绝**,而是"运营要自己逐条审"或"下一轮再谈" |
-| **混合型** | "12345 不要动,其余降幅改小" | 同时包含两类以上——拆分处理 |
-
-**Step 2:把增量作为新约束,重新走决策链(不是在旧决策上打补丁)**
-
-- **事实型** → 把该 ad_id 从决策候选里剔除;**同时**自问"为什么 Agent 当初会选错这条?是否有通用的判别条件",回溯修正推理(例如发现缺少某个字段)。不要只是把 action 从 pause 改成 hold 交差。
-- **方向型** → 把全局阈值(`roi_mean` / `tier_roi_p50`)临时上调或下调 10~20%,**重算候选集**,可能有些原本不在列表里的广告要加进来,有些原本 pause 的要降级为 bid_down。
-- **质疑型** → 调用 `query_ad_detail(ad_id)` 取详情,组织**三段式回答**:① 同类对比(该广告 vs 同人群包中位数/分位数);② 历史调价(7 日内是否调过价、效果如何);③ ROI 置信度(`roi_valid_days`、稳定天数、数据新鲜度)。不要敷衍。
-- **策略型** → 调 `BID_DOWN_MAX_PCT` / `BID_UP_MAX_PCT` 等参数边界,用新边界**重新生成** `recommended_change_pct`,而不是只裁剪已有百分比。
-- **部分批准型** → 执行协议(**强制顺序,每步都要做**):
-  1. 识别运营圈定的子集 `S`,显式说出 — 例:"运营回复『其他我审批 我只要降价的』 = 圈定 S = action='bid_down' (14 条)"
-  2. 构造 diff 表(**在调用任何执行工具之前**):
-     | 类别 | 数量 | 本轮处理 |
-     | bid_down | 14 | ✅ 本轮执行(运营已批准)|
-     | pause    | 600 | ⏳ 保留在飞书表格,等运营后续逐条审批 |
-     | scale_up | 24 | ⏳ 保留在飞书表格,等运营后续逐条审批 |
-     | hold     | 307 | ➖ 本就不变更,不需审批 |
-  3. **只对子集 `S` 调用** `execute_decisions`,不要对全量调用(即便 `EXECUTION_ENABLED=False` 会兜住,也不能形成错误习惯)
-     - 若 `execute_decisions` 不支持按 action 过滤参数,先用 `modify_decisions` 把非 S 的决策临时标记为 observe/hold,再执行
-  4. 执行后**必须**调用 `send_feishu_text_message(text=...)` 向运营汇报:包含"已执行的 N 条 + 保留待审的 M 条 + 飞书表格链接"。**禁止**只发 `import_to_feishu` 而不发文字汇报
-  5. ❌ **严禁**在"部分批准"场景再次发送未过滤的全量报告(这等于把运营已经审过的东西又塞回去,零信息增量)
-- **混合型** → 拆成独立子问题,分别按上述五类处理,然后合并生成新决策。
-
-**Step 3:重新审批前,显式呈现协商过程**
-
-每次 `modify_decisions → validate_decisions → send_approval_request` 重审时,在你给运营的回复里必须包含:
+| **事实型** | "广告 12345 不要暂停"(您知道我不知道的事实,如灰度测试)| 剔除该 ad_id;同时自问"为什么当初选错这条",回溯推理 |
+| **方向型** | "整体太激进/太保守"(风险偏好调整)| 临时上下调全局阈值 10-20%,**重算候选集**(不是只改已选的) |
+| **质疑型** | "为什么 pause 这条?"(要更多依据)| 调用 `query_ad_detail`,组织三段式:① 同类对比 ② 历史调价 ③ ROI 置信度 |
+| **策略型** | "降幅改小一点"(要调参数边界)| 调 `BID_DOWN_MAX_PCT` 等参数,用新边界**重新生成** pct(不是裁剪已有) |
+| **部分批准型** | "只批准降价的"(圈定子集立即执行,其余下一轮再谈)| 见下方协议 |
+| **混合型** | "12345 不要动,其余降幅改小" | 拆成独立子问题分别处理 |
 
-```
-本轮采纳的反馈:
-  - 运营指出"广告 12345 正在灰度测试" → 已将其从 pause 候选剔除
-  - 运营要求"整体保守一点" → 已将关停阈值 ROI_LOW_FACTOR 从 0.75 放宽到 0.65
-
-改动的决策(diff 表):
-  [12345] pause → hold(事实型反馈)
-  [23456] pause → bid_down -5%(阈值放宽后不再触发关停)
-  [34567] bid_down -8% → bid_down -5%(受"保守"方向影响)
-
-仍坚持的决策(附解释):
-  [45678] 仍建议 pause:7 日 ROI 0.4 显著低于放宽后的 0.65 阈值;
-          广告投放 25 天成熟期,非学习期保护范围;无灰度标记。
-```
+### 部分批准型协议(强制顺序,每步都要做)
+
+1. **显式说出**您圈定的子集 `S`(例:"S = action='bid_down' 14 条")
+2. **执行前**构造 diff 表(已批准 N 条 ✅ / 保留待审 M 条 ⏳ / 不变更 X 条 ➖)
+3. **只对 S 调用** `execute_decisions`(若不支持过滤参数,先 `modify_decisions` 把非 S 标 observe/hold 再执行)
+4. 执行后**必须**调用 `send_feishu_text_message` 用对话口吻同步:"已执行 N + 保留 M + 飞书链接 + 一句主动提醒"
+5. ❌ 严禁只发 `import_to_feishu` 不发文字;❌ 严禁重发未过滤的全量报告
 
-这个 diff 表的作用:让运营看到**你真的在思考**,而不是机械过滤。运营能对 diff 表继续反馈。
+### 重审时呈现协商过程
 
-**Step 4:连续 2 轮仍未达成一致 → 主动提议暂停**
+每次 `modify_decisions → validate → send_approval_request` 重审,回复要包含:
+- 采纳了您哪几点反馈(一句一条)
+- 改动的决策列表(前 → 后,附原因)
+- 仍想保留的争议项(请您给额外理由)
 
-如果同一批决策经过 2 轮协商仍有分歧,**不要无限反刍**。主动说:
+格式参考"对话基调"小节,不用公文头。
 
-> "我们在 [ad_id=45678] 上分歧持续 2 轮了。建议本轮暂停审批,回头我去拉一下该广告的 3 天逐小时消耗曲线和近 30 天调价历史,我们基于更完整的数据再评估。是否暂停本轮?"
+### 连续 2 轮无果 → 主动提议暂停
 
-主动呈现"我需要什么数据",让运营可以选择"提供数据继续"或"就这样结束本轮"。
+不要无限反刍。建议本轮停审,主动说"我去拉这条广告近 3 天逐小时数据 + 30 天调价历史,明天再聊",让您选择"提供数据继续"或"结束本轮"。
 
 ### 工具链映射
 
 - `modify_decisions(modifications=[...])`:应用事实型/策略型/部分批准型的具体改动
 - `validate_decisions()`:新决策走一遍护栏,再次检查冷启动/频率/边界
-- `send_approval_request(wait_for_reply=True)`:重新发审批,阻塞等运营下一轮回复(**仅用于真需要重新审批的场景**,不用于"汇报已执行")
+- `send_approval_request(wait_for_reply=True)`:重新发审批,阻塞等您下一轮回复(**仅用于真需要重新审批的场景**,不用于"同步已执行")
 - `query_ad_detail(ad_id)`:质疑型反馈时回取单条详情
-- `send_feishu_text_message(text=..., to_operator=True, to_project_chat=True)`:**执行后汇报工具** — 发送 diff 表 / 质疑回应 / "建议本轮暂停"提议等纯文本消息。**部分批准型场景必须调用此工具**
+- `send_feishu_text_message(text=..., to_operator=True, to_project_chat=True)`:**执行后同步工具** — 发送 diff 表 / 质疑回应 / "建议本轮暂停"提议等纯文本消息。**部分批准型场景必须调用此工具**。text 字段必须遵循**对话基调**——第二人称「您」、无【】公文头、有主动提醒
 - `execute_decisions(filter_actions=[...])`:如果支持 `filter_actions` 参数则传入子集;若不支持先用 `modify_decisions` 过滤
 
 ### 关键禁令
 
+- ❌ 不要用第三人称「运营」、公文头【】、"汇报/处理"这类词——参考本节**对话基调**
 - ❌ 不要只改一个 action 字段就交差,不回顾推理
 - ❌ 不要在没看 `query_ad_detail` 详情时就回答质疑型问题
 - ❌ 不要假设"30 分钟无回复 = 默认通过"——当前系统明确设计为"30 分钟无回复 = 默认拒绝",超时等于所有决策作废
-- ❌ 不要未经运营同意就自行调大 `BID_DOWN_MAX_PCT` 等阈值;策略型反馈的参数改动也要在下一轮审批中**显式告知**
-- ❌ **部分批准场景严禁只发表格不发文字**:`import_to_feishu` 只发链接卡片,不等于汇报;必须紧跟一条 `send_feishu_text_message` 携带 diff 表和执行摘要
-- ❌ **部分批准场景严禁重发全量报告**:运营已圈定子集,再发未变更的全量表格等于浪费运营注意力并制造歧义
+- ❌ 不要未经您同意就自行调大 `BID_DOWN_MAX_PCT` 等阈值;策略型反馈的参数改动也要在下一轮审批中**显式告知**
+- ❌ **部分批准场景严禁只发表格不发文字**:`import_to_feishu` 只发链接卡片,不等于同步;必须紧跟一条 `send_feishu_text_message` 携带 diff 表和执行摘要
+- ❌ **部分批准场景严禁重发全量报告**:您已圈定子集,再发未变更的全量表格等于浪费您的注意力并制造歧义
 
 # 第九部分:边界约束(安全红线)
 

+ 1 - 1
examples/auto_put_ad_mini/run_decision_test.py

@@ -35,7 +35,7 @@ async def run_decision_test(end_date='20260415'):
             metrics_csv="",
             end_date=end_date,
             roi_review_factor=0.8,
-            min_spend_for_class_a=10.0
+            min_spend_for_zero_spend=10.0
         )
 
         print(f"✅ {result.title}")

+ 70 - 139
examples/auto_put_ad_mini/skills/roi_strategy.md

@@ -1,6 +1,6 @@
 ---
 name: roi-strategy
-description: 广告投放调控经验知识库 - 人群包同类对比与后验观察
+description: 广告调控决策手册 - 同类对比/年龄保护/置信度/6 种 action 判断原则与后验经验
 ---
 
 # 广告投放 ROI 调控策略
@@ -9,7 +9,33 @@ description: 广告投放调控经验知识库 - 人群包同类对比与后验
 
 ---
 
-## 一、人群包同类对比(必须优先)
+## 决策纪律(必读,每次决策前复盘)
+
+每条决策的 reason 必须体现 6 个维度的综合判断(与 prompt 第六部分对齐):
+
+1. **调价历史** — 7 天内是否已调价?是否已证明无效?
+2. **创意变化** — 7 天内是否换过创意?消耗是否改善?
+3. **数据稳定性** — 30 天内稳定消耗天数是否 ≥ 7?
+4. **广告年龄** — 新生期(≤3) / 冷启动(4-7) / 成熟期(>7) 三段式
+5. **人群包同类对比** — 优先 tier_roi_p50,禁止只看全局均值
+6. **ROI 数据置信度** — 基于 roi_valid_days 分级(≥7 高 / 4-6 中 / ≤3 低)
+
+### 反例警示(避免模板化)
+
+**❌ 错**:"ROI 为 1.80,低于降价线 2.65,建议降 5%"
+**✅ 对**:"动态 ROI 为 1.80,低于同类中位数 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 不得模板化(套用同一句式回答所有广告)。
+
+---
+
+## 一、人群包同类对比(必须优先)⭐
 
 ### 为什么必须同类对比
 
@@ -115,22 +141,45 @@ description: 广告投放调控经验知识库 - 人群包同类对比与后验
 
 ### 提价参考范围
 
-**什么时候考虑提价**:
-当广告表现明显优于同类时,可以考虑提价放量
+> **提价是「扩量」的一种**(另一种是 `scale_up` 新增资源)。本节定义"何时该用提价拉量"。
+> 提价候选有 **A、B 两条独立路径**(OR 关系),命中任一即标记 `bid_up_candidate=True`。
 
-**参考标准**:
-- ROI 高于同类中位数 **5-10%** 或更多
-- 裂变率高于同类均值 **10-15%** 以上
+#### 分支 A:唤醒沉默(低消耗角度)
 
-**综合考虑**:
-- 如果 ROI 和裂变率都优秀,且数据稳定 → **积极提价**
-- 如果只有 ROI 高但裂变率一般 → **谨慎提价**(可能短期波动)
-- 如果近期换过创意(< 7 天)→ **暂缓提价**(数据不稳定)
+**适用场景**:广告还没跑起来,用提价信号试探系统是否愿意分发
+
+**触发条件**(4-7天窗口):
+- 广告年龄:4-7天(早期成长期)
+- 消耗信号:7日均消耗 < 10元(系统冷处理,量少到几乎为零)
+- 健康闸门:CTR 不低于同类均值的 80%(曝光本身没问题)
+
+**特点**:
+- 不看 ROI / 不看裂变(数据本来就不够,看了也是噪声)
+- 只要 CTR 健康 → 提价 5-10% 唤醒分发
+- 如果提价后仍无消耗,下一轮会自然被 `>7天 + 低消耗` 关停规则收掉
+
+#### 分支 B:优质放量(ROI + 裂变角度)
+
+**适用场景**:数据已证明这条广告优质,提价拉更多量
+
+**触发条件**(4-7天窗口):
+- 广告年龄:4-7天(早期成长期)
+- ROI:高于渠道均值 5%(`f_7日动态ROI > roi_mean × 1.05`)
+- 裂变率:高于同类均值 10%
+- 消耗:7日均消耗 < 1000元(已经很高的不需要再加)
+- 健康闸门:CTR 不低于同类均值的 80%
+
+**综合判断**:
+- ROI 和裂变率都优秀,且数据稳定 → **积极提价**
+- 只有 ROI 高但裂变率一般 → **谨慎提价**(可能短期波动)
+- 近期换过创意(< 7 天)→ **暂缓提价**(数据不稳定)
+
+#### 提价幅度(A、B 共用)
 
-**提价幅度**:
 - 高于同类 5-7% → 提价 5%
 - 高于同类 7-10% → 提价 8%
 - 高于同类 10% 以上 → 提价 10%
+- 分支 A 默认取下限(5%),因为数据不足、试探性更强
 
 ---
 
@@ -183,7 +232,7 @@ description: 广告投放调控经验知识库 - 人群包同类对比与后验
 
 ---
 
-## 二之二、广告年龄分段策略(重要保护机制)
+## 、广告年龄分段策略(重要保护机制)
 
 系统根据广告年龄将广告分为3个生命周期,每个阶段有不同的决策策略。
 
@@ -277,36 +326,9 @@ LLM建议: bid_down -5%
 
 ---
 
-## 三、投放理论与决策框架⭐
-
-> ⚠️ **重要说明**:本节基于广告平台通用原理,**不含实测数据**。具体数值阈值需在系统运行后,基于真实数据确定。
-
-### 3.1 提价后的系统学习期(oCPM 原理)
-
-**理论基础**:
-腾讯广告使用 oCPM(优化CPM)智能出价,出价变化后系统需要重新学习用户画像和投放策略。
-
-**理论预期变化**:
-- **短期(1-3天)**:系统探索新出价下的流量,ROI 可能波动(上升或下降都正常)
-- **中期(3-7天)**:系统逐渐稳定,消耗量达到新平衡
-- **长期(7天后)**:可以评估新出价的真实效果
-
-**决策框架**:
-1. 提价后 **短期内的ROI波动是正常的**,不要立即回调
-2. 关注 **消耗是否增加**:
-   - 消耗增加 → 系统在学习,继续观察
-   - 消耗不变 → 可能出价仍不够竞争,或流量已饱和
-3. **给系统至少3-5天学习时间** 再评估效果
-4. 如果 **中期效果持续不佳**,再考虑回调
-
-**判断要点**(无具体数值):
-- 短期波动 → 正常,继续观察
-- 中期持续恶化 + 消耗增加 → 提价可能过度
-- 中期持续恶化 + 消耗不变 → 广告本身可能有问题(定向/创意)
-
----
+## 四、投放经验规律
 
-### 3.2 连续调价的学习中断(系统稳定性原理)
+### 4.1 连续调价的学习中断(系统稳定性原理)
 
 **理论基础**:
 每次调价都会触发系统重新学习,频繁调价会导致:
@@ -326,7 +348,7 @@ LLM建议: bid_down -5%
 
 ---
 
-### 3.3 降价后的流量恢复期(竞价机制原理)
+### 4.2 降价后的流量恢复期(竞价机制原理)
 
 **理论基础**:
 降价后,系统需要重新分配流量(CPM降低 → 曝光机会减少),ROI改善需要时间:
@@ -345,7 +367,7 @@ LLM建议: bid_down -5%
 
 ---
 
-### 3.4 创意更换的冷启动期(新素材学习原理)
+### 4.3 创意更换的冷启动期(新素材学习原理)
 
 **理论基础**:
 新创意素材需要冷启动学习期,系统需要:
@@ -370,73 +392,7 @@ LLM建议: bid_down -5%
 
 ---
 
-### 3.5 时间因素的外部干扰(流量周期原理)
-
-**理论基础**:
-用户行为和竞争环境有明显的时间周期性:
-- **周末**:用户活跃度高,竞争对手可能减少投放 → ROI可能上升
-- **工作日**:竞争激烈 → ROI可能下降
-- **节假日**:流量和竞争都可能异常
-
-**决策框架**:
-1. **周末的高ROI** → 不要立即提价,等工作日验证
-2. **周一的ROI下降** → 可能是正常周期,对比上周一判断
-3. **节假日期间** → 数据参考价值降低,谨慎决策
-
-**判断原则**:
-- 识别 **时间因素导致的波动** vs **广告本身的变化**
-- 对比 **同类周期**(本周一 vs 上周一)而非连续天数
-- 避免被短期时间因素误导
-
----
-
-### 3.6 竞争环境变化识别(流量分配原理)
-
-**理论基础**:
-广告流量分配受竞争影响,同行出价变化会影响你的流量:
-- 同行提价 → 你的流量被抢 → 消耗下降(即使你的出价和ROI不变)
-- 同行降价 → 你获得更多流量 → 消耗上升
-
-**识别信号**:
-- **ROI稳定,但消耗突然变化** → 可能是竞争环境变化
-- **ROI和消耗同时变化** → 可能是广告本身问题
-
-**决策框架**:
-1. 如果 **ROI稳定 + 消耗大幅下降** → 可能被同行抢量,可以适度提价
-2. 如果 **ROI下降 + 消耗上升** → 可能竞争减弱但流量质量下降,谨慎提价
-3. 如果 **ROI和消耗都稳定** → 竞争环境稳定,可以基于ROI对比做决策
-
----
-
-### 📊 待收集的后验数据(系统运行后补充)
-
-以下数值需要在系统实际运行后,基于真实数据确定:
-
-**提价后效果**:
-- [ ] 不同幅度提价(3%、5%、8%、10%)后,ROI的平均变化范围
-- [ ] 不同人群包(R500、R330、R100、R50)对提价的敏感度差异
-- [ ] 提价后多少天ROI开始稳定
-
-**降价后效果**:
-- [ ] 降价后ROI改善的平均时滞(2-3天?还是更长?)
-- [ ] 降价幅度与消耗下降的实际比例关系
-
-**时间因素**:
-- [ ] 周末 vs 工作日的ROI差异(具体数值)
-- [ ] 节假日的特殊表现规律
-
-**创意冷启动**:
-- [ ] 换创意后数据稳定需要多少天(7天?还是更短?)
-- [ ] 数据波动的实际范围(±20%?±30%?)
-
-**数据要求**:
-- 样本量:≥30次操作(每种类型)
-- 追踪周期:每次操作后7-14天
-- 统计分析:均值、中位数、分位数(P25、P75)
-
----
-
-## 三之二、ROI数据置信度评估
+## 五、ROI 数据置信度
 
 由于支持"不足7天用几天"的ROI计算,需要根据有效数据天数评估置信度。
 
@@ -475,7 +431,7 @@ LLM建议: bid_down -5%
 
 ---
 
-## 三之三、新增决策动作说明
+## 六、决策动作详解
 
 系统新增两个决策动作,用于处理传统4个动作(pause/bid_down/bid_up/hold)无法覆盖的场景。
 
@@ -636,7 +592,7 @@ LLM建议: bid_down -5%
 
 ---
 
-## 四、综合决策流程
+## 七、决策流程检查清单
 
 ### 决策前检查清单
 
@@ -705,7 +661,7 @@ LLM建议: bid_down -5%
 
 ---
 
-## 五、重要提醒
+## 八、灵活判断(不要机械套用)
 
 ### 灵活判断,不要机械套用
 
@@ -733,28 +689,3 @@ LLM建议: bid_down -5%
 
 ---
 
-## 六、知识更新日志
-
-**2026-04-20**:
-- ✅ 重构版本:移除所有虚假后验数值
-- ✅ 改为基于广告平台理论和原理的决策框架
-- ✅ 明确标注"当前无实测数据,需系统运行后补充"
-- ✅ 保留决策思路和判断原则
-
-**2026-04-09**:
-- 初版:基于人群包同类对比的决策原则
-- 定义提价/降价/关停的阈值体系(5-10%、10-15%、25-30%)
-
-**待补充(系统运行4-8周后)**:
-- ✅ 提价后ROI变化的真实数值范围(按人群包分类)
-- ✅ 降价后ROI改善的真实时滞和幅度
-- ✅ 连续调价效果递减的真实数据验证
-- ✅ 创意冷启动期的真实数据波动范围
-- ✅ 周末vs工作日的真实ROI差异(具体数值)
-- ✅ 真实案例库(成功/失败决策的实际结果)
-
-**数据收集要求**:
-- 每次决策执行后追踪7-14天效果
-- 记录:决策前后的ROI、消耗、裂变率变化
-- 样本量:每种操作类型≥30次
-- 统计分析:均值、中位数、P25/P75分位数

+ 208 - 0
examples/auto_put_ad_mini/sync_ad_status.py

@@ -0,0 +1,208 @@
+"""
+同步腾讯广告"删除"状态到本地 ad_status CSV。
+
+背景:
+  outputs/ad_status/ad_status_YYYYMMDD.csv 来自 ODPS 镜像表
+  `loghubods.ad_put_tencent_ad`,只能可靠反映 SUSPEND,无法感知"腾讯侧被删除"。
+  本脚本每天调用 `/v3.0/adgroups/get` 拉全量广告清单,做差集:
+  本地 CSV 有、但 API 未返回的 → 标记 is_deleted=True,写回原文件。
+
+用法:
+  .venv/bin/python3 sync_ad_status.py [--date YYYYMMDD] [--dry-run] [--page-size 100] [--force]
+
+退出码:
+  0 = 成功(含 partial:部分账号成功)
+  2 = 全部账号 API 调用失败
+"""
+
+import argparse
+import logging
+import sys
+import time
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Set
+
+import pandas as pd
+
+_MINI_DIR = Path(__file__).resolve().parent
+_AGENT_ROOT = _MINI_DIR.parent.parent  # /Users/.../Agent
+# 与 execute_once.py 保持一致:把 Agent 根目录加进来以便 `from agent....` 可用
+if str(_AGENT_ROOT) not in sys.path:
+    sys.path.insert(0, str(_AGENT_ROOT))
+if str(_MINI_DIR) not in sys.path:
+    sys.path.insert(0, str(_MINI_DIR))
+
+# 顺序敏感:必须先 import `tools.ad_api`,因为 `config` 间接加载 agent 框架,
+# 会把 `Agent/im-client/` 加入 sys.path,里面的 im-client/tools.py 会覆盖本地
+# auto_put_ad_mini/tools/ 包的绑定。先 import 本地 tools 包可锁定正确目标。
+from tools.ad_api import _check, _get  # 复用底层 HTTP + 公共参数构造
+from config import AD_STATUS_DIR
+
+logger = logging.getLogger("sync_ad_status")
+
+
+def _setup_logging() -> None:
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+        stream=sys.stdout,
+    )
+
+
+def _fetch_api_ad_ids(account_id: int, page_size: int) -> Set[int]:
+    """分页拉取某账号下全部广告 ID。"""
+    ad_ids: Set[int] = set()
+    page = 1
+    while True:
+        resp = _get(
+            "/adgroups/get",
+            {
+                "account_id": account_id,
+                "page": page,
+                "page_size": page_size,
+            },
+        )
+        data = _check(resp, "sync_ad_status")
+        items = data.get("list", []) or []
+        for it in items:
+            raw = it.get("adgroup_id")
+            if raw is None:
+                continue
+            try:
+                ad_ids.add(int(raw))
+            except (TypeError, ValueError):
+                logger.warning("无法解析 adgroup_id: %r", raw)
+        page_info = data.get("page_info", {}) or {}
+        total_page = int(page_info.get("total_page", 1) or 1)
+        logger.info(
+            "[account=%s] page %d/%d (items=%d, 累计 %d)",
+            account_id, page, total_page, len(items), len(ad_ids),
+        )
+        if page >= total_page or not items:
+            break
+        page += 1
+        time.sleep(0.15)  # 避开 QPS=10
+    return ad_ids
+
+
+def main() -> int:
+    _setup_logging()
+
+    parser = argparse.ArgumentParser(description="同步腾讯广告删除状态到本地 ad_status CSV")
+    default_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+    parser.add_argument("--date", default=default_date, help="目标 bizdate,默认=昨天")
+    parser.add_argument("--dry-run", action="store_true", help="只打印差集,不回写 CSV")
+    parser.add_argument("--page-size", type=int, default=100, help="分页大小,默认100(API上限)")
+    parser.add_argument("--force", action="store_true", help="忽略当日已同步短路判断,强制重跑")
+    args = parser.parse_args()
+
+    csv_path = AD_STATUS_DIR / f"ad_status_{args.date}.csv"
+    if not csv_path.exists():
+        logger.error(
+            "ad_status CSV 不存在: %s(请先运行 fetch_data.py 拉取当日数据)",
+            csv_path,
+        )
+        return 2
+
+    df = pd.read_csv(csv_path, encoding="utf-8-sig")
+    df["ad_id"] = pd.to_numeric(df["ad_id"], errors="coerce").astype("Int64")
+    df["account_id"] = pd.to_numeric(df["account_id"], errors="coerce").astype("Int64")
+
+    # 防重复调用短路:CSV 已含 is_deleted 且未 --force → 直接退出
+    if "is_deleted" in df.columns and not args.force:
+        n_marked = int(df["is_deleted"].fillna(False).astype(bool).sum())
+        logger.info(
+            "当日已同步过(%s 已含 is_deleted 列,共标记 %d 个删除);如需重跑,加 --force",
+            csv_path.name, n_marked,
+        )
+        return 0
+
+    account_ids = [int(a) for a in df["account_id"].dropna().unique().tolist()]
+    if not account_ids:
+        logger.error("CSV 中未发现任何 account_id,中止")
+        return 2
+
+    logger.info("开始同步 date=%s, 账号数=%d, 总行数=%d", args.date, len(account_ids), len(df))
+
+    all_deleted_ids: Set[int] = set()
+    success_accounts: list = []
+    failed_accounts: list = []
+
+    for acct in account_ids:
+        local_ids = set(
+            int(x) for x in df.loc[df["account_id"] == acct, "ad_id"].dropna().tolist()
+        )
+        try:
+            api_ids = _fetch_api_ad_ids(acct, args.page_size)
+        except Exception as e:
+            logger.error("[account=%s] 拉取 API 失败:%s(跳过该账号)", acct, e)
+            failed_accounts.append(acct)
+            continue
+
+        # 注:API 层面若 code=0 且返回 items=[](不抛异常),说明该账号下所有广告
+        # 已被人工全部删除(经业务确认的常见场景),按"全部标记删除"处理。
+        # 只有真正的 HTTP/API 异常(见上面 except 分支)才会跳过账号。
+        deleted = local_ids - api_ids
+        ratio = len(deleted) / max(len(local_ids), 1)
+
+        # 护栏:删除比例 > 50% 打 warning 但放行
+        if ratio > 0.5:
+            logger.warning(
+                "[account=%s] 删除比例 %.1f%% 过高(%d/%d),请人工确认是否正常",
+                acct, ratio * 100, len(deleted), len(local_ids),
+            )
+
+        logger.info(
+            "[account=%s] 本地 %d / API %d / 新标记删除 %d",
+            acct, len(local_ids), len(api_ids), len(deleted),
+        )
+        all_deleted_ids.update(deleted)
+        success_accounts.append(acct)
+
+    # 汇总
+    logger.info(
+        "=== 汇总 === 账号 成功=%d / 失败=%d,总计标记删除 %d 个广告",
+        len(success_accounts), len(failed_accounts), len(all_deleted_ids),
+    )
+
+    if args.dry_run:
+        logger.info("[dry-run] 不回写 CSV。示例 ad_id(前 20 个):%s",
+                    sorted(all_deleted_ids)[:20])
+        # 全部失败退出码 2;否则 0
+        return 2 if success_accounts == [] else 0
+
+    if not success_accounts:
+        logger.error("所有账号均拉取失败,不回写 CSV")
+        return 2
+
+    # 回写 is_deleted 列:对成功账号的本地 ad_id 全部重算 False/True;
+    # 对失败账号的行保持原值(如果有),没有原值则先置 False
+    if "is_deleted" not in df.columns:
+        df["is_deleted"] = False
+    else:
+        df["is_deleted"] = df["is_deleted"].fillna(False).astype(bool)
+
+    success_mask = df["account_id"].isin(success_accounts)
+    # 成功账号:先全部置 False,再对在删除集合里的置 True
+    df.loc[success_mask, "is_deleted"] = False
+    deleted_mask = success_mask & df["ad_id"].isin(all_deleted_ids)
+    df.loc[deleted_mask, "is_deleted"] = True
+
+    # 同步将 ad_status 改为 AD_STATUS_DELETED,便于下游直接按 ad_status 识别
+    # (注意:SUSPEND 也在 deleted 集合里时会被覆盖为 DELETED;对下游过滤无影响,
+    # 因为 SUSPEND 和 DELETED 都会被排除在决策表外)
+    if "ad_status" in df.columns:
+        df.loc[deleted_mask, "ad_status"] = "AD_STATUS_DELETED"
+
+    df.to_csv(csv_path, index=False, encoding="utf-8-sig")
+    logger.info(
+        "已写回 %s(is_deleted=True 共 %d 行 / 总 %d 行,ad_status 同步改为 AD_STATUS_DELETED)",
+        csv_path, int(df["is_deleted"].sum()), len(df),
+    )
+
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 74 - 21
examples/auto_put_ad_mini/tools/ad_decision.py

@@ -307,6 +307,16 @@ async def get_ads_for_review(
         if end_date == "yesterday":
             end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
 
+        # 前置过滤:SUSPEND / DELETED 广告不参与 LLM 评估(省 token + 避免无效候选)
+        # 注:apply_decisions 里还有一道兜底过滤;这里是前置剪枝
+        if "configured_status" in df.columns:
+            before = len(df)
+            excluded_status = {"AD_STATUS_SUSPEND", "AD_STATUS_DELETED"}
+            df = df[~df["configured_status"].isin(excluded_status)].copy()
+            dropped = before - len(df)
+            if dropped > 0:
+                logger.info(f"get_ads_for_review 入口过滤 {dropped} 条 SUSPEND/DELETED 广告")
+
         # ===== 新增:读取人群包级别统计数据(同类对比基准)=====
         logger.info("读取人群包级别统计数据...")
         by_tier_stats = {}
@@ -377,17 +387,26 @@ async def get_ads_for_review(
             bid_amount = float(row.get("bid_amount", 0) or 0)
 
             # 零消耗待关停:7日均消耗 < 10元,几乎无活动(强规则,仍保留)
-            # ⚠️ 但需要年龄保护:≤7天的广告不适用零消耗规则
+            # ⚠️ 年龄保护分层:
+            #   - ≤3天(冷启动期):保护,不关停且不评估(不动)
+            #   - 4-7天(早期成长期)+ 低消耗:放行进入候选评估,可能命中"提价分支A"(投手经验1.1第一条:唤醒沉默)
+            #   - >7天(成熟期)+ 低消耗:正常应用零消耗规则关停
             if cost_7d_avg < min_spend_for_zero_spend:
-                # 检查广告年龄
-                if ad_age is not None and ad_age <= EARLY_GROWTH_DAYS:
-                    # 4-7天(早期成长期)或≤3天(冷启动期):保护,不关停
+                if ad_age is not None and ad_age <= COLD_START_DAYS:
+                    # ≤3天(冷启动期):保护,不关停也不评估
                     normal_ads_count += 1
                     logger.debug(
-                        f"广告 {row['ad_id']} 年龄{ad_age}天≤{EARLY_GROWTH_DAYS}天,"
+                        f"广告 {row['ad_id']} 年龄{ad_age}天≤{COLD_START_DAYS}天(冷启动期),"
                         f"虽消耗低({cost_7d_avg:.2f}元),但年龄保护不关停"
                     )
                     continue
+                elif ad_age is not None and ad_age <= EARLY_GROWTH_DAYS:
+                    # 4-7天(早期成长期)+ 低消耗:放行进入候选评估
+                    # 不 continue,让其落到下方 bid_up_candidate_a 判断(投手经验1.1第一条)
+                    logger.debug(
+                        f"广告 {row['ad_id']} 年龄{ad_age}天属早期成长期+消耗低({cost_7d_avg:.2f}元),"
+                        f"放行评估提价分支A(唤醒沉默)"
+                    )
                 else:
                     # >7天的低消耗广告:正常应用零消耗规则
                     zero_spend_ads.append({
@@ -432,23 +451,37 @@ async def get_ads_for_review(
             ad_ctr = ad_click / ad_view if ad_view > 0 else None
             tier_ctr_mean = tier_stats.get("ctr_mean")
 
-            # ===== 出价调整候选(投手经验对齐)=====
-            # 提价条件(投手经验1.1+1.2):
-            #   - ROI高于渠道均值5% + 裂变高于同类10% + CTR正常
-            #   - 消耗<1000(投手经验:"均值消耗小于1000")
-            #   - 仅4-7天可提价(投手经验1.2:">7天稳定期建议不调整出价")
-            bid_up_candidate = (
+            # ===== 出价调整候选(投手经验1.1 双分支 - 不同观察角度,OR 关系)=====
+            # 分支A(消耗角度 / 唤醒沉默):
+            #   3-7天 + 日均消耗 < 10元 + CTR 正常 → 提价 5-10%
+            #   含义:"广告还没跑起来,先用提价信号试探系统是否愿意分发"
+            bid_up_candidate_a = (
+                ad_age is not None
+                and COLD_START_DAYS < ad_age <= EARLY_GROWTH_DAYS          # 4-7天
+                and cost_7d_avg < min_spend_for_zero_spend                 # 日均消耗 < 10元(与 L394 放行口径一致)
+                and bid_amount > 0
+                and (tier_ctr_mean is None or ad_ctr is None               # CTR 不低于同类均值80%("正常"定义)
+                     or ad_ctr >= tier_ctr_mean * 0.80)
+            ) if BID_ADJUSTMENT_ENABLED else False
+
+            # 分支B(ROI+裂变角度 / 优质放量):
+            #   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%
+                and f_roi > roi_mean * 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天已被冷启动排除)
+                and (ad_age is not None and ad_age <= EARLY_GROWTH_DAYS)   # 仅4-7天可提价(≤3天已被冷启动排除)
                 and (tier_fission_mean is None or ad_fission is None       # 裂变高于同类均值10%(无数据时跳过)
                      or ad_fission > tier_fission_mean * 1.10)
-                and (tier_ctr_mean is None or ad_ctr is None               # CTR不低于同类均值80%("正常"定义)
+                and (tier_ctr_mean is None or ad_ctr is None               # CTR 不低于同类均值80%
                      or ad_ctr >= tier_ctr_mean * 0.80)
             ) if BID_ADJUSTMENT_ENABLED else False
 
+            # 命中任一分支即视为提价候选(OR 关系,两条经验路径独立有效)
+            bid_up_candidate = bid_up_candidate_a or bid_up_candidate_b
+
             # 降价:ROI低于渠道均值10% + 裂变低于同类10% + 消耗≥500元/天
             bid_down_candidate = (
                 (not pd.isna(f_roi))
@@ -979,21 +1012,41 @@ async def apply_decisions(
         except Exception as e:
             logger.warning(f"合并 metrics 字段失败(决策CSV将缺少扩展字段): {e}")
 
-        # 过滤:已经是 AD_STATUS_SUSPEND 的广告不应出现在决策表中(已暂停无需再决策)
-        ad_status_path = _MINI_DIR / "outputs" / "ad_status" / f"ad_status_{end_date}.csv"
+        # 过滤:已暂停(SUSPEND)或腾讯侧已删除(is_deleted=True,由 sync_ad_status.py 写入)
+        # 优先读 now()-1d 的 ad_status(和 sync_ad_status.py、im_approval 保持一致,
+        # 因为 is_deleted 是"当前 API 状态快照",不绑定 end_date;且 sync 默认同步 T-1)
+        # 若 T-1 CSV 不存在,降级到 end_date 当天的 CSV 作为兜底。
+        sync_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+        ad_status_path = _MINI_DIR / "outputs" / "ad_status" / f"ad_status_{sync_date}.csv"
+        if not ad_status_path.exists():
+            fallback = _MINI_DIR / "outputs" / "ad_status" / f"ad_status_{end_date}.csv"
+            if fallback.exists():
+                logger.info(f"ad_status T-1 CSV 不存在,降级使用 end_date={end_date} 的快照")
+                ad_status_path = fallback
         if ad_status_path.exists():
             try:
                 df_status = pd.read_csv(ad_status_path)
-                suspended_ads = set(
-                    df_status[df_status["ad_status"] == "AD_STATUS_SUSPEND"]["ad_id"].tolist()
+                # 1) SUSPEND
+                suspended_mask = df_status["ad_status"] == "AD_STATUS_SUSPEND"
+                # 2) 腾讯侧已删除(sync_ad_status.py 每日同步回写,向后兼容:列缺失即视为全 False)
+                if "is_deleted" in df_status.columns:
+                    deleted_mask = df_status["is_deleted"].fillna(False).astype(bool)
+                else:
+                    deleted_mask = pd.Series(False, index=df_status.index)
+                excluded_ads = set(
+                    df_status[suspended_mask | deleted_mask]["ad_id"].tolist()
                 )
 
-                # 过滤掉所有已暂停的广告(不论决策是什么,已暂停的广告不应出现在决策表中)
                 before_count = len(df_out)
-                df_out = df_out[~df_out["ad_id"].isin(suspended_ads)]
+                df_out = df_out[~df_out["ad_id"].isin(excluded_ads)]
                 filtered_count = before_count - len(df_out)
                 if filtered_count > 0:
-                    logger.info(f"过滤掉 {filtered_count} 个已暂停广告(AD_STATUS_SUSPEND)")
+                    n_suspend = int(suspended_mask.sum())
+                    n_deleted = int(deleted_mask.sum())
+                    logger.info(
+                        f"过滤掉 {filtered_count} 个广告"
+                        f"(暂停 {n_suspend} + 腾讯侧已删除 {n_deleted})"
+                    )
             except Exception as e:
                 logger.warning(f"加载广告状态数据失败,跳过过滤: {e}")
 

+ 12 - 2
examples/auto_put_ad_mini/tools/feishu_doc.py

@@ -20,6 +20,7 @@ import json
 import logging
 import sys
 import time
+from datetime import datetime
 from pathlib import Path
 from typing import Dict, Optional
 
@@ -215,9 +216,18 @@ def _set_permission(token: str, file_token: str, file_type: str = "sheet") -> No
 
 
 def _send_link_message(chat_id: str, url: str, title: str) -> bool:
-    """通过 IM 发送在线表格链接到群聊"""
+    """通过 IM 发送在线表格链接到群聊。
+
+    文案:口语化,第二人称「您」,不用【】公文头。
+    """
     try:
-        text = f"**广告决策报告: {title}**\n\n报告已生成,点击查看: [打开在线表格]({url})"
+        now_label = datetime.now().strftime("%m-%d %H:%M")
+        text = (
+            f"这是 {now_label} 这批决策的详单,方便您对照查看:\n"
+            f"{url}\n\n"
+            f"审批请在聊天里直接回复我 ——\n"
+            f"例:『通过』/『拒绝』/『广告 xxx 不动』/『只批准降价』"
+        )
         _feishu.send_message(to=chat_id, text=text)
         logger.info("报告链接已发送到群聊: chat_id=%s", chat_id)
         return True

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

@@ -200,6 +200,104 @@ class Guardrail(ABC):
         pass
 
 
+# ═══════════════════════════════════════════
+# 护栏 0: 决策一致性自检(action ↔ pct ↔ ROI 方向对齐)
+# ═══════════════════════════════════════════
+
+
+class ActionConsistencyGuardrail(Guardrail):
+    """决策一致性护栏:拦住 LLM 的自相矛盾输出。
+
+    典型问题(实测事故):
+      1. action=bid_down but pct=0 → 显示为 "降价 0%",人类看着就困惑
+      2. action=bid_down but ROI 高于 tier 中位数 ×1.05 → reason 说"表现优秀"却降价
+      3. action=hold/observe/pause but pct != 0 → 维持类动作不应改出价
+
+    处理策略:
+      - 方向硬冲突(bid_down/bid_up 方向和 pct 相反)→ 改为 hold,记录 modified
+      - 维持类动作 pct ≠ 0 → 把 pct 归 0,改为 modified
+      - 高 ROI + bid_down → 改为 hold(避免误杀明星广告)
+    """
+
+    # ROI 保护阈值:ROI ≥ tier_p50 × 该倍数时禁止 bid_down
+    ROI_PROTECT_FACTOR = 1.05
+
+    @property
+    def name(self) -> str:
+        return "决策一致性"
+
+    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
+
+        # ---- 规则 A1:bid_down 必须 pct < 0 ----
+        if action == "bid_down" and pct >= 0:
+            return GuardrailResult(
+                status="modified",
+                reason=f"方向冲突:action=bid_down 但 pct={pct:+.2%}(应为负)→ 改 hold",
+                modified_action="hold",
+                modified_change_pct=0.0,
+                modified_bid=None,
+            )
+
+        # ---- 规则 A2:bid_up 必须 pct > 0 ----
+        if action == "bid_up" and pct <= 0:
+            return GuardrailResult(
+                status="modified",
+                reason=f"方向冲突:action=bid_up 但 pct={pct:+.2%}(应为正)→ 改 hold",
+                modified_action="hold",
+                modified_change_pct=0.0,
+                modified_bid=None,
+            )
+
+        # ---- 规则 A3:hold/observe pct 必须为 0 ----
+        if action in ("hold", "observe") and abs(pct) > 1e-6:
+            return GuardrailResult(
+                status="modified",
+                reason=f"维持类动作不应改出价:action={action} 但 pct={pct:+.2%} → pct 归零",
+                modified_action=action,
+                modified_change_pct=0.0,
+                modified_bid=None,
+            )
+
+        # ---- 规则 B:高 ROI 保护(严禁对优秀广告降价)----
+        # 优先使用 7 日均值(更稳定),回退到单日动态ROI
+        roi_val = row.get("动态ROI_7日均值", None)
+        if roi_val is None or (isinstance(roi_val, float) and pd.isna(roi_val)):
+            roi_val = row.get("动态ROI", None)
+        tier_p50 = row.get("tier_roi_p50", None)
+
+        try:
+            roi_val = float(roi_val) if roi_val is not None else None
+            tier_p50 = float(tier_p50) if tier_p50 is not None else None
+        except (ValueError, TypeError):
+            roi_val = None
+            tier_p50 = None
+
+        if action == "bid_down" and roi_val is not None and tier_p50 is not None and tier_p50 > 0:
+            if roi_val >= tier_p50 * self.ROI_PROTECT_FACTOR:
+                return GuardrailResult(
+                    status="modified",
+                    reason=(
+                        f"高 ROI 保护:动态ROI {roi_val:.2f} ≥ tier 中位数 {tier_p50:.2f}×{self.ROI_PROTECT_FACTOR} "
+                        f"= {tier_p50*self.ROI_PROTECT_FACTOR:.2f},禁止降价 → 改 hold"
+                    ),
+                    modified_action="hold",
+                    modified_change_pct=0.0,
+                    modified_bid=None,
+                )
+
+        return GuardrailResult(status="approved", reason="")
+
+
 # ═══════════════════════════════════════════
 # 护栏 1: 冷启动保护
 # ═══════════════════════════════════════════
@@ -523,6 +621,7 @@ def _run_guardrails(
     history = AdjustmentHistory()
 
     guardrails = [
+        ActionConsistencyGuardrail(),  # ★ 首位:拦 action↔pct↔ROI 矛盾(避免"降价 0%"等)
         ColdStartGuardrail(),
         DataFreshnessGuardrail(),
         BidBoundaryGuardrail(),

+ 49 - 4
examples/auto_put_ad_mini/tools/im_approval.py

@@ -469,13 +469,35 @@ async def send_approval_request(
         else:
             logger.warning("未找到 metrics 文件,审批表格将缺少关键字段")
 
-        # 过滤已暂停的广告(不应出现在审批表中)
+        # 过滤已暂停/已删除的广告(不应出现在审批表中)
         if "configured_status" in df.columns:
             before_count = len(df)
-            df = df[df["configured_status"] != "AD_STATUS_SUSPEND"].copy()
+            excluded_status = {"AD_STATUS_SUSPEND", "AD_STATUS_DELETED"}
+            df = df[~df["configured_status"].isin(excluded_status)].copy()
             filtered_count = before_count - len(df)
             if filtered_count > 0:
-                logger.info(f"审批请求过滤掉 {filtered_count} 个已暂停广告")
+                logger.info(f"审批请求过滤掉 {filtered_count} 个已暂停/已删除广告(configured_status)")
+
+        # 第二道防线:腾讯侧已删除(由 sync_ad_status.py 每日同步回写 is_deleted 列)
+        # 即使上游 apply_decisions 的过滤改坏,也不让僵尸广告进入飞书审批表
+        try:
+            from config import AD_STATUS_DIR
+            bizdate = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+            ad_status_path = AD_STATUS_DIR / f"ad_status_{bizdate}.csv"
+            if ad_status_path.exists():
+                df_status = pd.read_csv(ad_status_path)
+                if "is_deleted" in df_status.columns:
+                    deleted_ids = set(
+                        df_status[df_status["is_deleted"].fillna(False).astype(bool)]["ad_id"].tolist()
+                    )
+                    if deleted_ids:
+                        before_count = len(df)
+                        df = df[~df["ad_id"].isin(deleted_ids)].copy()
+                        dropped = before_count - len(df)
+                        if dropped > 0:
+                            logger.info(f"审批请求过滤掉 {dropped} 个腾讯侧已删除广告")
+        except Exception as e:
+            logger.warning(f"读取 ad_status CSV 做 is_deleted 过滤失败(不影响主流程): {e}")
 
         if df.empty:
             return ToolResult(title="send_approval_request", output="过滤后无数据")
@@ -493,7 +515,30 @@ async def send_approval_request(
             return ToolResult(title="send_approval_request", output="无决策数据")
 
         # 合并需审批的和无需操作的(供运营参考)
-        df_for_review = pd.concat([df_tier2_3, df_tier0], ignore_index=True) if not df_tier0.empty else df_tier2_3
+        # ⚠️ 排除 hold(保持)和 observe(观察):这两类不需运营立刻干预,不写飞书表降低噪声
+        # creative_adjust(创意调整)仍保留——它是主动建议运营换素材的弱信号
+        FEISHU_EXCLUDE_ACTIONS = {"hold", "observe"}
+        df_tier0_for_review = df_tier0
+        if not df_tier0.empty:
+            action_col = "final_action" if "final_action" in df_tier0.columns else "action"
+            if action_col in df_tier0.columns:
+                before_filter = len(df_tier0)
+                df_tier0_for_review = df_tier0[~df_tier0[action_col].isin(FEISHU_EXCLUDE_ACTIONS)].copy()
+                dropped_count = before_filter - len(df_tier0_for_review)
+                if dropped_count > 0:
+                    dropped_breakdown = (
+                        df_tier0[df_tier0[action_col].isin(FEISHU_EXCLUDE_ACTIONS)][action_col]
+                        .value_counts().to_dict()
+                    )
+                    logger.info(
+                        f"飞书表过滤掉 {dropped_count} 个 {sorted(FEISHU_EXCLUDE_ACTIONS)} 决策"
+                        f"(明细: {dropped_breakdown},减少表格噪声)"
+                    )
+        df_for_review = (
+            pd.concat([df_tier2_3, df_tier0_for_review], ignore_index=True)
+            if not df_tier0_for_review.empty
+            else df_tier2_3
+        )
 
         if df_tier2_3.empty:
             total_no_op = len(df_tier0) + len(df_tier1)

+ 70 - 10
examples/auto_put_ad_mini/tools/roi_calculator.py

@@ -130,6 +130,7 @@ def _aggregate_creative_to_ad(df: pd.DataFrame) -> pd.DataFrame:
         "configured_status": "first",
         "bid_amount": "first",
         "广告优化目标": "first",
+        "package_name": "first",   # 人群包名称(如 R50*泛知识*生活科普)
         "人群包人数": "first",
         # 数值指标(SUM — 聚合后再计算派生比值,不能直接平均)
         "cost": "sum",
@@ -306,10 +307,14 @@ def _calculate_7d_summary(ad_df: pd.DataFrame, end_date: str) -> pd.DataFrame:
     ].copy()
 
     # 按 ad_id 聚合
-    summary = df_7d.groupby("ad_id", as_index=False).agg({
-        "cost": "sum",
-        "total_revenue": "sum",
-    })
+    agg_cols = {"cost": "sum", "total_revenue": "sum"}
+    # 7日累计 click/view(供人群包基线计算 CTR)
+    if "valid_click_count" in df_7d.columns:
+        agg_cols["valid_click_count"] = "sum"
+    if "view_count" in df_7d.columns:
+        agg_cols["view_count"] = "sum"
+
+    summary = df_7d.groupby("ad_id", as_index=False).agg(agg_cols)
 
     summary.rename(columns={
         "cost": "cost_7d_total",
@@ -318,8 +323,11 @@ def _calculate_7d_summary(ad_df: pd.DataFrame, end_date: str) -> pd.DataFrame:
 
     summary["cost_7d_avg"] = summary["cost_7d_total"] / 7
 
-    # 获取最新一天的 动态ROI(单日值)和 动态ROI_7日均值(决策参考值)
-    latest_df = ad_df[ad_df["date"] == end_date][["ad_id", "动态ROI", "动态ROI_7日均值"]].copy()
+    # 获取最新一天的 动态ROI + T0裂变系数_7日均值
+    latest_cols_7d = ["ad_id", "动态ROI", "动态ROI_7日均值"]
+    if "T0裂变系数_7日均值" in ad_df.columns:
+        latest_cols_7d.append("T0裂变系数_7日均值")
+    latest_df = ad_df[ad_df["date"] == end_date][[c for c in latest_cols_7d if c in ad_df.columns]].copy()
     latest_df.rename(columns={
         "动态ROI": "动态ROI_latest",
         "动态ROI_7日均值": "动态ROI_7日均值_latest"
@@ -442,6 +450,7 @@ async def calculate_roi_metrics(
         latest_cols = [
             "ad_id", "account_id", "ad_name", "create_time",
             "configured_status", "bid_amount", "creative_count",
+            "package_name",  # 人群包名称(如 R50*泛知识*生活科普)
             "yesterday_roi", "yesterday_cost",  # 昨日ROI+昨日消耗(投手经验2.4关停门槛)
         ]
         # 只取存在的列(yesterday_cost 可能在 _calculate_yesterday_roi 中添加)
@@ -456,10 +465,17 @@ async def calculate_roi_metrics(
             (end_dt - pd.to_datetime(result_df["create_time"])).dt.days
         )
 
-        # ===== 新增:添加 audience_tier 和 roi_valid_days =====
-        # 提取人群包(从 ad_name)
+        # ===== 人群包字段 =====
+        # audience_tier: 使用 package_name 原始值(如 R50*泛知识*生活科普)
+        # r_tier: 从 ad_name 提取的 R 层级(如 R50),作为辅助分组
         extract_tier = _get_extract_audience_tier()
-        result_df["audience_tier"] = result_df["ad_name"].apply(extract_tier)
+        result_df["r_tier"] = result_df["ad_name"].apply(extract_tier)
+        # 优先使用 package_name 作为 audience_tier,缺失时用 r_tier 兜底
+        if "package_name" in result_df.columns:
+            result_df["audience_tier"] = result_df["package_name"].fillna("").replace("", pd.NA)
+            result_df["audience_tier"] = result_df["audience_tier"].fillna(result_df["r_tier"])
+        else:
+            result_df["audience_tier"] = result_df["r_tier"]
 
         # 获取 roi_valid_days(从 ad_df 最新一天的数据)
         latest_roi_valid = ad_df[ad_df["date"] == end_date_str][["ad_id", "roi_valid_days"]].copy()
@@ -489,6 +505,49 @@ async def calculate_roi_metrics(
         result_df.to_csv(metrics_temp, index=False, encoding="utf-8-sig")
         logger.info("指标临时文件已更新: %s", metrics_temp)
 
+        # ===== 自动生成 portfolio_summary(人群包基线)=====
+        # 这是决策引擎的硬依赖,直接在 ROI 计算完成后生成
+        portfolio_tier_count = 0
+        try:
+            from tools.portfolio_metrics import _describe_group, _compute_daily_tier_snapshot, _compute_market_signal
+            portfolio_dir = _MINI_DIR / "outputs" / "portfolio_summary"
+            portfolio_dir.mkdir(parents=True, exist_ok=True)
+            portfolio_file = portfolio_dir / f"portfolio_summary_{end_date_str}.json"
+
+            by_tier = {}
+            if "audience_tier" in result_df.columns:
+                for tier, group in result_df.groupby("audience_tier"):
+                    by_tier[str(tier)] = _describe_group(group)
+
+            by_tier_goal = {}
+            goal_col = "广告优化目标" if "广告优化目标" in result_df.columns else None
+            if "audience_tier" in result_df.columns and goal_col:
+                for (tier, goal), group in result_df.groupby(["audience_tier", goal_col]):
+                    by_tier_goal[f"{tier}_{goal}"] = _describe_group(group)
+
+            global_stats = _describe_group(result_df)
+            by_date = _compute_daily_tier_snapshot(end_dt, days=7)
+            market_signal = _compute_market_signal(by_date)
+
+            import json as _json
+            summary = {
+                "end_date": end_date_str,
+                "source_csv": str(metrics_csv),
+                "by_audience_tier": by_tier,
+                "by_tier_goal": by_tier_goal,
+                "global": global_stats,
+                "by_date": by_date,
+                "market_signal": market_signal,
+            }
+            portfolio_file.write_text(
+                _json.dumps(summary, ensure_ascii=False, indent=2),
+                encoding="utf-8",
+            )
+            portfolio_tier_count = len(by_tier)
+            logger.info("人群包基线已生成: %s(%d 个人群包)", portfolio_file, portfolio_tier_count)
+        except Exception as e:
+            logger.warning("人群包基线生成失败(不影响主流程): %s", e)
+
         output_lines = [
             f"ROI 计算完成(截至 {end_date_str})",
             f"广告总数: {len(result_df)}",
@@ -503,7 +562,8 @@ async def calculate_roi_metrics(
             "  - cost_7d_total, cost_7d_avg, revenue_7d_total",
             "  - cost_30d_total, cost_30d_avg, stable_spend_days_30d",
             "  - ad_age_days, creative_count",
-            "  - audience_tier(人群包层级,用于同类对比)",
+            "  - audience_tier(人群包名称,如 R50*泛知识*生活科普,用于同类对比)",
+            "  - r_tier(R层级,如 R50,辅助分组)",
             "  - roi_valid_days(有效ROI数据天数,用于置信度评估)",
         ]
 

+ 63 - 0
examples/auto_put_ad_mini/投手经验.md

@@ -0,0 +1,63 @@
+# 投手经验 — 广告调整与关停策略
+
+---
+
+## 一、调整
+
+### 1. 出价
+
+#### 1.1 上调
+
+- **广告创建时间 > 3天**
+  - 消耗(日均消耗大于某值)/ 且 CTR/CVR(参考行业均值或固定)或者是会员周期的所有渠道的均值
+  - **操作**:均值出价基础上提价 **5-10%**
+
+- **广告创建时间 3-7天**
+  - 条件:后端数据表现良好,均值消耗小于1000
+  - 7日均值 ROI 高于渠道均值范围 **5-10%**;同类(人群定向)裂变均值范围 **10-15%**
+  - **操作**:均值出价基础上提价 **5-10%**
+
+#### 1.2 固定定向广告
+
+- **广告创建时间 > 7天**
+  - 稳定期建议不调整出价,增加账户/广告/创意去拿消耗,观察广告生命周期
+  - 单稳定消耗 > 1000 时 → 增加账户/广告/创意(详见新增)
+
+#### 1.3 下调
+
+- **同类人群定向的均值出价** → 详见"关停-数据差"
+
+- **消耗 > 500,ROI 低于渠道均值 10-15%,且裂变低于同类(人群定向)均值 10-15%**
+  - **操作**:调整降低出价 **3-5%**
+  - **操作**:调整素材方向
+
+---
+
+## 二、关停
+
+### 2.1 日常关停
+
+- 分渠道,当日消耗 > 15万(根据前一日总预算定)
+  - **操作**:当日关停并设置第二天6点投放
+
+### 2.2 新增实验定向广告
+
+(单独分支,作为关停下的一个类别)
+
+### 2.3 数据差控停
+
+### 2.4 固定定向广告
+
+- **条件**:广告创建时间 > 3天,当天消耗 > 300(不足7天,有几天用几天的均值)
+
+  - **ROI 低于渠道均值 10-15%,且裂变低于同类(人群定向)均值 10-15%**:
+    - 调整降低出价 **3-5%** → 持续低于均值就关停
+    - 调整素材方向 → 持续低于均值就关停
+
+  - **ROI 低于渠道均值 25-30%**:
+    - **操作**:直接关停
+
+### 2.5 无效广告清理
+
+- 广告连续 7-10 天无消耗,且消耗 < 10
+  - **操作**:关停