|
|
@@ -201,117 +201,158 @@ def _format_project_notification_message(df_tier2: pd.DataFrame, df_tier1: pd.Da
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
+def _score_top_decisions(df_tier2: pd.DataFrame, top_n: int = 5) -> pd.DataFrame:
|
|
|
+ """对 Tier 2/3 决策按"影响力"评分,选出 top N 展示给运营。
|
|
|
+
|
|
|
+ 评分公式:
|
|
|
+ score = normalize(cost_7d_avg) * 0.6
|
|
|
+ + action_weight * 0.3
|
|
|
+ + confidence_weight * 0.1
|
|
|
+
|
|
|
+ 权重理由:
|
|
|
+ - 消耗权重最高(0.6):决策影响的金额越大越该优先看
|
|
|
+ - 动作权重其次(0.3):pause/bid_down 是风险型操作,优先展示
|
|
|
+ - 置信度权重最低(0.1):仅作为 tiebreaker
|
|
|
+ """
|
|
|
+ if df_tier2.empty:
|
|
|
+ return df_tier2
|
|
|
+
|
|
|
+ df = df_tier2.copy()
|
|
|
+
|
|
|
+ # 消耗归一化(0-1)
|
|
|
+ cost = pd.to_numeric(df.get("cost_7d_avg", 0), errors="coerce").fillna(0)
|
|
|
+ cost_max = cost.max()
|
|
|
+ cost_norm = cost / cost_max if cost_max > 0 else cost * 0
|
|
|
+
|
|
|
+ # 动作权重:pause/bid_down = 1.0(风险型),bid_up/scale_up = 0.7(机会型),其他 = 0.5
|
|
|
+ def _action_weight(a: str) -> float:
|
|
|
+ a = str(a).strip()
|
|
|
+ if a in ("pause", "bid_down"):
|
|
|
+ return 1.0
|
|
|
+ if a in ("bid_up", "scale_up"):
|
|
|
+ return 0.7
|
|
|
+ return 0.5
|
|
|
+
|
|
|
+ action_col = df.get("final_action", df.get("action", ""))
|
|
|
+ action_weight = action_col.apply(_action_weight)
|
|
|
+
|
|
|
+ # 置信度权重
|
|
|
+ def _conf_weight(c: str) -> float:
|
|
|
+ c = str(c).strip().lower()
|
|
|
+ if c == "high":
|
|
|
+ return 1.0
|
|
|
+ if c == "medium":
|
|
|
+ return 0.6
|
|
|
+ return 0.3
|
|
|
+
|
|
|
+ conf_weight = df.get("confidence", "medium").apply(_conf_weight) if "confidence" in df.columns else 0.6
|
|
|
+
|
|
|
+ df["_top_score"] = cost_norm * 0.6 + action_weight * 0.3 + conf_weight * 0.1
|
|
|
+ df = df.sort_values("_top_score", ascending=False)
|
|
|
+
|
|
|
+ # 如果总量 <= top_n,全部返回
|
|
|
+ if len(df) <= top_n:
|
|
|
+ return df.drop(columns=["_top_score"])
|
|
|
+
|
|
|
+ return df.head(top_n).drop(columns=["_top_score"])
|
|
|
+
|
|
|
+
|
|
|
def _format_approval_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, df_tier0: pd.DataFrame, request_id: str) -> str:
|
|
|
+ """格式化审批消息(瘦身版,目标:单屏可读,≤ 2 KB)。
|
|
|
+
|
|
|
+ 设计原则:
|
|
|
+ - 删除 Tier 0 逐条列表(由飞书表格兜底)
|
|
|
+ - 删除 Tier 1 "自动执行" 段落(TIER1_MAX_CHANGE_PCT=0.00 已禁用该通路)
|
|
|
+ - 用 Top 5 高置信/高消耗决策替代"前 5 个示例"的无排序列表
|
|
|
+ - 展示"影响金额"让运营感知决策规模
|
|
|
+ - 明确告知"30 分钟无回复 = 默认拒绝",无隐式自动通过
|
|
|
+ """
|
|
|
+ total = len(df_tier2)
|
|
|
+
|
|
|
+ # 统计各 action 数量
|
|
|
+ action_col = df_tier2.get("final_action", df_tier2.get("action", ""))
|
|
|
+ action_counts = action_col.value_counts().to_dict() if total > 0 else {}
|
|
|
+ n_pause = action_counts.get("pause", 0)
|
|
|
+ n_down = action_counts.get("bid_down", 0)
|
|
|
+ n_up = action_counts.get("bid_up", 0)
|
|
|
+ n_scale = action_counts.get("scale_up", 0)
|
|
|
+ n_observe = sum(v for k, v in action_counts.items() if k in ("observe", "hold", "creative_adjust"))
|
|
|
+
|
|
|
+ # 影响金额(7日均消耗合计 + 昨日总消耗)
|
|
|
+ cost_7d = pd.to_numeric(df_tier2.get("cost_7d_avg", 0), errors="coerce").fillna(0).sum() if total > 0 else 0.0
|
|
|
+ yesterday_cost = pd.to_numeric(df_tier2.get("yesterday_cost", 0), errors="coerce").fillna(0).sum() if "yesterday_cost" in df_tier2.columns else 0.0
|
|
|
+
|
|
|
lines = [
|
|
|
- "📊 广告调控审批请求",
|
|
|
- f"请求ID: {request_id}",
|
|
|
- f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
|
|
+ f"📊 广告调控审批请求 {request_id}",
|
|
|
+ "─" * 40,
|
|
|
+ f"📌 决策总数: {total} (需全部人工审批,无自动执行)",
|
|
|
+ f" ⏸ 暂停 {n_pause} / ⬇ 降价 {n_down} / ⬆ 提价 {n_up} / 🚀 扩量 {n_scale} / 👀 观察 {n_observe}",
|
|
|
+ "",
|
|
|
+ "💰 影响金额",
|
|
|
+ f" 受影响广告 7 日均消耗合计: {cost_7d:,.0f} 元",
|
|
|
+ f" 昨日总消耗: {yesterday_cost:,.0f} 元",
|
|
|
"",
|
|
|
]
|
|
|
|
|
|
- # Tier 2/3: 需审批
|
|
|
- if not df_tier2.empty:
|
|
|
- total_count = len(df_tier2)
|
|
|
- lines.append(f"🔶 需审批操作({total_count} 个):")
|
|
|
- lines.append("-" * 40)
|
|
|
+ # Top N 高置信/高消耗决策
|
|
|
+ if total > 0:
|
|
|
+ top_df = _score_top_decisions(df_tier2, top_n=5)
|
|
|
+ header = f"🔝 Top {len(top_df)} 高置信/高消耗决策" if total > 5 else f"🔝 全部决策({total} 个)"
|
|
|
+ lines.append(header)
|
|
|
|
|
|
- # 统计各操作类型
|
|
|
- action_counts = df_tier2.get("final_action", df_tier2.get("action", "")).value_counts().to_dict()
|
|
|
- for action, count in action_counts.items():
|
|
|
+ for idx, (_, row) in enumerate(top_df.iterrows(), start=1):
|
|
|
+ ad_id = row.get("ad_id", "")
|
|
|
+ action = str(row.get("final_action", row.get("action", ""))).strip()
|
|
|
+ ad_name = str(row.get("ad_name", ""))[:20]
|
|
|
+ reason = str(row.get("reason", "")).replace("\n", " ")[:60]
|
|
|
+
|
|
|
+ # ROI(优先使用 7 日均值,回退到单日动态 ROI)
|
|
|
+ roi_val = row.get("动态ROI_7日均值", row.get("动态ROI", row.get("yesterday_roi", 0)))
|
|
|
+ try:
|
|
|
+ roi_val = float(roi_val)
|
|
|
+ roi_str = f"ROI {roi_val:.2f}"
|
|
|
+ except (ValueError, TypeError):
|
|
|
+ roi_str = "ROI -"
|
|
|
+
|
|
|
+ # 动作标签
|
|
|
if action == "pause":
|
|
|
- lines.append(f" ⏸️ 暂停: {count} 个")
|
|
|
+ action_label = "⏸ 暂停"
|
|
|
elif action == "bid_down":
|
|
|
- lines.append(f" ⬇️ 降价: {count} 个")
|
|
|
+ pct = row.get("recommended_change_pct", 0)
|
|
|
+ try:
|
|
|
+ pct = float(pct)
|
|
|
+ except (ValueError, TypeError):
|
|
|
+ pct = 0
|
|
|
+ action_label = f"⬇ 降价 {abs(pct)*100:.0f}%"
|
|
|
elif action == "bid_up":
|
|
|
- lines.append(f" ⬆️ 提价: {count} 个")
|
|
|
+ pct = row.get("recommended_change_pct", 0)
|
|
|
+ try:
|
|
|
+ pct = float(pct)
|
|
|
+ except (ValueError, TypeError):
|
|
|
+ pct = 0
|
|
|
+ action_label = f"⬆ 提价 {pct*100:.0f}%"
|
|
|
+ elif action == "scale_up":
|
|
|
+ action_label = "🚀 扩量"
|
|
|
else:
|
|
|
- lines.append(f" {action}: {count} 个")
|
|
|
- lines.append("")
|
|
|
+ action_label = action
|
|
|
|
|
|
- # 如果数量过多(>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 0: 无需操作(observe/hold/creative_adjust)
|
|
|
- if not df_tier0.empty:
|
|
|
- lines.append(f"ℹ️ 无需操作({len(df_tier0)} 个,仅通知):")
|
|
|
- for _, row in df_tier0.iterrows():
|
|
|
- ad_id = row.get("ad_id", "")
|
|
|
- action = row.get("final_action", row.get("action", ""))
|
|
|
- action_label = {
|
|
|
- "observe": "观察等待",
|
|
|
- "hold": "保持不变",
|
|
|
- "creative_adjust": "需人工调整素材",
|
|
|
- "scale_up": "建议扩量(新增广告/创意)"
|
|
|
- }.get(action, action)
|
|
|
- lines.append(f" [{ad_id}] {action_label}")
|
|
|
- lines.append("")
|
|
|
+ lines.append(f" {idx}. [{ad_id}] {ad_name} | {action_label} | {roi_str} | {reason}")
|
|
|
|
|
|
- # Tier 1: 小幅调价(自动执行)
|
|
|
- if not df_tier1.empty:
|
|
|
- lines.append(f"✅ 自动执行({len(df_tier1)} 个小幅调价):")
|
|
|
- for _, row in df_tier1.iterrows():
|
|
|
- ad_id = row.get("ad_id", "")
|
|
|
- action = row.get("final_action", row.get("action", ""))
|
|
|
- change_pct = row.get("recommended_change_pct", 0)
|
|
|
- lines.append(f" [{ad_id}] {action} {change_pct:+.1%}")
|
|
|
+ if total > 5:
|
|
|
+ lines.append(f" (完整列表见下方飞书在线表格链接)")
|
|
|
lines.append("")
|
|
|
|
|
|
- # 回复指令
|
|
|
+ # 回复方式(多轮协商)
|
|
|
lines.extend([
|
|
|
- "-" * 40,
|
|
|
- "📝 直接回复即可,示例:",
|
|
|
- " \"批准\" / \"通过\" — 全部批准",
|
|
|
- " \"拒绝\" / \"不行\" — 全部拒绝",
|
|
|
- " \"广告 12345 不要暂停\" — 修改指定广告",
|
|
|
- " \"只批准降价的\" — 部分批准",
|
|
|
- " \"降幅改小一点\" — 调整后重新审批",
|
|
|
- f" ⏰ 超时时间: {IM_APPROVAL_TIMEOUT_MINUTES} 分钟",
|
|
|
+ "📝 回复方式(支持多轮协商)",
|
|
|
+ " \"通过\" — 全部批准",
|
|
|
+ " \"拒绝\" — 全部取消",
|
|
|
+ " \"广告 12345 不动\" — 保留这条,其余按建议",
|
|
|
+ " \"整体太激进/保守\" — 要求重新评估",
|
|
|
+ " \"只批准 pause\" — 按 action 类型过滤",
|
|
|
+ f" ⏰ {IM_APPROVAL_TIMEOUT_MINUTES} 分钟无回复 = 默认拒绝",
|
|
|
"",
|
|
|
- "📎 决策详情请查看在线表格(自动发送链接)",
|
|
|
+ "📎 详单: 飞书在线表格链接(消息 2 单独发送)",
|
|
|
])
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
@@ -501,14 +542,14 @@ async def send_approval_request(
|
|
|
except Exception as e:
|
|
|
logger.warning("发送到个人失败: %s", e)
|
|
|
|
|
|
- # 消息 1b:发送到投放项目群聊(如果配置了)
|
|
|
+ # 消息 1b:发送到投放项目群聊 — 与个人 IM 一致的完整审批文本
|
|
|
if FEISHU_AD_PROJECT_CHAT_ID:
|
|
|
try:
|
|
|
result_project = _feishu.send_message(to=FEISHU_AD_PROJECT_CHAT_ID, text=message)
|
|
|
feishu_sent_to_project_chat = True
|
|
|
feishu_sent = True
|
|
|
logger.info("飞书审批消息发送成功(项目群): message_id=%s", result_project.message_id)
|
|
|
- # 群聊 chat_id 本身就是 oc_xxx 格式,可直接用于轮询
|
|
|
+ # 群聊 chat_id 本身就是 oc_xxx 格式,可直接用于轮询回复
|
|
|
if FEISHU_AD_PROJECT_CHAT_ID not in poll_chat_ids:
|
|
|
poll_chat_ids.append(FEISHU_AD_PROJECT_CHAT_ID)
|
|
|
except Exception as e:
|
|
|
@@ -518,30 +559,29 @@ async def send_approval_request(
|
|
|
try:
|
|
|
xlsx_path = _generate_approval_xlsx(df_for_review, request_id)
|
|
|
|
|
|
- # 导入飞书在线表格并发送链接(项目群)— 临时禁用
|
|
|
from feishu_doc import import_to_feishu
|
|
|
|
|
|
- # 发送到项目群 — 临时禁用
|
|
|
- # if FEISHU_AD_PROJECT_CHAT_ID:
|
|
|
- # import_result = await import_to_feishu(
|
|
|
- # ctx=ctx,
|
|
|
- # xlsx_path=str(xlsx_path),
|
|
|
- # send_im=True,
|
|
|
- # chat_id=FEISHU_AD_PROJECT_CHAT_ID
|
|
|
- # )
|
|
|
- #
|
|
|
- # if import_result.metadata and import_result.metadata.get("url"):
|
|
|
- # sheet_url = import_result.metadata["url"]
|
|
|
- # logger.info("飞书审批表格导入成功(项目群): %s", sheet_url)
|
|
|
- # else:
|
|
|
- # logger.warning("飞书在线表格导入失败(项目群),回退到文件附件模式")
|
|
|
- # # 回退:发送文件附件(项目群)
|
|
|
- # file_result = _feishu.send_file(
|
|
|
- # to=FEISHU_AD_PROJECT_CHAT_ID,
|
|
|
- # file=str(xlsx_path),
|
|
|
- # file_name=f"审批决策表_{request_id}.xlsx",
|
|
|
- # )
|
|
|
- # logger.info("飞书审批 Excel(文件)发送成功(项目群): message_id=%s", file_result.message_id)
|
|
|
+ # 发送到项目群(与个人一致)
|
|
|
+ if FEISHU_AD_PROJECT_CHAT_ID:
|
|
|
+ try:
|
|
|
+ import_result = await import_to_feishu(
|
|
|
+ ctx=ctx,
|
|
|
+ xlsx_path=str(xlsx_path),
|
|
|
+ send_im=True,
|
|
|
+ chat_id=FEISHU_AD_PROJECT_CHAT_ID,
|
|
|
+ )
|
|
|
+ if import_result.metadata and import_result.metadata.get("url"):
|
|
|
+ logger.info("飞书审批表格发送成功(项目群): %s", import_result.metadata["url"])
|
|
|
+ else:
|
|
|
+ logger.warning("飞书在线表格导入失败(项目群),回退到文件附件模式")
|
|
|
+ file_result = _feishu.send_file(
|
|
|
+ to=FEISHU_AD_PROJECT_CHAT_ID,
|
|
|
+ file=str(xlsx_path),
|
|
|
+ file_name=f"审批决策表_{request_id}.xlsx",
|
|
|
+ )
|
|
|
+ logger.info("飞书审批 Excel(文件)发送成功(项目群): message_id=%s", file_result.message_id)
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning("发送表格到项目群失败: %s", e)
|
|
|
|
|
|
# 发送到个人
|
|
|
if FEISHU_OPERATOR_OPEN_ID:
|