|
@@ -258,96 +258,60 @@ def _score_top_decisions(df_tier2: pd.DataFrame, top_n: int = 5) -> pd.DataFrame
|
|
|
|
|
|
|
|
|
|
|
|
|
def _format_approval_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, df_tier0: pd.DataFrame, request_id: str) -> str:
|
|
def _format_approval_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, df_tier0: pd.DataFrame, request_id: str) -> str:
|
|
|
- """格式化审批消息(极简版,目标:手机单屏可读,≤ 1 KB)。
|
|
|
|
|
|
|
+ """格式化审批消息(极简版,目标:手机单屏可读)。
|
|
|
|
|
|
|
|
设计原则:
|
|
设计原则:
|
|
|
- - 三行头部:标题 + 决策统计(一行含所有 action 数量) + 影响金额(一行)
|
|
|
|
|
- - 用 Top 5 高置信/高消耗决策替代"前 5 个示例"的无排序列表
|
|
|
|
|
- - 回复指引压到一行(核心 3 种 + "直接说您的想法"兜底自由表达)
|
|
|
|
|
- - 明确告知"30 分钟无回复 = 默认拒绝",无隐式自动通过
|
|
|
|
|
- - 标题用人性化时间,不暴露 request_id(内部 ID 放末尾追溯即可)
|
|
|
|
|
- - 两条消息策略:本文本 + 飞书表格详单链接(由调用方串行发送)
|
|
|
|
|
|
|
+ - 只保留三块信息:总量 + 各动作数量及昨日消耗 + 追溯码
|
|
|
|
|
+ - 决策明细、审批指引由调用方拼接的表格链接消息承载
|
|
|
|
|
+ - hold / observe / creative_adjust 合并为"观察"一行
|
|
|
|
|
+ - 只打印非零桶,固定顺序避免抖动
|
|
|
"""
|
|
"""
|
|
|
total = len(df_tier2)
|
|
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
|
|
|
|
|
-
|
|
|
|
|
- # 人性化时间标题:04-21 02:33
|
|
|
|
|
now_label = datetime.now().strftime("%m-%d %H:%M")
|
|
now_label = datetime.now().strftime("%m-%d %H:%M")
|
|
|
|
|
|
|
|
lines = [
|
|
lines = [
|
|
|
f"📊 广告调控 · {now_label} · 请您复核",
|
|
f"📊 广告调控 · {now_label} · 请您复核",
|
|
|
- f"📌 决策 {total} 条 · ⏸{n_pause} ⬇{n_down} ⬆{n_up} 🚀{n_scale} 👀{n_observe}(全部需您审批)",
|
|
|
|
|
- f"💰 影响金额:受影响广告 7 日均消耗合计 {cost_7d:,.0f} 元,昨日总消耗 {yesterday_cost:,.0f} 元",
|
|
|
|
|
- "",
|
|
|
|
|
|
|
+ f"📌 共 {total} 条决策需您审批",
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
- # Top N 高置信/高消耗决策
|
|
|
|
|
if total > 0:
|
|
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)
|
|
|
|
|
-
|
|
|
|
|
- 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":
|
|
|
|
|
- action_label = "⏸ 暂停"
|
|
|
|
|
- elif action == "bid_down":
|
|
|
|
|
- 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":
|
|
|
|
|
- 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:
|
|
|
|
|
- action_label = action
|
|
|
|
|
-
|
|
|
|
|
- lines.append(f" {idx}. [{ad_id}] {ad_name} | {action_label} | {roi_str} | {reason}")
|
|
|
|
|
-
|
|
|
|
|
- if total > 5:
|
|
|
|
|
- lines.append(f" (完整详单见下方表格消息)")
|
|
|
|
|
- lines.append("")
|
|
|
|
|
-
|
|
|
|
|
- # 回复方式(多轮协商,精简到核心 3 种 + 自由表达)
|
|
|
|
|
- lines.extend([
|
|
|
|
|
- "📝 回复:\"通过\" / \"拒绝\" / \"广告 12345 不动\" / \"只批准 降价\",或直接说您的想法",
|
|
|
|
|
- f"⏰ {IM_APPROVAL_TIMEOUT_MINUTES} 分钟无回复 = 默认拒绝",
|
|
|
|
|
- f"(追溯码: {request_id})",
|
|
|
|
|
- ])
|
|
|
|
|
-
|
|
|
|
|
|
|
+ # 取 action 列(双 fallback,保持和现状一致)
|
|
|
|
|
+ action_col = df_tier2.get("final_action", df_tier2.get("action", pd.Series(dtype=str)))
|
|
|
|
|
+ df = df_tier2.assign(_action=action_col.fillna(""))
|
|
|
|
|
+
|
|
|
|
|
+ # hold / observe / creative_adjust 合并为 observe 桶
|
|
|
|
|
+ def _bucket(a: str) -> str:
|
|
|
|
|
+ if a in ("hold", "observe", "creative_adjust"):
|
|
|
|
|
+ return "observe"
|
|
|
|
|
+ return a
|
|
|
|
|
+ df["_bucket"] = df["_action"].map(_bucket)
|
|
|
|
|
+
|
|
|
|
|
+ # 昨日消耗(列缺失或非数值自动兜底为 0)
|
|
|
|
|
+ cost_col = pd.to_numeric(df.get("yesterday_cost", 0), errors="coerce").fillna(0)
|
|
|
|
|
+ df["_ycost"] = cost_col
|
|
|
|
|
+
|
|
|
|
|
+ agg = df.groupby("_bucket").agg(
|
|
|
|
|
+ n=("ad_id", "count"),
|
|
|
|
|
+ cost=("_ycost", "sum"),
|
|
|
|
|
+ ).to_dict("index")
|
|
|
|
|
+
|
|
|
|
|
+ # 显式顺序;无数据的桶不打印
|
|
|
|
|
+ display_order = [
|
|
|
|
|
+ ("pause", "⏸ 暂停"),
|
|
|
|
|
+ ("bid_down", "⬇ 降价"),
|
|
|
|
|
+ ("bid_up", "⬆ 提价"),
|
|
|
|
|
+ ("scale_up", "🚀 扩量"),
|
|
|
|
|
+ ("observe", "👀 观察"),
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ lines.extend(["", "各动作明细:"])
|
|
|
|
|
+ for bucket, label in display_order:
|
|
|
|
|
+ info = agg.get(bucket)
|
|
|
|
|
+ if not info or info["n"] == 0:
|
|
|
|
|
+ continue
|
|
|
|
|
+ lines.append(f" {label} {info['n']} 条|昨日消耗 {info['cost']:,.0f} 元")
|
|
|
|
|
+
|
|
|
|
|
+ lines.extend(["", f"(追溯码: {request_id})"])
|
|
|
return "\n".join(lines)
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
@@ -561,90 +525,95 @@ async def send_approval_request(
|
|
|
"validated_csv": validated_csv,
|
|
"validated_csv": validated_csv,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- # ─── 通过飞书 API 发送审批消息(文本 + Excel) ───
|
|
|
|
|
|
|
+ # ─── 通过飞书 API 发送审批消息(单条合并:概述 + 表格链接 + 审批指引) ───
|
|
|
|
|
+ # 设计:不再分两次发(纯文本 + 表格链接),改为一次发合并消息。
|
|
|
|
|
+ # message 作为 preamble 传给 import_to_feishu,内部 _send_link_message 负责合并。
|
|
|
feishu_sent = False
|
|
feishu_sent = False
|
|
|
feishu_sent_to_project_chat = False
|
|
feishu_sent_to_project_chat = False
|
|
|
sent_time_sec = str(int(time.time())) # 飞书 API start_time 单位:秒
|
|
sent_time_sec = str(int(time.time())) # 飞书 API start_time 单位:秒
|
|
|
poll_chat_ids = [] # 用于轮询的真正 chat_id(从 send_message 返回值中提取)
|
|
poll_chat_ids = [] # 用于轮询的真正 chat_id(从 send_message 返回值中提取)
|
|
|
try:
|
|
try:
|
|
|
- # 消息 1a:发送到个人(FEISHU_OPERATOR_OPEN_ID)
|
|
|
|
|
- if FEISHU_OPERATOR_OPEN_ID:
|
|
|
|
|
- try:
|
|
|
|
|
- result_personal = _feishu.send_message(to=FEISHU_OPERATOR_OPEN_ID, text=message)
|
|
|
|
|
- logger.info("飞书审批消息发送成功(个人): message_id=%s", result_personal.message_id)
|
|
|
|
|
- # ✅ 关键修复:从返回值提取 P2P chat_id(用于后续轮询)
|
|
|
|
|
- if hasattr(result_personal, 'chat_id') and result_personal.chat_id:
|
|
|
|
|
- poll_chat_ids.append(result_personal.chat_id)
|
|
|
|
|
- logger.info("提取到 P2P chat_id: %s(用于轮询回复)", result_personal.chat_id)
|
|
|
|
|
- feishu_sent = True
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- logger.warning("发送到个人失败: %s", e)
|
|
|
|
|
|
|
+ xlsx_path = _generate_approval_xlsx(df_for_review, request_id)
|
|
|
|
|
+
|
|
|
|
|
+ from feishu_doc import import_to_feishu
|
|
|
|
|
|
|
|
- # 消息 1b:发送到投放项目群聊 — 与个人 IM 一致的完整审批文本
|
|
|
|
|
|
|
+ # 发送到项目群
|
|
|
if FEISHU_AD_PROJECT_CHAT_ID:
|
|
if FEISHU_AD_PROJECT_CHAT_ID:
|
|
|
try:
|
|
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 格式,可直接用于轮询回复
|
|
|
|
|
- if FEISHU_AD_PROJECT_CHAT_ID not in poll_chat_ids:
|
|
|
|
|
- poll_chat_ids.append(FEISHU_AD_PROJECT_CHAT_ID)
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- logger.warning("发送到项目群聊失败: %s", e)
|
|
|
|
|
-
|
|
|
|
|
- # 消息 2:导入为飞书在线表格(决策详情,含hold参考)
|
|
|
|
|
- try:
|
|
|
|
|
- xlsx_path = _generate_approval_xlsx(df_for_review, request_id)
|
|
|
|
|
-
|
|
|
|
|
- from feishu_doc import import_to_feishu
|
|
|
|
|
-
|
|
|
|
|
- # 发送到项目群(与个人一致)
|
|
|
|
|
- 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,
|
|
|
|
|
|
|
+ import_result = await import_to_feishu(
|
|
|
|
|
+ ctx=ctx,
|
|
|
|
|
+ xlsx_path=str(xlsx_path),
|
|
|
|
|
+ send_im=True,
|
|
|
|
|
+ chat_id=FEISHU_AD_PROJECT_CHAT_ID,
|
|
|
|
|
+ preamble=message,
|
|
|
|
|
+ )
|
|
|
|
|
+ meta = import_result.metadata or {}
|
|
|
|
|
+ if meta.get("url"):
|
|
|
|
|
+ logger.info("飞书审批合并消息发送成功(项目群): %s", meta["url"])
|
|
|
|
|
+ if meta.get("im_sent"):
|
|
|
|
|
+ feishu_sent = True
|
|
|
|
|
+ feishu_sent_to_project_chat = True
|
|
|
|
|
+ # 群聊 chat_id 本身就是 oc_xxx,可直接轮询
|
|
|
|
|
+ if FEISHU_AD_PROJECT_CHAT_ID not in poll_chat_ids:
|
|
|
|
|
+ poll_chat_ids.append(FEISHU_AD_PROJECT_CHAT_ID)
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.warning("飞书在线表格导入失败(项目群),回退到文件附件 + 文本")
|
|
|
|
|
+ # 回退:发 preamble + 文件附件
|
|
|
|
|
+ try:
|
|
|
|
|
+ _feishu.send_message(to=FEISHU_AD_PROJECT_CHAT_ID, text=message)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass
|
|
|
|
|
+ file_result = _feishu.send_file(
|
|
|
|
|
+ to=FEISHU_AD_PROJECT_CHAT_ID,
|
|
|
|
|
+ file=str(xlsx_path),
|
|
|
|
|
+ file_name=f"广告审批_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
|
|
|
)
|
|
)
|
|
|
- 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"广告审批_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
|
|
|
|
|
- )
|
|
|
|
|
- logger.info("飞书审批 Excel(文件)发送成功(项目群): message_id=%s", file_result.message_id)
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- logger.warning("发送表格到项目群失败: %s", e)
|
|
|
|
|
|
|
+ logger.info("飞书审批 Excel(文件)发送成功(项目群): message_id=%s", file_result.message_id)
|
|
|
|
|
+ feishu_sent = True
|
|
|
|
|
+ feishu_sent_to_project_chat = True
|
|
|
|
|
+ if FEISHU_AD_PROJECT_CHAT_ID not in poll_chat_ids:
|
|
|
|
|
+ poll_chat_ids.append(FEISHU_AD_PROJECT_CHAT_ID)
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.warning("发送合并消息到项目群失败: %s", e)
|
|
|
|
|
|
|
|
- # 发送到个人
|
|
|
|
|
- if FEISHU_OPERATOR_OPEN_ID:
|
|
|
|
|
- try:
|
|
|
|
|
- personal_import_result = await import_to_feishu(
|
|
|
|
|
- ctx=ctx,
|
|
|
|
|
- xlsx_path=str(xlsx_path),
|
|
|
|
|
- send_im=True,
|
|
|
|
|
- chat_id=FEISHU_OPERATOR_OPEN_ID
|
|
|
|
|
|
|
+ # 发送到个人
|
|
|
|
|
+ if FEISHU_OPERATOR_OPEN_ID:
|
|
|
|
|
+ try:
|
|
|
|
|
+ personal_import_result = await import_to_feishu(
|
|
|
|
|
+ ctx=ctx,
|
|
|
|
|
+ xlsx_path=str(xlsx_path),
|
|
|
|
|
+ send_im=True,
|
|
|
|
|
+ chat_id=FEISHU_OPERATOR_OPEN_ID,
|
|
|
|
|
+ preamble=message,
|
|
|
|
|
+ )
|
|
|
|
|
+ meta_p = personal_import_result.metadata or {}
|
|
|
|
|
+ if meta_p.get("url"):
|
|
|
|
|
+ logger.info("飞书审批合并消息发送成功(个人): %s", meta_p["url"])
|
|
|
|
|
+ if meta_p.get("im_sent"):
|
|
|
|
|
+ feishu_sent = True
|
|
|
|
|
+ # 从 send_message 返回值提取 P2P chat_id(用于后续轮询)
|
|
|
|
|
+ p2p_chat = meta_p.get("im_chat_id")
|
|
|
|
|
+ if p2p_chat and p2p_chat not in poll_chat_ids:
|
|
|
|
|
+ poll_chat_ids.append(p2p_chat)
|
|
|
|
|
+ logger.info("提取到 P2P chat_id: %s(用于轮询回复)", p2p_chat)
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 回退:发 preamble + 文件附件
|
|
|
|
|
+ logger.warning("飞书在线表格导入失败(个人),回退到文件附件 + 文本")
|
|
|
|
|
+ try:
|
|
|
|
|
+ res_txt = _feishu.send_message(to=FEISHU_OPERATOR_OPEN_ID, text=message)
|
|
|
|
|
+ if hasattr(res_txt, 'chat_id') and res_txt.chat_id and res_txt.chat_id not in poll_chat_ids:
|
|
|
|
|
+ poll_chat_ids.append(res_txt.chat_id)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass
|
|
|
|
|
+ file_result_personal = _feishu.send_file(
|
|
|
|
|
+ to=FEISHU_OPERATOR_OPEN_ID,
|
|
|
|
|
+ file=str(xlsx_path),
|
|
|
|
|
+ file_name=f"广告审批_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
|
|
|
)
|
|
)
|
|
|
- if personal_import_result.metadata and personal_import_result.metadata.get("url"):
|
|
|
|
|
- logger.info("飞书审批表格发送成功(个人): %s", personal_import_result.metadata["url"])
|
|
|
|
|
- else:
|
|
|
|
|
- # 回退:发送文件附件(个人)
|
|
|
|
|
- file_result_personal = _feishu.send_file(
|
|
|
|
|
- to=FEISHU_OPERATOR_OPEN_ID,
|
|
|
|
|
- file=str(xlsx_path),
|
|
|
|
|
- file_name=f"广告审批_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
|
|
|
|
|
- )
|
|
|
|
|
- logger.info("飞书决策 Excel(文件)发送成功(个人): message_id=%s", file_result_personal.message_id)
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- logger.warning("发送表格到个人失败: %s", e)
|
|
|
|
|
-
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- logger.warning("飞书在线表格导入失败(不影响审批流程): %s", e)
|
|
|
|
|
|
|
+ logger.info("飞书决策 Excel(文件)发送成功(个人): message_id=%s", file_result_personal.message_id)
|
|
|
|
|
+ feishu_sent = True
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.warning("发送合并消息到个人失败: %s", e)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.warning("飞书发消息失败: %s", e)
|
|
logger.warning("飞书发消息失败: %s", e)
|
|
|
|
|
|