فهرست منبع

feat(auto_put_ad_mini): 实现人群包同类对比决策与飞书审批优化

核心改动:

1. 人群包同类对比决策引擎
   - ad_decision.py: 新增 portfolio_summary 数据加载
   - ad_decision.py: 计算人群包级别动态阈值(提价/降价/关停线)
   - roi_strategy.md: 新增人群包同类对比决策原则和案例

2. 飞书审批流程优化
   - im_approval.py: 支持双通道发送(个人私聊+项目群聊)
   - im_approval.py: 支持任意用户审批(移除特定用户限制)
   - feishu_doc.py: 权限升级为 anyone_editable(最大权限)

3. 决策策略增强
   - system.prompt: 新增人群包对比维度决策指导
   - roi_calculator.py: ROI 计算优化
   - config.py: 调整冷启动保护期为7天

架构优化:
- 人群包同类对比:不同人群包使用各自的中位数作为对比基准
- 避免误判:R500 和 R50 广告各自与同类对比,而非全局均值

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
刘立冬 3 هفته پیش
والد
کامیت
59a3c72575

+ 6 - 0
examples/auto_put_ad_mini/.env.example

@@ -13,9 +13,15 @@ TENCENT_AD_ACCOUNT_ID=80769799
 # ========================================
 # ========================================
 FEISHU_APP_ID=cli_a955e97067f85cb3
 FEISHU_APP_ID=cli_a955e97067f85cb3
 FEISHU_APP_SECRET=NQaG4ci1plXRDTgwCqrLJgMLLoA2tdF8
 FEISHU_APP_SECRET=NQaG4ci1plXRDTgwCqrLJgMLLoA2tdF8
+
+# 运营审批群聊(需要审批回复的群)
 FEISHU_OPERATOR_OPEN_ID=ou_498988d823b61ab89c9afe4310f85bb4
 FEISHU_OPERATOR_OPEN_ID=ou_498988d823b61ab89c9afe4310f85bb4
 FEISHU_OPERATOR_CHAT_ID=oc_88e0a1970a7de02eb5ac225a8b0cedea
 FEISHU_OPERATOR_CHAT_ID=oc_88e0a1970a7de02eb5ac225a8b0cedea
 
 
+# 投放项目群聊(仅通知,不需审批)— 可选配置
+# 如何获取群聊ID:运行 python3 get_chat_id.py
+# FEISHU_AD_PROJECT_CHAT_ID=oc_xxxxxxxxxxxxxxxxxxxxxx
+
 # ========================================
 # ========================================
 # ODPS 数据平台配置
 # ODPS 数据平台配置
 # ========================================
 # ========================================

+ 11 - 2
examples/auto_put_ad_mini/config.py

@@ -93,8 +93,14 @@ BID_CHANGE_MIN_PCT = 0.03       # 最小调幅 3%
 BID_CHANGE_MAX_PCT = 0.10       # 最大单次调幅 10%
 BID_CHANGE_MAX_PCT = 0.10       # 最大单次调幅 10%
 BID_FLOOR_YUAN = 0.50           # 出价下限(元)
 BID_FLOOR_YUAN = 0.50           # 出价下限(元)
 BID_CEILING_YUAN = 200.00       # 出价上限(元)
 BID_CEILING_YUAN = 200.00       # 出价上限(元)
-COLD_START_DAYS = 4             # 绝对保护期(不做任何负向操作)
-CAUTIOUS_DAYS = 7               # 谨慎期(4-7天仅允许小幅降价 max 5%)
+AD_AGE_NEWBORN = 3              # 新生期(≤3天):极度保护
+COLD_START_DAYS = 7             # 冷启动期(4-7天):谨慎调控,仅允许提价
+CAUTIOUS_DAYS = 7               # 谨慎期(与COLD_START_DAYS相同,保留兼容)
+AD_AGE_MATURE = 7               # 成熟期(>7天):正常调控
+
+# 高燃烧预警配置
+HIGH_BURN_AGE_THRESHOLD = 3     # 广告年龄>3天才检查
+HIGH_BURN_COST_THRESHOLD = 300  # 昨日消耗>300元触发预警
 
 
 # ═══════════════════════════════════════════
 # ═══════════════════════════════════════════
 # 安全护栏配置
 # 安全护栏配置
@@ -132,6 +138,9 @@ FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "NQaG4ci1plXRDTgwCqrLJgMLLoA2
 FEISHU_OPERATOR_OPEN_ID = os.getenv("FEISHU_OPERATOR_OPEN_ID", "ou_498988d823b61ab89c9afe4310f85bb4")
 FEISHU_OPERATOR_OPEN_ID = os.getenv("FEISHU_OPERATOR_OPEN_ID", "ou_498988d823b61ab89c9afe4310f85bb4")
 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", "")  # 设置为空表示不发送
+
 # 腾讯广告默认账户(测试账户)
 # 腾讯广告默认账户(测试账户)
 TENCENT_AD_ACCOUNT_ID = int(os.getenv("TENCENT_AD_ACCOUNT_ID", "80769799"))
 TENCENT_AD_ACCOUNT_ID = int(os.getenv("TENCENT_AD_ACCOUNT_ID", "80769799"))
 
 

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

@@ -267,19 +267,81 @@ generate_report
 "30天内仅3天消耗稳定,数据波动较大,建议观察"
 "30天内仅3天消耗稳定,数据波动较大,建议观察"
 ```
 ```
 
 
-## 4. 广告年龄维度
+## 4. 广告年龄维度(3段式)
 
 
-**分析要求**:
-- **5-10天(新广告)**:给予学习期,ROI略低时倾向观察
-- **10-30天(成长期)**:正常阈值判断
-- **30天+(老广告)**:ROI持续低迷可更果断关停
+广告年龄影响决策的激进程度和允许的操作范围。
+
+**age_segment 判断**:
+- `newborn`(≤3天):极度保护,几乎不干预
+- `cold_start`(4-7天):仅允许提价,不允许降价(allow_bid_down=False)
+- `mature`(>7天):正常调控,可降价可提价(max_bid_down_pct=10%)
+
+**high_burn_alert 判断**:
+- 触发条件:广告年龄>3天 且 昨日消耗>300元
+- 决策影响:即使ROI正常,也需评估消耗是否异常
+
+**决策限制**:
+- 冷启动期(4-7天):**禁止 bid_down 和 pause**,只允许 bid_up/hold/observe/creative_adjust
+- 系统会自动过滤冷启动期的降价和关停决策
 
 
 **理由示例**(自然语言):
 **理由示例**(自然语言):
 ```
 ```
