# -*- 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