fix(kpi): 阈值红黄绿读写/填写规则修复 — 统一解析器+红灯优先+建议可解析+前端校验+存量修正 (project-20260911-threshold-fix)

① 新建 app/utils/threshold_parser.py 统一解析/判定(单一事实源):
   - 支持负数(>=-100000)、闭区间 A~B、多条件 C1|C2;空串=未设置
   - 判定顺序 red→green→yellow:红灯优先(消除宽黄灯遮蔽红灯),
     green 先于 yellow(green 是主条件,yellow 是更宽兜底带)
② kpis.py / alert_generator.py 双实现收敛到统一解析器(消除重复维护)
③ thresholds.py suggest 输出可解析表达式(原黄色/适中型生成 `52.33~107.67`、`偏离55%` 等
   区间/文字 → 用户点"自动建议"填入后静默失效;另修复大数目标生成 1.6e+06 科学计数法)
④ KPIDetail.vue 三个阈值输入加 placeholder/tooltip/帮助文案 + 保存前格式校验(非法即拒)
⑤ 存量 4 个不可解析表达式修正(id=46 C_REBATE_RATE / id=327 BH_SALES_10),
   改前 mysqldump 备份 backend/scripts/backups/kpi_definitions.bak-20260911.sql +
   threshold_rows_before_20260911.json(可回滚);不可解析 5→0
