R3 期间口径(仅 YYYY-MM)延伸到取数层:新增 alert_entity_scope.month_period_clause()(跨库可移植 SQL 等价式),应用到 15 个文件 26 处「按 period 倒序取最新值/最近N期」位点;dashboard 原 MySQL-only REGEXP 正则收口到同单一事实源。 GET /api/cma/subjects 响应增补 entity_id(NULL=全局标准准则科目 / N=账套私有明细),既有 10 字段语义与名称不变。 证据:pending-tasks/_reports/yanxue-20260924-cma-fetch-caliber-fixes-fullstack.md(改前/改后 A/B:F_NET_PROFIT 2026-H2/-55.0 → 2026-08/-10.46)。新增 7 例回归测试;全量 1 failed(预存在) 1473 passed,基线同项。
196 lines
7.2 KiB
Python
196 lines
7.2 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.utils.alert_entity_scope import month_period_clause
|
||
from app.risk_levels import risk_level
|
||
|
||
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
|
||
dependencies=[Depends(require_auth)],
|
||
)
|
||
|
||
# 反向指标编码(值越低越好)。
|
||
# 方向权威源 = app.api.kpis.REVERSE_INDICATORS(2026-09-15 M2 准入2 收口):
|
||
# 本文件原 import 了权威表却未使用、另立字面量 ['FB_RESPONSE_TIME'],会让同一 kpi_code
|
||
# 在「能力评分」(取权威表=反向) 与「Bot 自评」(取本表=正向) 方向打架
|
||
# (实测同一宇宙内 F_COST_RATIO / F_AR_DAYS / F_REBATE_RATE 三码即为此类)。
|
||
# 现改为派生:权威表 ∪ Bot 专有反向指标(零漂移)。
|
||
from app.api.kpis import REVERSE_INDICATORS as _AUTHORITATIVE_REVERSE_INDICATORS
|
||
|
||
BOT_ONLY_REVERSE_INDICATORS = ['FB_RESPONSE_TIME']
|
||
REVERSE_BOT_INDICATORS = list(_AUTHORITATIVE_REVERSE_INDICATORS) + [
|
||
c for c in BOT_ONLY_REVERSE_INDICATORS if c not in _AUTHORITATIVE_REVERSE_INDICATORS]
|
||
|
||
|
||
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),
|
||
entity_id: int = Depends(get_entity_id),
|
||
):
|
||
"""获取某Bot的所有KPI(含评分)(账套隔离 2026-09-21:仅本账套,对齐 update_bot_kpi_value)"""
|
||
kpis = db.query(KPIDefinition).filter(
|
||
KPIDefinition.bot_source == source,
|
||
KPIDefinition.status == "active",
|
||
KPIDefinition.entity_id == entity_id,
|
||
).order_by(KPIDefinition.kpi_code).all()
|
||
|
||
if not kpis:
|
||
return {
|
||
"source": source,
|
||
"kpis": [],
|
||
"groups": {},
|
||
"overall": None,
|
||
}
|
||
|
||
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)
|
||
val_query = val_query.filter(month_period_clause(KPIValue.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}
|