357 lines
13 KiB
Python
357 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
||||
|
|
"""炉层杂质预警 · 预警规则与阈值设定引擎(Issue #72 / PRD 5.3 ③)。
|
|||
|
|
|
|||
|
|
PRD 5.3 ③ / 场景 A(line 80):异常检测模型触发 → 驾驶舱红色告警 + LLM 生成
|
|||
|
|
"原因+处置建议" → 值班长确认。本模块把"行业知识"——**预警分级 + 阈值 + 处置 SOP**——
|
|||
|
|
外置为模板配置(PRD line 152/171:阈值外置 JSON,行业工程师在配置台维护),引擎
|
|||
|
|
按规则评估特征向量产出带 severity 的 Alert。
|
|||
|
|
|
|||
|
|
设计要点
|
|||
|
|
--------
|
|||
|
|
1. **声明式 AlertRule**:每条规则声明 ``id`` + ``severity``(P0/P1/P2)+ ``condition``
|
|||
|
|
(特征名 + 比较运算 + 阈值)+ ``sop``(处置 SOP 引用,供 LLM 报警解释/驾驶舱展示)。
|
|||
|
|
2. **severity 三级**(PRD line 333:关键告警不直接联动执行机构,高利害人工确认):
|
|||
|
|
- ``P0``(critical):红色告警,立即人工确认 + 紧急处置;
|
|||
|
|
- ``P1``(warning):黄色告警,加强监控 + 预备处置;
|
|||
|
|
- ``P2``(info):提示,记录跟踪。
|
|||
|
|
3. **规则引擎**:``AlertRuleEngine.evaluate`` 对一个特征向量评估全部规则,返回命中的
|
|||
|
|
Alert 列表(取最高 severity 为主告警);可与 #70/#71 组合(特征向量/异常分数均可作为
|
|||
|
|
condition 输入)。
|
|||
|
|
4. **零依赖 YAML 子集解析**(对齐 data-bus / rag-kb / #70),阈值外置模板资产。
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import math
|
|||
|
|
import os
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from enum import Enum
|
|||
|
|
from typing import Callable, Dict, List, Optional, Sequence, Tuple
|
|||
|
|
|
|||
|
|
NAN = float("nan")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AlertSeverity(str, Enum):
|
|||
|
|
"""预警严重度三级(PRD line 80 红色告警 / line 333 关键告警人工确认)。"""
|
|||
|
|
|
|||
|
|
P0 = "P0" # critical:红色,立即人工确认 + 紧急处置
|
|||
|
|
P1 = "P1" # warning:黄色,加强监控 + 预备处置
|
|||
|
|
P2 = "P2" # info:提示,记录跟踪
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def label(self) -> str:
|
|||
|
|
return {AlertSeverity.P0: "严重", AlertSeverity.P1: "警告",
|
|||
|
|
AlertSeverity.P2: "提示"}[self]
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def rank(self) -> int:
|
|||
|
|
"""排序权重,越大越严重(用于取主告警)。"""
|
|||
|
|
return {AlertSeverity.P0: 3, AlertSeverity.P1: 2, AlertSeverity.P2: 1}[self]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# 比较运算符注册表(condition.op 取值)
|
|||
|
|
OPS: Dict[str, Callable[[float, float], bool]] = {
|
|||
|
|
">": lambda a, b: a > b,
|
|||
|
|
">=": lambda a, b: a >= b,
|
|||
|
|
"<": lambda a, b: a < b,
|
|||
|
|
"<=": lambda a, b: a <= b,
|
|||
|
|
"==": lambda a, b: a == b,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _is_num(x: object) -> bool:
|
|||
|
|
return isinstance(x, (int, float)) and not (isinstance(x, float) and math.isnan(x))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class AlertCondition:
|
|||
|
|
"""单条触发条件:特征名 + 比较运算 + 阈值。"""
|
|||
|
|
|
|||
|
|
feature: str
|
|||
|
|
op: str
|
|||
|
|
threshold: float
|
|||
|
|
|
|||
|
|
def __post_init__(self) -> None:
|
|||
|
|
if self.op not in OPS:
|
|||
|
|
raise ValueError(f"未知比较运算 {self.op!r}(应为 {sorted(OPS)})")
|
|||
|
|
|
|||
|
|
def matches(self, values: Dict[str, float]) -> bool:
|
|||
|
|
v = values.get(self.feature)
|
|||
|
|
if not _is_num(v):
|
|||
|
|
return False
|
|||
|
|
return OPS[self.op](float(v), self.threshold)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class AlertRule:
|
|||
|
|
"""声明式预警规则(模板配置中的一行规则声明)。
|
|||
|
|
|
|||
|
|
Attributes:
|
|||
|
|
id: 规则 id(稳定标识,供驾驶舱/审计引用)。
|
|||
|
|
severity: 严重度(P0/P1/P2)。
|
|||
|
|
conditions: 触发条件列表(AND 语义:全部满足才命中)。
|
|||
|
|
message: 告警文案(驾驶舱展示)。
|
|||
|
|
sop: 处置 SOP 引用(PRD 场景A:LLM 报警解释 + 值班长确认)。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
id: str
|
|||
|
|
severity: AlertSeverity
|
|||
|
|
conditions: List[AlertCondition]
|
|||
|
|
message: str = ""
|
|||
|
|
sop: str = ""
|
|||
|
|
|
|||
|
|
def __post_init__(self) -> None:
|
|||
|
|
if not self.id:
|
|||
|
|
raise ValueError("AlertRule.id 不能为空")
|
|||
|
|
if not self.conditions:
|
|||
|
|
raise ValueError(f"规则 {self.id!r} 至少需要 1 条 condition")
|
|||
|
|
|
|||
|
|
def matches(self, values: Dict[str, float]) -> bool:
|
|||
|
|
return all(c.matches(values) for c in self.conditions)
|
|||
|
|
|
|||
|
|
def describe(self) -> str:
|
|||
|
|
conds = " 且 ".join(f"{c.feature}{c.op}{c.threshold}" for c in self.conditions)
|
|||
|
|
return f"[{self.severity.value}] {self.id}: {conds}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Alert:
|
|||
|
|
"""一次预警命中(规则 + 触发时的特征快照)。"""
|
|||
|
|
|
|||
|
|
rule_id: str
|
|||
|
|
severity: AlertSeverity
|
|||
|
|
message: str
|
|||
|
|
sop: str
|
|||
|
|
timestamp: float
|
|||
|
|
snapshot: Dict[str, float] = field(default_factory=dict)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AlertRuleEngine:
|
|||
|
|
"""预警规则引擎:评估特征向量,产出带 severity 的 Alert 列表。
|
|||
|
|
|
|||
|
|
换行业只改模板配置(AlertRule 列表),引擎零改动(PRD line 152/171)。
|
|||
|
|
|
|||
|
|
用法::
|
|||
|
|
|
|||
|
|
engine = AlertRuleEngine(rules)
|
|||
|
|
alerts = engine.evaluate(timestamp=100, values={"炉温_ema5": 920.0})
|
|||
|
|
if alerts:
|
|||
|
|
primary = engine.primary_alert(alerts) # 取最高 severity
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, rules: Sequence[AlertRule]):
|
|||
|
|
if not rules:
|
|||
|
|
raise ValueError("AlertRuleEngine 至少需要 1 条规则")
|
|||
|
|
ids = set()
|
|||
|
|
for r in rules:
|
|||
|
|
if r.id in ids:
|
|||
|
|
raise ValueError(f"规则 id 重复:{r.id!r}")
|
|||
|
|
ids.add(r.id)
|
|||
|
|
self.rules: List[AlertRule] = list(rules)
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def from_template_config(cls, path: str) -> "AlertRuleEngine":
|
|||
|
|
return cls(load_alert_rules_config(path).rules)
|
|||
|
|
|
|||
|
|
def evaluate(self, timestamp: float,
|
|||
|
|
values: Dict[str, float]) -> List[Alert]:
|
|||
|
|
"""评估一个特征向量,返回全部命中规则的 Alert(按 severity 降序)。"""
|
|||
|
|
hits: List[Alert] = []
|
|||
|
|
for rule in self.rules:
|
|||
|
|
if rule.matches(values):
|
|||
|
|
hits.append(Alert(
|
|||
|
|
rule_id=rule.id, severity=rule.severity,
|
|||
|
|
message=rule.message, sop=rule.sop,
|
|||
|
|
timestamp=timestamp, snapshot=dict(values),
|
|||
|
|
))
|
|||
|
|
hits.sort(key=lambda a: a.severity.rank, reverse=True)
|
|||
|
|
return hits
|
|||
|
|
|
|||
|
|
def primary_alert(self, alerts: Sequence[Alert]) -> Optional[Alert]:
|
|||
|
|
"""取最高 severity 的主告警(驾驶舱红色告警)。无命中返回 None。"""
|
|||
|
|
return alerts[0] if alerts else None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 模板配置(零依赖 YAML 子集解析,对齐 #70 / data-bus / rag-kb)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class AlertRulesTemplateConfig:
|
|||
|
|
"""模板预警规则配置:模板元信息 + AlertRule 列表。"""
|
|||
|
|
|
|||
|
|
template: str
|
|||
|
|
version: str
|
|||
|
|
rules: List[AlertRule]
|
|||
|
|
description: str = ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_scalar(text: str) -> str:
|
|||
|
|
t = text.split(" #", 1)[0].strip()
|
|||
|
|
if len(t) >= 2 and t[0] == t[-1] and t[0] in ("'", '"'):
|
|||
|
|
return t[1:-1]
|
|||
|
|
return t
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_flow_value(text: str):
|
|||
|
|
"""解析 ``key: value`` 右侧的值,支持行内 flow map ``{k: v, k: v}``。
|
|||
|
|
|
|||
|
|
其余(标量 / 引号串)退化为 :func:`_parse_scalar`。flow map 用于
|
|||
|
|
``conditions: [{feature: x, op: ">", threshold: 900.0}]`` 这种紧凑声明。
|
|||
|
|
"""
|
|||
|
|
t = text.split(" #", 1)[0].strip()
|
|||
|
|
if t.startswith("{") and t.endswith("}"):
|
|||
|
|
inner = t[1:-1].strip()
|
|||
|
|
out: Dict[str, object] = {}
|
|||
|
|
if not inner:
|
|||
|
|
return out
|
|||
|
|
for part in inner.split(","):
|
|||
|
|
if ":" not in part:
|
|||
|
|
raise ValueError(f"flow map 项不是键值对:{part!r}")
|
|||
|
|
k, _, v = part.partition(":")
|
|||
|
|
out[k.strip()] = _parse_scalar(v)
|
|||
|
|
return out
|
|||
|
|
return _parse_scalar(text)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _strip_comments(lines: List[str]) -> List[Tuple[str, int]]:
|
|||
|
|
out = []
|
|||
|
|
for i, ln in enumerate(lines):
|
|||
|
|
s = ln.strip()
|
|||
|
|
if not s or s.startswith("#"):
|
|||
|
|
continue
|
|||
|
|
out.append((ln, i + 1))
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_node(lines: List[Tuple[str, int]], i: int, indent: int):
|
|||
|
|
text, _ = lines[i]
|
|||
|
|
if text.lstrip(" ").startswith("- "):
|
|||
|
|
items: List[object] = []
|
|||
|
|
while i < len(lines):
|
|||
|
|
t, no = lines[i]
|
|||
|
|
stripped = t.lstrip(" ")
|
|||
|
|
if not stripped.startswith("- "):
|
|||
|
|
break
|
|||
|
|
lead_j = len(t) - len(t.lstrip(" "))
|
|||
|
|
if lead_j != indent:
|
|||
|
|
break
|
|||
|
|
item_text = stripped[2:].strip()
|
|||
|
|
if not item_text:
|
|||
|
|
raise ValueError(f"alerts.yaml 第 {no} 行:list 项为空")
|
|||
|
|
# 行内 flow map({k: v, ...})优先用 _parse_flow_value,避免被
|
|||
|
|
# 下方「含 : 即 map 项」分支误判(flow map 也含 :)。
|
|||
|
|
if item_text.startswith("{") and item_text.endswith("}"):
|
|||
|
|
items.append(_parse_flow_value(item_text))
|
|||
|
|
i += 1
|
|||
|
|
elif ":" in item_text:
|
|||
|
|
map_indent = len(t) - len(t.lstrip(" ")) + 2
|
|||
|
|
lines[i] = (" " * map_indent + item_text, no)
|
|||
|
|
v, i = _parse_node(lines, i, map_indent)
|
|||
|
|
items.append(v)
|
|||
|
|
else:
|
|||
|
|
items.append(_parse_flow_value(item_text))
|
|||
|
|
i += 1
|
|||
|
|
return items, i
|
|||
|
|
result: Dict[str, object] = {}
|
|||
|
|
while i < len(lines):
|
|||
|
|
t, no = lines[i]
|
|||
|
|
lead_j = len(t) - len(t.lstrip(" "))
|
|||
|
|
if lead_j < indent or t.lstrip(" ").startswith("- "):
|
|||
|
|
break
|
|||
|
|
if lead_j > indent:
|
|||
|
|
raise ValueError(f"alerts.yaml 第 {no} 行缩进异常")
|
|||
|
|
if ":" not in t:
|
|||
|
|
raise ValueError(f"alerts.yaml 第 {no} 行不是合法键值对:{t!r}")
|
|||
|
|
key, _, rest = t.partition(":")
|
|||
|
|
key = key.strip()
|
|||
|
|
rest = rest.strip()
|
|||
|
|
if rest:
|
|||
|
|
result[key] = _parse_flow_value(rest)
|
|||
|
|
i += 1
|
|||
|
|
continue
|
|||
|
|
if i + 1 >= len(lines):
|
|||
|
|
raise ValueError(f"alerts.yaml 第 {no} 行 {key!r} 缺少值")
|
|||
|
|
sub_indent = len(lines[i + 1][0]) - len(lines[i + 1][0].lstrip(" "))
|
|||
|
|
if sub_indent <= indent:
|
|||
|
|
raise ValueError(f"alerts.yaml 第 {no} 行 {key!r} 缺少值(无嵌套)")
|
|||
|
|
v, i = _parse_node(lines, i + 1, sub_indent)
|
|||
|
|
result[key] = v
|
|||
|
|
return result, i
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _load_yaml_text(text: str) -> Dict[str, object]:
|
|||
|
|
lines = _strip_comments(text.splitlines())
|
|||
|
|
if not lines:
|
|||
|
|
return {}
|
|||
|
|
top_indent = len(lines[0][0]) - len(lines[0][0].lstrip(" "))
|
|||
|
|
value, next_i = _parse_node(lines, 0, top_indent)
|
|||
|
|
if not isinstance(value, dict):
|
|||
|
|
raise ValueError("alerts.yaml 顶层必须是 map")
|
|||
|
|
if next_i < len(lines):
|
|||
|
|
raise ValueError(f"alerts.yaml 第 {lines[next_i][1]} 行:顶层存在多个节点")
|
|||
|
|
return value
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_alert_rules_config(path: str) -> AlertRulesTemplateConfig:
|
|||
|
|
"""从模板预警规则 YAML 资产加载配置。
|
|||
|
|
|
|||
|
|
期望结构(详见 ``config/alert_rules.template.yaml``)::
|
|||
|
|
|
|||
|
|
template: ti-cl4
|
|||
|
|
version: 1.0.0
|
|||
|
|
rules:
|
|||
|
|
- id: bed_temp_critical
|
|||
|
|
severity: P0
|
|||
|
|
message: 炉温超上限,立即降流减料
|
|||
|
|
sop: SOP-CL-001
|
|||
|
|
conditions:
|
|||
|
|
- {feature: 炉温_ema5, op: ">", threshold: 900.0}
|
|||
|
|
"""
|
|||
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|||
|
|
data = _load_yaml_text(fh.read())
|
|||
|
|
|
|||
|
|
template = str(data.get("template", "")).strip()
|
|||
|
|
if not template:
|
|||
|
|
raise ValueError("alerts.yaml 缺少 template 字段")
|
|||
|
|
version = str(data.get("version", "1.0.0")).strip() or "1.0.0"
|
|||
|
|
description = str(data.get("description", "")).strip()
|
|||
|
|
|
|||
|
|
raw_rules = data.get("rules") or []
|
|||
|
|
if not isinstance(raw_rules, list):
|
|||
|
|
raise ValueError("alerts.yaml rules 必须是 list")
|
|||
|
|
rules: List[AlertRule] = []
|
|||
|
|
for idx, item in enumerate(raw_rules):
|
|||
|
|
if not isinstance(item, dict):
|
|||
|
|
raise ValueError(f"alerts.yaml rules[{idx}] 必须是 map")
|
|||
|
|
rid = str(item.get("id", "")).strip()
|
|||
|
|
sev_name = str(item.get("severity", "")).strip().upper()
|
|||
|
|
sev_map = {s.value: s for s in AlertSeverity}
|
|||
|
|
if sev_name not in sev_map:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"alerts.yaml rules[{idx}] 未知 severity {sev_name!r}"
|
|||
|
|
f"(应为 {sorted(sev_map)})")
|
|||
|
|
message = str(item.get("message", "")).strip()
|
|||
|
|
sop = str(item.get("sop", "")).strip()
|
|||
|
|
raw_conds = item.get("conditions") or []
|
|||
|
|
if not isinstance(raw_conds, list):
|
|||
|
|
raise ValueError(f"alerts.yaml rules[{idx}] conditions 必须是 list")
|
|||
|
|
conds: List[AlertCondition] = []
|
|||
|
|
for ci, c in enumerate(raw_conds):
|
|||
|
|
if not isinstance(c, dict):
|
|||
|
|
raise ValueError(f"alerts.yaml rules[{idx}].conditions[{ci}] 必须是 map")
|
|||
|
|
feature = str(c.get("feature", "")).strip()
|
|||
|
|
op = str(c.get("op", "")).strip()
|
|||
|
|
if op not in OPS:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"alerts.yaml rules[{idx}].conditions[{ci}] 未知 op {op!r}")
|
|||
|
|
try:
|
|||
|
|
threshold = float(c.get("threshold"))
|
|||
|
|
except (TypeError, ValueError) as exc:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"alerts.yaml rules[{idx}].conditions[{ci}] threshold 不是数值") from exc
|
|||
|
|
conds.append(AlertCondition(feature=feature, op=op, threshold=threshold))
|
|||
|
|
rules.append(AlertRule(id=rid, severity=sev_map[sev_name],
|
|||
|
|
conditions=conds, message=message, sop=sop))
|
|||
|
|
return AlertRulesTemplateConfig(template=template, version=version,
|
|||
|
|
rules=rules, description=description)
|