This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
# -*- 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)
|
||||
Reference in New Issue
Block a user