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

feat(auto_put_ad_mini): 优化审批表格格式和决策理由自然语言化

主要改进:

1. **审批表格格式优化**
   - E1 单元格(决策动作表头)添加黄色高亮
   - 启用首行自动筛选功能
   - 冻结前 5 列(包含决策动作)
   - 列名统一:f_7日动态ROI → 7日均值动态ROI
   - 删除冗余列:当前出价(P列)、建议出价(Q列)
   - 审批表格精简至 15 列

2. **决策理由自然语言化**
   - 去掉技术术语(pause_line、bid_increased_7d、ad_age_days等)
   - 使用自然中文表达:
     - pause_line → "关停线"
     - bid_down_line → "降价线"
     - bid_increased_7d=true → "7天内已提价"
     - ad_age_days=9天 → "广告已投放9天"
   - 增强理由的可读性和专业性

3. **审批表格数据增强**
   - 添加审批日期列作为第一列
   - 支持从 metrics 数据补充关键字段
   - 零消耗广告自动排序到表格末尾

4. **飞书文档权限调整**
   - 修改权限:tenant_readable → tenant_editable
   - 支持组织内成员直接编辑审批表格

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
刘立冬 3 недель назад
Родитель
Сommit
b7a3b541a0

+ 71 - 26
examples/auto_put_ad_mini/prompts/system.prompt

@@ -148,7 +148,12 @@ generate_report
 - 用户说"不要暂停XXX"
   → modify_decisions → validate_decisions → send_approval_request → execute_decisions
 
-**关键**:理解意图后,自主选择最短路径,不强制走完整流程。
+**关键**:理解意图后,自主选择最短路径,但**全量分析场景必须走完整流程**(包括审批),不可跳过。
+
+**⚠️ 强制规则**:
+- 全量分析("分析广告"/"执行完整流程"/"最近效果怎么样")→ **必须**包含 send_approval_request
+- 审批环节有独立价值(飞书表格导出、人工审核、决策留档),与 EXECUTION_ENABLED 无关
+- 只有明确的单点查询/解释场景才可简化流程
 
 ### 错误处理原则
 
@@ -174,7 +179,7 @@ generate_report
     "ad_id": "123456",
     "action": "pause",
     "dimension": "ROI偏低",
-    "reason": "f_7日动态ROI=1.23 < pause_line(1.36),7日均消耗150元,效率持续低迷",
+    "reason": "动态ROI为1.23,低于关停线1.36;7日日均消耗150元,效率持续低迷",
     "confidence": "high",
     "recommended_change_pct": 0.0
   },
@@ -182,7 +187,7 @@ generate_report
     "ad_id": "234567",
     "action": "bid_down",
     "dimension": "ROI偏低-降价",
-    "reason": "动态ROI_7日均值=1.85 < bid_down_line(2.18),当前出价3.5元,建议降5%至3.33元",
+    "reason": "动态ROI为1.85,低于降价线2.18;当前出价3.5元,建议降5%至3.33元",
     "confidence": "medium",
     "recommended_change_pct": -0.05
   },
@@ -190,18 +195,27 @@ generate_report
     "ad_id": "345678",
     "action": "bid_up",
     "dimension": "高ROI低量-提价",