-"广告仅投放7天,仍在学习期,建议观察"
-"广告已投放45天,属于老广告,ROI持续低迷可果断关停"
+"广告仅投放2天,数据不稳定;建议保持观察"
+"广告处于冷启动第5天,ROI为2.3低于同类中位数;但不建议降价以免打断学习,建议观察至第8天"
+"广告已投放10天,ROI稳定在2.1低于同类中位数15%;建议降价7%"
+```
+
+## 5. 人群包同类对比维度(必须优先)
+
+**分析要求**:
+- ✅ 优先使用 tier_roi_p25/p50/p75(同人群包的分位数)
+- ❌ 不要只看 roi_mean(全局均值)
+- ⚠️ 当同类数据不足时,才使用全局均值兜底
+
+**决策影响**:
+- **提价**:ROI > tier_roi_p50 × 1.10(高于同类中位数10%以上)且 裂变率高
+- **保持**:tier_roi_p50 × 0.90 ≤ ROI ≤ tier_roi_p50 × 1.10(±10%范围内)
+- **降价**:tier_roi_p50 × 0.75 ≤ ROI < tier_roi_p50 × 0.90(低于中位数10-25%)且 消耗≥500 且 裂变率低
+- **关停**:ROI < tier_roi_p50 × 0.75(低于同类中位数25%以上)
+
+**对比逻辑示例**:
+```
+广告A(R500,成熟期):f_7日动态ROI = 2.5,7日均消耗 = 650元,裂变率 = 0.45
+R500组统计:p50=2.8, fission_mean=0.62
+
+# 计算阈值
+bid_down_line_min = 2.8 × 0.85 = 2.38(-15%)
+bid_down_line_max = 2.8 × 0.90 = 2.52(-10%)
+fission_down_threshold = 0.62 × 0.85 = 0.527(-15%)
+
+# 判断
+ROI: 2.5 < 2.52 且 2.5 > 2.38 → 低于中位数11%
+消耗: 650 >= 500 ✓
+裂变: 0.45 < 0.527 ✓(低于同类均值15%)
+
+→ action=bid_down, pct=3%
+→ reason="动态ROI为2.5,低于R500组中位数2.8的11%;裂变率0.45低于同类均值0.62的27%;7日均消耗650元,建议降价3%"
 ```
 ```
 
 
+**理由表达要求**:
+- 必须说明对比基准("R500组中位数2.8")
+- 必须说明偏离程度("15%")
+- 必须说明同类位置("中下水平" / "优秀" / "最差25%")
+- 禁止使用技术术语("pause_line" / "bid_down_line")
+
+## 6. ROI数据置信度
+
+根据 roi_valid_days 评估决策可靠性。
+
+**置信度分级**:
+- ≥7天:高置信度,可正常决策
+- 4-6天:中等置信度,谨慎决策
+- 3天:低置信度,保守决策
+
+**理由表达**:
+- 必须说明数据天数:"基于5天数据(置信度中等)..."
+- 数据不足时降低操作幅度:"建议降价3%而非5%"
+
 ## 反例警示(避免模板化)
 ## 反例警示(避免模板化)
 
 
 **❌ 错误示例(模板化,未使用多维度)**:
 **❌ 错误示例(模板化,未使用多维度)**:

+ 240 - 10
examples/auto_put_ad_mini/skills/roi_strategy.md

@@ -3,6 +3,76 @@ name: roi-strategy
 description: 广告投放调控经验知识库 - 从业务目标到决策执行
 description: 广告投放调控经验知识库 - 从业务目标到决策执行
 ---
 ---
 
 
+# 零、人群包同类对比(必须优先使用)
+
+## 核心原则
+
+**❌ 错误做法**:将R500广告与全体均值对比
+**✅ 正确做法**:将R500广告与R500组的p25/p50/p75对比
+
+**为什么必须这样做**:
+不同人群包的ROI分布差异巨大:
+- R500(高价值人群):ROI中位数可能在3.0-5.0
+- R50(低价值人群):ROI中位数可能在0.8-1.5
+
+如果混在一起对比全局均值2.0,会导致:
+- R500的2.5(实际是同类中下水平)被误判为"优秀"
+- R50的1.8(实际是同类优秀)被误判为"偏低"
+
+## 对比基准选择
+
+| 决策类型 | 使用基准 | 阈值范围 | 说明 |
+|---------|---------|---------|------|
+| **提价判断** | tier_roi_p50 | p50 × 1.10 ~ 1.15 | 高于同类中位数10-15%,优质广告 |
+| **降价判断** | tier_roi_p50 | p50 × 0.85 ~ 0.90 | 低于同类中位数10-15%,需优化 |
+| **关停判断** | tier_roi_p50 | p50 × 0.70 ~ 0.75 | 低于同类中位数25-30%,明确低效 |
+
+**兜底机制**:当某人群包数据不足(<5个广告)时,使用全局均值
+
+## 决策逻辑示例
+
+**场景:R500广告,f_7日动态ROI=2.5**
+
+```python
+# 获取R500组的统计数据
+tier_roi_p50 = 2.8  # 中位数
+pause_line_min = 2.8 × 0.70 = 1.96   # 关停线下限(-30%)
+pause_line_max = 2.8 × 0.75 = 2.10   # 关停线上限(-25%)
+bid_down_line_min = 2.8 × 0.85 = 2.38  # 降价线下限(-15%)
+bid_down_line_max = 2.8 × 0.90 = 2.52  # 降价线上限(-10%)
+bid_up_line_min = 2.8 × 1.10 = 3.08    # 提价线下限(+10%)
+bid_up_line_max = 2.8 × 1.15 = 3.22    # 提价线上限(+15%)
+
+# 判断(假设当前广告 ROI=2.5,消耗≥500元,裂变率正常)
+IF 2.5 < 1.96:  # 低于关停线下限(-30%)
+    → action=pause, reason="低于R500组中位数30%,同类最差表现"
+ELIF 2.5 < 2.10:  # 低于关停线上限(-25%)
+    → action=pause, reason="低于R500组中位数27%,建议关停"
+ELIF 2.5 < 2.38:  # 低于降价线下限(-15%)
+    → action=bid_down, pct=5%, reason="低于R500组中位数15%,建议降价5%"
+ELIF 2.5 < 2.52:  # 低于降价线上限(-10%)
+    → action=bid_down, pct=3%, reason="低于R500组中位数11%,建议降价3%"
+ELIF 2.5 > 3.22:  # 高于提价线上限(+15%)
+    → action=bid_up, pct=10%, reason="高于R500组中位数15%,优质广告,建议提价10%"
+ELIF 2.5 > 3.08:  # 高于提价线下限(+10%)
+    → action=bid_up, pct=5%, reason="高于R500组中位数12%,建议提价5%"
+ELSE:
+    → action=hold, reason="ROI处于R500组中等水平,保持当前出价"
+```
+
+## 理由表达要求
+
+**❌ 错误**:"ROI低于阈值1.96"(技术术语)
+**✅ 正确**:"动态ROI为2.5,低于R500组中位数2.8的15%;同类对比显示处于中下水平"
+
+**必须包含**:
+1. 当前ROI值
+2. 对比基准("R500组中位数2.8")
+3. 偏离程度("15%")
+4. 同类位置("中下水平" / "优秀" / "最差25%")
+
+---
+
 # 一、业务目标与核心理念
 # 一、业务目标与核心理念
 
 
 ## 我们的目标
 ## 我们的目标
@@ -28,23 +98,84 @@ description: 广告投放调控经验知识库 - 从业务目标到决策执行
 
 
 ---
 ---
 
 
