|
|
@@ -1101,3 +1101,360 @@ async def apply_decisions(
|
|
|
except Exception as e:
|
|
|
logger.error("apply_decisions 失败: %s", e, exc_info=True)
|
|
|
return ToolResult(title="apply_decisions 失败", output=str(e))
|
|
|
+
|
|
|
+
|
|
|
+# ═══════════════════════════════════════════
|
|
|
+# 智能引擎工具 3:查询单个广告详情(Mode 2 支撑)
|
|
|
+# ═══════════════════════════════════════════
|
|
|
+
|
|
|
+
|
|
|
+@tool(description="查询单个广告的当前指标和历史数据")
|
|
|
+async def query_ad_detail(
|
|
|
+ ctx: ToolContext,
|
|
|
+ ad_id: str,
|
|
|
+ metrics_csv: str = "",
|
|
|
+) -> ToolResult:
|
|
|
+ """
|
|
|
+ 查询单个广告的当前指标 + 全局分布上下文(Mode 2 定向操作用)。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ ctx: 工具上下文
|
|
|
+ ad_id: 广告 ID(字符串或数字均可)
|
|
|
+ metrics_csv: ROI 指标 CSV 路径(默认 outputs/metrics_temp.csv)
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ ToolResult,包含该广告的详细指标和全局上下文
|
|
|
+ """
|
|
|
+ import json
|
|
|
+ import os
|
|
|
+
|
|
|
+ try:
|
|
|
+ if not metrics_csv:
|
|
|
+ metrics_csv = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
|
|
|
+
|
|
|
+ metrics_path = Path(metrics_csv)
|
|
|
+ if not metrics_path.exists():
|
|
|
+ return ToolResult(
|
|
|
+ title="query_ad_detail 失败",
|
|
|
+ output=f"指标文件不存在: {metrics_csv},请先执行 calculate_roi_metrics",
|
|
|
+ )
|
|
|
+
|
|
|
+ # 检查数据新鲜度
|
|
|
+ file_mtime = os.path.getmtime(metrics_path)
|
|
|
+ age_hours = (datetime.now().timestamp() - file_mtime) / 3600
|
|
|
+ freshness_warning = ""
|
|
|
+ if age_hours > 24:
|
|
|
+ freshness_warning = f"⚠️ 数据已过期({age_hours:.1f}小时前更新),建议先执行 fetch_creative_data + calculate_roi_metrics 刷新数据。\n\n"
|
|
|
+
|
|
|
+ df = pd.read_csv(metrics_csv)
|
|
|
+
|
|
|
+ # 查找目标广告
|
|
|
+ ad_id_int = int(ad_id)
|
|
|
+ ad_row = df[df["ad_id"] == ad_id_int]
|
|
|
+
|
|
|
+ if ad_row.empty:
|
|
|
+ return ToolResult(
|
|
|
+ title="query_ad_detail",
|
|
|
+ output=f"{freshness_warning}未找到广告 {ad_id},共有 {len(df)} 个广告",
|
|
|
+ )
|
|
|
+
|
|
|
+ row = ad_row.iloc[0]
|
|
|
+
|
|
|
+ # 计算广告年龄
|
|
|
+ ad_age_days = _calculate_ad_age_days(row.get("create_time"))
|
|
|
+
|
|
|
+ # 全局 ROI 分布
|
|
|
+ roi_series = df["动态ROI_7日均值"].dropna()
|
|
|
+ roi_mean = float(roi_series.mean()) if len(roi_series) > 0 else 0.0
|
|
|
+ cost_series = df["cost_7d_avg"].dropna()
|
|
|
+ cost_median = float(cost_series.median()) if len(cost_series) > 0 else 0.0
|
|
|
+
|
|
|
+ roi_low_line = roi_mean * ROI_LOW_FACTOR if "ROI_LOW_FACTOR" in dir() else roi_mean * 0.5
|
|
|
+ bid_down_line = roi_mean * BID_DOWN_ROI_FACTOR
|
|
|
+ bid_up_line = roi_mean * BID_UP_ROI_FACTOR
|
|
|
+
|
|
|
+ # 构建广告详情
|
|
|
+ f_roi = row.get("动态ROI_7日均值")
|
|
|
+ ad_detail = {
|
|
|
+ "ad_id": ad_id_int,
|
|
|
+ "ad_name": str(row.get("ad_name", "")),
|
|
|
+ "bid_amount": round(float(row.get("bid_amount", 0) or 0), 2),
|
|
|
+ "动态ROI_7日均值": round(float(f_roi), 4) if not pd.isna(f_roi) else None,
|
|
|
+ "cost_7d_avg": round(float(row.get("cost_7d_avg", 0) or 0), 2),
|
|
|
+ "cost_7d_total": round(float(row.get("cost_7d_total", 0) or 0), 2),
|
|
|
+ "ad_age_days": ad_age_days,
|
|
|
+ "configured_status": str(row.get("configured_status", "")),
|
|
|
+ }
|
|
|
+
|
|
|
+ # 检测干预信号
|
|
|
+ try:
|
|
|
+ end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
|
|
|
+ raw_dir = _MINI_DIR / "outputs" / "raw"
|
|
|
+ ad_status_dir = _MINI_DIR / "outputs" / "ad_status"
|
|
|
+ decay_signals = _detect_decay_signals(
|
|
|
+ ad_ids=[ad_id_int],
|
|
|
+ raw_dir=raw_dir,
|
|
|
+ ad_status_dir=ad_status_dir,
|
|
|
+ end_date=end_date,
|
|
|
+ )
|
|
|
+ if not decay_signals.empty:
|
|
|
+ ds_row = decay_signals.iloc[0]
|
|
|
+ ad_detail["bid_increased_7d"] = bool(ds_row.get("bid_increased_7d", False))
|
|
|
+ ad_detail["creative_changed_7d"] = bool(ds_row.get("creative_changed_7d", False))
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning("检测干预信号失败: %s", e)
|
|
|
+
|
|
|
+ # 全局上下文
|
|
|
+ global_context = {
|
|
|
+ "全体动态ROI均值": round(roi_mean, 4),
|
|
|
+ "ROI关停线": round(roi_mean * 0.5, 4),
|
|
|
+ "ROI降价线": round(bid_down_line, 4),
|
|
|
+ "ROI提价线": round(bid_up_line, 4),
|
|
|
+ "全体消耗中位数": round(cost_median, 2),
|
|
|
+ }
|
|
|
+
|
|
|
+ result = {
|
|
|
+ "ad_detail": ad_detail,
|
|
|
+ "global_context": global_context,
|
|
|
+ }
|
|
|
+
|
|
|
+ output = freshness_warning + json.dumps(result, ensure_ascii=False, indent=2)
|
|
|
+
|
|
|
+ return ToolResult(
|
|
|
+ title=f"广告 {ad_id} 详情",
|
|
|
+ output=output,
|
|
|
+ metadata=result,
|
|
|
+ )
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error("query_ad_detail 失败: %s", e, exc_info=True)
|
|
|
+ return ToolResult(title="query_ad_detail 失败", output=str(e))
|
|
|
+
|
|
|
+
|
|
|
+# ═══════════════════════════════════════════
|
|
|
+# 智能引擎工具 4:修改已有决策(Mode 3 支撑)
|
|
|
+# ═══════════════════════════════════════════
|
|
|
+
|
|
|
+
|
|
|
+@tool(description="修改已有决策:修改指定广告的操作或调幅,也可新增决策")
|
|
|
+async def modify_decisions(
|
|
|
+ ctx: ToolContext,
|
|
|
+ modifications: str,
|
|
|
+ decisions_csv: str = "",
|
|
|
+ end_date: str = "yesterday",
|
|
|
+) -> ToolResult:
|
|
|
+ """
|
|
|
+ 修改已有 llm_decisions_{date}.csv 中的决策(Mode 3 反馈修改用)。
|
|
|
+
|
|
|
+ 支持两种修改方式:
|
|
|
+ 1. 按 ad_id 精确修改/新增(upsert):
|
|
|
+ [{"ad_id": "90289631207", "new_action": "bid_down", "new_change_pct": -0.05}]
|
|
|
+ 2. 按过滤器批量修改:
|
|
|
+ [{"filter": "all_bid_down", "new_change_pct": -0.03}]
|
|
|
+ 支持: all_pause / all_bid_down / all_bid_up / all_llm
|
|
|
+
|
|
|
+ Args:
|
|
|
+ ctx: 工具上下文
|
|
|
+ modifications: JSON 字符串,修改列表
|
|
|
+ decisions_csv: 决策 CSV 路径(默认自动查找最新)
|
|
|
+ end_date: 结束日期(用于查找默认 CSV)
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ ToolResult,包含修改日志和新的 action 分布
|
|
|
+ """
|
|
|
+ import json
|
|
|
+ import glob as glob_mod
|
|
|
+
|
|
|
+ try:
|
|
|
+ if end_date == "yesterday":
|
|
|
+ end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
|
|
|
+
|
|
|
+ # 解析修改列表
|
|
|
+ try:
|
|
|
+ mod_list = json.loads(modifications)
|
|
|
+ except json.JSONDecodeError as e:
|
|
|
+ return ToolResult(title="modify_decisions 失败", output=f"modifications 不是合法 JSON: {e}")
|
|
|
+
|
|
|
+ if not isinstance(mod_list, list):
|
|
|
+ return ToolResult(title="modify_decisions 失败", output="modifications 必须是 JSON 数组")
|
|
|
+
|
|
|
+ # 定位决策 CSV
|
|
|
+ if not decisions_csv:
|
|
|
+ reports_dir = _MINI_DIR / "outputs" / "reports"
|
|
|
+ # 先找当天的,再找最新的
|
|
|
+ target_path = reports_dir / f"llm_decisions_{end_date}.csv"
|
|
|
+ if target_path.exists():
|
|
|
+ decisions_csv = str(target_path)
|
|
|
+ else:
|
|
|
+ # 查找最新的 llm_decisions_*.csv
|
|
|
+ pattern = str(reports_dir / "llm_decisions_*.csv")
|
|
|
+ files = sorted(glob_mod.glob(pattern), reverse=True)
|
|
|
+ if files:
|
|
|
+ decisions_csv = files[0]
|
|
|
+ else:
|
|
|
+ return ToolResult(
|
|
|
+ title="modify_decisions 失败",
|
|
|
+ output="未找到任何已有决策文件(llm_decisions_*.csv),请先执行全量分析",
|
|
|
+ )
|
|
|
+
|
|
|
+ decisions_path = Path(decisions_csv)
|
|
|
+ if not decisions_path.exists():
|
|
|
+ return ToolResult(title="modify_decisions 失败", output=f"决策文件不存在: {decisions_csv}")
|
|
|
+
|
|
|
+ df = pd.read_csv(decisions_csv)
|
|
|
+ if df.empty:
|
|
|
+ return ToolResult(title="modify_decisions 失败", output="决策文件为空")
|
|
|
+
|
|
|
+ # 加载 metrics 获取 bid_amount
|
|
|
+ metrics_csv_path = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
|
|
|
+ bid_map = {}
|
|
|
+ try:
|
|
|
+ df_metrics = pd.read_csv(metrics_csv_path)
|
|
|
+ bid_map = dict(zip(df_metrics["ad_id"].astype(int), df_metrics["bid_amount"].fillna(0)))
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning("加载 metrics 获取 bid_amount 失败: %s", e)
|
|
|
+
|
|
|
+ change_log = []
|
|
|
+ new_rows = []
|
|
|
+
|
|
|
+ for mod in mod_list:
|
|
|
+ if "filter" in mod:
|
|
|
+ # 批量修改
|
|
|
+ filter_type = mod["filter"]
|
|
|
+ filter_map = {
|
|
|
+ "all_pause": "pause",
|
|
|
+ "all_bid_down": "bid_down",
|
|
|
+ "all_bid_up": "bid_up",
|
|
|
+ "all_llm": None, # 所有 LLM 决策
|
|
|
+ }
|
|
|
+ if filter_type not in filter_map:
|
|
|
+ change_log.append(f"⚠️ 未知 filter: {filter_type},跳过")
|
|
|
+ continue
|
|
|
+
|
|
|
+ target_action = filter_map[filter_type]
|
|
|
+ if target_action:
|
|
|
+ mask = df["action"] == target_action
|
|
|
+ else:
|
|
|
+ mask = df["source"] == "llm"
|
|
|
+
|
|
|
+ matched = mask.sum()
|
|
|
+ if matched == 0:
|
|
|
+ change_log.append(f"filter={filter_type}: 无匹配行")
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 应用修改
|
|
|
+ if "new_action" in mod:
|
|
|
+ df.loc[mask, "action"] = mod["new_action"]
|
|
|
+ if "new_change_pct" in mod:
|
|
|
+ df.loc[mask, "recommended_change_pct"] = mod["new_change_pct"]
|
|
|
+ # 重算 recommended_bid
|
|
|
+ for idx in df[mask].index:
|
|
|
+ ad_id_val = int(df.at[idx, "ad_id"])
|
|
|
+ bid = bid_map.get(ad_id_val, 0)
|
|
|
+ if bid > 0:
|
|
|
+ new_bid = round(bid * (1 + mod["new_change_pct"]), 2)
|
|
|
+ new_bid = max(new_bid, BID_FLOOR_YUAN)
|
|
|
+ new_bid = min(new_bid, BID_CEILING_YUAN)
|
|
|
+ df.at[idx, "recommended_bid"] = new_bid
|
|
|
+ df.at[idx, "current_bid"] = round(bid, 2)
|
|
|
+ if "new_dimension" in mod:
|
|
|
+ df.loc[mask, "dimension"] = mod["new_dimension"]
|
|
|
+ if "new_reason" in mod:
|
|
|
+ df.loc[mask, "reason"] = mod["new_reason"]
|
|
|
+
|
|
|
+ df.loc[mask, "source"] = "llm_modified"
|
|
|
+ change_log.append(f"filter={filter_type}: 修改 {matched} 行")
|
|
|
+
|
|
|
+ elif "ad_id" in mod:
|
|
|
+ # 精确修改/新增(upsert)
|
|
|
+ target_id = int(mod["ad_id"])
|
|
|
+ mask = df["ad_id"] == target_id
|
|
|
+
|
|
|
+ if mask.any():
|
|
|
+ # 修改已有行
|
|
|
+ if "new_action" in mod:
|
|
|
+ old_action = df.loc[mask, "action"].iloc[0]
|
|
|
+ df.loc[mask, "action"] = mod["new_action"]
|
|
|
+ change_log.append(f"ad_id={target_id}: action {old_action} → {mod['new_action']}")
|
|
|
+ if "new_change_pct" in mod:
|
|
|
+ df.loc[mask, "recommended_change_pct"] = mod["new_change_pct"]
|
|
|
+ bid = bid_map.get(target_id, 0)
|
|
|
+ if bid > 0:
|
|
|
+ new_bid = round(bid * (1 + mod["new_change_pct"]), 2)
|
|
|
+ new_bid = max(new_bid, BID_FLOOR_YUAN)
|
|
|
+ new_bid = min(new_bid, BID_CEILING_YUAN)
|
|
|
+ df.loc[mask, "recommended_bid"] = new_bid
|
|
|
+ df.loc[mask, "current_bid"] = round(bid, 2)
|
|
|
+ change_log.append(f"ad_id={target_id}: change_pct → {mod['new_change_pct']}")
|
|
|
+ if "new_dimension" in mod:
|
|
|
+ df.loc[mask, "dimension"] = mod["new_dimension"]
|
|
|
+ if "new_reason" in mod:
|
|
|
+ df.loc[mask, "reason"] = mod["new_reason"]
|
|
|
+ df.loc[mask, "source"] = "llm_modified"
|
|
|
+ else:
|
|
|
+ # 新增行
|
|
|
+ new_action = mod.get("new_action", "hold")
|
|
|
+ change_pct = mod.get("new_change_pct")
|
|
|
+ bid = bid_map.get(target_id, 0)
|
|
|
+ new_bid = None
|
|
|
+ if change_pct is not None and bid > 0:
|
|
|
+ new_bid = round(bid * (1 + change_pct), 2)
|
|
|
+ new_bid = max(new_bid, BID_FLOOR_YUAN)
|
|
|
+ new_bid = min(new_bid, BID_CEILING_YUAN)
|
|
|
+
|
|
|
+ new_row = {
|
|
|
+ "ad_id": target_id,
|
|
|
+ "action": new_action,
|
|
|
+ "dimension": mod.get("new_dimension", "用户指定"),
|
|
|
+ "reason": mod.get("new_reason", "用户定向操作"),
|
|
|
+ "confidence": mod.get("confidence", "high"),
|
|
|
+ "source": "llm_modified",
|
|
|
+ "recommended_change_pct": change_pct,
|
|
|
+ "current_bid": round(bid, 2) if bid > 0 else None,
|
|
|
+ "recommended_bid": new_bid,
|
|
|
+ }
|
|
|
+ new_rows.append(new_row)
|
|
|
+ change_log.append(f"ad_id={target_id}: 新增 action={new_action}")
|
|
|
+ else:
|
|
|
+ change_log.append(f"⚠️ 修改项缺少 ad_id 或 filter,跳过: {mod}")
|
|
|
+
|
|
|
+ # 合并新增行
|
|
|
+ if new_rows:
|
|
|
+ df = pd.concat([df, pd.DataFrame(new_rows)], ignore_index=True)
|
|
|
+
|
|
|
+ # 保存(覆盖原文件)
|
|
|
+ df.to_csv(decisions_csv, index=False, encoding="utf-8-sig")
|
|
|
+
|
|
|
+ # 统计新的 action 分布
|
|
|
+ action_dist = df["action"].value_counts().to_dict()
|
|
|
+
|
|
|
+ output_parts = [
|
|
|
+ f"决策已修改并保存: {decisions_csv}",
|
|
|
+ "",
|
|
|
+ "修改日志:",
|
|
|
+ ]
|
|
|
+ for log in change_log:
|
|
|
+ output_parts.append(f" {log}")
|
|
|
+
|
|
|
+ output_parts.extend([
|
|
|
+ "",
|
|
|
+ "当前 action 分布:",
|
|
|
+ ])
|
|
|
+ for action, count in action_dist.items():
|
|
|
+ output_parts.append(f" {action}: {count} 个")
|
|
|
+ output_parts.append(f" 总计: {len(df)} 个")
|
|
|
+
|
|
|
+ return ToolResult(
|
|
|
+ title=f"决策修改完成({len(change_log)}项变更)",
|
|
|
+ output="\n".join(output_parts),
|
|
|
+ metadata={
|
|
|
+ "csv_path": str(decisions_csv),
|
|
|
+ "changes": len(change_log),
|
|
|
+ "action_distribution": action_dist,
|
|
|
+ "total": len(df),
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error("modify_decisions 失败: %s", e, exc_info=True)
|
|
|
+ return ToolResult(title="modify_decisions 失败", output=str(e))
|