|
@@ -590,3 +590,255 @@ async def analyze_ads(
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.error("analyze_ads 失败: %s", e, exc_info=True)
|
|
logger.error("analyze_ads 失败: %s", e, exc_info=True)
|
|
|
return ToolResult(title="analyze_ads 失败", output=str(e))
|
|
return ToolResult(title="analyze_ads 失败", output=str(e))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ═══════════════════════════════════════════
|
|
|
|
|
+# 智能引擎工具 1:整理待评估广告数据
|
|
|
|
|
+# ═══════════════════════════════════════════
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@tool(description="智能引擎:整理需要关注的广告数据,供LLM推理决策")
|
|
|
|
|
+async def get_ads_for_review(
|
|
|
|
|
+ ctx: ToolContext,
|
|
|
|
|
+ metrics_csv: str = "",
|
|
|
|
|
+ end_date: str = "yesterday",
|
|
|
|
|
+ roi_review_factor: float = 0.8,
|
|
|
|
|
+ min_spend_for_class_a: float = 1.0,
|
|
|
|
|
+) -> ToolResult:
|
|
|
|
|
+ """
|
|
|
|
|
+ 不做决策,将广告分为三类,返回结构化摘要供 LLM 推理。
|
|
|
|
|
+
|
|
|
|
|
+ 类别 A【已确认异常,建议直接关停】:7日均消耗 < 1元(几乎零活动)
|
|
|
|
|
+ 类别 B【待LLM评估】:消耗有意义但指标异常(ROI偏低或衰退信号)
|
|
|
|
|
+ 类别 C【正常运行】:仅返回摘要统计
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ metrics_csv: ROI 指标 CSV 路径(calculate_roi_metrics 输出)
|
|
|
|
|
+ end_date: 结束日期
|
|
|
|
|
+ roi_review_factor: 动态ROI < 全体均值 × 此值 → 进入 B 类(默认 0.8)
|
|
|
|
|
+ min_spend_for_class_a: 7日均消耗低于此值(元)→ A 类(默认 1.0)
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ if not metrics_csv:
|
|
|
|
|
+ metrics_csv = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
|
|
|
|
|
+
|
|
|
|
|
+ df = pd.read_csv(metrics_csv)
|
|
|
|
|
+ if df.empty:
|
|
|
|
|
+ return ToolResult(title="get_ads_for_review", output="指标数据为空")
|
|
|
|
|
+
|
|
|
|
|
+ if end_date == "yesterday":
|
|
|
|
|
+ end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
|
|
|
|
|
+
|
|
|
|
|
+ # 计算广告年龄
|
|
|
|
|
+ df["ad_age_days"] = df["create_time"].apply(_calculate_ad_age_days)
|
|
|
|
|
+
|
|
|
|
|
+ # 检测衰退信号
|
|
|
|
|
+ raw_dir = _MINI_DIR / "outputs" / "raw"
|
|
|
|
|
+ ad_status_dir = _MINI_DIR / "outputs" / "ad_status"
|
|
|
|
|
+ decay_signals = _detect_decay_signals(
|
|
|
|
|
+ ad_ids=df["ad_id"].tolist(),
|
|
|
|
|
+ raw_dir=raw_dir,
|
|
|
|
|
+ ad_status_dir=ad_status_dir,
|
|
|
|
|
+ end_date=end_date,
|
|
|
|
|
+ )
|
|
|
|
|
+ df = df.merge(decay_signals, on="ad_id", how="left")
|
|
|
|
|
+ df["bid_increased_7d"] = df["bid_increased_7d"].fillna(False)
|
|
|
|
|
+ df["creative_changed_7d"] = df["creative_changed_7d"].fillna(False)
|
|
|
|
|
+ df["stable_spend_days_30d"] = df["stable_spend_days_30d"].fillna(0)
|
|
|
|
|
+
|
|
|
|
|
+ # 全体 ROI 分布
|
|
|
|
|
+ roi_series = df["动态ROI_7日均值"].dropna()
|
|
|
|
|
+ roi_mean = float(roi_series.mean()) if len(roi_series) > 0 else 0.0
|
|
|
|
|
+ roi_p25 = float(roi_series.quantile(0.25)) if len(roi_series) > 0 else 0.0
|
|
|
|
|
+ roi_p50 = float(roi_series.quantile(0.50)) if len(roi_series) > 0 else 0.0
|
|
|
|
|
+ roi_p75 = float(roi_series.quantile(0.75)) if len(roi_series) > 0 else 0.0
|
|
|
|
|
+ roi_p90 = float(roi_series.quantile(0.90)) if len(roi_series) > 0 else 0.0
|
|
|
|
|
+
|
|
|
|
|
+ # 分类
|
|
|
|
|
+ class_a = []
|
|
|
|
|
+ class_b = []
|
|
|
|
|
+ class_c_count = 0
|
|
|
|
|
+
|
|
|
|
|
+ for _, row in df.iterrows():
|
|
|
|
|
+ cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
|
|
|
|
|
+ f_roi = row.get("动态ROI_7日均值")
|
|
|
|
|
+ ad_age = row.get("ad_age_days")
|
|
|
|
|
+ bid_inc = bool(row.get("bid_increased_7d", False))
|
|
|
|
|
+ creative_chg = bool(row.get("creative_changed_7d", False))
|
|
|
|
|
+ stable_days = float(row.get("stable_spend_days_30d", 0) or 0)
|
|
|
|
|
+
|
|
|
|
|
+ # A 类:几乎零消耗
|
|
|
|
|
+ if cost_7d_avg < min_spend_for_class_a:
|
|
|
|
|
+ class_a.append({
|
|
|
|
|
+ "ad_id": int(row["ad_id"]),
|
|
|
|
|
+ "ad_name": str(row.get("ad_name", "")),
|
|
|
|
|
+ "cost_7d_avg": round(cost_7d_avg, 2),
|
|
|
|
|
+ })
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # B 类:ROI 偏低 或 衰退信号
|
|
|
|
|
+ roi_low = (not pd.isna(f_roi)) and (f_roi < roi_mean * roi_review_factor)
|
|
|
|
|
+ decay_signal = (
|
|
|
|
|
+ stable_days >= 7
|
|
|
|
|
+ and cost_7d_avg < 100
|
|
|
|
|
+ and (bid_inc or creative_chg)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if roi_low or decay_signal:
|
|
|
|
|
+ class_b.append({
|
|
|
|
|
+ "ad_id": int(row["ad_id"]),
|
|
|
|
|
+ "ad_name": str(row.get("ad_name", "")),
|
|
|
|
|
+ "动态ROI_7日均值": round(float(f_roi), 4) if not pd.isna(f_roi) else None,
|
|
|
|
|
+ "cost_7d_avg": round(cost_7d_avg, 2),
|
|
|
|
|
+ "cost_7d_total": round(float(row.get("cost_7d_total", 0) or 0), 2),
|
|
|
|
|
+ "ad_age_days": int(ad_age) if ad_age is not None else None,
|
|
|
|
|
+ "bid_increased_7d": bid_inc,
|
|
|
|
|
+ "creative_changed_7d": creative_chg,
|
|
|
|
|
+ "stable_spend_days_30d": int(stable_days),
|
|
|
|
|
+ })
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ class_c_count += 1
|
|
|
|
|
+
|
|
|
|
|
+ import json
|
|
|
|
|
+ result = {
|
|
|
|
|
+ "summary": {
|
|
|
|
|
+ "total": len(df),
|
|
|
|
|
+ "class_a": len(class_a),
|
|
|
|
|
+ "class_b": len(class_b),
|
|
|
|
|
+ "class_c": class_c_count,
|
|
|
|
|
+ },
|
|
|
|
|
+ "distribution": {
|
|
|
|
|
+ "roi_mean": round(roi_mean, 4),
|
|
|
|
|
+ "p25": round(roi_p25, 4),
|
|
|
|
|
+ "p50": round(roi_p50, 4),
|
|
|
|
|
+ "p75": round(roi_p75, 4),
|
|
|
|
|
+ "p90": round(roi_p90, 4),
|
|
|
|
|
+ },
|
|
|
|
|
+ "class_a": class_a,
|
|
|
|
|
+ "class_b": class_b,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ output_json = json.dumps(result, ensure_ascii=False, indent=2)
|
|
|
|
|
+
|
|
|
|
|
+ return ToolResult(
|
|
|
|
|
+ title=f"待评估广告(A类:{len(class_a)} B类:{len(class_b)} C类:{class_c_count})",
|
|
|
|
|
+ output=output_json,
|
|
|
|
|
+ metadata={
|
|
|
|
|
+ "total": len(df),
|
|
|
|
|
+ "class_a": len(class_a),
|
|
|
|
|
+ "class_b": len(class_b),
|
|
|
|
|
+ "class_c": class_c_count,
|
|
|
|
|
+ "roi_mean": roi_mean,
|
|
|
|
|
+ "end_date": end_date,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error("get_ads_for_review 失败: %s", e, exc_info=True)
|
|
|
|
|
+ return ToolResult(title="get_ads_for_review 失败", output=str(e))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ═══════════════════════════════════════════
|
|
|
|
|
+# 智能引擎工具 2:保存 LLM 决策结果
|
|
|
|
|
+# ═══════════════════════════════════════════
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@tool(description="智能引擎:接收LLM的决策列表,保存为结构化结果")
|
|
|
|
|
+async def apply_decisions(
|
|
|
|
|
+ ctx: ToolContext,
|
|
|
|
|
+ decisions: str,
|
|
|
|
|
+ end_date: str = "yesterday",
|
|
|
|
|
+ metrics_csv: str = "",
|
|
|
|
|
+) -> ToolResult:
|
|
|
|
|
+ """
|
|
|
|
|
+ 接收 LLM 的决策,合并 A 类广告(自动关停),保存到 llm_decisions_{date}.csv。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ decisions: JSON 字符串,LLM 输出的决策列表
|
|
|
|
|
+ 格式:[{"ad_id": 123, "action": "pause"/"hold",
|
|
|
|
|
+ "dimension": "...", "reason": "...", "confidence": "high"/"medium"/"low"}]
|
|
|
|
|
+ end_date: 结束日期
|
|
|
|
|
+ metrics_csv: ROI 指标 CSV 路径(用于获取 A 类广告)
|
|
|
|
|
+ """
|
|
|
|
|
+ import json
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ if end_date == "yesterday":
|
|
|
|
|
+ end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
|
|
|
|
|
+
|
|
|
|
|
+ # 解析 LLM 决策
|
|
|
|
|
+ try:
|
|
|
|
|
+ llm_list = json.loads(decisions)
|
|
|
|
|
+ except json.JSONDecodeError as e:
|
|
|
|
|
+ return ToolResult(title="apply_decisions 失败", output=f"decisions 不是合法 JSON: {e}")
|
|
|
|
|
+
|
|
|
|
|
+ if not isinstance(llm_list, list):
|
|
|
|
|
+ return ToolResult(title="apply_decisions 失败", output="decisions 必须是 JSON 数组")
|
|
|
|
|
+
|
|
|
|
|
+ # 加载 A 类广告(自动关停)
|
|
|
|
|
+ a_class_rows = []
|
|
|
|
|
+ if not metrics_csv:
|
|
|
|
|
+ metrics_csv = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ df_metrics = pd.read_csv(metrics_csv)
|
|
|
|
|
+ for _, row in df_metrics.iterrows():
|
|
|
|
|
+ cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
|
|
|
|
|
+ if cost_7d_avg < 1.0:
|
|
|
|
|
+ a_class_rows.append({
|
|
|
|
|
+ "ad_id": int(row["ad_id"]),
|
|
|
|
|
+ "action": "pause",
|
|
|
|
|
+ "dimension": "长期零消耗",
|
|
|
|
|
+ "reason": f"7日均消耗={cost_7d_avg:.2f}元,几乎无活动",
|
|
|
|
|
+ "confidence": "high",
|
|
|
|
|
+ "source": "class_a_auto",
|
|
|
|
|
+ })
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.warning("加载 A 类广告失败(跳过): %s", e)
|
|
|
|
|
+
|
|
|
|
|
+ # 合并 LLM 决策(标注来源)
|
|
|
|
|
+ for item in llm_list:
|
|
|
|
|
+ item["source"] = "llm"
|
|
|
|
|
+
|
|
|
|
|
+ all_decisions = a_class_rows + llm_list
|
|
|
|
|
+
|
|
|
|
|
+ if not all_decisions:
|
|
|
|
|
+ return ToolResult(title="apply_decisions", output="无决策数据")
|
|
|
|
|
+
|
|
|
|
|
+ df_out = pd.DataFrame(all_decisions)
|
|
|
|
|
+
|
|
|
|
|
+ # 确保必要列存在
|
|
|
|
|
+ for col in ["ad_id", "action", "dimension", "reason", "confidence", "source"]:
|
|
|
|
|
+ if col not in df_out.columns:
|
|
|
|
|
+ df_out[col] = ""
|
|
|
|
|
+
|
|
|
|
|
+ # 保存
|
|
|
|
|
+ reports_dir = _MINI_DIR / "outputs" / "reports"
|
|
|
|
|
+ reports_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ out_path = reports_dir / f"llm_decisions_{end_date}.csv"
|
|
|
|
|
+ df_out.to_csv(out_path, index=False, encoding="utf-8-sig")
|
|
|
|
|
+
|
|
|
|
|
+ pause_count = (df_out["action"] == "pause").sum()
|
|
|
|
|
+ hold_count = (df_out["action"] == "hold").sum()
|
|
|
|
|
+
|
|
|
|
|
+ return ToolResult(
|
|
|
|
|
+ title=f"智能引擎决策已保存({len(df_out)}条)",
|
|
|
|
|
+ output=(
|
|
|
|
|
+ f"智能引擎决策已保存: {out_path}\n"
|
|
|
|
|
+ f" 关停: {pause_count} 个(含A类自动关停: {len(a_class_rows)} 个)\n"
|
|
|
|
|
+ f" 保持: {hold_count} 个"
|
|
|
|
|
+ ),
|
|
|
|
|
+ metadata={
|
|
|
|
|
+ "csv_path": str(out_path),
|
|
|
|
|
+ "total": len(df_out),
|
|
|
|
|
+ "pause": int(pause_count),
|
|
|
|
|
+ "hold": int(hold_count),
|
|
|
|
|
+ "class_a_auto": len(a_class_rows),
|
|
|
|
|
+ "end_date": end_date,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error("apply_decisions 失败: %s", e, exc_info=True)
|
|
|
|
|
+ return ToolResult(title="apply_decisions 失败", output=str(e))
|