-# 二、广告生命周期经验
+# 二、广告生命周期经验(3段式)
+
+系统根据广告年龄将广告分为3个生命周期,每个阶段有不同的决策策略。
+
+## 第1段:新生期(≤3天)
+
+**特征**:
+- 系统刚开始学习用户画像
+- 数据量少,ROI波动极大
+- 出价调整可能导致重新学习
+
+**策略**:
+- ✅ **极度保护**,几乎不干预
+- ❌ **不降价**(避免打断学习)
+- ❌ **不关停**(给予充分时间)
+- ⚠️ **唯一例外**:零消耗7日均值<10元 → 自动关停(强规则)
+
+**age_segment**: `newborn`
+**age_protection_level**: `极度保护`
 
 
-## 冷启动期(0-7天)
+## 第2段:冷启动期(4-7天)
 
 
 **特征**:
 **特征**:
-- 系统正在学习受众特征
-- 数据波动极大,ROI忽高忽低
-- 消耗不稳定,可能0元也可能暴涨
+- 系统正在学习用户画像
+- ROI开始收敛,但仍有波动
+- **⚠️ 降价会打断学习,重新起跑**
+
+**策略**:
+- ✅ **允许提价**(如果ROI好,可以加速放量)
+- ❌ **不允许降价**(避免打断系统学习)
+- ❌ **不关停**(给予充分学习时间)
+- ✅ 允许 `observe`(数据不稳定时)
+- ✅ 允许 `creative_adjust`(素材优化不影响出价)
 
 
 **投放经验**:
 **投放经验**:
-- **绝对保护**(0-4天):任何负向操作都可能打断学习
-- **谨慎观察**(4-7天):可小幅降价(≤5%),但不关停
 - **冷启动期ROI低是正常的**:系统在探索,还未找到精准人群
 - **冷启动期ROI低是正常的**:系统在探索,还未找到精准人群
+- **降价的代价**:系统会重新学习,之前的数据积累作废
+- **例外情况**:日消耗>500元 且 ROI<0.5(极端亏损)→ 可以考虑关停
 
 
-**决策原则**:
-- 给予充分学习期,不要因短期ROI低就关停
-- 除非日消耗>500元且ROI<0.5(极端亏损),否则观察
+**age_segment**: `cold_start`
+**age_protection_level**: `仅允许提价`
+**allow_bid_down**: `False`
+**allow_bid_up**: `True`
+
+## 第3段:成熟期(>7天)
+
+**特征**:
+- 数据充分(≥7天),ROI稳定
+- 系统学习完成
+- 可全面调控
+
+**策略**:
+- ✅ **可降价**,最大10%(max_bid_down_pct=0.10)
+- ✅ **可提价**
+- ✅ **可关停**
+- ✅ 所有动作均可使用
+
+**age_segment**: `mature`
+**age_protection_level**: `正常调控`
+**allow_bid_down**: `True`
+**allow_bid_up**: `True`
+
+## 特殊情况:高燃烧预警 🔥
+
+**触发条件**:`high_burn_alert=True`
+- 广告年龄 > 3天
+- 昨日消耗 > 300元
+
+**说明**:
+即使在成熟期,单日消耗过高需要重点关注,可能存在:
+- 定向过宽,流量失控
+- 出价过高,竞争力过强
+- 创意吸引过度点击但转化低
+
+**策略**:
+- 优先评估ROI是否正常
+- 如果ROI正常且高燃烧 → 可能是优质广告,考虑适当降价控制节奏
+- 如果ROI偏低且高燃烧 → 立即降价或关停
 
 
 ## 成长期(7-30天)
 ## 成长期(7-30天)
 
 
@@ -519,4 +650,103 @@ bid_increased_7d=true(7天内已提价)但ROI仍低迷,
 
 
 ---
 ---
 
 
+# 八、ROI数据置信度评估
+
+由于支持"不足7天用几天"的ROI计算,需要根据有效数据天数评估置信度。
+
+## 置信度分级
+
+| roi_valid_days | 置信度 | 决策建议 |
+|---------------|--------|---------|
+| ≥7天 | 高 | 可正常决策 |
+| 4-6天 | 中 | 谨慎决策,避免激进操作 |
+| 3天 | 低 | 仅做保守决策(如明显异常才关停) |
+| <3天 | 无 | ROI=NaN,无法决策 |
+
+## 理由表达
+
+**示例**:
+- 7天数据:"基于7天数据,动态ROI为2.5..."
+- 5天数据:"基于5天数据(置信度中等),动态ROI为2.5;建议降价3%而非5%..."
+- 3天数据:"基于3天数据(置信度较低),动态ROI为2.5;数据不足,建议观察..."
+
+---
+
+# 九、新增决策动作
+
+系统新增两个决策动作,用于处理传统4个动作(pause/bid_down/bid_up/hold)无法覆盖的场景。
+
+## creative_adjust(调整素材方向)
+
+**核心定位**:ROI正常但消耗不足,需要人工优化素材
+
+**适用场景**:
+1. ROI正常(> bid_down_line),但消耗过低(<100元/天)
+2. 7天内已更换创意(creative_changed_7d=true),但效果仍不佳
+3. 裂变率偏低,但ROI尚可
+
+**与其他action的区别**:
+- vs **hold**:hold是认可当前状态,creative_adjust是认为ROI可以但需改进素材
+- vs **bid_up**:bid_up是提价拉量,creative_adjust是优化素材吸引力
+
+**决策逻辑**:
+```python
+IF roi > bid_down_line AND cost_7d_avg < 100:
+    action = "creative_adjust"
+    reason = "ROI处于正常水平,但7日均消耗仅XX元;建议调整素材方向以提升吸引力"
+
+IF creative_changed_7d AND fission_ratio < tier_fission_mean * 0.85:
+    action = "creative_adjust"
+    reason = "7天内已更换创意,但裂变率仍低于同类均值;建议进一步调整素材,突出裂变激励"
+```
+
+**执行方式**:
+- ⚠️ **不调用API**,仅在审批表中标识
+- 由运营人员根据理由人工调整素材
+
+**理由示例**:
+"动态ROI为2.8,高于R500组中位数,但7日均消耗仅65元;裂变率0.45低于同类均值0.62;建议调整素材方向,突出裂变激励"
+
+---
+
+## observe(观察等待)
+
+**核心定位**:数据不稳定或接近阈值边界,需要短期观察后再决策
+
+**适用场景**:
+1. ROI数据天数不足(roi_valid_days < 7),置信度低
+2. ROI接近阈值边界(在bid_down_line ± 5%范围内)
+3. 广告处于冷启动期(4-7天),数据波动大
+4. 消耗稳定天数不足(stable_spend_days < 7)
+
+**与其他action的区别**:
+- vs **hold**:hold是认可当前状态长期保持,observe是数据存疑需短期复查
+- vs **pause**:pause是明确低效,observe是不确定需要更多数据
+
+**决策逻辑**:
+```python
+IF roi_valid_days < 7 AND roi > pause_line:
+    action = "observe"
+    confidence = "low"
+    reason = f"基于{roi_valid_days}天数据,ROI为{roi};数据不足,建议2-3天后重新评估"
+
+IF abs(roi - bid_down_line) / bid_down_line < 0.05:  # ROI在降价线±5%范围内
+    action = "observe"
+    reason = "ROI接近降价阈值边界,建议观察1-2天后再决策"
+
+IF age_segment == "cold_start" AND stable_spend_days < 5:
+    action = "observe"
+    reason = "广告处于冷启动期,消耗波动较大;建议观察至稳定后再调整"
+```
+
+**执行方式**:
+- 在审批表中标识为 "观察等待"
+- 系统2-3天后自动重新评估该广告
+
+**理由示例**:
+- "基于4天数据(置信度中等),动态ROI为2.2;接近降价线2.38,建议观察2天后确认趋势"
+- "广告处于冷启动期第5天,消耗波动从50元至180元;虽ROI为2.5,但稳定性不足,建议观察"
+
+---
+
 **记住**:你是投放专家,不是计算器。要像经验丰富的优化师一样思考,而不是机械地套用公式。
 **记住**:你是投放专家,不是计算器。要像经验丰富的优化师一样思考,而不是机械地套用公式。

