# -*- coding: utf-8 -*- """报警看板配置化(issue #52 / PRD 5.5「⑤ 配置化驾驶舱」)。 PRD 5.5 的告警面板(``alarm_panel``)在 #50/#51 里被实现为「固定订阅 ``alarm_stream``、套用主题默认告警色」的硬编码组件。本模块把告警面板的 **展示语义外置为模板配置**(PRD line 152/171:阈值/规则外置 JSON,行业工程师 在配置台维护),让"换行业只换配置资产、前端代码零改动"这条验收口径也覆盖到 告警面板。 外置的配置点(均由行业模板在配置台维护): 1. **严重度→颜色映射**(``severity_colors``):P0/P1/P2 三级各自的前景色 / 背景色 / 图标,覆盖主题默认告警色;驾驶舱红色告警(PRD 5.3 ③ 场景A) 即由 ``P0`` 的 ``fg`` 决定,**换行业只改这张映射表**。 2. **告警规则 / 阈值源绑定**(``rules_source`` / ``thresholds_source``): 告警面板订阅哪份规则资产(如 ``templates/ti-cl4/impurity-forecast/ config/alert_rules.template.yaml``)与哪份阈值包——把"看哪条规则" 也变成配置项,避免把规则 id 写死在前端。 3. **SOP 联动开关**(``show_sop``):PRD 场景A「LLM 生成原因+处置建议 → 值班长确认」,是否在面板里展开处置 SOP(高利害人工确认,PRD line 333)。 4. **确认 / 静默行为**(``require_ack`` / ``ack_timeout_s`` / ``mute_lower``): 关键告警是否强制人工确认、超时升级、是否静默低于某 severity 的提示。 5. **分组 / 排序 / 最大条数**(``group_by`` / ``sort_by`` / ``max_items``): 大屏展示策略外置(与 #51 的 perf 策略互补:这里是"展示多少/怎么排", #51 是"怎么渲染得快")。 设计要点 -------- - **零运行时依赖**:与 #50/#51、data-bus、rag-kb 一致,只用标准库; 配置资产是可 ``json.dumps`` 的纯 dict,便于配置台发布与审计。 - **声明式 + 强校验**:``validate_alarm_config`` 收集全部字段级错误 (沿用 #50 ``LayoutValidationResult`` 风格),``load_alarm_config`` 在校验 不通过时抛 ``AlarmConfigError`` 并携带错误清单,便于配置台「错误列表」展示。 - **与 #50/#51 解耦**:本模块不 import ``layout`` / ``renderer``(它们尚未合入 main),避免对未合并分支形成硬依赖;``render_alarm_panel_props`` 仅产出一份 ``alarm_panel`` 组件的 props dict,由 #51 渲染层在 ``_build_props`` 里合并即可。 """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple # --------------------------------------------------------------------------- # 版本标识(被告警面板配置资产的 $schema 引用) # --------------------------------------------------------------------------- ALARM_CONFIG_SCHEMA_ID: str = "iAOP-cockpit-alarm-panel-v1" ALARM_CONFIG_SCHEMA_VERSION: int = 1 # --------------------------------------------------------------------------- # 合法性集合 # --------------------------------------------------------------------------- # 严重度三级(与 templates/ti-cl4/impurity-forecast/alert_rules.py 的 AlertSeverity 对齐)。 VALID_SEVERITIES: Tuple[str, ...] = ("P0", "P1", "P2") # severity 排序权重(越大越严重;用于 mute_lower / sort_by=severity 的排序基准)。 SEVERITY_RANK: Dict[str, int] = {"P0": 3, "P1": 2, "P2": 1} # severity_colors 里每个 severity 必须声明的颜色键。 _REQUIRED_COLOR_KEYS: Tuple[str, ...] = ("fg", "bg") # severity_colors 里允许额外声明的可选颜色键(图标 / 边框)。 _OPTIONAL_COLOR_KEYS: Tuple[str, ...] = ("icon", "border") # rules_source / thresholds_source 允许的 ``kind`` 取值: # - asset : 指向模板仓库内的一份配置资产路径(如 alert_rules.template.yaml) # - inline : 内联在配置里(rules_source.data 直接给出规则声明列表) VALID_SOURCE_KINDS: Tuple[str, ...] = ("asset", "inline") # 告警面板允许的分组维度(PRD 5.5 能力:可按 severity / 工序 / 规则分组)。 VALID_GROUP_BY: Tuple[str, ...] = ("severity", "rule", "process") # 告警面板允许的排序键(severity 按严重度;time 按触发时间倒序)。 VALID_SORT_BY: Tuple[str, ...] = ("severity", "time") # 静默下限:低于该 severity 的告警不展示(默认 P2 = 提示级也展示,即不静默)。 DEFAULT_MUTE_LOWER: str = "P2" # 单面板最大展示条数(PRD 5.5 大屏策略:超过则折叠 + 计数角标)。 DEFAULT_MAX_ITEMS: int = 50 MAX_ALLOWED_ITEMS: int = 500 # 确认超时下限(秒):require_ack=True 时,超时未确认自动升级 severity。 DEFAULT_ACK_TIMEOUT_S: int = 300 # --------------------------------------------------------------------------- # 异常 / 结果 # --------------------------------------------------------------------------- class AlarmConfigError(ValueError): """告警面板配置校验失败。``load_alarm_config`` 在校验不通过时抛出。 ``errors`` 收集全部字段级错误,便于配置台「错误列表(行号+原因)」展示, 沿用 #50 ``LayoutValidationError`` 的多错误聚合风格。 """ def __init__(self, errors: List[str]): super().__init__("; ".join(errors) if errors else "alarm panel config validation failed") self.errors: List[str] = list(errors) @dataclass class AlarmConfigValidationResult: """``validate_alarm_config`` 的返回值,区分「是否合法」与「全部错误清单」。""" ok: bool errors: List[str] = field(default_factory=list) # 校验通过后回填的规范化配置(补默认值后的 dict),便于直接发布/渲染。 normalized: Optional[Dict[str, Any]] = None # --------------------------------------------------------------------------- # 内存模型(dataclass) # --------------------------------------------------------------------------- @dataclass class SeverityColor: """单个严重度的展示配色(覆盖主题默认告警色)。 Attributes: severity: P0 / P1 / P2。 fg: 前景色(告警文本 / 图标颜色;P0 的 fg 即驾驶舱「红色告警」)。 bg: 背景色(告警条底色)。 icon: 可选图标名(如 ``"alert-triangle"``)。 border: 可选左边框色(用于告警条强调)。 """ severity: str fg: str bg: str icon: Optional[str] = None border: Optional[str] = None def to_dict(self) -> Dict[str, Any]: d: Dict[str, Any] = {"severity": self.severity, "fg": self.fg, "bg": self.bg} if self.icon is not None: d["icon"] = self.icon if self.border is not None: d["border"] = self.border return d @dataclass class AlarmRulesSource: """告警规则 / 阈值的来源绑定(PRD「阈值外置 JSON」)。 Attributes: kind: ``asset``(模板仓库内资产路径)或 ``inline``(内联声明)。 ref: ``asset`` 时的资产路径(相对模板根,如 ``ti-cl4/impurity-forecast/config/alert_rules.template.yaml``)。 data: ``inline`` 时的规则声明列表(每条是 {id, severity, ...} dict)。 """ kind: str ref: Optional[str] = None data: Optional[List[Dict[str, Any]]] = None def to_dict(self) -> Dict[str, Any]: d: Dict[str, Any] = {"kind": self.kind} if self.ref is not None: d["ref"] = self.ref if self.data is not None: d["data"] = list(self.data) return d @dataclass class AlarmPanelConfig: """一份告警面板配置的内存模型(对应一份模板级配置资产)。 渲染层(Vue3 / 配置台)消费本对象即可驱动 ``AlarmPanel`` 组件的全部展示 行为:切换行业模板 = 加载另一份 ``AlarmPanelConfig``,**前端代码零改动**。 """ severity_colors: List[SeverityColor] rules_source: AlarmRulesSource thresholds_source: Optional[AlarmRulesSource] = None show_sop: bool = True require_ack: bool = False ack_timeout_s: int = DEFAULT_ACK_TIMEOUT_S mute_lower: str = DEFAULT_MUTE_LOWER group_by: str = "severity" sort_by: str = "severity" max_items: int = DEFAULT_MAX_ITEMS schema: str = ALARM_CONFIG_SCHEMA_ID def to_dict(self) -> Dict[str, Any]: """序列化为可发布的配置资产 dict(结构对齐校验器输入)。""" d: Dict[str, Any] = { "$schema": self.schema, "severityColors": [c.to_dict() for c in self.severity_colors], "rulesSource": self.rules_source.to_dict(), "showSop": self.show_sop, "requireAck": self.require_ack, "muteLower": self.mute_lower, "groupBy": self.group_by, "sortBy": self.sort_by, "maxItems": self.max_items, } if self.thresholds_source is not None: d["thresholdsSource"] = self.thresholds_source.to_dict() if self.require_ack: d["ackTimeoutS"] = self.ack_timeout_s return d # --------------------------------------------------------------------------- # 校验器 # --------------------------------------------------------------------------- def _is_str_nonempty(value: Any) -> bool: return isinstance(value, str) and value.strip() != "" def _validate_source( source: Any, field_name: str, errors: List[str], required: bool ) -> Optional[Dict[str, Any]]: """校验一个 rules_source / thresholds_source dict。 返回规范化后的 dict(校验通过时),或 None(非法 / 缺失)。 """ ctx = field_name if source is None: if required: errors.append(f"{ctx}: 缺失(告警面板必须绑定 rulesSource)") return None if not isinstance(source, dict): errors.append(f"{ctx}: 必须是对象(dict),实际为 {type(source).__name__}") return None kind = source.get("kind") if kind not in VALID_SOURCE_KINDS: errors.append( f"{ctx}.kind: 非法 {kind!r},合法值 {list(VALID_SOURCE_KINDS)}" ) return None normalized: Dict[str, Any] = {"kind": kind} if kind == "asset": ref = source.get("ref") if not _is_str_nonempty(ref): errors.append(f"{ctx}.ref: kind=asset 时必须给出非空资产路径") else: normalized["ref"] = ref else: # inline data = source.get("data") if not isinstance(data, list) or not data: errors.append(f"{ctx}.data: kind=inline 时必须给出非空规则声明列表") else: # 每条内联规则至少要有 id(便于驾驶舱引用 / 审计) bad = [ str(i) for i, item in enumerate(data) if not isinstance(item, dict) or not _is_str_nonempty(item.get("id")) ] if bad: errors.append( f"{ctx}.data: 内联规则项 {','.join(bad)} 缺失 id 或非对象" ) else: normalized["data"] = list(data) return normalized def validate_alarm_config(config: Any) -> AlarmConfigValidationResult: """对一份告警面板配置资产做结构 + 语义校验,返回校验结果。 非法资产不会提前返回:尽量收集全部字段级错误,便于配置台一次性展示 「错误列表(字段 + 原因)」,与 #50 ``validate_layout`` 行为一致。 """ errors: List[str] = [] if not isinstance(config, dict): return AlarmConfigValidationResult( ok=False, errors=[f"配置必须是对象(dict),实际为 {type(config).__name__}"] ) # $schema(选填,但若给出必须对齐版本标识) schema = config.get("$schema") if schema is not None and schema != ALARM_CONFIG_SCHEMA_ID: errors.append( f"$schema: 应为 {ALARM_CONFIG_SCHEMA_ID!r},实际为 {schema!r}" ) # severityColors:必填、至少覆盖 P0/P1/P2 三级、每级颜色键齐全 raw_colors = config.get("severityColors", config.get("severity_colors")) color_by_sev: Dict[str, Dict[str, Any]] = {} if not isinstance(raw_colors, list) or not raw_colors: errors.append("severityColors: 缺失或非列表(至少需要 P0/P1/P2 三级配色)") else: for i, item in enumerate(raw_colors): ctx = f"severityColors[{i}]" if not isinstance(item, dict): errors.append(f"{ctx}: 必须是对象(dict)") continue sev = item.get("severity") if sev not in VALID_SEVERITIES: errors.append( f"{ctx}.severity: 非法 {sev!r},合法值 {list(VALID_SEVERITIES)}" ) continue if sev in color_by_sev: errors.append(f"{ctx}.severity: {sev!r} 重复声明") continue norm_color: Dict[str, Any] = {"severity": sev} color_ok = True for key in _REQUIRED_COLOR_KEYS: v = item.get(key) if not _is_str_nonempty(v): errors.append(f"{ctx}.{key}: 缺失或非非空字符串") color_ok = False else: norm_color[key] = v for key in _OPTIONAL_COLOR_KEYS: v = item.get(key) if v is None: continue if not _is_str_nonempty(v): errors.append(f"{ctx}.{key}: 给出则必须是非空字符串") else: norm_color[key] = v if color_ok: color_by_sev[sev] = norm_color for sev in VALID_SEVERITIES: if sev not in color_by_sev: errors.append(f"severityColors: 缺少 {sev} 级配色(必须覆盖 P0/P1/P2)") # rulesSource:必填 raw_rules = config.get("rulesSource", config.get("rules_source")) norm_rules = _validate_source(raw_rules, "rulesSource", errors, required=True) # thresholdsSource:选填(阈值可与规则同源,也可独立) raw_thr = config.get("thresholdsSource", config.get("thresholds_source")) norm_thr = _validate_source(raw_thr, "thresholdsSource", errors, required=False) # showSop:布尔 show_sop = config.get("showSop", config.get("show_sop", True)) if not isinstance(show_sop, bool): errors.append(f"showSop: 必须是布尔,实际为 {type(show_sop).__name__}") # requireAck:布尔 require_ack = config.get("requireAck", config.get("require_ack", False)) if not isinstance(require_ack, bool): errors.append(f"requireAck: 必须是布尔,实际为 {type(require_ack).__name__}") # ackTimeoutS:require_ack 时才生效,给出则必须是正整数 ack_timeout = config.get("ackTimeoutS", config.get("ack_timeout_s", DEFAULT_ACK_TIMEOUT_S)) if isinstance(ack_timeout, bool) or not isinstance(ack_timeout, int) or ack_timeout <= 0: errors.append(f"ackTimeoutS: 必须是正整数(秒),实际为 {ack_timeout!r}") # muteLower:必须是合法 severity mute_lower = config.get("muteLower", config.get("mute_lower", DEFAULT_MUTE_LOWER)) if mute_lower not in VALID_SEVERITIES: errors.append( f"muteLower: 非法 {mute_lower!r},合法值 {list(VALID_SEVERITIES)}" ) # groupBy / sortBy:枚举 group_by = config.get("groupBy", config.get("group_by", "severity")) if group_by not in VALID_GROUP_BY: errors.append(f"groupBy: 非法 {group_by!r},合法值 {list(VALID_GROUP_BY)}") sort_by = config.get("sortBy", config.get("sort_by", "severity")) if sort_by not in VALID_SORT_BY: errors.append(f"sortBy: 非法 {sort_by!r},合法值 {list(VALID_SORT_BY)}") # maxItems:1..MAX_ALLOWED_ITEMS max_items = config.get("maxItems", config.get("max_items", DEFAULT_MAX_ITEMS)) if ( isinstance(max_items, bool) or not isinstance(max_items, int) or not (1 <= max_items <= MAX_ALLOWED_ITEMS) ): errors.append( f"maxItems: 必须是 1..{MAX_ALLOWED_ITEMS} 的整数,实际为 {max_items!r}" ) if errors: return AlarmConfigValidationResult(ok=False, errors=errors) # 规范化输出(统一字段名 + 补默认值),便于直接发布 / 渲染。 normalized: Dict[str, Any] = { "$schema": ALARM_CONFIG_SCHEMA_ID, "severityColors": [color_by_sev[sev] for sev in VALID_SEVERITIES], "rulesSource": norm_rules, "showSop": show_sop, "requireAck": require_ack, "ackTimeoutS": ack_timeout if require_ack else DEFAULT_ACK_TIMEOUT_S, "muteLower": mute_lower, "groupBy": group_by, "sortBy": sort_by, "maxItems": max_items, } if norm_thr is not None: normalized["thresholdsSource"] = norm_thr return AlarmConfigValidationResult(ok=True, errors=[], normalized=normalized) def load_alarm_config(config: Any) -> AlarmPanelConfig: """从已解析的 dict 构造 ``AlarmPanelConfig``(校验失败抛 ``AlarmConfigError``)。 与 #50 ``load_layout`` 对称:先 ``validate_alarm_config``,再按规范化结果 构造内存模型;校验不通过时把全部错误聚合到异常里。 """ result = validate_alarm_config(config) if not result.ok: raise AlarmConfigError(result.errors) assert result.normalized is not None # 校验通过一定回填 normalized norm = result.normalized severity_colors = [ SeverityColor( severity=c["severity"], fg=c["fg"], bg=c["bg"], icon=c.get("icon"), border=c.get("border"), ) for c in norm["severityColors"] ] def _to_source(d: Dict[str, Any]) -> AlarmRulesSource: return AlarmRulesSource( kind=d["kind"], ref=d.get("ref"), data=d.get("data"), ) thresholds_source = None if norm.get("thresholdsSource") is not None: thresholds_source = _to_source(norm["thresholdsSource"]) return AlarmPanelConfig( severity_colors=severity_colors, rules_source=_to_source(norm["rulesSource"]), thresholds_source=thresholds_source, show_sop=norm["showSop"], require_ack=norm["requireAck"], ack_timeout_s=norm["ackTimeoutS"], mute_lower=norm["muteLower"], group_by=norm["groupBy"], sort_by=norm["sortBy"], max_items=norm["maxItems"], schema=ALARM_CONFIG_SCHEMA_ID, ) # --------------------------------------------------------------------------- # 渲染辅助:把告警面板配置编译成 alarm_panel 组件的 props # --------------------------------------------------------------------------- def render_alarm_panel_props(config: AlarmPanelConfig) -> Dict[str, Any]: """把一份 ``AlarmPanelConfig`` 编译成 ``alarm_panel`` 组件的 props dict。 #51 渲染层在 ``_build_props`` 里对 ``alarm_panel`` 原本只写死 ``subscribe="alarm_stream"``;合入本模块后改为合并本函数的输出即可, 前端 ``AlarmPanel`` 组件据此驱动「颜色 / 规则源 / SOP / 确认 / 分组排序」, **切换行业模板只换配置资产,前端代码零改动**(PRD 5.5 验收口径)。 返回的 props 与 #51 的 props 风格一致:扁平、可直接 ``json.dumps``、 键名用前端友好的 camelCase。 """ # severity → 展示描述(颜色 + 可选图标 / 边框),驱动告警条着色 severity_styles: Dict[str, Dict[str, Any]] = {} for c in config.severity_colors: style: Dict[str, Any] = {"fg": c.fg, "bg": c.bg} if c.icon is not None: style["icon"] = c.icon if c.border is not None: style["border"] = c.border severity_styles[c.severity] = style # 规则 / 阈值源:asset 透传 ref,inline 透传 data def _source_props(src: AlarmRulesSource) -> Dict[str, Any]: if src.kind == "asset": return {"kind": "asset", "ref": src.ref or ""} return {"kind": "inline", "data": list(src.data or [])} props: Dict[str, Any] = { # 保留 #51 原有的订阅语义(向后兼容) "subscribe": "alarm_stream", "severityStyles": severity_styles, "rulesSource": _source_props(config.rules_source), "showSop": config.show_sop, "requireAck": config.require_ack, "muteLower": config.mute_lower, "groupBy": config.group_by, "sortBy": config.sort_by, "maxItems": config.max_items, } if config.thresholds_source is not None: props["thresholdsSource"] = _source_props(config.thresholds_source) if config.require_ack: props["ackTimeoutS"] = config.ack_timeout_s return props def filter_alarms_by_mute( alarms: List[Dict[str, Any]], mute_lower: str = DEFAULT_MUTE_LOWER, ) -> List[Dict[str, Any]]: """按 ``mute_lower`` 过滤告警列表(演示配置如何驱动展示策略)。 给定一批告警 dict(每条含 ``severity``),返回严重度 ≥ ``mute_lower`` 的子集。 用于验证「静默下限」配置点真正生效——配置台改 ``muteLower`` 即可改变面板 展示内容,无需改前端代码。``sort_by=severity`` 时同时按严重度降序排序。 """ if mute_lower not in SEVERITY_RANK: raise AlarmConfigError([f"mute_lower 非法: {mute_lower!r}"]) floor = SEVERITY_RANK[mute_lower] kept = [ a for a in alarms if isinstance(a, dict) and SEVERITY_RANK.get(a.get("severity"), 0) >= floor ] return sorted( kept, key=lambda a: SEVERITY_RANK.get(a.get("severity"), 0), reverse=True, )