|
|
@@ -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)
|
|
|
|