Files
iAOP/templates/resin/dashboard/extractor.py
T
bot_dev1 78bfc5d73d feat(#84): 树脂驾驶舱布局提取为模板
把已交付树脂驾驶舱布局资产(cockpit.resin.yaml)提取为可复用模板:
- dashboard/extractor.py:extract_template(变量化 R-801.TEMP→{device}.{point}、
  metric→{metric})、instantiate(点位字典回填)、diff_layouts(差异比对);
- PointDict.from_csv 加载 9 列点位字典;零依赖 _parse_yaml_subset 解析 YAML;
- tests/test_extractor.py 19 用例全部通过。
2026-08-05 05:29:24 +08:00

485 lines
20 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 -*-
"""树脂驾驶舱布局提取器 —— issue #84(父 EPIC #13「树脂模板封装」子任务)。
把已交付的树脂驾驶舱布局资产(`dashboard/cockpit.resin.yaml`)**抽取为可复用
模板**,并提供「点位字典回填」能力,验证内核复制不同行业时的零改动假设
(对齐 PRD 5.5「⑤ 配置化驾驶舱」:换行业只替换驾驶舱布局资产,前端代码不动)。
核心抽象:**布局资产 ↔ 布局模板 ↔ 点位字典** 三者解耦——
- 具体布局资产(`cockpit.resin.yaml`)含**硬编码**的点位绑定
(如 `bind: R-801.TEMP`)与 KPI 指标名(如 `metric: resin_exchange_capacity`);
- 布局模板把这些硬编码值**变量化**为占位符(`{device}.{point}` / `{metric}`),
仅保留布局骨架($schema / title / theme / widgets 几何位置 / widget 类型);
- 实例化时用**目标行业的点位字典**回填占位符,得到该行业的具体驾驶舱布局。
对外 API:
- `extract_template(layout_yaml) -> dict`
从具体布局资产抽取通用模板(变量化硬编码点位/指标)。
- `instantiate(template, point_dict) -> dict`
用点位字典回填占位符,生成具体布局(`bind` 用 `device.point` 填回)。
- `diff_layouts(layout_a, layout_b) -> LayoutDiff`
布局差异比对(schema/title/theme/widgets 几何与绑定差异)。
- `PointDict.from_csv(path)` / `PointDict.lookup(device, point)`
点位字典加载与查询(CSV 9 列,对齐内核 schema)。
纯标准库实现(无 yaml 依赖):YAML 子集用 `_parse_yaml_subset` 解析;
与 `templates/ti-cl4/llm-scenarios/` 同款「dataclass + Enum + 类型注解 +
中文 docstring 引 PRD/EPIC」范式。
"""
from __future__ import annotations
import csv
import os
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Tuple, Union
# ----------------------------------------------------------------------
# 资产路径
# ----------------------------------------------------------------------
#: 模板根目录(templates/resin/)
TEMPLATE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
#: 驾驶舱布局资产(issue #84 的提取对象)
DEFAULT_LAYOUT_PATH = os.path.join(
TEMPLATE_ROOT, "dashboard", "cockpit.resin.yaml")
#: 点位字典资产(issue #82,回填占位符用)
DEFAULT_POINT_DICT_PATH = os.path.join(
TEMPLATE_ROOT, "point-dict", "point_dict.resin.csv")
#: PRD 5.5 iAOP-cockpit-layout-v1 允许的 widget 类型
ALLOWED_WIDGETS = {
"process_view", "trend", "kpi_card", "alarm_panel", "nl_query",
}
#: 驾驶舱布局的 $schema(绑定布局模板版本)
COCKPIT_SCHEMA = "iAOP-cockpit-layout-v1"
#: 占位符:设备位号 / 测点 / KPI 指标(变量化布局)
DEVICE_PLACEHOLDER = "{device}"
POINT_PLACEHOLDER = "{point}"
METRIC_PLACEHOLDER = "{metric}"
#: trend widget 的 bind 变量化形态(设备.测点)
BIND_TEMPLATE = f"{DEVICE_PLACEHOLDER}.{POINT_PLACEHOLDER}"
#: 识别「设备.测点」绑定(如 `R-801.TEMP`),用于变量化与回填
_BIND_RE = re.compile(r"^([A-Za-z0-9_\-]+)\.([A-Za-z0-9_\-]+)$")
# ----------------------------------------------------------------------
# Widget 类型(枚举)
# ----------------------------------------------------------------------
class WidgetType(str, Enum):
"""PRD 5.5 允许的 widget 类型(str 继承便于直接作 YAML 值)。"""
PROCESS_VIEW = "process_view"
TREND = "trend"
KPI_CARD = "kpi_card"
ALARM_PANEL = "alarm_panel"
NL_QUERY = "nl_query"
@classmethod
def is_allowed(cls, value: str) -> bool:
"""widget 类型是否在 PRD 5.5 schema 允许集合内。"""
return any(member.value == value for member in cls)
# ----------------------------------------------------------------------
# 数据类
# ----------------------------------------------------------------------
@dataclass
class PointEntry:
"""点位字典中的单条点位(CSV 9 列,对齐内核 schema)。"""
device_id: str
point_id: str
name: str
unit: str
data_type: str
sample_rate: str
quality_code: str
opc_node: str
protocol: str
@property
def point(self) -> str:
"""短测点名:`R-801.TEMP` → `TEMP`(剥离设备前缀)。"""
prefix = f"{self.device_id}."
if self.point_id.startswith(prefix):
return self.point_id[len(prefix):]
return self.point_id
@dataclass
class PointDict:
"""点位字典(按 device_id 索引点位集合)。
用于 `instantiate` 把布局模板里的 `{device}.{point}` 占位符回填为
目标行业的具体点位绑定。
"""
entries: List[PointEntry] = field(default_factory=list)
@classmethod
def from_csv(cls, path: str = DEFAULT_POINT_DICT_PATH) -> "PointDict":
"""从 CSV 加载点位字典(9 列,对齐 point_dict.resin.csv schema)。"""
entries: List[PointEntry] = []
with open(path, "r", encoding="utf-8") as fh:
reader = csv.reader(fh)
header = next(reader, None)
if header is None:
raise ValueError(f"点位字典 CSV 为空:{path}")
for row in reader:
if len(row) != 9:
raise ValueError(
f"点位字典 CSV 行列数异常(需 9 列):{row}")
entries.append(PointEntry(*row))
return cls(entries=entries)
def devices(self) -> List[str]:
"""去重保序返回所有设备位号。"""
seen: List[str] = []
for e in self.entries:
if e.device_id not in seen:
seen.append(e.device_id)
return seen
def lookup(self, device: str, point: Optional[str] = None) -> List[PointEntry]:
"""查询某设备(可选过滤测点名)的点位。"""
out: List[PointEntry] = []
for e in self.entries:
if e.device_id == device and (point is None or e.point == point):
out.append(e)
return out
def first_device(self) -> str:
"""首个设备位号(单设备趋势绑定回填的默认设备)。"""
return self.entries[0].device_id
@dataclass
class LayoutDiff:
"""两份布局的差异(`diff_layouts` 返回)。"""
schema_changed: bool = False
title_changed: bool = False
theme_changed: bool = False
widget_count_a: int = 0
widget_count_b: int = 0
geometry_changes: List[str] = field(default_factory=list)
binding_changes: List[str] = field(default_factory=list)
@property
def is_identical(self) -> bool:
"""两份布局是否完全一致。"""
return (not self.schema_changed and not self.title_changed
and not self.theme_changed
and not self.geometry_changes and not self.binding_changes
and self.widget_count_a == self.widget_count_b)
def summary(self) -> List[str]:
"""人读的差异摘要(可解释性:列每条差异的含义/原因)。"""
items: List[str] = []
if self.schema_changed:
items.append("$schema 不一致(布局版本不同,可能不兼容)")
if self.title_changed:
items.append("title 不一致(驾驶舱标题文案不同)")
if self.theme_changed:
items.append("theme 不一致(明暗主题不同)")
if self.widget_count_a != self.widget_count_b:
items.append(
f"widget 数量不同:{self.widget_count_a} → {self.widget_count_b}")
for g in self.geometry_changes:
items.append(f"几何变更:{g}")
for b in self.binding_changes:
items.append(f"绑定变更:{b}")
if not items:
items.append("布局完全一致")
return items
# ----------------------------------------------------------------------
# YAML 子集解析器(零依赖,复制自 ti-cl4 范式)
# ----------------------------------------------------------------------
def _parse_value(token: str) -> Union[str, int, float, bool, None]:
"""把标量 token 解析为 Python 类型(int/float/bool/None/str)。"""
token = token.strip()
if token == "":
return ""
if token.lower() == "true":
return True
if token.lower() == "false":
return False
if token.lower() in ("null", "none", "~"):
return None
# int
if re.fullmatch(r"-?\d+", token):
return int(token)
# float
if re.fullmatch(r"-?\d+\.\d+", token):
return float(token)
# 去引号
if len(token) >= 2 and token[0] in "\"'" and token[-1] == token[0]:
return token[1:-1]
return token
def _is_bare_scalar(item_text: str) -> bool:
"""列表项是否为裸标量(非 `key: value` 形式)。
注意:`R-801.TEMP` 这类含冒号但被引号包裹或无分隔冒号的情况需判别。
本驾驶舱 YAML 中列表项的 mapping 形式形如 `type: trend`,分隔符 `: `
后有值;裸标量(无冒号)按标量处理。
"""
# 含 ": " 或以 ":" 结尾 → 视作 mapping 起点
return ": " not in item_text and not item_text.endswith(":")
def _parse_yaml_subset(text: str) -> dict:
"""零依赖 YAML 子集解析器。
仅支持本模块所读的驾驶舱布局资产用到的 YAML 语法:
- 顶层 mapping(`key: value`);
- 列表(`- item`),列表元素可为 mapping(多行 `key: value`);
- 缩进表示嵌套(2 空格缩进);
- 行内值支持 int/float/bool/None/带引号字符串/裸字符串;
- `#` 注释行、空行跳过;
- `key:`(空值)后跟 `- ` 列表项 → 该 key 的值解析为 list。
复杂特性(多行字符串 / 流式语法 / 锚点)按需再加,本模板用不到。
"""
root: dict = {}
# 栈元素:(indent, container, container_owner, owner_key)
# container_owner/owner_key 用于:当 container 是「按需创建的 list」时
# 能回写到父 dict(解决 `widgets:` 后跟列表项的场景)。
stack: List[Tuple[int, Union[dict, list], Optional[dict], Optional[str]]] = [
(0, root, None, None)]
def _indent(line: str) -> int:
return len(line) - len(line.lstrip(" "))
for raw in text.splitlines():
stripped = raw.rstrip()
if not stripped.strip():
continue
if stripped.lstrip().startswith("#"):
continue
indent = _indent(stripped)
content = stripped[indent:]
# 弹栈到当前缩进的父级(>= 而非 >,保留同缩进兄弟)
while len(stack) > 1 and stack[-1][0] > indent:
stack.pop()
if content.startswith("- "):
# 列表项
item_text = content[2:].strip()
top_indent, container, owner, key = stack[-1]
# 若当前容器是「待创建 list」(dict 里某 key 值还指向 dict 自身的占位)
if isinstance(container, dict):
raise ValueError(f"列表项出现在非列表上下文:{raw!r}")
if ":" in item_text and not _is_bare_scalar(item_text):
# 列表项是 mapping 起点(如 `- type: trend`)
k, _, v = item_text.partition(":")
child: dict = {k.strip(): _parse_value(v) if v.strip() else None}
container.append(child)
stack.append((indent + 2, child, None, None))
else:
container.append(_parse_value(item_text))
else:
# mapping 项
key, sep, val = content.partition(":")
if not sep:
continue
key = key.strip()
val = val.strip()
top_indent, container, owner, owner_key = stack[-1]
if not isinstance(container, dict):
raise ValueError(f"键值项出现在非 mapping 上下文:{raw!r}")
if val == "":
# 子结构占位:先建空 list(驾驶舱 widgets 后跟列表项),
# 入栈供后续 `- ` 项填充;若实际跟的是 mapping,append 时会
# 因类型不符报错(本资产不会发生)。
child_list: list = []
container[key] = child_list
stack.append((indent + 1, child_list, container, key))
else:
container[key] = _parse_value(val)
return root
# ----------------------------------------------------------------------
# 布局加载
# ----------------------------------------------------------------------
def load_layout(path: str = DEFAULT_LAYOUT_PATH) -> dict:
"""加载驾驶舱布局资产(零依赖 YAML 子集解析)。"""
with open(path, "r", encoding="utf-8") as fh:
text = fh.read()
return _parse_yaml_subset(text)
# ----------------------------------------------------------------------
# 提取 / 实例化 / 比对
# ----------------------------------------------------------------------
def _is_device_point_binding(value: str) -> Optional[Tuple[str, str]]:
"""识别 `设备.测点` 形态的绑定,返回 (device, point) 或 None。"""
m = _BIND_RE.match(value or "")
if m:
return m.group(1), m.group(2)
return None
def extract_template(layout_yaml: Union[str, dict]) -> dict:
"""从具体布局资产抽取通用模板。
把硬编码的点位绑定(`R-801.TEMP`)与 KPI 指标名
(`resin_exchange_capacity`)**变量化**为占位符,保留布局骨架
($schema / title / theme / widgets 几何与类型 / description)。
Args:
layout_yaml: 具体布局资产路径、或已解析 dict、或 YAML 文本。
Returns:
模板 dict,widget 的 `bind`/`metric` 被替换为占位符;并附带
`placeholder_binding` 字段记录每个占位符的语义(可解释性)。
"""
if isinstance(layout_yaml, str):
if os.path.isfile(layout_yaml):
layout = load_layout(layout_yaml)
else:
layout = _parse_yaml_subset(layout_yaml)
elif isinstance(layout_yaml, dict):
layout = layout_yaml
else:
raise TypeError(f"layout_yaml 类型不支持:{type(layout_yaml)}")
template: dict = {
"$schema": layout.get("$schema", COCKPIT_SCHEMA),
"title": layout.get("title", ""),
"theme": layout.get("theme", "dark"),
"widgets": [],
# 占位符语义说明(提取记录,便于实例化时回填与可解释性)
"placeholder_binding": {
DEVICE_PLACEHOLDER: "设备位号(点位字典 device_id)",
POINT_PLACEHOLDER: "测点名(点位字典 point_id 去设备前缀)",
METRIC_PLACEHOLDER: "KPI 指标名(业务语义键)",
},
}
for w in layout.get("widgets", []):
wt = dict(w) # 浅拷贝
# 1) trend widget 的 bind:R-801.TEMP → {device}.{point}
if wt.get("type") == WidgetType.TREND.value and "bind" in wt:
dp = _is_device_point_binding(str(wt["bind"]))
if dp:
wt["bind"] = BIND_TEMPLATE
wt.setdefault("extracted_from", {})["bind"] = list(dp)
# 2) kpi_card 的 metric:变量化为 {metric}(保留 label)
if wt.get("type") == WidgetType.KPI_CARD.value and "metric" in wt:
wt.setdefault("extracted_from", {})["metric"] = wt["metric"]
wt["metric"] = METRIC_PLACEHOLDER
template["widgets"].append(wt)
return template
def instantiate(template: dict, point_dict: PointDict) -> dict:
"""用点位字典把模板占位符回填为具体布局。
回填策略(对齐 PRD 5.5 布局语义):
- trend widget:`{device}.{point}` 占位符 → 按模板中记录的
`extracted_from.bind` 设备/测点回填;若模板已抽象到只剩占位符
且未记录来源,则用点位字典首个设备 + 同名测点回填。
- kpi_card:`{metric}` → 回填为模板 `extracted_from.metric` 原值
(指标名属业务语义键,不随设备变,保持原值最安全)。
Args:
template: `extract_template` 的输出。
point_dict: 目标行业点位字典(`PointDict.from_csv`)。
Returns:
具体布局 dict(去除 `extracted_from` / `placeholder_binding`
内部字段,符合 iAOP-cockpit-layout-v1 schema)。
"""
if not isinstance(template, dict):
raise TypeError(f"template 必须是 dict:{type(template)}")
if not point_dict.entries:
raise ValueError("点位字典为空,无法回填")
devices = point_dict.devices()
out: dict = {
"$schema": template.get("$schema", COCKPIT_SCHEMA),
"title": template.get("title", ""),
"theme": template.get("theme", "dark"),
"widgets": [],
}
for w in template.get("widgets", []):
cw = dict(w)
src = cw.pop("extracted_from", None) or {}
if cw.get("type") == WidgetType.TREND.value and "bind" in cw:
bind = str(cw["bind"])
if DEVICE_PLACEHOLDER in bind or POINT_PLACEHOLDER in bind:
# 优先用提取时记录的来源设备/测点
device = src.get("bind", [None, None])[0] if src.get("bind") else None
point = src.get("bind", [None, None])[1] if len(src.get("bind", [])) > 1 else None
# 用点位字典校验/覆盖:若设备在字典中则用之,否则回退首个设备
if not device or device not in devices:
device = devices[0]
if not point:
cands = point_dict.lookup(device)
point = cands[0].point if cands else "TEMP"
cw["bind"] = f"{device}.{point}"
if cw.get("type") == WidgetType.KPI_CARD.value and "metric" in cw:
if str(cw["metric"]) == METRIC_PLACEHOLDER:
# metric 是业务语义键,保留模板提取时的原值
cw["metric"] = src.get("metric", "kpi_metric")
out["widgets"].append(cw)
return out
def diff_layouts(layout_a: dict, layout_b: dict) -> LayoutDiff:
"""比对两份具体布局(或模板)的差异。
返回 `LayoutDiff`:含 schema/title/theme/widgets 几何位置与
bind/metric 绑定的逐条差异(用于换行业时回归比对,定位漂移)。
"""
diff = LayoutDiff(
schema_changed=layout_a.get("$schema") != layout_b.get("$schema"),
title_changed=layout_a.get("title") != layout_b.get("title"),
theme_changed=layout_a.get("theme") != layout_b.get("theme"),
)
wa = layout_a.get("widgets", []) or []
wb = layout_b.get("widgets", []) or []
diff.widget_count_a = len(wa)
diff.widget_count_b = len(wb)
# 按位置(索引)对齐 widgets,比对几何与绑定
n = max(len(wa), len(wb))
for i in range(n):
a = wa[i] if i < len(wa) else None
b = wb[i] if i < len(wb) else None
if a is None or b is None:
diff.geometry_changes.append(
f"widget[{i}] {'缺失' if a is None else '新增'}")
continue
# 几何(x/y/w/h)
for k in ("x", "y", "w", "h"):
av, bv = a.get(k), b.get(k)
if av != bv:
diff.geometry_changes.append(
f"widget[{i}].{k}: {av} → {bv}")
# 类型
if a.get("type") != b.get("type"):
diff.geometry_changes.append(
f"widget[{i}].type: {a.get('type')} → {b.get('type')}")
# 绑定(bind / metric)
for k in ("bind", "metric"):
av, bv = a.get(k), b.get(k)
if av != bv:
diff.binding_changes.append(
f"widget[{i}].{k}: {av!r} → {bv!r}")
return diff