Kaynağa Gözat

feat(auto_put_ad_mini): 知识层扩建+审批消息瘦身+交互式审批深化

Block A (知识层):
- 新增 skills/tencent_ad_playbook.md,注入腾讯 Marketing API v3.0 平台级硬规则
  (oCPM 学习期、≤30% 调价上限、少广告多素材、15 天归因、T+1 数据权威)
- config.py 补全 skills 白名单,修复 ad-domain 此前从未注入的问题
- guardrail_rules.md 阈值与代码对齐(冷启动天数、数据新鲜度、出价上下限)

Block B (审批消息瘦身):
- _format_approval_message 重写: 11.5KB → 1.9KB (-83%)
- 删除 Tier 0 逐条列出、Tier 1 自动执行误导段
- 新增影响金额展示 + Top 5 高置信/高消耗决策 (加权排序)
- 群聊与个人 IM 对称发送完整消息

Block C (交互式审批深化):
- execution_engine 新增硬开关: TIER1_MAX_CHANGE_PCT ≤ 0 时强制所有 Tier1 入审批
- prompts/system.prompt 第八部分重写: 四步协商 (识别增量 → 重走决策链 → diff 呈现 → 2 轮未达成主动暂停)
- 取消"自动通过"语义,统一为"30 分钟无回复 = 默认拒绝"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
刘立冬 3 hafta önce
ebeveyn
işleme
1e79fb2f24

+ 3 - 2
examples/auto_put_ad_mini/config.py

@@ -45,7 +45,7 @@ MAIN_CONFIG = RunConfig(
         # 飞书文档(报告导入 & 分享):
         # 飞书文档(报告导入 & 分享):
         "import_to_feishu",
         "import_to_feishu",
     ],
     ],
