guardrails.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. """
  2. 安全护栏引擎 — auto_put_ad_mini
  3. 6 道护栏按顺序执行:
  4. 1. ColdStartGuardrail — 冷启动保护
  5. 2. DataFreshnessGuardrail — 数据新鲜度校验
  6. 3. BidBoundaryGuardrail — 出价边界钳位
  7. 4. RateLimitGuardrail — 频率限制(每日次数/间隔/累计调幅)
  8. 5. DailyOpsCapGuardrail — 每日操作总量上限
  9. 6. DryRunGuardrail — 干运行模式
  10. 每道护栏输出:approved / blocked / modified
  11. blocked = 阻止操作,modified = 自动修正参数后放行
  12. """
  13. import json
  14. import logging
  15. import sys
  16. from abc import ABC, abstractmethod
  17. from dataclasses import dataclass, field
  18. from datetime import datetime, timedelta
  19. from pathlib import Path
  20. from typing import Dict, List, Optional
  21. import pandas as pd
  22. from agent.tools import tool
  23. from agent.tools.models import ToolContext, ToolResult
  24. _MINI_DIR = Path(__file__).resolve().parent.parent
  25. _TOOLS_DIR = Path(__file__).resolve().parent
  26. if str(_MINI_DIR) not in sys.path:
  27. sys.path.insert(0, str(_MINI_DIR))
  28. if str(_TOOLS_DIR) not in sys.path:
  29. sys.path.insert(0, str(_TOOLS_DIR))
  30. from config import (
  31. COLD_START_DAYS,
  32. CAUTIOUS_DAYS,
  33. BID_FLOOR_YUAN,
  34. BID_CEILING_YUAN,
  35. MAX_ADJUSTMENTS_PER_AD_PER_DAY,
  36. MIN_ADJUSTMENT_INTERVAL_HOURS,
  37. MAX_DAILY_CUMULATIVE_CHANGE_PCT,
  38. MAX_DAILY_OPS,
  39. DATA_FRESHNESS_MAX_HOURS,
  40. ADJUSTMENT_HISTORY_PATH,
  41. DRY_RUN_MODE,
  42. GUARDRAILS_ENABLED,
  43. DATA_DIR,
  44. )
  45. logger = logging.getLogger(__name__)
  46. # ═══════════════════════════════════════════
  47. # 调整历史持久化
  48. # ═══════════════════════════════════════════
  49. class AdjustmentHistory:
  50. """广告调整历史记录(JSON 文件持久化)。"""
  51. def __init__(self, path: Path = ADJUSTMENT_HISTORY_PATH):
  52. self._path = path
  53. self._data: Dict[str, Dict] = {}
  54. self._load()
  55. def _load(self):
  56. if self._path.exists():
  57. try:
  58. self._data = json.loads(self._path.read_text(encoding="utf-8"))
  59. except Exception as e:
  60. logger.warning("加载调整历史失败,使用空记录: %s", e)
  61. self._data = {}
  62. def _save(self):
  63. self._path.parent.mkdir(parents=True, exist_ok=True)
  64. self._path.write_text(
  65. json.dumps(self._data, ensure_ascii=False, indent=2),
  66. encoding="utf-8",
  67. )
  68. def get_today_adjustments(self, ad_id: str) -> List[Dict]:
  69. """获取某广告今天的调整记录。"""
  70. today = datetime.now().strftime("%Y-%m-%d")
  71. record = self._data.get(str(ad_id), {})
  72. adjustments = record.get("adjustments", [])
  73. return [a for a in adjustments if a.get("ts", "").startswith(today)]
  74. def get_last_adjustment_ts(self, ad_id: str) -> Optional[datetime]:
  75. """获取某广告最后一次调整的时间。"""
  76. record = self._data.get(str(ad_id), {})
  77. last_ts = record.get("last_ts")
  78. if last_ts:
  79. try:
  80. return datetime.fromisoformat(last_ts)
  81. except ValueError:
  82. return None
  83. return None
  84. def get_cumulative_pct_today(self, ad_id: str) -> float:
  85. """获取某广告今天的累计调幅绝对值。"""
  86. today_adj = self.get_today_adjustments(str(ad_id))
  87. return sum(abs(a.get("pct", 0)) for a in today_adj)
  88. def record_adjustment(self, ad_id: str, action: str, pct: float):
  89. """记录一次调整。"""
  90. ad_key = str(ad_id)
  91. now = datetime.now().isoformat()
  92. if ad_key not in self._data:
  93. self._data[ad_key] = {"adjustments": [], "last_ts": None}
  94. self._data[ad_key]["adjustments"].append({
  95. "ts": now,
  96. "action": action,
  97. "pct": pct,
  98. })
  99. self._data[ad_key]["last_ts"] = now
  100. # 只保留最近 7 天的记录
  101. cutoff = (datetime.now() - timedelta(days=7)).isoformat()
  102. self._data[ad_key]["adjustments"] = [
  103. a for a in self._data[ad_key]["adjustments"]
  104. if a.get("ts", "") >= cutoff
  105. ]
  106. self._save()
  107. def get_today_total_ops(self) -> int:
  108. """获取今天已操作的广告总数。"""
  109. today = datetime.now().strftime("%Y-%m-%d")
  110. count = 0
  111. for ad_key, record in self._data.items():
  112. adjustments = record.get("adjustments", [])
  113. if any(a.get("ts", "").startswith(today) for a in adjustments):
  114. count += 1
  115. return count
  116. # ═══════════════════════════════════════════
  117. # 护栏检查结果
  118. # ═══════════════════════════════════════════
  119. @dataclass
  120. class GuardrailResult:
  121. """单个护栏的检查结果。"""
  122. status: str # "approved" / "blocked" / "modified"
  123. reason: str
  124. modified_action: Optional[str] = None
  125. modified_bid: Optional[float] = None
  126. modified_change_pct: Optional[float] = None
  127. # ═══════════════════════════════════════════
  128. # 护栏基类
  129. # ═══════════════════════════════════════════
  130. class Guardrail(ABC):
  131. """护栏基类。"""
  132. @property
  133. @abstractmethod
  134. def name(self) -> str:
  135. pass
  136. @abstractmethod
  137. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  138. """
  139. 检查单个决策是否通过护栏。
  140. Args:
  141. row: 决策行(包含 action, ad_id, recommended_change_pct 等)
  142. history: 调整历史记录
  143. Returns:
  144. GuardrailResult
  145. """
  146. pass
  147. # ═══════════════════════════════════════════
  148. # 护栏 1: 冷启动保护
  149. # ═══════════════════════════════════════════
  150. class ColdStartGuardrail(Guardrail):
  151. """冷启动保护:0-4天不做负向操作,4-7天仅允许小幅降价。"""
  152. @property
  153. def name(self) -> str:
  154. return "冷启动保护"
  155. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  156. action = row.get("action", "hold")
  157. ad_age = row.get("ad_age_days")
  158. if ad_age is None or action == "hold":
  159. return GuardrailResult(status="approved", reason="")
  160. # 冷启动期(≤3天):极度保护,禁止所有操作
  161. if ad_age <= COLD_START_DAYS:
  162. if action in ("pause", "bid_down", "bid_up"):
  163. return GuardrailResult(
  164. status="blocked",
  165. reason=f"冷启动期({ad_age}天 ≤ {COLD_START_DAYS}天),极度保护,禁止{action}",
  166. modified_action="hold",
  167. )
  168. # 早期成长期(4-7天):仅允许提价
  169. elif ad_age <= CAUTIOUS_DAYS:
  170. # 早期成长期(4-7天):仅允许提价
  171. if action in ("pause", "bid_down"):
  172. return GuardrailResult(
  173. status="blocked",
  174. reason=f"早期成长期({ad_age}天,4-{CAUTIOUS_DAYS}天),仅允许提价,禁止{action}",
  175. modified_action="hold",
  176. )
  177. return GuardrailResult(status="approved", reason="")
  178. # ═══════════════════════════════════════════
  179. # 护栏 2: 数据新鲜度
  180. # ═══════════════════════════════════════════
  181. class DataFreshnessGuardrail(Guardrail):
  182. """数据新鲜度校验:数据超过 26 小时视为过期。"""
  183. @property
  184. def name(self) -> str:
  185. return "数据新鲜度"
  186. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  187. action = row.get("action", "hold")
  188. if action == "hold":
  189. return GuardrailResult(status="approved", reason="")
  190. data_date = row.get("_data_date") # 由工具注入
  191. if data_date:
  192. try:
  193. data_dt = datetime.strptime(str(data_date), "%Y%m%d")
  194. hours_old = (datetime.now() - data_dt).total_seconds() / 3600
  195. if hours_old > DATA_FRESHNESS_MAX_HOURS:
  196. return GuardrailResult(
  197. status="blocked",
  198. reason=f"数据已过期({hours_old:.0f}小时前,上限{DATA_FRESHNESS_MAX_HOURS}小时),阻止所有操作",
  199. modified_action="hold",
  200. )
  201. except ValueError:
  202. pass
  203. return GuardrailResult(status="approved", reason="")
  204. # ═══════════════════════════════════════════
  205. # 护栏 3: 出价边界
  206. # ═══════════════════════════════════════════
  207. class BidBoundaryGuardrail(Guardrail):
  208. """出价边界检查:钳位到 [BID_FLOOR, BID_CEILING]。"""
  209. @property
  210. def name(self) -> str:
  211. return "出价边界"
  212. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  213. action = row.get("action", "hold")
  214. if action not in ("bid_up", "bid_down"):
  215. return GuardrailResult(status="approved", reason="")
  216. recommended_bid = row.get("recommended_bid")
  217. if recommended_bid is None or recommended_bid == "":
  218. return GuardrailResult(status="approved", reason="")
  219. recommended_bid = float(recommended_bid)
  220. current_bid = float(row.get("current_bid", 0) or 0)
  221. if recommended_bid < BID_FLOOR_YUAN:
  222. new_bid = BID_FLOOR_YUAN
  223. new_pct = (new_bid - current_bid) / current_bid if current_bid > 0 else 0
  224. return GuardrailResult(
  225. status="modified",
  226. reason=f"出价{recommended_bid:.2f}元低于下限{BID_FLOOR_YUAN}元,钳位至{new_bid:.2f}元",
  227. modified_bid=new_bid,
  228. modified_change_pct=round(new_pct, 4),
  229. )
  230. elif recommended_bid > BID_CEILING_YUAN:
  231. new_bid = BID_CEILING_YUAN
  232. new_pct = (new_bid - current_bid) / current_bid if current_bid > 0 else 0
  233. return GuardrailResult(
  234. status="modified",
  235. reason=f"出价{recommended_bid:.2f}元超过上限{BID_CEILING_YUAN}元,钳位至{new_bid:.2f}元",
  236. modified_bid=new_bid,
  237. modified_change_pct=round(new_pct, 4),
  238. )
  239. return GuardrailResult(status="approved", reason="")
  240. # ═══════════════════════════════════════════
  241. # 护栏 4: 频率限制
  242. # ═══════════════════════════════════════════
  243. class RateLimitGuardrail(Guardrail):
  244. """频率限制:每日次数/间隔/累计调幅。"""
  245. @property
  246. def name(self) -> str:
  247. return "频率限制"
  248. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  249. action = row.get("action", "hold")
  250. if action not in ("bid_up", "bid_down", "pause"):
  251. return GuardrailResult(status="approved", reason="")
  252. ad_id = str(row.get("ad_id", ""))
  253. # 今日已调整次数
  254. today_adj = history.get_today_adjustments(ad_id)
  255. if len(today_adj) >= MAX_ADJUSTMENTS_PER_AD_PER_DAY:
  256. return GuardrailResult(
  257. status="blocked",
  258. reason=f"今日已调整{len(today_adj)}次(上限{MAX_ADJUSTMENTS_PER_AD_PER_DAY}次)",
  259. modified_action="hold",
  260. )
  261. # 距上次调整间隔
  262. last_ts = history.get_last_adjustment_ts(ad_id)
  263. if last_ts:
  264. hours_since = (datetime.now() - last_ts).total_seconds() / 3600
  265. if hours_since < MIN_ADJUSTMENT_INTERVAL_HOURS:
  266. return GuardrailResult(
  267. status="blocked",
  268. reason=f"距上次调整仅{hours_since:.1f}小时(最小间隔{MIN_ADJUSTMENT_INTERVAL_HOURS}小时)",
  269. modified_action="hold",
  270. )
  271. # 日累计调幅
  272. if action in ("bid_up", "bid_down"):
  273. change_pct = row.get("recommended_change_pct", 0)
  274. if isinstance(change_pct, str):
  275. try:
  276. change_pct = float(change_pct)
  277. except ValueError:
  278. change_pct = 0
  279. cumulative = history.get_cumulative_pct_today(ad_id)
  280. if cumulative + abs(change_pct) > MAX_DAILY_CUMULATIVE_CHANGE_PCT:
  281. remaining = MAX_DAILY_CUMULATIVE_CHANGE_PCT - cumulative
  282. if remaining <= 0:
  283. return GuardrailResult(
  284. status="blocked",
  285. reason=f"日累计调幅已达{cumulative*100:.1f}%(上限{MAX_DAILY_CUMULATIVE_CHANGE_PCT*100:.0f}%)",
  286. modified_action="hold",
  287. )
  288. else:
  289. # 修正调幅
  290. direction = 1 if change_pct > 0 else -1
  291. new_pct = direction * remaining
  292. current_bid = float(row.get("current_bid", 0) or 0)
  293. new_bid = round(current_bid * (1 + new_pct), 2) if current_bid > 0 else None
  294. return GuardrailResult(
  295. status="modified",
  296. reason=f"调幅从{abs(change_pct)*100:.1f}%缩减至{abs(new_pct)*100:.1f}%(日累计限制)",
  297. modified_change_pct=round(new_pct, 4),
  298. modified_bid=new_bid,
  299. )
  300. return GuardrailResult(status="approved", reason="")
  301. # ═══════════════════════════════════════════
  302. # 护栏 5: 每日操作总量上限
  303. # ═══════════════════════════════════════════
  304. class DailyOpsCapGuardrail(Guardrail):
  305. """每日操作总量上限:单日最多操作 N 个广告。"""
  306. @property
  307. def name(self) -> str:
  308. return "每日操作上限"
  309. def __init__(self):
  310. self._approved_count = 0
  311. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  312. action = row.get("action", "hold")
  313. if action == "hold":
  314. return GuardrailResult(status="approved", reason="")
  315. total_ops = history.get_today_total_ops() + self._approved_count
  316. if total_ops >= MAX_DAILY_OPS:
  317. return GuardrailResult(
  318. status="blocked",
  319. reason=f"今日已操作{total_ops}个广告(上限{MAX_DAILY_OPS}个),请按ROI严重度排优先级",
  320. modified_action="hold",
  321. )
  322. self._approved_count += 1
  323. return GuardrailResult(status="approved", reason="")
  324. # ═══════════════════════════════════════════
  325. # 护栏 6: 干运行模式
  326. # ═══════════════════════════════════════════
  327. class DryRunGuardrail(Guardrail):
  328. """干运行模式:全部标记为 dry_run。"""
  329. @property
  330. def name(self) -> str:
  331. return "干运行模式"
  332. def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
  333. action = row.get("action", "hold")
  334. if action == "hold":
  335. return GuardrailResult(status="approved", reason="")
  336. if DRY_RUN_MODE:
  337. return GuardrailResult(
  338. status="modified",
  339. reason="干运行模式:操作不会实际执行",
  340. )
  341. return GuardrailResult(status="approved", reason="")
  342. # ═══════════════════════════════════════════
  343. # 护栏引擎
  344. # ═══════════════════════════════════════════
  345. def _run_guardrails(
  346. df: pd.DataFrame,
  347. data_date: str,
  348. dry_run: bool = False,
  349. ) -> pd.DataFrame:
  350. """
  351. 对决策 DataFrame 执行 6 道护栏检查。
  352. 新增列:
  353. - guardrail_status: approved / blocked / modified
  354. - guardrail_reason: 护栏说明
  355. - final_action: 护栏修正后的最终动作
  356. - final_bid: 护栏修正后的最终出价
  357. """
  358. history = AdjustmentHistory()
  359. guardrails = [
  360. ColdStartGuardrail(),
  361. DataFreshnessGuardrail(),
  362. BidBoundaryGuardrail(),
  363. RateLimitGuardrail(),
  364. DailyOpsCapGuardrail(),
  365. DryRunGuardrail() if dry_run or DRY_RUN_MODE else None,
  366. ]
  367. guardrails = [g for g in guardrails if g is not None]
  368. # 注入数据日期
  369. df["_data_date"] = data_date
  370. statuses = []
  371. reasons = []
  372. final_actions = []
  373. final_bids = []
  374. for _, row in df.iterrows():
  375. action = row.get("action", "hold")
  376. current_status = "approved"
  377. current_reasons = []
  378. current_action = action
  379. current_bid = row.get("recommended_bid")
  380. current_change_pct = row.get("recommended_change_pct")
  381. if action == "hold":
  382. statuses.append("approved")
  383. reasons.append("")
  384. final_actions.append("hold")
  385. final_bids.append(None)
  386. continue
  387. for guardrail in guardrails:
  388. # 构建可变行用于护栏检查
  389. check_row = row.copy()
  390. if current_bid is not None:
  391. check_row["recommended_bid"] = current_bid
  392. if current_change_pct is not None:
  393. check_row["recommended_change_pct"] = current_change_pct
  394. check_row["action"] = current_action
  395. result = guardrail.check(check_row, history)
  396. if result.status == "blocked":
  397. current_status = "blocked"
  398. current_reasons.append(f"[{guardrail.name}] {result.reason}")
  399. current_action = result.modified_action or "hold"
  400. break
  401. elif result.status == "modified":
  402. current_status = "modified"
  403. current_reasons.append(f"[{guardrail.name}] {result.reason}")
  404. if result.modified_action:
  405. current_action = result.modified_action
  406. if result.modified_bid is not None:
  407. current_bid = result.modified_bid
  408. if result.modified_change_pct is not None:
  409. current_change_pct = result.modified_change_pct
  410. statuses.append(current_status)
  411. reasons.append("; ".join(current_reasons))
  412. final_actions.append(current_action)
  413. final_bids.append(current_bid if current_action in ("bid_up", "bid_down") else None)
  414. df["guardrail_status"] = statuses
  415. df["guardrail_reason"] = reasons
  416. df["final_action"] = final_actions
  417. df["final_bid"] = final_bids
  418. # 清理临时列
  419. df.drop(columns=["_data_date"], inplace=True, errors="ignore")
  420. return df
  421. # ═══════════════════════════════════════════
  422. # 工具:验证决策安全性
  423. # ═══════════════════════════════════════════
  424. @tool(description="验证决策安全性:冷启动保护、出价边界、频率限制、数据新鲜度")
  425. async def validate_decisions(
  426. ctx: ToolContext,
  427. decisions_csv: str = "",
  428. end_date: str = "yesterday",
  429. dry_run: bool = False,
  430. ) -> ToolResult:
  431. """
  432. 对每个决策执行 6 道护栏检查。
  433. 输入:apply_decisions 输出的 llm_decisions CSV
  434. 输出:validated_decisions_{date}.csv,新增列 guardrail_status / guardrail_reason / final_action / final_bid
  435. Args:
  436. decisions_csv: 决策 CSV 路径(默认最新的 llm_decisions)
  437. end_date: 结束日期
  438. dry_run: 是否强制干运行模式
  439. """
  440. try:
  441. if not GUARDRAILS_ENABLED:
  442. return ToolResult(
  443. title="护栏已禁用",
  444. output="GUARDRAILS_ENABLED=False,跳过护栏验证",
  445. )
  446. if end_date == "yesterday":
  447. end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
  448. # 自动查找最新决策 CSV(按修改时间排序,而非文件名)
  449. if not decisions_csv:
  450. reports_dir = _MINI_DIR / "outputs" / "reports"
  451. candidates = sorted(
  452. reports_dir.glob("llm_decisions_*.csv"),
  453. key=lambda p: p.stat().st_mtime, # 按修改时间排序
  454. reverse=True
  455. )
  456. if not candidates:
  457. return ToolResult(title="validate_decisions", output="未找到决策 CSV")
  458. decisions_csv = str(candidates[0])
  459. df = pd.read_csv(decisions_csv)
  460. if df.empty:
  461. return ToolResult(title="validate_decisions", output="决策数据为空")
  462. # 补充广告年龄(如果缺失)
  463. if "ad_age_days" not in df.columns:
  464. metrics_csv = _MINI_DIR / "outputs" / "metrics_temp.csv"
  465. if metrics_csv.exists():
  466. df_metrics = pd.read_csv(metrics_csv)
  467. if "create_time" in df_metrics.columns:
  468. from ad_decision import _calculate_ad_age_days
  469. df_metrics["ad_age_days"] = df_metrics["create_time"].apply(_calculate_ad_age_days)
  470. age_map = df_metrics.set_index("ad_id")["ad_age_days"].to_dict()
  471. df["ad_age_days"] = df["ad_id"].map(age_map)
  472. # 补充当前出价(如果缺失)
  473. if "current_bid" not in df.columns or df["current_bid"].isna().all():
  474. metrics_csv = _MINI_DIR / "outputs" / "metrics_temp.csv"
  475. if metrics_csv.exists():
  476. df_metrics = pd.read_csv(metrics_csv)
  477. if "bid_amount" in df_metrics.columns:
  478. bid_map = df_metrics.set_index("ad_id")["bid_amount"].to_dict()
  479. df["current_bid"] = df["ad_id"].map(bid_map)
  480. # 运行护栏链
  481. df = _run_guardrails(df, data_date=end_date, dry_run=dry_run)
  482. # 保存验证结果
  483. reports_dir = _MINI_DIR / "outputs" / "reports"
  484. reports_dir.mkdir(parents=True, exist_ok=True)
  485. out_path = reports_dir / f"validated_decisions_{end_date}.csv"
  486. df.to_csv(out_path, index=False, encoding="utf-8-sig")
  487. # 统计
  488. total = len(df)
  489. approved = (df["guardrail_status"] == "approved").sum()
  490. blocked = (df["guardrail_status"] == "blocked").sum()
  491. modified = (df["guardrail_status"] == "modified").sum()
  492. # 最终动作统计
  493. final_pause = (df["final_action"] == "pause").sum()
  494. final_hold = (df["final_action"] == "hold").sum()
  495. final_bid_up = (df["final_action"] == "bid_up").sum()
  496. final_bid_down = (df["final_action"] == "bid_down").sum()
  497. output_lines = [
  498. f"护栏验证完成: {out_path}",
  499. "",
  500. f"护栏结果:",
  501. f" approved: {approved} 个(直接通过)",
  502. f" modified: {modified} 个(参数修正后通过)",
  503. f" blocked: {blocked} 个(被拦截→hold)",
  504. "",
  505. f"最终动作分布:",
  506. f" pause: {final_pause} 个",
  507. f" bid_down: {final_bid_down} 个",
  508. f" bid_up: {final_bid_up} 个",
  509. f" hold: {final_hold} 个",
  510. ]
  511. if DRY_RUN_MODE or dry_run:
  512. output_lines.append("")
  513. output_lines.append("⚠️ 当前为干运行模式(DRY_RUN),操作不会实际执行")
  514. return ToolResult(
  515. title=f"护栏验证({total}条,拦截{blocked})",
  516. output="\n".join(output_lines),
  517. metadata={
  518. "csv_path": str(out_path),
  519. "total": total,
  520. "approved": int(approved),
  521. "blocked": int(blocked),
  522. "modified": int(modified),
  523. "final_pause": int(final_pause),
  524. "final_hold": int(final_hold),
  525. "final_bid_up": int(final_bid_up),
  526. "final_bid_down": int(final_bid_down),
  527. "dry_run": DRY_RUN_MODE or dry_run,
  528. },
  529. )
  530. except Exception as e:
  531. logger.error("validate_decisions 失败: %s", e, exc_info=True)
  532. return ToolResult(title="validate_decisions 失败", output=str(e))