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
+166
View File
@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
"""阈值表达式统一解析与红黄绿判定 —— 单一事实源(Single Source of Truth)
背景(project-20260911-threshold-fix):
原先 kpis.py 与 alert_generator.py 各自实现一份阈值解析(正则相同、独立维护),
且解析器不支持负数,导致 `>=-100000` 这类表达式静默失效。
表达式语法(label 的语义 = “该等级的触发条件”,不是区间标签):
>=N <=N >N <N =N !=N 标量比较,N 支持负数与小数(如 `<-100000`、`>=52.33`)
A~B 闭区间包含,A <= 实际值 <= B(如 `52.33~107.67`)
C1|C2 多条件或,任一命中即为命中(如 `<=36|>=124`)
允许空白:`>= 800`、`52.33 ~ 107.67`;空串/None 视为“无阈值”。
红黄绿判定优先级:red → green → yellow(理由见下方 calc_level_from_exprs)
"""
from __future__ import annotations
import re
from typing import List, Optional, Tuple, Union
# 标量比较:操作符 + 数值(负号/小数)
_SCALAR_RE = re.compile(r"(>=|<=|!=|>|<|=)\s*(-?\d+(?:\.\d+)?)")
# 区间:A~B(闭区间)
_RANGE_RE = re.compile(r"(-?\d+(?:\.\d+)?)\s*~\s*(-?\d+(?:\.\d+)?)")
# 多条件分隔(半角竖线 / 全角竖线)
_CLAUSE_SPLIT_RE = re.compile(r"\s*[||]\s*")
#: 单个子句取值:标量 -> float;区间 -> (low, high)
ThresholdValue = Union[float, Tuple[float, float]]
Clause = Tuple[str, ThresholdValue]
#: 支持的格式说明(前端 tooltip / 接口文档共用文案)
THRESHOLD_HELP = (
"支持格式:>=800 / <=25 / >60 / <20 / =100 / !=0(数值可为负数,如 <-100000);"
"区间 52.33~107.67(含端点);多条件用 | 连接,如 <=36|>=124。"
)
THRESHOLD_PLACEHOLDER = "如 >=800 / 52.33~107.67"
#: 判定优先级(严重度最高者优先;green 优先于 yellow 见 docstring 说明)
LEVEL_PRIORITY: Tuple[str, str, str] = ("red", "green", "yellow")
def _is_blank(expr) -> bool:
return expr is None or not str(expr).strip()
def parse_clause(clause) -> Optional[Clause]:
"""解析单个子句;非法返回 None。"""
s = str(clause).strip()
if not s:
return None
m = _RANGE_RE.fullmatch(s)
if m:
lo, hi = float(m.group(1)), float(m.group(2))
return ("~", (lo, hi))
m = _SCALAR_RE.fullmatch(s)
if m:
return (m.group(1), float(m.group(2)))
return None
def parse_clauses(expr) -> List[Clause]:
"""解析表达式为子句列表;任一子句非法则整体视为非法(返回空列表)。"""
if _is_blank(expr):
return []
parts = _CLAUSE_SPLIT_RE.split(str(expr).strip())
clauses: List[Clause] = []
for p in parts:
c = parse_clause(p)
if c is None:
return []
clauses.append(c)
return clauses
def parse_threshold(expr) -> Tuple[Optional[str], Optional[ThresholdValue]]:
"""解析阈值表达式,返回 (operator, value)。
保持向后兼容的二元返回:解析失败 -> (None, None)。
区间操作符为 "~",value 为 (low, high);多条件表达式返回首个命中子句之外的首个子句。
示例:
'>=32000000' -> ('>=', 32000000.0)
'>=-100000' -> ('>=', -100000.0)
'52.33~107.67' -> ('~', (52.33, 107.67))
"""
clauses = parse_clauses(expr)
if not clauses:
return None, None
return clauses[0]
def is_valid_threshold(expr) -> bool:
"""表达式是否合法(空串视为合法=不设置阈值)。"""
if _is_blank(expr):
return True
return bool(parse_clauses(expr))
def eval_clause(actual, clause: Clause) -> bool:
op, val = clause
if actual is None:
return False
if op == ">=":
return actual >= val # type: ignore[operator]
if op == "<=":
return actual <= val # type: ignore[operator]
if op == ">":
return actual > val # type: ignore[operator]
if op == "<":
return actual < val # type: ignore[operator]
if op == "=":
return actual == val
if op == "!=":
return actual != val
if op == "~":
lo, hi = val # type: ignore[misc]
return lo <= actual <= hi
return False
def eval_threshold(actual, expr) -> bool:
"""实际值是否命中阈值表达式(多条件任一命中即 True;非法表达式 -> False)。"""
if actual is None or _is_blank(expr):
return False
clauses = parse_clauses(expr)
return any(eval_clause(actual, c) for c in clauses)
def calc_level_from_exprs(actual, green=None, yellow=None, red=None) -> str:
"""红黄绿判定(统一入口),返回 'red' / 'yellow' / 'green' / 'info'。
判定顺序 red → green → yellow:
1. red 最严重,必须最先判 —— 否则宽黄灯(如 Y='>76')会遮蔽红灯(如 R='>91'),
红灯永远不可达(缺陷5:id=6 实测值 95 被判黄)。
2. green 先于 yellow —— green 表达式(如 G='>=800')是“主条件”,
yellow(如 Y='>=700')是更宽的兜底带;若 yellow 先判,值 900 会被黄灯截胡,
与用户“>=800 即绿”的填写意图相反(id=1 实测)。
对 moderate 型(绿=内区间、黄=外侧环带)两者天然互斥,顺序无影响。
3. 三者均不命中 -> 'info'(无判定,与旧实现一致)。
"""
if actual is None:
return "info"
exprs = {"green": green, "yellow": yellow, "red": red}
for level in LEVEL_PRIORITY:
if eval_threshold(actual, exprs[level]):
return level
return "info"
def calc_level(actual, kpi) -> str:
"""按 KPI 定义对象(含 threshold_green/yellow/red 属性)判定级别。"""
return calc_level_from_exprs(
actual,
green=getattr(kpi, "threshold_green", None),
yellow=getattr(kpi, "threshold_yellow", None),
red=getattr(kpi, "threshold_red", None),
)
def describe_level(actual, kpi) -> Tuple[str, Optional[str]]:
"""返回 (级别, 命中的表达式)。供预警消息构造使用;无命中 -> (level, None)。"""
level = calc_level(actual, kpi)
if level == "info":
return level, None
field = {"green": "threshold_green", "yellow": "threshold_yellow", "red": "threshold_red"}[level]
return level, getattr(kpi, field, None)