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

refactor(auto_put_ad_mini): ROI 对比口径对齐(渠道P50) + 升级 Sonnet 4.5 + 审批消息降噪

【ROI 决策口径统一】prompt/skills/代码三处同步
- 阈值基准从"同类中位数 tier_roi_p50"改为"渠道P50 (roi_mean)"
- 裂变率 / CTR 仍用同类均值 tier_fission_mean,两套基准严禁混用
- ad_decision.py: pause_line/bid_down_line/bid_up_line 重算为 channel_p50 × {0.70~0.75, 0.85~0.90, 1.05~1.10}
- 从 LLM context 移除 tier_roi_p25/p50/p75/mean 字段,避免 LLM 误用
- guardrails 删除重复的"高 ROI 保护"规则,改由 prompt 硬约束承担
- skills/roi_strategy.md 头部补"对比口径分离"口径表;案例 1-3 / 降价 / 关停参考范围全部改写为渠道P50

【模型升级 + prompt 加固】
- config.py: qwen → anthropic/claude-sonnet-4.5,开启 max_tokens=32000
- prompt 新增硬约束:apply_decisions 前禁止输出 markdown 分析表格 / tier 拆分汇总(防 max_tokens 截断 tool_call)

【审批消息体验降噪】
- im_approval: 审批卡片极简化,只保留"总量 + 各 action 分桶 + 追溯码",删掉 Top5 示例、影响金额、操作指引
- feishu_doc: 新增 preamble 参数,合并"概述 + 表格链接 + 审批指引"为单条 IM 消息,决策明细下沉到在线表格
- 追溯码智能识别并保持在消息最末尾

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

+ 2 - 2
examples/auto_put_ad_mini/config.py

