① 新建 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 条,含差异说明)
158 lines
6.7 KiB
Python
158 lines
6.7 KiB
Python
#!/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()
|