+ 95 - 8
examples/auto_put_ad_mini/tools/ad_decision.py

@@ -41,8 +41,12 @@ from config import (
     BID_CHANGE_MAX_PCT,
     BID_CHANGE_MAX_PCT,
     BID_FLOOR_YUAN,
     BID_FLOOR_YUAN,
     BID_CEILING_YUAN,
     BID_CEILING_YUAN,
+    AD_AGE_NEWBORN,
     COLD_START_DAYS,
     COLD_START_DAYS,
     CAUTIOUS_DAYS,
     CAUTIOUS_DAYS,
+    AD_AGE_MATURE,
+    HIGH_BURN_AGE_THRESHOLD,
+    HIGH_BURN_COST_THRESHOLD,
     ROI_LOW_FACTOR,
     ROI_LOW_FACTOR,
 )
 )
 
 
@@ -856,6 +860,28 @@ async def get_ads_for_review(
         if end_date == "yesterday":
         if end_date == "yesterday":
             end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
             end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
 
 
+        # ===== 新增:读取人群包级别统计数据(同类对比基准)=====
+        logger.info("读取人群包级别统计数据...")
+        by_tier_stats = {}
+        try:
+            # 读取 portfolio_summary JSON 文件
+            portfolio_dir = _MINI_DIR / "outputs" / "portfolio_summary"
+            portfolio_file = portfolio_dir / f"portfolio_summary_{end_date}.json"
+
+            if portfolio_file.exists():
+                import json
+                with open(portfolio_file, "r", encoding="utf-8") as f:
+                    portfolio_data = json.load(f)
+                by_tier_stats = portfolio_data.get("by_audience_tier", {})
+                logger.info(f"✅ 从 {portfolio_file.name} 加载了 {len(by_tier_stats)} 个人群包的统计数据")
+            else:
+                logger.warning(f"未找到 portfolio_summary 文件: {portfolio_file},将使用全局均值兜底")
+                # 可以选择在这里调用 calculate_portfolio_summary 生成文件
+                # 但为了简化,我们先用空字典兜底
+        except Exception as e:
+            logger.warning(f"读取人群包统计数据失败,使用空字典兜底: {e}")
+            by_tier_stats = {}
+
         # 计算广告年龄
         # 计算广告年龄
         df["ad_age_days"] = df["create_time"].apply(_calculate_ad_age_days)
         df["ad_age_days"] = df["create_time"].apply(_calculate_ad_age_days)
 
 
@@ -899,7 +925,7 @@ async def get_ads_for_review(
             stable_days = float(row.get("stable_spend_days_30d", 0) or 0)
             stable_days = float(row.get("stable_spend_days_30d", 0) or 0)
             bid_amount = float(row.get("bid_amount", 0) or 0)
             bid_amount = float(row.get("bid_amount", 0) or 0)
 
 
-            # 零消耗待关停:7日均消耗 < 10元,几乎无活动
+            # 零消耗待关停:7日均消耗 < 10元,几乎无活动(强规则,仍保留)
             if cost_7d_avg < min_spend_for_class_a:
             if cost_7d_avg < min_spend_for_class_a:
                 zero_spend_ads.append({
                 zero_spend_ads.append({
                     "ad_id": int(row["ad_id"]),
                     "ad_id": int(row["ad_id"]),
@@ -908,11 +934,9 @@ async def get_ads_for_review(
                 })
                 })
                 continue
                 continue
 
 
-            # 冷启动保护(前置判断):广告年龄 ≤ 4天,直接归类为正常运行(hold)
-            # 不进入LLM推理,节省token,避免护栏拦截
-            if ad_age is not None and ad_age <= COLD_START_DAYS:
-                normal_ads_count += 1
-                continue
+            # ===== 移除冷启动强制保护(改为标记,不排除)=====
+            # 冷启动期广告也进入待评估,但会带上特殊标记
+            # 由 LLM 和护栏层判断是否可操作
 
 
             # 待优化评估:ROI 偏低 或 衰退信号 或 出价调整候选(需要智能判断)
             # 待优化评估:ROI 偏低 或 衰退信号 或 出价调整候选(需要智能判断)
             roi_low = (not pd.isna(f_roi)) and (f_roi < roi_mean * roi_review_factor)
             roi_low = (not pd.isna(f_roi)) and (f_roi < roi_mean * roi_review_factor)
@@ -937,7 +961,8 @@ async def get_ads_for_review(
             ) if BID_ADJUSTMENT_ENABLED else False
             ) if BID_ADJUSTMENT_ENABLED else False
 
 
             if roi_low or decay_signal or bid_up_candidate or bid_down_candidate:
             if roi_low or decay_signal or bid_up_candidate or bid_down_candidate:
-                need_review_ads.append({
+                # ===== 构建广告字典(基础字段)=====
+                ad_dict = {
                     "ad_id": int(row["ad_id"]),
                     "ad_id": int(row["ad_id"]),
                     "ad_name": str(row.get("ad_name", "")),
                     "ad_name": str(row.get("ad_name", "")),
                     "动态ROI_7日均值": round(float(f_roi), 4) if not pd.isna(f_roi) else None,
                     "动态ROI_7日均值": round(float(f_roi), 4) if not pd.isna(f_roi) else None,
@@ -949,7 +974,69 @@ async def get_ads_for_review(
                     "stable_spend_days_30d": int(stable_days),
                     "stable_spend_days_30d": int(stable_days),
                     "bid_amount": round(bid_amount, 2),
                     "bid_amount": round(bid_amount, 2),
                     "bid_candidate": "bid_up" if bid_up_candidate else ("bid_down" if bid_down_candidate else None),
                     "bid_candidate": "bid_up" if bid_up_candidate else ("bid_down" if bid_down_candidate else None),
-                })
+                }
+
+                # ===== 新增:添加 audience_tier 和 roi_valid_days =====
+                ad_dict["audience_tier"] = str(row.get("audience_tier", "default"))
+                ad_dict["roi_valid_days"] = int(row.get("roi_valid_days", 0) or 0)
+
+                # ===== 新增:添加同类对比数据 =====
+                tier = ad_dict.get("audience_tier", "default")
+                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")
+
+                # ===== 新增:裂变率同类对比数据(如果有)=====
+                ad_dict["tier_fission_mean"] = tier_stats.get("fission_mean")
+                ad_dict["tier_fission_p50"] = tier_stats.get("fission_p50")
+
+                # 计算动态阈值(供LLM参考)
+                tier_roi_p50 = tier_stats.get("roi_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
+
+                # 降价线:中位数的 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
+
+                # 提价线:中位数的 110-115%(高于10-15%)
+                ad_dict["bid_up_line_min"] = round(tier_roi_p50 * 1.10, 4) if tier_roi_p50 else None
+                ad_dict["bid_up_line_max"] = round(tier_roi_p50 * 1.15, 4) if tier_roi_p50 else None
+
+                # ===== 新增:年龄分段标签 =====
+                if ad_age is not None:
+                    if ad_age <= AD_AGE_NEWBORN:  # ≤3天
+                        ad_dict["age_segment"] = "newborn"
+                        ad_dict["age_protection_level"] = "极度保护"
+                        ad_dict["allow_bid_down"] = False
+                        ad_dict["allow_bid_up"] = False
+                    elif ad_age <= COLD_START_DAYS:  # 4-7天
+                        ad_dict["age_segment"] = "cold_start"
+                        ad_dict["age_protection_level"] = "仅允许提价"
+                        ad_dict["allow_bid_down"] = False  # 不允许降价
+                        ad_dict["allow_bid_up"] = True     # 允许提价
+                        ad_dict["max_bid_down_pct"] = 0.05  # 最大降价5%(虽然不允许,但保留字段)
+                    else:  # >7天
+                        ad_dict["age_segment"] = "mature"
+                        ad_dict["age_protection_level"] = "正常调控"
+                        ad_dict["allow_bid_down"] = True
+                        ad_dict["allow_bid_up"] = True
+                        ad_dict["max_bid_down_pct"] = 0.10  # 最大降价10%
+
+                    # ⚠️ 高燃烧预警:广告年龄>3天 且 昨日消耗>300元
+                    yesterday_cost = float(row.get("前1日消耗", 0) or 0)
+                    if ad_age > HIGH_BURN_AGE_THRESHOLD and yesterday_cost > HIGH_BURN_COST_THRESHOLD:
+                        ad_dict["high_burn_alert"] = True
+                        ad_dict["yesterday_cost"] = round(yesterday_cost, 2)
+                    else:
+                        ad_dict["high_burn_alert"] = False
+
+                need_review_ads.append(ad_dict)
                 continue
                 continue
 
 
             # 正常运行:ROI 正常且无异常信号
             # 正常运行:ROI 正常且无异常信号

+ 6 - 6
examples/auto_put_ad_mini/tools/feishu_doc.py

@@ -3,7 +3,7 @@
 
 
 职责:
 职责:
   - 将本地 xlsx 文件上传并导入为飞书在线表格
   - 将本地 xlsx 文件上传并导入为飞书在线表格
-  - 设置文档权限(组织内获得链接可查看
+  - 设置文档权限(任何人获得链接可查看 - 支持不同主体访问
   - 通过 IM 发送在线表格链接
   - 通过 IM 发送在线表格链接
 
 
 飞书 Drive API(通过 httpx 直连):
 飞书 Drive API(通过 httpx 直连):
@@ -195,14 +195,14 @@ def _wait_import_result(token: str, ticket: str) -> Dict:
 
 
 
 
 def _set_permission(token: str, file_token: str, file_type: str = "sheet") -> None:
 def _set_permission(token: str, file_token: str, file_type: str = "sheet") -> None:
-    """设置文档权限:组织内获得链接可编辑"""
+    """设置文档权限:任何人可编辑(最大权限)"""
     resp = httpx.patch(
     resp = httpx.patch(
         f"{FEISHU_BASE_URL}/drive/v1/permissions/{file_token}/public",
         f"{FEISHU_BASE_URL}/drive/v1/permissions/{file_token}/public",
         headers={**_auth_headers(token), "Content-Type": "application/json"},
         headers={**_auth_headers(token), "Content-Type": "application/json"},
         params={"type": file_type},
         params={"type": file_type},
         json={
         json={
-            "external_access_entity": "closed",
-            "link_share_entity": "tenant_editable",
+            "external_access_entity": "open",  # 外部开放
+            "link_share_entity": "anyone_editable",  # ✅ 修改:任何人可编辑
         },
         },
         timeout=_HTTP_TIMEOUT,
         timeout=_HTTP_TIMEOUT,
     )
     )
@@ -211,7 +211,7 @@ def _set_permission(token: str, file_token: str, file_type: str = "sheet") -> No
     if data.get("code") != 0:
     if data.get("code") != 0:
         logger.warning("设置权限失败(不影响主流程): %s", data.get("msg", data))
         logger.warning("设置权限失败(不影响主流程): %s", data.get("msg", data))
     else:
     else:
-        logger.info("文档权限已设置: tenant_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) -> bool:
@@ -230,7 +230,7 @@ def _send_link_message(chat_id: str, url: str, title: str) -> bool:
 # 对外工具:import_to_feishu
 # 对外工具:import_to_feishu
 # ═══════════════════════════════════════════
 # ═══════════════════════════════════════════
 
 
-@tool(description="将本地 xlsx 报告导入为飞书在线表格,设置组织内可查看权限,并通过 IM 发送链接到运营群")
+@tool(description="将本地 xlsx 报告导入为飞书在线表格,设置任何人获得链接可查看权限,并通过 IM 发送链接到运营群")
 async def import_to_feishu(
 async def import_to_feishu(
     ctx: ToolContext,
     ctx: ToolContext,
     xlsx_path: str = "",
     xlsx_path: str = "",

+ 272 - 129
examples/auto_put_ad_mini/tools/im_approval.py

@@ -43,6 +43,7 @@ from config import (
     FEISHU_APP_SECRET,
     FEISHU_APP_SECRET,
     FEISHU_OPERATOR_OPEN_ID,
     FEISHU_OPERATOR_OPEN_ID,
     FEISHU_OPERATOR_CHAT_ID,
     FEISHU_OPERATOR_CHAT_ID,
+    FEISHU_AD_PROJECT_CHAT_ID,  # 新增:投放项目群聊
 )
 )
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -111,6 +112,88 @@ def _generate_approval_xlsx(df_tier2_3: pd.DataFrame, request_id: str) -> Path:
 # ═══════════════════════════════════════════
 # ═══════════════════════════════════════════
 
 
 
 
+def _format_project_notification_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, request_id: str) -> str:
+    """格式化投放项目群聊通知消息(仅通知,不需要审批)"""
+    lines = [
+        "📊 广告调控决策通知",
+        f"请求ID: {request_id}",
+        f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
+        "",
+    ]
+
+    # 需审批操作统计
+    if not df_tier2.empty:
+        total_count = len(df_tier2)
+        lines.append(f"🔶 待审批操作({total_count} 个):")
+        lines.append("-" * 40)
+
+        # 统计各操作类型
+        action_counts = df_tier2.get("final_action", df_tier2.get("action", "")).value_counts().to_dict()
+        for action, count in action_counts.items():
+            if action == "pause":
+                lines.append(f"  ⏸️  暂停: {count} 个")
+            elif action == "bid_down":
+                lines.append(f"  ⬇️  降价: {count} 个")
+            elif action == "bid_up":
+                lines.append(f"  ⬆️  提价: {count} 个")
+            else:
+                lines.append(f"  {action}: {count} 个")
+        lines.append("")
+
+        # 简化展示(只显示前3个)
+        lines.append("前 3 个示例:")
+        for i, (_, row) in enumerate(df_tier2.head(3).iterrows()):
+            ad_id = row.get("ad_id", "")
+            action = row.get("final_action", row.get("action", ""))
+            ad_name = str(row.get("ad_name", ""))[:20]
+            cost_avg = row.get("cost_7d_avg", 0)
+            roi = row.get("动态ROI_7日均值", 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}元 | ROI: {roi:.2f}")
+            lines.append("")
+
+        if total_count > 3:
+            lines.append(f"  ...还有 {total_count - 3} 个(查看在线表格)")
+            lines.append("")
+
+    # 已自动执行操作
+    if not df_tier1.empty:
+        lines.append(f"✅ 已自动执行({len(df_tier1)} 个)")
+        lines.append("")
+
+    lines.extend([
+        "-" * 40,
+        "ℹ️  说明:",
+        "  • 此消息为智能决策结果通知",
+        "  • 运营审批通过后才会实际执行",
+        "  • 详情请查看在线表格(自动发送)",
+    ])
+
+    return "\n".join(lines)
+
+
 def _format_approval_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, request_id: str) -> str:
 def _format_approval_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, request_id: str) -> str:
     lines = [
     lines = [
         "📊 广告调控审批请求",
         "📊 广告调控审批请求",
@@ -368,38 +451,78 @@ async def send_approval_request(
 
 
         # ─── 通过飞书 API 发送审批消息(文本 + Excel) ───
         # ─── 通过飞书 API 发送审批消息(文本 + Excel) ───
         feishu_sent = False
         feishu_sent = 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 单位:秒
         try:
         try:
-            # 消息 1:文本摘要
-            result = _feishu.send_message(to=FEISHU_OPERATOR_CHAT_ID, text=message)
-            feishu_sent = True
-            logger.info("飞书审批消息发送成功: message_id=%s", result.message_id)
+            # 消息 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)
+                except Exception as e:
+                    logger.warning("发送到个人失败: %s", e)
+
+            # 消息 1b:发送到投放项目群聊(如果配置了)— 临时禁用
+            # if FEISHU_AD_PROJECT_CHAT_ID:
+            #     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)
+            #     except Exception as e:
+            #         logger.warning("发送到项目群聊失败: %s", e)
 
 
             # 消息 2:导入为飞书在线表格(决策详情,含hold参考)
             # 消息 2:导入为飞书在线表格(决策详情,含hold参考)
             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
-                import_result = await import_to_feishu(
-                    ctx=ctx,
-                    xlsx_path=str(xlsx_path),
-                    send_im=True,
-                    chat_id=FEISHU_OPERATOR_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_OPERATOR_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:
+                #     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_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 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"决策表_{request_id}.xlsx",
+                            )
+                            logger.info("飞书决策 Excel(文件)发送成功(个人): message_id=%s", file_result_personal.message_id)
+                    except Exception as e:
+                        logger.warning("发送表格到个人失败: %s", e)
+
             except Exception as e:
             except Exception as e:
                 logger.warning("飞书在线表格导入失败(不影响审批流程): %s", e)
                 logger.warning("飞书在线表格导入失败(不影响审批流程): %s", e)
         except Exception as e:
         except Exception as e:
@@ -449,69 +572,78 @@ async def send_approval_request(
             if poll_count % 10 == 0:
             if poll_count % 10 == 0:
                 logger.info("飞书审批轮询 #%d,剩余 %.1f 分钟", poll_count, remaining)
                 logger.info("飞书审批轮询 #%d,剩余 %.1f 分钟", poll_count, remaining)
 
 
-            # 读取飞书聊天中审批消息之后的回复
+            # 读取个人和项目群的审批回复
             try:
             try:
-                result = _feishu.get_message_list(
-                    chat_id=FEISHU_OPERATOR_CHAT_ID,
-                    start_time=sent_time_sec,
-                    page_size=10,
-                )
-                if result and result.get("items"):
-                    for msg in result["items"]:
-                        sender_id = msg.get("sender_id", "")
-                        sender_type = msg.get("sender_type", "")
-
-                        logger.debug(
-                            "飞书消息: sender_type=%s, sender_id=%s, content=%s",
-                            sender_type, sender_id, str(msg.get("content", ""))[:100],
-                        )
-
-                        # 只看指定运营的用户消息(非机器人)
-                        if sender_type != "user" or sender_id != FEISHU_OPERATOR_OPEN_ID:
-                            continue
-
-                        # 框架已自动解析 text 消息的 JSON -> 纯文本
-                        text = msg.get("content", "")
-                        if not text.strip():
-                            continue
-
-                        # 检测到运营回复,返回原文给 Agent 理解
-                        parsed = _parse_approval_reply(text, tier2_ad_ids)
-                        if parsed["status"] != "unknown":
-                            _approval_requests[request_id].update({
-                                "status": "replied",
-                                "reply_content": text,
-                                "reply_at": datetime.now().isoformat(),
-                                "ad_ids": tier2_ad_ids,
-                            })
-
-                            logger.info("飞书审批收到运营回复: %s", text[:200])
-
-                            ad_ids_str = ", ".join(str(x) for x in tier2_ad_ids[:10])
-                            if len(tier2_ad_ids) > 10:
-                                ad_ids_str += f"...共{len(tier2_ad_ids)}个"
+                # ✅ 修改:监听个人私聊和项目群聊的消息 — 临时只监听个人
+                chat_ids_to_check = []
+                if FEISHU_OPERATOR_OPEN_ID:
+                    chat_ids_to_check.append(FEISHU_OPERATOR_OPEN_ID)
+                # 临时禁用项目群聊监听
+                # if FEISHU_AD_PROJECT_CHAT_ID:
+                #     chat_ids_to_check.append(FEISHU_AD_PROJECT_CHAT_ID)
+
+                for chat_id in chat_ids_to_check:
+                    result = _feishu.get_message_list(
+                        chat_id=chat_id,
+                        start_time=sent_time_sec,
+                        page_size=10,
+                    )
+                    if result and result.get("items"):
+                        for msg in result["items"]:
+                            sender_id = msg.get("sender_id", "")
+                            sender_type = msg.get("sender_type", "")
+
+                            logger.debug(
+                                "飞书消息 [%s]: sender_type=%s, sender_id=%s, content=%s",
+                                chat_id, sender_type, sender_id, str(msg.get("content", ""))[:100],
+                            )
 
 
-                            return ToolResult(
-                                title="运营已回复",
-                                output=(
-                                    f"运营飞书回复原文: {text}\n"
-                                    f"等待审批的广告ID: {ad_ids_str}\n"
-                                    f"等待时间: {poll_count * poll_interval_seconds} 秒\n\n"
-                                    f"请根据运营的自然语言回复判断后续操作:\n"
-                                    f"- 运营同意/批准/通过 → 调用 execute_decisions 执行\n"
-                                    f"- 运营拒绝/驳回 → 停止执行,告知原因\n"
-                                    f"- 运营要求修改(如\"广告X不要暂停\")→ 进入 Mode 3: modify_decisions → validate → 重新审批\n"
-                                    f"- 运营部分批准(如\"只批准降价的\")→ 相应过滤后 execute_decisions"
-                                ),
-                                metadata={
-                                    "request_id": request_id,
-                                    "feishu_sent": feishu_sent,
-                                    "msg_path": str(msg_path),
-                                    "poll_count": poll_count,
-                                    "raw_reply": text,
+                            # ✅ 修改:接受任何用户的回复(不限制特定个人)
+                            if sender_type != "user":
+                                continue
+
+                            # 框架已自动解析 text 消息的 JSON -> 纯文本
+                            text = msg.get("content", "")
+                            if not text.strip():
+                                continue
+
+                            # 检测到运营回复,返回原文给 Agent 理解
+                            parsed = _parse_approval_reply(text, tier2_ad_ids)
+                            if parsed["status"] != "unknown":
+                                _approval_requests[request_id].update({
+                                    "status": "replied",
+                                    "reply_content": text,
+                                    "reply_at": datetime.now().isoformat(),
                                     "ad_ids": tier2_ad_ids,
                                     "ad_ids": tier2_ad_ids,
-                                },
-                            )
+                                })
+
+                                logger.info("飞书审批收到运营回复: %s", text[:200])
+
+                                ad_ids_str = ", ".join(str(x) for x in tier2_ad_ids[:10])
+                                if len(tier2_ad_ids) > 10:
+                                    ad_ids_str += f"...共{len(tier2_ad_ids)}个"
+
+                                return ToolResult(
+                                    title="运营已回复",
+                                    output=(
+                                        f"运营飞书回复原文: {text}\n"
+                                        f"等待审批的广告ID: {ad_ids_str}\n"
+                                        f"等待时间: {poll_count * poll_interval_seconds} 秒\n\n"
+                                        f"请根据运营的自然语言回复判断后续操作:\n"
+                                        f"- 运营同意/批准/通过 → 调用 execute_decisions 执行\n"
+                                        f"- 运营拒绝/驳回 → 停止执行,告知原因\n"
+                                        f"- 运营要求修改(如\"广告X不要暂停\")→ 进入 Mode 3: modify_decisions → validate → 重新审批\n"
+                                        f"- 运营部分批准(如\"只批准降价的\")→ 相应过滤后 execute_decisions"
+                                    ),
+                                    metadata={
+                                        "request_id": request_id,
+                                        "feishu_sent": feishu_sent,
+                                        "msg_path": str(msg_path),
+                                        "poll_count": poll_count,
+                                        "raw_reply": text,
+                                        "ad_ids": tier2_ad_ids,
+                                    },
+                                )
             except Exception as e:
             except Exception as e:
                 logger.debug("飞书读消息失败(将重试): %s", e)
                 logger.debug("飞书读消息失败(将重试): %s", e)
 
 
@@ -587,56 +719,67 @@ async def check_approval_status(
             created_at_sec = str(int(
             created_at_sec = str(int(
                 datetime.fromisoformat(request["created_at"]).timestamp()
                 datetime.fromisoformat(request["created_at"]).timestamp()
             ))
             ))
-            result = _feishu.get_message_list(
-                chat_id=FEISHU_OPERATOR_CHAT_ID,
-                start_time=created_at_sec,
-                page_size=10,
-            )
 
 
-            if result and result.get("items"):
-                for msg in result["items"]:
-                    sender_id = msg.get("sender_id", "")
-                    sender_type = msg.get("sender_type", "")
+            # ✅ 修改:监听个人私聊和项目群聊的消息 — 临时只监听个人
+            chat_ids_to_check = []
+            if FEISHU_OPERATOR_OPEN_ID:
+                chat_ids_to_check.append(FEISHU_OPERATOR_OPEN_ID)
+            # 临时禁用项目群聊监听
+            # if FEISHU_AD_PROJECT_CHAT_ID:
+            #     chat_ids_to_check.append(FEISHU_AD_PROJECT_CHAT_ID)
 
 
-                    logger.debug(
-                        "飞书消息(check): sender_type=%s, sender_id=%s, content=%s",
-                        sender_type, sender_id, str(msg.get("content", ""))[:100],
-                    )
+            for chat_id in chat_ids_to_check:
+                result = _feishu.get_message_list(
+                    chat_id=chat_id,
+                    start_time=created_at_sec,
+                    page_size=10,
+                )
 
 
-                    if sender_type != "user" or sender_id != FEISHU_OPERATOR_OPEN_ID:
-                        continue
-
-                    text = msg.get("content", "")
-                    if not text.strip():
-                        continue
-
-                    # 检测到运营回复,返回原文给 Agent 理解
-                    parsed = _parse_approval_reply(text, request["ad_ids"])
-                    if parsed["status"] != "unknown":
-                        request.update({
-                            "status": "replied",
-                            "reply_content": text,
-                            "reply_at": datetime.now().isoformat(),
-                        })
-
-                        ad_ids = request["ad_ids"]
-                        ad_ids_str = ", ".join(str(x) for x in ad_ids[:10])
-                        if len(ad_ids) > 10:
-                            ad_ids_str += f"...共{len(ad_ids)}个"
-
-                        return ToolResult(
-                            title="运营已回复",
-                            output=(
-                                f"运营飞书回复原文: {text}\n"
-                                f"等待审批的广告ID: {ad_ids_str}\n\n"
-                                f"请根据运营的自然语言回复判断后续操作:\n"
-                                f"- 运营同意/批准/通过 → 调用 execute_decisions 执行\n"
-                                f"- 运营拒绝/驳回 → 停止执行,告知原因\n"
-                                f"- 运营要求修改 → 进入 Mode 3: modify_decisions → validate → 重新审批\n"
-                                f"- 运营部分批准 → 相应过滤后 execute_decisions"
-                            ),
-                            metadata={"request_id": request_id, "raw_reply": text, "ad_ids": ad_ids},
+                if result and result.get("items"):
+                    for msg in result["items"]:
+                        sender_id = msg.get("sender_id", "")
+                        sender_type = msg.get("sender_type", "")
+
+                        logger.debug(
+                            "飞书消息(check) [%s]: sender_type=%s, sender_id=%s, content=%s",
+                            chat_id, sender_type, sender_id, str(msg.get("content", ""))[:100],
                         )
                         )
+
+                        # ✅ 修改:接受任何用户的回复(不限制特定个人)
+                        if sender_type != "user":
+                            continue
+
+                        text = msg.get("content", "")
+                        if not text.strip():
+                            continue
+
+                        # 检测到运营回复,返回原文给 Agent 理解
+                        parsed = _parse_approval_reply(text, request["ad_ids"])
+                        if parsed["status"] != "unknown":
+                            request.update({
+                                "status": "replied",
+                                "reply_content": text,
+                                "reply_at": datetime.now().isoformat(),
+                            })
+
+                            ad_ids = request["ad_ids"]
+                            ad_ids_str = ", ".join(str(x) for x in ad_ids[:10])
+                            if len(ad_ids) > 10:
+                                ad_ids_str += f"...共{len(ad_ids)}个"
+
+                            return ToolResult(
+                                title="运营已回复",
+                                output=(
+                                    f"运营飞书回复原文: {text}\n"
+                                    f"等待审批的广告ID: {ad_ids_str}\n\n"
+                                    f"请根据运营的自然语言回复判断后续操作:\n"
+                                    f"- 运营同意/批准/通过 → 调用 execute_decisions 执行\n"
+                                    f"- 运营拒绝/驳回 → 停止执行,告知原因\n"
+                                    f"- 运营要求修改 → 进入 Mode 3: modify_decisions → validate → 重新审批\n"
+                                    f"- 运营部分批准 → 相应过滤后 execute_decisions"
+                                ),
+                                metadata={"request_id": request_id, "raw_reply": text, "ad_ids": ad_ids},
+                            )
         except Exception as e:
         except Exception as e:
             logger.debug("飞书读消息失败: %s", e)
             logger.debug("飞书读消息失败: %s", e)
 
 

+ 57 - 0
examples/auto_put_ad_mini/tools/roi_calculator.py

@@ -24,6 +24,7 @@ ROI 计算工具 — auto_put_ad_mini V3
 """
 """
 
 
 import logging
 import logging
+import sys
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 from pathlib import Path
 from pathlib import Path
 from typing import Dict, List, Optional
 from typing import Dict, List, Optional
@@ -36,11 +37,49 @@ from agent.tools.models import ToolContext, ToolResult
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# 添加当前目录到路径以导入其他工具模块
+_TOOLS_DIR = Path(__file__).resolve().parent
+if str(_TOOLS_DIR) not in sys.path:
+    sys.path.insert(0, str(_TOOLS_DIR))
+
 _MINI_DIR = Path(__file__).resolve().parent.parent
 _MINI_DIR = Path(__file__).resolve().parent.parent
 _RAW_DIR = _MINI_DIR / "outputs" / "raw"
 _RAW_DIR = _MINI_DIR / "outputs" / "raw"
 _AD_STATUS_DIR = _MINI_DIR / "outputs" / "ad_status"
 _AD_STATUS_DIR = _MINI_DIR / "outputs" / "ad_status"
 _MERGED_DIR = _MINI_DIR / "outputs" / "merged"
 _MERGED_DIR = _MINI_DIR / "outputs" / "merged"
 
 
+# 延迟导入 _extract_audience_tier(避免循环导入)
+def _get_extract_audience_tier():
+    """延迟导入人群包提取函数"""
+    try:
+        from ad_decision import _extract_audience_tier
+        return _extract_audience_tier
+    except ImportError:
+        logger.warning("无法导入 _extract_audience_tier,使用默认实现")
+        # 提供一个简单的默认实现
+        def default_extract(ad_name: str) -> str:
+            if not ad_name:
+                return "default"
+            # 简化版:直接匹配常见模式
+            ad_name_lower = str(ad_name).lower()
+            if "r500" in ad_name_lower or "r_500" in ad_name_lower:
+                return "R500"
+            elif "r330+" in ad_name_lower or "回流330+" in ad_name_lower:
+                return "R330+"
+            elif "r330" in ad_name_lower or "r_330" in ad_name_lower or "回流330" in ad_name_lower:
+                return "R330"
+            elif "r180" in ad_name_lower or "r_180" in ad_name_lower or "回流180" in ad_name_lower:
+                return "R180"
+            elif "r100" in ad_name_lower or "r_100" in ad_name_lower or "回流100" in ad_name_lower:
+                return "R100"
+            elif "r50" in ad_name_lower or "r_50" in ad_name_lower or "回流50" in ad_name_lower:
+                return "R50"
+            elif "r10" in ad_name_lower or "r_10" in ad_name_lower:
+                return "R10"
+            elif "r2" in ad_name_lower or "r_2" in ad_name_lower:
+                return "R2"
+            return "default"
+        return default_extract
+
 
 
 # ===== 创意 → 广告聚合 =====
 # ===== 创意 → 广告聚合 =====
 
 
@@ -199,6 +238,12 @@ def _calculate_f7_dynamic_roi(
         .transform(lambda x: x.rolling(window=7, min_periods=3).mean())
         .transform(lambda x: x.rolling(window=7, min_periods=3).mean())
     )
     )
 
 
+    # ===== 新增:计算有效ROI数据天数(非NaN的天数)=====
+    ad_df["roi_valid_days"] = (
+        ad_df.groupby("ad_id")["动态ROI"]
+        .transform(lambda x: x.notna().sum())
+    )
+
     return ad_df
     return ad_df
 
 
 
 
@@ -404,6 +449,16 @@ async def calculate_roi_metrics(
             (end_dt - pd.to_datetime(result_df["create_time"])).dt.days
             (end_dt - pd.to_datetime(result_df["create_time"])).dt.days
         )
         )
 
 
+        # ===== 新增:添加 audience_tier 和 roi_valid_days =====
+        # 提取人群包(从 ad_name)
+        extract_tier = _get_extract_audience_tier()
+        result_df["audience_tier"] = result_df["ad_name"].apply(extract_tier)
+
+        # 获取 roi_valid_days(从 ad_df 最新一天的数据)
+        latest_roi_valid = ad_df[ad_df["date"] == end_date_str][["ad_id", "roi_valid_days"]].copy()
+        result_df = result_df.merge(latest_roi_valid, on="ad_id", how="left")
+        result_df["roi_valid_days"] = result_df["roi_valid_days"].fillna(0).astype(int)
+
         # 重命名输出列,统一供决策引擎使用
         # 重命名输出列,统一供决策引擎使用
         # 动态ROI_latest → 动态ROI(单日值,反映当日效率)
         # 动态ROI_latest → 动态ROI(单日值,反映当日效率)
         # 动态ROI_7日均值_latest → 动态ROI_7日均值(决策参考值)
         # 动态ROI_7日均值_latest → 动态ROI_7日均值(决策参考值)
@@ -441,6 +496,8 @@ async def calculate_roi_metrics(
             "  - cost_7d_total, cost_7d_avg, revenue_7d_total",
             "  - cost_7d_total, cost_7d_avg, revenue_7d_total",
             "  - cost_30d_total, cost_30d_avg, stable_spend_days_30d",
             "  - cost_30d_total, cost_30d_avg, stable_spend_days_30d",
             "  - ad_age_days, creative_count",
             "  - ad_age_days, creative_count",
+            "  - audience_tier(人群包层级,用于同类对比)",
+            "  - roi_valid_days(有效ROI数据天数,用于置信度评估)",
         ]
         ]
 
 
         return ToolResult(
         return ToolResult(