-    "reason": "动态ROI_7日均值=4.15 > bid_up_line(3.26),但7日均消耗仅45元,建议提8%至4.32元放量",
+    "reason": "动态ROI为4.15,高于提价线3.26;但7日日均消耗仅45元,建议提8%至4.32元加速放量",
     "confidence": "medium",
     "recommended_change_pct": 0.08
   }
 ]
 ```
 
-**要求**:
-- 必须引用具体数值(ROI、阈值、消耗)
-- 理由清晰明确,不泛泛而谈
-- 置信度符合数据支撑程度
-- 出价调幅为小数(+0.05=提5%,-0.08=降8%),单次≤10%
+**理由编写规范(自然语言表达)**:
+- ✅ 使用自然中文表达,避免技术术语(如 pause_line、bid_increased_7d、ad_age_days)
+- ✅ 术语替换对照:
+  - `pause_line` → "关停线"
+  - `bid_down_line` → "降价线"
+  - `bid_up_line` → "提价线"
+  - `bid_increased_7d=true` → "7天内已提价"
+  - `creative_changed_7d=true` → "7天内已更换创意"
+  - `ad_age_days=9天` → "广告已投放9天" 或 "投放9天"
+  - `stable_spend_days_30d=3天` → "30天内仅3天消耗稳定" 或 "消耗波动较大"
+- ✅ 数值描述清晰:引用具体ROI、阈值、消耗数值
+- ✅ 逻辑连贯:使用分号或逗号连接多个判断依据
+- ✅ 置信度符合数据支撑程度
+- ✅ 出价调幅为小数(+0.05=提5%,-0.08=降8%),单次≤10%
 
 # 第六部分:决策推理要求(像投放专家一样思考)
 
@@ -209,62 +223,93 @@ generate_report
 
 你必须像经验丰富的优化师一样,综合分析以下所有维度:
 
-## 1. 调价历史(bid_increased_7d)
+## 1. 调价历史维度
 
 **分析要求**:
-- 如果 `bid_increased_7d = true`,检查ROI是否改善
+- 如果7天内已提价,检查ROI是否改善
 - 未改善 → 判断为"调价无效"
 
 **决策影响**:
-- ROI < pause_line → 关停(调价无效,继续投放浪费预算)
-- ROI在pause_line和bid_down_line之间 → 降价幅度加大(8-10%而非常规5%)
+- ROI低于关停线 → 关停(调价无效,继续投放浪费预算)
+- ROI在关停线和降价线之间 → 降价幅度加大(8-10%而非常规5%)
 
-## 2. 创意变化(creative_changed_7d)
+**理由示例**(自然语言):
+```
+"7天内已提价但ROI仍低迷,判断为调价无效"
+```
+
+## 2. 创意变化维度
 
 **分析要求**:
-- 如果 `creative_changed_7d = true`,检查消耗是否提升
+- 如果7天内已更换创意,检查消耗是否提升
 - 未提升 → 判断为"创意问题"
 
 **决策影响**:
-- cost_7d_avg < 50元 → 暂停(创意吸引力不足)
+- 7日日均消耗 < 50元 → 暂停(创意吸引力不足)
+
+**理由示例**(自然语言):
+```
+"7天内已更换创意,但日均消耗仍低于50元,判断为创意吸引力不足"
+```
 
-## 3. 稳定性(stable_spend_days_30d)
+## 3. 数据稳定性维度
 
 **分析要求**:
-- `< 7天`:数据不稳定,降低置信度
-- `>= 7天`:数据可信,可正常决策
+- 30天内稳定消耗天数 < 7天:数据不稳定,降低置信度
+- 30天内稳定消耗天数 >= 7天:数据可信,可正常决策
 
 **决策影响**:
 - 数据不稳定时,对于ROI接近阈值的广告,倾向于观察而非立即关停
 - confidence设为"low"
 
-## 4. 广告年龄(ad_age_days)
+**理由示例**(自然语言):
+```
+"30天内仅3天消耗稳定,数据波动较大,建议观察"
+```
+
+## 4. 广告年龄维度
 
 **分析要求**:
 - **5-10天(新广告)**:给予学习期,ROI略低时倾向观察
 - **10-30天(成长期)**:正常阈值判断
 - **30天+(老广告)**:ROI持续低迷可更果断关停
 
+**理由示例**(自然语言):
+```
+"广告仅投放7天,仍在学习期,建议观察"
+"广告已投放45天,属于老广告,ROI持续低迷可果断关停"
+```
+
 ## 反例警示(避免模板化)
 
 **❌ 错误示例(模板化,未使用多维度)**:
 ```
-"ROI=1.80 < bid_down_line(2.65),建议降5%"
+"ROI为1.80,低于降价线2.65,建议降5%"
 ```
 
-**✅ 正确示例(多维度综合分析)**:
+**✅ 正确示例(多维度综合分析,自然语言表达)**:
 ```
-"ROI=1.80低于降价线(2.65),但bid_increased_7d=true(7天内已提价),调价无效,建议降8%而非常规5%"
+"动态ROI为1.80,低于降价线2.65;7天内已提价但ROI仍低迷,判断为调价无效,建议降8%而非常规5%"
 ```
 
-**❌ 错误示例(只看ROI)**:
+**❌ 错误示例(只看ROI,有技术术语)**:
 ```
 "ROI=1.25 < pause_line(1.36),建议关停"
 ```
 
-**✅ 正确示例(考虑稳定性和年龄)**:
+**✅ 正确示例(考虑稳定性和年龄,自然语言)**:
+```
+"动态ROI为1.25,低于关停线1.36;但30天内仅3天消耗稳定数据波动较大,且广告仅投放7天仍在学习期,建议观察而非立即关停"
+```
+
+**❌ 错误示例(包含英文变量名)**:
+```
+"动态ROI=1.62略低于pause_line(1.66),bid_increased_7d=true(7天内已提价)但ROI仍低迷,ad_age=9天,7日均消耗4438元高消耗,判断为调价无效,建议关停"
+```
+
+**✅ 正确示例(完全自然语言,运营易读)**:
 ```
