Kaynağa Gözat

feat(approval): 部分批准型一轮制协议 + 飞书审批指标列优化

- prompt: 部分批准型改为一轮制(execute_decisions(filter_ad_ids=...) + 简短回执),禁止重审循环;补全部拒绝场景强制收尾文案
- execution_engine: execute_decisions 新增 filter_ad_ids 参数,实现部分批准过滤
- im_approval: 锚点式回复解析器(_parse_anchored_decision),支持 5 种部分批准/拒绝混合句;移除 "ok" 关键字
- roi_calculator/ad_decision/report_generator: 审批表格新增代理商列 + 当日裂变 3 个指标(open/fission/T0率),冻结前 6 列
- 新增 test_approval_replay.py: 复用已验证决策 CSV 反复发审批,便于交互式测试 5 种回复场景

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
刘立冬 1 ay önce
ebeveyn
işleme
cccc8edd7f

+ 38 - 17
examples/auto_put_ad_mini/prompts/system.prompt

@@ -202,19 +202,37 @@ Skill 提供「判断原则」,工具提供「数据」,你负责综合判
 | **方向型** | "整体太激进/太保守"(风险偏好调整)| 根据反馈方向适度调整决策的保守/激进程度,**重算候选集**(不是只改已选的) |
 | **质疑型** | "为什么 pause 这条?"(要更多依据)| 调用 `query_ad_detail`,组织三段式:① 同类对比 ② 历史调价 ③ ROI 置信度 |
 | **策略型** | "降幅改小一点"(要调参数边界)| 调 `BID_DOWN_MAX_PCT` 等参数,用新边界**重新生成** pct(不是裁剪已有) |
-| **部分批准型** | "只批准降价的"(圈定子集立即执行,其余下一轮再谈)| 见下方协议 |
+| **部分批准型** | "通过广告 X" / "只批准降价的"(圈定子集**立即执行**,未明确通过的=默认拒绝、本轮作废,**无需重审**)| 见下方协议 |
 | **混合型** | "12345 不要动,其余降幅改小" | 拆成独立子问题分别处理 |
 
-### 部分批准型协议(强制顺序,每步都要做)
+### 部分批准型协议(一轮制,无需重审)
 
-**核心原则**:任何修改后都必须**重新审批**,等待明确的"同意"/"通过"才能执行
+**核心原则**:您圈定的子集 `S` = 显式批准,**直接执行**;其余未明确通过的 ad_id = **默认拒绝、本轮作废**,不再发审批表征求二次同意
 
-1. **显式说出**您圈定的子集 `S`(例:"移除 action='bid_down' 3 条,保留 pause 15 条")
-2. 调用 `modify_decisions` 按您的要求修改决策
-3. 调用 `validate_decisions` 重新验证
-4. **调用 `send_approval_request` 重新发送审批表**(只包含修改后的决策)
-5. **等待您明确回复"同意"/"通过"** → 再调用 `execute_decisions`
-6. ❌ 严禁修改后直接执行;❌ 严禁跳过重新审批环节
+1. 解析回复得到 `approved_ids`(显式批准) 和 `rejected_ids`(其余默认拒绝)
+2. 调用 `execute_decisions(filter_ad_ids=approved_ids)` 仅执行批准子集
+3. 调用 `send_feishu_text_message` 发简要回执:
+   ```
+   ✅ 已执行 N 条:广告 12345678901, 22345678902
+   未执行 M 条(默认拒绝)
+   ```
+4. ❌ **严禁**回到 `modify_decisions → send_approval_request` 重审循环——您已经在回复里明确了边界,二次审批是噪音
+5. ❌ **严禁**追加询问"为什么拒绝其他的""要不要重新评估"——本轮就此结束,等下一轮日跑
+
+> **例外**:如果您的回复属于**策略型**("降幅改小一点")或**方向型**("整体太激进")——此时您没有圈定具体 ad_id,而是改边界——才走 `modify_decisions → validate → send_approval_request` 重审路径。
+
+### 全部拒绝场景(强制收尾)
+
+收到"全部拒绝"/"rejected" 后,必须立即调用 `send_feishu_text_message` 发送一条简要回执:
+
+```
+✅ 已收到您的回复:全部拒绝(N 条决策)
+本轮决策已作废,未执行任何调整。
+```
+
+❌ 不要追加询问反馈方向、不要列原因清单、不要邀请进一步沟通——保持简洁,等待您下一步明确指令。
+❌ 严禁仅在内部产生思考文本就结束 trace — 您看不到 Agent 的内部思考,飞书没消息 = 没回复。
+❌ 严禁发完整报告或重发审批表。
 
 ### 审批通过后的执行流程(简化版)
 
