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
@@ -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()