Files
cma-management/backend/scripts/verify_threshold_regression_20260911.py
T
Hermes CI Fix 4c6fb85cf5 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 条,含差异说明)
2026-09-11 17:25:24 +08:00

158 lines
6.7 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.
#!/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()