- 新增 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不存在/审计日志
262 lines
8.8 KiB
Python
262 lines
8.8 KiB
Python
"""
|
||
铁律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),
|
||
}
|