Files
cma-management/backend/app/api/bot_kpis.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

177 lines
5.7 KiB
Python

"""Bot KPI管理 API — 管理各Agent的KPI自评体系"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.auth_middleware import require_auth
from app.models import KPIDefinition, KPIValue
from app.risk_levels import risk_level
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
dependencies=[Depends(require_auth)],
)
# 反向指标编码(值越低越好)
REVERSE_BOT_INDICATORS = ['FB_RESPONSE_TIME']
def _calc_bot_kpi_score(current_value, target_value, is_reverse=False):
"""五档评分引擎(复用KPI体系)- 1~5分"""
if current_value is None or target_value is None or target_value == 0:
return None, "info"
ratio = current_value / target_value
if is_reverse:
if ratio <= 0.5:
return 5, "success"
elif ratio <= 0.8:
return 4, "success"
elif ratio <= 1.0:
return 3, "warning"
elif ratio <= 1.2:
return 2, "danger"
else:
return 1, "danger"
else:
if ratio >= 1.2:
return 5, "success"
elif ratio >= 1.0:
return 4, "success"
elif ratio >= 0.8:
return 3, "warning"
elif ratio >= 0.5:
return 2, "danger"
else:
return 1, "danger"
@router.get("")
@risk_level("L1")
def list_bot_kpis(
source: str = Query("finance-bot", description="Bot标识"),
period: Optional[str] = None,
db: Session = Depends(get_db),
):
"""获取某Bot的所有KPI(含评分)"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.bot_source == source,
KPIDefinition.status == "active",
).order_by(KPIDefinition.kpi_code).all()
if not kpis:
return {
"source": source,
"kpis": [],
"groups": {},
"overall": None,
}
from app.api.kpis import REVERSE_INDICATORS as _ri
result_kpis = []
for k in kpis:
# 取最新实际值
val_query = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None),
)
if period:
val_query = val_query.filter(KPIValue.period == period)
latest_val = val_query.order_by(KPIValue.period.desc()).first()
current_val = latest_val.actual_value if latest_val else None
is_reverse = k.kpi_code in REVERSE_BOT_INDICATORS
score, status = _calc_bot_kpi_score(current_val, k.target_value, is_reverse=is_reverse)
result_kpis.append({
"id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"category": k.category or "core_output",
"formula": k.formula,
"target_value": k.target_value,
"current_value": current_val,
"unit": k.unit,
"weight": 15, # 权重在PRD中定义,但未存表,前端使用
"score": score,
"status": status,
"period": latest_val.period if latest_val else None,
"frequency": k.frequency or "monthly",
})
# 按group分组: core_output / quality / user_feedback
groups = {
"core_output": {"label": "核心产出", "kpis": []},
"quality": {"label": "质量监控", "kpis": []},
"user_feedback": {"label": "用户反馈", "kpis": []},
}
# category映射: FB编码前缀区分
for kp in result_kpis:
code = kp["kpi_code"]
if code.startswith("FB_ANALYSIS") or code.startswith("FB_ACCURACY") or code.startswith("FB_ISSUE") or code.startswith("FB_ACTION") or code.startswith("FB_RESPONSE"):
groups["core_output"]["kpis"].append(kp)
elif code.startswith("FB_DATA") or code.startswith("FB_CONSISTENCY") or code.startswith("FB_CITATION"):
groups["quality"]["kpis"].append(kp)
else:
groups["user_feedback"]["kpis"].append(kp)
# 综合得分
scored_kpis = [k for k in result_kpis if k["score"] is not None]
if scored_kpis:
overall = round(sum(k["score"] * k["weight"] for k in scored_kpis) / sum(k["weight"] for k in scored_kpis), 2)
overall_status = "success" if overall >= 4 else ("warning" if overall >= 3 else "danger")
else:
overall = None
overall_status = "info"
return {
"source": source,
"kpis": result_kpis,
"groups": groups,
"overall": {"score": overall, "status": overall_status},
}
@router.post("/{kpi_id}/value")
@risk_level("L2")
def update_bot_kpi_value(
kpi_id: int,
data: dict,
db: Session = Depends(get_db),
):
"""更新Bot KPI当前值"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.bot_source.isnot(None),
).first()
if not kpi:
raise HTTPException(404, "Bot KPI不存在")
actual_value = data.get("actual_value")
if actual_value is None:
raise HTTPException(422, "actual_value 不能为空")
period = data.get("period", datetime.now().strftime("%Y-%m"))
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == period,
).first()
if existing:
existing.actual_value = actual_value
existing.source_type = "manual"
else:
val = KPIValue(
kpi_id=kpi_id,
entity_id=kpi.entity_id if kpi else None, # 账套隔离 P2
period=period,
actual_value=actual_value,
source_type="manual",
data_status="pending",
)
db.add(val)
db.commit()
return {"message": "更新成功", "kpi_id": kpi_id, "period": period, "actual_value": actual_value}