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 任总).
This commit is contained in:
@@ -15,7 +15,8 @@ from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
|
||||
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
|
||||
from app.utils.alert_entity_scope import is_excluded_entity
|
||||
# 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫)
|
||||
from app.utils.alert_entity_scope import guard_alert_period, is_excluded_entity
|
||||
|
||||
logger = logging.getLogger("alert_rules")
|
||||
|
||||
@@ -273,6 +274,9 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
||||
value_cache = {}
|
||||
|
||||
alerts_generated = 0
|
||||
# R5(裁定 2026-09-19):期间非规整月(含伪期间 2026-H1/H2)→ 该行不入库,跳过并记数
|
||||
skipped_non_month_period = 0
|
||||
skipped_non_month_period_kpis = []
|
||||
|
||||
for rule in rules:
|
||||
try:
|
||||
@@ -341,6 +345,14 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
||||
alert_level, alert_message = _check_trend(kpi.id, value, "down", params, db)
|
||||
|
||||
if alert_level and alert_level != "green":
|
||||
# R5 生成侧守卫(裁定 2026-09-19):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库。
|
||||
# 注:`latest_value` 按 period 字符串倒序取,`2026-H1` 会排在 `2026-09` 之前 →
|
||||
# 伪期间确实可能被取为「最新值」,此处是伪期间入库唯一的拦截点。
|
||||
if not guard_alert_period(period, source="alert_rules.run_all_alert_checks",
|
||||
detail="kpi=%s rule_id=%s" % (kpi.kpi_code, rule.id)):
|
||||
skipped_non_month_period += 1
|
||||
skipped_non_month_period_kpis.append("%s[%s]" % (kpi.kpi_code, period))
|
||||
continue
|
||||
# 检查是否已有相同预警
|
||||
existing_alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == rule.kpi_id,
|
||||
@@ -366,7 +378,12 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
||||
continue
|
||||
|
||||
db.commit()
|
||||
return {"message": f"预警检查完成: 生成{alerts_generated}条", "generated": alerts_generated}
|
||||
if skipped_non_month_period:
|
||||
logger.info("R5 期间守卫:本次跳过 %s 条不可归期预警(%s)",
|
||||
skipped_non_month_period, skipped_non_month_period_kpis)
|
||||
return {"message": f"预警检查完成: 生成{alerts_generated}条", "generated": alerts_generated,
|
||||
"skipped_non_month_period": skipped_non_month_period,
|
||||
"skipped_non_month_period_kpis": skipped_non_month_period_kpis}
|
||||
|
||||
|
||||
@router.get("/dynamic-thresholds")
|
||||
|
||||
@@ -20,6 +20,8 @@ from app.models.budget_plan import BudgetPlan
|
||||
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
from app.models import KPICausality
|
||||
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
|
||||
import json
|
||||
|
||||
logger = logging.getLogger("cma.bot_bridge")
|
||||
@@ -673,6 +675,10 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
period = period or datetime.now().strftime("%Y-%m")
|
||||
|
||||
# R1(裁定 2026-09-19):「R1 与入口无关」——Bot 通道按显式 entity 写入同样不得产出业务预警
|
||||
alerts_allowed = entity_alert_guard(db, entity_id, source="bot_bridge.kpi-value-with-check",
|
||||
detail="kpi_id=%s" % kpi_id)
|
||||
|
||||
# ① 写值(upsert)
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id, KPIValue.period == period).first()
|
||||
@@ -689,7 +695,9 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
||||
|
||||
# ② 跑该KPI关联的预警规则(复用 alert_rules 检查函数,非全量)
|
||||
alerts = []
|
||||
if run_check:
|
||||
# R5(裁定 2026-09-19):期间非规整月(含伪期间)→ 不入库,跳过并记数
|
||||
skipped_non_month_period = 0
|
||||
if run_check and alerts_allowed:
|
||||
from app.api.alert_rules import AlertRule, _check_static, _check_dynamic, _check_trend
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.kpi_id == kpi_id, AlertRule.entity_id == entity_id,
|
||||
@@ -709,6 +717,11 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
||||
else:
|
||||
continue
|
||||
if level and level != "green":
|
||||
# R5 生成侧守卫(裁定 2026-09-19):期间非规整月 → 该 kpi_alerts 行不入库
|
||||
if not guard_alert_period(period, source="bot_bridge.kpi-value-with-check",
|
||||
detail="kpi_id=%s rule_id=%s" % (kpi_id, rule.id)):
|
||||
skipped_non_month_period += 1
|
||||
continue
|
||||
dup = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi_id, KPIAlert.kpi_value_id == kv.id,
|
||||
KPIAlert.alert_level == level, KPIAlert.status == "pending").first()
|
||||
@@ -720,8 +733,13 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
||||
except Exception as e:
|
||||
logger.warning(f"聚合检查失败 rule={rule.id}: {e}")
|
||||
db.commit()
|
||||
return {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
|
||||
"period": period, "alerts": alerts, "status": "ok"}
|
||||
resp = {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
|
||||
"period": period, "alerts": alerts, "status": "ok",
|
||||
"skipped_non_month_period": skipped_non_month_period}
|
||||
if not alerts_allowed:
|
||||
# R1(裁定 2026-09-19):demo 账套在 Bot 通道同样不产生业务预警(值照写、预警跳过)
|
||||
resp.update({"skipped": True, "reason": "R1_demo_entity_excluded"})
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/kpis/create-with-links")
|
||||
|
||||
@@ -24,6 +24,8 @@ from app.models import (
|
||||
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")
|
||||
|
||||
@@ -307,6 +309,12 @@ def receive_mpm_result(
|
||||
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,
|
||||
@@ -367,6 +375,13 @@ def receive_mpm_result(
|
||||
|
||||
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:
|
||||
@@ -394,7 +409,7 @@ def receive_mpm_result(
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
return {
|
||||
resp = {
|
||||
"success": True,
|
||||
"kpi_updated": len(kpi_updates),
|
||||
"alerts_triggered": len(alerts),
|
||||
@@ -403,7 +418,12 @@ def receive_mpm_result(
|
||||
"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")
|
||||
|
||||
@@ -87,6 +87,45 @@ def period_in_window(period, now: Optional[datetime] = None) -> bool:
|
||||
return str(period) >= window_lower_period(now)
|
||||
|
||||
|
||||
# ── R5(裁定 2026-09-19,R3 的一致延伸):可归期原则 + 生成侧守卫 ──
|
||||
|
||||
def is_attributable_period(period) -> bool:
|
||||
"""R5:期间是否「可归期」= 规整月 `YYYY-MM`。
|
||||
|
||||
`period IS NULL` 与 `2026-H1/H2/FY` 同属「不可归期」→ 不参与预警
|
||||
(不可复核、不可归责、不可驱动行动)。
|
||||
"""
|
||||
return is_month_period(period)
|
||||
|
||||
|
||||
def guard_alert_period(period, *, source: str = "", detail: str = "") -> bool:
|
||||
"""R5 生成侧守卫:期间非规整月(含 None)→ 该 `kpi_alerts` 行**不入库**(跳过 + 记日志)。
|
||||
|
||||
返回 True = 放行(可入库);False = 跳过(**调用点必须不落库**)。
|
||||
裁定原文:「任何生成路径产出的 kpi_alerts 行,period 非 YYYY-MM 一律不入库(跳过并记日志),防复发。」
|
||||
"""
|
||||
if is_attributable_period(period):
|
||||
return True
|
||||
logger.info(
|
||||
"R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r source=%s detail=%s",
|
||||
period, source, detail)
|
||||
return False
|
||||
|
||||
|
||||
def entity_alert_guard(db_session, entity_id, *, source: str = "", detail: str = "") -> bool:
|
||||
"""R1 通用生成侧守卫:demo 账套在任何写通道上都不得产生业务预警。
|
||||
|
||||
「R1 与入口无关」——包含按显式 `entity_id` 写入的 Bot 通道(bot_bridge / bot_bridge_v2 等)。
|
||||
返回 True = 放行;False = 跳过(原因 = R1 demo 账套排除,沿用 `excluded_entity_ids`)。
|
||||
"""
|
||||
if is_excluded_entity(db_session, entity_id):
|
||||
logger.info(
|
||||
"R1 账套守卫:跳过不入库(demo 演示账套不产生业务预警)entity_id=%s source=%s detail=%s",
|
||||
entity_id, source, detail)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def scope_summary(db_session, now: Optional[datetime] = None) -> dict:
|
||||
"""口径证据(写日志/报告用):被排除账套 + 窗口下限"""
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user