Files
cma-management/backend/app/api/bot_kpis.py
T
Hermes CI Fix ac645a56ee fix(security): ai_analysis多租户隔离缺口 + dashboard预警计数 + bot_kpis归属 + cost账套过滤 + kpis create白名单 (OpenCode审查20260907)
- ai_analysis: generate_rule_suggestions pending预警按entity过滤; kpi_analysis归属校验404;
  dashboard-analysis-stream/ask/review-plans 补 get_entity_id + KPI/ActionPlan/KPIAlert 全按账套过滤;
  stream cache key 含 entity_id (原跨企业串缓存)
- dashboard: summary alert_count 补 KPIAlert.entity_id 过滤 (kpi_total 已有)
- bot_kpis: update_bot_kpi_value 归属校验 404 (原任意登录可改他企 bot KPI)
- cost: ActualCost/StandardCost 确认多企业共用 → cost_overview/variance/breakdown/dashboard
  补 entity 过滤(engine 函数加 entity_id 透传); dashboard total_cost 既有 KeyError 修复
- kpis: create_kpi 字段白名单 (仿 UPDATE_KPI_WHITELIST+kpi_code), 拦截 status/important_flag 注入
- tests: 新增 test_multitenant_ai_20260907.py 11条越权用例 (跨entity 404/空返回断言)
- 全量 pytest: 745 passed / 10 failed(与基线 HEAD 一致, 存量问题非本次引入) / 1 xfailed
2026-09-07 10:22:24 +08:00

185 lines
6.3 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.deps import get_entity_id
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,
# P0-① 三区标注: Bot KPI值最近回填时间/批次(UI BotSourceTag 消费)
"value_updated_at": latest_val.calculated_at.strftime("%Y-%m-%d %H:%M") if latest_val and latest_val.calculated_at else None,
"value_source_batch": latest_val.source_batch 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),
entity_id: int = Depends(get_entity_id),
):
"""更新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不存在")
# 多租户隔离 2026-09-07: 归属校验,禁止跨企业修改他企 Bot KPI
if kpi.entity_id != entity_id:
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}