Files
cma-management/backend/app/api/bot_bridge_v2.py
T
Hermes CI Fix 5a99ed87e0 fix(alerts): enforce caliber ruling R5
- alert_entity_scope: add is_attributable_period / guard_alert_period (R5) and entity_alert_guard (R1, any write path)
- alert_rules.run_all_alert_checks: skip non-YYYY-MM period alert rows (R5)
- bot_bridge / bot_bridge_v2: R1 demo-entity guard + R5 period guard on write paths
- tests: new R5 suite (10 cases)

Not pushed / not deployed (release gate held by 任总).
2026-09-19 10:42:09 +08:00

652 lines
22 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.
"""
Bot-Bridge V2 — 合并改造:Bot桥接 + 自动验证引擎
数据流:
bot-bridge(财务Bot→CMA)→ 写入KPI
verify(CMA→校验)→ 读取KPI → 通过/失败
API:
POST /api/cma/bot-bridge/mpm-result — 接收MPM分析结果
POST /api/cma/bot-bridge/verify/{action_plan_id} — 验证ActionPlan执行结果
"""
import json, logging, re
from datetime import datetime
from typing import Optional
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 (
MpmResult, BotBridgeConfig,
KPIDefinition, KPIValue, KPIAlert,
ActionPlan, Entity,
)
from app.risk_levels import risk_level
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
logger = logging.getLogger("cma.bot_bridge_v2")
router = APIRouter(prefix="/api/cma/bot-bridge", tags=["Bot桥接V2"])
# ═══════════════════════════════════════════════
# 鉴权
# ═══════════════════════════════════════════════
def verify_bridge_token(
x_bridge_token: str = Header(None, alias="X-BRIDGE-TOKEN"),
db: Session = Depends(get_db),
):
"""从bot_bridge_config表校验Token"""
if not x_bridge_token:
raise HTTPException(401, "缺少X-BRIDGE-TOKEN请求头")
config = db.query(BotBridgeConfig).filter(
BotBridgeConfig.token == x_bridge_token,
BotBridgeConfig.is_active == True,
).first()
if not config:
raise HTTPException(401, "Token无效或已停用")
return config.bot_name
# ═══════════════════════════════════════════════
# 验证引擎
# ═══════════════════════════════════════════════
def _get_kpi_current_value(db: Session, kpi_id: int, entity_id: int = None) -> Optional[dict]:
"""获取KPI当前最新值及目标值"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
return None
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.data_status == "verified",
).order_by(KPIValue.calculated_at.desc(), KPIValue.period.desc()).first()
return {
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"value": latest.actual_value if latest else None,
"target": kpi.target_value,
"period": latest.period if latest else None,
"unit": kpi.unit,
}
def _parse_condition(condition: str) -> list:
"""
解析condition表达式为可执行结构
支持格式:
"value > target" — 当前值 > 目标值
"value >= 80" — 当前值 >= 80 (绝对值)
"value / target * 100 > 80" — 完成率 > 80%
"value < baseline" — 当前值 < 基线值(目标值=基线)
"value == 0" — 精确等于
"value > target AND value < 100" — 多条件AND
"value > 50 OR value == -1" — 多条件OR
"""
if not condition:
return []
# 分割AND/OR
parts = re.split(r'\s+(AND|OR)\s+', condition, flags=re.IGNORECASE)
clauses = []
for i in range(0, len(parts), 2):
expr = parts[i].strip()
# 检查是否有运算符连接
match = re.match(
r'(value|target|baseline)\s*([><=!]+)\s*(value|target|baseline|-?\d+\.?\d*)(?:\s*([+\-*/])\s*(\d+\.?\d*))?',
expr
)
if not match:
# 尝试 value/target*100 > 80 这种复合表达式
match = re.match(
r'(value)\s*(/|\\*)\s*(target|baseline)\s*(\*?\s*\d+)?\s*([><=!]+)\s*(-?\d+\.?\d*)',
expr
)
if not match:
logger.warning(f"无法解析condition表达式: {expr}")
continue
logical_op = parts[i + 1].upper() if i + 1 < len(parts) else "AND"
clauses.append({"expr": expr, "logical_op": logical_op, "match": match.groups() if match else ()})
return clauses
def _evaluate_condition(condition: str, kpi_data: dict) -> bool:
"""
评估condition表达式
Args:
condition: 条件表达式 e.g. "value > target"
kpi_data: {value, target, ...}
Returns:
True=通过, False=不通过
"""
if not condition:
return False
value = kpi_data.get("value")
target = kpi_data.get("target")
baseline = kpi_data.get("baseline", target)
if value is None:
logger.warning(f"KPI {kpi_data.get('kpi_code')} 当前值为空,无法验证")
return False
result = True
current_logical = "AND"
# 按AND/OR分割
parts = re.split(r'\s+(AND|OR)\s+', condition, flags=re.IGNORECASE)
for i in range(0, len(parts), 2):
expr = parts[i].strip()
if i + 1 < len(parts):
current_logical = parts[i + 1].upper()
clause_passed = _eval_single_expr(expr, value, target, baseline)
if current_logical == "AND":
result = result and clause_passed
elif current_logical == "OR":
result = result or clause_passed
# 短路优化
if current_logical == "AND" and not result:
break
if current_logical == "OR" and result:
break
return result
def _eval_single_expr(expr: str, value: float, target: Optional[float], baseline: Optional[float]) -> bool:
"""评估单条条件表达式"""
# 模式1: value <op> target/baseline/number (如 value > target, value >= 80)
m = re.match(
r'(value|target|baseline)\s*([><=!]+)\s*(value|target|baseline|-?\d+\.?\d*)',
expr
)
if m:
left = m.group(1)
op = m.group(2)
right_raw = m.group(3)
left_val = _resolve_var(left, value, target, baseline)
right_val = _resolve_var(right_raw, value, target, baseline)
if left_val is None or right_val is None:
return False
return _apply_op(left_val, op, right_val)
# 模式2: value [*/] target/baseline [* number] <op> number (如 value/target*100 > 80)
m = re.match(
r'(value)\s*([/*])\s*(target|baseline)(?:\s*\*\s*(\d+))?\s*([><=!]+)\s*(-?\d+\.?\d*)',
expr
)
if m:
left = m.group(1)
op1 = m.group(2) # / or *
var2 = m.group(3) # target or baseline
multiplier = float(m.group(4)) if m.group(4) else 100
op2 = m.group(5) # > < >= <= == !=
right_num = float(m.group(6))
left_val = _resolve_var(left, value, target, baseline)
right_var = _resolve_var(var2, value, target, baseline)
if left_val is None or right_var is None or right_var == 0:
return False
if op1 == '/':
computed = (left_val / right_var) * multiplier
else: # *
computed = left_val * right_var
return _apply_op(computed, op2, right_num)
# 模式3: 纯数字比较 (提供兼容)
logger.warning(f"无法解析表达式: {expr}")
return False
def _resolve_var(token: str, value: float, target: Optional[float], baseline: Optional[float]) -> Optional[float]:
"""将变量名解析为数值"""
token = token.strip()
if token == "value":
return value
elif token == "target":
return target
elif token == "baseline":
return baseline
else:
try:
return float(token)
except (ValueError, TypeError):
return None
def _apply_op(left: float, op: str, right: float) -> bool:
"""应用比较运算符"""
try:
if op == ">":
return left > right
elif op == ">=":
return left >= right
elif op == "<":
return left < right
elif op == "<=":
return left <= right
elif op in ("==", "="):
return abs(left - right) < 0.0001
elif op == "!=":
return abs(left - right) >= 0.0001
else:
logger.warning(f"未知运算符: {op}")
return False
except (TypeError, ValueError):
return False
# ═══════════════════════════════════════════════
# KPI映射表:MPM结果字段 → KPI编码
# ═══════════════════════════════════════════════
MPM_TO_KPI_MAP = {
"revenue": None, # 不做KPI映射,保留在raw_data
"cost": None,
"gross_margin_standard": None,
"gross_margin_adjusted": "F_GROSS_MARGIN",
"net_profit_standard": None,
"net_profit_adjusted": "F_NET_PROFIT",
"channel_rebate_rate": "C_REBATE_RATE",
"mgmt_expense_ratio": "F_COST_RATIO",
}
# 预警阈值配置
ALERT_THRESHOLDS = {
"channel_rebate_rate": {"threshold": 75, "operator": ">", "kpi_code": "C_REBATE_RATE", "kpi_name": "渠补率"},
"mgmt_expense_ratio": {"threshold": 50, "operator": ">", "kpi_code": "F_COST_RATIO", "kpi_name": "管理费/净收入"},
}
# ═══════════════════════════════════════════════
# API端点
# ═══════════════════════════════════════════════
@router.post("/mpm-result")
@risk_level("L3")
def receive_mpm_result(
data: dict,
bridge_bot: str = Depends(verify_bridge_token),
db: Session = Depends(get_db),
):
"""
接收财务Bot/其他Bot的MPM分析结果
- 鉴权(校验X-BRIDGE-TOKEN)
- 写入mpm_results表
- 更新KPI当前值(kpi_values表)
- 触发预警(如渠补率超75%)
"""
source = data.get("source", bridge_bot)
entity_id = data.get("entity_id")
period = data.get("period")
results = data.get("results", {})
if not entity_id or not period:
raise HTTPException(400, "缺少必填字段: entity_id, period")
# 验证entity存在
entity = db.query(Entity).filter(Entity.id == entity_id).first()
if not entity:
raise HTTPException(404, f"实体entity_id={entity_id}不存在")
# R1/R5(裁定 2026-09-19):「R1 与入口无关」——Bot 通道按显式 entity 写入同样不得产出业务预警;
# 期间非规整月(含 2026-H1/H2 伪期间)的 kpi_alerts 行一律不入库(跳过 + 记日志)。
alerts_allowed = entity_alert_guard(db, entity_id, source="bot_bridge_v2.receive_mpm_result")
period_attributable = guard_alert_period(period, source="bot_bridge_v2.receive_mpm_result",
detail="entity_id=%s" % entity_id)
# ── 1. 写入MPM结果 ──
record = MpmResult(
entity_id=entity_id,
period=period,
source=source,
raw_data=results,
)
db.add(record)
db.flush()
# ── 2. 更新KPI当前值 ──
kpi_updates = []
for field, kpi_code in MPM_TO_KPI_MAP.items():
if kpi_code is None:
continue
field_value = results.get(field)
if field_value is None:
continue
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == kpi_code,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
logger.warning(f"KPI编码 {kpi_code} 未找到 (entity_id={entity_id})")
continue
# 写入最新值 (upsert: 存在同period则更新,否则插入)
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).first()
if existing:
existing.actual_value = field_value
existing.source_type = "bot-bridge-v2"
existing.data_status = "verified"
else:
kpi_val = KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=field_value,
source_type="bot-bridge-v2",
data_status="verified",
)
db.add(kpi_val)
kpi_updates.append(kpi_code)
# ── 3. 触发预警 ──
alerts = []
for field, config in ALERT_THRESHOLDS.items():
field_value = results.get(field)
if field_value is None:
continue
threshold = config["threshold"]
operator = config["operator"]
kpi_code = config.get("kpi_code")
kpi_name = config["kpi_name"]
if (operator == ">" and field_value > threshold) or \
(operator == ">=" and field_value >= threshold):
# R1/R5 生成侧守卫(裁定 2026-09-19):demo 账套 / 不可归期期间 → 该行不入库
if not alerts_allowed:
logger.info("R1 账套守卫:跳过不入库(demo 演示账套)entity_id=%s", entity_id)
continue
if not period_attributable:
logger.info("R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r", period)
continue
# 查找关联KPI
kpi_id = None
if kpi_code:
kpi_def = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == kpi_code,
KPIDefinition.entity_id == entity_id,
).first()
if kpi_def:
kpi_id = kpi_def.id
# 创建预警
alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]"
alert = KPIAlert(
entity_id=entity_id, # 2026-09-13 F1: 显式写归属
kpi_id=kpi_id or 1, # fallback to first KPI if not found
alert_level="red",
alert_message=alert_msg,
alert_type="actual",
status="pending",
)
db.add(alert)
alerts.append(alert_msg)
logger.warning(alert_msg)
db.commit()
db.refresh(record)
resp = {
"success": True,
"kpi_updated": len(kpi_updates),
"alerts_triggered": len(alerts),
"mpm_record_id": record.id,
"details": {
"kpi_codes": kpi_updates,
"alerts": alerts,
},
"skipped_non_month_period": (not period_attributable),
}
if not alerts_allowed:
# R1(裁定 2026-09-19):demo 账套在 Bot 通道同样不产生业务预警
resp.update({"skipped": True, "reason": "R1_demo_entity_excluded"})
return resp
@router.post("/kpi-result")
@risk_level("L2")
def push_kpi_result(
data: dict,
bridge_bot: str = Depends(verify_bridge_token),
db: Session = Depends(get_db),
):
"""
Bot分析结果回填KPI值 + 触发预警(PRD第七部分 bot-bridge数据通道)
入参:
entity_id (int, 默认1): 企业实体ID
kpi_code (str, 必填): KPI编码
period (str, 必填): 期间 YYYY-MM
value (num, 必填): 实际值
source (str, 默认finance-bot): 来源Bot标识
remark (str, 可选): 备注
行为:
1. 按(entity_id, kpi_code)查KPI → 不存在返回错误
2. 写入KPIValue (source_type=bot, data_status=verified) — 与auto-verify引擎兼容
3. 调用 run_alert_check 触发阈值预警 → 返回 new_alerts
"""
entity_id = data.get("entity_id", 1)
kpi_code = data.get("kpi_code")
period = data.get("period")
value = data.get("value")
source = data.get("source", "finance-bot")
remark = data.get("remark")
if not kpi_code:
raise HTTPException(400, "缺少必填字段: kpi_code")
if not period:
raise HTTPException(400, "缺少必填字段: period")
if value is None or value == "":
raise HTTPException(400, "缺少必填字段: value")
try:
actual_value = float(value)
except (TypeError, ValueError):
raise HTTPException(400, f"value不是有效数值: {value!r}")
# 1. 按(entity_id, kpi_code)查KPI
kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == kpi_code,
).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_code} 不存在 (entity_id={entity_id})")
# 2. 写入KPIValue(同period已存在则更新,幂等upsert)
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).first()
if existing:
existing.actual_value = actual_value
existing.source_type = "bot"
existing.source_batch = source
existing.data_status = "verified"
existing.calculated_at = datetime.now()
if remark:
existing.remark = remark
val = existing
else:
val = KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=actual_value,
source_type="bot",
source_batch=source,
data_status="verified",
calculated_at=datetime.now(),
remark=remark,
)
db.add(val)
db.commit()
db.refresh(val)
# 3. 触发预警检查(复用alert_generator引擎,yellow/red自动联动行动计划)
from scripts.alert_generator import run_alert_check
new_count = run_alert_check(db, period)
# 收集本次写入值直接触发的预警(kpi_value_id关联)
new_alerts = []
if new_count:
triggered = db.query(KPIAlert).filter(
KPIAlert.kpi_value_id == val.id,
).order_by(KPIAlert.created_at.desc()).all()
new_alerts = [
{
"id": a.id,
"kpi_id": a.kpi_id,
"kpi_code": kpi.kpi_code,
"level": a.alert_level,
"message": a.alert_message,
"action_plan_id": a.action_plan_linked_id,
"created_at": a.created_at.isoformat() if a.created_at else None,
} for a in triggered
]
logger.info(
f"[bot-bridge] KPI回填: {kpi.kpi_code}@{period}={actual_value} "
f"source={source} entity={entity_id} alerts={new_count}"
)
return {
"status": "ok",
"kpi_value_id": val.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"period": period,
"value": actual_value,
"source": source,
"new_alerts_count": len(new_alerts),
"new_alerts": new_alerts,
}
@router.post("/verify/{action_plan_id}")
@risk_level("L2")
def verify_action_plan(
action_plan_id: int,
bridge_bot: str = Depends(verify_bridge_token),
db: Session = Depends(get_db),
):
"""
验证ActionPlan的执行结果
⚠️ 双 verify 入口关系(2026-08-30 评审收敛,暂不重构):
- 本函数(/api/cma/bot-bridge/verify/{action_plan_id}): 轻量版 — 仅读取KPI并按condition
校验,记录 verify_log,不写KPIValue回填、不联动OKR、不发企微通知。供Bot桥接通道调用。
- verify.py(/api/cma/verify/{plan_id}): 完整链路 — 回填KPIValue + OKR progress联动
+ 企微通知。业务侧手动/自动重验走那个入口。
- 两者行为不一致,勿混用。
1. 读取ActionPlan的auto_verify_rule
2. 读取关联KPI的当前值
3. 按condition校验
4. 返回通过/失败 + 详细数据
"""
plan = db.query(ActionPlan).filter(ActionPlan.id == action_plan_id).first()
if not plan:
raise HTTPException(404, f"ActionPlan {action_plan_id} 不存在")
if not plan.auto_verify_rule:
raise HTTPException(400, "该ActionPlan未配置auto_verify_rule验证规则")
# 解析验证规则
if isinstance(plan.auto_verify_rule, str):
rule = json.loads(plan.auto_verify_rule)
else:
rule = plan.auto_verify_rule
condition = rule.get("condition", "")
description = rule.get("description", "")
# 获取KPI当前值
kpi_data = _get_kpi_current_value(db, plan.kpi_id)
if not kpi_data:
return {
"success": False,
"action_plan_id": action_plan_id,
"title": plan.title,
"verify_result": "fail",
"reason": "关联KPI不存在",
}
# 执行验证
passed = _evaluate_condition(condition, kpi_data)
# 记录验证日志
verify_log_entry = {
"timestamp": datetime.now().isoformat(),
"condition": condition,
"kpi_data": kpi_data,
"passed": passed,
}
existing_logs = plan.verify_log or []
if isinstance(existing_logs, list):
existing_logs.append(verify_log_entry)
else:
existing_logs = [verify_log_entry]
plan.verify_result = "pass" if passed else "fail"
plan.verify_log = existing_logs
db.commit()
return {
"success": True,
"action_plan_id": action_plan_id,
"title": plan.title,
"verify_result": plan.verify_result,
"condition": condition,
"condition_description": description,
"kpi_data": kpi_data,
"verification_detail": {
"value": kpi_data.get("value"),
"target": kpi_data.get("target"),
"condition": condition,
"passed": passed,
},
}
@router.get("/verify/{action_plan_id}/history")
@risk_level("L1")
def verify_history(
action_plan_id: int,
bridge_bot: str = Depends(verify_bridge_token),
db: Session = Depends(get_db),
):
"""获取ActionPlan的验证历史"""
plan = db.query(ActionPlan).filter(ActionPlan.id == action_plan_id).first()
if not plan:
raise HTTPException(404, f"ActionPlan {action_plan_id} 不存在")
return {
"action_plan_id": action_plan_id,
"title": plan.title,
"verify_result": plan.verify_result,
"verify_log": plan.verify_log or [],
"auto_verify_rule": plan.auto_verify_rule,
}