guardrails.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962
  1. """
  2. 安全护栏引擎 — auto_put_ad_mini
  3. 7 道护栏按顺序执行:
  4. 1. ColdStartGuardrail — 冷启动保护
  5. 2. DataFreshnessGuardrail — 数据新鲜度校验
  6. 3. BidBoundaryGuardrail — 出价边界钳位(0.05~1.00元)
  7. 3.5 BidRangeGuardrail — 调幅范围钳位(提价5-10%, 降价3-5%)
  8. 4. RateLimitGuardrail — 频率限制(每日次数/间隔/累计调幅)
  9. 5. DailyOpsCapGuardrail — 每日操作总量上限
  10. 6. DryRunGuardrail — 干运行模式
  11. 每道护栏输出:approved / blocked / modified
  12. blocked = 阻止操作,modified = 自动修正参数后放行
  13. """
  14. import json
  15. import logging
  16. import sys
  17. from abc import ABC, abstractmethod
  18. from dataclasses import dataclass, field
  19. from datetime import datetime, timedelta
  20. from pathlib import Path
  21. from typing import Dict, List, Optional
  22. from zoneinfo import ZoneInfo
  23. import pandas as pd
  24. from agent.tools import tool
  25. from agent.tools.models import ToolContext, ToolResult
  26. _MINI_DIR = Path(__file__).resolve().parent.parent
  27. _TOOLS_DIR = Path(__file__).resolve().parent
  28. if str(_MINI_DIR) not in sys.path:
  29. sys.path.insert(0, str(_MINI_DIR))
  30. if str(_TOOLS_DIR) not in sys.path:
  31. sys.path.insert(0, str(_TOOLS_DIR))
  32. from config import (
  33. COLD_START_DAYS,
  34. CAUTIOUS_DAYS,
  35. BID_FLOOR_YUAN,
  36. BID_CEILING_YUAN,
  37. BID_UP_MIN_PCT,
  38. BID_UP_MAX_PCT,
  39. BID_DOWN_MIN_PCT,
  40. BID_DOWN_MAX_PCT,
  41. BID_UP_ROI_FACTOR,
  42. ROI_LOW_FACTOR,
  43. MAX_ADJUSTMENTS_PER_AD_PER_DAY,
  44. MIN_ADJUSTMENT_INTERVAL_HOURS,
  45. MAX_DAILY_CUMULATIVE_CHANGE_PCT,
  46. MAX_DAILY_OPS,
  47. DATA_FRESHNESS_MAX_HOURS,
  48. ADJUSTMENT_HISTORY_PATH,
  49. DRY_RUN_MODE,
  50. GUARDRAILS_ENABLED,
  51. DATA_DIR,
  52. TIMEZONE,
  53. CREATIVE_MIN_COST_SHARE,
  54. CREATIVE_MIN_AGE_DAYS,
  55. CREATIVE_MIN_REMAINING,
  56. CREATIVE_RATELIMIT_DAYS,
  57. )
  58. CREATIVE_PAUSE_HISTORY_PATH = DATA_DIR / "creative_pause_history.json"
  59. logger = logging.getLogger(__name__)
  60. # ═══════════════════════════════════════════
  61. # 调整历史持久化
  62. # ═══════════════════════════════════════════
  63. class AdjustmentHistory:
  64. """广告调整历史记录(JSON 文件持久化)。"""
  65. def __init__(self, path: Path = ADJUSTMENT_HISTORY_PATH):
  66. self._path = path
  67. self._data: Dict[str, Dict] = {}
  68. self._load()
  69. def _load(self):
  70. """从 JSON 文件加载调整历史,失败时使用空记录。"""
  71. if self._path.exists():
  72. try:
  73. self._data = json.loads(self._path.read_text(encoding="utf-8"))
  74. except Exception as e:
  75. logger.warning("加载调整历史失败,使用空记录: %s", e)
  76. self._data = {}
  77. def _save(self):
  78. """将调整历史持久化到 JSON 文件。"""
  79. self._path.parent.mkdir(parents=True, exist_ok=True)
  80. self._path.write_text(
  81. json.dumps(self._data, ensure_ascii=False, indent=2),
  82. encoding="utf-8",
  83. )
  84. def get_today_adjustments(self, ad_id: str) -> List[Dict]:
  85. """获取某广告今天的调整记录。"""
  86. today = datetime.now().strftime("%Y-%m-%d")
  87. record = self._data.get(str(ad_id), {})
  88. adjustments = record.get("adjustments", [])
  89. return [a for a in adjustments if a.get("ts", "").startswith(today)]
  90. def get_last_adjustment_ts(self, ad_id: str) -> Optional[datetime]:
  91. """获取某广告最后一次调整的时间。"""
  92. record = self._data.get(str(ad_id), {})
  93. last_ts = record.get("last_ts")
  94. if last_ts:
  95. try:
  96. return datetime.fromisoformat(last_ts)
  97. except ValueError:
  98. return None
  99. return None
  100. def get_cumulative_pct_today(self, ad_id: str) -> float:
  101. """获取某广告今天的累计调幅绝对值。"""
  102. today_adj = self.get_today_adjustments(str(ad_id))
  103. return sum(abs(a.get("pct", 0)) for a in today_adj)
  104. def record_adjustment(self, ad_id: str, action: str, pct: float):
  105. """记录一次调整。"""
  106. ad_key = str(ad_id)
  107. now = datetime.now().isoformat()
  108. if ad_key not in self._data:
  109. self._data[ad_key] = {"adjustments": [], "last_ts": None}
  110. self._data[ad_key]["adjustments"].append({
  111. "ts": now,
  112. "action": action,
  113. "pct": pct,
  114. })
  115. self._data[ad_key]["last_ts"] = now
  116. # 只保留最近 14 天的记录(扩展:支持"持续低ROI升级关停"需查7天前降价记录)
  117. cutoff = (datetime.now() - timedelta(days=14)).isoformat()
  118. self._data[ad_key]["adjustments"] = [
  119. a for a in self._data[ad_key]["adjustments"]
  120. if a.get("ts", "") >= cutoff
  121. ]
  122. self._save()
  123. def get_today_total_ops(self) -> int:
  124. """获取今天已操作的广告总数。"""
  125. today = datetime.now().strftime("%Y-%m-%d")
  126. count = 0
  127. for ad_key, record in self._data.items():
  128. adjustments = record.get("adjustments", [])
  129. if any(a.get("ts", "").startswith(today) for a in adjustments):
  130. count += 1
  131. return count
  132. def get_last_bid_down_ts(self, ad_id: str) -> Optional[datetime]:
  133. """获取某广告最近一次 bid_down 的时间(用于"持续低ROI升级关停"判断)。"""
  134. record = self._data.get(str(ad_id), {})
  135. adjustments = record.get("adjustments", [])
  136. bid_downs = [a for a in adjustments if a.get("action") == "bid_down"]
  137. if bid_downs:
  138. last = max(bid_downs, key=lambda a: a.get("ts", ""))
  139. try:
  140. return datetime.fromisoformat(last["ts"])
  141. except ValueError:
  142. return None
  143. return None
  144. # ═══════════════════════════════════════════
  145. # 创意级 pause 历史(用于创意级速率限制 + 审计回溯)
  146. # ═══════════════════════════════════════════
  147. class CreativePauseHistory:
  148. """创意级 pause 历史(JSON 文件持久化,key 为 'ad_id:creative_id')。"""
  149. def __init__(self, path: Path = CREATIVE_PAUSE_HISTORY_PATH):
  150. self._path = path
  151. self._data: Dict[str, Dict] = {}
  152. self._load()
  153. def _load(self):
  154. """从 JSON 文件加载创意 pause 历史,失败时使用空记录。"""
  155. if self._path.exists():
  156. try:
  157. self._data = json.loads(self._path.read_text(encoding="utf-8"))
  158. except Exception as e:
  159. logger.warning("加载创意 pause 历史失败,使用空记录: %s", e)
  160. self._data = {}
  161. def _save(self):
  162. """将创意 pause 历史持久化到 JSON 文件。"""
  163. self._path.parent.mkdir(parents=True, exist_ok=True)
  164. self._path.write_text(
  165. json.dumps(self._data, ensure_ascii=False, indent=2),
  166. encoding="utf-8",
  167. )
  168. @staticmethod
  169. def _key(ad_id, creative_id) -> str:
  170. """构造 'ad_id:creative_id' 形式的记录键。"""
  171. return f"{ad_id}:{creative_id}"
  172. def get_last_pause_ts(self, ad_id, creative_id) -> Optional[datetime]:
  173. """获取某创意最近一次 pause 的时间,无记录返回 None。"""
  174. record = self._data.get(self._key(ad_id, creative_id))
  175. if not record:
  176. return None
  177. last_ts = record.get("last_ts")
  178. if last_ts:
  179. try:
  180. return datetime.fromisoformat(last_ts)
  181. except ValueError:
  182. return None
  183. return None
  184. def was_paused_within(self, ad_id, creative_id, days: int) -> bool:
  185. """判断某创意在最近 days 天内是否被 pause 过(用于速率限制)。"""
  186. last = self.get_last_pause_ts(ad_id, creative_id)
  187. if last is None:
  188. return False
  189. return (datetime.now() - last) < timedelta(days=days)
  190. def record_pause(self, ad_id, creative_id, action: str = "pause"):
  191. """记录一次创意级 pause 事件(仅保留最近 30 天历史)。"""
  192. key = self._key(ad_id, creative_id)
  193. now = datetime.now().isoformat()
  194. if key not in self._data:
  195. self._data[key] = {"ad_id": str(ad_id), "creative_id": str(creative_id),
  196. "events": [], "last_ts": None}
  197. self._data[key]["events"].append({"ts": now, "action": action})
  198. self._data[key]["last_ts"] = now
  199. # 只保留 30 天历史
  200. cutoff = (datetime.now() - timedelta(days=30)).isoformat()
  201. self._data[key]["events"] = [
  202. e for e in self._data[key]["events"] if e.get("ts", "") >= cutoff
  203. ]
  204. self._save()
  205. # ═══════════════════════════════════════════
  206. # 护栏检查结果
  207. # ═══════════════════════════════════════════
  208. @dataclass
  209. class GuardrailResult:
  210. """单个护栏的检查结果。"""
  211. status: str # "approved" / "blocked" / "modified"
  212. reason: str
  213. modified_action: Optional[str] = None
  214. modified_bid: Optional[float] = None
  215. modified_change_pct: Optional[float] = None
  216. # ═══════════════════════════════════════════
  217. # 护栏基类
  218. # ═══════════════════════════════════════════
  219. class Guardrail(ABC):
  220. """护栏基类。"""
  221. @property
  222. @abstractmethod
  223. def name(self) -> str:
  224. """护栏名称(用于日志和 guardrail_reason 前缀)。"""
  225. pass
  226. @abstractmethod
  227. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  228. """
  229. 检查单个决策是否通过护栏。
  230. Args:
  231. row: 决策行(包含 action, ad_id, recommended_change_pct 等)
  232. history: 调整历史记录
  233. Returns:
  234. GuardrailResult
  235. """
  236. pass
  237. # ═══════════════════════════════════════════
  238. # 护栏 0: 决策一致性自检(action ↔ pct ↔ ROI 方向对齐)
  239. # ═══════════════════════════════════════════
  240. class ActionConsistencyGuardrail(Guardrail):
  241. """决策一致性护栏:拦住 LLM 的自相矛盾输出。
  242. 分两个子系列:
  243. A. 方向一致性(action ↔ pct 符号)
  244. A1: action=bid_down 必须 pct < 0(否则改 hold)
  245. A2: action=bid_up 必须 pct > 0
  246. A3: action=hold/observe 必须 pct = 0
  247. B. ROI 水位一致性(action ↔ 广告 ROI vs 渠道P50)
  248. B1: bid_down 且 dynamic_roi_7d >= 渠道P50 × 1.05 → 改 hold(Top 1 实测事故)
  249. B2: bid_up 且 dynamic_roi_7d < 渠道P50 × 0.75 → 改 hold(低效广告不能加码)
  250. B3: scale_up 且 dynamic_roi_7d < 渠道P50 × 0.90 → 改 observe(扩量先证明效率)
  251. 依赖:
  252. - row["动态ROI_7日均值"]:广告自身 ROI(由 ad_decision 合并到 decisions CSV)
  253. - row["_channel_roi_p50"]:渠道P50(由 _run_guardrails 从 metrics 算后注入)
  254. 任一值缺失时 B 系列自动跳过(降级为纯 A 系列),保证旧数据仍可通过。
  255. """
  256. @property
  257. def name(self) -> str:
  258. return "决策一致性"
  259. @staticmethod
  260. def _to_float(val) -> Optional[float]:
  261. """安全转 float:None/NaN/空串/非数字 → None。"""
  262. if val is None or val == "":
  263. return None
  264. try:
  265. f = float(val)
  266. if pd.isna(f):
  267. return None
  268. return f
  269. except (ValueError, TypeError):
  270. return None
  271. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  272. """检查 action/pct/裂变信号是否自洽,冲突时改写为 hold/observe。"""
  273. action = str(row.get("action", "hold")).strip()
  274. # pct 标准化(None/NaN/非数字 → 0)
  275. pct = self._to_float(row.get("recommended_change_pct")) or 0.0
  276. # ==================== A 系列:方向一致性 ====================
  277. if action == "bid_down" and pct >= 0:
  278. return GuardrailResult(
  279. status="modified",
  280. reason=f"方向冲突:action=bid_down 但 pct={pct:+.2%}(应为负)→ 改 hold",
  281. modified_action="hold",
  282. modified_change_pct=0.0,
  283. modified_bid=None,
  284. )
  285. if action == "bid_up" and pct <= 0:
  286. return GuardrailResult(
  287. status="modified",
  288. reason=f"方向冲突:action=bid_up 但 pct={pct:+.2%}(应为正)→ 改 hold",
  289. modified_action="hold",
  290. modified_change_pct=0.0,
  291. modified_bid=None,
  292. )
  293. if action in ("hold", "observe") and abs(pct) > 1e-6:
  294. return GuardrailResult(
  295. status="modified",
  296. reason=f"维持类动作不应改出价:action={action} 但 pct={pct:+.2%} → pct 归零",
  297. modified_action=action,
  298. modified_change_pct=0.0,
  299. modified_bid=None,
  300. )
  301. # ==================== B4: 裂变-关停冲突 ====================
  302. # pause 但裂变率显著优于同类(>1.10×)→ 改 observe(裂变好说明广告质量有潜力)
  303. if action == "pause":
  304. ad_fission = self._to_float(row.get("_ad_fission"))
  305. tier_fission_mean = self._to_float(row.get("_tier_fission_mean"))
  306. if (
  307. ad_fission is not None
  308. and tier_fission_mean is not None
  309. and tier_fission_mean > 0
  310. and ad_fission > tier_fission_mean * 1.10
  311. ):
  312. return GuardrailResult(
  313. status="modified",
  314. reason=(
  315. f"裂变-关停冲突:裂变率={ad_fission:.2f} > 同类均值{tier_fission_mean:.2f}×1.10"
  316. f"={tier_fission_mean * 1.10:.2f}(裂变优秀,广告质量有潜力),"
  317. f"不应关停 → 改 observe"
  318. ),
  319. modified_action="observe",
  320. modified_change_pct=0.0,
  321. modified_bid=None,
  322. )
  323. return GuardrailResult(status="approved", reason="")
  324. # ═══════════════════════════════════════════
  325. # 护栏 1: 冷启动保护
  326. # ═══════════════════════════════════════════
  327. class ColdStartGuardrail(Guardrail):
  328. """冷启动保护:0-4天不做负向操作,4-7天仅允许小幅降价。"""
  329. @property
  330. def name(self) -> str:
  331. return "冷启动保护"
  332. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  333. """按广告年龄分层拦截:冷启动期禁止所有操作,早期成长期仅允许提价。"""
  334. action = row.get("action", "hold")
  335. ad_age = row.get("ad_age_days")
  336. if ad_age is None or action == "hold":
  337. return GuardrailResult(status="approved", reason="")
  338. # 冷启动期(≤3天):极度保护,禁止所有操作
  339. if ad_age <= COLD_START_DAYS:
  340. if action in ("pause", "bid_down", "bid_up"):
  341. return GuardrailResult(
  342. status="blocked",
  343. reason=f"冷启动期({ad_age}天 ≤ {COLD_START_DAYS}天),极度保护,禁止{action}",
  344. modified_action="hold",
  345. )
  346. # 早期成长期(4-7天):仅允许提价
  347. elif ad_age <= CAUTIOUS_DAYS:
  348. # 早期成长期(4-7天):仅允许提价
  349. if action in ("pause", "bid_down"):
  350. return GuardrailResult(
  351. status="blocked",
  352. reason=f"早期成长期({ad_age}天,4-{CAUTIOUS_DAYS}天),仅允许提价,禁止{action}",
  353. modified_action="hold",
  354. )
  355. return GuardrailResult(status="approved", reason="")
  356. # ═══════════════════════════════════════════
  357. # 护栏 2: 数据新鲜度
  358. # ═══════════════════════════════════════════
  359. class DataFreshnessGuardrail(Guardrail):
  360. """数据新鲜度校验:数据超过 26 小时视为过期。"""
  361. @property
  362. def name(self) -> str:
  363. return "数据新鲜度"
  364. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  365. """校验数据日期新鲜度,超过上限小时数则阻断所有操作。"""
  366. action = row.get("action", "hold")
  367. if action == "hold":
  368. return GuardrailResult(status="approved", reason="")
  369. data_date = row.get("_data_date") # 由工具注入
  370. if data_date:
  371. try:
  372. data_dt = datetime.strptime(str(data_date), "%Y%m%d")
  373. # 使用时区感知的 datetime(支持海外部署)
  374. now = datetime.now(ZoneInfo(TIMEZONE))
  375. # data_dt 需要本地化到相同时区
  376. data_dt_aware = data_dt.replace(tzinfo=ZoneInfo(TIMEZONE))
  377. hours_old = (now - data_dt_aware).total_seconds() / 3600
  378. if hours_old > DATA_FRESHNESS_MAX_HOURS:
  379. return GuardrailResult(
  380. status="blocked",
  381. reason=f"数据已过期({hours_old:.0f}小时前,上限{DATA_FRESHNESS_MAX_HOURS}小时),阻止所有操作",
  382. modified_action="hold",
  383. )
  384. except ValueError:
  385. pass
  386. return GuardrailResult(status="approved", reason="")
  387. # ═══════════════════════════════════════════
  388. # 护栏 3: 出价边界
  389. # ═══════════════════════════════════════════
  390. class BidBoundaryGuardrail(Guardrail):
  391. """出价边界检查:钳位到 [BID_FLOOR, BID_CEILING]。"""
  392. @property
  393. def name(self) -> str:
  394. return "出价边界"
  395. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  396. """检查建议出价是否越界,越界时钳位到 [下限, 上限] 并重算调幅。"""
  397. action = row.get("action", "hold")
  398. if action not in ("bid_up", "bid_down"):
  399. return GuardrailResult(status="approved", reason="")
  400. recommended_bid = row.get("recommended_bid")
  401. if recommended_bid is None or recommended_bid == "":
  402. return GuardrailResult(status="approved", reason="")
  403. recommended_bid = float(recommended_bid)
  404. current_bid = float(row.get("current_bid", 0) or 0)
  405. if recommended_bid < BID_FLOOR_YUAN:
  406. new_bid = BID_FLOOR_YUAN
  407. new_pct = (new_bid - current_bid) / current_bid if current_bid > 0 else 0
  408. return GuardrailResult(
  409. status="modified",
  410. reason=f"出价{recommended_bid:.2f}元低于下限{BID_FLOOR_YUAN}元,钳位至{new_bid:.2f}元",
  411. modified_bid=new_bid,
  412. modified_change_pct=round(new_pct, 4),
  413. )
  414. elif recommended_bid > BID_CEILING_YUAN:
  415. new_bid = BID_CEILING_YUAN
  416. new_pct = (new_bid - current_bid) / current_bid if current_bid > 0 else 0
  417. return GuardrailResult(
  418. status="modified",
  419. reason=f"出价{recommended_bid:.2f}元超过上限{BID_CEILING_YUAN}元,钳位至{new_bid:.2f}元",
  420. modified_bid=new_bid,
  421. modified_change_pct=round(new_pct, 4),
  422. )
  423. return GuardrailResult(status="approved", reason="")
  424. # ═══════════════════════════════════════════
  425. # 护栏 3.5: 调幅范围钳位
  426. # ═══════════════════════════════════════════
  427. class BidRangeGuardrail(Guardrail):
  428. """调幅范围钳位:bid_up 钳位到 [5%, 10%],bid_down 钳位到 [-5%, -3%]。"""
  429. @property
  430. def name(self) -> str:
  431. return "调幅范围"
  432. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  433. """检查调幅是否超出允许范围,超出时钳位并重算出价。"""
  434. action = row.get("action", "hold")
  435. if action not in ("bid_up", "bid_down"):
  436. return GuardrailResult(status="approved", reason="")
  437. change_pct = row.get("recommended_change_pct")
  438. if change_pct is None or change_pct == "":
  439. return GuardrailResult(status="approved", reason="")
  440. change_pct = float(change_pct)
  441. current_bid = float(row.get("current_bid", 0) or 0)
  442. if action == "bid_up":
  443. # 提价:钳位到 [BID_UP_MIN_PCT, BID_UP_MAX_PCT]
  444. clamped = max(BID_UP_MIN_PCT, min(BID_UP_MAX_PCT, change_pct))
  445. if abs(clamped - change_pct) > 0.001:
  446. new_bid = round(current_bid * (1 + clamped), 2) if current_bid > 0 else None
  447. return GuardrailResult(
  448. status="modified",
  449. reason=f"提价调幅从{change_pct*100:.1f}%钳位至{clamped*100:.1f}%(范围{BID_UP_MIN_PCT*100:.0f}%-{BID_UP_MAX_PCT*100:.0f}%)",
  450. modified_change_pct=round(clamped, 4),
  451. modified_bid=new_bid,
  452. )
  453. elif action == "bid_down":
  454. # 降价:change_pct 应为负数,钳位到 [-BID_DOWN_MAX_PCT, -BID_DOWN_MIN_PCT]
  455. clamped = min(-BID_DOWN_MIN_PCT, max(-BID_DOWN_MAX_PCT, change_pct))
  456. if abs(clamped - change_pct) > 0.001:
  457. new_bid = round(current_bid * (1 + clamped), 2) if current_bid > 0 else None
  458. return GuardrailResult(
  459. status="modified",
  460. reason=f"降价调幅从{change_pct*100:.1f}%钳位至{clamped*100:.1f}%(范围-{BID_DOWN_MAX_PCT*100:.0f}%~-{BID_DOWN_MIN_PCT*100:.0f}%)",
  461. modified_change_pct=round(clamped, 4),
  462. modified_bid=new_bid,
  463. )
  464. return GuardrailResult(status="approved", reason="")
  465. # ═══════════════════════════════════════════
  466. # 护栏 4: 频率限制
  467. # ═══════════════════════════════════════════
  468. class RateLimitGuardrail(Guardrail):
  469. """频率限制:每日次数/间隔/累计调幅。"""
  470. @property
  471. def name(self) -> str:
  472. return "频率限制"
  473. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  474. """基于调整历史限频:每日次数、最小间隔、日累计调幅超限时阻断或缩减。"""
  475. action = row.get("action", "hold")
  476. if action not in ("bid_up", "bid_down", "pause"):
  477. return GuardrailResult(status="approved", reason="")
  478. ad_id = str(row.get("ad_id", ""))
  479. # 今日已调整次数
  480. today_adj = history.get_today_adjustments(ad_id)
  481. if len(today_adj) >= MAX_ADJUSTMENTS_PER_AD_PER_DAY:
  482. return GuardrailResult(
  483. status="blocked",
  484. reason=f"今日已调整{len(today_adj)}次(上限{MAX_ADJUSTMENTS_PER_AD_PER_DAY}次)",
  485. modified_action="hold",
  486. )
  487. # 距上次调整间隔
  488. last_ts = history.get_last_adjustment_ts(ad_id)
  489. if last_ts:
  490. hours_since = (datetime.now() - last_ts).total_seconds() / 3600
  491. if hours_since < MIN_ADJUSTMENT_INTERVAL_HOURS:
  492. return GuardrailResult(
  493. status="blocked",
  494. reason=f"距上次调整仅{hours_since:.1f}小时(最小间隔{MIN_ADJUSTMENT_INTERVAL_HOURS}小时)",
  495. modified_action="hold",
  496. )
  497. # 日累计调幅
  498. if action in ("bid_up", "bid_down"):
  499. change_pct = row.get("recommended_change_pct", 0)
  500. if isinstance(change_pct, str):
  501. try:
  502. change_pct = float(change_pct)
  503. except ValueError:
  504. change_pct = 0
  505. cumulative = history.get_cumulative_pct_today(ad_id)
  506. if cumulative + abs(change_pct) > MAX_DAILY_CUMULATIVE_CHANGE_PCT:
  507. remaining = MAX_DAILY_CUMULATIVE_CHANGE_PCT - cumulative
  508. if remaining <= 0:
  509. return GuardrailResult(
  510. status="blocked",
  511. reason=f"日累计调幅已达{cumulative*100:.1f}%(上限{MAX_DAILY_CUMULATIVE_CHANGE_PCT*100:.0f}%)",
  512. modified_action="hold",
  513. )
  514. else:
  515. # 修正调幅
  516. direction = 1 if change_pct > 0 else -1
  517. new_pct = direction * remaining
  518. current_bid = float(row.get("current_bid", 0) or 0)
  519. new_bid = round(current_bid * (1 + new_pct), 2) if current_bid > 0 else None
  520. return GuardrailResult(
  521. status="modified",
  522. reason=f"调幅从{abs(change_pct)*100:.1f}%缩减至{abs(new_pct)*100:.1f}%(日累计限制)",
  523. modified_change_pct=round(new_pct, 4),
  524. modified_bid=new_bid,
  525. )
  526. return GuardrailResult(status="approved", reason="")
  527. # ═══════════════════════════════════════════
  528. # 护栏 5: 每日操作总量上限
  529. # ═══════════════════════════════════════════
  530. class DailyOpsCapGuardrail(Guardrail):
  531. """每日操作总量上限:单日最多操作 N 个广告。"""
  532. @property
  533. def name(self) -> str:
  534. return "每日操作上限"
  535. def __init__(self):
  536. self._approved_count = 0
  537. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  538. """检查今日操作总量(历史记录 + 本批已放行数),超上限则阻断。"""
  539. action = row.get("action", "hold")
  540. if action == "hold":
  541. return GuardrailResult(status="approved", reason="")
  542. total_ops = history.get_today_total_ops() + self._approved_count
  543. if total_ops >= MAX_DAILY_OPS:
  544. return GuardrailResult(
  545. status="blocked",
  546. reason=f"今日已操作{total_ops}个广告(上限{MAX_DAILY_OPS}个),请按ROI严重度排优先级",
  547. modified_action="hold",
  548. )
  549. self._approved_count += 1
  550. return GuardrailResult(status="approved", reason="")
  551. # ═══════════════════════════════════════════
  552. # 护栏 6: 干运行模式
  553. # ═══════════════════════════════════════════
  554. class DryRunGuardrail(Guardrail):
  555. """干运行模式:全部标记为 dry_run。"""
  556. @property
  557. def name(self) -> str:
  558. return "干运行模式"
  559. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  560. """干运行模式下将所有非 hold 操作标记为 modified(不实际执行)。"""
  561. action = row.get("action", "hold")
  562. if action == "hold":
  563. return GuardrailResult(status="approved", reason="")
  564. if DRY_RUN_MODE:
  565. return GuardrailResult(
  566. status="modified",
  567. reason="干运行模式:操作不会实际执行",
  568. )
  569. return GuardrailResult(status="approved", reason="")
  570. # ═══════════════════════════════════════════
  571. # 护栏引擎
  572. # ═══════════════════════════════════════════
  573. def _run_guardrails(
  574. df: pd.DataFrame,
  575. data_date: str,
  576. dry_run: bool = False,
  577. channel_roi_p50: Optional[float] = None,
  578. ) -> pd.DataFrame:
  579. """
  580. 对决策 DataFrame 执行 2 道护栏检查。
  581. 新增列:
  582. - guardrail_status: approved / blocked / modified
  583. - guardrail_reason: 护栏说明
  584. - final_action: 护栏修正后的最终动作
  585. - final_bid: 护栏修正后的最终出价
  586. Args:
  587. df: 决策 DataFrame
  588. data_date: 数据日期(YYYYMMDD)
  589. dry_run: 是否强制干运行
  590. channel_roi_p50: 渠道P50(全体广告动态ROI_7日均值的中位数),
  591. 用于 ActionConsistencyGuardrail B 系列规则。缺省时 B 系列跳过。
  592. """
  593. history = AdjustmentHistory()
  594. guardrails = [
  595. # 暂时全部关闭,测试 LLM 裸输出 + 字段精简的效果
  596. ]
  597. # 注入数据日期 + 渠道P50(给 ActionConsistencyGuardrail B 系列用)
  598. df["_data_date"] = data_date
  599. if channel_roi_p50 is not None and channel_roi_p50 > 0:
  600. df["_channel_roi_p50"] = channel_roi_p50
  601. statuses = []
  602. reasons = []
  603. final_actions = []
  604. final_bids = []
  605. for _, row in df.iterrows():
  606. action = row.get("action", "hold")
  607. current_status = "approved"
  608. current_reasons = []
  609. current_action = action
  610. current_bid = row.get("recommended_bid")
  611. current_change_pct = row.get("recommended_change_pct")
  612. if action == "hold":
  613. statuses.append("approved")
  614. reasons.append("")
  615. final_actions.append("hold")
  616. final_bids.append(None)
  617. continue
  618. for guardrail in guardrails:
  619. # 构建可变行用于护栏检查
  620. check_row = row.copy()
  621. if current_bid is not None:
  622. check_row["recommended_bid"] = current_bid
  623. if current_change_pct is not None:
  624. check_row["recommended_change_pct"] = current_change_pct
  625. check_row["action"] = current_action
  626. result = guardrail.check(check_row, history)
  627. if result.status == "blocked":
  628. current_status = "blocked"
  629. current_reasons.append(f"[{guardrail.name}] {result.reason}")
  630. current_action = result.modified_action or "hold"
  631. break
  632. elif result.status == "modified":
  633. current_status = "modified"
  634. current_reasons.append(f"[{guardrail.name}] {result.reason}")
  635. if result.modified_action:
  636. current_action = result.modified_action
  637. if result.modified_bid is not None:
  638. current_bid = result.modified_bid
  639. if result.modified_change_pct is not None:
  640. current_change_pct = result.modified_change_pct
  641. statuses.append(current_status)
  642. reasons.append("; ".join(current_reasons))
  643. final_actions.append(current_action)
  644. final_bids.append(current_bid if current_action in ("bid_up", "bid_down") else None)
  645. df["guardrail_status"] = statuses
  646. df["guardrail_reason"] = reasons
  647. df["final_action"] = final_actions
  648. df["final_bid"] = final_bids
  649. # 清理临时列
  650. df.drop(columns=["_data_date", "_channel_roi_p50"], inplace=True, errors="ignore")
  651. return df
  652. # ═══════════════════════════════════════════
  653. # 工具:验证决策安全性
  654. # ═══════════════════════════════════════════
  655. @tool(description="验证决策安全性:冷启动保护、出价边界、频率限制、数据新鲜度")
  656. async def validate_decisions(
  657. ctx: ToolContext = None,
  658. decisions_csv: str = "",
  659. end_date: str = "yesterday",
  660. dry_run: bool = False,
  661. ) -> ToolResult:
  662. """
  663. 对每个决策执行 6 道护栏检查。
  664. 输入:apply_decisions 输出的 llm_decisions CSV
  665. 输出:validated_decisions_{date}.csv,新增列 guardrail_status / guardrail_reason / final_action / final_bid
  666. Args:
  667. decisions_csv: 决策 CSV 路径(默认最新的 llm_decisions)
  668. end_date: 结束日期
  669. dry_run: 是否强制干运行模式
  670. """
  671. try:
  672. if not GUARDRAILS_ENABLED:
  673. return ToolResult(
  674. title="护栏已禁用",
  675. output="GUARDRAILS_ENABLED=False,跳过护栏验证",
  676. )
  677. if end_date == "yesterday":
  678. end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
  679. # 自动查找最新决策 CSV(按修改时间排序,而非文件名)
  680. if not decisions_csv:
  681. reports_dir = _MINI_DIR / "outputs" / "reports"
  682. candidates = sorted(
  683. reports_dir.glob("llm_decisions_*.csv"),
  684. key=lambda p: p.stat().st_mtime, # 按修改时间排序
  685. reverse=True
  686. )
  687. if not candidates:
  688. return ToolResult(title="validate_decisions", output="未找到决策 CSV")
  689. decisions_csv = str(candidates[0])
  690. df = pd.read_csv(decisions_csv)
  691. if df.empty:
  692. return ToolResult(title="validate_decisions", output="决策数据为空")
  693. # 读取一次 metrics 做补字段和渠道P50(供 ActionConsistencyGuardrail B 系列用)
  694. metrics_csv = _MINI_DIR / "outputs" / "metrics_temp.csv"
  695. channel_roi_p50: Optional[float] = None
  696. if metrics_csv.exists():
  697. try:
  698. df_metrics = pd.read_csv(metrics_csv)
  699. # 补充广告年龄(如果缺失)
  700. if "ad_age_days" not in df.columns and "create_time" in df_metrics.columns:
  701. from ad_decision import _calculate_ad_age_days
  702. df_metrics["ad_age_days"] = df_metrics["create_time"].apply(_calculate_ad_age_days)
  703. age_map = df_metrics.set_index("ad_id")["ad_age_days"].to_dict()
  704. df["ad_age_days"] = df["ad_id"].map(age_map)
  705. # 补充当前出价(如果缺失)
  706. if ("current_bid" not in df.columns or df["current_bid"].isna().all()) \
  707. and "bid_amount" in df_metrics.columns:
  708. bid_map = df_metrics.set_index("ad_id")["bid_amount"].to_dict()
  709. df["current_bid"] = df["ad_id"].map(bid_map)
  710. # 计算渠道P50:全体广告"动态ROI_7日均值"的中位数
  711. if "动态ROI_7日均值" in df_metrics.columns:
  712. roi_series = df_metrics["动态ROI_7日均值"].dropna()
  713. if len(roi_series) > 0:
  714. channel_roi_p50 = float(roi_series.median())
  715. logger.info(f"护栏 B 系列启用:渠道P50={channel_roi_p50:.4f}")
  716. # 注入裂变字段(供 B4 护栏使用)
  717. if "T0裂变系数_7日均值" in df_metrics.columns and "audience_tier" in df_metrics.columns:
  718. # ad_fission: 每条广告自身的裂变率
  719. fission_map = df_metrics.set_index("ad_id")["T0裂变系数_7日均值"].to_dict()
  720. df["_ad_fission"] = df["ad_id"].map(fission_map)
  721. # tier_fission_mean: 按人群包分组的同类均值
  722. tier_fission = df_metrics.groupby("audience_tier")["T0裂变系数_7日均值"].mean().to_dict()
  723. tier_map = df_metrics.set_index("ad_id")["audience_tier"].to_dict()
  724. df["_tier_fission_mean"] = df["ad_id"].map(
  725. lambda aid: tier_fission.get(tier_map.get(aid))
  726. )
  727. b4_ready = df["_ad_fission"].notna().sum()
  728. logger.info(f"护栏 B4 裂变字段已注入:{b4_ready} 条广告有裂变数据")
  729. except Exception as e:
  730. logger.warning(f"读取 metrics 失败(护栏 B 系列将跳过): {e}")
  731. if channel_roi_p50 is None:
  732. logger.warning("未能计算渠道P50,ActionConsistencyGuardrail B 系列规则跳过")
  733. # 运行护栏链
  734. df = _run_guardrails(df, data_date=end_date, dry_run=dry_run, channel_roi_p50=channel_roi_p50)
  735. # 保存验证结果
  736. reports_dir = _MINI_DIR / "outputs" / "reports"
  737. reports_dir.mkdir(parents=True, exist_ok=True)
  738. out_path = reports_dir / f"validated_decisions_{end_date}.csv"
  739. df.to_csv(out_path, index=False, encoding="utf-8-sig")
  740. # 统计
  741. total = len(df)
  742. approved = (df["guardrail_status"] == "approved").sum()
  743. blocked = (df["guardrail_status"] == "blocked").sum()
  744. modified = (df["guardrail_status"] == "modified").sum()
  745. # 最终动作统计
  746. final_pause = (df["final_action"] == "pause").sum()
  747. final_hold = (df["final_action"] == "hold").sum()
  748. final_bid_up = (df["final_action"] == "bid_up").sum()
  749. final_bid_down = (df["final_action"] == "bid_down").sum()
  750. output_lines = [
  751. f"护栏验证完成: {out_path}",
  752. "",
  753. f"护栏结果:",
  754. f" approved: {approved} 个(直接通过)",
  755. f" modified: {modified} 个(参数修正后通过)",
  756. f" blocked: {blocked} 个(被拦截→hold)",
  757. "",
  758. f"最终动作分布:",
  759. f" pause: {final_pause} 个",
  760. f" bid_down: {final_bid_down} 个",
  761. f" bid_up: {final_bid_up} 个",
  762. f" hold: {final_hold} 个",
  763. ]
  764. if DRY_RUN_MODE or dry_run:
  765. output_lines.append("")
  766. output_lines.append("⚠️ 当前为干运行模式(DRY_RUN),操作不会实际执行")
  767. return ToolResult(
  768. title=f"护栏验证({total}条,拦截{blocked})",
  769. output="\n".join(output_lines),
  770. metadata={
  771. "csv_path": str(out_path),
  772. "total": total,
  773. "approved": int(approved),
  774. "blocked": int(blocked),
  775. "modified": int(modified),
  776. "final_pause": int(final_pause),
  777. "final_hold": int(final_hold),
  778. "final_bid_up": int(final_bid_up),
  779. "final_bid_down": int(final_bid_down),
  780. "dry_run": DRY_RUN_MODE or dry_run,
  781. },
  782. )
  783. except Exception as e:
  784. logger.error("validate_decisions 失败: %s", e, exc_info=True)
  785. return ToolResult(title="validate_decisions 失败", output=str(e))