Files
cma-management/backend/app/utils/threshold_parser.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

167 lines
6.3 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.
# -*- 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)