Files
cma-management/backend/app/api/bot_iron_law.py
T
Hermes CI Fix 758f820970 feat: Bot API L1-L4风险分级标注 + 操作审计日志
- 新增 app/risk_levels.py: RISK_LEVELS定义 + @risk_level装饰器 + API_RISK_MAP
  (L1只读21 / L2业务写5 / L3批量写3 / L4=0安全底线)
- 新增 app/api/audit_log.py: Bot API审计中间件 → backend/logs/bot_audit.log
  (JSON行: timestamp/bot_name/endpoint/method/risk_level/entity_id/status,
   L3额外记rows行数, 不阻塞业务)
- bot_bridge/bot_bridge_v2/bot_kpis/bot_iron_law 全部29路由标注级别
- 新增 GET /api/cma/bot/risk-levels (X-BOT-KEY鉴权): API→级别→处理方式清单
- main.py 注册审计中间件
- tests/test_risk_levels.py: 覆盖路由标注/risk-levels端点/L4不存在/审计日志
2026-08-28 00:31:31 +08:00

262 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
铁律KPI看板API — CMA铁律执行效果数据采集
提供: 验证次数、通过率、违规数、按Bot分组的通过率
"""
import os, json, sqlite3, logging
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.models import ActionPlan
from app.risk_levels import risk_level
logger = logging.getLogger("cma.iron_law")
router = APIRouter(prefix="/api/cma/bot", tags=["铁律KPI看板"])
# ── BOT API Key 配置(复用bot_bridge的一致鉴权) ──
_BOT_API_KEYS = {}
def _load_bot_keys():
global _BOT_API_KEYS
raw = os.getenv("CMA_BOT_API_KEYS", "")
if not raw:
_BOT_API_KEYS = {
"cma-bot-finance-2026": {"role": "finance", "name": "财务BOT"},
"cma-bot-shop-2026": {"role": "business", "name": "店研学BOT"},
"cma-bot-admin-2026": {"role": "ceo", "name": "管理BOT"},
}
else:
try:
_BOT_API_KEYS = json.loads(raw)
except:
_BOT_API_KEYS = {}
_load_bot_keys()
def verify_bot_key(x_bot_key: str = Header(None, alias="X-BOT-KEY")):
if not x_bot_key or x_bot_key not in _BOT_API_KEYS:
raise HTTPException(401, "无效的BOT API Key")
bot_info = _BOT_API_KEYS[x_bot_key]
return bot_info
# ── 各Hermes Profile state.db 路径 ──
HERMES_HOME = "/root/.hermes/profiles"
def _list_profile_dbs():
"""列出所有Hermes Profile的state.db路径"""
dbs = []
if not os.path.isdir(HERMES_HOME):
return dbs
for name in os.listdir(HERMES_HOME):
db_path = os.path.join(HERMES_HOME, name, "state.db")
if os.path.isfile(db_path):
dbs.append((name, db_path))
return sorted(dbs)
def _query_state_db(db_path: str, since_days: int = 30):
"""从单个 state.db 查询验证相关消息"""
cutoff = datetime.now() - timedelta(days=since_days)
cutoff_ts = cutoff.timestamp()
results = {
"total_verify_msgs": 0,
"pass_msgs": 0,
"fail_msgs": 0,
"violation_msgs": 0,
}
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
c = conn.cursor()
# 查询含验证/铁律关键词的消息(排除tool_call、校验自身和系统prompt
c.execute("""
SELECT content, role FROM messages
WHERE timestamp >= ?
AND (content LIKE '%verify%'
OR content LIKE '%验证%'
OR content LIKE '%铁律%'
OR content LIKE '%validate%')
AND content NOT LIKE '%verify_bot_key%'
AND content NOT LIKE '%X-BOT-KEY%'
AND content NOT LIKE '%system_prompt%'
""", (cutoff_ts,))
rows = c.fetchall()
results["total_verify_msgs"] = len(rows)
for row in rows:
content = row["content"] or ""
role = row["role"]
# 判断是否通过/成功/完成
pass_patterns = ["通过", "passed", "success", "✅", "完成", "completed", "验证通过"]
fail_patterns = ["失败", "failed", "error", "❌", "违规", "未通过", "错误", "异常"]
violation_patterns = ["违规", "violation", "拦截", "blocked", "违例"]
# 助理角色消息:用于判断验证结果
if role == "assistant":
has_pass = any(p in content for p in pass_patterns)
has_fail = any(p in content for p in fail_patterns)
if has_pass and not has_fail:
results["pass_msgs"] += 1
elif has_fail and not has_pass:
results["fail_msgs"] += 1
elif has_pass and has_fail:
# 混合内容,默认算通过(因为通常有通过+补充说明)
results["pass_msgs"] += 1
if any(p in content for p in violation_patterns):
results["violation_msgs"] += 1
conn.close()
except Exception as e:
logger.warning(f"查询state.db失败 {db_path}: {e}")
return results
def _query_action_plan_verify(db: Session):
"""从CMA ActionPlan表查询验证数据"""
total = db.query(func.count(ActionPlan.id)).scalar() or 0
verified = db.query(func.count(ActionPlan.id)).filter(
ActionPlan.verify_result.isnot(None)
).scalar() or 0
passed = db.query(func.count(ActionPlan.id)).filter(
ActionPlan.verify_result == "pass"
).scalar() or 0
failed = db.query(func.count(ActionPlan.id)).filter(
ActionPlan.verify_result == "fail"
).scalar() or 0
with_rule = db.query(func.count(ActionPlan.id)).filter(
ActionPlan.auto_verify_rule.isnot(None)
).scalar() or 0
return {
"total_plans": total,
"verified": verified,
"passed": passed,
"failed": failed,
"with_auto_rule": with_rule,
"pass_rate": round(passed / verified * 100, 1) if verified > 0 else 0,
}
# ═══════════════ 端点 ═══════════════
@router.get("/iron-law")
@risk_level("L1")
def get_iron_law_kpis(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
since_days: int = 30,
):
"""
铁律执行效果KPI看板
返回各Bot的验证统计、通过率、违规数,以及ActionPlan的验证覆盖率。
数据来源:
- Hermes 各Profile state.db (消息含 verify/验证/铁律)
- CMA action_plans 表 (verify_result, auto_verify_rule)
"""
# ── 1. 采集各Profile state.db ──
profile_dbs = _list_profile_dbs()
per_bot = {}
total_verify = 0
total_pass = 0
total_fail = 0
total_violations = 0
for profile_name, db_path in profile_dbs:
stats = _query_state_db(db_path, since_days=since_days)
per_bot[profile_name] = {
"verification_count": stats["total_verify_msgs"],
"pass_count": stats["pass_msgs"],
"fail_count": stats["fail_msgs"],
"violation_count": stats["violation_msgs"],
"pass_rate": round(
stats["pass_msgs"] / stats["total_verify_msgs"] * 100, 1
) if stats["total_verify_msgs"] > 0 else 0,
}
total_verify += stats["total_verify_msgs"]
total_pass += stats["pass_msgs"]
total_fail += stats["fail_msgs"]
total_violations += stats["violation_msgs"]
# ── 2. 采集ActionPlan验证数据 ──
plan_stats = _query_action_plan_verify(db)
return {
"bot": bot,
"timestamp": datetime.now().isoformat(),
"period": f"past_{since_days}d",
"summary": {
"total_verifications": total_verify,
"pass_rate": round(total_pass / total_verify * 100, 1) if total_verify > 0 else 0,
"total_violations": total_violations,
"plan_verify_count": plan_stats["verified"],
"plan_pass_rate": plan_stats["pass_rate"],
"plans_with_auto_rule": plan_stats["with_auto_rule"],
"total_action_plans": plan_stats["total_plans"],
},
"per_bot": per_bot,
"action_plan_verification": {
"total_plans": plan_stats["total_plans"],
"verified": plan_stats["verified"],
"passed": plan_stats["passed"],
"failed": plan_stats["failed"],
"pass_rate": plan_stats["pass_rate"],
"with_auto_rule": plan_stats["with_auto_rule"],
"coverage": round(
plan_stats["verified"] / plan_stats["total_plans"] * 100, 1
) if plan_stats["total_plans"] > 0 else 0,
},
"data_sources": {
"state_dbs": len(profile_dbs),
"profiles_queried": [p[0] for p in profile_dbs],
"action_plans_table": True,
},
}
@router.get("/iron-law/bots")
@risk_level("L1")
def get_bot_iron_law_ranking(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
since_days: int = 30,
):
"""按Bot排名:验证通过率从高到低"""
data = get_iron_law_kpis(bot=bot, db=db, since_days=since_days)
ranking = sorted(
data["per_bot"].items(),
key=lambda x: x[1]["pass_rate"],
reverse=True,
)
ranked = []
for rank, (name, stats) in enumerate(ranking, 1):
ranked.append({
"rank": rank,
"bot_name": name,
"pass_rate": stats["pass_rate"],
"verification_count": stats["verification_count"],
"violations": stats["violation_count"],
"failures": stats["fail_count"],
})
return {
"bot": bot,
"timestamp": datetime.now().isoformat(),
"period": f"past_{since_days}d",
"ranking": ranked,
"total_bots": len(ranked),
}