@@ -232,9 +250,11 @@ Skill 提供「判断原则」,工具提供「数据」,你负责综合判
 3. ❌ **不要调用** `generate_report` 和 `import_to_feishu` — 审批表已足够,无需再发完整报告表格
 4. ❌ **不要重复发送审批表** — 审批流程已结束
 
-### 重审时呈现协商过程
+### 重审时呈现协商过程(仅适用于策略型 / 方向型)
+
+> ⚠️ 部分批准型**不走重审**(见上节一轮制协议)。本节仅适用于策略型("降幅改小一点")或方向型("整体太激进")反馈——这两类才需要 `modify_decisions → validate → send_approval_request`。
 
-每次 `modify_decisions → validate → send_approval_request` 重审,回复要包含:
+每次重审,回复要包含:
 - 采纳了您哪几点反馈(一句一条)
 - 改动的决策列表(前 → 后,附原因)
 - 仍想保留的争议项(请您给额外理由)
@@ -247,12 +267,12 @@ Skill 提供「判断原则」,工具提供「数据」,你负责综合判
 
 ### 工具链映射
 
-- `modify_decisions(modifications=[...])`:应用事实型/策略型/部分批准型的具体改动
-- `validate_decisions()`:新决策走一遍护栏,再次检查冷启动/频率/边界
-- `send_approval_request(wait_for_reply=True)`:重新发审批,阻塞等您下一轮回复(**仅用于真需要重新审批的场景**,不用于"同步已执行")
+- `modify_decisions(modifications=[...])`:应用**事实型/策略型/方向型**的具体改动(⚠️ **部分批准型不用此工具**——直接 `execute_decisions(filter_ad_ids=...)`)
+- `validate_decisions()`:新决策走一遍护栏,再次检查冷启动/频率/边界(仅策略型/方向型重审时使用)
+- `send_approval_request(wait_for_reply=True)`:重新发审批,阻塞等您下一轮回复(**仅用于策略型/方向型重审**,**严禁**用于部分批准型;不用于"同步已执行")
 - `query_ad_detail(ad_id)`:质疑型反馈时回取单条详情
-- `send_feishu_text_message(text=..., to_operator=True, to_project_chat=True)`:**执行后同步工具** — 发送 diff 表 / 质疑回应 / "建议本轮暂停"提议等纯文本消息。**部分批准型场景必须调用此工具**。text 字段必须遵循**对话基调**——第二人称「您」、无【】公文头、有主动提醒
-- `execute_decisions(filter_actions=[...])`:如果支持 `filter_actions` 参数则传入子集;若不支持先用 `modify_decisions` 过滤
+- `send_feishu_text_message(text=..., to_operator=True, to_project_chat=True)`:**执行后同步工具** — 发送 diff 表 / 质疑回应 / "建议本轮暂停"提议等纯文本消息。**部分批准型 / 全部拒绝场景必须调用此工具**收尾。text 字段必须遵循**对话基调**——第二人称「您」、无【】公文头、有主动提醒
+- `execute_decisions(filter_ad_ids=[...])`:传入显式批准的 ad_id 列表,仅执行该子集(部分批准型主路径)
 
 ### 关键禁令
 
@@ -261,7 +281,8 @@ Skill 提供「判断原则」,工具提供「数据」,你负责综合判
 - ❌ 不要在没看 `query_ad_detail` 详情时就回答质疑型问题
 - ❌ 不要假设无回复 = 默认通过",无回复 = 默认拒绝",最多等待一小时,超时等于所有决策作废
 - ❌ 不要未经您同意就自行调大 `BID_DOWN_MAX_PCT` 等阈值;策略型反馈的参数改动也要在下一轮审批中**显式告知**