-    skills=["roi-strategy", "guardrail-rules"],
+    skills=["ad-domain", "roi-strategy", "guardrail-rules", "tencent-ad-playbook"],
     # extra_llm_params={"extra_body": {"enable_thinking": True}},  # 禁用:千问不支持 thinking
     # extra_llm_params={"extra_body": {"enable_thinking": True}},  # 禁用:千问不支持 thinking
     knowledge=KnowledgeConfig(
     knowledge=KnowledgeConfig(
         enable_extraction=False,
         enable_extraction=False,
@@ -143,7 +143,8 @@ FEISHU_OPERATOR_OPEN_ID = os.getenv("FEISHU_OPERATOR_OPEN_ID", "ou_498988d823b61
 FEISHU_OPERATOR_CHAT_ID = os.getenv("FEISHU_OPERATOR_CHAT_ID", "oc_88e0a1970a7de02eb5ac225a8b0cedea")
 FEISHU_OPERATOR_CHAT_ID = os.getenv("FEISHU_OPERATOR_CHAT_ID", "oc_88e0a1970a7de02eb5ac225a8b0cedea")
 
 
 # 投放项目群聊 — 用于接收决策结果通知和审批回复
 # 投放项目群聊 — 用于接收决策结果通知和审批回复
-FEISHU_AD_PROJECT_CHAT_ID = os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "oc_7940ec97cde40b245cff9cb606ff1ac7")
+# 置空则不发送到群,仅发送到个人
+FEISHU_AD_PROJECT_CHAT_ID = os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "")
 
 
 # 腾讯广告默认账户(测试账户)
 # 腾讯广告默认账户(测试账户)
 TENCENT_AD_ACCOUNT_ID = int(os.getenv("TENCENT_AD_ACCOUNT_ID", "80769799"))
 TENCENT_AD_ACCOUNT_ID = int(os.getenv("TENCENT_AD_ACCOUNT_ID", "80769799"))

+ 64 - 16
examples/auto_put_ad_mini/prompts/system.prompt

@@ -142,9 +142,9 @@ Step 2: merge_creative_data       ← 合并创意数据+广告状态
 Step 3: calculate_roi_metrics     ← 计算ROI(依赖Step 1+2的数据)
 Step 3: calculate_roi_metrics     ← 计算ROI(依赖Step 1+2的数据)
-Step 4: get_ads_for_review        ← ABC三级分类
+Step 4: get_ads_for_review        ← 三级分类(零消耗待关停 / 待评估 / 正常运行)
-Step 5: AI推理决策                 ← 对B类广告推理
+Step 5: AI推理决策                 ← 对【待评估(候选)】广告推理
 Step 6: apply_decisions           ← 保存决策
 Step 6: apply_decisions           ← 保存决策
@@ -494,24 +494,72 @@ Step 10: generate_report          ← 生成报告
 
 
 # 第八部分:与运营交互
 # 第八部分:与运营交互
 
 
-## 审批响应(自然语言理解)
+## 审批响应 = 多轮协商,不是单轮过滤
 
 
-运营会用自然语言回复,你需理解语义:
-- "批准" / "通过" / "可以" / "没问题" → 全部批准
-- "拒绝" / "不行" / "取消" → 停止执行,确认原因
-- "广告XXX不要暂停" → modify_decisions → 重新审批
-- "只批准降价的" / "暂停的不要" → 部分批准,过滤执行
-- 运营提问 → 耐心解释决策依据,等待最终确认
+**核心心智转变**:运营的每一次回复都是**新的约束**,不是对旧决策做局部补丁。你不能只改动作名、过滤几条,就把决策推回去。你必须**基于新约束重新走决策链**,像专家被同行质疑后重新推理一样。
 
 
-## 策略调整(阈值调整)
+### 每次收到运营反馈,按以下顺序深度思考
 
 
-运营说"太保守" / "关停太多" / "太激进"时:
+**Step 1:识别反馈的「信息增量」类型**
 
 
-1. **确认意图**:"您是希望放宽关停阈值吗?当前ROI_LOW_FACTOR=0.5"
-2. **建议调整**:"建议将ROI_LOW_FACTOR从0.5放宽到0.3,预计关停数量减少约40%"
-3. **等待确认**:"请确认是否调整"
-4. **持久化调整**:调用`update_strategy_params`工具(如可用)
-5. **重新推理**:使用新阈值重新分析
+| 类型 | 特征 | 运营掌握的增量信息 |
+|---|---|---|
+| **事实型** | "广告 12345 不要暂停" / "23456 保留观察" | 运营知道一个 Agent 不知道的事实(例如这条在跑白名单策略/正在灰度测试) |
+| **方向型** | "整体太激进/太保守" / "关停太多" | 运营对整个批次的风险偏好想调整 |
+| **质疑型** | "为什么 pause 这条?" / "这个降幅依据是什么?" | 运营不接受当前 reason,要更多依据 |
+| **策略型** | "降幅改小一点" / "所有提价都再激进些" | 运营要调整参数边界 |
+| **混合型** | "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`,而不是只裁剪已有百分比。
+- **混合型** → 拆成独立子问题,分别按上述四类处理,然后合并生成新决策。
+
+**Step 3:重新审批前,显式呈现协商过程**
+
+每次 `modify_decisions → validate_decisions → send_approval_request` 重审时,在你给运营的回复里必须包含:
+
+```
+本轮采纳的反馈:
+  - 运营指出"广告 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 天成熟期,非学习期保护范围;无灰度标记。
+```
+
+这个 diff 表的作用:让运营看到**你真的在思考**,而不是机械过滤。运营能对 diff 表继续反馈。
+
+**Step 4:连续 2 轮仍未达成一致 → 主动提议暂停**
+
+如果同一批决策经过 2 轮协商仍有分歧,**不要无限反刍**。主动说:
+
+> "我们在 [ad_id=45678] 上分歧持续 2 轮了。建议本轮暂停审批,回头我去拉一下该广告的 3 天逐小时消耗曲线和近 30 天调价历史,我们基于更完整的数据再评估。是否暂停本轮?"
+
+主动呈现"我需要什么数据",让运营可以选择"提供数据继续"或"就这样结束本轮"。
+
+### 工具链映射(复用既有工具,不新增)
+
+- `modify_decisions(modifications=[...])`:应用事实型/策略型的具体改动
+- `validate_decisions()`:新决策走一遍护栏,再次检查冷启动/频率/边界
+- `send_approval_request(wait_for_reply=True)`:重新发审批,阻塞等运营下一轮回复
+- `query_ad_detail(ad_id)`:质疑型反馈时回取单条详情
+
+### 关键禁令
+
+- ❌ 不要只改一个 action 字段就交差,不回顾推理
+- ❌ 不要在没看 `query_ad_detail` 详情时就回答质疑型问题
+- ❌ 不要假设"30 分钟无回复 = 默认通过"——当前系统明确设计为"30 分钟无回复 = 默认拒绝",超时等于所有决策作废
+- ❌ 不要未经运营同意就自行调大 `BID_DOWN_MAX_PCT` 等阈值;策略型反馈的参数改动也要在下一轮审批中**显式告知**
 
 
 # 第九部分:边界约束(安全红线)
 # 第九部分:边界约束(安全红线)
 
 

+ 7 - 7
examples/auto_put_ad_mini/skills/guardrail_rules.md

@@ -11,16 +11,16 @@ category: ad_safety
 ## 6 道护栏
 ## 6 道护栏
 
 
 ### 1. 冷启动保护
 ### 1. 冷启动保护
-- **0-4天**:所有 pause 和 bid_down 都会被 **Block**
-- **4-7天**:pause 被 Block,bid_down 最大降幅限制为 5%
-- **建议**:对 ad_age_days < 4 的广告直接输出 hold
+- **≤3 天(冷启动期)**:所有 pause 和 bid_down 都会被 **Block**
+- **4-7 (早期成长期)**:pause 被 Block,bid_down 最大降幅限制为 5%
+- **建议**:对 ad_age_days ≤ 3 的广告直接输出 hold / observe
 
 
 ### 2. 数据新鲜度
 ### 2. 数据新鲜度
-- 数据超过 **26小时** 未更新 → 所有非 hold 操作被 Block
+- 数据超过 **96 小时** 未更新 → 所有非 hold 操作被 Block
 - 如果你看到数据日期较旧,主动标注"数据可能过期"
 - 如果你看到数据日期较旧,主动标注"数据可能过期"
 
 
 ### 3. 出价边界
 ### 3. 出价边界
-- 出价 **< 0.5元** 或 **> 200元** → 自动钳位到边界(Modified,不 Block)
+- 出价 **< 0.05 元** 或 **> 1.00 元** → 自动钳位到边界(Modified,不 Block)
 - 确保你建议的出价在合理范围内
 - 确保你建议的出价在合理范围内
 
 
 ### 4. 频率限制
 ### 4. 频率限制
@@ -32,7 +32,7 @@ category: ad_safety
 ### 5. 每日操作上限
 ### 5. 每日操作上限
 - 单日最多操作 **50个** 广告
 - 单日最多操作 **50个** 广告
 - 超出后按 ROI 严重度排优先级,低优先级的会被 Block
 - 超出后按 ROI 严重度排优先级,低优先级的会被 Block
-- **建议**:当 B 类广告很多时,优先处理 ROI 最低的和消耗最高的
+- **建议**:当【待评估(候选)】广告很多时,优先处理 ROI 最低的和消耗最高的
 
 
 ### 6. 干运行模式
 ### 6. 干运行模式
 - `DRY_RUN_MODE=True` 时,所有操作标记为 dry_run(Modified),不实际执行
 - `DRY_RUN_MODE=True` 时,所有操作标记为 dry_run(Modified),不实际执行
@@ -43,4 +43,4 @@ category: ad_safety
 - 对冷启动期广告(< 4天),直接 hold 并在 reason 中说明
 - 对冷启动期广告(< 4天),直接 hold 并在 reason 中说明
 - 出价调整建议保持在 3%~10% 范围
 - 出价调整建议保持在 3%~10% 范围
 - 优先处理高消耗广告(数据更可信,操作影响更大)
 - 优先处理高消耗广告(数据更可信,操作影响更大)
-- 如果 B 类广告超过 50 个,按 ROI 严重度降序处理
+- 如果【待评估(候选)】广告超过 50 个,按 ROI 严重度降序处理

+ 130 - 0
examples/auto_put_ad_mini/skills/tencent_ad_playbook.md

@@ -0,0 +1,130 @@
+---
+name: tencent-ad-playbook
+description: 腾讯广告 Marketing API v3.0 官方投放 Playbook(平台级硬规则 + 实战经验)
+category: ad_optimization
+---
+
+## 这份 Skill 的定位
+
+这份知识**不是**业务阈值(ROI/出价步长在 roi-strategy),**不是**业务基础概念(f_7日动态ROI 在 ad-domain),**不是**系统护栏(冷启动/频率在 guardrail-rules)。
+
+这里是**腾讯广告 3.0 平台本身的投放规则**:哪些操作会被平台惩罚?哪些节奏是 oCPM 学习机制硬要求的?什么情况下数据本身不可信?
+
+**当你做决策前,以下规则优先于任何 ROI 信号。** 违反平台规则的决策即使"数值上合理"也会掉量。
+
+---
+
+## 1. oCPM 学习期硬规则
+
+本项目所有广告固定 `bid_mode = BID_MODE_OCPM`。oCPM 的"前 5 个转化"阶段叫**学习期**,系统在此期间探索人群、优化投放。
+
+**硬约束:**
+- **前 5 个转化之内不要调整任何参数**(出价、定向、创意、预算),否则学习期清零、重新探索,跑量会断崖。
+- **24 小时内同一广告调整次数 ≤ 2 次**,超过会触发"频繁调整"惩罚。
+- **日预算 ≥ 出价 × 20**,否则预算撑不起 oCPM 系统需要的探索量,学习期拉长甚至失败。
+- 学习期失败(7 天仍未出满 5 个转化)的广告基本可以关停,回归无意义。
+
+**决策含义:**
+- 看到 `ad_age_days <= 3` 或 `conversions_count_7d < 5` 的广告,默认输出 `observe`,不做任何调整建议。
+- 即使 ROI 指标看起来低,也先让系统把学习期跑完,再下结论。
+
+---
+
+## 2. 调价幅度上限(平台级)
+
+**单次降价幅度严格 ≤ 30%。**
+
+- 超过 30% 会被平台判定为"剧烈波动",系统重新进入探索期,量会直接掉一半以上,2~3 天才缓过来。
+- 提价没有这么严的平台惩罚,但建议单次 ≤ 20%(业务层 `BID_UP_MAX_PCT=0.10` 更保守)。
+- 连续多次调价会叠加累计幅度:24 小时内累计调幅 > 30% 同样触发惩罚。
+
+**决策含义:**
+- 如果业务层算出来的 `recommended_change_pct` 下降幅度超过 30%,**主动压到 ≤ 30%**,不要硬执行。
+- 如果判断需要大幅降价(例如 ROI 严重偏低),**首选关停而非大幅降价** —— 关停是干净的止损,大幅降价既掉量又继续烧钱。
+
+---
+
+## 3. 少广告多素材原则
+
+3.0 的核心策略变化:**同一广告下挂多个动态创意,系统自动优选**。这不是营销口号,是数据归因逻辑:多个创意的表现数据会归集到同一个"广告"的学习模型里,缩短冷启动、提高 oCPM 优化效率。
+
+**决策含义:**
+- **单广告动态创意数量 < 5 → 一律 `observe`**。此时数据样本不足,任何调价都是对噪声的过拟合,系统优选尚未完成。
+- 创意数 ≥ 5 再参与调价决策。
+- 如果看到某人群包/计划下广告数量激增但每条都只有 1~2 个创意,reason 里提醒:"基建思路偏旧版,建议合并广告、补创意后再评估"。
+
+---
+
+## 4. 素材疲劳与衰退识别
+
+3.0 系统会自动优选素材,但**无法反复救活同一套已疲劳的素材**。
+
+**判断标准:**
+- CTR 与 CVR **同时**下跌 > 25%(对比前 7 日)→ 素材疲劳。
+- 单独 CTR 跌但 CVR 稳定 → 可能是流量池扩张(曝光质量变化),不是素材问题。
+- 单独 CVR 跌但 CTR 稳定 → 落地页/转化路径问题,不是素材问题。
+
+**决策含义:**
+- 素材疲劳时,**首选 `creative_adjust`(建议人工换素材),而非 pause**。pause 会丢掉这条广告累积的 oCPM 学习资产。
+- 如果人工短期内不可能换素材(例如周末/节假日),才考虑 pause 止损。
+- reason 里必须写清楚:"CTR + CVR 同时下滑 X%,判定素材疲劳,建议换素材而非关停"。
+
+---
+
+## 5. 小程序场景优先级(本业务固定)
+
+本项目场景:微信小程序(`MARKETING_CARRIER_TYPE_MINI_PROGRAM_WECHAT`)+ 用户增长。
+
+**固定策略:**
+- `optimization_goal` 优先 `OPTIMIZATIONGOAL_PAGE_VIEW`(页面浏览),不是点击。PAGE_VIEW 更接近真实转化路径,oCPM 学的是"有效访问"而不是"广告点击"。
+- **归因窗口 15 天**。这意味着:一次曝光/点击带来的转化可能最晚 15 天后才回流到报表。
+- **投放天数 < 1~3 天的广告,ROI 必然被低估**。因为收入侧还没开始回流,成本侧已经跑完。对这类广告输出 `observe`,不要根据短期 ROI 下结论。
+- 投放天数 ≥ 7 天的广告 ROI 才基本稳定(对齐项目侧 `min_periods=7`)。
+
+**决策含义:**
+- 看 `ad_age_days` 时永远配合归因窗口解读:1~3 天的低 ROI 是**系统性低估**,不是真实信号。
+- 如果 reason 里引用了 ROI,必须同时提一句数据成熟度,例如:"广告投放 2 天,考虑到 15 天归因窗口,当前 ROI 1.3 可能被低估 30%+,建议观察到 7 日再评估"。
+
+---
+
+## 6. 数据口径(别让实时数据骗你)
+
+腾讯广告报表有两个口径,用错会导致决策全错:
+
+| 口径 | 字段来源 | 延迟 | 用途 |
+|---|---|---|---|
+| 实时 | `realtime_cost`、分时报表 | 15~30 分钟 | **只用于监控异常暴涨/高燃烧预警**,不用于 ROI 计算 |
+| T+1 权威 | `daily_reports`(日报) | 次日凌晨 | **唯一可用于 ROI 计算和调价决策的口径** |
+
+**硬规则:**
+- **ROI 计算、`f_7日动态ROI`、调价决策 → 必须用 T+1 `daily_reports`**。
+- 实时数据只回答"现在是否在烧钱",不回答"投放效率如何"。
+- 转化数据延迟 1~2 小时,归因还要 15 天,任何"今日 ROI"都是假数据。
+
+**决策含义:**
+- 如果工具返回的是"今日进行中"数据,直接 `observe`,要求使用 T+1 数据重新评估。
+- reason 里禁止引用"今日 ROI" —— 要么用昨日,要么用 7 日。
+
+---
+
+## 7. 执行层硬约束(写操作规范)
+
+所有对腾讯广告 API 的写操作(更新出价、暂停广告)必须遵守以下平台限制。**这些不是建议,违反会直接被 API 拒绝或账户被降权。**
+
+- **批量操作单次 ≤ 50 条**:一次 `adgroups/update` 请求里最多 50 个广告。超过要分批。
+- **单账户 QPS ≤ 8**(平台上限 10,留 2 个 buffer 给异常重试)。
+- **`FREQUENCY_LIMIT` 错误必须指数退避重试**:遇到 code 11017 / 429,按 2^n 秒退避(1s, 2s, 4s, 8s),最多 3 次。
+- **所有写操作必须带 `operation_id`**(幂等键),用同一个 uuid 重试,避免重复执行。
+- **写操作失败 ≠ 广告未变化**:API 超时但实际已执行是常见情况,失败后先 `get_ad_state` 确认状态再决定是否重试。
+
+**决策含义:**
+- 决策输出阶段(LLM 角色)不直接碰 API,这段约束是给执行层(`execution_engine.py`)看的。
+- 但 LLM 应该知道:**一次输出超过 50 个调整决策,意味着执行层要分批**,这是正常的;不要因为看到"50 条上限"就人为压缩决策数量。
+
+---
+
+## 使用这份 Skill 的姿势
+
+1. **先于 ROI 判断**:看到任何广告数据,先按本 Playbook 过一遍(学习期 / 创意数 / 数据成熟度 / 口径),过关了才去看 ROI 阈值。
+2. **reason 中显式引用**:如果决策是基于本 Playbook 的规则(例如"创意数 3 < 5 不做调价"),reason 里必须把规则出处说清楚,让运营能追溯。
+3. **规则冲突时,Playbook > ROI 信号**:例如 ROI 提示降价,但广告处于学习期 → 按 Playbook 输出 observe。Playbook 是平台硬约束,违反它会把 ROI 优势也一起丢掉。

+ 14 - 1
examples/auto_put_ad_mini/tools/execution_engine.py

@@ -308,10 +308,23 @@ async def execute_decisions(
         df_tier1 = df_exec[df_exec["tier"] == 1]
         df_tier1 = df_exec[df_exec["tier"] == 1]
         df_tier2_3 = df_exec[df_exec["tier"] >= 2]
         df_tier2_3 = df_exec[df_exec["tier"] >= 2]
 
 
+        # ⚠️ 硬开关:TIER1_MAX_CHANGE_PCT <= 0 → 完全禁用自动执行通道
+        # 即使分类器误将某条归为 Tier 1,也强制转入审批通道,避免未审批就执行
+        if TIER1_MAX_CHANGE_PCT <= 0 and not df_tier1.empty:
+            logger.warning(
+                "TIER1_MAX_CHANGE_PCT=%s ≤ 0,自动执行通道已禁用;强制将 %d 条 Tier 1 决策转入审批",
+                TIER1_MAX_CHANGE_PCT, len(df_tier1),
+            )
+            df_tier1_forced = df_tier1.copy()
+            df_tier1_forced["tier"] = 2  # 提升到 Tier 2 走审批
+            df_tier2_3 = pd.concat([df_tier2_3, df_tier1_forced], ignore_index=True)
+            df_tier1 = df_tier1.iloc[0:0]  # 清空 Tier 1
+
         for t in [1, 2, 3]:
         for t in [1, 2, 3]:
-            tier_summary[t] = int((df_exec["tier"] == t).sum())
+            tier_summary[t] = int((df_exec["tier"] == t).sum() if not df_exec.empty else 0)
 
 
         # ═══ Phase 1: 自动执行 Tier 1 ═══
         # ═══ Phase 1: 自动执行 Tier 1 ═══
+        # 注意:当 TIER1_MAX_CHANGE_PCT <= 0 时 df_tier1 已被清空,此循环不会执行
         for _, row in df_tier1.iterrows():
         for _, row in df_tier1.iterrows():
             action = row.get("final_action", row.get("action"))
             action = row.get("final_action", row.get("action"))
             ad_id = int(row["ad_id"])
             ad_id = int(row["ad_id"])

+ 159 - 119
examples/auto_put_ad_mini/tools/im_approval.py

@@ -201,117 +201,158 @@ def _format_project_notification_message(df_tier2: pd.DataFrame, df_tier1: pd.Da
     return "\n".join(lines)
     return "\n".join(lines)
 
 
 
 
+def _score_top_decisions(df_tier2: pd.DataFrame, top_n: int = 5) -> pd.DataFrame:
+    """对 Tier 2/3 决策按"影响力"评分,选出 top N 展示给运营。
+
+    评分公式:
+      score = normalize(cost_7d_avg) * 0.6
+            + action_weight * 0.3
+            + confidence_weight * 0.1
+
+    权重理由:
+      - 消耗权重最高(0.6):决策影响的金额越大越该优先看
+      - 动作权重其次(0.3):pause/bid_down 是风险型操作,优先展示
+      - 置信度权重最低(0.1):仅作为 tiebreaker
+    """
+    if df_tier2.empty:
+        return df_tier2
+
+    df = df_tier2.copy()
+
+    # 消耗归一化(0-1)
+    cost = pd.to_numeric(df.get("cost_7d_avg", 0), errors="coerce").fillna(0)
+    cost_max = cost.max()
+    cost_norm = cost / cost_max if cost_max > 0 else cost * 0
+
+    # 动作权重:pause/bid_down = 1.0(风险型),bid_up/scale_up = 0.7(机会型),其他 = 0.5
+    def _action_weight(a: str) -> float:
+        a = str(a).strip()
+        if a in ("pause", "bid_down"):
+            return 1.0
+        if a in ("bid_up", "scale_up"):
+            return 0.7
+        return 0.5
+
+    action_col = df.get("final_action", df.get("action", ""))
+    action_weight = action_col.apply(_action_weight)
+
+    # 置信度权重
+    def _conf_weight(c: str) -> float:
+        c = str(c).strip().lower()
+        if c == "high":
+            return 1.0
+        if c == "medium":
+            return 0.6
+        return 0.3
+
+    conf_weight = df.get("confidence", "medium").apply(_conf_weight) if "confidence" in df.columns else 0.6
+
+    df["_top_score"] = cost_norm * 0.6 + action_weight * 0.3 + conf_weight * 0.1
+    df = df.sort_values("_top_score", ascending=False)
+
+    # 如果总量 <= top_n,全部返回
+    if len(df) <= top_n:
+        return df.drop(columns=["_top_score"])
+
+    return df.head(top_n).drop(columns=["_top_score"])
+
+
 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:
+    """格式化审批消息(瘦身版,目标:单屏可读,≤ 2 KB)。
+
+    设计原则:
+      - 删除 Tier 0 逐条列表(由飞书表格兜底)
+      - 删除 Tier 1 "自动执行" 段落(TIER1_MAX_CHANGE_PCT=0.00 已禁用该通路)
+      - 用 Top 5 高置信/高消耗决策替代"前 5 个示例"的无排序列表
+      - 展示"影响金额"让运营感知决策规模
+      - 明确告知"30 分钟无回复 = 默认拒绝",无隐式自动通过
+    """
+    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
+
     lines = [
     lines = [
-        "📊 广告调控审批请求",
-        f"请求ID: {request_id}",
-        f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
+        f"📊 广告调控审批请求  {request_id}",
+        "─" * 40,
+        f"📌 决策总数: {total}  (需全部人工审批,无自动执行)",
+        f"   ⏸ 暂停 {n_pause} / ⬇ 降价 {n_down} / ⬆ 提价 {n_up} / 🚀 扩量 {n_scale} / 👀 观察 {n_observe}",
+        "",
+        "💰 影响金额",
+        f"   受影响广告 7 日均消耗合计: {cost_7d:,.0f} 元",
+        f"   昨日总消耗: {yesterday_cost:,.0f} 元",
         "",
         "",
     ]
     ]
 
 
-    # Tier 2/3: 需审批
-    if not df_tier2.empty:
-        total_count = len(df_tier2)
-        lines.append(f"🔶 需审批操作({total_count} 个):")
-        lines.append("-" * 40)
+    # Top N 高置信/高消耗决策
+    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)
 
 
-        # 统计各操作类型
-        action_counts = df_tier2.get("final_action", df_tier2.get("action", "")).value_counts().to_dict()
-        for action, count in action_counts.items():
+        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":
             if action == "pause":
-                lines.append(f"  ⏸️  暂停: {count} 个")
+                action_label = "⏸ 暂停"
             elif action == "bid_down":
             elif action == "bid_down":
-                lines.append(f"  ⬇️  降价: {count} 个")
+                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":
             elif action == "bid_up":
-                lines.append(f"  ⬆️  提价: {count} 个")
+                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:
             else:
-                lines.append(f"  {action}: {count} 个")
-        lines.append("")
+                action_label = action
 
 
-        # 如果数量过多(>20),只显示摘要统计,不逐条列出
-        if total_count > 20:
-            lines.append(f"⚠️ 广告数量较多({total_count} 个),详情请查看在线表格")
-            # 只展示前 5 个示例
-            lines.append("")
-            lines.append("前 5 个示例:")
-            for i, (_, row) in enumerate(df_tier2.head(5).iterrows()):
-                ad_id = row.get("ad_id", "")
-                action = row.get("final_action", row.get("action", ""))
-                ad_name = str(row.get("ad_name", ""))[:20]
-                lines.append(f"  [{ad_id}] {ad_name} → {action}")
-            lines.append("  ...")
-        else:
-            # 数量较少,逐条列出
-            for _, row in df_tier2.iterrows():
-                ad_id = row.get("ad_id", "")
-                action = row.get("final_action", row.get("action", ""))
-                ad_name = str(row.get("ad_name", ""))[:20]
-                reason = str(row.get("reason", ""))[:60]
-                cost_avg = row.get("cost_7d_avg", 0)
-
-                if action == "pause":
-                    action_label = "⏸️ 暂停"
-                elif action == "bid_down":
-                    pct = row.get("recommended_change_pct", 0)
-                    if isinstance(pct, str):
-                        try:
-                            pct = float(pct)
-                        except ValueError:
-                            pct = 0
-                    action_label = f"⬇️ 降价{abs(pct)*100:.0f}%"
-                elif action == "bid_up":
-                    pct = row.get("recommended_change_pct", 0)
-                    if isinstance(pct, str):
-                        try:
-                            pct = float(pct)
-                        except ValueError:
-                            pct = 0
-                    action_label = f"⬆️ 提价{pct*100:.0f}%"
-                else:
-                    action_label = action
-
-                lines.append(f"  [{ad_id}] {ad_name}")
-                lines.append(f"    操作: {action_label} | 日均消耗: {cost_avg:.0f}元")
-                lines.append(f"    原因: {reason}")
-                lines.append("")
-
-    # Tier 0: 无需操作(observe/hold/creative_adjust)
-    if not df_tier0.empty:
-        lines.append(f"ℹ️  无需操作({len(df_tier0)} 个,仅通知):")
-        for _, row in df_tier0.iterrows():
-            ad_id = row.get("ad_id", "")
-            action = row.get("final_action", row.get("action", ""))
-            action_label = {
-                "observe": "观察等待",
-                "hold": "保持不变",
-                "creative_adjust": "需人工调整素材",
-                "scale_up": "建议扩量(新增广告/创意)"
-            }.get(action, action)
-            lines.append(f"  [{ad_id}] {action_label}")
-        lines.append("")
+            lines.append(f"   {idx}. [{ad_id}] {ad_name} | {action_label} | {roi_str} | {reason}")
 
 
-    # Tier 1: 小幅调价(自动执行)
-    if not df_tier1.empty:
-        lines.append(f"✅ 自动执行({len(df_tier1)} 个小幅调价):")
-        for _, row in df_tier1.iterrows():
-            ad_id = row.get("ad_id", "")
-            action = row.get("final_action", row.get("action", ""))
-            change_pct = row.get("recommended_change_pct", 0)
-            lines.append(f"  [{ad_id}] {action} {change_pct:+.1%}")
+        if total > 5:
+            lines.append(f"   (完整列表见下方飞书在线表格链接)")
         lines.append("")
         lines.append("")
 
 
-    # 回复指令
+    # 回复方式(多轮协商)
     lines.extend([
     lines.extend([
-        "-" * 40,
-        "📝 直接回复即可,示例:",
-        "  \"批准\" / \"通过\"          — 全部批准",
-        "  \"拒绝\" / \"不行\"          — 全部拒绝",
-        "  \"广告 12345 不要暂停\"     — 修改指定广告",
-        "  \"只批准降价的\"            — 部分批准",
-        "  \"降幅改小一点\"            — 调整后重新审批",
-        f"  ⏰ 超时时间: {IM_APPROVAL_TIMEOUT_MINUTES} 分钟",
+        "📝 回复方式(支持多轮协商)",
+        "   \"通过\"                — 全部批准",
+        "   \"拒绝\"                — 全部取消",
+        "   \"广告 12345 不动\"      — 保留这条,其余按建议",
+        "   \"整体太激进/保守\"      — 要求重新评估",
+        "   \"只批准 pause\"        — 按 action 类型过滤",
+        f"   ⏰ {IM_APPROVAL_TIMEOUT_MINUTES} 分钟无回复 = 默认拒绝",
         "",
         "",
-        "📎 决策详情请查看在线表格(自动发送链接)",
+        "📎 详单: 飞书在线表格链接(消息 2 单独发送)",
     ])
     ])
 
 
     return "\n".join(lines)
     return "\n".join(lines)
@@ -501,14 +542,14 @@ async def send_approval_request(
                 except Exception as e:
                 except Exception as e:
                     logger.warning("发送到个人失败: %s", e)
                     logger.warning("发送到个人失败: %s", e)
 
 
-            # 消息 1b:发送到投放项目群聊(如果配置了)
+            # 消息 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)
                     result_project = _feishu.send_message(to=FEISHU_AD_PROJECT_CHAT_ID, text=message)
                     feishu_sent_to_project_chat = True
                     feishu_sent_to_project_chat = True
                     feishu_sent = True
                     feishu_sent = True
                     logger.info("飞书审批消息发送成功(项目群): message_id=%s", result_project.message_id)
                     logger.info("飞书审批消息发送成功(项目群): message_id=%s", result_project.message_id)
-                    # 群聊 chat_id 本身就是 oc_xxx 格式,可直接用于轮询
+                    # 群聊 chat_id 本身就是 oc_xxx 格式,可直接用于轮询回复
                     if FEISHU_AD_PROJECT_CHAT_ID not in poll_chat_ids:
                     if FEISHU_AD_PROJECT_CHAT_ID not in poll_chat_ids:
                         poll_chat_ids.append(FEISHU_AD_PROJECT_CHAT_ID)
                         poll_chat_ids.append(FEISHU_AD_PROJECT_CHAT_ID)
                 except Exception as e:
                 except Exception as e:
@@ -518,30 +559,29 @@ async def send_approval_request(
             try:
             try:
                 xlsx_path = _generate_approval_xlsx(df_for_review, request_id)
                 xlsx_path = _generate_approval_xlsx(df_for_review, request_id)
 
 
-                # 导入飞书在线表格并发送链接(项目群)— 临时禁用
                 from feishu_doc import import_to_feishu
                 from feishu_doc import import_to_feishu
 
 
-                # 发送到项目群 — 临时禁用
-                # if 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
-                #     )
-                #
-                #     if import_result.metadata and import_result.metadata.get("url"):
-                #         sheet_url = import_result.metadata["url"]
-                #         logger.info("飞书审批表格导入成功(项目群): %s", sheet_url)
-                #     else:
-                #         logger.warning("飞书在线表格导入失败(项目群),回退到文件附件模式")
-                #         # 回退:发送文件附件(项目群)
-                #         file_result = _feishu.send_file(
-                #             to=FEISHU_AD_PROJECT_CHAT_ID,
-                #             file=str(xlsx_path),
-                #             file_name=f"审批决策表_{request_id}.xlsx",
-                #         )
-                #         logger.info("飞书审批 Excel(文件)发送成功(项目群): message_id=%s", file_result.message_id)
+                # 发送到项目群(与个人一致)
+                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,
+                        )
+                        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"审批决策表_{request_id}.xlsx",
+                            )
+                            logger.info("飞书审批 Excel(文件)发送成功(项目群): message_id=%s", file_result.message_id)
+                    except Exception as e:
+                        logger.warning("发送表格到项目群失败: %s", e)
 
 
                 # 发送到个人
                 # 发送到个人
                 if FEISHU_OPERATOR_OPEN_ID:
                 if FEISHU_OPERATOR_OPEN_ID: