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

fix(execute): 部分批准型跳过 Phase 2 二次审批 + NaN 出价兜底

Why:
- LLM 在部分批准场景下偶尔忘传 filter_ad_ids,导致 Phase 2 重复发审批,与"一轮制"协议冲突
- 出价缺失广告(final_bid=NaN)进入执行分支会触发 int(NaN)*100 崩溃

How:
- execution_engine: filter_ad_ids 非空时直接视为已批准,跳过 IM 二次审批,避免重复打扰运营
- execution_engine: bid_up/bid_down 分支增加 pd.isna() 兜底,记审计 skipped 而非崩溃
- system.prompt: 部分批准型协议补充正反例对比,强化 LLM 必须传 filter_ad_ids
- 清理过时文档(.env.test/API_TEST_GUIDE.md/3 个手册 .md)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
刘立冬 1 месяц назад
Родитель
Сommit
699adf4f28

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

@@ -1,6 +0,0 @@
-# 数据库配置(阿里云 RDS MySQL)
-DB_HOST=rm-t4nh1xx6o2a6vj8qu3o.mysql.singapore.rds.aliyuncs.com
-DB_PORT=3306
-DB_USER=ad_rw
-DB_PASSWORD=p82SzuW4kAP3LJXcQGso
-DB_NAME=tencent_ad_autoput

+ 0 - 242
examples/auto_put_ad_mini/API_TEST_GUIDE.md

@@ -1,242 +0,0 @@
-# 腾讯广告平台 API 测试指南
-
-## 项目分析总结
-
-### 系统架构
-
-**auto_put_ad_mini** 是一个智能广告投放系统,主要功能包括:
-
-```
-数据拉取 → ROI 计算 → 广告分类(A/B/C) → AI 推理决策 → 保存决策 → 护栏验证 → 生成报告 → 执行
-```
-
-### 核心组件
-
-1. **API 封装** (`tools/ad_api.py`)
-   - 腾讯广告 Marketing API v3.0 封装
-   - 支持广告创建、更新、查询
-   - 支持创意管理
-   - 支持数据报表查询
-   - 支持账户信息查询
-
-2. **数据查询** (`tools/data_query.py`)
-   - 拉取创意数据
-   - 合并数据
-
-3. **ROI 计算** (`tools/roi_calculator.py`)
-   - 计算 动态 ROI (7日均值)
-   - 多维度 ROI 分析
-
-4. **决策引擎** (`tools/ad_decision.py`)
-   - 广告分类 (A/B/C)
-   - AI 推理决策
-   - 应用决策
-
-5. **安全护栏** (`tools/guardrails.py`)
-   - 验证决策安全性
-   - 防止过度调整
-
-6. **执行引擎** (`tools/execution_engine.py`)
-   - 分级执行决策
-   - 效果检查
-
-7. **报告生成** (`tools/report_generator.py`)
-   - 生成决策报告
-   - 对比分析
-
-### 关键特性
-
-- **API 版本**: 腾讯广告 Marketing API v3.0
-- **层级结构**: 广告(Ad) → 创意(Dynamic Creative)
-- **决策依据**: 动态 ROI (7日均值) + 消耗双维度
-- **决策范围**: 调整出价、暂停广告
-- **安全模式**: DRY_RUN_MODE 默认开启,不实际执行
-
----
-
-## API 测试配置指南
-
-### 1. 获取腾讯广告 API 凭证
-
-#### 1.1 获取 Access Token
-
-1. 登录腾讯广告平台: https://e.qq.com
-2. 进入"开发者中心" → "API 管理"
-3. 创建应用并获取 `App ID` 和 `App Secret`
-4. 使用 OAuth2.0 授权流程获取 `access_token`
-
-详细文档: https://developers.e.qq.com/docs/guide/auth
-
-#### 1.2 获取账户 ID
-
-1. 登录腾讯广告平台
-2. 在账户管理页面查看账户 ID (数字格式)
-3. 或通过 API 调用 `/accounts/get` 获取
-
-### 2. 配置环境变量
-
-编辑项目根目录的 `.env` 文件,添加以下配置:
-
-```bash
-# 腾讯广告 API 配置
-TENCENT_AD_ACCESS_TOKEN=your_access_token_here
-TENCENT_AD_ACCOUNT_ID=your_account_id_here
-
-# 可选: 自定义 API Base URL (默认为 v3.0)
-TENCENT_AD_BASE_URL=https://api.e.qq.com/v3.0
-```
-
-**示例:**
-
-```bash
-TENCENT_AD_ACCESS_TOKEN=abcdefghijklmnopqrstuvwxyz123456
-TENCENT_AD_ACCOUNT_ID=1234567890
-```
-
-### 3. 安装依赖
-
-```bash
-# 安装 httpx (用于 API 请求)
-pip3 install httpx
-
-# 或安装完整依赖
-pip3 install -r requirements.txt
-```
-
-### 4. 运行测试
-
-```bash
-cd /Users/liulidong/project/agent/Agent
-
-# 运行简化版测试 (推荐)
-python3 examples/auto_put_ad_mini/test_api_simple.py
-```
-
-### 5. 预期结果
-
-如果配置正确,测试脚本会依次测试:
-
-```
-✅ 环境变量检查
-✅ 账户信息查询
-✅ 广告列表查询
-✅ 数据报表查询
-
-🎉 所有测试通过! 腾讯广告平台接口可用
-```
-
----
-
-## 测试失败排查
-
-### 错误 1: 环境变量未设置
-
-**现象:**
-```
-❌ TENCENT_AD_ACCESS_TOKEN: 未设置
-```
-
-**解决方法:**
-- 确认 `.env` 文件存在于项目根目录
-- 确认 `.env` 文件中已添加 `TENCENT_AD_ACCESS_TOKEN` 和 `TENCENT_AD_ACCOUNT_ID`
-- 确认没有多余的空格或引号
-
-### 错误 2: API 错误 code != 0
-
-**现象:**
-```
-❌ API 错误 (code=4001): access_token invalid
-```
-
-**可能原因:**
-1. Access Token 已过期
-2. Access Token 格式错误
-3. Access Token 权限不足
-
-**解决方法:**
-- 重新获取 Access Token
-- 检查 Token 是否有账户访问权限
-- 确认使用的是 v3.0 API
-
-### 错误 3: HTTP 错误 403/401
-
-**现象:**
-```
-❌ HTTP 错误: 403
-```
-
-**可能原因:**
-1. 账户权限不足
-2. IP 白名单未配置
-3. 账户状态异常
-
-**解决方法:**
-- 检查账户状态是否正常
-- 配置 API 调用 IP 白名单
-- 确认账户有 API 调用权限
-
-### 错误 4: 网络连接错误
-
-**现象:**
-```
-❌ 网络请求错误: Connection timeout
-```
-
-**可能原因:**
-1. 网络不通
-2. 防火墙拦截
-3. 需要代理
-
-**解决方法:**
-- 检查网络连接
-- 配置代理 (如果需要):
-  ```bash
-  export HTTP_PROXY=http://127.0.0.1:7890
-  export HTTPS_PROXY=http://127.0.0.1:7890
-  ```
-
----
-
-## API 限制说明
-
-根据腾讯广告 API 文档:
-
-- **QPS 限制**: 单账户 10 QPS
-- **批量操作**: 单次最多 50 条
-- **金额单位**: 分 (1元 = 100分)
-- **审核时间**: 2-4 小时 (普通素材)
-- **数据延迟**: 实时数据 15-30 分钟,转化数据 1-2 小时
-
----
-
-## 下一步操作
-
-配置成功后,可以:
-
-1. **运行完整系统**:
-   ```bash
-   python3 examples/auto_put_ad_mini/run.py
-   ```
-   输入指令: `分析广告`
-
-2. **查看系统配置**:
-   - 配置文件: `examples/auto_put_ad_mini/config.py`
-   - 决策策略: `examples/auto_put_ad_mini/skills/roi_strategy.md`
-   - 安全护栏: `examples/auto_put_ad_mini/skills/guardrail_rules.md`
-
-3. **启用执行模式** (谨慎):
-   - 编辑 `config.py`
-   - 设置 `DRY_RUN_MODE = False`
-   - 设置 `EXECUTION_ENABLED = True`
-
----
-
-## 技术支持
-
-- 腾讯广告 API 文档: https://developers.e.qq.com/docs/
-- Marketing API v3.0: https://developers.e.qq.com/docs/api/marketing
-- OAuth2.0 授权: https://developers.e.qq.com/docs/guide/auth
-
----
-
-**最后更新**: 2026-04-15

+ 25 - 2
examples/auto_put_ad_mini/prompts/system.prompt

@@ -209,8 +209,10 @@ Skill 提供「判断原则」,工具提供「数据」,你负责综合判
 
 **核心原则**:您圈定的子集 `S` = 显式批准,**直接执行**;其余未明确通过的 ad_id = **默认拒绝、本轮作废**,不再发审批表征求二次同意。
 
-1. 解析回复得到 `approved_ids`(显式批准) 和 `rejected_ids`(其余默认拒绝)
-2. 调用 `execute_decisions(filter_ad_ids=approved_ids)` 仅执行批准子集
+**触发识别**:运营回复中**包含任意一个或多个 ad_id 数字**(例如 `95676588286 96490506566 ...`),无论是用空格、换行、逗号分隔,均**必须**走部分批准型协议。
+
+1. 解析回复得到 `approved_ids`(显式批准 ad_id 列表) 和 `rejected_ids`(其余默认拒绝)
+2. **必须**调用 `execute_decisions(validated_csv=..., filter_ad_ids=approved_ids)` 仅执行批准子集
 3. 调用 `send_feishu_text_message` 发简要回执:
    ```
    ✅ 已执行 N 条:广告 12345678901, 22345678902
@@ -219,6 +221,27 @@ Skill 提供「判断原则」,工具提供「数据」,你负责综合判
 4. ❌ **严禁**回到 `modify_decisions → send_approval_request` 重审循环——您已经在回复里明确了边界,二次审批是噪音
 5. ❌ **严禁**追加询问"为什么拒绝其他的""要不要重新评估"——本轮就此结束,等下一轮日跑
 
+**正反例对比**:
+
+✅ **正确**:
+```
+运营回复: "95676588286\n96490506566\n97535789388"
+→ execute_decisions(validated_csv="...validated_decisions_*.csv",
+                    filter_ad_ids=[95676588286, 96490506566, 97535789388])
+```
+
+❌ **错误**(本轮已发生过的真实失败案例):
+```
+运营回复: 17 个 ad_id 列表
+→ execute_decisions(validated_csv="...", approval_mode="auto")   ← 严重违规
+后果:Phase 2 触发二次审批 → 浪费运营时间 + 与"一轮制"协议冲突
+```
+
+❌ **严禁的参数模式**:
+- `approval_mode="auto"` 配合 ad_id 列表回复 → 必崩协议
+- 仅传 `validated_csv` 不传 `filter_ad_ids` 配合 ad_id 列表回复 → 必崩协议
+- 把 ad_id 列表理解为"全部批准"调用 `approval_mode="auto"` → **错把部分批准当全部批准**
+
 > **例外**:如果您的回复属于**策略型**("降幅改小一点")或**方向型**("整体太激进")——此时您没有圈定具体 ad_id,而是改边界——才走 `modify_decisions → validate → send_approval_request` 重审路径。
 
 ### 全部拒绝场景(强制收尾)

+ 127 - 125
examples/auto_put_ad_mini/tools/execution_engine.py

@@ -589,13 +589,13 @@ async def execute_decisions(
                 exec_status_override = pause_result["exec_status"]
             elif action in ("bid_up", "bid_down"):
                 final_bid = row.get("final_bid", row.get("recommended_bid"))
-                if final_bid is None or final_bid == "":
+                if final_bid is None or final_bid == "" or pd.isna(final_bid):
                     audit.log({
                         "ad_id": ad_id,
                         "action": action,
                         "tier": 1,
                         "execution_status": "skipped",
-                        "reason": "无出价数据",
+                        "reason": "无出价数据(NaN/空)",
                     })
                     continue
                 bid_fen = int(float(final_bid) * 100)
@@ -648,7 +648,17 @@ async def execute_decisions(
 
         # ═══ Phase 2: Tier 2/3 — 审批 + 执行 ═══
         if not df_tier2_3.empty:
-            if IM_ENABLED:
+            # 决定审批状态
+            if filter_ad_ids is not None:
+                # 部分批准型:filter_ad_ids 已是上游审批结果,直接视为"已批准",**严禁再发审批表**
+                logger.info(
+                    "Tier 2/3 共 %d 个操作,filter_ad_ids 已上游批准,跳过 Phase 2 二次审批,直接执行",
+                    len(df_tier2_3),
+                )
+                approval_status = "approved"
+                approved_ids = list({int(x) for x in filter_ad_ids})
+                rejected_ids = []
+            elif IM_ENABLED:
                 # 阻塞式审批:调用 send_approval_request(wait_for_reply=True)
                 logger.info("Tier 2/3 共 %d 个操作,发送 IM 审批并等待...", len(df_tier2_3))
 
@@ -674,139 +684,131 @@ async def execute_decisions(
                     if approval_result.metadata
                     else []
                 )
+            else:
+                # 兜底:IM 关 + 无 filter_ad_ids,全部标记 timeout
+                approval_status = "timeout"
+                approved_ids = []
+                rejected_ids = []
+
+            # 根据审批状态执行
+            if approval_status == "timeout":
+                # 超时:所有 Tier 2/3 标记为 timeout
+                timeout_count = len(df_tier2_3)
+                for _, row in df_tier2_3.iterrows():
+                    audit.log({
+                        "ad_id": int(row["ad_id"]),
+                        "account_id": int(row.get("account_id", 0) or 0),
+                        "action": row.get("final_action", row.get("action")),
+                        "tier": int(row.get("tier", 2)),
+                        "execution_status": "timeout",
+                        "source": row.get("source", ""),
+                    })
+            else:
+                # 执行已批准的广告
+                approved_set = set(int(x) for x in approved_ids)
+                rejected_set = set(int(x) for x in rejected_ids)
+
+                for _, row in df_tier2_3.iterrows():
+                    ad_id = int(row["ad_id"])
+                    account_id = int(row.get("account_id", 0) or 0)
+                    action = row.get("final_action", row.get("action"))
+                    tier = int(row.get("tier", 2))
 
-                if approval_status == "timeout":
-                    # 超时:所有 Tier 2/3 标记为 timeout
-                    timeout_count = len(df_tier2_3)
-                    for _, row in df_tier2_3.iterrows():
+                    if ad_id in rejected_set:
+                        rejected_count += 1
                         audit.log({
-                            "ad_id": int(row["ad_id"]),
-                            "account_id": int(row.get("account_id", 0) or 0),
-                            "action": row.get("final_action", row.get("action")),
-                            "tier": int(row.get("tier", 2)),
-                            "execution_status": "timeout",
+                            "ad_id": ad_id,
+                            "account_id": account_id,
+                            "action": action,
+                            "tier": tier,
+                            "execution_status": "rejected",
                             "source": row.get("source", ""),
                         })
-                else:
-                    # 执行已批准的广告
-                    approved_set = set(int(x) for x in approved_ids)
-                    rejected_set = set(int(x) for x in rejected_ids)
-
-                    for _, row in df_tier2_3.iterrows():
-                        ad_id = int(row["ad_id"])
-                        account_id = int(row.get("account_id", 0) or 0)
-                        action = row.get("final_action", row.get("action"))
-                        tier = int(row.get("tier", 2))
-
-                        if ad_id in rejected_set:
-                            rejected_count += 1
-                            audit.log({
-                                "ad_id": ad_id,
-                                "account_id": account_id,
-                                "action": action,
-                                "tier": tier,
-                                "execution_status": "rejected",
-                                "source": row.get("source", ""),
-                            })
-                            continue
+                        continue
 
-                        if ad_id not in approved_set:
-                            # 既不在 approved 也不在 rejected(部分审批场景遗漏)
-                            pending_approval += 1
-                            audit.log({
-                                "ad_id": ad_id,
-                                "account_id": account_id,
-                                "action": action,
-                                "tier": tier,
-                                "execution_status": "pending_approval",
-                                "source": row.get("source", ""),
-                            })
-                            continue
-
-                        # 已批准 → 执行
-                        pre_state = await executor.get_ad_state(ad_id, account_id)
-                        pause_extra: Dict[str, Any] = {}
-
-                        if action == "pause":
-                            pause_result = await _execute_pause(executor, row, audit, history)
-                            result = {"code": pause_result["code"], "message": pause_result["message"]}
-                            pause_extra = {
-                                "pause_scope": pause_result["scope"],
-                                "creative_results": pause_result["creative_results"],
-                            }
-                            exec_status_override = pause_result["exec_status"]
-                        elif action in ("bid_up", "bid_down"):
-                            final_bid = row.get("final_bid", row.get("recommended_bid"))
-                            if final_bid is None or final_bid == "":
-                                audit.log({
-                                    "ad_id": ad_id,
-                                    "action": action,
-                                    "tier": tier,
-                                    "execution_status": "skipped",
-                                    "reason": "无出价数据",
-                                })
-                                continue
-                            bid_fen = int(float(final_bid) * 100)
-                            result = await executor.update_bid(ad_id, account_id, bid_fen)
-                            exec_status_override = None
-                        else:
-                            continue
-
-                        api_code = result.get("code", -1)
-                        if exec_status_override is not None:
-                            exec_status = exec_status_override
-                        else:
-                            exec_status = "success" if api_code == 0 else "failed"
-
-                        if exec_status in ("success", "partial"):
-                            approved_executed += 1
-                            change_pct = row.get("recommended_change_pct", 0)
-                            if isinstance(change_pct, str):
-                                try:
-                                    change_pct = float(change_pct)
-                                except ValueError:
-                                    change_pct = 0
-                            history.record_adjustment(str(ad_id), action, change_pct)
-                        else:
-                            failed += 1
-
-                        post_state = await executor.get_ad_state(ad_id, account_id) if exec_status in ("success", "partial") else None
-
-                        audit_entry = {
+                    if ad_id not in approved_set:
+                        # 既不在 approved 也不在 rejected(部分审批场景遗漏)
+                        pending_approval += 1
+                        audit.log({
                             "ad_id": ad_id,
                             "account_id": account_id,
                             "action": action,
                             "tier": tier,
-                            "pre_state": {
-                                "bid_amount": pre_state.get("bid_amount") if pre_state else None,
-                                "status": pre_state.get("configured_status") if pre_state else None,
-                            } if pre_state else None,
-                            "post_state": {
-                                "bid_amount": post_state.get("bid_amount") if post_state else None,
-                                "status": post_state.get("configured_status") if post_state else None,
-                            } if post_state else None,
-                            "api_code": api_code,
-                            "api_message": result.get("message", ""),
-                            "execution_status": f"approved_{exec_status}",
+                            "execution_status": "pending_approval",
                             "source": row.get("source", ""),
+                        })
+                        continue
+
+                    # 已批准 → 执行
+                    pre_state = await executor.get_ad_state(ad_id, account_id)
+                    pause_extra: Dict[str, Any] = {}
+
+                    if action == "pause":
+                        pause_result = await _execute_pause(executor, row, audit, history)
+                        result = {"code": pause_result["code"], "message": pause_result["message"]}
+                        pause_extra = {
+                            "pause_scope": pause_result["scope"],
+                            "creative_results": pause_result["creative_results"],
                         }
-                        if pause_extra:
-                            audit_entry.update(pause_extra)
-                        audit.log(audit_entry)
-            else:
-                # IM 未启用:Tier 2/3 仅记录不执行
-                logger.info("IM 未启用,Tier 2/3 共 %d 个操作仅记录不执行", len(df_tier2_3))
-                pending_approval = len(df_tier2_3)
-                for _, row in df_tier2_3.iterrows():
-                    audit.log({
-                        "ad_id": int(row["ad_id"]),
-                        "account_id": int(row.get("account_id", 0) or 0),
-                        "action": row.get("final_action", row.get("action")),
-                        "tier": int(row.get("tier", 2)),
-                        "execution_status": "pending_approval",
-                        "note": "IM未启用,操作仅记录",
+                        exec_status_override = pause_result["exec_status"]
+                    elif action in ("bid_up", "bid_down"):
+                        final_bid = row.get("final_bid", row.get("recommended_bid"))
+                        if final_bid is None or final_bid == "" or pd.isna(final_bid):
+                            audit.log({
+                                "ad_id": ad_id,
+                                "action": action,
+                                "tier": tier,
+                                "execution_status": "skipped",
+                                "reason": "无出价数据(NaN/空)",
+                            })
+                            continue
+                        bid_fen = int(float(final_bid) * 100)
+                        result = await executor.update_bid(ad_id, account_id, bid_fen)
+                        exec_status_override = None
+                    else:
+                        continue
+
+                    api_code = result.get("code", -1)
+                    if exec_status_override is not None:
+                        exec_status = exec_status_override
+                    else:
+                        exec_status = "success" if api_code == 0 else "failed"
+
+                    if exec_status in ("success", "partial"):
+                        approved_executed += 1
+                        change_pct = row.get("recommended_change_pct", 0)
+                        if isinstance(change_pct, str):
+                            try:
+                                change_pct = float(change_pct)
+                            except ValueError:
+                                change_pct = 0
+                        history.record_adjustment(str(ad_id), action, change_pct)
+                    else:
+                        failed += 1
+
+                    post_state = await executor.get_ad_state(ad_id, account_id) if exec_status in ("success", "partial") else None
+
+                    audit_entry = {
+                        "ad_id": ad_id,
+                        "account_id": account_id,
+                        "action": action,
+                        "tier": tier,
+                        "pre_state": {
+                            "bid_amount": pre_state.get("bid_amount") if pre_state else None,
+                            "status": pre_state.get("configured_status") if pre_state else None,
+                        } if pre_state else None,
+                        "post_state": {
+                            "bid_amount": post_state.get("bid_amount") if post_state else None,
+                            "status": post_state.get("configured_status") if post_state else None,
+                        } if post_state else None,
+                        "api_code": api_code,
+                        "api_message": result.get("message", ""),
+                        "execution_status": f"approved_{exec_status}",
                         "source": row.get("source", ""),
-                    })
+                    }
+                    if pause_extra:
+                        audit_entry.update(pause_extra)
+                    audit.log(audit_entry)
 
         total_executed = executed + approved_executed
 
@@ -817,7 +819,7 @@ async def execute_decisions(
             f"  Tier 1 自动执行: {executed} 个成功 / {failed} 个失败",
         ]
 
-        if IM_ENABLED and not df_tier2_3.empty:
+        if not df_tier2_3.empty and (filter_ad_ids is not None or IM_ENABLED):
             output_lines.extend([
                 f"  Tier 2/3 审批后执行: {approved_executed} 个成功",
                 f"  审批拒绝: {rejected_count} 个",

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

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

+ 0 - 212
examples/auto_put_ad_mini/新增群聊通知功能说明.md

@@ -1,212 +0,0 @@
-# 新增群聊通知功能说明
-
-## 功能简介
-
-现在系统支持同时将广告决策结果发送到两个群聊:
-
-1. **审批群聊**(原有功能):运营人员审批决策,需要回复"批准/拒绝"
-2. **投放项目群聊**(新增功能):仅通知决策结果,不需要审批
-
-## 快速开始
-
-### 步骤1:获取投放项目群聊 ID
-
-```bash
-# 1. 确保机器人已加入您的投放项目群聊
-
-# 2. 运行获取群聊 ID 工具
-cd /Users/liulidong/project/agent/Agent/examples/auto_put_ad_mini
-source .venv/bin/activate  # 如果有虚拟环境
-python3 get_chat_id.py
-
-# 3. 在您的飞书群聊中发送任意消息(建议@机器人)
-
-# 4. 脚本会输出群聊 ID,类似:
-#    ✅ 请将以下群聊 ID 配置到 .env 文件:
-#    FEISHU_AD_PROJECT_CHAT_ID=oc_xxxxxxxxxxxxxxxxxxxxxx
-
-# 5. 按 Ctrl+C 停止监听
-```
-
-### 步骤2:配置环境变量
-
-创建或编辑 `.env` 文件:
-
-```bash
-# 飞书应用凭据
-FEISHU_APP_ID=cli_a955e97067f85cb3
-FEISHU_APP_SECRET=NQaG4ci1plXRDTgwCqrLJgMLLoA2tdF8
-
-# 审批群聊(原有配置,保持不变)
-FEISHU_OPERATOR_OPEN_ID=ou_498988d823b61ab89c9afe4310f85bb4
-FEISHU_OPERATOR_CHAT_ID=oc_88e0a1970a7de02eb5ac225a8b0cedea
-
-# 投放项目群聊(新增配置)
-FEISHU_AD_PROJECT_CHAT_ID=oc_xxxxxxxxxxxxxxxxxxxxxx  # 替换为实际的群聊ID
-```
-
-### 步骤3:测试运行
-
-```bash
-# 运行广告分析
-python3 execute_once.py
-
-# 或者交互式运行
-python3 run.py
-> 分析广告
-```
-
-## 消息差异
-
-### 审批群聊消息(需要审批)
-
-```
-📊 广告调控审批请求
-请求ID: req_20260417_143000_abc123
-时间: 2026-04-17 14:30
-
-🔶 需审批操作(25 个):
-----------------------------------------
-  ⏸️  暂停: 10 个
-  ⬇️  降价: 15 个
-
-前 5 个示例:
-  [90289631207] R500_回流用户_20260410
-    操作: ⏸️ 暂停 | 日均消耗: 1524元
-    原因: ROI=1.18 < 关停线1.64, 持续亏损
-
-----------------------------------------
-📝 直接回复即可,示例:
-  "批准" / "通过"          — 全部批准
-  "拒绝" / "不行"          — 全部拒绝
-  "广告 12345 不要暂停"     — 修改指定广告
-  ⏰ 超时时间: 30 分钟
-
-📎 决策详情请查看在线表格(自动发送链接)
-```
-
-### 投放项目群聊消息(仅通知)
-
-```
-📊 广告调控决策通知
-请求ID: req_20260417_143000_abc123
-时间: 2026-04-17 14:30
-
-🔶 待审批操作(25 个):
-----------------------------------------
-  ⏸️  暂停: 10 个
-  ⬇️  降价: 15 个
-
-前 3 个示例:
-  [90289631207] R500_回流用户_20260410
-    操作: ⏸️ 暂停 | 日均消耗: 1524元 | ROI: 1.18
-
-  [37429627354] R330_定向投放_20260412
-    操作: ⬇️ 降价8% | 日均消耗: 1228元 | ROI: 1.81
-
-  ...
-
-----------------------------------------
-ℹ️  说明:
-  • 此消息为智能决策结果通知
-  • 运营审批通过后才会实际执行
-  • 详情请查看在线表格(自动发送)
-```
-
-## 功能说明
-
-### 双群聊发送机制
-
-- **审批群聊**:完整审批流程,需要运营回复
-- **投放项目群聊**:仅接收通知,不需要任何操作
-
-### 在线表格
-
-两个群聊都会收到相同的在线表格(或Excel附件),包含所有决策详情。
-
-### 可选配置
-
-如果不想发送到投放项目群聊,只需:
-
-```bash
-# 方式1:不配置 FEISHU_AD_PROJECT_CHAT_ID(保持为空)
-# 方式2:在 .env 中注释掉该行
-# FEISHU_AD_PROJECT_CHAT_ID=oc_xxxxxxxxxxxxxxxxxxxxxx
-```
-
-## 故障排查
-
-### 问题1:群聊 ID 获取失败
-
-**症状**:运行 `get_chat_id.py` 后没有输出
-
-**原因**:
-- 机器人未加入群聊
-- 飞书应用未启用事件订阅
-- App ID 或 App Secret 配置错误
-
-**解决**:
-1. 确认机器人已在群聊中(群设置 → 群机器人)
-2. 检查飞书开放平台 → 应用管理 → 事件订阅是否启用
-3. 验证 `FEISHU_APP_ID` 和 `FEISHU_APP_SECRET` 是否正确
-
-### 问题2:消息只发送到审批群,没有发送到投放项目群
-
-**原因**:
-- `FEISHU_AD_PROJECT_CHAT_ID` 未配置或为空
-- 群聊 ID 配置错误
-
-**解决**:
-1. 检查 `.env` 文件中的配置
-2. 确认群聊 ID 以 `oc_` 开头
-3. 查看日志是否有错误信息:`logger.warning("发送到投放项目群聊失败: ...")`
-
-### 问题3:机器人无法在群聊中发送消息
-
-**原因**:
-- 机器人没有发送消息权限
-- 群聊禁止机器人发言
-
-**解决**:
-1. 飞书开放平台 → 应用管理 → 权限管理 → 添加权限:
-   - `im:message`(发送消息)
-   - `im:message:send_as_bot`(以机器人身份发送)
-2. 检查群聊设置是否允许机器人发言
-
-## 技术细节
-
-### 代码修改位置
-
-1. **config.py**(第 141 行):
-   ```python
-   FEISHU_AD_PROJECT_CHAT_ID = os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "")
-   ```
-
-2. **im_approval.py**:
-   - 导入配置(第 46 行)
-   - 新增消息格式化函数 `_format_project_notification_message`(第 114 行)
-   - 发送到投放项目群聊(第 448-452 行)
-   - 发送在线表格到投放项目群聊(第 464-472 行)
-
-3. **get_chat_id.py**:新增工具脚本,用于获取群聊 ID
-
-### 自动识别机制
-
-飞书客户端会根据 ID 前缀自动识别接收者类型:
-- `ou_` 开头 → 个人用户
-- `oc_` 开头 → 群聊
-
-无需手动指定类型。
-
-## 后续扩展
-
-如果需要支持更多群聊,可以参考同样的模式:
-
-1. 在 `config.py` 添加新配置项
-2. 在 `im_approval.py` 的发送逻辑中添加新的发送调用
-3. 更新 `.env.example` 说明
-
----
-
-**更新日期**:2026-04-17
-**功能版本**:v1.0

+ 0 - 205
examples/auto_put_ad_mini/飞书权限配置说明.md

@@ -1,205 +0,0 @@
-# 飞书应用权限配置说明
-
-## 问题:外部主体人员无法访问表格
-
-如果外部人员(不在您的飞书组织内)无法访问在线表格,需要配置以下权限。
-
----
-
-## 📋 必需的飞书应用权限
-
-### 步骤1:登录飞书开放平台
-
-访问:https://open.feishu.cn/app
-
-选择您的应用:**增长投放**(App ID: `cli_a955e97067f85cb3`)
-
-### 步骤2:配置权限
-
-进入 **权限管理** → **权限配置**,添加以下权限:
-
-#### 核心权限(必须)
-
-| 权限范围 | 权限名称 | 权限说明 | 用途 |
-|---------|---------|---------|------|
-| **云文档** | `drive:drive` | 查看、评论、编辑和管理云文档 | 上传和导入表格 |
-| **云文档** | `drive:drive:readonly` | 查看云文档 | 读取文档信息 |
-| **云文档** | `drive:media:upload` | 上传素材 | 上传 Excel 文件 |
-| **云文档** | `drive:permission:manage` | 管理文档权限 | **设置外部可访问权限** ✅ |
-| **即时消息** | `im:message` | 获取与发送单聊、群组消息 | 发送表格链接到群聊 |
-| **即时消息** | `im:message:send_as_bot` | 以应用的身份发送消息 | 以机器人身份发送 |
-
-#### 外部访问权限(关键)
-
-⚠️ **最关键的权限**:
-- `drive:permission:manage` - **管理文档权限**
-  - 允许应用设置文档为"任何人可查看"
-  - 没有此权限,外部人员将无法访问
-
-### 步骤3:权限申请(如果需要)
-
-某些权限可能需要管理员审批:
-
-1. 进入 **权限管理** → **权限申请**
-2. 选择需要的权限
-3. 填写申请理由:
-   ```
-   应用需要将广告决策报告导入为在线表格,并设置为外部可访问,
-   以便不同组织的相关人员(如客户、合作伙伴)可以查看报告数据。
-   ```
-4. 提交申请,等待管理员审批
-
----
-
-## 🧪 测试外部访问
-
-配置完成后,测试步骤:
-
-### 1. 生成测试表格
-
-```bash
-cd /Users/liulidong/project/agent/Agent/examples/auto_put_ad_mini
-python3 -c "
-import sys
-from pathlib import Path
-sys.path.insert(0, str(Path.cwd()))
-
-from tools.feishu_doc import _get_tenant_token, _upload_media, _create_import_task, _wait_import_result, _set_permission
-
-# 使用任意 Excel 文件测试
-xlsx_path = Path('outputs/reports').glob('*.xlsx')
-xlsx_file = next(xlsx_path, None)
-
-if xlsx_file:
-    print(f'测试文件: {xlsx_file}')
-    token = _get_tenant_token()
-    file_token = _upload_media(token, xlsx_file)
-    ticket = _create_import_task(token, file_token, xlsx_file.name)
-    result = _wait_import_result(token, ticket)
-    sheet_token = result.get('token')
-    url = result.get('url')
-
-    _set_permission(token, sheet_token, 'sheet')
-
-    print(f'在线表格 URL: {url}')
-    print(f'权限设置: 任何人获得链接可查看')
-else:
-    print('未找到测试文件')
-"
-```
-
-### 2. 验证外部访问
-
-1. 复制生成的表格 URL
-2. **退出您的飞书账号**(或使用隐私模式浏览器)
-3. 访问 URL
-4. 如果能直接查看,说明外部访问配置成功 ✅
-5. 如果提示需要登录或无权限,说明权限配置有问题 ❌
-
-### 3. 跨组织测试(推荐)
-
-找一个**不在您飞书组织**的同事:
-1. 发送表格链接给他
-2. 让他尝试打开链接
-3. 如果能直接查看,说明配置成功
-
----
-
-## 🔍 常见问题
-
-### 问题1:设置权限后仍然无法访问
-
-**症状**:外部人员点击链接后提示"无权限"或"需要登录"
-
-**原因**:
-- 飞书应用缺少 `drive:permission:manage` 权限
-- 权限设置 API 调用失败(被静默忽略)
-
-**解决**:
-1. 确认应用已有 `drive:permission:manage` 权限
-2. 查看日志,检查权限设置是否成功:
-   ```bash
-   grep "文档权限已设置" /path/to/log
-   ```
-3. 如果日志显示"设置权限失败",检查权限配置
-
-### 问题2:权限申请被拒绝
-
-**症状**:管理员拒绝 `drive:permission:manage` 权限申请
-
-**原因**:
-- 安全顾虑(允许应用设置文档为公开可能有风险)
-- 需要更详细的使用说明
-
-**解决**:
-1. 与管理员沟通,说明业务需求
-2. 提供安全保障措施:
-   - 仅对特定类型文档设置公开权限
-   - 添加审计日志
-   - 限制可设置权限的文档范围
-3. 如果仍无法获得权限,降级方案:
-   - 手动设置文档权限(在飞书中打开文档 → 右上角"分享"→ 设置为"任何人可查看")
-   - 使用 `tenant_readable`(仅组织内可查看)
-
-### 问题3:权限设置成功但仍无法访问
-
-**症状**:日志显示权限设置成功,但外部人员仍无法访问
-
-**原因**:
-- 飞书组织管理员设置了全局限制(禁止外部访问)
-- 文档所在文件夹有更严格的权限限制
-
-**解决**:
-1. 联系飞书管理员,检查组织安全设置
-2. 在飞书管理后台 → 安全设置 → 外部协作 → 确认是否允许外部访问
-3. 如果组织禁止外部访问,考虑使用其他方案(如导出PDF发送)
-
----
-
-## 📊 当前配置状态
-
-### 代码层面(已完成 ✅)
-
-```python
-# tools/feishu_doc.py 第 197-215 行
-def _set_permission(token: str, file_token: str, file_type: str = "sheet") -> None:
-    """设置文档权限:任何人获得链接可查看(不同主体都能看)"""
-    resp = httpx.patch(
-        f"{FEISHU_BASE_URL}/drive/v1/permissions/{file_token}/public",
-        headers={**_auth_headers(token), "Content-Type": "application/json"},
-        params={"type": file_type},
-        json={
-            "external_access_entity": "open",  # ✅ 外部开放
-            "link_share_entity": "anyone_readable",  # ✅ 任何人获得链接可查看
-        },
-        timeout=_HTTP_TIMEOUT,
-    )
-```
-
-### 飞书应用权限(需要配置 ⚠️)
-
-请确认以下权限已配置:
-
-- [x] `drive:drive` - 管理云文档
-- [x] `drive:media:upload` - 上传素材
-- [ ] `drive:permission:manage` - **管理文档权限**(关键)✅
-- [x] `im:message` - 发送消息
-- [x] `im:message:send_as_bot` - 以机器人身份发送
-
----
-
-## 🎯 快速检查清单
-
-完成以下检查,确保外部访问配置正确:
-
-- [ ] 飞书应用已添加 `drive:permission:manage` 权限
-- [ ] 权限申请已通过(如需审批)
-- [ ] 代码已更新为 `anyone_readable` 权限设置
-- [ ] 测试外部访问(退出飞书账号测试)
-- [ ] 跨组织测试(找其他组织的人测试)
-- [ ] 查看日志确认权限设置成功
-
----
-
-**更新日期**:2026-04-17
-**配置版本**:v1.0