-"ROI=1.25 < pause_line(1.36),但stable_spend_days_30d=3天数据波动大,且ad_age_days=7天仍在学习期,建议观察而非立即关停"
+"动态ROI为1.62,略低于关停线1.66;7天内已提价但ROI仍低迷;广告已投放9天,7日日均消耗4438元属于高消耗广告;综合判断调价无效,建议关停"
 ```
 
 # 第七部分:投放经验知识库(详见Skills)

+ 322 - 0
examples/auto_put_ad_mini/tools/feishu_doc.py

@@ -0,0 +1,322 @@
+"""
+飞书文档工具 — auto_put_ad_mini
+
+职责:
+  - 将本地 xlsx 文件上传并导入为飞书在线表格
+  - 设置文档权限(组织内获得链接可查看)
+  - 通过 IM 发送在线表格链接
+
+飞书 Drive API(通过 httpx 直连):
+  - 上传素材:POST /drive/v1/medias/upload_all
+  - 创建导入任务:POST /drive/v1/import_tasks
+  - 查询导入结果:GET /drive/v1/import_tasks/{ticket}
+  - 设置权限:PATCH /drive/v1/permissions/{token}/public
+
+飞书 IM(通过框架 FeishuClient):
+  - 发送链接消息:send_message(to, text)
+"""
+
+import json
+import logging
+import sys
+import time
+from pathlib import Path
+from typing import Dict, Optional
+
+import httpx
+
+from agent.tools import tool
+from agent.tools.models import ToolContext, ToolResult
+from agent.tools.builtin.feishu.feishu_client import FeishuClient
+
+_MINI_DIR = Path(__file__).resolve().parent.parent
+_TOOLS_DIR = Path(__file__).resolve().parent
+if str(_MINI_DIR) not in sys.path:
+    sys.path.insert(0, str(_MINI_DIR))
+if str(_TOOLS_DIR) not in sys.path:
+    sys.path.insert(0, str(_TOOLS_DIR))
+
+from config import (
+    FEISHU_APP_ID,
+    FEISHU_APP_SECRET,
+    FEISHU_OPERATOR_CHAT_ID,
+    REPORTS_DIR,
+)
+
+logger = logging.getLogger(__name__)
+
+# ═══════════════════════════════════════════
+# 常量
+# ═══════════════════════════════════════════
+
+FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
+_HTTP_TIMEOUT = 30
+_IMPORT_POLL_INTERVAL = 2  # 秒
+_IMPORT_MAX_WAIT = 60  # 秒
+
+# 全局 FeishuClient(复用框架能力发 IM 消息)
+_feishu = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET)
+
+# token 缓存
+_token_cache: Dict[str, object] = {}
+_TOKEN_TTL = 1500  # 25 分钟(官方有效期 2 小时,留余量)
+
+
+# ═══════════════════════════════════════════
+# 内部方法:飞书 tenant_access_token
+# ═══════════════════════════════════════════
+
+def _get_tenant_token() -> str:
+    """获取飞书 tenant_access_token(带缓存)"""
+    cached = _token_cache.get("tenant")
+    if cached and time.time() - cached["ts"] < _TOKEN_TTL:
+        return cached["token"]
+
+    resp = httpx.post(
+        f"{FEISHU_BASE_URL}/auth/v3/tenant_access_token/internal",
+        json={"app_id": FEISHU_APP_ID, "app_secret": FEISHU_APP_SECRET},
+        timeout=_HTTP_TIMEOUT,
+    )
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"获取飞书 token 失败: {data}")
+
+    token = data["tenant_access_token"]
+    _token_cache["tenant"] = {"token": token, "ts": time.time()}
+    logger.info("飞书 tenant_access_token 已刷新")
+    return token
+
+
+def _auth_headers(token: str) -> Dict[str, str]:
+    return {"Authorization": f"Bearer {token}"}
+
+
+# ═══════════════════════════════════════════
+# 内部方法:Drive API
+# ═══════════════════════════════════════════
+
+def _upload_media(token: str, file_path: Path) -> str:
+    """上传素材文件到飞书(用于后续导入)
+
+    Returns:
+        file_token: 上传后的文件标识
+    """
+    file_size = file_path.stat().st_size
+    file_name = file_path.name
+
+    with open(file_path, "rb") as f:
+        resp = httpx.post(
+            f"{FEISHU_BASE_URL}/drive/v1/medias/upload_all",
+            headers=_auth_headers(token),
+            data={
+                "file_name": file_name,
+                "parent_type": "explorer",
+                "parent_node": "",
+                "size": str(file_size),
+            },
+            files={"file": (file_name, f, "application/octet-stream")},
+            timeout=60,
+        )
+
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"上传素材失败: {data.get('msg', data)}")
+
+    file_token = data["data"]["file_token"]
+    logger.info("素材上传成功: file_token=%s, size=%d", file_token, file_size)
+    return file_token
+
+
+def _create_import_task(token: str, file_token: str, file_name: str) -> str:
+    """创建导入任务(xlsx → 飞书在线表格)
+
+    Returns:
+        ticket: 导入任务标识
+    """
+    title = file_name.replace(".xlsx", "").replace(".xls", "")
+    body = {
+        "file_extension": "xlsx",
+        "file_token": file_token,
+        "type": "sheet",
+        "file_name": title,
+        "point": {
+            "mount_type": 1,
+            "mount_key": "",
+        },
+    }
+
+    resp = httpx.post(
+        f"{FEISHU_BASE_URL}/drive/v1/import_tasks",
+        headers={**_auth_headers(token), "Content-Type": "application/json"},
+        json=body,
+        timeout=_HTTP_TIMEOUT,
+    )
+
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"创建导入任务失败: {data.get('msg', data)}")
+
+    ticket = data["data"]["ticket"]
+    logger.info("导入任务已创建: ticket=%s, title=%s", ticket, title)
+    return ticket
+
+
+def _wait_import_result(token: str, ticket: str) -> Dict:
+    """轮询导入结果
+
+    Returns:
+        dict: 包含 token, type, url 的结果字典
+    """
+    start = time.time()
+    while time.time() - start < _IMPORT_MAX_WAIT:
+        resp = httpx.get(
+            f"{FEISHU_BASE_URL}/drive/v1/import_tasks/{ticket}",
+            headers=_auth_headers(token),
+            timeout=_HTTP_TIMEOUT,
+        )
+        data = resp.json()
+
+        if data.get("code") != 0:
+            raise RuntimeError(f"查询导入结果失败: {data}")
+
+        result = data.get("data", {}).get("result", {})
+        job_status = result.get("job_status", -1)
+
+        if job_status == 0:
+            logger.info("导入成功: url=%s", result.get("url", ""))
+            return result
+        if job_status == 3:
+            error_msg = result.get("job_error_msg", "未知错误")
+            raise RuntimeError(f"导入失败: {error_msg}")
+
+        time.sleep(_IMPORT_POLL_INTERVAL)
+
+    raise RuntimeError(f"导入超时(等待 {_IMPORT_MAX_WAIT} 秒)")
+
+
+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": "closed",
+            "link_share_entity": "tenant_editable",
+        },
+        timeout=_HTTP_TIMEOUT,
+    )
+
+    data = resp.json()
+    if data.get("code") != 0:
+        logger.warning("设置权限失败(不影响主流程): %s", data.get("msg", data))
+    else:
+        logger.info("文档权限已设置: tenant_editable")
+
+
+def _send_link_message(chat_id: str, url: str, title: str) -> bool:
+    """通过 IM 发送在线表格链接到群聊"""
+    try:
+        text = f"**广告决策报告: {title}**\n\n报告已生成,点击查看: [打开在线表格]({url})"
+        _feishu.send_message(to=chat_id, text=text)
+        logger.info("报告链接已发送到群聊: chat_id=%s", chat_id)
+        return True
+    except Exception as e:
+        logger.warning("发送报告链接失败(不影响主流程): %s", e)
+        return False
+
+
+# ═══════════════════════════════════════════
+# 对外工具:import_to_feishu
+# ═══════════════════════════════════════════
+
+@tool(description="将本地 xlsx 报告导入为飞书在线表格,设置组织内可查看权限,并通过 IM 发送链接到运营群")
+async def import_to_feishu(
+    ctx: ToolContext,
+    xlsx_path: str = "",
+    send_im: bool = True,
+    chat_id: str = "",
+) -> ToolResult:
+    """将 xlsx 文件导入飞书在线表格并分享
+
+    完整流程:上传素材 → 导入为在线表格 → 设置权限 → IM 发送链接
+
+    Args:
+        xlsx_path: xlsx 文件路径。为空时自动使用 outputs/reports/ 下最新的 xlsx
+        send_im: 是否通过 IM 发送链接(默认 True)
+        chat_id: 目标群聊 ID。为空时使用配置中的 FEISHU_OPERATOR_CHAT_ID
+    """
+    try:
+        # --- 1. 定位 xlsx 文件 ---
+        if xlsx_path:
+            file_path = Path(xlsx_path)
+        else:
+            # 自动找最新的 xlsx
+            candidates = sorted(REPORTS_DIR.glob("*.xlsx"), reverse=True)
+            if not candidates:
+                return ToolResult(
+                    title="未找到报告文件",
+                    output=f"在 {REPORTS_DIR} 下未找到 xlsx 文件,请先运行 generate_report 生成报告",
+                )
+            file_path = candidates[0]
+
+        if not file_path.exists():
+            return ToolResult(
+                title="文件不存在",
+                output=f"文件不存在: {file_path}",
+            )
+
+        logger.info("开始导入飞书: %s (%d bytes)", file_path.name, file_path.stat().st_size)
+
+        # --- 2. 获取 token ---
+        token = _get_tenant_token()
+
+        # --- 3. 上传素材 ---
+        file_token = _upload_media(token, file_path)
+
+        # --- 4. 创建导入任务 & 等待完成 ---
+        ticket = _create_import_task(token, file_token, file_path.name)
+        result = _wait_import_result(token, ticket)
+
+        url = result.get("url", "")
+        sheet_token = result.get("token", "")
+        file_type = result.get("type", "sheet")
+
+        # --- 5. 设置权限 ---
+        if sheet_token:
+            _set_permission(token, sheet_token, file_type)
+
+        # --- 6. IM 发送链接 ---
+        im_sent = False
+        if send_im and url:
+            target_chat = chat_id or FEISHU_OPERATOR_CHAT_ID
+            if target_chat:
+                title = file_path.stem
+                im_sent = _send_link_message(target_chat, url, title)
+
+        # --- 结果 ---
+        output_lines = [
+            f"文件: {file_path.name}",
+            f"在线表格: {url}",
+            f"表格 token: {sheet_token}",
+            f"IM 发送: {'成功' if im_sent else '未发送' if not send_im else '失败'}",
+        ]
+
+        return ToolResult(
+            title=f"飞书导入成功: {file_path.stem}",
+            output="\n".join(output_lines),
+            metadata={
+                "url": url,
+                "sheet_token": sheet_token,
+                "file_type": file_type,
+                "xlsx_path": str(file_path),
+                "im_sent": im_sent,
+            },
+        )
+
+    except Exception as e:
+        logger.error("import_to_feishu 失败: %s", e, exc_info=True)
+        return ToolResult(
+            title="飞书导入失败",
+            output=str(e),
+        )

+ 158 - 50
examples/auto_put_ad_mini/tools/im_approval.py

@@ -59,13 +59,17 @@ _feishu = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET)
 # ═══════════════════════════════════════════
 
 # 审批表精选列(运营审阅所需的关键指标)
+# 列顺序:日期 → 账户ID → 广告ID → 广告消耗 → 决策动作 → 其他关键信息(简洁版)
 APPROVAL_COLUMNS = [
-    "ad_id", "ad_name", "audience_tier",
-    "ad_age_days", "bid_amount",
-    "cost_7d_avg", "f_7日动态ROI", "f_7日动态ROI_mean_all",
-    "action", "reason",
-    "recommended_change_pct", "current_bid", "recommended_bid",
-    "guardrail_status", "final_action", "final_bid",
+    # 核心标识(前5列,含决策动作)
+    "approval_date", "account_id", "ad_id", "cost_7d_avg", "action",
+    # 基础信息
+    "ad_name", "audience_tier", "ad_age_days", "bid_amount",
+    # 关键指标(使用实际列名)
+    "动态ROI_7日均值", "cost_7d_total", "revenue_7d_total",
+    # 决策详情
+    "dimension", "reason",
+    "recommended_change_pct",
 ]
 
 
@@ -81,10 +85,23 @@ def _generate_approval_xlsx(df_tier2_3: pd.DataFrame, request_id: str) -> Path:
     approval_dir.mkdir(parents=True, exist_ok=True)
     xlsx_path = approval_dir / f"{request_id}.xlsx"
 
+    # 添加审批日期列(当前日期)
+    df_tier2_3 = df_tier2_3.copy()
+    df_tier2_3["approval_date"] = datetime.now().strftime("%Y-%m-%d")
+
     # 精选列(仅保留 df 中存在的列)
     cols = [c for c in APPROVAL_COLUMNS if c in df_tier2_3.columns]
     df_out = df_tier2_3[cols].copy()
 
+    # 排序:7日消耗0元的放最后,有消耗的在前,同组内按消耗降序
+    if "cost_7d_avg" in df_out.columns:
+        df_out["_has_spend"] = (df_out["cost_7d_avg"] > 0.01).astype(int)  # >0.01元算有消耗
+        df_out = df_out.sort_values(
+            ["_has_spend", "cost_7d_avg"],
+            ascending=[False, False]  # 有消耗在前(1在前),消耗高的在前
+        )
+        df_out.drop(columns=["_has_spend"], inplace=True)
+
     _write_xlsx_with_format(df_out, xlsx_path)
     return xlsx_path
 
@@ -104,41 +121,69 @@ def _format_approval_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, req
 
     # Tier 2/3: 需审批
     if not df_tier2.empty:
-        lines.append(f"🔶 需审批操作({len(df_tier2)} 个):")
+        total_count = len(df_tier2)
+        lines.append(f"🔶 需审批操作({total_count} 个):")
         lines.append("-" * 40)
 
-        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)
-
+        # 统计各操作类型
+        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":
-                action_label = "⏸️ 暂停"
+                lines.append(f"  ⏸️  暂停: {count} 个")
             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}%"
+                lines.append(f"  ⬇️  降价: {count} 个")
             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}%"
+                lines.append(f"  ⬆️  提价: {count} 个")
             else:
-                action_label = action
+                lines.append(f"  {action}: {count} 个")
+        lines.append("")
 
-            lines.append(f"  [{ad_id}] {ad_name}")
-            lines.append(f"    操作: {action_label} | 日均消耗: {cost_avg:.0f}元")
-            lines.append(f"    原因: {reason}")
+        # 如果数量过多(>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 1: 已自动执行(通知)
     if not df_tier1.empty:
@@ -160,7 +205,7 @@ def _format_approval_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, req
         "  \"降幅改小一点\"            — 调整后重新审批",
         f"  ⏰ 超时时间: {IM_APPROVAL_TIMEOUT_MINUTES} 分钟",
         "",
-        "📎 决策详情请查看附件 Excel 表格",
+        "📎 决策详情请查看在线表格(自动发送链接)",
     ])
 
     return "\n".join(lines)
@@ -242,17 +287,64 @@ async def send_approval_request(
         if df.empty:
             return ToolResult(title="send_approval_request", output="决策数据为空")
 
-        # 筛选有操作的决策
-        df_active = df[df["final_action"] != "hold"].copy()
-        if df_active.empty:
-            return ToolResult(title="send_approval_request", output="无需审批的操作")
+        # ⚠️ 关键:补充 metrics 数据(通过 ad_id 关联)
+        metrics_path = _MINI_DIR / "outputs" / "metrics_temp.csv"
+        if not metrics_path.exists():
+            # 尝试查找最新的 metrics 文件
+            reports_dir = _MINI_DIR / "outputs"
+            candidates = sorted(reports_dir.glob("metrics_*.csv"), reverse=True)
+            if candidates:
+                metrics_path = candidates[0]
+
+        if metrics_path.exists():
+            df_metrics = pd.read_csv(metrics_path)
+            # 选择需要的列(避免重复列,使用实际列名)
+            metrics_cols = [
+                "ad_id", "account_id", "ad_name",
+                "cost_7d_avg", "cost_7d_total", "revenue_7d_total",
+                "动态ROI_7日均值", "bid_amount"
+            ]
+            # 只保留存在的列
+            metrics_cols = [c for c in metrics_cols if c in df_metrics.columns]
+            df_metrics_sub = df_metrics[metrics_cols].copy()
+
+            # 从 ad_name 中提取 audience_tier(如 "R500_xxx" → "R500")
+            if "ad_name" in df_metrics_sub.columns:
+                df_metrics_sub["audience_tier"] = df_metrics_sub["ad_name"].str.extract(r"^(R\d+)")[0]
+
+            # 左连接:保留 df 的所有行,补充 metrics 数据
+            df = df.merge(df_metrics_sub, on="ad_id", how="left", suffixes=("", "_metrics"))
+            logger.info(f"已从 metrics 补充 {len(metrics_cols)} 列数据")
+        else:
+            logger.warning("未找到 metrics 文件,审批表格将缺少关键字段")
+
+        # 过滤已暂停的广告(不应出现在审批表中)
+        if "configured_status" in df.columns:
+            before_count = len(df)
+            df = df[df["configured_status"] != "AD_STATUS_SUSPEND"].copy()
+            filtered_count = before_count - len(df)
+            if filtered_count > 0:
+                logger.info(f"审批请求过滤掉 {filtered_count} 个已暂停广告")
 
-        # 分级
+        if df.empty:
+            return ToolResult(title="send_approval_request", output="过滤后无数据")
+
+        # 分级(包含hold记录,用于参考)
         from execution_engine import _classify_tier
-        df_active["tier"] = df_active.apply(_classify_tier, axis=1)
+        df["tier"] = df.apply(_classify_tier, axis=1)
+
+        # 分类:需执行操作 vs hold(参考)
+        df_active = df[df["final_action"] != "hold"].copy()
+        df_hold = df[df["final_action"] == "hold"].copy()
 
-        df_tier1 = df_active[df_active["tier"] == 1]
-        df_tier2_3 = df_active[df_active["tier"] >= 2]
+        if df_active.empty and df_hold.empty:
+            return ToolResult(title="send_approval_request", output="无决策数据")
+
+        df_tier1 = df_active[df_active["tier"] == 1] if not df_active.empty else pd.DataFrame()
+        df_tier2_3 = df_active[df_active["tier"] >= 2] if not df_active.empty else pd.DataFrame()
+
+        # 合并 Tier2/3 + hold(供运营参考)
+        df_for_review = pd.concat([df_tier2_3, df_hold], ignore_index=True) if not df_hold.empty else df_tier2_3
 
         if df_tier2_3.empty:
             return ToolResult(
@@ -283,17 +375,33 @@ async def send_approval_request(
             feishu_sent = True
             logger.info("飞书审批消息发送成功: message_id=%s", result.message_id)
 
-            # 消息 2:Excel 文件附件(决策详情
+            # 消息 2:导入为飞书在线表格(决策详情,含hold参考
             try:
-                xlsx_path = _generate_approval_xlsx(df_tier2_3, request_id)
-                file_result = _feishu.send_file(
-                    to=FEISHU_OPERATOR_CHAT_ID,
-                    file=str(xlsx_path),
-                    file_name=f"审批决策表_{request_id}.xlsx",
+                xlsx_path = _generate_approval_xlsx(df_for_review, request_id)
+
+                # 导入飞书在线表格并发送链接
+                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
                 )
-                logger.info("飞书审批 Excel 发送成功: message_id=%s", file_result.message_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)
             except Exception as e:
-                logger.warning("飞书审批 Excel 发送失败(不影响审批流程): %s", e)
+                logger.warning("飞书在线表格导入失败(不影响审批流程): %s", e)
         except Exception as e:
             logger.warning("飞书发消息失败: %s", e)
 

+ 85 - 59
examples/auto_put_ad_mini/tools/report_generator.py

@@ -22,17 +22,17 @@ logger = logging.getLogger(__name__)
 _MINI_DIR = Path(__file__).resolve().parent.parent
 _REPORTS_DIR = _MINI_DIR / "outputs" / "reports"
 
-# 最终输出列顺序
+# 最终输出列顺序(审批表格:简洁版,去掉技术性列)
 OUTPUT_COLUMNS = [
-    "ad_id", "account_id", "ad_name", "audience_tier",
-    "create_time", "ad_age_days", "configured_status", "bid_amount",
+    # 核心标识(优先显示)
+    "account_id", "ad_id", "cost_7d_avg",
+    # 基础信息
+    "ad_name", "audience_tier", "create_time", "ad_age_days", "bid_amount",
     # 昨日表现
     "yesterday_cost", "yesterday_revenue", "yesterday_roi",
     # 7日汇总
-    "cost_7d_total", "cost_7d_avg", "revenue_7d_total",
-    # f_7日动态ROI 组成
-    "T0裂变系数_latest", "arpu_latest", "a_latest",
-    "b_7d_mean", "T0裂变系数_7d_mean", "e_factor",
+    "cost_7d_total", "revenue_7d_total",
+    # f_7日动态ROI(仅结果值,不显示组成)
     "f_7日动态ROI",
     # 30日上下文
     "cost_30d_total", "cost_30d_avg",
@@ -40,17 +40,16 @@ OUTPUT_COLUMNS = [
     # 决策
     "action", "dimension", "reason",
     "recommended_change_pct", "current_bid", "recommended_bid",
-    # 护栏 & 执行
-    "guardrail_status", "guardrail_reason", "final_action", "final_bid",
-    "execution_status",
     # 参考
     "f_7日动态ROI_mean_all",
 ]
 
 # 中文列名映射
 CN_COLUMNS = {
-    "ad_id": "广告ID",
+    "approval_date": "日期",
     "account_id": "账户ID",
+    "ad_id": "广告ID",
+    "cost_7d_avg": "广告消耗(7日日均/元)",
     "ad_name": "广告名称",
     "audience_tier": "人群包",
     "create_time": "创建时间",
@@ -61,7 +60,6 @@ CN_COLUMNS = {
     "yesterday_revenue": "昨日收入(元)",
     "yesterday_roi": "昨日ROI",
     "cost_7d_total": "7日总消耗(元)",
-    "cost_7d_avg": "7日日均消耗(元)",
     "revenue_7d_total": "7日总收入(元)",
     "T0裂变系数_latest": "T0裂变系数(最新)",
     "arpu_latest": "ARPU(最新)",
@@ -69,7 +67,8 @@ CN_COLUMNS = {
     "b_7d_mean": "b值(7日均值)",
     "T0裂变系数_7d_mean": "T0裂变系数(7日均值)",
     "e_factor": "e因子",
-    "f_7日动态ROI": "f_7日动态ROI",
+    "f_7日动态ROI": "7日均值动态ROI",
+    "动态ROI_7日均值": "7日均值动态ROI",
     "cost_30d_total": "30日总消耗(元)",
     "cost_30d_avg": "30日日均消耗(元)",
     "stable_spend_days_30d": "稳定消耗天数(30日)",
@@ -80,12 +79,18 @@ CN_COLUMNS = {
     "recommended_change_pct": "建议调幅(%)",
     "current_bid": "当前出价(元)",
     "recommended_bid": "建议出价(元)",
-    "guardrail_status": "护栏状态",
     "guardrail_reason": "护栏说明",
-    "final_action": "最终动作",
-    "final_bid": "最终出价(元)",
     "execution_status": "执行状态",
     "f_7日动态ROI_mean_all": "全体动态ROI均值",
+    "source": "数据来源",
+}
+
+# 动作中文映射
+ACTION_CN_MAP = {
+    "pause": "关停",
+    "bid_down": "降价",
+    "bid_up": "提价",
+    "hold": "保持",
 }
 
 
@@ -93,12 +98,16 @@ def _write_xlsx_with_format(df: pd.DataFrame, path: Path) -> None:
     """生成带条件格式的 XLSX 文件。"""
     try:
         import openpyxl
-        from openpyxl.styles import Font, PatternFill, Alignment
+        from openpyxl.styles import Font, Alignment, PatternFill
         from openpyxl.utils import get_column_letter
     except ImportError:
         logger.warning("openpyxl 未安装,跳过 XLSX 生成")
         return
 
+    # 动作中文化
+    if "action" in df.columns:
+        df["action"] = df["action"].map(ACTION_CN_MAP).fillna(df["action"])
+
     # 中文列名
     df_cn = df.rename(columns=CN_COLUMNS)
     df_cn.to_excel(path, index=False, engine="openpyxl")
@@ -106,53 +115,69 @@ def _write_xlsx_with_format(df: pd.DataFrame, path: Path) -> None:
     wb = openpyxl.load_workbook(path)
     ws = wb.active
 
-    # 表头样式
-    header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
-    header_font = Font(color="FFFFFF", bold=True, size=10)
+    # 表头样式(加粗 + 灰色背景)
+    header_font = Font(bold=True, size=10)
+    header_fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid")  # 灰色背景
+    center_alignment = Alignment(horizontal="center", vertical="center")
 
     for cell in ws[1]:
-        cell.fill = header_fill
         cell.font = header_font
-        cell.alignment = Alignment(horizontal="center")
+        cell.fill = header_fill
+        cell.alignment = center_alignment
+
+    # 所有数据单元格居中对齐
+    for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
+        for cell in row:
+            cell.alignment = center_alignment
 
-    # 条件格式:不同动作不同颜色
-    red_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
-    red_font = Font(color="9C0006")
-    yellow_fill = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
-    yellow_font = Font(color="9C6500")
-    green_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
-    green_font = Font(color="006100")
+    # 自动列宽(增加30%)
+    for col_idx in range(1, ws.max_column + 1):
+        col_letter = get_column_letter(col_idx)
+        max_len = max(
+            len(str(ws.cell(row=r, column=col_idx).value or ""))
+            for r in range(1, min(ws.max_row + 1, 50))
+        )
+        # 列宽增加30%:原公式 max_len + 4,现改为 (max_len + 4) * 1.3
+        ws.column_dimensions[col_letter].width = min((max_len + 4) * 1.3, 40)
 
+    # 决策动作列条件格式化
     action_col_idx = None
     for idx, cell in enumerate(ws[1], 1):
         if cell.value == "决策动作":
             action_col_idx = idx
+            # E1 单元格(决策动作表头)黄色高亮
+            yellow_header_fill = PatternFill(start_color="FFFF00", end_color="FFFF00", fill_type="solid")
+            cell.fill = yellow_header_fill
             break
 
     if action_col_idx:
-        for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
-            action_val = row[action_col_idx - 1].value
-            if action_val == "pause":
-                for cell in row:
-                    cell.fill = red_fill
-                    cell.font = red_font
-            elif action_val == "bid_down":
-                for cell in row:
-                    cell.fill = yellow_fill
-                    cell.font = yellow_font
-            elif action_val == "bid_up":
-                for cell in row:
-                    cell.fill = green_fill
-                    cell.font = green_font
-
-    # 自动列宽
-    for col_idx in range(1, ws.max_column + 1):
-        col_letter = get_column_letter(col_idx)
-        max_len = max(
-            len(str(ws.cell(row=r, column=col_idx).value or ""))
-            for r in range(1, min(ws.max_row + 1, 50))
-        )
-        ws.column_dimensions[col_letter].width = min(max_len + 4, 30)
+        # 颜色定义
+        yellow_fill = PatternFill(start_color="FFFF00", end_color="FFFF00", fill_type="solid")  # 黄色(整列默认)
+        green_fill = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid")   # 浅绿色
+        red_fill = PatternFill(start_color="FF6B6B", end_color="FF6B6B", fill_type="solid")     # 红色
+        orange_fill = PatternFill(start_color="FFB84D", end_color="FFB84D", fill_type="solid")  # 橘黄色
+
+        # 为决策动作列的所有数据单元格设置颜色
+        for row_idx in range(2, ws.max_row + 1):
+            cell = ws.cell(row=row_idx, column=action_col_idx)
+            value = str(cell.value).strip() if cell.value else ""
+
+            # 根据值设置颜色
+            if value == "保持":
+                cell.fill = green_fill
+            elif value == "关停":
+                cell.fill = red_fill
+            elif value == "降价":
+                cell.fill = orange_fill
+            else:
+                # 其他值(如"提价")使用黄色
+                cell.fill = yellow_fill
+
+    # 启用自动筛选(首行)
+    ws.auto_filter.ref = ws.dimensions
+
+    # 冻结窗格:锁定第一行和前五列(冻结到F2单元格)
+    ws.freeze_panes = "F2"
 
     wb.save(path)
     logger.info("XLSX 已生成: %s", path)
@@ -192,21 +217,22 @@ async def generate_report(
         cols = [c for c in OUTPUT_COLUMNS if c in df.columns]
         df_out = df[cols].copy()
 
-        # 排序:关停在前,按消耗降序
+        # 排序:有消耗的在前,无消耗的在后,同组内按消耗降序
         sort_cols = []
         ascending_flags = []
-        if "action" in df_out.columns:
-            df_out["_sort_action"] = (df_out["action"] == "pause").astype(int) * -1
-            sort_cols.append("_sort_action")
-            ascending_flags.append(True)
+        if "cost_7d_avg" in df_out.columns:
+            # 无消耗(cost_7d_avg=0)放最后
+            df_out["_has_spend"] = (df_out["cost_7d_avg"] > 0).astype(int)
+            sort_cols.append("_has_spend")
+            ascending_flags.append(False)  # 1在前,0在后
         if "cost_7d_total" in df_out.columns:
             sort_cols.append("cost_7d_total")
             ascending_flags.append(False)
 
         if sort_cols:
             df_out = df_out.sort_values(sort_cols, ascending=ascending_flags)
-            if "_sort_action" in df_out.columns:
-                df_out.drop(columns=["_sort_action"], inplace=True)
+            if "_has_spend" in df_out.columns:
+                df_out.drop(columns=["_has_spend"], inplace=True)
 
         # CSV
         csv_path = _REPORTS_DIR / f"decision_{end_date}.csv"