|
|
@@ -1,117 +1,37 @@
|
|
|
"""
|
|
|
预算计算引擎 — 出价调整与账户评估
|
|
|
|
|
|
-核心机制:通过调整 oCPM 出价(bid_amount)控制消耗速度,不设日预算限制。
|
|
|
-决策矩阵:ROI × 跑量 二维分类,5 种动作(keep/increase/decrease/close/observe)。
|
|
|
-缩量、扩量、持平各一套矩阵,调整幅度 5%-15%。
|
|
|
+改造后的三层架构:
|
|
|
+- 数据层:get_ad_performance / get_account_summary — 获取原始数据
|
|
|
+- 计算层:compute_budget_thresholds / classify_ads / compute_bid_adjustment — 确定性计算
|
|
|
+- 执行层:bid_adjustment_execute — 调用 API 执行调整
|
|
|
+
|
|
|
+所有策略参数(分位数、决策矩阵、幅度)从策略配置层传入,不在此处硬编码。
|
|
|
"""
|
|
|
|
|
|
+import json
|
|
|
import logging
|
|
|
from datetime import datetime, timedelta
|
|
|
from pathlib import Path
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
+import pandas as pd
|
|
|
+
|
|
|
from agent.tools import tool
|
|
|
from agent.tools.models import ToolContext, ToolResult
|
|
|
|
|
|
from examples.auto_put_ad.tools.ad_api import ad_update
|
|
|
from examples.auto_put_ad.tools.data_query import _get_odps_client
|
|
|
+from examples.auto_put_ad.tools.strategy_config import (
|
|
|
+ get_strategy_config,
|
|
|
+ get_decision_matrix,
|
|
|
+ determine_strategy,
|
|
|
+)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
-# ===== 常量 =====
|
|
|
-MIN_BID = 10 # 最低出价 0.10 元(10分)
|
|
|
-MAX_BID = 10000 # 最高出价 100 元(10000分)
|
|
|
-TOP_RATIO = 0.30 # 优质广告占比(保留兼容,新逻辑用分位数)
|
|
|
-
|
|
|
-# 动作类型
|
|
|
-ACTION_KEEP = "keep"
|
|
|
-ACTION_INCREASE = "increase"
|
|
|
-ACTION_DECREASE = "decrease"
|
|
|
-ACTION_CLOSE = "close"
|
|
|
-ACTION_OBSERVE = "observe"
|
|
|
-
|
|
|
-
|
|
|
-def _determine_strategy(scale_ratio: float) -> str:
|
|
|
- """根据缩量/扩量比例判断策略"""
|
|
|
- if scale_ratio < 0.7:
|
|
|
- return "aggressive_scale_down"
|
|
|
- elif scale_ratio < 0.95:
|
|
|
- return "moderate_scale_down"
|
|
|
- elif scale_ratio <= 1.05:
|
|
|
- return "maintain"
|
|
|
- elif scale_ratio <= 1.3:
|
|
|
- return "moderate_scale_up"
|
|
|
- else:
|
|
|
- return "aggressive_scale_up"
|
|
|
-
|
|
|
-
|
|
|
-def _compute_thresholds(df_valid) -> dict:
|
|
|
- """基于有效广告池计算分位数阈值
|
|
|
-
|
|
|
- Returns:
|
|
|
- dict with keys: roi_p70, roi_p30, cost_p50
|
|
|
- """
|
|
|
- return {
|
|
|
- "roi_p70": float(df_valid["efficiency"].quantile(0.70)),
|
|
|
- "roi_p30": float(df_valid["efficiency"].quantile(0.30)),
|
|
|
- "cost_p50": float(df_valid["cost"].quantile(0.50)),
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
-def _classify_ad(efficiency: float, cost: float, thresholds: dict) -> tuple:
|
|
|
- """将广告分类到 ROI × 跑量 二维象限
|
|
|
-
|
|
|
- Returns:
|
|
|
- (roi_level, volume_level): e.g. ("high", "high")
|
|
|
- """
|
|
|
- if efficiency >= thresholds["roi_p70"]:
|
|
|
- roi_level = "high"
|
|
|
- elif efficiency >= thresholds["roi_p30"]:
|
|
|
- roi_level = "mid"
|
|
|
- else:
|
|
|
- roi_level = "low"
|
|
|
-
|
|
|
- volume_level = "high" if cost >= thresholds["cost_p50"] else "low"
|
|
|
- return roi_level, volume_level
|
|
|
-
|
|
|
-
|
|
|
-def _decide_action(roi_level: str, volume_level: str, strategy: str) -> tuple:
|
|
|
- """根据 ROI×跑量 分类 + 策略,返回 (action, adj_ratio)
|
|
|
-
|
|
|
- 三套矩阵:缩量 / 扩量 / 持平
|
|
|
- """
|
|
|
- # 缩量矩阵(aggressive / moderate)
|
|
|
- if strategy in ("aggressive_scale_down", "moderate_scale_down"):
|
|
|
- aggressive = strategy == "aggressive_scale_down"
|
|
|
- matrix = {
|
|
|
- ("high", "high"): (ACTION_KEEP, 0.0),
|
|
|
- ("high", "low"): (ACTION_KEEP, 0.0),
|
|
|
- ("mid", "high"): (ACTION_DECREASE, -0.10 if aggressive else -0.05),
|
|
|
- ("mid", "low"): (ACTION_OBSERVE, 0.0),
|
|
|
- ("low", "high"): (ACTION_DECREASE, -0.15 if aggressive else -0.10),
|
|
|
- ("low", "low"): (ACTION_CLOSE, 0.0),
|
|
|
- }
|
|
|
- return matrix[(roi_level, volume_level)]
|
|
|
-
|
|
|
- # 扩量矩阵(aggressive / moderate)
|
|
|
- if strategy in ("aggressive_scale_up", "moderate_scale_up"):
|
|
|
- aggressive = strategy == "aggressive_scale_up"
|
|
|
- matrix = {
|
|
|
- ("high", "high"): (ACTION_KEEP, 0.0),
|
|
|
- ("high", "low"): (ACTION_INCREASE, 0.15 if aggressive else 0.10),
|
|
|
- ("mid", "high"): (ACTION_KEEP, 0.0),
|
|
|
- ("mid", "low"): (ACTION_INCREASE, 0.05),
|
|
|
- ("low", "high"): (ACTION_DECREASE, -0.10),
|
|
|
- ("low", "low"): (ACTION_CLOSE, 0.0),
|
|
|
- }
|
|
|
- return matrix[(roi_level, volume_level)]
|
|
|
-
|
|
|
- # 持平矩阵
|
|
|
- if roi_level == "low" and volume_level == "low":
|
|
|
- return (ACTION_CLOSE, 0.0)
|
|
|
- return (ACTION_KEEP, 0.0)
|
|
|
|
|
|
+# ===== 内部辅助函数 =====
|
|
|
|
|
|
def _parse_bizdate(bizdate: str) -> tuple:
|
|
|
"""解析业务日期,返回 (YYYYMMDD, YYYY-MM-DD)"""
|
|
|
@@ -124,12 +44,7 @@ def _parse_bizdate(bizdate: str) -> tuple:
|
|
|
|
|
|
|
|
|
def _build_efficiency_sql(biz: str, biz_dash: str) -> str:
|
|
|
- """构建昨日效率数据 SQL(广告维度聚合)
|
|
|
-
|
|
|
- 包含冷启动保护所需字段:
|
|
|
- - create_time: 广告创建时间(判定冷启动期 48h)
|
|
|
- - conversions_count: 转化量(判定赔付门槛 6 次)
|
|
|
- """
|
|
|
+ """构建昨日效率数据 SQL(广告维度聚合)"""
|
|
|
return f"""
|
|
|
SELECT
|
|
|
a.account_id,
|
|
|
@@ -175,29 +90,130 @@ GROUP BY a.account_id, a.ad_id, c.ad_name, c.create_time
|
|
|
"""
|
|
|
|
|
|
|
|
|
-# ===== 账户级评估 =====
|
|
|
+# ═════════════════════════════════════════════════════════════
|
|
|
+# 数据层 — 获取原始数据
|
|
|
+# ═════════════════════════════════════════════════════════════
|
|
|
|
|
|
-@tool(description="评估各账户昨日投放表现,输出账户健康度和稳定性标签")
|
|
|
-async def account_evaluate(
|
|
|
+@tool(description="获取指定账户昨日广告效果数据,包含效率分、消耗、转化数、冷启动信息等")
|
|
|
+async def get_ad_performance(
|
|
|
+ account_id: int = 0,
|
|
|
bizdate: str = "yesterday",
|
|
|
context: Optional[ToolContext] = None,
|
|
|
) -> ToolResult:
|
|
|
"""
|
|
|
- 账户级评估:查询各账户昨日汇总数据,按消耗量判断稳定性。
|
|
|
+ 拉取昨日广告效果数据(广告维度聚合)。
|
|
|
|
|
|
Args:
|
|
|
- bizdate: 数据日期,"yesterday" 或 YYYYMMDD 格式
|
|
|
+ account_id: 账户ID(传 0 不过滤账户,拉全量)
|
|
|
+ bizdate: 业务日期,"yesterday" 或 YYYYMMDD 格式
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ 结构化广告列表,每条含: ad_id, account_id, cost, efficiency,
|
|
|
+ open_count, conversions_count, create_time, bid_amount, ad_status
|
|
|
"""
|
|
|
- import pandas as pd
|
|
|
+ try:
|
|
|
+ client = _get_odps_client()
|
|
|
+ if client is None:
|
|
|
+ return ToolResult(title="get_ad_performance 失败", output="ODPS 客户端未初始化")
|
|
|
+
|
|
|
+ biz, biz_dash = _parse_bizdate(bizdate)
|
|
|
+
|
|
|
+ # 拉取效率数据
|
|
|
+ sql_efficiency = _build_efficiency_sql(biz, biz_dash)
|
|
|
+ df_eff = client.execute_sql(sql_efficiency)
|
|
|
+ if df_eff.empty:
|
|
|
+ return ToolResult(title="get_ad_performance", output=f"昨日({biz})无效率数据")
|
|
|
|
|
|
+ # 按账户过滤
|
|
|
+ if account_id > 0:
|
|
|
+ df_eff = df_eff[df_eff["account_id"].astype(float).astype("Int64") == account_id]
|
|
|
+ if df_eff.empty:
|
|
|
+ return ToolResult(title="get_ad_performance", output=f"账户 {account_id} 昨日({biz})无数据")
|
|
|
+
|
|
|
+ # 拉取当前出价/状态
|
|
|
+ ad_ids = [int(x) for x in df_eff["ad_id"].dropna().unique() if str(x) != "nan"]
|
|
|
+ ad_ids_str = ",".join(map(str, ad_ids))
|
|
|
+ sql_status = f"""
|
|
|
+SELECT ad_id, ad_name, account_id, bid_amount, day_amount, ad_status, optimization_goal
|
|
|
+FROM loghubods.ad_put_tencent_ad
|
|
|
+WHERE ad_id IN ({ad_ids_str})
|
|
|
+"""
|
|
|
+ df_status = client.execute_sql(sql_status)
|
|
|
+
|
|
|
+ # 合并
|
|
|
+ df_eff["ad_id"] = df_eff["ad_id"].astype(float).astype("Int64")
|
|
|
+ df_status["ad_id"] = df_status["ad_id"].astype(float).astype("Int64")
|
|
|
+ df = pd.merge(
|
|
|
+ df_eff,
|
|
|
+ df_status[["ad_id", "bid_amount", "day_amount", "ad_status", "optimization_goal"]],
|
|
|
+ on="ad_id", how="left",
|
|
|
+ )
|
|
|
+
|
|
|
+ # 计算效率分
|
|
|
+ df["efficiency"] = df.apply(
|
|
|
+ lambda r: r["fission0_count"] / r["cost"]
|
|
|
+ if r["cost"] and r["cost"] > 0 and r["fission0_count"] is not None and pd.notna(r["fission0_count"])
|
|
|
+ else None,
|
|
|
+ axis=1,
|
|
|
+ )
|
|
|
+
|
|
|
+ total_cost = float(df["cost"].sum())
|
|
|
+ avg_eff = float(df["efficiency"].mean()) if not df["efficiency"].isna().all() else 0
|
|
|
+
|
|
|
+ lines = [
|
|
|
+ f"广告效果数据({biz},{len(df)}条广告)",
|
|
|
+ f"总消耗: {total_cost:,.0f}元,平均效率分: {avg_eff:.4f}",
|
|
|
+ "",
|
|
|
+ f"{'广告ID':<12} {'账户ID':<12} {'消耗(元)':>10} {'效率分':>8} {'转化':>5} {'出价(分)':>8} {'状态':<10}",
|
|
|
+ "-" * 70,
|
|
|
+ ]
|
|
|
+ for _, row in df.head(20).iterrows():
|
|
|
+ eff_str = f"{row['efficiency']:.4f}" if pd.notna(row["efficiency"]) else "-"
|
|
|
+ bid_str = str(int(float(row["bid_amount"]))) if pd.notna(row.get("bid_amount")) else "-"
|
|
|
+ conv = int(row["conversions_count"]) if pd.notna(row.get("conversions_count")) else 0
|
|
|
+ lines.append(
|
|
|
+ f"{int(row['ad_id']):<12} {int(row['account_id']):<12} "
|
|
|
+ f"{row['cost']:>10,.0f} {eff_str:>8} {conv:>5} {bid_str:>8} "
|
|
|
+ f"{str(row.get('ad_status', '')):>10}"
|
|
|
+ )
|
|
|
+ if len(df) > 20:
|
|
|
+ lines.append(f"... 还有 {len(df) - 20} 条")
|
|
|
+
|
|
|
+ return ToolResult(
|
|
|
+ title=f"广告效果数据({len(df)}条,总消耗{total_cost:,.0f}元)",
|
|
|
+ output="\n".join(lines),
|
|
|
+ metadata={
|
|
|
+ "ad_data": df.to_dict("records"),
|
|
|
+ "total_cost": total_cost,
|
|
|
+ "avg_efficiency": avg_eff,
|
|
|
+ "ad_count": len(df),
|
|
|
+ "bizdate": biz,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error("get_ad_performance 失败: %s", e, exc_info=True)
|
|
|
+ return ToolResult(title="get_ad_performance 失败", output=str(e))
|
|
|
+
|
|
|
+
|
|
|
+@tool(description="获取账户维度昨日汇总数据,包含消耗、效率、广告数、稳定性标签")
|
|
|
+async def get_account_summary(
|
|
|
+ bizdate: str = "yesterday",
|
|
|
+ context: Optional[ToolContext] = None,
|
|
|
+) -> ToolResult:
|
|
|
+ """
|
|
|
+ 账户级汇总:查询各账户昨日汇总数据,按消耗量判断稳定性。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ bizdate: 数据日期,"yesterday" 或 YYYYMMDD 格式
|
|
|
+ """
|
|
|
try:
|
|
|
client = _get_odps_client()
|
|
|
if client is None:
|
|
|
- return ToolResult(title="account_evaluate 失败", output="ODPS 客户端未初始化")
|
|
|
+ return ToolResult(title="get_account_summary 失败", output="ODPS 客户端未初始化")
|
|
|
|
|
|
biz, biz_dash = _parse_bizdate(bizdate)
|
|
|
|
|
|
- # 查询各账户昨日汇总(复用效率 SQL,按 account_id 聚合)
|
|
|
inner_sql = _build_efficiency_sql(biz, biz_dash)
|
|
|
sql = f"""
|
|
|
SELECT
|
|
|
@@ -211,15 +227,13 @@ GROUP BY account_id
|
|
|
"""
|
|
|
df = client.execute_sql(sql)
|
|
|
if df.empty:
|
|
|
- return ToolResult(title="account_evaluate", output=f"昨日({biz})无账户数据")
|
|
|
+ return ToolResult(title="get_account_summary", output=f"昨日({biz})无账户数据")
|
|
|
|
|
|
- # 计算效率分均值
|
|
|
df["avg_efficiency"] = df.apply(
|
|
|
lambda r: r["total_fission0"] / r["total_cost"] if r["total_cost"] and r["total_cost"] > 0 else 0,
|
|
|
axis=1,
|
|
|
)
|
|
|
|
|
|
- # 按消耗量判断稳定性(中位数为阈值)
|
|
|
median_cost = df["total_cost"].median()
|
|
|
p30_cost = df["total_cost"].quantile(0.30)
|
|
|
|
|
|
@@ -234,10 +248,9 @@ GROUP BY account_id
|
|
|
df["stability"] = df["total_cost"].apply(label_stability)
|
|
|
df = df.sort_values("total_cost", ascending=False).reset_index(drop=True)
|
|
|
|
|
|
- # 格式化输出
|
|
|
lines = [
|
|
|
- f"账户评估({biz},共 {len(df)} 个账户)",
|
|
|
- f"消耗中位数: {median_cost:,.0f}元(≥中位数=稳定,≥P30=一般,<P30=低量)",
|
|
|
+ f"账户汇总({biz},共 {len(df)} 个账户)",
|
|
|
+ f"消耗中位数: {median_cost:,.0f}元",
|
|
|
"",
|
|
|
f"{'账户ID':<15} {'广告数':>6} {'昨日消耗(元)':>12} {'效率分均值':>10} {'稳定性':>6}",
|
|
|
"-" * 55,
|
|
|
@@ -249,7 +262,6 @@ GROUP BY account_id
|
|
|
f"{row['stability']:>6}"
|
|
|
)
|
|
|
|
|
|
- # 标记建议扩量的账户
|
|
|
stable_high_eff = df[(df["stability"] == "稳定") & (df["avg_efficiency"] > df["avg_efficiency"].median())]
|
|
|
if not stable_high_eff.empty:
|
|
|
lines += ["", "扩量建议账户(稳定 + 效率分高于中位数):"]
|
|
|
@@ -257,7 +269,7 @@ GROUP BY account_id
|
|
|
lines.append(f" 账户 {int(row['account_id'])}(消耗 {row['total_cost']:,.0f}元,效率分 {row['avg_efficiency']:.4f})")
|
|
|
|
|
|
return ToolResult(
|
|
|
- title=f"账户评估({len(df)}个账户)",
|
|
|
+ title=f"账户汇总({len(df)}个账户)",
|
|
|
output="\n".join(lines),
|
|
|
metadata={
|
|
|
"accounts": df.to_dict("records"),
|
|
|
@@ -267,184 +279,304 @@ GROUP BY account_id
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
- logger.error("account_evaluate 失败: %s", e, exc_info=True)
|
|
|
- return ToolResult(title="account_evaluate 失败", output=str(e))
|
|
|
+ logger.error("get_account_summary 失败: %s", e, exc_info=True)
|
|
|
+ return ToolResult(title="get_account_summary 失败", output=str(e))
|
|
|
|
|
|
|
|
|
-# ===== 出价调整 =====
|
|
|
+# ═════════════════════════════════════════════════════════════
|
|
|
+# 计算层 — 确定性计算,策略参数从外部传入
|
|
|
+# ═════════════════════════════════════════════════════════════
|
|
|
|
|
|
-@tool(description="基于昨日裂变效率数据计算今日出价调整方案(ROI×跑量二维矩阵,5种动作)")
|
|
|
-async def budget_calculate_from_data(
|
|
|
- account_id: int,
|
|
|
- total_budget_yuan: float,
|
|
|
- bizdate: str = "yesterday",
|
|
|
- strategy: str = "auto",
|
|
|
- min_bid_cents: int = MIN_BID,
|
|
|
+@tool(description="计算分位数阈值(ROI高/低 + 消耗中位数),分位数参数可自定义")
|
|
|
+async def compute_budget_thresholds(
|
|
|
+ ad_data_json: str,
|
|
|
+ roi_high_percentile: float = 0.70,
|
|
|
+ roi_low_percentile: float = 0.30,
|
|
|
+ cost_mid_percentile: float = 0.50,
|
|
|
+ min_open_count: int = 100,
|
|
|
context: Optional[ToolContext] = None,
|
|
|
) -> ToolResult:
|
|
|
"""
|
|
|
- 智能出价调整(ROI × 跑量 二维决策矩阵):
|
|
|
- 1. 拉取昨日效率数据(按广告维度聚合)
|
|
|
- 2. 拉取当前广告出价/状态
|
|
|
- 3. 计算分位数阈值(ROI P70/P30 + 消耗 P50)
|
|
|
- 4. 每个广告分类到二维象限,决定动作(keep/increase/decrease/close/observe)
|
|
|
- 5. 样本不足广告跳过不操作
|
|
|
+ 基于广告效果数据计算分位数阈值。
|
|
|
|
|
|
Args:
|
|
|
- account_id: 账户ID(传 0 则不过滤账户)
|
|
|
- total_budget_yuan: 今日总预算(元)
|
|
|
- bizdate: 业务日期,格式 YYYYMMDD 或 "yesterday"
|
|
|
- strategy: "auto" 或手动指定策略
|
|
|
- min_bid_cents: 最低出价(分,默认 10)
|
|
|
+ ad_data_json: 广告效果数据 JSON(来自 get_ad_performance 的 metadata.ad_data)
|
|
|
+ roi_high_percentile: ROI 高阈值分位数(默认 P70,可调)
|
|
|
+ roi_low_percentile: ROI 低阈值分位数(默认 P30,可调)
|
|
|
+ cost_mid_percentile: 消耗中位数分位数(默认 P50,可调)
|
|
|
+ min_open_count: 有效广告最低打开数(默认 100)
|
|
|
"""
|
|
|
- import pandas as pd
|
|
|
-
|
|
|
try:
|
|
|
- client = _get_odps_client()
|
|
|
- if client is None:
|
|
|
- return ToolResult(title="budget_calculate_from_data 失败", output="ODPS 客户端未初始化")
|
|
|
+ ad_data = json.loads(ad_data_json) if isinstance(ad_data_json, str) else ad_data_json
|
|
|
+ df = pd.DataFrame(ad_data)
|
|
|
|
|
|
- biz, biz_dash = _parse_bizdate(bizdate)
|
|
|
+ # 筛选有效广告
|
|
|
+ df_valid = df[df["open_count"] >= min_open_count].copy()
|
|
|
+ df_nosample = df[df["open_count"] < min_open_count]
|
|
|
|
|
|
- # Step 1: 昨日效率数据
|
|
|
- logger.info("拉取昨日效率数据: bizdate=%s", biz)
|
|
|
- sql_efficiency = _build_efficiency_sql(biz, biz_dash)
|
|
|
- df_eff = client.execute_sql(sql_efficiency)
|
|
|
- if df_eff.empty:
|
|
|
- return ToolResult(title="budget_calculate_from_data 失败", output=f"昨日({biz})效率数据为空")
|
|
|
+ if df_valid.empty:
|
|
|
+ return ToolResult(
|
|
|
+ title="compute_budget_thresholds",
|
|
|
+ output=f"无有效广告(open_count >= {min_open_count})",
|
|
|
+ )
|
|
|
|
|
|
- # Step 2: 当前广告出价/状态
|
|
|
- ad_ids = [int(x) for x in df_eff["ad_id"].dropna().unique() if str(x) != "nan"]
|
|
|
- ad_ids_str = ",".join(map(str, ad_ids))
|
|
|
- sql_status = f"""
|
|
|
-SELECT ad_id, ad_name, account_id, bid_amount, day_amount, ad_status, optimization_goal
|
|
|
-FROM loghubods.ad_put_tencent_ad
|
|
|
-WHERE ad_id IN ({ad_ids_str})
|
|
|
-"""
|
|
|
- df_status = client.execute_sql(sql_status)
|
|
|
+ # 确保 efficiency 列有效
|
|
|
+ df_valid["efficiency"] = pd.to_numeric(df_valid["efficiency"], errors="coerce")
|
|
|
+ df_valid = df_valid.dropna(subset=["efficiency"])
|
|
|
|
|
|
- # Step 3: 合并
|
|
|
- df_eff["ad_id"] = df_eff["ad_id"].astype(float).astype("Int64")
|
|
|
- df_status["ad_id"] = df_status["ad_id"].astype(float).astype("Int64")
|
|
|
- df = pd.merge(
|
|
|
- df_eff,
|
|
|
- df_status[["ad_id", "bid_amount", "day_amount", "ad_status", "optimization_goal"]],
|
|
|
- on="ad_id", how="left",
|
|
|
+ thresholds = {
|
|
|
+ "roi_high": float(df_valid["efficiency"].quantile(roi_high_percentile)),
|
|
|
+ "roi_low": float(df_valid["efficiency"].quantile(roi_low_percentile)),
|
|
|
+ "cost_mid": float(df_valid["cost"].quantile(cost_mid_percentile)),
|
|
|
+ }
|
|
|
+
|
|
|
+ output = (
|
|
|
+ f"阈值计算结果(有效广告 {len(df_valid)} 条,样本不足 {len(df_nosample)} 条):\n"
|
|
|
+ f" ROI 高阈值 (P{int(roi_high_percentile*100)}): {thresholds['roi_high']:.4f}\n"
|
|
|
+ f" ROI 低阈值 (P{int(roi_low_percentile*100)}): {thresholds['roi_low']:.4f}\n"
|
|
|
+ f" 消耗中位数 (P{int(cost_mid_percentile*100)}): {thresholds['cost_mid']:.0f}元\n"
|
|
|
+ f"\n分位数参数: roi_high=P{int(roi_high_percentile*100)}, "
|
|
|
+ f"roi_low=P{int(roi_low_percentile*100)}, "
|
|
|
+ f"cost_mid=P{int(cost_mid_percentile*100)}"
|
|
|
)
|
|
|
|
|
|
- # Step 4: 效率分 + 有效广告筛选
|
|
|
- df["efficiency"] = df.apply(
|
|
|
- lambda r: r["fission0_count"] / r["cost"]
|
|
|
- if r["cost"] and r["cost"] > 0 and r["fission0_count"] is not None and pd.notna(r["fission0_count"])
|
|
|
- else None,
|
|
|
- axis=1,
|
|
|
+ return ToolResult(
|
|
|
+ title=f"阈值计算({len(df_valid)}条有效广告)",
|
|
|
+ output=output,
|
|
|
+ metadata={
|
|
|
+ "thresholds": thresholds,
|
|
|
+ "valid_count": len(df_valid),
|
|
|
+ "nosample_count": len(df_nosample),
|
|
|
+ "percentiles_used": {
|
|
|
+ "roi_high": roi_high_percentile,
|
|
|
+ "roi_low": roi_low_percentile,
|
|
|
+ "cost_mid": cost_mid_percentile,
|
|
|
+ },
|
|
|
+ },
|
|
|
)
|
|
|
- df_valid = df[df["open_count"] >= 100].copy().sort_values("efficiency", ascending=False).reset_index(drop=True)
|
|
|
- df_nosample = df[df["open_count"] < 100].copy()
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error("compute_budget_thresholds 失败: %s", e, exc_info=True)
|
|
|
+ return ToolResult(title="compute_budget_thresholds 失败", output=str(e))
|
|
|
+
|
|
|
+
|
|
|
+@tool(description="将广告按 ROI×跑量 二维象限分类,阈值从外部传入")
|
|
|
+async def classify_ads(
|
|
|
+ ad_data_json: str,
|
|
|
+ thresholds_json: str,
|
|
|
+ min_open_count: int = 100,
|
|
|
+ context: Optional[ToolContext] = None,
|
|
|
+) -> ToolResult:
|
|
|
+ """
|
|
|
+ 将每条广告分类到 ROI × 跑量 二维象限。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ ad_data_json: 广告效果数据 JSON
|
|
|
+ thresholds_json: 阈值 JSON,如 {"roi_high": 0.05, "roi_low": 0.02, "cost_mid": 500}
|
|
|
+ min_open_count: 有效广告最低打开数
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ ad_data = json.loads(ad_data_json) if isinstance(ad_data_json, str) else ad_data_json
|
|
|
+ thresholds = json.loads(thresholds_json) if isinstance(thresholds_json, str) else thresholds_json
|
|
|
+ df = pd.DataFrame(ad_data)
|
|
|
+
|
|
|
+ df_valid = df[df["open_count"] >= min_open_count].copy()
|
|
|
+ df_nosample = df[df["open_count"] < min_open_count]
|
|
|
|
|
|
if df_valid.empty:
|
|
|
- return ToolResult(title="budget_calculate_from_data", output="无有效广告(open_count >= 100)")
|
|
|
+ return ToolResult(title="classify_ads", output="无有效广告")
|
|
|
+
|
|
|
+ classified = []
|
|
|
+ counts = {"high_high": 0, "high_low": 0, "mid_high": 0, "mid_low": 0, "low_high": 0, "low_low": 0}
|
|
|
+
|
|
|
+ for _, row in df_valid.iterrows():
|
|
|
+ eff = float(row["efficiency"]) if pd.notna(row.get("efficiency")) else 0.0
|
|
|
+ cost = float(row["cost"])
|
|
|
+
|
|
|
+ if eff >= thresholds["roi_high"]:
|
|
|
+ roi_level = "high"
|
|
|
+ elif eff >= thresholds["roi_low"]:
|
|
|
+ roi_level = "mid"
|
|
|
+ else:
|
|
|
+ roi_level = "low"
|
|
|
+
|
|
|
+ volume_level = "high" if cost >= thresholds["cost_mid"] else "low"
|
|
|
+ quadrant = f"{roi_level}_{volume_level}"
|
|
|
+ counts[quadrant] = counts.get(quadrant, 0) + 1
|
|
|
|
|
|
- # Step 5: 计算分位数阈值
|
|
|
- thresholds = _compute_thresholds(df_valid)
|
|
|
+ item = row.to_dict()
|
|
|
+ item["roi_level"] = roi_level
|
|
|
+ item["volume_level"] = volume_level
|
|
|
+ item["quadrant"] = quadrant
|
|
|
+ classified.append(item)
|
|
|
|
|
|
- # Step 6: 判断策略
|
|
|
- yesterday_total = float(df_valid["cost"].sum())
|
|
|
- scale_ratio = total_budget_yuan / yesterday_total if yesterday_total > 0 else 1.0
|
|
|
- if strategy == "auto":
|
|
|
- strategy = _determine_strategy(scale_ratio)
|
|
|
+ lines = [
|
|
|
+ f"广告分类结果({len(classified)}条有效,{len(df_nosample)}条样本不足)",
|
|
|
+ f"阈值: ROI高={thresholds['roi_high']:.4f}, ROI低={thresholds['roi_low']:.4f}, 消耗中位={thresholds['cost_mid']:.0f}元",
|
|
|
+ "",
|
|
|
+ "分布:",
|
|
|
+ ]
|
|
|
+ for q, c in counts.items():
|
|
|
+ roi, vol = q.split("_")
|
|
|
+ lines.append(f" {roi}ROI + {vol}跑量: {c}条")
|
|
|
+
|
|
|
+ return ToolResult(
|
|
|
+ title=f"广告分类({len(classified)}条)",
|
|
|
+ output="\n".join(lines),
|
|
|
+ metadata={
|
|
|
+ "classified_ads": classified,
|
|
|
+ "nosample_ads": df_nosample.to_dict("records"),
|
|
|
+ "distribution": counts,
|
|
|
+ "thresholds_used": thresholds,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error("classify_ads 失败: %s", e, exc_info=True)
|
|
|
+ return ToolResult(title="classify_ads 失败", output=str(e))
|
|
|
+
|
|
|
+
|
|
|
+@tool(description="计算出价调整方案,策略参数(决策矩阵、幅度、保护规则)全部从外部传入")
|
|
|
+async def compute_bid_adjustment(
|
|
|
+ classified_ads_json: str,
|
|
|
+ strategy: str,
|
|
|
+ decision_matrix_json: str = "",
|
|
|
+ max_increase_pct: float = 0.15,
|
|
|
+ max_decrease_pct: float = -0.15,
|
|
|
+ protect_cold_start: bool = True,
|
|
|
+ cold_start_hours: int = 48,
|
|
|
+ cold_start_min_conversions: int = 6,
|
|
|
+ protect_compensation: bool = True,
|
|
|
+ compensation_min_conversions: int = 6,
|
|
|
+ min_bid: int = 10,
|
|
|
+ max_bid: int = 10000,
|
|
|
+ context: Optional[ToolContext] = None,
|
|
|
+) -> ToolResult:
|
|
|
+ """
|
|
|
+ 根据分类结果和策略参数,计算每条广告的出价调整方案。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ classified_ads_json: 分类后的广告数据 JSON(来自 classify_ads)
|
|
|
+ strategy: 策略名称(aggressive_scale_down / moderate_scale_down / maintain / moderate_scale_up / aggressive_scale_up)
|
|
|
+ decision_matrix_json: 决策矩阵 JSON。为空则从配置层加载。
|
|
|
+ 格式: {"high_high": ["keep", 0.0], "mid_high": ["decrease", -0.05], ...}
|
|
|
+ max_increase_pct: 最大提价幅度(默认 0.15 = +15%)
|
|
|
+ max_decrease_pct: 最大降价幅度(默认 -0.15 = -15%)
|
|
|
+ protect_cold_start: 是否启用冷启动保护
|
|
|
+ cold_start_hours: 冷启动期小时数
|
|
|
+ cold_start_min_conversions: 冷启动最少转化数
|
|
|
+ protect_compensation: 是否启用赔付保护
|
|
|
+ compensation_min_conversions: 赔付门槛转化数
|
|
|
+ min_bid: 最低出价(分)
|
|
|
+ max_bid: 最高出价(分)
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ classified_ads = json.loads(classified_ads_json) if isinstance(classified_ads_json, str) else classified_ads_json
|
|
|
+
|
|
|
+ # 加载决策矩阵
|
|
|
+ if decision_matrix_json:
|
|
|
+ matrix = json.loads(decision_matrix_json) if isinstance(decision_matrix_json, str) else decision_matrix_json
|
|
|
+ # 转为 tuple
|
|
|
+ matrix = {k: tuple(v) for k, v in matrix.items()}
|
|
|
+ else:
|
|
|
+ config = get_strategy_config()
|
|
|
+ matrix = get_decision_matrix(config, strategy)
|
|
|
|
|
|
- # Step 7: 二维矩阵决策(含冷启动保护)
|
|
|
results = []
|
|
|
cold_start_count = 0
|
|
|
- for _, row in df_valid.iterrows():
|
|
|
- eff = float(row["efficiency"]) if pd.notna(row["efficiency"]) else 0.0
|
|
|
- cost = float(row["cost"])
|
|
|
- conversions = int(row["conversions_count"]) if pd.notna(row.get("conversions_count")) else 0
|
|
|
|
|
|
- # --- 冷启动保护判定(优先于决策矩阵) ---
|
|
|
+ for ad in classified_ads:
|
|
|
+ conversions = int(ad.get("conversions_count", 0) or 0)
|
|
|
+ quadrant = ad.get("quadrant", f"{ad.get('roi_level', 'mid')}_{ad.get('volume_level', 'low')}")
|
|
|
+
|
|
|
+ # --- 冷启动保护 ---
|
|
|
is_cold_start = False
|
|
|
cold_start_reason = ""
|
|
|
|
|
|
- # 判定1:广告创建时间 < 48 小时
|
|
|
- create_time = row.get("create_time")
|
|
|
- if create_time and pd.notna(create_time):
|
|
|
- try:
|
|
|
- if isinstance(create_time, str):
|
|
|
- ct = datetime.strptime(create_time[:19], "%Y-%m-%d %H:%M:%S")
|
|
|
- else:
|
|
|
- ct = pd.Timestamp(create_time).to_pydatetime()
|
|
|
- hours_since_creation = (datetime.now() - ct).total_seconds() / 3600
|
|
|
- if hours_since_creation < 48:
|
|
|
- is_cold_start = True
|
|
|
- cold_start_reason = f"冷启动期({hours_since_creation:.0f}h<48h)"
|
|
|
- except (ValueError, TypeError):
|
|
|
- pass
|
|
|
-
|
|
|
- # 判定2:转化数不足 < 6
|
|
|
- if conversions < 6 and not is_cold_start:
|
|
|
- is_cold_start = True
|
|
|
- cold_start_reason = f"转化不足({conversions}<6)"
|
|
|
+ if protect_cold_start:
|
|
|
+ create_time = ad.get("create_time")
|
|
|
+ if create_time and pd.notna(create_time):
|
|
|
+ try:
|
|
|
+ if isinstance(create_time, str):
|
|
|
+ ct = datetime.strptime(create_time[:19], "%Y-%m-%d %H:%M:%S")
|
|
|
+ else:
|
|
|
+ ct = pd.Timestamp(create_time).to_pydatetime()
|
|
|
+ hours_since = (datetime.now() - ct).total_seconds() / 3600
|
|
|
+ if hours_since < cold_start_hours:
|
|
|
+ is_cold_start = True
|
|
|
+ cold_start_reason = f"冷启动期({hours_since:.0f}h<{cold_start_hours}h)"
|
|
|
+ except (ValueError, TypeError):
|
|
|
+ pass
|
|
|
+
|
|
|
+ if conversions < cold_start_min_conversions and not is_cold_start:
|
|
|
+ is_cold_start = True
|
|
|
+ cold_start_reason = f"转化不足({conversions}<{cold_start_min_conversions})"
|
|
|
|
|
|
if is_cold_start:
|
|
|
cold_start_count += 1
|
|
|
- roi_level, volume_level = _classify_ad(eff, cost, thresholds)
|
|
|
- action = ACTION_OBSERVE
|
|
|
- adj_ratio = 0.0
|
|
|
+ action, adj_ratio = "observe", 0.0
|
|
|
else:
|
|
|
- roi_level, volume_level = _classify_ad(eff, cost, thresholds)
|
|
|
- action, adj_ratio = _decide_action(roi_level, volume_level, strategy)
|
|
|
-
|
|
|
- # --- 关停保护:赔付门槛检查 ---
|
|
|
- if action == ACTION_CLOSE and 3 <= conversions < 6:
|
|
|
- action = ACTION_OBSERVE
|
|
|
- adj_ratio = 0.0
|
|
|
- cold_start_reason = f"接近赔付门槛({conversions}次转化,等待积累到6)"
|
|
|
-
|
|
|
- bid = row["bid_amount"] if pd.notna(row["bid_amount"]) else None
|
|
|
+ action, adj_ratio = matrix.get(quadrant, ("keep", 0.0))
|
|
|
+
|
|
|
+ # 限制幅度范围
|
|
|
+ if adj_ratio > 0:
|
|
|
+ adj_ratio = min(adj_ratio, max_increase_pct)
|
|
|
+ elif adj_ratio < 0:
|
|
|
+ adj_ratio = max(adj_ratio, max_decrease_pct)
|
|
|
+
|
|
|
+ # --- 赔付保护 ---
|
|
|
+ if protect_compensation and action == "close":
|
|
|
+ if 3 <= conversions < compensation_min_conversions:
|
|
|
+ action = "observe"
|
|
|
+ adj_ratio = 0.0
|
|
|
+ cold_start_reason = f"接近赔付门槛({conversions}次转化,等待积累到{compensation_min_conversions})"
|
|
|
+
|
|
|
+ # 计算新出价
|
|
|
+ bid = ad.get("bid_amount")
|
|
|
+ bid_val = float(bid) if bid and pd.notna(bid) else None
|
|
|
new_bid = None
|
|
|
- if bid and action in (ACTION_INCREASE, ACTION_DECREASE):
|
|
|
- new_bid = max(min_bid_cents, min(MAX_BID, int(float(bid) * (1 + adj_ratio))))
|
|
|
- elif bid:
|
|
|
- new_bid = int(float(bid)) # keep/observe/close 不改出价
|
|
|
+ if bid_val and action in ("increase", "decrease"):
|
|
|
+ new_bid = max(min_bid, min(max_bid, int(bid_val * (1 + adj_ratio))))
|
|
|
+ elif bid_val:
|
|
|
+ new_bid = int(bid_val)
|
|
|
|
|
|
results.append({
|
|
|
"date": datetime.now().strftime("%Y-%m-%d"),
|
|
|
- "ad_id": int(row["ad_id"]),
|
|
|
- "ad_name": str(row["ad_name"]) if pd.notna(row["ad_name"]) else "",
|
|
|
- "account_id": int(row["account_id"]) if pd.notna(row["account_id"]) else 0,
|
|
|
- "roi_level": roi_level,
|
|
|
- "volume_level": volume_level,
|
|
|
- "efficiency": round(eff, 4),
|
|
|
- "cost": round(cost, 2),
|
|
|
- "open_count": int(row["open_count"]),
|
|
|
+ "ad_id": int(ad.get("ad_id", 0)),
|
|
|
+ "ad_name": str(ad.get("ad_name", "")),
|
|
|
+ "account_id": int(ad.get("account_id", 0)),
|
|
|
+ "roi_level": ad.get("roi_level", ""),
|
|
|
+ "volume_level": ad.get("volume_level", ""),
|
|
|
+ "quadrant": quadrant,
|
|
|
+ "efficiency": round(float(ad.get("efficiency", 0) or 0), 4),
|
|
|
+ "cost": round(float(ad.get("cost", 0)), 2),
|
|
|
+ "open_count": int(ad.get("open_count", 0)),
|
|
|
"conversions_count": conversions,
|
|
|
"is_cold_start": is_cold_start,
|
|
|
"cold_start_reason": cold_start_reason,
|
|
|
- "current_bid": int(float(bid)) if bid else None,
|
|
|
+ "current_bid": int(bid_val) if bid_val else None,
|
|
|
"new_bid": new_bid,
|
|
|
- "adjustment_ratio": f"{adj_ratio:+.0%}" if adj_ratio != 0 else "—",
|
|
|
+ "adjustment_ratio": f"{adj_ratio:+.0%}" if adj_ratio != 0 else "-",
|
|
|
"action": action,
|
|
|
- "ad_status": str(row["ad_status"]) if pd.notna(row.get("ad_status")) else "",
|
|
|
+ "ad_status": str(ad.get("ad_status", "")),
|
|
|
})
|
|
|
|
|
|
- # Step 8: 格式化输出(按动作分组)
|
|
|
- direction = "缩量" if scale_ratio < 1 else "扩量" if scale_ratio > 1 else "持平"
|
|
|
+ # 汇总统计
|
|
|
+ action_counts = {}
|
|
|
+ for r in results:
|
|
|
+ action_counts[r["action"]] = action_counts.get(r["action"], 0) + 1
|
|
|
+
|
|
|
+ action_labels = [
|
|
|
+ ("keep", "保持不动"), ("increase", "提价放量"), ("decrease", "降价控量"),
|
|
|
+ ("close", "建议关停"), ("observe", "观察不动"),
|
|
|
+ ]
|
|
|
+
|
|
|
lines = [
|
|
|
- f"出价调整方案({direction} {abs(1-scale_ratio)*100:.0f}%)",
|
|
|
- f"昨日消耗: {yesterday_total:,.0f} 元 → 今日预算: {total_budget_yuan:,.0f} 元",
|
|
|
+ f"出价调整方案({len(results)}条广告)",
|
|
|
f"策略: {strategy}",
|
|
|
- f"阈值: ROI P70={thresholds['roi_p70']:.4f}, P30={thresholds['roi_p30']:.4f}, 消耗 P50={thresholds['cost_p50']:.0f}元",
|
|
|
"",
|
|
|
]
|
|
|
|
|
|
- action_labels = [
|
|
|
- (ACTION_KEEP, "保持不动"),
|
|
|
- (ACTION_INCREASE, "提价放量"),
|
|
|
- (ACTION_DECREASE, "降价控量"),
|
|
|
- (ACTION_CLOSE, "建议关停"),
|
|
|
- (ACTION_OBSERVE, "观察不动"),
|
|
|
- ]
|
|
|
for act, label in action_labels:
|
|
|
sub = [r for r in results if r["action"] == act]
|
|
|
if not sub:
|
|
|
@@ -460,109 +592,52 @@ WHERE ad_id IN ({ad_ids_str})
|
|
|
lines.append(f" ... 还有 {len(sub)-5} 个")
|
|
|
lines.append("")
|
|
|
|
|
|
- if len(df_nosample) > 0:
|
|
|
- lines.append(f"【样本不足 - {len(df_nosample)}个,本次不操作】")
|
|
|
- lines.append("")
|
|
|
-
|
|
|
if cold_start_count > 0:
|
|
|
- cold_start_ads = [r for r in results if r.get("is_cold_start")]
|
|
|
- lines.append(f"【冷启动保护 - {cold_start_count}个,标记observe不调价】")
|
|
|
- for item in cold_start_ads[:5]:
|
|
|
- lines.append(f" {item['ad_id']} | {item['cold_start_reason']} | 转化:{item['conversions_count']} | 消耗:{item['cost']:.0f}元")
|
|
|
+ cold_ads = [r for r in results if r["is_cold_start"]]
|
|
|
+ lines.append(f"【冷启动保护 - {cold_start_count}个】")
|
|
|
+ for item in cold_ads[:5]:
|
|
|
+ lines.append(f" {item['ad_id']} | {item['cold_start_reason']} | 转化:{item['conversions_count']}")
|
|
|
if cold_start_count > 5:
|
|
|
lines.append(f" ... 还有 {cold_start_count - 5} 个")
|
|
|
lines.append("")
|
|
|
|
|
|
- # 汇总
|
|
|
- action_counts = {}
|
|
|
- for r in results:
|
|
|
- action_counts[r["action"]] = action_counts.get(r["action"], 0) + 1
|
|
|
summary_parts = [f"{label}:{action_counts.get(act, 0)}" for act, label in action_labels]
|
|
|
- lines.append(f"合计:{' / '.join(summary_parts)} / 样本不足:{len(df_nosample)} / 冷启动保护:{cold_start_count}")
|
|
|
-
|
|
|
- # Step 9: 输出 Excel(按动作颜色标识)
|
|
|
- try:
|
|
|
- import openpyxl
|
|
|
- from openpyxl.styles import PatternFill, Font
|
|
|
-
|
|
|
- ACTION_COLORS = {
|
|
|
- "increase": "C6EFCE", # 绿
|
|
|
- "decrease": "FFEB9C", # 橙黄
|
|
|
- "close": "FFC7CE", # 红
|
|
|
- "observe": "FFFF99", # 黄
|
|
|
- "keep": "FFFFFF", # 白
|
|
|
- }
|
|
|
-
|
|
|
- output_dir = Path(__file__).parent.parent / "outputs"
|
|
|
- output_dir.mkdir(exist_ok=True)
|
|
|
- xlsx_path = output_dir / f"adjustment_plan_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
|
|
-
|
|
|
- headers_cn = ["日期", "账户ID", "广告ID", "广告名称", "动作", "当前出价(分)", "新出价(分)",
|
|
|
- "调整幅度", "ROI等级", "跑量等级", "效率分", "昨日消耗(元)", "打开数",
|
|
|
- "转化数", "冷启动", "冷启动原因", "广告状态"]
|
|
|
- fields_en = ["date", "account_id", "ad_id", "ad_name", "action", "current_bid", "new_bid",
|
|
|
- "adjustment_ratio", "roi_level", "volume_level", "efficiency", "cost",
|
|
|
- "open_count", "conversions_count", "is_cold_start", "cold_start_reason", "ad_status"]
|
|
|
-
|
|
|
- wb = openpyxl.Workbook()
|
|
|
- ws = wb.active
|
|
|
- ws.title = "调整方案"
|
|
|
-
|
|
|
- # 表头(加粗)
|
|
|
- ws.append(headers_cn)
|
|
|
- for cell in ws[1]:
|
|
|
- cell.font = Font(bold=True)
|
|
|
-
|
|
|
- # 数据行 + 颜色
|
|
|
- for row in results:
|
|
|
- ws.append([row.get(f) for f in fields_en])
|
|
|
- color = ACTION_COLORS.get(row.get("action", "keep"), "FFFFFF")
|
|
|
- fill = PatternFill(fill_type="solid", fgColor=color)
|
|
|
- for cell in ws[ws.max_row]:
|
|
|
- cell.fill = fill
|
|
|
-
|
|
|
- # 冻结首行 + 列宽
|
|
|
- ws.freeze_panes = "A2"
|
|
|
- for col in ws.columns:
|
|
|
- ws.column_dimensions[col[0].column_letter].width = 16
|
|
|
-
|
|
|
- wb.save(xlsx_path)
|
|
|
- lines.append(f"\n📄 完整方案已输出: {xlsx_path}")
|
|
|
- logger.info("方案已保存: %s", xlsx_path)
|
|
|
- except Exception as xlsx_err:
|
|
|
- logger.warning("xlsx 输出失败(不影响主流程): %s", xlsx_err)
|
|
|
+ lines.append(f"合计: {' / '.join(summary_parts)} / 冷启动保护:{cold_start_count}")
|
|
|
|
|
|
return ToolResult(
|
|
|
- title=f"出价调整方案({len(results)}个广告,{direction}{abs(1-scale_ratio)*100:.0f}%)",
|
|
|
+ title=f"出价调整方案({len(results)}条,{strategy})",
|
|
|
output="\n".join(lines),
|
|
|
metadata={
|
|
|
"adjustment_plan": results,
|
|
|
"strategy": strategy,
|
|
|
- "scale_ratio": scale_ratio,
|
|
|
- "thresholds": thresholds,
|
|
|
- "yesterday_total": yesterday_total,
|
|
|
- "total_budget_yuan": total_budget_yuan,
|
|
|
- "nosample_count": len(df_nosample),
|
|
|
- "cold_start_count": cold_start_count,
|
|
|
"action_counts": action_counts,
|
|
|
+ "cold_start_count": cold_start_count,
|
|
|
+ "params": {
|
|
|
+ "max_increase_pct": max_increase_pct,
|
|
|
+ "max_decrease_pct": max_decrease_pct,
|
|
|
+ "protect_cold_start": protect_cold_start,
|
|
|
+ "protect_compensation": protect_compensation,
|
|
|
+ },
|
|
|
},
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
- logger.error("budget_calculate_from_data 失败: %s", e, exc_info=True)
|
|
|
- return ToolResult(title="budget_calculate_from_data 失败", output=str(e))
|
|
|
+ logger.error("compute_bid_adjustment 失败: %s", e, exc_info=True)
|
|
|
+ return ToolResult(title="compute_bid_adjustment 失败", output=str(e))
|
|
|
|
|
|
|
|
|
-# ===== 执行出价调整 =====
|
|
|
+# ═════════════════════════════════════════════════════════════
|
|
|
+# 执行层 — 调用 API 执行调整
|
|
|
+# ═════════════════════════════════════════════════════════════
|
|
|
|
|
|
-@tool(description="执行出价调整方案")
|
|
|
+@tool(description="执行出价调整方案,批量调用 API 修改出价")
|
|
|
async def bid_adjustment_execute(
|
|
|
adjustment_plan: List[Dict],
|
|
|
account_id: int,
|
|
|
context: Optional[ToolContext] = None,
|
|
|
) -> ToolResult:
|
|
|
"""
|
|
|
- 批量执行出价调整
|
|
|
+ 批量执行出价调整。
|
|
|
|
|
|
Args:
|
|
|
adjustment_plan: 调整方案列表,每项包含 ad_id, new_bid, action
|
|
|
@@ -606,3 +681,12 @@ async def bid_adjustment_execute(
|
|
|
title="出价调整执行结果",
|
|
|
output="\n".join(output_lines),
|
|
|
)
|
|
|
+
|
|
|
+
|
|
|
+# ═════════════════════════════════════════════════════════════
|
|
|
+# 兼容层 — 保留旧接口,内部调用新工具链
|
|
|
+# ═════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+# 保留旧名称引用,避免 config.py 中的工具白名单报错
|
|
|
+account_evaluate = get_account_summary
|
|
|
+budget_calculate_from_data = None # 已废弃,功能拆分到 get_ad_performance + compute_* 工具链
|