# -*- coding: utf-8 -*- """⑤.7 配置预览渲染引擎 —— issue #65 / PRD ⑤.7。 配置台让实施工程师"边配边看":改完布局/告警/查询配置后,立即在预览区看到 驾驶舱会变成什么样、告警会怎么触发、NL 查询会怎么响应——**不必发布到生产 就能确认效果**。本模块是预览区的渲染后端,把 ``ConfigStore`` 里的配置渲染 为**结构化的预览片段**(dict/JSON),对齐 ``iAOP-cockpit-layout-v1`` 的 widget 类型与既有驾驶舱资产(resin cockpit)。 三类预览: - **布局预览**(``render_layout_preview``):把 layout widget 列表渲染为 带占位网格坐标的 widget 描述(type/bind/metric/description + x/y/w/h), 计算网格占用率(发现越界/重叠); - **告警预览**(``render_alarm_preview``):把告警规则(point + 阈值 + 级别) 渲染为"当 X 超过 Y 时,触发 级别 告警"的可读条目 + 模拟评估(给定当前值 是否触发); - **NL 查询预览**(``render_nl_query_preview``):把 NL 查询模板渲染为示例 问答对(模板 × 示例槽位 → 渲染后的问句 + 预期数据来源)。 预览是**只读、无副作用**的——只读配置、产出结构化输出,不改任何状态, 对齐 PRD「预演不污染生产」。 零运行时依赖:仅用 dataclass / Enum / 标准库。 """ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Any, Dict, List, Optional from .config_store import ALLOWED_WIDGET_TYPES, ConfigKind, ConfigStore # --------------------------------------------------------------------------- # 预览模型 # --------------------------------------------------------------------------- class PreviewKind(str, Enum): """三类预览。""" LAYOUT = "layout" ALARM = "alarm" NL_QUERY = "nl_query" @dataclass class PreviewResult: """一次预览渲染的结果(结构化片段 + 说明 + 问题提示)。""" kind: PreviewKind title: str items: List[Dict[str, Any]] = field(default_factory=list) # 渲染后的条目 notes: List[str] = field(default_factory=list) # 说明 / 渲染提示 warnings: List[str] = field(default_factory=list) # 布局越界/重叠等 reason: str = "" # 本次预览的来源说明 @property def ok(self) -> bool: return not self.warnings def to_dict(self) -> dict: return { "kind": self.kind.value, "title": self.title, "items": self.items, "notes": self.notes, "warnings": self.warnings, "reason": self.reason, "ok": self.ok, } # --------------------------------------------------------------------------- # 布局预览 # --------------------------------------------------------------------------- #: 驾驶舱网格规格(对齐 resin cockpit:12 列 × 若干行,w/h 以网格单元计) GRID_COLUMNS = 12 def render_layout_preview( widgets: List[Dict[str, Any]], title: str = "驾驶舱布局预览", grid_columns: int = GRID_COLUMNS, ) -> PreviewResult: """渲染布局 widget 列表为预览片段。 每个 widget 渲染为带 type/描述/坐标的卡片;同时做**布局体检**: - 越界(x+w 超出列数 / y+h 超出合理行数); - 重叠(两个 widget 矩形相交); - 非法类型(不在 ``ALLOWED_WIDGET_TYPES``)。 """ result = PreviewResult(kind=PreviewKind.LAYOUT, title=title, reason=f"渲染 {len(widgets)} 个 widget") seen_rects: List[Dict[str, int]] = [] for i, w in enumerate(widgets): wt = w.get("type") x, y = w.get("x", 0), w.get("y", 0) ww, hh = w.get("w", 0), w.get("h", 0) card: Dict[str, Any] = { "index": i, "type": wt, "x": x, "y": y, "w": ww, "h": hh, "description": w.get("description", ""), } # 携带业务绑定(trend.bind / kpi_card.metric) if wt == "trend": card["bind"] = w.get("bind", "") elif wt == "kpi_card": card["metric"] = w.get("metric", "") card["label"] = w.get("label", "") elif wt == "process_view": card["src"] = w.get("src", "") result.items.append(card) # 体检:类型合法 if wt not in ALLOWED_WIDGET_TYPES: result.warnings.append(f"widget[{i}] 非法类型 '{wt}'") # 越界 if x < 0 or y < 0 or ww <= 0 or hh <= 0: result.warnings.append(f"widget[{i}] 坐标/尺寸非法 ({x},{y},{ww},{hh})") elif x + ww > grid_columns: result.warnings.append( f"widget[{i}] 越界:x+w={x + ww} > {grid_columns} 列") else: # 重叠检测(矩形相交) rect = {"x": x, "y": y, "w": ww, "h": hh} for j, prev in enumerate(seen_rects): if _rects_overlap(rect, prev): result.warnings.append(f"widget[{i}] 与 widget[{j}] 重叠") seen_rects.append(rect) # 网格占用率 total_area = sum(r["w"] * r["h"] for r in seen_rects) max_row = max((r["y"] + r["h"] for r in seen_rects), default=0) grid_area = grid_columns * max(max_row, 1) usage = round(total_area / grid_area * 100, 1) if grid_area else 0.0 result.notes.append(f"网格占用率 {usage}%({grid_columns} 列,最大 {max_row} 行)") return result def _rects_overlap(a: Dict[str, int], b: Dict[str, int]) -> bool: """两个网格矩形是否相交(不含边界共享视为不重叠)。""" ax2, ay2 = a["x"] + a["w"], a["y"] + a["h"] bx2, by2 = b["x"] + b["w"], b["y"] + b["h"] return not (ax2 <= b["x"] or bx2 <= a["x"] or ay2 <= b["y"] or by2 <= a["y"]) # --------------------------------------------------------------------------- # 告警预览 # --------------------------------------------------------------------------- #: 合法的告警级别(对齐 cockpit alarm_panel) ALARM_LEVELS = {"info", "warn", "critical"} @dataclass class AlarmRule: """一条告警规则(供告警预览渲染与模拟评估)。""" point_id: str # 关联测点 metric: str # 指标名(展示用) operator: str # 比较运算符 > / >= / < / <= / == threshold: float # 阈值 level: str = "warn" # 告警级别 info/warn/critical message: str = "" # 告警文案模板(可含 {value}) def evaluate(self, value: float) -> bool: """给定当前值,判断是否触发告警。""" ops = { ">": value > self.threshold, ">=": value >= self.threshold, "<": value < self.threshold, "<=": value <= self.threshold, "==": value == self.threshold, } return ops.get(self.operator, False) def render_alarm_preview( rules: List[AlarmRule], current_values: Optional[Dict[str, float]] = None, title: str = "告警规则预览", ) -> PreviewResult: """渲染告警规则为可读条目,并用当前值模拟触发评估。 Args: rules: 告警规则列表; current_values: 当前测点值(point_id → value),用于模拟评估; 不提供则只渲染规则、不做触发评估。 """ result = PreviewResult(kind=PreviewKind.ALARM, title=title, reason=f"渲染 {len(rules)} 条告警规则") for r in rules: if r.level not in ALARM_LEVELS: result.warnings.append(f"告警 '{r.point_id}' 非法级别 '{r.level}'") if r.operator not in (">", ">=", "<", "<=", "=="): result.warnings.append(f"告警 '{r.point_id}' 非法运算符 '{r.operator}'") text = (f"当 {r.metric}({r.point_id}) {r.operator} {r.threshold} 时," f"触发 [{r.level}] 告警") entry: Dict[str, Any] = { "point_id": r.point_id, "metric": r.metric, "operator": r.operator, "threshold": r.threshold, "level": r.level, "text": text, } if current_values is not None and r.point_id in current_values: val = current_values[r.point_id] triggered = r.evaluate(val) entry["current_value"] = val entry["triggered"] = triggered entry["state"] = "触发" if triggered else "正常" result.items.append(entry) if current_values is not None: triggered_count = sum(1 for e in result.items if e.get("triggered")) result.notes.append(f"模拟评估:{triggered_count}/{len(rules)} 条触发") return result # --------------------------------------------------------------------------- # NL 查询预览 # --------------------------------------------------------------------------- @dataclass class NLQueryTemplate: """一条 NL 查询模板(供 NL 查询预览渲染)。""" name: str # 模板名 question_template: str # 问句模板(含 {slot} 占位) slots: Dict[str, List[str]] # 槽位 → 候选取值(用于生成示例问句) data_source: str = "" # 预期数据来源(如 tdengine/rag) answer_hint: str = "" # 预期答案提示 def render_examples(self, max_per_slot: int = 2) -> List[str]: """用槽位候选值生成示例问句(笛卡尔积,限量)。""" if not self.slots: return [self.question_template] examples: List[str] = [] # 取每个槽位前 N 个候选,做限量笛卡尔积 first_slot = next(iter(self.slots)) for val in self.slots[first_slot][:max_per_slot]: examples.append(self.question_template.replace("{" + first_slot + "}", val)) if not examples: examples.append(self.question_template) return examples def render_nl_query_preview( templates: List[NLQueryTemplate], title: str = "NL 查询模板预览", ) -> PreviewResult: """渲染 NL 查询模板为示例问答对。""" result = PreviewResult(kind=PreviewKind.NL_QUERY, title=title, reason=f"渲染 {len(templates)} 个查询模板") for t in templates: examples = t.render_examples() entry: Dict[str, Any] = { "name": t.name, "data_source": t.data_source, "answer_hint": t.answer_hint, "examples": examples, } result.items.append(entry) if not t.question_template: result.warnings.append(f"模板 '{t.name}' 问句模板为空") result.notes.append(f"共生成 {sum(len(e['examples']) for e in result.items)} 条示例问句") return result # --------------------------------------------------------------------------- # 从 ConfigStore 一键预览 # --------------------------------------------------------------------------- def preview_from_store( store: ConfigStore, kind: PreviewKind = PreviewKind.LAYOUT, current_values: Optional[Dict[str, float]] = None, ) -> PreviewResult: """从 ConfigStore 读取配置并渲染对应预览(配置台预览区入口)。 - LAYOUT:读 ``layout`` 类目下 key 含 'dashboard' 的 widget 列表; - ALARM:读 ``model_param`` 类目下 key 以 'alarm_' 开头的规则; - NL_QUERY:读 ``rag_config`` 类目下 key 以 'nl_' 开头的模板。 配置缺失时返回空结果(含提示),不报错——预览是只读的、宽容的。 """ if kind == PreviewKind.LAYOUT: widgets: List[Dict[str, Any]] = [] for it in store.list(ConfigKind.LAYOUT): if isinstance(it.value, list): widgets.extend(it.value) if not widgets: return PreviewResult(kind=kind, title="布局预览(空)", notes=["未配置布局 widget,请在布局编辑页添加"]) return render_layout_preview(widgets) if kind == PreviewKind.ALARM: rules: List[AlarmRule] = [] for it in store.list(ConfigKind.MODEL_PARAM): if it.key.startswith("alarm_") and isinstance(it.value, dict): rules.append(AlarmRule( point_id=it.value.get("point_id", ""), metric=it.value.get("metric", ""), operator=it.value.get("operator", ">"), threshold=float(it.value.get("threshold", 0)), level=it.value.get("level", "warn"), message=it.value.get("message", ""), )) if not rules: return PreviewResult(kind=kind, title="告警预览(空)", notes=["未配置告警规则,请在告警编辑页添加"]) return render_alarm_preview(rules, current_values=current_values) # NL_QUERY templates: List[NLQueryTemplate] = [] for it in store.list(ConfigKind.RAG_CONFIG): if it.key.startswith("nl_") and isinstance(it.value, dict): templates.append(NLQueryTemplate( name=it.value.get("name", it.key), question_template=it.value.get("question_template", ""), slots=it.value.get("slots", {}), data_source=it.value.get("data_source", ""), answer_hint=it.value.get("answer_hint", ""), )) if not templates: return PreviewResult(kind=kind, title="NL 查询预览(空)", notes=["未配置 NL 查询模板,请在查询编辑页添加"]) return render_nl_query_preview(templates)