-- ❌ **部分批准场景严禁只发表格不发文字**:`import_to_feishu` 只发链接卡片,不等于同步;必须紧跟一条 `send_feishu_text_message` 携带 diff 表和执行摘要
+- ❌ **部分批准场景严禁回到重审循环**:圈定子集即终局,直接 `execute_decisions(filter_ad_ids=...)` + `send_feishu_text_message` 收尾;严禁再走 `modify_decisions → send_approval_request`
+- ❌ **部分批准场景严禁只发表格不发文字**:`import_to_feishu` 只发链接卡片,不等于同步;必须紧跟一条 `send_feishu_text_message` 携带执行摘要
 - ❌ **部分批准场景严禁重发全量报告**:您已圈定子集,再发未变更的全量表格等于浪费您的注意力并制造歧义
 
 # 第八部分:边界约束(安全红线)

+ 124 - 0
examples/auto_put_ad_mini/test_approval_replay.py

@@ -0,0 +1,124 @@
+"""
+审批回放测试 — 复用 validated_decisions_20260507.csv 反复发审批。
+
+每次启动跑一轮:发审批表 → 阻塞等您在飞书回复 → Agent 根据回复选择后续工具链 → 发回执。
+您可在飞书群里换不同回复(通过/拒绝/通过广告 X/降幅改小一点/为什么 pause 这条 X)测试 Agent 的动作和回执文案。
+
+用法:
+    cd /Users/liulidong/project/agent/Agent
+    .venv/bin/python3 examples/auto_put_ad_mini/test_approval_replay.py [validated_csv_path]
+"""
+
+import asyncio
+import os
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from agent.core.runner import AgentRunner
+from agent.trace import FileSystemTraceStore
+from agent.llm import create_openrouter_llm_call
+from agent.utils import setup_logging
+
+from examples.auto_put_ad_mini.config import (
+    MAIN_CONFIG, SKILLS_DIR, TRACE_STORE_PATH, LOG_LEVEL, LOG_FILE,
+)
+
+# 触发 @tool 注册
+from examples.auto_put_ad_mini.tools.data_query import fetch_creative_data, merge_creative_data  # noqa
+from examples.auto_put_ad_mini.tools.roi_calculator import calculate_roi_metrics  # noqa
+from examples.auto_put_ad_mini.tools.portfolio_metrics import calculate_portfolio_summary  # noqa
+from examples.auto_put_ad_mini.tools.ad_decision import (  # noqa
+    get_ads_for_review, apply_decisions, query_ad_detail, modify_decisions,
+)
+from examples.auto_put_ad_mini.tools.report_generator import generate_report  # noqa
+from examples.auto_put_ad_mini.tools.guardrails import validate_decisions  # noqa
+from examples.auto_put_ad_mini.tools.execution_engine import execute_decisions, check_execution_feedback  # noqa
+from examples.auto_put_ad_mini.tools.im_approval import (  # noqa
+    send_approval_request, check_approval_status, send_feishu_text_message,
+)
+
+
+def _load_system_prompt(base_dir: Path) -> str:
+    p = base_dir / "prompts" / "system.prompt"
+    return p.read_text(encoding="utf-8") if p.exists() else ""
+
+
+async def main():
+    setup_logging(level=LOG_LEVEL, file=LOG_FILE)
+    base_dir = Path(__file__).parent
+
+    validated_csv = sys.argv[1] if len(sys.argv) > 1 else str(
+        base_dir / "outputs" / "reports" / "validated_decisions_20260507.csv"
+    )
+    if not Path(validated_csv).exists():
+        print(f"❌ 找不到 validated CSV: {validated_csv}")
+        sys.exit(1)
+
+    print(f"▶ 使用决策 CSV: {validated_csv}")
+    print("▶ 启动 Agent,将在飞书发审批表并阻塞等待回复...")
+    print("▶ 在飞书群里 @机器人 + 回复任意以下内容测试:")
+    print("    - 通过 / 同意 / 批准")
+    print("    - 拒绝 / 驳回")
+    print("    - 通过广告 12345678901")
+    print("    - 通过 12345678901, 拒绝 22345678901")
+    print("    - 降幅改小一点")
+    print("    - 为什么 pause 这条 12345678901?")
+    print("─" * 60)
+
+    store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
+    runner = AgentRunner(
+        trace_store=store,
+        llm_call=create_openrouter_llm_call(model=MAIN_CONFIG.model),
+        skills_dir=SKILLS_DIR if Path(SKILLS_DIR).exists() else None,
+        logger_name="agents.auto_put_ad_mini.replay",
+    )
+
+    config = MAIN_CONFIG
+    system_prompt = _load_system_prompt(base_dir)
+    if system_prompt:
+        config.system_prompt = system_prompt
+
+    user_msg = (
+        f"复用已验证决策 CSV `{validated_csv}` 直接走审批协商流程:\n"
+        "1. 调用 `send_approval_request(validated_csv='{path}', wait_for_reply=True)` 发审批表并阻塞等回复\n"
+        "2. 收到回复后,严格按 system prompt 的反馈类型识别表选择后续工具链:\n"
+        "   - 全部通过 → execute_decisions + send_feishu_text_message 摘要\n"
+        "   - 全部拒绝 → 仅 send_feishu_text_message 简短回执,不执行\n"
+        "   - 部分批准型 → execute_decisions(filter_ad_ids=approved_ids) + send_feishu_text_message 回执,**严禁重审**\n"
+        "   - 策略型/方向型 → modify_decisions → validate_decisions → send_approval_request 重审\n"
+        "   - 质疑型 → query_ad_detail + send_feishu_text_message 解释\n"
+        "3. 全程使用第二人称「您」,禁用【】公文头\n"
+    ).format(path=validated_csv)
+
+    messages = [
+        {"role": "system", "content": system_prompt},
+        {"role": "user", "content": user_msg},
+    ]
+
+    async for item in runner.run(messages=messages, config=config):
+        # 流式打印 Agent 每一步动作
+        cls_name = type(item).__name__
+        if hasattr(item, "tool_name") and getattr(item, "tool_name", None):
+            print(f"  → tool: {item.tool_name}")
+        elif hasattr(item, "role") and getattr(item, "role", None) == "assistant":
+            content = getattr(item, "content", "")
+            if isinstance(content, str) and content.strip():
+                snippet = content[:500]
+                print(f"\n[assistant] {snippet}")
+            elif isinstance(content, list):
+                for blk in content:
+                    if isinstance(blk, dict) and blk.get("type") == "text":
+                        print(f"\n[assistant text] {str(blk.get('text', ''))[:500]}")
+        else:
+            print(f"  · {cls_name}")
+    print("─" * 60)
+    print("▶ Agent 终止")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 4 - 2
examples/auto_put_ad_mini/tools/ad_decision.py