⑥ 单测 97 条 + 红黄绿判定回归 367 判定点(旧→新差异 14 条,含差异说明)
This commit is contained in:
Hermes CI Fix
2026-09-11 17:25:24 +08:00
parent f61b6ce604
commit 4c6fb85cf5
11 changed files with 1981 additions and 122 deletions
+16 -37
View File
@@ -6,7 +6,6 @@
import sys
import os
import re
import json
import logging
from datetime import datetime, timedelta
@@ -15,22 +14,15 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
logger = logging.getLogger("cma.alert_gen")
def _parse_threshold(expr: str) -> tuple:
"""解析阈值表达式,返回 (operator, value)
示例:
>=32000000 -> ('>=', 32000000)
<20 -> ('<', 20)
<=55 -> ('<=', 55)
>60 -> ('>', 60)
"""
m = re.match(r"(>=|<=|>|<|=|!=)\s*([\d.]+)", str(expr).strip())
if m:
return m.group(1), float(m.group(2))
return None, None
"""[兼容保留] 统一委托 app.utils.threshold_parser.parse_threshold"""
from app.utils.threshold_parser import parse_threshold as _p
return _p(expr)
def cleanup_green_alerts(db_session) -> int:
@@ -52,33 +44,16 @@ def cleanup_green_alerts(db_session) -> int:
def _check_threshold(actual: float, threshold_expr: str, level: str) -> tuple:
"""检查实际值是否触发阈值,返回 (触发, 消息)"""
"""检查实际值是否触发阈值,返回 (触发, 消息)(统一走 app.utils.threshold_parser)"""
if not threshold_expr or actual is None:
return False, ""
op, val = _parse_threshold(threshold_expr)
if op is None:
if not eval_threshold(actual, threshold_expr):
return False, ""
triggered = False
if op == ">=" and actual >= val:
triggered = True
elif op == "<=" and actual <= val:
triggered = True
elif op == ">" and actual > val:
triggered = True
elif op == "<" and actual < val:
triggered = True
elif op == "=" and actual == val:
triggered = True
if triggered:
level_names = {"green": "正常", "yellow": "预警", "red": "紧急"}
msg = (f"KPI当前值 {actual:.2f},触发{level_names.get(level, level)}阈值 "
f"({threshold_expr})")
return True, msg
return False, ""
msg = (f"KPI当前值 {actual:.2f},触发{ {'green': '正常', 'yellow': '预警', 'red': '紧急'}.get(level, level) }阈值 "
f"({threshold_expr})")
return True, msg
def run_alert_check(db_session, period: str = None) -> int:
@@ -112,9 +87,12 @@ def run_alert_check(db_session, period: str = None) -> int:
actual = latest.actual_value
# 从绿到红检查(绿灯最高优先级——满足即止)
# 判定顺序:红灯优先 → 绿灯 → 黄灯(最严重者优先,消除宽黄灯遮蔽红灯)
# 2026-09-11 修复「黄灯遮蔽红灯」:原顺序 绿→黄→红命中即止,宽黄灯(如 Y='>76')
# 会让红灯(如 R='>91')永远不可达(id=6 实测值 95 被判黄);
# green 先于 yellow:green 是主条件,yellow 是更宽的兜底带(id=1 G='>=800' Y='>=700')。
# Bug1修复: green(正常)不生成pending预警,只对yellow/red落库
for level in ["green", "yellow", "red"]:
for level in LEVEL_PRIORITY:
expr = thr[level]
if not expr:
continue
@@ -173,7 +151,8 @@ def run_alert_check(db_session, period: str = None) -> int:
logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}")
break # 只取最高级别
elif level == "red" and not triggered:
# 红没触发,如果已有红色预警但当前不满足,自动降级或关闭
# 红没触发(判定顺序中 red 最先)→ 若该期间该KPI已有 pending/processing 红灯,
# 说明当前值已不再触发红色阈值,自动解除(保留原有自动降级行为)
existing_red = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.kpi_value_id == latest.id,
@@ -0,0 +1,751 @@
{
"samples": 367,
"count_old": {
"red": 120,
"yellow": 17,
"green": 46,
"info": 184
},
"count_new": {
"red": 126,
"yellow": 12,
"green": 54,
"info": 175
},
"diff_ab": [
{
"kpi_id": 6,
"kpi_code": "F_AR_DAYS",
"period": "2026-04",
"actual": 152.62,
"old": "yellow",
"new": "red",
"severity_order": "red",
"G": "<=76",
"Y": ">76",
"R": ">91"
},
{
"kpi_id": 6,
"kpi_code": "F_AR_DAYS",
"period": "2026-05",
"actual": 105.47,
"old": "yellow",
"new": "red",
"severity_order": "red",
"G": "<=76",
"Y": ">76",
"R": ">91"
},
{
"kpi_id": 6,
"kpi_code": "F_AR_DAYS",
"period": "2026-07",
"actual": 116.5,
"old": "yellow",
"new": "red",
"severity_order": "red",
"G": "<=76",
"Y": ">76",
"R": ">91"
},
{
"kpi_id": 6,
"kpi_code": "F_AR_DAYS",
"period": "2026-08",
"actual": 610.2,
"old": "yellow",
"new": "red",
"severity_order": "red",
"G": "<=76",
"Y": ">76",
"R": ">91"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-01",
"actual": 124.1,
"old": "info",
"new": "red",
"severity_order": "red",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-02",
"actual": 84.0,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-03",
"actual": 69.6,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-04",
"actual": 81.7,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-05",
"actual": 88.3,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-06",
"actual": 52.8,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-07",
"actual": 68.7,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-08",
"actual": 75.0,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-H1",
"actual": 86.4,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 407,
"kpi_code": "F_REBATE_RATE",
"period": "2026-08",
"actual": 48.44,
"old": "yellow",
"new": "red",
"severity_order": "red",
"G": "<=10",
"Y": ">10",
"R": ">12"
}
],
"diff_bc": [
{
"kpi_id": 3,
"kpi_code": "F_NET_PROFIT",
"period": "2026-01",
"actual": 94.31,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=30",
"Y": ">=20",
"R": "<10"
},
{
"kpi_id": 4,
"kpi_code": "F_OP_CFLOW",
"period": "2026-01",
"actual": 65.2173,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=20",
"Y": ">=0",
"R": "<0"
},
{
"kpi_id": 4,
"kpi_code": "F_OP_CFLOW",
"period": "2026-02",
"actual": 78.4307,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=20",
"Y": ">=0",
"R": "<0"
},
{
"kpi_id": 4,
"kpi_code": "F_OP_CFLOW",
"period": "2026-04",
"actual": 124.422,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=20",
"Y": ">=0",
"R": "<0"
},
{
"kpi_id": 4,
"kpi_code": "F_OP_CFLOW",
"period": "2026-06",
"actual": 43.4998,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=20",
"Y": ">=0",
"R": "<0"
},
{
"kpi_id": 5,
"kpi_code": "F_COST_RATIO",
"period": "2026-01",
"actual": 15.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<=25",
"Y": "<=30",
"R": ">30"
},
{
"kpi_id": 5,
"kpi_code": "F_COST_RATIO",
"period": "2026-02",
"actual": 18.85,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<=25",
"Y": "<=30",
"R": ">30"
},
{
"kpi_id": 5,
"kpi_code": "F_COST_RATIO",
"period": "2026-06",
"actual": 24.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<=25",
"Y": "<=30",
"R": ">30"
},
{
"kpi_id": 5,
"kpi_code": "F_COST_RATIO",
"period": "2026-H1",
"actual": 24.9,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<=25",
"Y": "<=30",
"R": ">30"
},
{
"kpi_id": 8,
"kpi_code": "C_NEW_CLIENTS",
"period": "2026-07",
"actual": 88.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=10",
"Y": ">=5",
"R": "<5"
},
{
"kpi_id": 14,
"kpi_code": "F_REVENUE_GROWTH",
"period": "2026-05",
"actual": 48.91,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=20",
"Y": ">=15",
"R": "<15"
},
{
"kpi_id": 14,
"kpi_code": "F_REVENUE_GROWTH",
"period": "2026-06",
"actual": 43.54,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=20",
"Y": ">=15",
"R": "<15"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-01",
"actual": 37.01,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-02",
"actual": 38.33,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-04",
"actual": 42.02,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-05",
"actual": 41.49,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-06",
"actual": 39.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-07",
"actual": 38.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-08",
"actual": 39.9,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 184,
"kpi_code": "FB_ANALYSIS_COUNT",
"period": "2026-07",
"actual": 18.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=12",
"Y": ">=8",
"R": "<4"
},
{
"kpi_id": 318,
"kpi_code": "BH_SALES_1",
"period": "2026-H1",
"actual": 754562.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=754562",
"Y": ">=603649",
"R": "<452737"
},
{
"kpi_id": 319,
"kpi_code": "BH_SALES_2",
"period": "2026-H1",
"actual": 579205.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=579205",
"Y": ">=463364",
"R": "<347523"
},
{
"kpi_id": 320,
"kpi_code": "BH_SALES_3",
"period": "2026-H1",
"actual": 209038.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=209038",
"Y": ">=167230",
"R": "<125422"
},
{
"kpi_id": 321,
"kpi_code": "BH_SALES_4",
"period": "2026-H1",
"actual": 285990.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=285990",
"Y": ">=228792",
"R": "<171594"
},
{
"kpi_id": 322,
"kpi_code": "BH_SALES_5",
"period": "2026-H1",
"actual": 372816.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=372816",
"Y": ">=298253",
"R": "<223690"
},
{
"kpi_id": 323,
"kpi_code": "BH_SALES_6",
"period": "2026-H1",
"actual": 229512.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=229512",
"Y": ">=183610",
"R": "<137707"
},
{
"kpi_id": 324,
"kpi_code": "BH_SALES_7",
"period": "2026-H1",
"actual": 35173.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=35173",
"Y": ">=28138",
"R": "<21104"
},
{
"kpi_id": 325,
"kpi_code": "BH_SALES_8",
"period": "2026-H1",
"actual": 2327.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=2327",
"Y": ">=1862",
"R": "<1396"
},
{
"kpi_id": 326,
"kpi_code": "BH_SALES_9",
"period": "2026-01",
"actual": 625983.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=1785",
"Y": ">=1428",
"R": "<1071"
},
{
"kpi_id": 326,
"kpi_code": "BH_SALES_9",
"period": "2026-02",
"actual": 253099.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=1785",
"Y": ">=1428",
"R": "<1071"
},
{
"kpi_id": 326,
"kpi_code": "BH_SALES_9",
"period": "2026-03",
"actual": 526357.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=1785",
"Y": ">=1428",
"R": "<1071"
},
{
"kpi_id": 326,
"kpi_code": "BH_SALES_9",
"period": "2026-H1",
"actual": 1785.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=1785",
"Y": ">=1428",
"R": "<1071"
},
{
"kpi_id": 327,
"kpi_code": "BH_SALES_10",
"period": "2026-02",
"actual": 1486.73,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=200",
"Y": ">=160",
"R": "<120"
},
{
"kpi_id": 327,
"kpi_code": "BH_SALES_10",
"period": "2026-03",
"actual": 2991.15,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=200",
"Y": ">=160",
"R": "<120"
},
{
"kpi_id": 327,
"kpi_code": "BH_SALES_10",
"period": "2026-H1",
"actual": 4180.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=200",
"Y": ">=160",
"R": "<120"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2022",
"actual": 103.4,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2025",
"actual": 107.6,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2026-01",
"actual": 201.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2026-04",
"actual": 120.5,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2026-05",
"actual": 110.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2026-08",
"actual": 278.7,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2026-H1",
"actual": 106.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
}
],
"alert_only_old": [
[
6,
"2026-04",
"yellow"
],
[
6,
"2026-05",
"yellow"
],
[
6,
"2026-07",
"yellow"
],
[
6,
"2026-08",
"yellow"
],
[
407,
"2026-08",
"yellow"
]
],
"alert_only_new": [
[
6,
"2026-04",
"red"
],
[
6,
"2026-05",
"red"
],
[
6,
"2026-07",
"red"
],
[
6,
"2026-08",
"red"
],
[
46,
"2026-01",
"red"
],
[
407,
"2026-08",
"red"
]
],
"alert_gen_dry_run_new": 0
}
@@ -0,0 +1,32 @@
{
"created_at": "2026-09-11T17:11:24.159913",
"rows": [
{
"id": 46,
"kpi_code": "C_REBATE_RATE",
"kpi_name": "渠补率",
"target_value": 80.0,
"threshold_green": "52.33~107.67",
"threshold_yellow": "偏离55%",
"threshold_red": "偏离>55%"
},
{
"id": 327,
"kpi_code": "BH_SALES_10",
"kpi_name": "销售-任富海",
"target_value": 200.0,
"threshold_green": "",
"threshold_yellow": "偏离182878%",
"threshold_red": "偏离>182878%"
},
{
"id": 32,
"kpi_code": "F_EVA",
"kpi_name": "经济增加值(EVA)",
"target_value": 0.0,
"threshold_green": ">=0",
"threshold_yellow": ">=-100000",
"threshold_red": "<-100000"
}
]
}
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""存量阈值数据修正(project-20260911-threshold-fix ⑤)
修正前:
1) 备份 kpi_definitions 全表 → backend/scripts/backups/kpi_definitions.bak-20260911.sql(mysqldump)
2) 记录 3 行的原值 → backups/threshold_rows_before_20260911.json(可回滚)
修正内容(7 个不可解析表达式 → 0):
id=32 F_EVA : Y='>=-100000' R='<-100000' → 解析器支持负数后自然可解析,无需改值(仅验证)
id=46 C_REBATE_RATE: G='52.33~107.67'(保留原绿区间)
Y='偏离55%' → '<52.33|>107.67'(绿区间外即黄)
R='偏离>55%' → '<=36|>=124'(80±27.67×2 ≈ 原"55"口径)
id=327 BH_SALES_10 : G=''(补) → '>=200'
Y='偏离182878%'→ '>=160'
R='偏离>182878%'→ '<120'
口径对齐同族 BH_SALES_1..9:G>=target / Y>=0.8target / R<0.6target
用法:
python3 scripts/fix_threshold_data_20260911.py # 预演(只打印)
python3 scripts/fix_threshold_data_20260911.py --apply # 备份 + 落库
"""
import os
import sys
import json
import subprocess
from datetime import datetime
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE)
BACKUP_DIR = os.path.join(BASE, "scripts", "backups")
envf = os.path.join(BASE, ".env")
ENV = {}
if os.path.exists(envf):
for line in open(envf, encoding="utf-8"):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
ENV[k.strip()] = v.strip().strip('"').strip("'")
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
from app.database import get_session_local
from app.models import KPIDefinition
from app.utils.threshold_parser import is_valid_threshold
# 目标行号 → 新值(None 表示不改动)
FIXES = {
46: {"threshold_green": "52.33~107.67",
"threshold_yellow": "<52.33|>107.67",
"threshold_red": "<=36|>=124"},
327: {"threshold_green": ">=200",
"threshold_yellow": ">=160",
"threshold_red": "<120"},
}
# 仅验证不修改
VERIFY_ONLY = [32]
FIELDS = ("threshold_green", "threshold_yellow", "threshold_red")
def scan(db):
"""返回 (总表达式数, 不可解析明细)"""
rows = db.query(KPIDefinition).all()
total = 0
bad = []
for k in rows:
for f in FIELDS:
e = getattr(k, f)
if e is None or not str(e).strip():
continue
total += 1
if not is_valid_threshold(e):
bad.append((k.id, k.kpi_code, f, str(e)))
return total, bad
def dump_table_backup():
os.makedirs(BACKUP_DIR, exist_ok=True)
out = os.path.join(BACKUP_DIR, "kpi_definitions.bak-20260911.sql")
cmd = ["mysqldump",
f"-u{ENV.get('CMA_DB_USER', 'cma_user')}",
f"-p{ENV.get('CMA_DB_PASS', '')}",
f"-h{ENV.get('CMA_DB_HOST', '127.0.0.1')}",
f"-P{ENV.get('CMA_DB_PORT', '3306')}",
ENV.get("CMA_DB_NAME", "cma"), "kpi_definitions"]
with open(out, "w", encoding="utf-8") as fh:
p = subprocess.run(cmd, stdout=fh, stderr=subprocess.PIPE)
if p.returncode != 0:
raise SystemExit("mysqldump 失败: " + p.stderr.decode("utf-8", "ignore")[:400])
return out
def main():
apply = "--apply" in sys.argv
db = get_session_local()()
total_before, bad_before = scan(db)
print(f"[before] 非空阈值表达式 {total_before} 条, 不可解析 {len(bad_before)} 条")
for b in bad_before:
print(" BAD", b)
# 记录原值(备份 json)
rows_before = []
for tid in list(FIXES.keys()) + VERIFY_ONLY:
k = db.query(KPIDefinition).filter(KPIDefinition.id == tid).first()
if k:
rows_before.append({"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"target_value": k.target_value,
**{f: getattr(k, f) for f in FIELDS}})
os.makedirs(BACKUP_DIR, exist_ok=True)
jpath = os.path.join(BACKUP_DIR, "threshold_rows_before_20260911.json")
if apply:
with open(jpath, "w", encoding="utf-8") as fh:
json.dump({"created_at": datetime.now().isoformat(), "rows": rows_before},
fh, ensure_ascii=False, indent=2)
sql_path = dump_table_backup()
print(f"[backup] json={jpath}")
print(f"[backup] sql={sql_path}")
else:
print(f"[dry-run] 将写入备份 json={jpath}(未执行)")
for r in rows_before:
print(" before:", r)
# 应用修正
for tid, newvals in FIXES.items():
k = db.query(KPIDefinition).filter(KPIDefinition.id == tid).first()
if not k:
print(f" !! id={tid} 不存在,跳过")
continue
for f, v in newvals.items():
old = getattr(k, f)
print(f" id={tid} {f}: {old!r} -> {v!r}")
if apply:
setattr(k, f, v)
if apply:
db.commit()
# 回查
for tid in list(FIXES.keys()) + VERIFY_ONLY:
k = db.query(KPIDefinition).filter(KPIDefinition.id == tid).first()
if k:
vals = {f: getattr(k, f) for f in FIELDS}
ok = all(is_valid_threshold(v) for v in vals.values())
print(f"[after] id={tid} {k.kpi_code} {vals} parseable={ok}")
total_after, bad_after = scan(db)
print(f"[after] 非空阈值表达式 {total_after} 条, 不可解析 {len(bad_after)} 条")
for b in bad_after:
print(" BAD", b)
db.close()
if not apply:
print("※ 预演模式,未落库。加 --apply 执行。")
if __name__ == "__main__":
main()
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""阈值判定变更回归(project-20260911-threshold-fix ⑥)
对比三条链路的判定结果,产出「改前改后差异样本」:
A. 旧实现:alert_generator 顺序 绿→黄→红 + 正则 (>=|<=|>|<|=|!=)\\s*([\\d.]+)(不支持负数/区间)
B. 新实现 red→green→yellow(统一 threshold_parser)
C. 若按任务字面「red→yellow→green」严重度顺序(用于说明为何 green 必须先于 yellow)
输出:逐 (KPI, 期间最新值) 的 A/B 差异清单 + 预警集合差异 + 真实 alert_generator 事务内预演(不落库)
"""
import os
import re
import sys
import json
from collections import Counter
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE)
envf = os.path.join(BASE, ".env")
for line in open(envf, encoding="utf-8"):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue
from app.utils.threshold_parser import calc_level_from_exprs
OLD_RE = re.compile(r"(>=|<=|>|<|=|!=)\s*([\d.]+)")
def old_level(actual, kpi) -> str:
"""旧实现:绿→黄→红,命中即止;不支持负数/区间。"""
if actual is None:
return "info"
for level, field in (("green", "threshold_green"), ("yellow", "threshold_yellow"),
("red", "threshold_red")):
expr = getattr(kpi, field, None)
if not expr:
continue
m = OLD_RE.match(str(expr).strip())
if not m:
continue
op, val = m.group(1), float(m.group(2))
hit = {">=": actual >= val, "<=": actual <= val, ">": actual > val,
"<": actual < val, "=": actual == val, "!=": actual != val}[op]
if hit:
return level
return "info"
def severity_order_level(actual, kpi) -> str:
"""按任务字面顺序 red→yellow→green(严重度优先)——用于对比说明。"""
for level, field in (("red", "threshold_red"), ("yellow", "threshold_yellow"),
("green", "threshold_green")):
expr = getattr(kpi, field, None)
if not expr:
continue
from app.utils.threshold_parser import eval_threshold
if eval_threshold(actual, expr):
return level
return "info"
def main():
db = get_session_local()()
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
samples = []
diffs_ab = []
diffs_bc = []
count_old = Counter()
count_new = Counter()
for kpi in kpis:
vals = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).all()
# 每期间取最新一条(对齐 alert_generator 取数方式)
latest_by_period = {}
for v in vals:
if v.actual_value is None:
continue
key = v.period
cur = latest_by_period.get(key)
if cur is None or (v.calculated_at and cur.calculated_at and v.calculated_at > cur.calculated_at):
latest_by_period[key] = v
for period, v in sorted(latest_by_period.items()):
a = old_level(v.actual_value, kpi)
b = calc_level_from_exprs(v.actual_value, kpi.threshold_green,
kpi.threshold_yellow, kpi.threshold_red)
c = severity_order_level(v.actual_value, kpi)
samples.append({
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "period": period,
"actual": v.actual_value, "old": a, "new": b, "severity_order": c,
"G": kpi.threshold_green, "Y": kpi.threshold_yellow, "R": kpi.threshold_red,
})
count_old[a] += 1
count_new[b] += 1
if a != b:
diffs_ab.append(samples[-1])
if c != b:
diffs_bc.append(samples[-1])
print(f"参与回归的 (KPI, 期间) 判定点: {len(samples)}")
print("旧实现级别分布 :", dict(count_old))
print("新实现级别分布 :", dict(count_new))
print(f"\n=== A(旧) vs B(新) 差异 {len(diffs_ab)} 条 ===")
for d in diffs_ab:
print(f" kpi={d['kpi_code']}(id={d['kpi_id']}) {d['period']} actual={d['actual']} "
f"old={d['old']} -> new={d['new']} G={d['G']!r} Y={d['Y']!r} R={d['R']!r}")
print(f"\n=== B(new=red>green>yellow) vs C(red>yellow>green) 差异 {len(diffs_bc)} 条 ===")
for d in diffs_bc[:20]:
print(f" kpi={d['kpi_code']}(id={d['kpi_id']}) {d['period']} actual={d['actual']} "
f"B={d['new']} vs C={d['severity_order']} G={d['G']!r} Y={d['Y']!r} R={d['R']!r}")
if len(diffs_bc) > 20:
print(f" ... 其余 {len(diffs_bc) - 20} 条")
# 预警集合差异(yellow/red 才落库)
old_alerts = [(d['kpi_id'], d['period'], d['old']) for d in samples if d['old'] in ("yellow", "red")]
new_alerts = [(d['kpi_id'], d['period'], d['new']) for d in samples if d['new'] in ("yellow", "red")]
print(f"\n=== 预警集合:旧 {len(old_alerts)} 条 → 新 {len(new_alerts)} 条 ===")
so, sn = set(old_alerts), set(new_alerts)
for x in sorted(so - sn):
print(" 仅旧有(解除):", x)
for x in sorted(sn - so):
print(" 仅新有(新增):", x)
# 真实 alert_generator 事务内预演(阻断 commit,事后 rollback,零落库)
import importlib.util # noqa: E402
_spec = importlib.util.spec_from_file_location(
"cma_alert_generator", os.path.join(BASE, "scripts", "alert_generator.py"))
ag = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(ag)
from datetime import datetime
period = datetime.now().strftime("%Y-%m")
real_commit = db.commit
db.commit = lambda: None # 阻断落库
try:
new_count = ag.run_alert_check(db, period)
finally:
db.commit = real_commit
db.rollback()
print(f"\n=== alert_generator 事务内预演(period={period},已 rollback 未落库)新增预警={new_count} ===")
out = os.path.join(BASE, "scripts", "backups", "threshold_regression_20260911.json")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
json.dump({"samples": len(samples), "count_old": dict(count_old), "count_new": dict(count_new),
"diff_ab": diffs_ab, "diff_bc": diffs_bc,
"alert_only_old": sorted(so - sn), "alert_only_new": sorted(sn - so),
"alert_gen_dry_run_new": new_count}, fh, ensure_ascii=False, indent=2)
print("报告 JSON:", out)
db.close()
if __name__ == "__main__":
main()