@@ -23,7 +23,7 @@ except ImportError:
 # ═══════════════════════════════════════════
 # ═══════════════════════════════════════════
 
 
 MAIN_CONFIG = RunConfig(
 MAIN_CONFIG = RunConfig(
-    model="qwen/qwen3.5-plus-02-15",
+    model="anthropic/claude-sonnet-4.5",
     temperature=0.3,
     temperature=0.3,
     max_iterations=50,
     max_iterations=50,
     name="广告智能调控助手",
     name="广告智能调控助手",
@@ -51,7 +51,7 @@ MAIN_CONFIG = RunConfig(
         # 会陷入"无法 apply"的死循环。直接在主 Agent 单次输出完成全部 decisions 更可靠。
         # 会陷入"无法 apply"的死循环。直接在主 Agent 单次输出完成全部 decisions 更可靠。
     ],
     ],
     skills=["ad-domain", "roi-strategy", "guardrail-rules", "tencent-ad-playbook"],
     skills=["ad-domain", "roi-strategy", "guardrail-rules", "tencent-ad-playbook"],
-    # extra_llm_params={"extra_body": {"enable_thinking": True}},  # 禁用:千问不支持 thinking
+    extra_llm_params={"max_tokens": 32000},
     knowledge=KnowledgeConfig(
     knowledge=KnowledgeConfig(
         enable_extraction=False,
         enable_extraction=False,
         enable_completion_extraction=False,
         enable_completion_extraction=False,

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

@@ -24,7 +24,7 @@ $system$
 
 
 **工具提供事实**(广告数据、tier 统计、阈值参考),**Skill 提供判断原则**(决策经验、年龄策略、后验观察),**你像法官一样综合判断**:读证据 → 依原则 → 出决策 → 用中文解释。
 **工具提供事实**(广告数据、tier 统计、阈值参考),**Skill 提供判断原则**(决策经验、年龄策略、后验观察),**你像法官一样综合判断**:读证据 → 依原则 → 出决策 → 用中文解释。
 
 
-阈值(如 `tier_roi_p50 × 1.05`)只是参考,**真正的决策依据是 Skill 中的业务洞察**——别机械套数值。
+阈值(如"渠道P50 × 1.05")只是参考,**真正的决策依据是 Skill 中的业务洞察**——别机械套数值。
 
 
 # 第三部分:意图理解(理解语义,非关键词匹配)
 # 第三部分:意图理解(理解语义,非关键词匹配)
 
 
@@ -150,10 +150,10 @@ Step 10: generate_report          ← 生成报告
 
 
 | 条件 | 允许的 action | 禁止的 action | 原因 |
 | 条件 | 允许的 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 低的双低广告无挽救价值 |
+| 动态ROI ≥ 渠道P50 × 1.05(优于渠道中位数 5%)| `hold`/`bid_up`/`scale_up`/`observe` | ❌ `bid_down` | ROI 都优于渠道了还降价 = 自相矛盾(Top 1 实测事故)|
+| 动态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 低的双低广告无挽救价值 |
 
 
 ### C. reason 与 action 语义一致
 ### C. reason 与 action 语义一致
 
 
@@ -178,7 +178,7 @@ Step 10: generate_report          ← 生成报告
 2. **创意变化** — 7 天内是否换过创意?消耗是否改善?
 2. **创意变化** — 7 天内是否换过创意?消耗是否改善?
 3. **数据稳定性** — 30 天内稳定消耗天数是否 ≥ 7?
 3. **数据稳定性** — 30 天内稳定消耗天数是否 ≥ 7?
 4. **广告年龄** — 新生期(≤3)/冷启动(4-7)/成熟期(>7) 三段式
 4. **广告年龄** — 新生期(≤3)/冷启动(4-7)/成熟期(>7) 三段式
-5. **人群包同类对比** — 优先 tier_roi_p50,禁止只看全局均值
+5. **同类对比(裂变 / CTR)** — 裂变率、CTR 等微观指标用同类均值;ROI 阈值用渠道P50(见第五部分自检表 B)
 6. **ROI 数据置信度** — 基于 roi_valid_days 分级(≥7 高 / 4-6 中 / ≤3 低)
 6. **ROI 数据置信度** — 基于 roi_valid_days 分级(≥7 高 / 4-6 中 / ≤3 低)
 
 
 详细判断标准、案例、后验经验见 roi-strategy skill。
 详细判断标准、案例、后验经验见 roi-strategy skill。
@@ -187,6 +187,7 @@ Step 10: generate_report          ← 生成报告
 - reason 中禁止出现英文变量名(pause_line、bid_down_line、tier_roi_p50 等),改用中文术语
 - reason 中禁止出现英文变量名(pause_line、bid_down_line、tier_roi_p50 等),改用中文术语
 - reason 不得模板化(错例:"ROI 低于线建议降价";正例见 roi-strategy skill 的"决策纪律"小节)
 - reason 不得模板化(错例:"ROI 低于线建议降价";正例见 roi-strategy skill 的"决策纪律"小节)
 - 冷启动期(4-7 天)系统会过滤 bid_down/pause,你也不该主动给这两种建议
 - 冷启动期(4-7 天)系统会过滤 bid_down/pause,你也不该主动给这两种建议
+- 🚨 **禁止在 apply_decisions 之前输出 markdown 分析表格、tier 拆分汇总、决策汇总表**。看完候选数据后**直接调用 apply_decisions** 提交决策 JSON 数组。所有判断理由都写进每条决策的 `reason` 字段里,**不要**在工具调用前面铺一段中间报告(会触发 max_tokens 截断导致 tool_call 失败)。
 
 
 # 第七部分:投放经验知识库(Skills)
 # 第七部分:投放经验知识库(Skills)
 
 

+ 5 - 6
examples/auto_put_ad_mini/run_decision_test.py

@@ -60,12 +60,11 @@ async def run_decision_test(end_date='20260415'):
                 print(f"      ROI有效天数: {ad.get('roi_valid_days', 'N/A')} 天")
                 print(f"      ROI有效天数: {ad.get('roi_valid_days', 'N/A')} 天")
                 print(f"      年龄分段: {ad.get('age_segment', 'N/A')} ({ad.get('age_protection_level', 'N/A')})")
                 print(f"      年龄分段: {ad.get('age_segment', 'N/A')} ({ad.get('age_protection_level', 'N/A')})")
 
 
-                # 检查同类对比字段
-                if 'tier_roi_p50' in ad:
-                    print(f"      同类中位数: {ad['tier_roi_p50']}")
-                    print(f"      关停线: {ad.get('pause_line_min', 'N/A')} ~ {ad.get('pause_line_max', 'N/A')}")
-                    print(f"      降价线: {ad.get('bid_down_line_min', 'N/A')} ~ {ad.get('bid_down_line_max', 'N/A')}")
-                    print(f"      提价线: {ad.get('bid_up_line_min', 'N/A')} ~ {ad.get('bid_up_line_max', 'N/A')}")
+                # 检查阈值字段(基于渠道P50)
+                if 'pause_line_min' in ad:
+                    print(f"      关停线(渠道P50×0.70~0.75): {ad.get('pause_line_min', 'N/A')} ~ {ad.get('pause_line_max', 'N/A')}")
+                    print(f"      降价线(渠道P50×0.85~0.90): {ad.get('bid_down_line_min', 'N/A')} ~ {ad.get('bid_down_line_max', 'N/A')}")
+                    print(f"      提价线(渠道P50×1.05~1.10): {ad.get('bid_up_line_min', 'N/A')} ~ {ad.get('bid_up_line_max', 'N/A')}")
 
 
                 # 检查操作限制
                 # 检查操作限制
                 if ad.get('age_segment') == 'cold_start':
                 if ad.get('age_segment') == 'cold_start':

+ 108 - 81
examples/auto_put_ad_mini/skills/roi_strategy.md

@@ -17,13 +17,13 @@ description: 广告调控决策手册 - 同类对比/年龄保护/置信度/6 
 2. **创意变化** — 7 天内是否换过创意?消耗是否改善?
 2. **创意变化** — 7 天内是否换过创意?消耗是否改善?
 3. **数据稳定性** — 30 天内稳定消耗天数是否 ≥ 7?
 3. **数据稳定性** — 30 天内稳定消耗天数是否 ≥ 7?
 4. **广告年龄** — 新生期(≤3) / 冷启动(4-7) / 成熟期(>7) 三段式
 4. **广告年龄** — 新生期(≤3) / 冷启动(4-7) / 成熟期(>7) 三段式
-5. **人群包同类对比** — 优先 tier_roi_p50,禁止只看全局均值
+5. **对比基准分离** — ROI 看渠道P50(`roi_mean`),裂变/CTR 看同类均值(`tier_fission_mean`),严禁用同类中位数判 ROI
 6. **ROI 数据置信度** — 基于 roi_valid_days 分级(≥7 高 / 4-6 中 / ≤3 低)
 6. **ROI 数据置信度** — 基于 roi_valid_days 分级(≥7 高 / 4-6 中 / ≤3 低)
 
 
 ### 反例警示(避免模板化)
 ### 反例警示(避免模板化)
 
 
 **❌ 错**:"ROI 为 1.80,低于降价线 2.65,建议降 5%"
 **❌ 错**:"ROI 为 1.80,低于降价线 2.65,建议降 5%"
-**✅ 对**:"动态 ROI 为 1.80,低于同类中位数 2.65 的 32%;7 天内已提价但 ROI 仍低迷,判断调价无效;7 日日均消耗 4438 元属于高消耗,建议关停"
+**✅ 对**:"动态 ROI 为 1.80,低于渠道P50 2.65 的 32%;7 天内已提价但 ROI 仍低迷,判断调价无效;7 日日均消耗 4438 元属于高消耗,建议关停"
 
 
 **❌ 错**:"ROI=1.25 < pause_line(1.36),建议关停"
 **❌ 错**:"ROI=1.25 < pause_line(1.36),建议关停"
 **✅ 对**:"动态 ROI 为 1.25,低于关停线 1.36;但 30 天内仅 3 天消耗稳定数据波动较大,且广告仅投放 7 天仍在学习期,建议观察而非立即关停"
 **✅ 对**:"动态 ROI 为 1.25,低于关停线 1.36;但 30 天内仅 3 天消耗稳定数据波动较大,且广告仅投放 7 天仍在学习期,建议观察而非立即关停"
@@ -33,61 +33,86 @@ description: 广告调控决策手册 - 同类对比/年龄保护/置信度/6 
 
 
 > **硬约束**:reason 中禁止出现英文变量名(pause_line、bid_down_line、tier_roi_p50、bid_increased_7d 等),改用中文术语;reason 不得模板化(套用同一句式回答所有广告)。
 > **硬约束**: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 受广告整体竞争环境影响,必须看全渠道;裂变率反映人群质量,必须看同类才公平。
+
 ---
 ---
 
 
-## 一、人群包同类对比(必须优先)⭐
+## 一、对比基准分离原则(必须优先)⭐
+
+### 两类指标,两套基准
+
+| 指标 | 对比基准 | 原因 |
+|------|---------|------|
+| **动态 ROI** | **渠道P50**(`roi_mean` = 全体广告"7日均值"的中位数)| ROI 反映广告在整个竞价市场的效率,必须跨人群统一衡量 |
+| **裂变率 fission_rate** | **同类均值**(`tier_fission_mean` = 同人群包的裂变均值)| 裂变反映人群质量,不同 R 值人群裂变水位天然不同 |
+| **CTR** | **同类均值** | 曝光质量受人群影响 |
 
 
-### 为什么必须同类对比
+> ⚠️ **常见误区**:历史上曾用"同人群 ROI 中位数"做 ROI 基准,导致 R500 广告 ROI 3.0(优于渠道但低于 R500 同类)被误判降价。现已全面修正为**渠道P50**。
 
 
-不同人群包的 ROI 分布差异巨大:
-- **R500(高价值人群)**:ROI 中位数通常在 3.5-4.5
-- **R330(中高价值)**:ROI 中位数通常在 3.0-3.5
-- **R100(中等价值)**:ROI 中位数通常在 2.0-2.5
-- **R50(低价值人群)**:ROI 中位数通常在 1.5-2.2
+### 为什么 ROI 必须看渠道
 
 
-**错误做法**:
-用全局均值(如 2.8)判断所有广告
-- R500 的 3.0(实际是同类中下水平)→ 被误判为"优秀"
-- R50 的 2.5(实际是同类优秀)→ 被误判为"偏低"
+- 渠道整体水位(`roi_mean`)反映当前大盘的"合理回报"
+- 某人群同类中位数 **不是**业务意义上的"达标线" —— 低价值人群同类中位数低,不代表这类广告"达标"
+- 运营视角:预算是跨人群共享的,低于渠道P50 就是跑不出渠道平均效率,应优化或淘汰
 
 
-**正确做法**:
-- R500 广告与 R500 组的中位数对比
-- R50 广告与 R50 组的中位数对比
+### 为什么裂变率必须看同类
+
+- 不同人群的裂变天然不同(R500 高价值人群裂变弱、R50 宽泛人群裂变强)
+- 跨人群比裂变率等于"比苹果和橘子"
+- 只有同 R 值人群的裂变均值才能衡量"这条广告在它的人群里裂变强不强"
 
 
 ---
 ---
 
 
-### 如何进行同类对比
+### 如何做对比(关键字段)
 
 
-**数据字段说明**:
+**数据字段**:
 - `audience_tier`: 人群包类型(R500、R330、R100、R50 等)
 - `audience_tier`: 人群包类型(R500、R330、R100、R50 等)
-- `tier_roi_p50`: 该人群包的 ROI 中位数(同类对比基准)
+- `roi_mean`: **渠道P50**(全体广告"动态ROI 7日均值"的中位数)— ROI 唯一基准
+- `tier_fission_mean`: 同人群包的裂变率均值 — 裂变唯一基准
 - `dynamic_roi_7d`: 广告自身的 7 日动态 ROI
 - `dynamic_roi_7d`: 广告自身的 7 日动态 ROI
 
 
 **判断方法**:
 **判断方法**:
-1. 查看广告的人群包类型
-2. 获取该人群包的中位数(tier_roi_p50
-3. 计算广告 ROI 与中位数的偏离程度
+1. ROI 判断:`dynamic_roi_7d` vs `roi_mean`(渠道P50)
+2. 裂变判断:广告裂变率 vs `tier_fission_mean`(同类均值
+3. 两个维度独立判断,不互相替代
 
 
 **案例理解 1:提价判断**
 **案例理解 1:提价判断**
 
 
 场景:
 场景:
 - 人群包:R500
 - 人群包:R500
-- 同类中位数:3.8
-- 广告 ROI:4.2
-- 裂变率:0.72
+- 渠道动态ROI中位数(P50):2.50
 - 同类裂变均值:0.60
 - 同类裂变均值:0.60
+- 广告 ROI:3.20
+- 广告裂变率:0.72
 
 
 分析过程:
 分析过程:
-- ROI 对比:4.2 vs 3.8 → 高出 0.4
-- 偏离程度:(4.2 - 3.8) / 3.8 = 10.5%
-- 裂变率对比:0.72 vs 0.60 → 高出 20%
-- 判断:ROI 高于同类 10%,裂变率高于同类 20%
+- ROI 对比:3.20 vs 渠道P50 2.50 → 高出 28%(远超 +5% 提价线)
+- 裂变率对比:0.72 vs 同类均值 0.60 → 高出 20%
+- 判断:ROI 在渠道里属优质表现,裂变率在同类中也优秀
 
 
 决策:**提价 8%**
 决策:**提价 8%**
 
 
 理由模板:
 理由模板:
-> "动态 ROI 为 4.2,高于 R500 组中位数 3.8 的 10.5%,在同类中属于优质表现;
-> 裂变率 0.72 高于同类均值 0.60 的 20%,说明长期价值优秀;
+> "动态 ROI 为 3.20,高于渠道P50 2.50 的 28%,在全渠道里属优质表现;
+> 裂变率 0.72 高于同类均值 0.60 的 20%,长期价值优秀;
 > 广告数据稳定,建议提价 8% 放大优质流量"
 > 广告数据稳定,建议提价 8% 放大优质流量"
 
 
 ---
 ---
@@ -96,23 +121,22 @@ description: 广告调控决策手册 - 同类对比/年龄保护/置信度/6 
 
 
 场景:
 场景:
 - 人群包:R330
 - 人群包:R330
-- 同类中位数:3.5
-- 广告 ROI:3.0
-- 裂变率:0.50
+- 渠道动态ROI中位数(P50):2.50
 - 同类裂变均值:0.58
 - 同类裂变均值:0.58
+- 广告 ROI:2.20
+- 广告裂变率:0.50
 
 
 分析过程:
 分析过程:
-- ROI 对比:3.0 vs 3.5 → 低了 0.5
-- 偏离程度:(3.0 - 3.5) / 3.5 = -14.3%
-- 裂变率对比:0.50 vs 0.58 → 低了 14%
-- 判断:ROI 低于同类 14%,裂变率也偏低
+- ROI 对比:2.20 vs 渠道P50 2.50 → 低 12%(落入 [-15%, -10%] 降价区间)
+- 裂变率对比:0.50 vs 同类均值 0.58 → 低 14%(同类裂变也偏低)
+- 判断:ROI 低于渠道但未到关停线,且裂变率在同类中偏低 → 降价优化
 
 
-决策:**降价 6%**
+决策:**降价 4%**
 
 
 理由模板:
 理由模板:
-> "动态 ROI 为 3.0,低于 R330 组中位数 3.5 的 14.3%,在同类中表现偏低;
-> 裂变率 0.50 低于同类均值 0.58,长期价值一般;
-> 建议降价 6% 优化成本"
+> "动态 ROI 为 2.20,低于渠道P50 2.50 的 12%,在全渠道中表现偏低;
+> 裂变率 0.50 低于同类均值 0.58 的 14%,长期价值一般;
+> 7 日均消耗 ≥ 500 元数据可信,建议降价 4%(硬边界 3%-5% 内)优化成本"
 
 
 ---
 ---
 
 
@@ -120,20 +144,19 @@ description: 广告调控决策手册 - 同类对比/年龄保护/置信度/6 
 
 
 场景:
 场景:
 - 人群包:R100
 - 人群包:R100
-- 同类中位数:2.3
-- 广告 ROI:1.6
+- 渠道动态ROI中位数(P50):2.50
+- 广告 ROI:1.50
 - 消耗:日均 200 元
 - 消耗:日均 200 元
 
 
 分析过程:
 分析过程:
-- ROI 对比:1.6 vs 2.3 → 低了 0.7
-- 偏离程度:(1.6 - 2.3) / 2.3 = -30.4%
-- 判断:ROI 只有同类的 70%,明显低效
+- ROI 对比:1.50 vs 渠道P50 2.50 → 低 40%(远低于关停线 ×0.75)
+- 判断:ROI 仅为渠道P50 的 60%,明显低效
 
 
 决策:**关停**
 决策:**关停**
 
 
 理由模板:
 理由模板:
-> "动态 ROI 为 1.6,低于 R100 组中位数 2.3 的 30.4%,在同类中表现明显偏低
-> 已处于同类最差水平,建议关停释放预算"
+> "动态 ROI 为 1.50,低于渠道P50 2.50 的 40%,仅为渠道中位数的 60%
+> 已远低于关停线(渠道P50 × 0.75 = 1.88),建议关停释放预算"
 
 
 ---
 ---
 
 
@@ -176,9 +199,9 @@ description: 广告调控决策手册 - 同类对比/年龄保护/置信度/6 
 
 
 #### 提价幅度(A、B 共用)
 #### 提价幅度(A、B 共用)
 
 
-- 高于同类 5-7% → 提价 5%
-- 高于同类 7-10% → 提价 8%
-- 高于同类 10% 以上 → 提价 10%
+- 高于**渠道P50** 5-7% → 提价 5%
+- 高于**渠道P50** 7-10% → 提价 8%
+- 高于**渠道P50** 10% 以上 → 提价 10%
 - 分支 A 默认取下限(5%),因为数据不足、试探性更强
 - 分支 A 默认取下限(5%),因为数据不足、试探性更强
 
 
 ---
 ---
@@ -189,18 +212,22 @@ description: 广告调控决策手册 - 同类对比/年龄保护/置信度/6 
 当广告表现低于同类,但还有优化空间时
 当广告表现低于同类,但还有优化空间时
 
 
 **参考标准**:
 **参考标准**:
-- ROI 低于同类中位数 **10-15%**
-- 消耗较高(日均 ≥ 500 元)
+- ROI 低于**渠道P50** 10-15%
+- 7 日均消耗 ≥ 500 元(消耗太低数据不可信,硬规则已过滤)
+- 裂变率低于**同类均值** 10% 或更多
 
 
 **综合考虑**:
 **综合考虑**:
 - 如果 ROI 低且消耗高 → **积极降价**(优化成本)
 - 如果 ROI 低且消耗高 → **积极降价**(优化成本)
-- 如果近期换过创意 → **观察 2-3 天再降价**
-- 如果近期已降价 → **避免频繁调整**
+- 如果近期换过创意(< 7 天)→ **观察 2-3 天再降价**
+- 如果近期已降价(7 天内)→ **避免频繁调整**,改 hold/observe
 - 降价时同时建议运营调整素材方向(不自动执行,仅在理由中输出)
 - 降价时同时建议运营调整素材方向(不自动执行,仅在理由中输出)
 
 
-**降价幅度**:
-- 低于同类 10-12% → 降价 3-5%
-- 低于同类 12-15% → 降价 5-8%
+**降价幅度**(基于 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` 路径,不要用"大幅降价"去代替关停。
 
 
 ---
 ---
 
 
@@ -215,8 +242,9 @@ description: 广告调控决策手册 - 同类对比/年龄保护/置信度/6 
 - 不满足以上条件的低 ROI 广告会被归入 hold
 - 不满足以上条件的低 ROI 广告会被归入 hold
 
 
 **参考标准**:
 **参考标准**:
-- ROI 低于同类中位数 **25-30%** 或更多
-- 已处于同类最差水平
+- ROI 低于**渠道P50** 25% 或更多(即 ROI < 渠道P50 × 0.75)
+- 已处于全渠道下分位水平
+- (裂变率作为辅助信号,不作为关停硬阈值)
 
 
 **持续低 ROI 升级关停**:
 **持续低 ROI 升级关停**:
 - 如果广告之前被降价过,且降价后 ≥ 7 天 ROI 仍低于渠道均值 → 自动升级为关停候选
 - 如果广告之前被降价过,且降价后 ≥ 7 天 ROI 仍低于渠道均值 → 自动升级为关停候选
@@ -264,7 +292,7 @@ description: 广告调控决策手册 - 同类对比/年龄保护/置信度/6 
 - **降价会打断学习,重新起跑**
 - **降价会打断学习,重新起跑**
 
 
 **策略**:
 **策略**:
-- ✅ **允许提价**(如果ROI高于同类中位数5-10% 且 裂变率高10-15%)
+- ✅ **允许提价**(如果 ROI 高于**渠道P50** 5-10% 且 裂变率高于**同类均值** 10-15%)
 - ❌ **不允许降价**(避免打断系统学习)
 - ❌ **不允许降价**(避免打断系统学习)
 - ❌ **不关停**(给予充分学习时间)
 - ❌ **不关停**(给予充分学习时间)
 - ✅ 允许 `observe`(数据不稳定时)
 - ✅ 允许 `observe`(数据不稳定时)
@@ -296,7 +324,7 @@ LLM建议: bid_down -5%
 - ✅ **可降价**,最大5%(`max_bid_down_pct=0.05`)— 决策树明确3-5%上限
 - ✅ **可降价**,最大5%(`max_bid_down_pct=0.05`)— 决策树明确3-5%上限
 - ❌ **不提价**(稳定期不调出价,通过新增广告/创意扩量)
 - ❌ **不提价**(稳定期不调出价,通过新增广告/创意扩量)
 - ✅ **可扩量**(scale_up,建议新增广告或创意拿消耗)
 - ✅ **可扩量**(scale_up,建议新增广告或创意拿消耗)
-- ✅ **可关停**(ROI低于同类中位数25-30%
+- ✅ **可关停**(ROI 低于**渠道P50** 25-30%,即 < 渠道P50 × 0.75
 - ✅ 可降价、观察、调整素材方向
 - ✅ 可降价、观察、调整素材方向
 
 
 **数据标识**:
 **数据标识**:
@@ -477,7 +505,7 @@ LLM建议: bid_down -5%
 - 由运营人员根据理由人工调整素材
 - 由运营人员根据理由人工调整素材
 
 
 **理由示例**:
 **理由示例**:
-> "动态ROI为2.8,高于R500组中位数,但7日均消耗仅65元;裂变率0.45低于同类均值0.62;建议调整素材方向,突出裂变激励"
+> "动态ROI为2.8,高于渠道P50 2.50 的 12%,但7日均消耗仅65元;裂变率0.45低于同类均值0.62;建议调整素材方向,突出裂变激励"
 
 
 ---
 ---
 
 
@@ -544,7 +572,7 @@ LLM建议: bid_down -5%
 1. 广告已投放 >7天(成熟期)
 1. 广告已投放 >7天(成熟期)
 2. 消耗稳定(stable_spend_days_30d ≥ 7)
 2. 消耗稳定(stable_spend_days_30d ≥ 7)
 3. 日均消耗高(cost_7d_avg > 1000元)
 3. 日均消耗高(cost_7d_avg > 1000元)
-4. ROI 正常或优秀(≥ 同类中位数的90%)
+4. ROI 正常或优秀(≥ 渠道P50 的 90%)
 
 
 **与其他action的区别**:
 **与其他action的区别**:
 - vs **bid_up**:bid_up是提高单个广告出价拉量,scale_up是建议复制成功经验新增资源
 - vs **bid_up**:bid_up是提高单个广告出价拉量,scale_up是建议复制成功经验新增资源
@@ -565,8 +593,7 @@ LLM建议: bid_down -5%
    - 说明广告已充分跑量,有扩量空间
    - 说明广告已充分跑量,有扩量空间
 
 
 3. **ROI 表现正常或优秀**:
 3. **ROI 表现正常或优秀**:
-   - 动态ROI ≥ 同类中位数的90%(tier_roi_p50 * 0.9)
-   - 或 动态ROI ≥ 全体均值的90%(roi_mean * 0.9)
+   - 动态ROI ≥ 渠道P50 的 90%(`roi_mean * 0.9`)
    - 说明投放效率可以接受,值得扩大规模
    - 说明投放效率可以接受,值得扩大规模
 
 
 4. **标记为扩量候选**:
 4. **标记为扩量候选**:
@@ -587,8 +614,8 @@ LLM建议: bid_down -5%
   - 增加账户预算配额
   - 增加账户预算配额
 
 
 **理由示例**:
 **理由示例**:
-- "广告已投放12天,消耗稳定(30日内稳定10天),7日日均消耗1250元;动态ROI为2.8,高于R500组中位数2.6的8%;建议扩量:复制该广告配置或新增创意"
-- "成熟期广告,日均消耗1500元,ROI稳定在2.5(同类中位数2.3);已验证投放效果,建议扩大规模"
+- "广告已投放12天,消耗稳定(30日内稳定10天),7日日均消耗1250元;动态ROI为2.8,高于渠道P50 2.50 的 12%;建议扩量:复制该广告配置或新增创意"
+- "成熟期广告,日均消耗1500元,ROI稳定在2.5(高于渠道P50 2.30 的 8%);已验证投放效果,建议扩大规模"
 
 
 ---
 ---
 
 
@@ -610,11 +637,10 @@ LLM建议: bid_down -5%
 - ✓ 今天是周几?(周末数据需要折扣理解)
 - ✓ 今天是周几?(周末数据需要折扣理解)
 - ✓ 消耗是否异常波动?(可能是竞争因素)
 - ✓ 消耗是否异常波动?(可能是竞争因素)
 
 
-**3. 同类对比分析**:
-- ✓ 广告的人群包类型(audience_tier)
-- ✓ 同类中位数(tier_roi_p50)
-- ✓ 偏离程度(高/低多少百分比)
-- ✓ 裂变率对比
+**3. 对比基准分析(ROI vs 渠道,裂变 vs 同类)**:
+- ✓ ROI 对比:广告动态 ROI vs 渠道P50(`roi_mean`),计算偏离百分比
+- ✓ 裂变对比:广告裂变率 vs 同类均值(`tier_fission_mean`),计算偏离百分比
+- ✓ 人群包类型(`audience_tier`)仅用于定位同类裂变基准,不用于 ROI 判断
 
 
 **4. 后验经验应用**:
 **4. 后验经验应用**:
 - ✓ 如果近期调价,ROI 波动是否在正常范围?
 - ✓ 如果近期调价,ROI 波动是否在正常范围?
@@ -627,8 +653,8 @@ LLM建议: bid_down -5%
 
 
 **提价理由**:
 **提价理由**:
 ```
 ```
-"动态 ROI 为 {roi},高于 {tier} 组中位数 {p50} 的 {pct}%,在同类中表现{优秀/良好};
-裂变率 {fission} {高于/低于} 同类均值 {mean} 的 {f_pct}%;
+"动态 ROI 为 {roi},高于渠道P50 {channel_p50} 的 {pct}%,在全渠道中表现{优秀/良好};
+裂变率 {fission} {高于/低于} 同类均值 {tier_mean} 的 {f_pct}%;
 {数据稳定性说明};
 {数据稳定性说明};
 {后验经验考虑};
 {后验经验考虑};
 建议提价 {pct}%"
 建议提价 {pct}%"
@@ -636,8 +662,8 @@ LLM建议: bid_down -5%
 
 
 **降价理由**:
 **降价理由**:
 ```
 ```
-"动态 ROI 为 {roi},低于 {tier} 组中位数 {p50} 的 {pct}%,在同类中表现偏低;
-{裂变率说明};
+"动态 ROI 为 {roi},低于渠道P50 {channel_p50} 的 {pct}%,在全渠道中表现偏低;
+{裂变率说明:对比同类均值 tier_fission_mean};
 {消耗情况};
 {消耗情况};
 建议降价 {pct}% 优化成本"
 建议降价 {pct}% 优化成本"
 ```
 ```
@@ -653,8 +679,8 @@ LLM建议: bid_down -5%
 
 
 **关停理由**:
 **关停理由**:
 ```
 ```
-"动态 ROI 为 {roi},低于 {tier} 组中位数 {p50} 的 {pct}%,
-在同类中处于明显低效水平(低于 25-30%)
+"动态 ROI 为 {roi},低于渠道P50 {channel_p50} 的 {pct}%,
+已低于关停线(渠道P50 × 0.75 = {pause_line}),在全渠道中处于明显低效水平
 {其他判断依据};
 {其他判断依据};
 建议关停释放预算"
 建议关停释放预算"
 ```
 ```
@@ -666,7 +692,8 @@ LLM建议: bid_down -5%
 ### 灵活判断,不要机械套用
 ### 灵活判断,不要机械套用
 
 
 1. **阈值是参考,不是铁律**
 1. **阈值是参考,不是铁律**
-   - 决策阈值(5-10%、10-15%、25-30%)是**同类对比的偏离区间**
+   - 决策阈值(5-10%、10-15%、25-30%)是 **ROI 相对"渠道P50"的偏离区间**
+   - 裂变率阈值(±10-15%)是相对"同类均值"的偏离区间
    - 关键是理解偏离程度的业务含义,不是精确计算
    - 关键是理解偏离程度的业务含义,不是精确计算
    - 需结合多个因素综合判断
    - 需结合多个因素综合判断
 
 

+ 14 - 17
examples/auto_put_ad_mini/tools/ad_decision.py

@@ -611,16 +611,13 @@ async def get_ads_for_review(
                 ad_dict["audience_tier"] = str(row.get("audience_tier", "default"))
                 ad_dict["audience_tier"] = str(row.get("audience_tier", "default"))
                 ad_dict["roi_valid_days"] = int(row.get("roi_valid_days", 0) or 0)
                 ad_dict["roi_valid_days"] = int(row.get("roi_valid_days", 0) or 0)
 
 
-                # ===== 新增:添加同类对比数据 =====
+                # ===== 同类对比数据(仅裂变/CTR/出价,ROI 对比走渠道) =====
                 tier = ad_dict.get("audience_tier", "default")
                 tier = ad_dict.get("audience_tier", "default")
                 tier_stats = by_tier_stats.get(tier, {})
                 tier_stats = by_tier_stats.get(tier, {})
 
 
-                ad_dict["tier_roi_p25"] = tier_stats.get("roi_p25")
-                ad_dict["tier_roi_p50"] = tier_stats.get("roi_p50")
-                ad_dict["tier_roi_p75"] = tier_stats.get("roi_p75")
-                ad_dict["tier_roi_mean"] = tier_stats.get("roi_mean")
+                # ROI 对比走"渠道整体"(roi_mean),故此处不注入 tier_roi_* 字段
 
 
-                # ===== 新增:裂变率同类对比数据(如果有)=====
+                # 裂变率同类对比数据(裂变必须对比同人群)
                 ad_dict["tier_fission_mean"] = tier_stats.get("fission_mean")
                 ad_dict["tier_fission_mean"] = tier_stats.get("fission_mean")
                 ad_dict["tier_fission_p50"] = tier_stats.get("fission_p50")
                 ad_dict["tier_fission_p50"] = tier_stats.get("fission_p50")
 
 
@@ -635,20 +632,20 @@ 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_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
                 ad_dict["bid_up_target_max"] = round(tier_bid_mean * 1.10, 4) if tier_bid_mean else None
 
 
-                # 计算动态阈值(供LLM参考)
-                tier_roi_p50 = tier_stats.get("roi_p50", roi_mean)  # 兜底用全局均值
+                # ROI 阈值线:基于"渠道P50"(roi_mean,全体广告7日均值的中位数),严禁用同类
+                channel_p50 = roi_mean
 
 
-                # 关停线:中位数的 70-75%(低于25-30%)
-                ad_dict["pause_line_min"] = round(tier_roi_p50 * 0.70, 4) if tier_roi_p50 else None
-                ad_dict["pause_line_max"] = round(tier_roi_p50 * 0.75, 4) if tier_roi_p50 else None
+                # 关停线:渠道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
 
 
-                # 降价线:中位数的 85-90%(低于10-15%)
-                ad_dict["bid_down_line_min"] = round(tier_roi_p50 * 0.85, 4) if tier_roi_p50 else None
-                ad_dict["bid_down_line_max"] = round(tier_roi_p50 * 0.90, 4) if tier_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
 
 
-                # 提价线:中位数的 105-110%(高于5-10%)— 决策树标准
-                ad_dict["bid_up_line_min"] = round(tier_roi_p50 * 1.05, 4) if tier_roi_p50 else None
-                ad_dict["bid_up_line_max"] = round(tier_roi_p50 * 1.10, 4) if tier_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
 
 
                 # ===== 新增:年龄分段标签(基于决策树图片)=====
                 # ===== 新增:年龄分段标签(基于决策树图片)=====
                 if ad_age is not None:
                 if ad_age is not None:

+ 49 - 13
examples/auto_put_ad_mini/tools/feishu_doc.py

@@ -215,25 +215,49 @@ def _set_permission(token: str, file_token: str, file_type: str = "sheet") -> No
         logger.info("文档权限已设置: anyone_editable(任何人获得链接可编辑 - 最大权限)")
         logger.info("文档权限已设置: anyone_editable(任何人获得链接可编辑 - 最大权限)")
 
 
 
 
-def _send_link_message(chat_id: str, url: str, title: str) -> bool:
+def _send_link_message(chat_id: str, url: str, title: str, preamble: str = ""):
     """通过 IM 发送在线表格链接到群聊。
     """通过 IM 发送在线表格链接到群聊。
 
 
-    文案:口语化,第二人称「您」,不用【】公文头。
+    - preamble 为空:走默认文案(老行为)
+    - preamble 非空:合并"概述 + 表格链接 + 审批指引"为一条消息
+      会智能识别 preamble 末尾的"(追溯码: xxx)"行并保持其在最末尾
+
+    返回:成功返回 send_message 的 result 对象(含 chat_id / message_id),
+          失败返回 None。
     """
     """
     try:
     try:
-        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)
+        if preamble:
+            # 拆出末尾追溯码行(如有),URL/指引插在追溯码之前
+            body_lines = preamble.rstrip().split("\n")
+            traceback_line = ""
+            if body_lines and "追溯码" in body_lines[-1]:
+                traceback_line = body_lines.pop()
+                # 追溯码上方可能有空行,清理掉
+                while body_lines and body_lines[-1].strip() == "":
+                    body_lines.pop()
+            body = "\n".join(body_lines)
+            text = (
+                f"{body}\n\n"
+                f"📋 决策详单:{url}\n\n"
+                f"审批请在聊天里直接回复我 ——\n"
+                f"例:『通过』/『拒绝』/『广告 xxx 不动』/『只批准降价』"
+            )
+            if traceback_line:
+                text += f"\n\n{traceback_line}"
+        else:
+            now_label = datetime.now().strftime("%m-%d %H:%M")
+            text = (
+                f"这是 {now_label} 这批决策的详单,方便您对照查看:\n"
+                f"{url}\n\n"
+                f"审批请在聊天里直接回复我 ——\n"
+                f"例:『通过』/『拒绝』/『广告 xxx 不动』/『只批准降价』"
+            )
+        result = _feishu.send_message(to=chat_id, text=text)
         logger.info("报告链接已发送到群聊: chat_id=%s", chat_id)
         logger.info("报告链接已发送到群聊: chat_id=%s", chat_id)
-        return True
+        return result
     except Exception as e:
     except Exception as e:
         logger.warning("发送报告链接失败(不影响主流程): %s", e)
         logger.warning("发送报告链接失败(不影响主流程): %s", e)
-        return False
+        return None
 
 
 
 
 # ═══════════════════════════════════════════
 # ═══════════════════════════════════════════
@@ -246,6 +270,7 @@ async def import_to_feishu(
     xlsx_path: str = "",
     xlsx_path: str = "",
     send_im: bool = True,
     send_im: bool = True,
     chat_id: str = "",
     chat_id: str = "",
+    preamble: str = "",
 ) -> ToolResult:
 ) -> ToolResult:
     """将 xlsx 文件导入飞书在线表格并分享
     """将 xlsx 文件导入飞书在线表格并分享
 
 
@@ -255,6 +280,8 @@ async def import_to_feishu(
         xlsx_path: xlsx 文件路径。为空时自动使用 outputs/reports/ 下最新的 xlsx
         xlsx_path: xlsx 文件路径。为空时自动使用 outputs/reports/ 下最新的 xlsx
         send_im: 是否通过 IM 发送链接(默认 True)
         send_im: 是否通过 IM 发送链接(默认 True)
         chat_id: 目标群聊 ID。为空时使用配置中的 FEISHU_OPERATOR_CHAT_ID
         chat_id: 目标群聊 ID。为空时使用配置中的 FEISHU_OPERATOR_CHAT_ID
+        preamble: 合并通知文本(非空时 IM 消息 = preamble + 表格链接 + 审批指引,单条合一;
+                  为空时走默认"链接+简短文案"模板)
     """
     """
     try:
     try:
         # --- 1. 定位 xlsx 文件 ---
         # --- 1. 定位 xlsx 文件 ---
@@ -298,11 +325,18 @@ async def import_to_feishu(
 
 
         # --- 6. IM 发送链接 ---
         # --- 6. IM 发送链接 ---
         im_sent = False
         im_sent = False
+        im_chat_id = ""
+        im_message_id = ""
         if send_im and url:
         if send_im and url:
             target_chat = chat_id or FEISHU_OPERATOR_CHAT_ID
             target_chat = chat_id or FEISHU_OPERATOR_CHAT_ID
             if target_chat:
             if target_chat:
                 title = file_path.stem
                 title = file_path.stem
-                im_sent = _send_link_message(target_chat, url, title)
+                send_result = _send_link_message(target_chat, url, title, preamble=preamble)
+                if send_result is not None:
+                    im_sent = True
+                    # send_message 返回值里通常带 chat_id/message_id,用于 P2P 轮询关联
+                    im_chat_id = getattr(send_result, "chat_id", "") or ""
+                    im_message_id = getattr(send_result, "message_id", "") or ""
 
 
         # --- 结果 ---
         # --- 结果 ---
         output_lines = [
         output_lines = [
@@ -321,6 +355,8 @@ async def import_to_feishu(
                 "file_type": file_type,
                 "file_type": file_type,
                 "xlsx_path": str(file_path),
                 "xlsx_path": str(file_path),
                 "im_sent": im_sent,
                 "im_sent": im_sent,
+                "im_chat_id": im_chat_id,
+                "im_message_id": im_message_id,
             },
             },
         )
         )
 
 

+ 3 - 36
examples/auto_put_ad_mini/tools/guardrails.py

@@ -210,18 +210,12 @@ class ActionConsistencyGuardrail(Guardrail):
 
 
     典型问题(实测事故):
     典型问题(实测事故):
       1. action=bid_down but pct=0 → 显示为 "降价 0%",人类看着就困惑
       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 → 维持类动作不应改出价
+      2. 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 ≥ 渠道P50 × 1.05 禁止 bid_down)已由 prompt 硬约束承担,
+    这里不再做规则化拦截,避免和 LLM 判断重复。
     """
     """
 
 
-    # ROI 保护阈值:ROI ≥ tier_p50 × 该倍数时禁止 bid_down
-    ROI_PROTECT_FACTOR = 1.05
-
     @property
     @property
     def name(self) -> str:
     def name(self) -> str:
         return "决策一致性"
         return "决策一致性"
@@ -268,33 +262,6 @@ class ActionConsistencyGuardrail(Guardrail):
                 modified_bid=None,
                 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="")
         return GuardrailResult(status="approved", reason="")
 
 
 
 

+ 121 - 152
examples/auto_put_ad_mini/tools/im_approval.py

@@ -258,96 +258,60 @@ def _score_top_decisions(df_tier2: pd.DataFrame, top_n: int = 5) -> pd.DataFrame
 
 
 
 
 def _format_approval_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, df_tier0: pd.DataFrame, request_id: str) -> str:
 def _format_approval_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, df_tier0: pd.DataFrame, request_id: str) -> str:
-    """格式化审批消息(极简版,目标:手机单屏可读,≤ 1 KB)。
+    """格式化审批消息(极简版,目标:手机单屏可读)。
 
 
     设计原则:
     设计原则:
-      - 三行头部:标题 + 决策统计(一行含所有 action 数量) + 影响金额(一行)
-      - 用 Top 5 高置信/高消耗决策替代"前 5 个示例"的无排序列表
-      - 回复指引压到一行(核心 3 种 + "直接说您的想法"兜底自由表达)
-      - 明确告知"30 分钟无回复 = 默认拒绝",无隐式自动通过
-      - 标题用人性化时间,不暴露 request_id(内部 ID 放末尾追溯即可)
-      - 两条消息策略:本文本 + 飞书表格详单链接(由调用方串行发送)
+      - 只保留三块信息:总量 + 各动作数量及昨日消耗 + 追溯码
+      - 决策明细、审批指引由调用方拼接的表格链接消息承载
+      - hold / observe / creative_adjust 合并为"观察"一行
+      - 只打印非零桶,固定顺序避免抖动
     """
     """
     total = len(df_tier2)
     total = len(df_tier2)
-
-    # 统计各 action 数量
-    action_col = df_tier2.get("final_action", df_tier2.get("action", ""))
-    action_counts = action_col.value_counts().to_dict() if total > 0 else {}
-    n_pause = action_counts.get("pause", 0)
-    n_down = action_counts.get("bid_down", 0)
-    n_up = action_counts.get("bid_up", 0)
-    n_scale = action_counts.get("scale_up", 0)
-    n_observe = sum(v for k, v in action_counts.items() if k in ("observe", "hold", "creative_adjust"))
-
-    # 影响金额(7日均消耗合计 + 昨日总消耗)
-    cost_7d = pd.to_numeric(df_tier2.get("cost_7d_avg", 0), errors="coerce").fillna(0).sum() if total > 0 else 0.0
-    yesterday_cost = pd.to_numeric(df_tier2.get("yesterday_cost", 0), errors="coerce").fillna(0).sum() if "yesterday_cost" in df_tier2.columns else 0.0
-
-    # 人性化时间标题:04-21 02:33
     now_label = datetime.now().strftime("%m-%d %H:%M")
     now_label = datetime.now().strftime("%m-%d %H:%M")
 
 
     lines = [
     lines = [
         f"📊 广告调控 · {now_label} · 请您复核",
         f"📊 广告调控 · {now_label} · 请您复核",
-        f"📌 决策 {total} 条 · ⏸{n_pause} ⬇{n_down} ⬆{n_up} 🚀{n_scale} 👀{n_observe}(全部需您审批)",
-        f"💰 影响金额:受影响广告 7 日均消耗合计 {cost_7d:,.0f} 元,昨日总消耗 {yesterday_cost:,.0f} 元",
-        "",
+        f"📌 共 {total} 条决策需您审批",
     ]
     ]
 
 
-    # Top N 高置信/高消耗决策
     if total > 0:
     if total > 0:
-        top_df = _score_top_decisions(df_tier2, top_n=5)
-        header = f"🔝 Top {len(top_df)} 高置信/高消耗决策" if total > 5 else f"🔝 全部决策({total} 个)"
-        lines.append(header)
-
-        for idx, (_, row) in enumerate(top_df.iterrows(), start=1):
-            ad_id = row.get("ad_id", "")
-            action = str(row.get("final_action", row.get("action", ""))).strip()
-            ad_name = str(row.get("ad_name", ""))[:20]
-            reason = str(row.get("reason", "")).replace("\n", " ")[:60]
-
-            # ROI(优先使用 7 日均值,回退到单日动态 ROI)
-            roi_val = row.get("动态ROI_7日均值", row.get("动态ROI", row.get("yesterday_roi", 0)))
-            try:
-                roi_val = float(roi_val)
-                roi_str = f"ROI {roi_val:.2f}"
-            except (ValueError, TypeError):
-                roi_str = "ROI -"
-
-            # 动作标签
-            if action == "pause":
-                action_label = "⏸ 暂停"
-            elif action == "bid_down":
-                pct = row.get("recommended_change_pct", 0)
-                try:
-                    pct = float(pct)
-                except (ValueError, TypeError):
-                    pct = 0
-                action_label = f"⬇ 降价 {abs(pct)*100:.0f}%"
-            elif action == "bid_up":
-                pct = row.get("recommended_change_pct", 0)
-                try:
-                    pct = float(pct)
-                except (ValueError, TypeError):
-                    pct = 0
-                action_label = f"⬆ 提价 {pct*100:.0f}%"
-            elif action == "scale_up":
-                action_label = "🚀 扩量"
-            else:
-                action_label = action
-
-            lines.append(f"   {idx}. [{ad_id}] {ad_name} | {action_label} | {roi_str} | {reason}")
-
-        if total > 5:
-            lines.append(f"   (完整详单见下方表格消息)")
-        lines.append("")
-
-    # 回复方式(多轮协商,精简到核心 3 种 + 自由表达)
-    lines.extend([
-        "📝 回复:\"通过\" / \"拒绝\" / \"广告 12345 不动\" / \"只批准 降价\",或直接说您的想法",
-        f"⏰ {IM_APPROVAL_TIMEOUT_MINUTES} 分钟无回复 = 默认拒绝",
-        f"(追溯码: {request_id})",
-    ])
-
+        # 取 action 列(双 fallback,保持和现状一致)
+        action_col = df_tier2.get("final_action", df_tier2.get("action", pd.Series(dtype=str)))
+        df = df_tier2.assign(_action=action_col.fillna(""))
+
+        # hold / observe / creative_adjust 合并为 observe 桶
+        def _bucket(a: str) -> str:
+            if a in ("hold", "observe", "creative_adjust"):
+                return "observe"
+            return a
+        df["_bucket"] = df["_action"].map(_bucket)
+
+        # 昨日消耗(列缺失或非数值自动兜底为 0)
+        cost_col = pd.to_numeric(df.get("yesterday_cost", 0), errors="coerce").fillna(0)
+        df["_ycost"] = cost_col
+
+        agg = df.groupby("_bucket").agg(
+            n=("ad_id", "count"),
+            cost=("_ycost", "sum"),
+        ).to_dict("index")
+
+        # 显式顺序;无数据的桶不打印
+        display_order = [
+            ("pause",    "⏸ 暂停"),
+            ("bid_down", "⬇ 降价"),
+            ("bid_up",   "⬆ 提价"),
+            ("scale_up", "🚀 扩量"),
+            ("observe",  "👀 观察"),
+        ]
+
+        lines.extend(["", "各动作明细:"])
+        for bucket, label in display_order:
+            info = agg.get(bucket)
+            if not info or info["n"] == 0:
+                continue
+            lines.append(f"  {label} {info['n']} 条|昨日消耗 {info['cost']:,.0f} 元")
+
+    lines.extend(["", f"(追溯码: {request_id})"])
     return "\n".join(lines)
     return "\n".join(lines)
 
 
 
 
@@ -561,90 +525,95 @@ async def send_approval_request(
             "validated_csv": validated_csv,
             "validated_csv": validated_csv,
         }
         }
 
 
-        # ─── 通过飞书 API 发送审批消息(文本 + Excel) ───
+        # ─── 通过飞书 API 发送审批消息(单条合并:概述 + 表格链接 + 审批指引) ───
+        # 设计:不再分两次发(纯文本 + 表格链接),改为一次发合并消息。
+        # message 作为 preamble 传给 import_to_feishu,内部 _send_link_message 负责合并。
         feishu_sent = False
         feishu_sent = False
         feishu_sent_to_project_chat = False
         feishu_sent_to_project_chat = False
         sent_time_sec = str(int(time.time()))  # 飞书 API start_time 单位:秒
         sent_time_sec = str(int(time.time()))  # 飞书 API start_time 单位:秒
         poll_chat_ids = []  # 用于轮询的真正 chat_id(从 send_message 返回值中提取)
         poll_chat_ids = []  # 用于轮询的真正 chat_id(从 send_message 返回值中提取)
         try:
         try:
-            # 消息 1a:发送到个人(FEISHU_OPERATOR_OPEN_ID)
-            if FEISHU_OPERATOR_OPEN_ID:
-                try:
-                    result_personal = _feishu.send_message(to=FEISHU_OPERATOR_OPEN_ID, text=message)
-                    logger.info("飞书审批消息发送成功(个人): message_id=%s", result_personal.message_id)
-                    # ✅ 关键修复:从返回值提取 P2P chat_id(用于后续轮询)
-                    if hasattr(result_personal, 'chat_id') and result_personal.chat_id:
-                        poll_chat_ids.append(result_personal.chat_id)
-                        logger.info("提取到 P2P chat_id: %s(用于轮询回复)", result_personal.chat_id)
-                    feishu_sent = True
-                except Exception as e:
-                    logger.warning("发送到个人失败: %s", e)
+            xlsx_path = _generate_approval_xlsx(df_for_review, request_id)
+
+            from feishu_doc import import_to_feishu
 
 
-            # 消息 1b:发送到投放项目群聊 — 与个人 IM 一致的完整审批文本
+            # 发送到项目群
             if FEISHU_AD_PROJECT_CHAT_ID:
             if FEISHU_AD_PROJECT_CHAT_ID:
                 try:
                 try:
-                    result_project = _feishu.send_message(to=FEISHU_AD_PROJECT_CHAT_ID, text=message)
-                    feishu_sent_to_project_chat = True
-                    feishu_sent = True
-                    logger.info("飞书审批消息发送成功(项目群): message_id=%s", result_project.message_id)
-                    # 群聊 chat_id 本身就是 oc_xxx 格式,可直接用于轮询回复
-                    if FEISHU_AD_PROJECT_CHAT_ID not in poll_chat_ids:
-                        poll_chat_ids.append(FEISHU_AD_PROJECT_CHAT_ID)
-                except Exception as e:
-                    logger.warning("发送到项目群聊失败: %s", e)
-
-            # 消息 2:导入为飞书在线表格(决策详情,含hold参考)
-            try:
-                xlsx_path = _generate_approval_xlsx(df_for_review, request_id)
-
-                from feishu_doc import import_to_feishu
-
-                # 发送到项目群(与个人一致)
-                if FEISHU_AD_PROJECT_CHAT_ID:
-                    try:
-                        import_result = await import_to_feishu(
-                            ctx=ctx,
-                            xlsx_path=str(xlsx_path),
-                            send_im=True,
-                            chat_id=FEISHU_AD_PROJECT_CHAT_ID,
+                    import_result = await import_to_feishu(
+                        ctx=ctx,
+                        xlsx_path=str(xlsx_path),
+                        send_im=True,
+                        chat_id=FEISHU_AD_PROJECT_CHAT_ID,
+                        preamble=message,
+                    )
+                    meta = import_result.metadata or {}
+                    if meta.get("url"):
+                        logger.info("飞书审批合并消息发送成功(项目群): %s", meta["url"])
+                        if meta.get("im_sent"):
+                            feishu_sent = True
+                            feishu_sent_to_project_chat = True
+                            # 群聊 chat_id 本身就是 oc_xxx,可直接轮询
+                            if FEISHU_AD_PROJECT_CHAT_ID not in poll_chat_ids:
+                                poll_chat_ids.append(FEISHU_AD_PROJECT_CHAT_ID)
+                    else:
+                        logger.warning("飞书在线表格导入失败(项目群),回退到文件附件 + 文本")
+                        # 回退:发 preamble + 文件附件
+                        try:
+                            _feishu.send_message(to=FEISHU_AD_PROJECT_CHAT_ID, text=message)
+                        except Exception:
+                            pass
+                        file_result = _feishu.send_file(
+                            to=FEISHU_AD_PROJECT_CHAT_ID,
+                            file=str(xlsx_path),
+                            file_name=f"广告审批_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
                         )
                         )
-                        if import_result.metadata and import_result.metadata.get("url"):
-                            logger.info("飞书审批表格发送成功(项目群): %s", import_result.metadata["url"])
-                        else:
-                            logger.warning("飞书在线表格导入失败(项目群),回退到文件附件模式")
-                            file_result = _feishu.send_file(
-                                to=FEISHU_AD_PROJECT_CHAT_ID,
-                                file=str(xlsx_path),
-                                file_name=f"广告审批_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
-                            )
-                            logger.info("飞书审批 Excel(文件)发送成功(项目群): message_id=%s", file_result.message_id)
-                    except Exception as e:
-                        logger.warning("发送表格到项目群失败: %s", e)
+                        logger.info("飞书审批 Excel(文件)发送成功(项目群): message_id=%s", file_result.message_id)
+                        feishu_sent = True
+                        feishu_sent_to_project_chat = True
+                        if FEISHU_AD_PROJECT_CHAT_ID not in poll_chat_ids:
+                            poll_chat_ids.append(FEISHU_AD_PROJECT_CHAT_ID)
+                except Exception as e:
+                    logger.warning("发送合并消息到项目群失败: %s", e)
 
 
-                # 发送到个人
-                if FEISHU_OPERATOR_OPEN_ID:
-                    try:
-                        personal_import_result = await import_to_feishu(
-                            ctx=ctx,
-                            xlsx_path=str(xlsx_path),
-                            send_im=True,
-                            chat_id=FEISHU_OPERATOR_OPEN_ID
+            # 发送到个人
+            if FEISHU_OPERATOR_OPEN_ID:
+                try:
+                    personal_import_result = await import_to_feishu(
+                        ctx=ctx,
+                        xlsx_path=str(xlsx_path),
+                        send_im=True,
+                        chat_id=FEISHU_OPERATOR_OPEN_ID,
+                        preamble=message,
+                    )
+                    meta_p = personal_import_result.metadata or {}
+                    if meta_p.get("url"):
+                        logger.info("飞书审批合并消息发送成功(个人): %s", meta_p["url"])
+                        if meta_p.get("im_sent"):
+                            feishu_sent = True
+                            # 从 send_message 返回值提取 P2P chat_id(用于后续轮询)
+                            p2p_chat = meta_p.get("im_chat_id")
+                            if p2p_chat and p2p_chat not in poll_chat_ids:
+                                poll_chat_ids.append(p2p_chat)
+                                logger.info("提取到 P2P chat_id: %s(用于轮询回复)", p2p_chat)
+                    else:
+                        # 回退:发 preamble + 文件附件
+                        logger.warning("飞书在线表格导入失败(个人),回退到文件附件 + 文本")
+                        try:
+                            res_txt = _feishu.send_message(to=FEISHU_OPERATOR_OPEN_ID, text=message)
+                            if hasattr(res_txt, 'chat_id') and res_txt.chat_id and res_txt.chat_id not in poll_chat_ids:
+                                poll_chat_ids.append(res_txt.chat_id)
+                        except Exception:
+                            pass
+                        file_result_personal = _feishu.send_file(
+                            to=FEISHU_OPERATOR_OPEN_ID,
+                            file=str(xlsx_path),
+                            file_name=f"广告审批_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
                         )
                         )
-                        if personal_import_result.metadata and personal_import_result.metadata.get("url"):
-                            logger.info("飞书审批表格发送成功(个人): %s", personal_import_result.metadata["url"])
-                        else:
-                            # 回退:发送文件附件(个人)
-                            file_result_personal = _feishu.send_file(
-                                to=FEISHU_OPERATOR_OPEN_ID,
-                                file=str(xlsx_path),
-                                file_name=f"广告审批_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
-                            )
-                            logger.info("飞书决策 Excel(文件)发送成功(个人): message_id=%s", file_result_personal.message_id)
-                    except Exception as e:
-                        logger.warning("发送表格到个人失败: %s", e)
-
-            except Exception as e:
-                logger.warning("飞书在线表格导入失败(不影响审批流程): %s", e)
+                        logger.info("飞书决策 Excel(文件)发送成功(个人): message_id=%s", file_result_personal.message_id)
+                        feishu_sent = True
+                except Exception as e:
+                    logger.warning("发送合并消息到个人失败: %s", e)
         except Exception as e:
         except Exception as e:
             logger.warning("飞书发消息失败: %s", e)
             logger.warning("飞书发消息失败: %s", e)