@@ -1330,11 +1330,13 @@ async def apply_decisions(
             df_metrics_full["ad_id"] = pd.to_numeric(df_metrics_full["ad_id"], errors="coerce").astype("Int64")
             # 选择需要合并的列(OUTPUT_COLUMNS中定义的所有列)
             merge_cols = [
-                "ad_id", "account_id", "ad_name", "audience_tier", "create_time", "ad_age_days",
+                "ad_id", "account_id", "agent_name", "ad_name", "audience_tier", "create_time", "ad_age_days",
                 "bid_amount", "yesterday_cost", "yesterday_revenue", "yesterday_roi",
                 "cost_7d_total", "cost_7d_avg", "revenue_7d_total",
                 "动态ROI", "动态ROI_7日均值", "cost_30d_total", "cost_30d_avg",
-                "stable_spend_days_30d", "creative_count", "roi_valid_days"
+                "stable_spend_days_30d", "creative_count", "roi_valid_days",
+                # 飞书审批表当日裂变指标(取有数据的最近一天)
+                "open_count_latest", "fission_count_latest", "T0裂变系数_latest",
             ]
             # 只保留存在的列
             merge_cols = [c for c in merge_cols if c in df_metrics_full.columns]

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

@@ -430,11 +430,12 @@ def _classify_tier(row: pd.Series) -> int:
 # ═══════════════════════════════════════════
 
 
-@tool(description="执行已验证的决策:分级自治 → API调用 → 审计日志")
+@tool(description="执行已验证的决策:分级自治 → API调用 → 审计日志(部分批准型场景请传 filter_ad_ids)")
 async def execute_decisions(
     ctx: ToolContext = None,
     validated_csv: str = "",
     approval_mode: str = "tiered",
+    filter_ad_ids: Optional[List[int]] = None,
 ) -> ToolResult:
     """
     执行已通过护栏验证的决策。
@@ -450,6 +451,7 @@ async def execute_decisions(
     Args:
         validated_csv: 护栏验证后的 CSV 路径
         approval_mode: "auto"=全部自动执行, "tiered"=分级, "manual"=全部需审批
+        filter_ad_ids: 仅执行该 ad_id 列表中的决策(部分批准型协议主路径);为 None 时执行全部
     """
     try:
         if not EXECUTION_ENABLED:
@@ -480,6 +482,29 @@ async def execute_decisions(
         df_exec = df[df["guardrail_status"].isin(["approved", "modified"])].copy()
         df_exec = df_exec[df_exec["final_action"] != "hold"]
 
+        # ===== 部分批准过滤(filter_ad_ids 优先)=====
+        # 部分批准型协议:只执行 approved_ids 子集,其余视为默认拒绝
+        if filter_ad_ids is not None:
+            try:
+                allow_set = {int(x) for x in filter_ad_ids}
+            except (TypeError, ValueError) as _e:
+                return ToolResult(
+                    title="filter_ad_ids 格式错误",
+                    output=f"必须是 ad_id 整数列表;收到: {filter_ad_ids}({_e})",
+                )
+            before = len(df_exec)
+            df_exec = df_exec[df_exec["ad_id"].astype(int).isin(allow_set)].copy()
+            logger.info(
+                "部分批准过滤:filter_ad_ids=%d 个,决策从 %d 条筛到 %d 条",
+                len(allow_set), before, len(df_exec),
+            )
+            if df_exec.empty:
+                return ToolResult(
+                    title="过滤后无可执行决策",
+                    output=f"filter_ad_ids={sorted(allow_set)} 与决策 CSV 中的 ad_id 无交集,"
+                           f"或这些 ad_id 都被护栏拦截 / 都是 hold",
+                )
+
         if df_exec.empty:
             return ToolResult(
                 title="无需执行的操作",

+ 99 - 37
examples/auto_put_ad_mini/tools/im_approval.py

@@ -15,6 +15,7 @@ IM 审批流 — auto_put_ad_mini(飞书直连版)
 import asyncio
 import json
 import logging
+import re
 import sys
 import time
 import uuid
@@ -110,12 +111,14 @@ _feishu = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET)
 # 审批表精选列(运营审阅所需的关键指标)
 # 列顺序:日期 → 账户ID → 广告ID → 广告消耗 → 决策动作 → 其他关键信息(简洁版)
 APPROVAL_COLUMNS = [
-    # 核心标识(前5列,含决策动作
-    "approval_date", "account_id", "ad_id", "cost_7d_avg", "action",
+    # 核心标识(前6列:账户后接代理商
+    "approval_date", "account_id", "agent_name", "ad_id", "cost_7d_avg", "action",
     # 基础信息
     "ad_name", "audience_tier", "ad_age_days", "bid_amount",
     # 关键指标(使用实际列名)
     "动态ROI_7日均值", "cost_7d_total", "revenue_7d_total",
+    # 当日裂变指标(取有数据的最近一天)
+    "open_count_latest", "fission_count_latest", "T0裂变系数_latest",
     # 决策详情
     "dimension", "reason",
     "recommended_change_pct",
@@ -409,6 +412,93 @@ def _parse_approval_reply(content: str, all_ad_ids: List[int]) -> Dict:
     }
 
 
+# ═══════════════════════════════════════════
+# 审批关键词 + 锚点法语义解析
+# ═══════════════════════════════════════════
+# 子串匹配(非正则): 移除"ok"避免英文随口语误判("ok吗"/"你说的ok")
+APPROVE_KEYWORDS = ["同意", "批准", "通过", "执行", "确认"]
+REJECT_KEYWORDS = ["拒绝", "驳回", "不同意", "不批准"]
+# 匹配优先级: 先匹配长串(不同意/不批准/驳回)再匹配单字"通过/拒绝",避免"不同意"被截成"同意"
+_ACTION_PATTERN = re.compile(r"(不同意|不批准|同意|批准|通过|执行|确认|驳回|拒绝)")
+_ID_PATTERN = re.compile(r"\b(\d{11})\b")  # 腾讯广告 ID 11 位
+
+
+def _parse_anchored_decision(text: str, tier2_ad_ids: List[int]) -> Optional[Dict]:
+    """锚点法解析审批回复。
+
+    规则(用户确认):
+      - 显式批准的 ID 才批准, 其他默认拒绝
+      - 仅"通过"(无 ID) → 全部批准
+      - 仅"拒绝"(无 ID) → 全部拒绝
+      - "通过 + 拒绝 X" → 除 X 外全部批准
+      - "通过 A, 拒绝 B" → A 批准, B/其他默认拒绝
+      - "拒绝 A" → 全部拒绝(无 approve 信号)
+
+    返回 None 表示无法解析(交回 Agent 处理),否则返回包含 status/approved_ids/rejected_ids 的字典。
+    """
+    matches = list(_ACTION_PATTERN.finditer(text))
+    if not matches:
+        return None
+
+    approve_ids: set = set()
+    reject_ids: set = set()
+    approve_global = False
+    reject_global = False
+    reject_verbs = {"不同意", "不批准", "拒绝", "驳回"}
+
+    for i, m in enumerate(matches):
+        verb = m.group(1)
+        is_reject = verb in reject_verbs
+        scope_start = m.end()
+        scope_end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
+        scope = text[scope_start:scope_end]
+        scope_ids = []
+        for id_m in _ID_PATTERN.finditer(scope):
+            ad_id = int(id_m.group(1))
+            if ad_id in tier2_ad_ids:
+                scope_ids.append(ad_id)
+        if scope_ids:
+            if is_reject:
+                reject_ids.update(scope_ids)
+            else:
+                approve_ids.update(scope_ids)
+        else:
+            if is_reject:
+                reject_global = True
+            else:
+                approve_global = True
+
+    all_set = set(tier2_ad_ids)
+
+    # case 1: 仅全局 approve(无任何 reject 信号、无具体 ID 锚定)
+    if approve_global and not approve_ids and not reject_ids and not reject_global:
+        return {"status": "approved", "approved_ids": list(tier2_ad_ids), "rejected_ids": []}
+
+    # case 2: 仅全局 reject
+    if reject_global and not reject_ids and not approve_ids and not approve_global:
+        return {"status": "rejected", "approved_ids": [], "rejected_ids": list(tier2_ad_ids)}
+
+    # case 3: 全局 approve + 显式 reject(如"通过, 拒绝 X")
+    if approve_global and reject_ids and not approve_ids:
+        approved = sorted(all_set - reject_ids)
+        rejected = sorted(reject_ids & all_set)
+        return {"status": "partial_approved", "approved_ids": approved, "rejected_ids": rejected}
+
+    # case 4: 显式 approve_ids → 仅这些批准, 其他默认拒绝(用户规则)
+    if approve_ids:
+        approved = sorted(approve_ids & all_set)
+        rejected = sorted(all_set - approve_ids)
+        if rejected:
+            return {"status": "partial_approved", "approved_ids": approved, "rejected_ids": rejected}
+        return {"status": "approved", "approved_ids": approved, "rejected_ids": []}
+
+    # case 5: 仅显式 reject_ids, 无 approve 信号 → 全部默认拒绝
+    if reject_ids:
+        return {"status": "rejected", "approved_ids": [], "rejected_ids": list(tier2_ad_ids)}
+
+    return None
+
+
 # ═══════════════════════════════════════════
 # 工具:发送审批请求(飞书版)
 # ═══════════════════════════════════════════
@@ -837,42 +927,14 @@ async def send_approval_request(
                                 })
                                 logger.info("HTTP 轮询收到运营回复: %s", text[:200])
 
-                                # 解析审批结果
-                                text_lower = text.lower()
-                                is_approved = any(kw in text_lower for kw in ["同意", "批准", "通过", "执行", "ok", "确认"])
-                                is_rejected = any(kw in text_lower for kw in ["拒绝", "驳回", "不同意", "不批准"])
-
-                                # 解析具体批准/拒绝的广告ID(如果有)
-                                import re
-                                mentioned_ids = []
-                                for match in re.finditer(r'\b(\d{11})\b', text):  # 腾讯广告ID通常11位
-                                    ad_id = int(match.group(1))
-                                    if ad_id in tier2_ad_ids:
-                                        mentioned_ids.append(ad_id)
-
-                                # 确定最终状态和ID列表
-                                if is_approved and not is_rejected:
-                                    # 全部批准或部分批准
-                                    status = "approved"
-                                    approved_ids = mentioned_ids if mentioned_ids else tier2_ad_ids
-                                    rejected_ids = []
-                                elif is_rejected and not is_approved:
-                                    # 全部拒绝
-                                    status = "rejected"
-                                    approved_ids = []
-                                    rejected_ids = tier2_ad_ids
-                                elif mentioned_ids:
-                                    # 提到具体ID:只批准/拒绝这些ID
-                                    if is_approved:
-                                        status = "partial_approved"
-                                        approved_ids = mentioned_ids
-                                        rejected_ids = [x for x in tier2_ad_ids if x not in mentioned_ids]
-                                    else:
-                                        status = "partial_rejected"
-                                        approved_ids = [x for x in tier2_ad_ids if x not in mentioned_ids]
-                                        rejected_ids = mentioned_ids
+                                # 锚点法解析(显式批准的才批准,其他默认拒绝)
+                                anchored = _parse_anchored_decision(text, tier2_ad_ids)
+                                if anchored is not None:
+                                    status = anchored["status"]
+                                    approved_ids = anchored["approved_ids"]
+                                    rejected_ids = anchored["rejected_ids"]
                                 else:
-                                    # 无法判断,需要Agent处理
+                                    # 无法解析,交回 Agent 处理
                                     status = "unclear"
                                     approved_ids = []
                                     rejected_ids = []

+ 10 - 5
examples/auto_put_ad_mini/tools/report_generator.py

@@ -25,12 +25,14 @@ _REPORTS_DIR = _MINI_DIR / "outputs" / "reports"
 # 最终输出列顺序(审批表格:简洁版,去掉技术性列)
 # 与 im_approval.py 的 APPROVAL_COLUMNS 保持一致(15列精简版)
 OUTPUT_COLUMNS = [
-    # 核心标识(前5列,含决策动作
-    "approval_date", "account_id", "ad_id", "cost_7d_avg", "action",
+    # 核心标识(前6列,账户后接代理商
+    "approval_date", "account_id", "agent_name", "ad_id", "cost_7d_avg", "action",
     # 基础信息
     "ad_name", "audience_tier", "ad_age_days", "bid_amount",
     # 关键指标
     "动态ROI_7日均值", "cost_7d_total", "revenue_7d_total",
+    # 当日裂变指标(取有数据的最近一天)
+    "open_count_latest", "fission_count_latest", "T0裂变系数_latest",
     # 决策详情
     "dimension", "reason",
     "recommended_change_pct",
@@ -42,6 +44,7 @@ OUTPUT_COLUMNS = [
 CN_COLUMNS = {
     "approval_date": "日期",
     "account_id": "账户ID",
+    "agent_name": "代理商",
     "ad_id": "广告ID",
     "cost_7d_avg": "广告消耗(7日日均/元)",
     "ad_name": "广告名称",
@@ -55,7 +58,9 @@ CN_COLUMNS = {
     "yesterday_roi": "昨日ROI",
     "cost_7d_total": "7日总消耗(元)",
     "revenue_7d_total": "7日总收入(元)",
-    "T0裂变系数_latest": "T0裂变系数(最新)",
+    "T0裂变系数_latest": "当日T0裂变率",
+    "open_count_latest": "当日首层人数",
+    "fission_count_latest": "当日裂变层人数",
     "arpu_latest": "ARPU(最新)",
     "a_latest": "a值(最新)",
     "b_7d_mean": "b值(7日均值)",
@@ -174,8 +179,8 @@ def _write_xlsx_with_format(df: pd.DataFrame, path: Path) -> None:
     # 启用自动筛选(首行)
     ws.auto_filter.ref = ws.dimensions
 
-    # 冻结窗格:锁定第一行和前五列(冻结到F2单元格)
-    ws.freeze_panes = "F2"
+    # 冻结窗格:锁定第一行和前六列(approval_date/account_id/agent_name/ad_id/cost_7d_avg/action)
+    ws.freeze_panes = "G2"
 
     wb.save(path)
     logger.info("XLSX 已生成: %s", path)

+ 28 - 1
examples/auto_put_ad_mini/tools/roi_calculator.py

@@ -125,6 +125,7 @@ def _aggregate_creative_to_ad(df: pd.DataFrame) -> pd.DataFrame:
     agg_dict = {
         # 广告属性(取第一个值)
         "account_id": "first",
+        "agent_name": "first",
         "ad_name": "first",
         "create_time": "first",
         "configured_status": "first",
@@ -476,7 +477,7 @@ async def calculate_roi_metrics(
 
         # Step 6: 合并所有指标(取最新一天的广告属性)
         latest_cols = [
-            "ad_id", "account_id", "ad_name", "create_time",
+            "ad_id", "account_id", "agent_name", "ad_name", "create_time",
             "configured_status", "bid_amount", "creative_count",
             "package_name",  # 人群包名称(如 R50*泛知识*生活科普)
             "yesterday_roi", "yesterday_cost",  # 昨日ROI+昨日消耗(投手经验2.4关停门槛)
@@ -485,6 +486,32 @@ async def calculate_roi_metrics(
         latest_cols = [c for c in latest_cols if c in ad_df.columns]
         latest_ad = ad_df[ad_df["date"] == end_date_str][latest_cols].copy()
 
+        # ===== 新增:当日裂变指标(取每个广告"有数据的最近一天",即 open_count > 0)=====
+        # 用途:飞书审批表展示当日首层人数 / 当日裂变层人数 / 当日 T0 裂变率
+        # 选择"最近一天"而非 end_date_str:避免昨日没跑量的广告该列为空
+        # T0 裂变系数在此处独立计算(不复用 _calculate_f7_dynamic_roi 的 T0裂变系数,
+        # 后者在日消耗 < 100 元时置 NaN,会丢失低消耗日的展示数据)
+        if {"ad_id", "date", "open_count"}.issubset(ad_df.columns):
+            _ad_with_traffic = ad_df[ad_df["open_count"].fillna(0) > 0].copy()
+            if not _ad_with_traffic.empty:
+                _ad_with_traffic = _ad_with_traffic.sort_values(["ad_id", "date"])
+                _latest_traffic = _ad_with_traffic.groupby("ad_id", as_index=False).tail(1).copy()
+                _latest_traffic["T0裂变系数_latest"] = np.where(
+                    _latest_traffic["open_count"].fillna(0) > 0,
+                    _latest_traffic.get("fission0_count", 0).fillna(0) / _latest_traffic["open_count"],
+                    np.nan,
+                )
+                _latest_cols = ["ad_id", "T0裂变系数_latest"]
+                _rename = {}
+                if "open_count" in _latest_traffic.columns:
+                    _latest_cols.append("open_count")
+                    _rename["open_count"] = "open_count_latest"
+                if "fission_count" in _latest_traffic.columns:
+                    _latest_cols.append("fission_count")
+                    _rename["fission_count"] = "fission_count_latest"
+                _latest_traffic = _latest_traffic[_latest_cols].rename(columns=_rename)
+                latest_ad = latest_ad.merge(_latest_traffic, on="ad_id", how="left")
+
         result_df = latest_ad.merge(summary_7d, on="ad_id", how="left")
         result_df = result_df.merge(summary_30d, on="ad_id", how="left")