From 4adfbb78fa6cd1c82000bccf65d288454131685d Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 05:28:00 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(#62):=20=E4=B8=89=E7=BA=A7=20RBAC=20?= =?UTF-8?q?=E6=9D=83=E9=99=90=E6=A8=A1=E5=9E=8B=EF=BC=88admin/engineer/rea?= =?UTF-8?q?donly=EF=BC=8C=E8=A7=92=E8=89=B2=E7=BB=A7=E6=89=BF+=E5=8F=AF?= =?UTF-8?q?=E8=A7=A3=E9=87=8A=E5=88=A4=E5=AE=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/template-console/__init__.py | 48 ++++ core/template-console/rbac.py | 310 ++++++++++++++++++++++ core/template-console/tests/_bootstrap.py | 26 ++ core/template-console/tests/test_rbac.py | 181 +++++++++++++ 4 files changed, 565 insertions(+) create mode 100644 core/template-console/__init__.py create mode 100644 core/template-console/rbac.py create mode 100644 core/template-console/tests/_bootstrap.py create mode 100644 core/template-console/tests/test_rbac.py diff --git a/core/template-console/__init__.py b/core/template-console/__init__.py new file mode 100644 index 0000000..ca1afec --- /dev/null +++ b/core/template-console/__init__.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +"""⑤.7 模板配置台(Template Console)内核引擎 —— EPIC #9。 + +配置台是**跨模板通用的内核能力**:为实施工程师提供一个无代码的配置驱动 +界面,把"模型超参 / RAG / 布局"三类配置 + 点位字典 + 版本发布统一编排, +并将发布的配置**推送给内核**(edge-gateway / rag-kb / model-framework)。 + +本包拆为 6 个子模块,对应 6 个 issue(同一 feature 分支承载,单 PR 关联): + +- ``rbac`` (#62) 三级 RBAC 权限(admin / engineer / readonly); +- ``point_importer`` (#63) 点位字典 CSV 导入 + 自动校验页面(复用 + ``core/edge-gateway/point_dict`` 校验器,增加配置台 + 级结果聚合 + OPC 节点格式校验 + 模板选择); +- ``config_store`` (#64) 配置项 CRUD(模型超参 / RAG / 布局三类,文件系统 + 版本化 JSON 存储); +- ``preview`` (#65) 预览渲染引擎(布局/告警/查询 → 可预览结构化输出, + 对齐 iAOP-cockpit-layout-v1 widget 类型); +- ``release`` (#66) 版本发布 + 回滚点(基于 config_store 快照,semver); +- ``push_channel`` (#67) 配置台↔内核配置推送契约(JSON manifest + 校验和 + + 幂等性)。 + +设计原则(对齐 PRD「可解释可溯源」与既有内核范式): +- 纯标准库零运行时依赖(无 pyyaml/numpy/pandas),YAML 子集用内置解析器; +- dataclass + Enum + 类型注解 + 中文 docstring; +- 关键决策均带 ``meaning`` / ``reason`` 字段,便于审计与可解释性。 +""" +from __future__ import annotations + +from .rbac import ( + Action, + Permission, + Role, + RoleKind, + User, + has_permission, +) + +__all__ = [ + "Action", + "Permission", + "Role", + "RoleKind", + "User", + "has_permission", +] + +#: 本包版本(对齐 EPIC #9 模板配置台交付节奏) +__version__ = "1.0.0" diff --git a/core/template-console/rbac.py b/core/template-console/rbac.py new file mode 100644 index 0000000..9ee3749 --- /dev/null +++ b/core/template-console/rbac.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +"""⑤.7 配置台三级 RBAC 权限模型 —— issue #62 / PRD ⑤.7。 + +配置台面向**多角色协作**:实施工程师配模板,行业工程师调参数,运维/管理者 +发布上线。直接对所有人开放写权限会带来误改与不可溯源风险。本模块用三级 +RBAC(基于角色的访问控制)锁定"谁能对哪类配置做什么",并把每次权限判定 +的**理由**一并返回,对齐 PRD「可解释可溯源」。 + +三级角色(由低到高,后者继承前者全部权限): + +- ``readonly`` (只读):查看配置 / 预览 / 历史版本,不可写; +- ``engineer`` (行业工程师):只读权限 + 编辑/校验/导入配置(模型超参 / + RAG / 布局 / 点位字典),但**不能发布与回滚**; +- ``admin`` (管理员):工程师权限 + 发布 / 回滚 / 推送内核 / 用户管理。 + +权限判定核心为 ``has_permission(user, resource, action)``,返回 +``PermissionDecision``(allow + reason),便于配置台前端把"为什么拒绝" +直接展示给操作者,而不是一个干瘪的 403。 + +零运行时依赖:仅用 dataclass / Enum / 标准库。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Set + + +# --------------------------------------------------------------------------- +# 权限维度:资源 × 动作 +# --------------------------------------------------------------------------- + +class Resource(str, Enum): + """配置台可管控的资源(对齐 #63~#67 子模块)。""" + + POINT_DICT = "point_dict" # 点位字典(#63) + MODEL_PARAM = "model_param" # 模型超参配置(#64) + RAG_CONFIG = "rag_config" # RAG 知识库配置(#64) + LAYOUT = "layout" # 驾驶舱布局配置(#64/#65) + PREVIEW = "preview" # 预览(#65) + RELEASE = "release" # 版本发布/回滚(#66) + PUSH = "push" # 配置推送内核(#67) + USER = "user" # 用户/角色管理 + + +class Action(str, Enum): + """对资源可执行的动作。""" + + VIEW = "view" # 查看 / 预览 / 列表 + EDIT = "edit" # 新增 / 修改 / 删除 / 导入 / 校验 + PUBLISH = "publish" # 发布版本 / 回滚 / 推送内核 + MANAGE = "manage" # 用户与角色管理 + + +class RoleKind(str, Enum): + """三级角色枚举(值即配置资产中的角色标识)。""" + + READONLY = "readonly" + ENGINEER = "engineer" + ADMIN = "admin" + + +# 各资源的「写」动作等价集合:EDIT 含新增/修改/删除/导入/校验。 +# PUBLISH 含发布/回滚/推送。这样配置台前端只需关心粗粒度动作。 +_WRITE_ACTIONS: Set[Action] = {Action.EDIT, Action.PUBLISH, Action.MANAGE} + + +# --------------------------------------------------------------------------- +# 权限模型 +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Permission: + """一条权限授予(角色 → 资源 → 动作)。 + + ``meaning`` 解释该权限的业务含义,用于审计日志与配置台权限矩阵展示。 + 注意:权限**匹配**基于 ``resource:action``(资源×动作),与授予角色无关—— + 这正是角色继承能生效的关键(admin 继承 engineer 的 edit,匹配键相同)。 + ``role`` 仅作为审计元数据,记录"是谁授予的"。 + """ + + role: RoleKind + resource: Resource + action: Action + meaning: str = "" + + def key(self) -> str: + """权限匹配键(资源:动作)—— 角色继承据此累计。""" + return f"{self.resource.value}:{self.action.value}" + + def audit_key(self) -> str: + """审计唯一键(角色/资源/动作三元组,含授予者)。""" + return f"{self.role.value}:{self.resource.value}:{self.action.value}" + + +@dataclass +class Role: + """一个角色:权限集合 + 继承的父角色。""" + + kind: RoleKind + label: str # 中文展示名 + permissions: List[Permission] = field(default_factory=list) + inherits: Optional[RoleKind] = None # 继承的低一级角色 + description: str = "" # 角色职责说明(可解释性) + + def permission_keys(self) -> Set[str]: + """本角色直接授予的权限键集合。""" + return {p.key() for p in self.permissions} + + +@dataclass +class User: + """配置台用户。""" + + username: str + role: RoleKind + display_name: str = "" + # 可选资源级收窄:即便角色允许,列表中的资源也会被额外限制为只读。 + # 用于"只允许工程师改某几类配置"的细粒度场景。 + restricted_to_readonly: List[Resource] = field(default_factory=list) + + +@dataclass +class PermissionDecision: + """``has_permission`` 的判定结果(带理由,可解释)。""" + + allow: bool + reason: str # 人类可读的判定理由(允许/拒绝原因) + role: RoleKind + resource: Resource + action: Action + source: str = "explicit" # explicit(本角色直接授予)/ inherited(继承自父角色) + + +# --------------------------------------------------------------------------- +# 角色注册表:三级权限矩阵(对齐 PRD ⑤.7「三级 RBAC」) +# --------------------------------------------------------------------------- + +def _build_role_registry() -> Dict[RoleKind, Role]: + """构建三级角色及其权限矩阵。 + + 权限设计依据(PRD ⑤.7): + - readonly:可查看所有配置/预览/历史,但不能改、不能发; + - engineer:在 readonly 基础上,可编辑/校验/导入四类业务配置, + 但**发布/回滚/推送/用户管理仍归 admin**(避免未经评审上线); + - admin:在 engineer 基础上,可发布/回滚/推送 + 管理用户角色。 + """ + ro = Role( + kind=RoleKind.READONLY, + label="只读", + description="实施/运维只读角色:查看配置、预览、历史版本,不可写。", + permissions=[ + Permission(RoleKind.READONLY, Resource.POINT_DICT, Action.VIEW, + "查看点位字典与校验报告"), + Permission(RoleKind.READONLY, Resource.MODEL_PARAM, Action.VIEW, + "查看模型超参配置"), + Permission(RoleKind.READONLY, Resource.RAG_CONFIG, Action.VIEW, + "查看 RAG 知识库配置"), + Permission(RoleKind.READONLY, Resource.LAYOUT, Action.VIEW, + "查看驾驶舱布局配置"), + Permission(RoleKind.READONLY, Resource.PREVIEW, Action.VIEW, + "查看配置预览"), + Permission(RoleKind.READONLY, Resource.RELEASE, Action.VIEW, + "查看历史发布版本"), + ], + ) + + engineer = Role( + kind=RoleKind.ENGINEER, + label="行业工程师", + inherits=RoleKind.READONLY, + description="行业工程师:编辑/校验/导入业务配置,但不能发布与推送。", + permissions=[ + Permission(RoleKind.ENGINEER, Resource.POINT_DICT, Action.EDIT, + "导入/编辑/校验点位字典 CSV"), + Permission(RoleKind.ENGINEER, Resource.MODEL_PARAM, Action.EDIT, + "调整模型超参配置"), + Permission(RoleKind.ENGINEER, Resource.RAG_CONFIG, Action.EDIT, + "编辑 RAG 知识库配置"), + Permission(RoleKind.ENGINEER, Resource.LAYOUT, Action.EDIT, + "编辑驾驶舱布局配置"), + Permission(RoleKind.ENGINEER, Resource.PREVIEW, Action.VIEW, + "预览配置效果(编辑后必看)"), + ], + ) + + admin = Role( + kind=RoleKind.ADMIN, + label="管理员", + inherits=RoleKind.ENGINEER, + description="管理员:在工程师基础上负责发布/回滚/推送与用户管理。", + permissions=[ + Permission(RoleKind.ADMIN, Resource.RELEASE, Action.PUBLISH, + "发布新版本与回滚到历史版本"), + Permission(RoleKind.ADMIN, Resource.PUSH, Action.PUBLISH, + "把已发布配置推送给内核"), + Permission(RoleKind.ADMIN, Resource.USER, Action.MANAGE, + "管理用户与角色分配"), + Permission(RoleKind.ADMIN, Resource.POINT_DICT, Action.PUBLISH, + "确认点位字典上线(审批环节)"), + Permission(RoleKind.ADMIN, Resource.MODEL_PARAM, Action.PUBLISH, + "确认模型超参上线"), + Permission(RoleKind.ADMIN, Resource.LAYOUT, Action.PUBLISH, + "确认布局上线"), + ], + ) + + return {RoleKind.READONLY: ro, RoleKind.ENGINEER: engineer, RoleKind.ADMIN: admin} + + +_ROLES: Dict[RoleKind, Role] = _build_role_registry() + + +def get_role(kind: RoleKind) -> Role: + """获取角色定义。""" + return _ROLES[kind] + + +def all_roles() -> List[Role]: + """全部角色(按权限由低到高)。""" + return [_ROLES[RoleKind.READONLY], _ROLES[RoleKind.ENGINEER], _ROLES[RoleKind.ADMIN]] + + +def effective_permissions(kind: RoleKind) -> Set[str]: + """角色有效权限键(含继承链)。 + + 继承解析:admin 继承 engineer 继承 readonly,递归向上累计权限键。 + """ + role = _ROLES[kind] + keys: Set[str] = set(role.permission_keys()) + if role.inherits is not None: + keys |= effective_permissions(role.inherits) + return keys + + +# --------------------------------------------------------------------------- +# 判定 API +# --------------------------------------------------------------------------- + +def has_permission( + user: User, + resource: Resource, + action: Action, +) -> PermissionDecision: + """判定用户对某资源执行某动作是否被允许(带理由)。 + + 判定顺序: + 1. 计算角色有效权限(含继承),命中即允许并标注来源(本角色/继承); + 2. 命中后若该资源在用户 ``restricted_to_readonly`` 列表且动作是写动作, + 则降级拒绝(细粒度收窄); + 3. 未命中则拒绝,理由标注缺失的权限三元组。 + + Args: + user: 配置台用户; + resource: 目标资源; + action: 目标动作。 + + Returns: + PermissionDecision:allow + reason(可直接展示给操作者)。 + """ + target = f"{resource.value}:{action.value}" + eff = effective_permissions(user.role) + + # 细粒度收窄:即便角色允许,特定资源也被限制为只读 + if resource in user.restricted_to_readonly and action in _WRITE_ACTIONS: + return PermissionDecision( + allow=False, + reason=(f"用户 '{user.username}' 对资源 '{resource.value}' 被收窄为只读," + f"禁止执行 '{action.value}' 动作"), + role=user.role, resource=resource, action=action, source="restricted", + ) + + if target in eff: + # 判定来源:本角色直接授予 or 继承自父角色 + own = get_role(user.role).permission_keys() + source = "explicit" if target in own else "inherited" + src_label = "本角色直接授予" if source == "explicit" else "继承自低级角色" + return PermissionDecision( + allow=True, + reason=(f"用户 '{user.username}'({get_role(user.role).label})" + f"允许对 '{resource.value}' 执行 '{action.value}'({src_label})"), + role=user.role, resource=resource, action=action, source=source, + ) + + return PermissionDecision( + allow=False, + reason=(f"用户 '{user.username}'({get_role(user.role).label})缺少权限 " + f"{user.role.value}:{resource.value}:{action.value};" + f"该动作需更高角色或审批"), + role=user.role, resource=resource, action=action, source="denied", + ) + + +def can_publish(user: User) -> bool: + """便捷判定:用户是否具备发布(发布/回滚/推送)能力。""" + return has_permission(user, Resource.RELEASE, Action.PUBLISH).allow + + +def user_summary(user: User) -> Dict[str, object]: + """用户权限概览(供配置台用户卡片/审计日志展示)。""" + role = get_role(user.role) + return { + "username": user.username, + "display_name": user.display_name or user.username, + "role": user.role.value, + "role_label": role.label, + "description": role.description, + "effective_permission_count": len(effective_permissions(user.role)), + "restricted_to_readonly": [r.value for r in user.restricted_to_readonly], + } diff --git a/core/template-console/tests/_bootstrap.py b/core/template-console/tests/_bootstrap.py new file mode 100644 index 0000000..bcff38b --- /dev/null +++ b/core/template-console/tests/_bootstrap.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""测试引导:把 `core/template-console` 以包名 `template_console` 挂载到 sys.modules。 + +目录名 `template-console` 含连字符,无法直接以包名 import;挂载后模块内 +相对导入(`from .rbac import ...`)在 unittest 发现机制下可正常解析。 + +同时把兄弟内核目录 `core/edge-gateway` 加入 sys.path,使 point_importer +可复用其 `point_dict` 子包(loader/validator/schema),避免重复造轮子。 +""" +import os +import sys +import types + +# 1) 挂载 core/template-console 为 template_console 包 +CONSOLE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, CONSOLE_DIR) +if "template_console" not in sys.modules: + pkg = types.ModuleType("template_console") + pkg.__path__ = [CONSOLE_DIR] + sys.modules["template_console"] = pkg + +# 2) 暴露兄弟内核 edge-gateway/point_dict(#63 复用其校验器) +CORE_DIR = os.path.dirname(CONSOLE_DIR) +EDGE_GW_DIR = os.path.join(CORE_DIR, "edge-gateway") +if os.path.isdir(EDGE_GW_DIR) and EDGE_GW_DIR not in sys.path: + sys.path.insert(0, EDGE_GW_DIR) diff --git a/core/template-console/tests/test_rbac.py b/core/template-console/tests/test_rbac.py new file mode 100644 index 0000000..4a26e4c --- /dev/null +++ b/core/template-console/tests/test_rbac.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +"""三级 RBAC 权限模型测试(issue #62)。 + +覆盖: +1. 三级角色权限矩阵正确(readonly/engineer/admin); +2. 角色继承(admin 继承 engineer 继承 readonly); +3. has_permission 允许/拒绝判定 + 理由可解释; +4. 细粒度收窄(restricted_to_readonly 把写动作降级拒绝); +5. 便捷判定 can_publish / 用户概览。 +""" +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _bootstrap # noqa: F401 + +from template_console.rbac import ( # noqa: E402 + Action, + Permission, + Resource, + Role, + RoleKind, + User, + all_roles, + can_publish, + effective_permissions, + get_role, + has_permission, + user_summary, +) + + +class RoleRegistryTest(unittest.TestCase): + """三级角色注册表。""" + + def test_three_roles_present(self): + roles = {r.kind for r in all_roles()} + self.assertEqual(roles, {RoleKind.READONLY, RoleKind.ENGINEER, RoleKind.ADMIN}) + + def test_role_labels_in_chinese(self): + self.assertEqual(get_role(RoleKind.READONLY).label, "只读") + self.assertEqual(get_role(RoleKind.ENGINEER).label, "行业工程师") + self.assertEqual(get_role(RoleKind.ADMIN).label, "管理员") + + def test_role_descriptions_explainable(self): + # 可解释性:每个角色都有职责说明 + for role in all_roles(): + self.assertTrue(role.description, f"{role.kind} 缺少 description") + + def test_inheritance_chain(self): + self.assertEqual(get_role(RoleKind.ADMIN).inherits, RoleKind.ENGINEER) + self.assertEqual(get_role(RoleKind.ENGINEER).inherits, RoleKind.READONLY) + self.assertIsNone(get_role(RoleKind.READONLY).inherits) + + def test_permission_key_format(self): + p = Permission(RoleKind.ADMIN, Resource.USER, Action.MANAGE) + # 匹配键为资源:动作(角色无关,便于继承);审计键含授予角色 + self.assertEqual(p.key(), "user:manage") + self.assertEqual(p.audit_key(), "admin:user:manage") + + +class EffectivePermissionTest(unittest.TestCase): + """继承后的有效权限集合。""" + + def test_admin_inherits_engineer_and_readonly(self): + eff = effective_permissions(RoleKind.ADMIN) + # 匹配键为 resource:action:admin 拥有自身的 user:manage, + # 也继承 engineer 的 model_param:edit 与 readonly 的 layout:view + self.assertIn("user:manage", eff) + self.assertIn("model_param:edit", eff) + self.assertIn("layout:view", eff) + + def test_engineer_cannot_publish(self): + eff = effective_permissions(RoleKind.ENGINEER) + # 工程师不能发布/推送/管用户 + self.assertNotIn("release:publish", eff) + self.assertNotIn("push:publish", eff) + self.assertNotIn("user:manage", eff) + + def test_readonly_has_no_write(self): + eff = effective_permissions(RoleKind.READONLY) + for key in eff: + # 只读权限只能以 :view 结尾 + self.assertTrue(key.endswith(":view"), f"readonly 不应有写/发布权限: {key}") + + +class HasPermissionTest(unittest.TestCase): + """has_permission 判定 + 理由。""" + + def setUp(self): + self.ro = User("viewer", RoleKind.READONLY, "查看员") + self.eng = User("li_engineer", RoleKind.ENGINEER, "李工") + self.admin = User("root_admin", RoleKind.ADMIN, "管理员甲") + + def test_readonly_view_allowed(self): + d = has_permission(self.ro, Resource.LAYOUT, Action.VIEW) + self.assertTrue(d.allow) + self.assertEqual(d.source, "explicit") + + def test_readonly_edit_denied(self): + d = has_permission(self.ro, Resource.LAYOUT, Action.EDIT) + self.assertFalse(d.allow) + self.assertIn("缺少权限", d.reason) + + def test_engineer_edit_allowed_inherited_view(self): + # 工程师编辑是本角色权限(explicit) + d_edit = has_permission(self.eng, Resource.LAYOUT, Action.EDIT) + self.assertTrue(d_edit.allow) + self.assertEqual(d_edit.source, "explicit") + # 工程师查看布局是继承自 readonly(inherited) + d_view = has_permission(self.eng, Resource.LAYOUT, Action.VIEW) + self.assertTrue(d_view.allow) + self.assertEqual(d_view.source, "inherited") + + def test_engineer_publish_denied(self): + d = has_permission(self.eng, Resource.RELEASE, Action.PUBLISH) + self.assertFalse(d.allow) + + def test_admin_publish_allowed(self): + d = has_permission(self.admin, Resource.RELEASE, Action.PUBLISH) + self.assertTrue(d.allow) + self.assertEqual(d.source, "explicit") + + def test_admin_inherited_engineer_edit(self): + d = has_permission(self.admin, Resource.MODEL_PARAM, Action.EDIT) + self.assertTrue(d.allow) + self.assertEqual(d.source, "inherited") + + def test_decision_carries_reason(self): + # 可解释性:无论允许/拒绝,reason 非空且含用户名与资源 + for user in (self.ro, self.eng, self.admin): + d = has_permission(user, Resource.PUSH, Action.PUBLISH) + self.assertIn(user.username, d.reason) + self.assertIn(Resource.PUSH.value, d.reason) + + +class RestrictedUserTest(unittest.TestCase): + """细粒度收窄:restricted_to_readonly。""" + + def test_restricted_engineer_cannot_edit_that_resource(self): + # 工程师本可编辑布局,但被收窄为只读后应拒绝 + u = User("limited", RoleKind.ENGINEER, "受限工程师", + restricted_to_readonly=[Resource.LAYOUT]) + d = has_permission(u, Resource.LAYOUT, Action.EDIT) + self.assertFalse(d.allow) + self.assertEqual(d.source, "restricted") + + def test_restricted_engineer_can_still_view(self): + u = User("limited", RoleKind.ENGINEER, "受限工程师", + restricted_to_readonly=[Resource.LAYOUT]) + d = has_permission(u, Resource.LAYOUT, Action.VIEW) + self.assertTrue(d.allow) + + def test_restricted_only_affects_named_resource(self): + u = User("limited", RoleKind.ENGINEER, "受限工程师", + restricted_to_readonly=[Resource.LAYOUT]) + # 模型超参未被收窄,仍可编辑 + d = has_permission(u, Resource.MODEL_PARAM, Action.EDIT) + self.assertTrue(d.allow) + + +class ConvenienceTest(unittest.TestCase): + """便捷判定与用户概览。""" + + def test_can_publish(self): + self.assertFalse(can_publish(User("v", RoleKind.READONLY))) + self.assertFalse(can_publish(User("e", RoleKind.ENGINEER))) + self.assertTrue(can_publish(User("a", RoleKind.ADMIN))) + + def test_user_summary(self): + s = user_summary(User("li", RoleKind.ENGINEER, "李工")) + self.assertEqual(s["username"], "li") + self.assertEqual(s["role"], "engineer") + self.assertEqual(s["role_label"], "行业工程师") + self.assertGreater(s["effective_permission_count"], 0) + self.assertEqual(s["restricted_to_readonly"], []) + + +if __name__ == "__main__": + unittest.main() -- 2.54.0 From 4457b531702c5e8029b4b9cbb061827d68a1405d Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 05:28:04 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(#63):=20=E7=82=B9=E4=BD=8D=E5=AD=97?= =?UTF-8?q?=E5=85=B8=20CSV=20=E5=AF=BC=E5=85=A5+=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=EF=BC=88=E5=A4=8D=E7=94=A8=E5=86=85=E6=A0=B8?= =?UTF-8?q?=20point=5Fdict=20=E6=A0=A1=E9=AA=8C=E5=99=A8=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20OPC=20=E8=8A=82=E7=82=B9/=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E7=BA=A7=E6=A0=A1=E9=AA=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/template-console/point_importer.py | 318 ++++++++++++++++++ .../tests/test_point_importer.py | 228 +++++++++++++ 2 files changed, 546 insertions(+) create mode 100644 core/template-console/point_importer.py create mode 100644 core/template-console/tests/test_point_importer.py diff --git a/core/template-console/point_importer.py b/core/template-console/point_importer.py new file mode 100644 index 0000000..667f645 --- /dev/null +++ b/core/template-console/point_importer.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8 -*- +"""⑤.7 点位字典 CSV 导入 + 自动校验页面 —— issue #63 / PRD ⑤.7。 + +配置台的"导入页面"要解决:实施工程师拿着 DCS 点表(Excel 转 CSV)粘进配置台, +**一次性看到所有问题**(表头错/量纲错/重复点号/采样率非正/协议非法/OPC 节点 +格式错),而不是改一条报一条。本模块是导入页面的后端引擎。 + +**复用而非重造**:点位字典的 schema/加载/校验(量纲/数据类型/采样率/重复点号/ +协议)已由内核 ``core/edge-gateway/point_dict``(loader/validator/schema)实现 +并被边缘网关正式使用。本模块在其基础上增加**配置台专属**校验维度: + +1. OPC 节点格式校验(OPC UA 节点须形如 ``ns=<数字>;s=<名>`` 或 PLC 寄存器 + ``holding:<数字>`` / ``coil:<数字>``,与 simulator/opcua 驱动约定一致); +2. 表头列顺序严格对齐(实施工程师照表填列,列序错位是高频错误); +3. 行级结果聚合为 ``ImportRowIssue``(行号 + 严重级别 + 问题 + 修复建议), + 供配置台前端逐行渲染、按严重级别过滤; +4. 模板选择(resin/ti):不同行业模板的合法量纲集合不同(如树脂含 rpm/mmol·g⁻¹), + 导入时按模板切换校验基线。 + +零运行时依赖:复用 ``point_dict`` 子包(纯标准库 csv/dataclass)。 +""" +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Tuple + +# 复用内核 edge-gateway 的点位字典加载/校验(schema/validator/loader) +# 在 tests/_bootstrap.py 中已把 core/edge-gateway 加入 sys.path, +# 故此处以顶层包 point_dict 引用(与 edge-gateway 自身测试一致)。 +from point_dict import ( # noqa: E402 + CSV_HEADERS, + VALID_PROTOCOLS, + VALID_UNITS, + Point, + PointDict, + load_point_dict_csv, +) +from point_dict import schema as _pd_schema # noqa: E402 + + +class Severity(str, Enum): + """问题严重级别(配置台前端据此着色/过滤)。""" + + ERROR = "error" # 阻断:不修复无法入库 + WARN = "warn" # 警告:可入库但建议复核(如 OPC 节点为空) + + +class TemplateKind(str, Enum): + """行业模板(决定合法量纲等校验基线)。""" + + TI = "ti" # 氯化/化工通用(templates/ti-cl4) + RESIN = "resin" # 吸附树脂(templates/resin,含 rpm / mmol/g) + + +# OPC 节点格式(与 simulator/opcua/s7 驱动约定一致): +# ns=2;s=CLF.Temp —— OPC UA 节点(namespace + 字符串 id) +# holding:40010 / coil:1 —— Modbus 寄存器(保持/线圈 + 地址) +_OPC_UA_RE = re.compile(r"^ns=\d+;s=[^\s,]+$") +_MODBUS_RE = re.compile(r"^(holding|coil|input|discrete):(\d+)$") + + +def _unit_set(template: TemplateKind) -> set: + """按模板返回合法量纲集合(树脂含 rpm/mmol·g⁻¹ 等扩展)。""" + base = set(VALID_UNITS) + if template == TemplateKind.RESIN: + # VALID_UNITS 已含树脂扩展(rpm/mmol/g),直接复用 + return base + # ti 模板:移除树脂专属量纲,避免化工模板误用树脂量纲 + base.discard("rpm") + base.discard("mmol/g") + return base + + +@dataclass +class ImportRowIssue: + """导入页一行的问题(行号 + 严重级别 + 问题 + 修复建议,可解释)。""" + + row: int # CSV 行号(表头=1,数据从 2 起) + severity: Severity + code: str # 错误码(对齐 point_dict.validator 的 code + 本模块扩展) + message: str # 问题描述 + suggestion: str = "" # 修复建议(供配置台"一键修复"提示) + + def to_dict(self) -> dict: + return { + "row": self.row, "severity": self.severity.value, + "code": self.code, "message": self.message, + "suggestion": self.suggestion, + } + + +@dataclass +class ImportReport: + """导入校验报告(配置台导入页面数据模型)。""" + + template: TemplateKind + total_rows: int = 0 # 数据行数 + issues: List[ImportRowIssue] = field(default_factory=list) + loaded_points: int = 0 # 成功加载的点数 + file_path: str = "" + + @property + def ok(self) -> bool: + """无 ERROR 级问题即可入库(WARN 不阻断)。""" + return not any(i.severity == Severity.ERROR for i in self.issues) + + @property + def error_count(self) -> int: + return sum(1 for i in self.issues if i.severity == Severity.ERROR) + + @property + def warn_count(self) -> int: + return sum(1 for i in self.issues if i.severity == Severity.WARN) + + def summary(self) -> str: + """人类可读汇总(配置台导入结果横幅)。""" + status = "通过" if self.ok else "未通过" + return (f"导入校验{status}:{self.loaded_points} 点 / " + f"{self.total_rows} 行,错误 {self.error_count},警告 {self.warn_count}") + + def to_dict(self) -> dict: + return { + "template": self.template.value, + "total_rows": self.total_rows, + "loaded_points": self.loaded_points, + "ok": self.ok, + "error_count": self.error_count, + "warn_count": self.warn_count, + "summary": self.summary(), + "issues": [i.to_dict() for i in self.issues], + } + + +# --------------------------------------------------------------------------- +# 校验扩展 +# --------------------------------------------------------------------------- + +def _validate_opc_node(point: Point) -> List[ImportRowIssue]: + """OPC 节点格式校验(配置台扩展维度)。 + + 约定(与驱动注册表对齐): + - 协议 opcua:节点须匹配 ``ns=<数字>;s=<名>``; + - 协议 modbus:节点须匹配 ``holding/coil/input/discrete:<数字>``; + - 协议 simulator:节点可空,或任意上述格式(演示用,宽松); + - 节点为空:WARN(可入库但运行时无法采集,建议补全)。 + """ + out: List[ImportRowIssue] = [] + node = (point.opc_node or "").strip() + if not node: + out.append(ImportRowIssue( + row=point.row_number, severity=Severity.WARN, code="empty_opc_node", + message=f"第{point.row_number}行 opcNode 为空", + suggestion="运行时无法采集,建议补全 OPC UA 节点或 PLC 寄存器地址", + )) + return out + + proto = (point.protocol or "").lower() + ok_ua = bool(_OPC_UA_RE.match(node)) + ok_mb = bool(_MODBUS_RE.match(node)) + if proto == "opcua" and not ok_ua: + out.append(ImportRowIssue( + row=point.row_number, severity=Severity.ERROR, code="bad_opc_node", + message=(f"第{point.row_number}行 opcNode '{node}' 不符合 OPC UA " + f"格式 ns=;s="), + suggestion="示例:ns=2;s=CLF.Temp", + )) + elif proto == "modbus" and not ok_mb: + out.append(ImportRowIssue( + row=point.row_number, severity=Severity.ERROR, code="bad_opc_node", + message=(f"第{point.row_number}行 opcNode '{node}' 不符合 Modbus " + f"格式 holding/coil/input/discrete:"), + suggestion="示例:holding:40010", + )) + elif proto not in ("opcua", "modbus") and not (ok_ua or ok_mb): + # simulator/s7/... 节点为空已 WARN;非空但格式都不符则 WARN(宽松) + out.append(ImportRowIssue( + row=point.row_number, severity=Severity.WARN, code="bad_opc_node", + message=(f"第{point.row_number}行 opcNode '{node}' 既非 OPC UA 也非 " + f"Modbus 格式"), + suggestion="确认节点格式或清空(演示协议可空)", + )) + return out + + +def _validate_header_order(headers: List[str]) -> List[ImportRowIssue]: + """表头列顺序严格对齐(列序错位是实施工程师高频错误)。""" + out: List[ImportRowIssue] = [] + if not headers: + out.append(ImportRowIssue( + row=1, severity=Severity.ERROR, code="empty_header", + message="CSV 缺少表头行", + suggestion=f"表头应为:{','.join(CSV_HEADERS)}", + )) + return out + missing = [h for h in CSV_HEADERS if h not in headers] + for h in missing: + out.append(ImportRowIssue( + row=1, severity=Severity.ERROR, code="missing_column", + message=f"表头缺少必填列:{h}", + suggestion=f"补列 {h}(完整表头:{','.join(CSV_HEADERS)})", + )) + if headers[: len(CSV_HEADERS)] != CSV_HEADERS and not missing: + out.append(ImportRowIssue( + row=1, severity=Severity.WARN, code="bad_column_order", + message=f"表头列顺序与标准不一致:{headers}", + suggestion=f"标准顺序:{','.join(CSV_HEADERS)}", + )) + return out + + +def _convert_validator_issues(report: "object", severity_for: Dict[str, Severity]) -> List[ImportRowIssue]: + """把内核 validator.ValidationReport.issues 转成 ImportRowIssue。""" + out: List[ImportRowIssue] = [] + for it in getattr(report, "issues", []): + sev = severity_for.get(it.code, Severity.ERROR) + out.append(ImportRowIssue( + row=it.row, severity=sev, code=it.code, message=it.message, + )) + return out + + +# 内核 validator 错误码 → 严重级别映射 +_SEVERITY_MAP: Dict[str, Severity] = { + "missing_column": Severity.ERROR, + "missing_field": Severity.ERROR, + "bad_unit": Severity.ERROR, + "bad_data_type": Severity.ERROR, + "bad_sample_rate": Severity.ERROR, + "bad_protocol": Severity.ERROR, + "dup_point": Severity.ERROR, +} + + +# --------------------------------------------------------------------------- +# 导入入口 +# --------------------------------------------------------------------------- + +def import_csv( + path: str, + template: TemplateKind = TemplateKind.TI, + extra_unit_check: bool = True, +) -> Tuple[PointDict, ImportReport]: + """导入并校验点位字典 CSV(配置台导入页面后端入口)。 + + Args: + path: CSV 文件路径(UTF-8,9 列表头); + template: 行业模板(决定合法量纲集合,resin/ti); + extra_unit_check: 是否按模板收窄量纲集合做额外校验。 + + Returns: + (PointDict, ImportReport):加载的点位模型 + 校验报告。 + 报告 ``ok`` 为 True 即可入库;WARN 不阻断。 + """ + report = ImportReport(template=template, file_path=path) + + # 1) 表头校验(先读表头行) + import csv as _csv + with open(path, "r", encoding="utf-8-sig") as fh: + reader = _csv.reader(fh) + rows = list(reader) + headers = [c.strip() for c in rows[0]] if rows else [] + report.issues.extend(_validate_header_order(headers)) + + # 2) 加载 + 内核校验(量纲/数据类型/采样率/重复点号/协议) + point_dict = load_point_dict_csv(path) + report.loaded_points = len(point_dict) + report.total_rows = len(point_dict.points) + + from point_dict.validator import validate_point_dict + kernel_report = validate_point_dict(point_dict, headers) + report.issues.extend(_convert_validator_issues(kernel_report, _SEVERITY_MAP)) + + # 3) 模板级量纲收窄(resin 才允许 rpm/mmol·g⁻¹) + if extra_unit_check: + allowed_units = _unit_set(template) + for p in point_dict.points: + if p.unit and p.unit not in allowed_units: + # 内核 validator 已按全集校验过;这里只补充模板级差异提示 + if p.unit in ("rpm", "mmol/g") and template == TemplateKind.TI: + report.issues.append(ImportRowIssue( + row=p.row_number, severity=Severity.ERROR, + code="template_unit_mismatch", + message=(f"第{p.row_number}行 量纲 '{p.unit}' 为树脂模板专属," + f"当前导入的是 {template.value} 模板"), + suggestion="切换模板为 resin,或修正量纲", + )) + + # 4) OPC 节点格式校验(配置台扩展) + for p in point_dict.points: + report.issues.extend(_validate_opc_node(p)) + + # 行号排序,便于配置台逐行展示 + report.issues.sort(key=lambda i: (i.row, i.code)) + return point_dict, report + + +def import_csv_string( + content: str, + template: TemplateKind = TemplateKind.TI, + encoding: str = "utf-8", +) -> Tuple[PointDict, ImportReport]: + """从 CSV 文本导入(配置台粘贴框场景,落临时文件后复用 import_csv)。""" + import tempfile + tmp = tempfile.NamedTemporaryFile( + mode="w", encoding=encoding, suffix=".csv", delete=False) + try: + tmp.write(content) + tmp.flush() + tmp.close() + return import_csv(tmp.name, template=template) + finally: + try: + os.unlink(tmp.name) + except OSError: + pass diff --git a/core/template-console/tests/test_point_importer.py b/core/template-console/tests/test_point_importer.py new file mode 100644 index 0000000..a2553f9 --- /dev/null +++ b/core/template-console/tests/test_point_importer.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +"""点位字典 CSV 导入 + 自动校验测试(issue #63)。 + +覆盖: +1. 合法 CSV 导入通过(ti / resin 两套模板); +2. 表头校验(缺失列 / 列序错位); +3. 内核校验复用(量纲/数据类型/采样率/重复点号/协议); +4. OPC 节点格式校验(opcua/modbus/空); +5. 模板级量纲收窄(rpm 仅 resin 允许); +6. 报告 ok/汇总/字典化 + 粘贴框入口。 +""" +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _bootstrap # noqa: F401 + +from template_console.point_importer import ( # noqa: E402 + ImportReport, + ImportRowIssue, + Severity, + TemplateKind, + import_csv, + import_csv_string, +) + +GOOD_TI = """device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol +CLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,ns=2;s=CLF.Temp,opcua +CLF-01,CLF-01.PRES,炉压,kPa,float,1000,true,ns=2;s=CLF.Pres,opcua +""" + +GOOD_RESIN = """device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol +R-801,R-801.TEMP,反应釜温度,℃,float,1000,true,ns=2;s=R801.Temp,opcua +R-801,R-801.AGIT,搅拌转速,rpm,float,1000,true,ns=2;s=R801.Agit,opcua +""" + +BAD_MULTI = """device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol +CLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,badnode,opcua +CLF-01,CLF-01.TEMP,炉压,kPa,badtype,0,true,ns=2;s=CLF.Pres,opcua +CLF-01,CLF-01.PRES,炉压,kPa,float,500,true,holding:40010,modbus +""" + + +class _TmpCsv: + """临时 CSV 文件助手。""" + + def __init__(self, content): + self._tmp = tempfile.mkdtemp() + self.path = os.path.join(self._tmp, "points.csv") + with open(self.path, "w", encoding="utf-8") as fh: + fh.write(content) + + def cleanup(self): + import shutil + shutil.rmtree(self._tmp, ignore_errors=True) + + +class GoodImportTest(unittest.TestCase): + """合法 CSV 导入。""" + + def test_good_ti_imports_ok(self): + f = _TmpCsv(GOOD_TI) + try: + pd, rep = import_csv(f.path, template=TemplateKind.TI) + self.assertTrue(rep.ok, rep.summary()) + self.assertEqual(rep.loaded_points, 2) + self.assertEqual(rep.error_count, 0) + finally: + f.cleanup() + + def test_good_resin_imports_ok_with_rpm(self): + f = _TmpCsv(GOOD_RESIN) + try: + pd, rep = import_csv(f.path, template=TemplateKind.RESIN) + self.assertTrue(rep.ok, rep.summary()) + # rpm 在 resin 模板合法 + self.assertEqual(rep.error_count, 0) + finally: + f.cleanup() + + def test_report_summary_and_dict(self): + f = _TmpCsv(GOOD_TI) + try: + _, rep = import_csv(f.path, template=TemplateKind.TI) + self.assertIn("通过", rep.summary()) + d = rep.to_dict() + self.assertTrue(d["ok"]) + self.assertEqual(d["template"], "ti") + self.assertEqual(d["loaded_points"], 2) + finally: + f.cleanup() + + +class HeaderValidationTest(unittest.TestCase): + """表头校验。""" + + def test_missing_column_is_error(self): + bad = "device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode\nCLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,ns=2;s=CLF.Temp\n" + f = _TmpCsv(bad) + try: + _, rep = import_csv(f.path, template=TemplateKind.TI) + self.assertFalse(rep.ok) + codes = [i.code for i in rep.issues if i.row == 1] + self.assertIn("missing_column", codes) + finally: + f.cleanup() + + def test_wrong_column_order_is_warn(self): + # 列齐全但顺序错(name 提前)→ WARN,不阻断 + bad = "device_id,point_id,name,unit,dataType,sampleRate,qualityCode,protocol,opcNode\nCLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,opcua,ns=2;s=CLF.Temp\n" + f = _TmpCsv(bad) + try: + _, rep = import_csv(f.path, template=TemplateKind.TI) + self.assertIn("bad_column_order", [i.code for i in rep.issues]) + finally: + f.cleanup() + + +class KernelValidationTest(unittest.TestCase): + """复用内核校验(量纲/数据类型/采样率/重复点号)。""" + + def test_dup_point_detected(self): + bad = GOOD_TI + "CLF-01,CLF-01.TEMP,炉温2,℃,float,1000,true,ns=2;s=CLF.Temp2,opcua\n" + f = _TmpCsv(bad) + try: + _, rep = import_csv(f.path, template=TemplateKind.TI) + self.assertFalse(rep.ok) + self.assertIn("dup_point", [i.code for i in rep.issues]) + finally: + f.cleanup() + + def test_bad_data_type_and_sample_rate(self): + f = _TmpCsv(BAD_MULTI) + try: + _, rep = import_csv(f.path, template=TemplateKind.TI) + codes = [i.code for i in rep.issues] + self.assertIn("bad_data_type", codes) + self.assertIn("bad_sample_rate", codes) + finally: + f.cleanup() + + def test_bad_protocol_detected(self): + bad = "device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol\nCLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,ns=2;s=CLF.Temp,unknownproto\n" + f = _TmpCsv(bad) + try: + _, rep = import_csv(f.path, template=TemplateKind.TI) + self.assertIn("bad_protocol", [i.code for i in rep.issues]) + finally: + f.cleanup() + + +class OpcNodeValidationTest(unittest.TestCase): + """OPC 节点格式校验(配置台扩展维度)。""" + + def test_bad_opcua_node_is_error(self): + # BAD_MULTI 第1行 opcNode=badnode 协议 opcua → ERROR + f = _TmpCsv(BAD_MULTI) + try: + _, rep = import_csv(f.path, template=TemplateKind.TI) + opc_issues = [i for i in rep.issues if i.code == "bad_opc_node"] + self.assertTrue(any(i.severity == Severity.ERROR for i in opc_issues)) + finally: + f.cleanup() + + def test_valid_modbus_node_ok(self): + # BAD_MULTI 第3行 holding:40010 modbus → 不报 bad_opc_node + f = _TmpCsv("device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol\nCLF-01,CLF-01.PRES,炉压,kPa,float,500,true,holding:40010,modbus\n") + try: + _, rep = import_csv(f.path, template=TemplateKind.TI) + self.assertNotIn("bad_opc_node", [i.code for i in rep.issues + if i.severity == Severity.ERROR]) + finally: + f.cleanup() + + def test_empty_opc_node_is_warn(self): + f = _TmpCsv("device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol\nCLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,,simulator\n") + try: + _, rep = import_csv(f.path, template=TemplateKind.TI) + empties = [i for i in rep.issues if i.code == "empty_opc_node"] + self.assertEqual(len(empties), 1) + self.assertEqual(empties[0].severity, Severity.WARN) + # 警告不阻断 + self.assertTrue(rep.ok) + finally: + f.cleanup() + + +class TemplateUnitTest(unittest.TestCase): + """模板级量纲收窄。""" + + def test_rpm_rejected_in_ti_template(self): + f = _TmpCsv(GOOD_RESIN) + try: + _, rep = import_csv(f.path, template=TemplateKind.TI) + # rpm 是树脂专属,ti 模板应报 template_unit_mismatch + self.assertIn("template_unit_mismatch", [i.code for i in rep.issues]) + self.assertFalse(rep.ok) + finally: + f.cleanup() + + def test_rpm_allowed_in_resin_template(self): + f = _TmpCsv(GOOD_RESIN) + try: + _, rep = import_csv(f.path, template=TemplateKind.RESIN) + self.assertNotIn("template_unit_mismatch", [i.code for i in rep.issues]) + self.assertTrue(rep.ok, rep.summary()) + finally: + f.cleanup() + + +class ImportStringTest(unittest.TestCase): + """粘贴框入口(import_csv_string)。""" + + def test_import_from_string(self): + pd, rep = import_csv_string(GOOD_TI, template=TemplateKind.TI) + self.assertTrue(rep.ok) + self.assertEqual(len(pd), 2) + + def test_import_string_bad_csv(self): + bad = "device_id,point_id\nCLF-01,CLF-01.TEMP\n" # 缺列 + _, rep = import_csv_string(bad, template=TemplateKind.TI) + self.assertFalse(rep.ok) + + +if __name__ == "__main__": + unittest.main() -- 2.54.0 From 94c2782e75dfc6bba13e1e028bf24e37465748ef Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 05:28:20 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat(#55):=20Ti=20=E8=A1=8C=E4=B8=9A?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E6=A8=A1=E6=9D=BF=EF=BC=88=E5=9B=9B=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=B5=81=E7=A8=8B=E8=A7=86=E5=9B=BE=EF=BC=89+=20layou?= =?UTF-8?q?t=5Fvalidator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯标准库实现,对齐 PRD 5.5「⑤ 配置化驾驶舱」iAOP-cockpit-layout-v1: - cockpit.ti.yaml:海绵钛四状态工艺流程(氯化→精制→还原→蒸馏), process_view 主视图声明 stages 覆盖,trend/kpi_card bind 对齐 point_dict.default.csv 的 point_id(CLF-01/RF-01/E-01/ST-01)。 - layout_validator.py:LayoutValidator 校验 widget 类型合法、12 列网格 不越界、bind point_id 在点位字典内、四状态覆盖完整(order 单调/id 唯一); 零依赖 YAML 子集解析(复制 impurity-forecast _parse_yaml_subset)。 - tests:18 用例覆盖真实资产端到端、非法类型、网格越界/负坐标/零宽、 bind 漂移、缺 process_view、缺必需状态、order 非单调、stage 重复、 $schema 头、CSV 加载、空布局。 - _sanity_check.py:冒烟验证 9 widgets 全校验通过。 --- templates/ti-cl4/dashboard/README.md | 37 ++ templates/ti-cl4/dashboard/__init__.py | 27 + templates/ti-cl4/dashboard/_sanity_check.py | 33 ++ templates/ti-cl4/dashboard/cockpit.ti.yaml | 108 ++++ .../ti-cl4/dashboard/layout_validator.py | 500 ++++++++++++++++++ .../ti-cl4/dashboard/tests/_bootstrap.py | 13 + .../dashboard/tests/test_layout_validator.py | 307 +++++++++++ 7 files changed, 1025 insertions(+) create mode 100644 templates/ti-cl4/dashboard/README.md create mode 100644 templates/ti-cl4/dashboard/__init__.py create mode 100644 templates/ti-cl4/dashboard/_sanity_check.py create mode 100644 templates/ti-cl4/dashboard/cockpit.ti.yaml create mode 100644 templates/ti-cl4/dashboard/layout_validator.py create mode 100644 templates/ti-cl4/dashboard/tests/_bootstrap.py create mode 100644 templates/ti-cl4/dashboard/tests/test_layout_validator.py diff --git a/templates/ti-cl4/dashboard/README.md b/templates/ti-cl4/dashboard/README.md new file mode 100644 index 0000000..20e9be0 --- /dev/null +++ b/templates/ti-cl4/dashboard/README.md @@ -0,0 +1,37 @@ +# 海绵钛驾驶舱布局资产 + 校验器(Issue #55 / PRD 5.5) + +> 父 Issue「⑤ Ti 行业布局模板(四状态流程视图)· 0.5d」 + +把海绵钛车间驾驶舱布局落为**对齐 iAOP-cockpit-layout-v1 的资产 + 可校验的纯标准库 +校验器**(无 node/前端构建环境,零运行时依赖)。 + +## 资产:`cockpit.ti.yaml` + +四状态工艺流程(PRD 4.2 海绵钛:**氯化 → 精制 → 还原 → 蒸馏**): + +- `process_view` 主视图(首屏立即加载),`stages` 声明四状态覆盖; +- `trend` 实时趋势(氯化炉温度 `CLF-01.TEMP` / 氯气流量 `CLF-01.CL2`); +- `kpi_card` KPI(TiCl₄纯度 `RF-01.PURITY` / 杂质 `RF-01.IMP` / 电耗 `E-01.KWH` / 蒸汽 `ST-01.STEAM`); +- `alarm_panel` 告警面板 + `nl_query` NL 查询入口。 + +所有 `bind` 的 `point_id` 对齐 `templates/ti-cl4/point-dict/point_dict.default.csv`。 + +## 校验器:`layout_validator.py` + +`LayoutValidator(layout_yaml, point_dict_csv).validate()` → `LayoutReport`,校验: + +1. **widget 类型合法**:在 `iAOP-cockpit-layout-v1` 允许集合内(process_view/trend/kpi_card/alarm_panel/nl_query); +2. **12 列网格不越界**:`0 ≤ x`、`x + w ≤ 12`、`y ≥ 0`、`w/h > 0`; +3. **bind point_id 在点位字典内**:防模板漂移(trend/kpi_card 的 bind 必须命中点字典); +4. **四状态覆盖完整**:process_view 的 stages 必须覆盖氯化/精制/还原/蒸馏,order 单调递增、id 唯一。 + +零依赖 YAML 子集解析(复制 impurity-forecast 的 `_parse_yaml_subset`,无 pyyaml)。 + +## 测试 + +```bash +python -m unittest discover -s templates/ti-cl4/dashboard/tests -p "test_*.py" -v +``` + +覆盖正常 + 边界 + 错误(18 用例):真实资产端到端通过、非法类型、网格越界/负坐标/零宽、 +bind 漂移、缺 process_view、缺必需状态、order 非单调、stage 重复、$schema 头、CSV 加载、空布局。 diff --git a/templates/ti-cl4/dashboard/__init__.py b/templates/ti-cl4/dashboard/__init__.py new file mode 100644 index 0000000..15ee51a --- /dev/null +++ b/templates/ti-cl4/dashboard/__init__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +"""海绵钛驾驶舱布局资产 + 校验器包(Issue #55)。 + +对齐 PRD 5.5「⑤ 配置化驾驶舱」布局 JSON Schema(iAOP-cockpit-layout-v1): +四状态工艺流程(氯化 → 精制 → 还原 → 蒸馏)。 +""" +from .layout_validator import ( + LayoutError, + LayoutIssue, + LayoutReport, + LayoutValidator, + WidgetSpec, + ALLOWED_WIDGET_TYPES, + GRID_COLUMNS, + REQUIRED_STAGES, +) + +__all__ = [ + "LayoutError", + "LayoutIssue", + "LayoutReport", + "LayoutValidator", + "WidgetSpec", + "ALLOWED_WIDGET_TYPES", + "GRID_COLUMNS", + "REQUIRED_STAGES", +] diff --git a/templates/ti-cl4/dashboard/_sanity_check.py b/templates/ti-cl4/dashboard/_sanity_check.py new file mode 100644 index 0000000..a7bf846 --- /dev/null +++ b/templates/ti-cl4/dashboard/_sanity_check.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +"""海绵钛驾驶舱布局冒烟脚本(Issue #55)。 + +直接运行 ``python _sanity_check.py`` 验证:cockpit.ti.yaml + point_dict.default.csv +端到端校验通过(widget 类型/网格/bind/四状态全覆盖)。零第三方依赖。 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +from layout_validator import LayoutValidator # noqa: E402 + +LAYOUT_YAML = os.path.join(HERE, "cockpit.ti.yaml") +POINT_DICT_CSV = os.path.join( + HERE, os.pardir, "point-dict", "point_dict.default.csv") + + +def main() -> int: + report = LayoutValidator(LAYOUT_YAML, POINT_DICT_CSV).validate() + if not report.passed: + print("FAIL") + for issue in report.errors: + print(f" - [{issue.widget_id}] {issue.field}: {issue.reason}") + return 1 + print(f"OK: {report.widget_count} widgets," + f"类型/网格/bind/四状态校验通过") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/ti-cl4/dashboard/cockpit.ti.yaml b/templates/ti-cl4/dashboard/cockpit.ti.yaml new file mode 100644 index 0000000..adcf963 --- /dev/null +++ b/templates/ti-cl4/dashboard/cockpit.ti.yaml @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# 海绵钛(Ti)车间驾驶舱布局资产(iAOP-Template-Ti,EPIC #12)。 +# +# 对齐 PRD 5.5「⑤ 配置化驾驶舱」布局 JSON Schema(iAOP-cockpit-layout-v1): +# 切换行业模板后,驾驶舱按本布局自动重排,无需改前端代码。 +# widget 类型:process_view(工艺流程视图)/ trend(实时趋势)/ kpi_card(KPI卡片)/ +# alarm_panel(告警面板)/ nl_query(NL查询入口)。 +# +# 四状态工艺流程(PRD 4.2 海绵钛:氯化 → 精制 → 还原 → 蒸馏): +# - 氯化 (CLF-01):TiO₂ + Cl₂ + C → TiCl₄(沸腾氯化炉) +# - 精制 :粗 TiCl₄ → 精 TiCl₄(除钒/除硅,常压精馏) +# - 还原 (RF-01):TiCl₄ + Mg → 海绵钛(真空还原,Kroll 法) +# - 蒸馏 :海绵钛 + 残余 Mg/MgCl₂ 分离(真空蒸馏) +# process_view 的 stages 字段声明四状态覆盖,layout_validator 校验完整性。 +# +# bind 的 point_id 对齐 templates/ti-cl4/point-dict/point_dict.default.csv。 +$schema: iAOP-cockpit-layout-v1 +title: 海绵钛车间驾驶舱 +theme: dark +widgets: + # ---- 四状态工艺流程主视图(首屏立即加载) --------------------------- + - type: process_view + src: ti_four_state.svg + x: 0 + y: 0 + w: 12 + h: 4 + description: 四状态工艺流程(氯化 → 精制 → 还原 → 蒸馏) + stages: + - id: chlorination + name: 氯化 + device: CLF-01 + order: 1 + - id: purification + name: 精制 + order: 2 + - id: reduction + name: 还原 + device: RF-01 + order: 3 + - id: distillation + name: 蒸馏 + order: 4 + # ---- 实时趋势:氯化炉温度/氯气流量(工艺核心监控) ------------------- + - type: trend + bind: CLF-01.TEMP + x: 0 + y: 4 + w: 6 + h: 2 + description: 氯化炉温度实时趋势(沸腾氯化炉温 850±50℃) + - type: trend + bind: CLF-01.CL2 + x: 6 + y: 4 + w: 6 + h: 2 + description: 氯气流量实时趋势(流态化监控) + # ---- KPI 卡片:还原质量/能耗(海绵钛核心指标) ----------------------- + - type: kpi_card + metric: ticl4_purity + bind: RF-01.PURITY + label: TiCl₄纯度 + x: 0 + y: 6 + w: 3 + h: 2 + description: 还原 TiCl₄ 纯度(%,工艺 ≥ 99.9%) + - type: kpi_card + metric: ticl4_impurity + bind: RF-01.IMP + label: 杂质含量 + x: 3 + y: 6 + w: 3 + h: 2 + description: 还原杂质含量(%,越低越好) + - type: kpi_card + metric: energy_per_ton + bind: E-01.KWH + label: 累计电耗 + x: 6 + y: 6 + w: 3 + h: 2 + description: 车间累计电耗(kWh,单吨海绵钛综合能耗输入) + - type: kpi_card + metric: steam_flow + bind: ST-01.STEAM + label: 蒸汽流量 + x: 9 + y: 6 + w: 3 + h: 2 + description: 蒸汽流量(t/h,公用工程监控) + # ---- 告警面板 + NL 查询入口 ------------------------------------------ + - type: alarm_panel + x: 0 + y: 8 + w: 9 + h: 3 + description: 告警面板(氯化炉温/还原真空度/纯度/杂质异常) + - type: nl_query + x: 9 + y: 8 + w: 3 + h: 3 + description: 自然语言查询入口(工艺/质量/能耗问答) diff --git a/templates/ti-cl4/dashboard/layout_validator.py b/templates/ti-cl4/dashboard/layout_validator.py new file mode 100644 index 0000000..a48a86c --- /dev/null +++ b/templates/ti-cl4/dashboard/layout_validator.py @@ -0,0 +1,500 @@ +# -*- coding: utf-8 -*- +"""海绵钛驾驶舱布局校验器(Issue #55 / PRD 5.5「⑤ 配置化驾驶舱」)。 + +PRD 5.5:切换行业模板后,驾驶舱按布局资产自动重排,无需改前端代码。 +本模块把布局资产(``cockpit.ti.yaml``)落为**可校验的纯标准库资产 + 校验器**—— +给定布局 YAML + 点位字典 CSV,校验: + +1. **widget 类型合法**:在 PRD 5.5 ``iAOP-cockpit-layout-v1`` 允许集合内 + (process_view/trend/kpi_card/alarm_panel/nl_query)。 +2. **12 列网格不越界**:每个 widget ``0 ≤ x`` 且 ``x + w ≤ 12``,``y ≥ 0``、 + ``h > 0``;坐标为非负整数,w/h 正整数(网格对齐)。 +3. **bind 的 point_id 在点位字典内**:trend/kpi_card 的 ``bind`` 必须命中 + ``point_dict.default.csv`` 的 ``point_id`` 列(防模板漂移)。 +4. **四状态视图覆盖完整**:process_view 的 ``stages`` 必须覆盖工艺全流程 + (氯化/精制/还原/蒸馏),且 order 单调递增、id 唯一。 + +校验产出 :class:`LayoutReport`(PASS/FAIL + 逐条 :class:`LayoutIssue`, +每条 issue 带 ``reason`` 可解释)。 + +设计要点 +-------- +- **零依赖 YAML 子集解析**:复制 impurity-forecast features.py 的 + ``_parse_yaml_subset``(无 pyyaml),支持 map/list/标量/行内 flow map。 +- **纯标准库**:CSV 用标准库 csv,无 numpy/pyyaml 依赖。 +- **换行业只改资产**:校验器对任何对齐 ``iAOP-cockpit-layout-v1`` 的布局都适用。 + +用法:: + + report = LayoutValidator(layout_yaml, point_dict_csv).validate() + if not report.passed: + for issue in report.issues: + print(issue.severity, issue.widget_id, issue.reason) +""" +from __future__ import annotations + +import csv +import os +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Tuple + +#: iAOP-cockpit-layout-v1 允许的 widget 类型集合(对齐 resin _sanity_check)。 +ALLOWED_WIDGET_TYPES = frozenset({ + "process_view", "trend", "kpi_card", "alarm_panel", "nl_query", +}) + +#: 12 列网格(主流前端栅格标准,对齐 cockpit layout v1)。 +GRID_COLUMNS = 12 + +#: 海绵钛四状态工艺流程(PRD 4.2:氯化 → 精制 → 还原 → 蒸馏)。 +#: process_view 的 stages 必须覆盖这四个 id。 +REQUIRED_STAGES = ("chlorination", "purification", "reduction", "distillation") + + +class LayoutError(ValueError): + """布局资产解析/声明错误(YAML 格式错、表头缺字段等)。""" + + +class Severity(str, Enum): + """问题严重度。""" + + ERROR = "error" # 阻断:布局不可用(类型非法/越界/bind 缺失/状态缺失) + WARN = "warn" # 告警:可运行但不规范(重复/顺序乱) + + +@dataclass +class LayoutIssue: + """单条布局校验问题(含 reason 可解释)。""" + + severity: Severity + reason: str + widget_id: str = "" # 关联 widget(index 或 src/metric) + field: str = "" # 关联字段(type/x/bind/stages ...) + + @property + def is_error(self) -> bool: + return self.severity is Severity.ERROR + + +@dataclass +class LayoutReport: + """布局校验报告。""" + + issues: List[LayoutIssue] = field(default_factory=list) + widget_count: int = 0 + + @property + def errors(self) -> List[LayoutIssue]: + return [i for i in self.issues if i.is_error] + + @property + def passed(self) -> bool: + """通过 = 无 ERROR(WARN 不阻断)。""" + return not any(i.is_error for i in self.issues) + + def to_dict(self) -> dict: + return { + "passed": self.passed, + "widget_count": self.widget_count, + "error_count": len(self.errors), + "warn_count": len(self.issues) - len(self.errors), + "issues": [ + {"severity": i.severity.value, "widget_id": i.widget_id, + "field": i.field, "reason": i.reason} + for i in self.issues + ], + } + + +@dataclass +class WidgetSpec: + """单个 widget 的内存模型(从 YAML 解析)。""" + + index: int # 在 widgets 列表中的位置(0 起) + type: str + x: int = 0 + y: int = 0 + w: int = 1 + h: int = 1 + bind: str = "" # trend/kpi_card 绑定的 point_id + src: str = "" # process_view 的 SVG + metric: str = "" # kpi_card 的 metric + label: str = "" + description: str = "" + stages: List[Dict[str, object]] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# 零依赖 YAML 子集解析(复制自 impurity-forecast features.py,对齐 data-bus) +# --------------------------------------------------------------------------- + +def _parse_scalar(text: str) -> str: + """去掉标量两侧引号与行内注释。""" + t = text.split(" #", 1)[0].strip() + if len(t) >= 2 and t[0] == t[-1] and t[0] in ("'", '"'): + return t[1:-1] + return t + + +def _parse_flow_value(text: str): + """解析 ``key: value`` 右侧值,支持行内 flow map ``{k: v, k: v}``。""" + t = text.split(" #", 1)[0].strip() + if t.startswith("{") and t.endswith("}"): + inner = t[1:-1].strip() + out: Dict[str, object] = {} + if not inner: + return out + for part in inner.split(","): + if ":" not in part: + raise LayoutError(f"flow map 项不是键值对:{part!r}") + k, _, v = part.partition(":") + out[k.strip()] = _parse_scalar(v) + return out + return _parse_scalar(text) + + +def _strip_comments(lines: List[str]) -> List[Tuple[str, int]]: + out: List[Tuple[str, int]] = [] + for i, ln in enumerate(lines): + s = ln.strip() + if not s or s.startswith("#"): + continue + out.append((ln, i + 1)) + return out + + +def _parse_node(lines: List[Tuple[str, int]], i: int, indent: int): + """递归解析 YAML 节点(map / list / scalar)。返回 (value, next_i)。""" + text, _ = lines[i] + # ---- list 节点 ---- + if text.lstrip(" ").startswith("- "): + items: List[object] = [] + while i < len(lines): + t, no = lines[i] + stripped = t.lstrip(" ") + if not stripped.startswith("- "): + break + lead_j = len(t) - len(t.lstrip(" ")) + if lead_j != indent: + break + item_text = stripped[2:].strip() + if not item_text: + raise LayoutError(f"cockpit.yaml 第 {no} 行:list 项为空") + if ":" in item_text: + map_indent = len(t) - len(t.lstrip(" ")) + 2 + lines[i] = (" " * map_indent + item_text, no) + v, i = _parse_node(lines, i, map_indent) + items.append(v) + else: + items.append(_parse_flow_value(item_text)) + i += 1 + return items, i + # ---- map 节点 ---- + result: Dict[str, object] = {} + while i < len(lines): + t, no = lines[i] + lead_j = len(t) - len(t.lstrip(" ")) + if lead_j < indent or t.lstrip(" ").startswith("- "): + break + if lead_j > indent: + raise LayoutError( + f"cockpit.yaml 第 {no} 行缩进异常(期望 {indent},实际 {lead_j})") + if ":" not in t: + raise LayoutError(f"cockpit.yaml 第 {no} 行不是合法键值对:{t!r}") + key, _, rest = t.partition(":") + key = key.strip() + rest = rest.strip() + if rest: + result[key] = _parse_flow_value(rest) + i += 1 + continue + if i + 1 >= len(lines): + raise LayoutError(f"cockpit.yaml 第 {no} 行 {key!r} 缺少值") + sub_indent = len(lines[i + 1][0]) - len(lines[i + 1][0].lstrip(" ")) + if sub_indent <= indent: + raise LayoutError(f"cockpit.yaml 第 {no} 行 {key!r} 缺少值(无嵌套)") + v, i = _parse_node(lines, i + 1, sub_indent) + result[key] = v + return result, i + + +def _load_yaml_text(text: str) -> Dict[str, object]: + """解析 YAML 文本为 dict(顶层必须是 map)。""" + lines = _strip_comments(text.splitlines()) + if not lines: + return {} + top_indent = len(lines[0][0]) - len(lines[0][0].lstrip(" ")) + value, next_i = _parse_node(lines, 0, top_indent) + if not isinstance(value, dict): + raise LayoutError("cockpit.yaml 顶层必须是 map") + if next_i < len(lines): + raise LayoutError( + f"cockpit.yaml 第 {lines[next_i][1]} 行:顶层存在多个节点") + return value + + +# --------------------------------------------------------------------------- +# 点位字典加载(CSV → point_id 集合) +# --------------------------------------------------------------------------- + +def load_point_ids(csv_path: str) -> List[str]: + """从点位字典 CSV 加载全部 point_id(保序,对齐 CSV point_id 列)。 + + CSV 表头对齐 core/edge-gateway point_dict schema(第二列 point_id)。 + """ + if not os.path.isfile(csv_path): + raise LayoutError(f"点位字典 CSV 不存在:{csv_path}") + with open(csv_path, "r", encoding="utf-8") as fh: + rows = list(csv.reader(fh)) + if not rows: + raise LayoutError(f"点位字典 CSV 为空:{csv_path}") + header = [c.strip() for c in rows[0]] + if "point_id" not in header: + raise LayoutError( + f"点位字典 CSV 表头缺 point_id 列:{header}") + col = header.index("point_id") + ids: List[str] = [] + for i, row in enumerate(rows[1:], 2): + if len(row) <= col: + continue + pid = row[col].strip() + if pid: + ids.append(pid) + if not ids: + raise LayoutError(f"点位字典 CSV 无 point_id 数据行:{csv_path}") + return ids + + +# --------------------------------------------------------------------------- +# 校验器 +# --------------------------------------------------------------------------- + +class LayoutValidator: + """海绵钛驾驶舱布局校验器。 + + Args: + layout_yaml_path: 布局资产路径(cockpit.ti.yaml)。 + point_dict_csv_path: 点位字典 CSV 路径(point_dict.default.csv)。 + grid_columns: 网格列数(默认 12,对齐 cockpit layout v1)。 + required_stages: process_view 必须覆盖的 stage id(默认海绵钛四状态)。 + """ + + def __init__( + self, + layout_yaml_path: str, + point_dict_csv_path: Optional[str] = None, + grid_columns: int = GRID_COLUMNS, + required_stages: Tuple[str, ...] = REQUIRED_STAGES, + ) -> None: + if grid_columns <= 0: + raise LayoutError(f"grid_columns 必须 > 0,实际 {grid_columns}") + self.layout_path = layout_yaml_path + self.point_dict_path = point_dict_csv_path + self.grid_columns = int(grid_columns) + self.required_stages = tuple(required_stages) + + # ------------------------------------------------------------------ + def validate(self) -> LayoutReport: + """执行全部校验,返回报告。""" + report = LayoutReport() + # 1) 解析布局 YAML + try: + with open(self.layout_path, "r", encoding="utf-8") as fh: + data = _load_yaml_text(fh.read()) + except LayoutError: + raise + except OSError as exc: + raise LayoutError(f"布局 YAML 读取失败:{self.layout_path} ({exc})") from exc + + # schema 头校验 + schema = str(data.get("$schema", "")).strip() + if schema != "iAOP-cockpit-layout-v1": + report.issues.append(LayoutIssue( + severity=Severity.ERROR, + field="$schema", + reason=f"$schema 应为 'iAOP-cockpit-layout-v1',实际 {schema!r}", + )) + + # 2) 解析 widgets + raw_widgets = data.get("widgets") or [] + if not isinstance(raw_widgets, list): + report.issues.append(LayoutIssue( + severity=Severity.ERROR, field="widgets", + reason=f"widgets 必须是 list,实际 {type(raw_widgets).__name__}")) + return report + widgets = self._parse_widgets(raw_widgets, report) + report.widget_count = len(widgets) + + if not widgets: + report.issues.append(LayoutIssue( + severity=Severity.ERROR, field="widgets", + reason="布局无任何 widget")) + return report + + # 3) 加载点位字典(bind 校验需要) + point_ids: Optional[set] = None + if self.point_dict_path: + try: + point_ids = set(load_point_ids(self.point_dict_path)) + except LayoutError as exc: + report.issues.append(LayoutIssue( + severity=Severity.ERROR, field="point_dict", + reason=str(exc))) + + # 4) 逐 widget 校验 + for w in widgets: + self._check_widget(w, point_ids, report) + + # 5) process_view 四状态覆盖 + self._check_process_views(widgets, report) + + return report + + # ------------------------------------------------------------------ + def _parse_widgets(self, raw_widgets: List[object], + report: LayoutReport) -> List[WidgetSpec]: + widgets: List[WidgetSpec] = [] + for idx, item in enumerate(raw_widgets): + if not isinstance(item, dict): + report.issues.append(LayoutIssue( + severity=Severity.ERROR, widget_id=f"[{idx}]", + field="widgets", + reason=f"widgets[{idx}] 必须是 map,实际 {type(item).__name__}")) + continue + wtype = str(item.get("type", "")).strip() + widgets.append(WidgetSpec( + index=idx, + type=wtype, + x=_to_int(item.get("x"), 0), + y=_to_int(item.get("y"), 0), + w=_to_int(item.get("w"), 1), + h=_to_int(item.get("h"), 1), + bind=str(item.get("bind", "")).strip(), + src=str(item.get("src", "")).strip(), + metric=str(item.get("metric", "")).strip(), + label=str(item.get("label", "")).strip(), + description=str(item.get("description", "")).strip(), + stages=_as_list_of_dict(item.get("stages")), + )) + return widgets + + # ------------------------------------------------------------------ + def _check_widget(self, w: WidgetSpec, point_ids: Optional[set], + report: LayoutReport) -> None: + wid = f"[{w.index}]({w.type})" + # 4a) widget 类型合法 + if w.type not in ALLOWED_WIDGET_TYPES: + report.issues.append(LayoutIssue( + severity=Severity.ERROR, widget_id=wid, field="type", + reason=f"非法 widget 类型 {w.type!r}(允许 {sorted(ALLOWED_WIDGET_TYPES)})")) + + # 4b) 12 列网格不越界(坐标非负整数、x+w ≤ columns、h>0) + if w.x < 0 or w.y < 0: + report.issues.append(LayoutIssue( + severity=Severity.ERROR, widget_id=wid, field="grid", + reason=f"坐标不能为负:x={w.x} y={w.y}")) + if w.w <= 0 or w.h <= 0: + report.issues.append(LayoutIssue( + severity=Severity.ERROR, widget_id=wid, field="grid", + reason=f"w/h 必须为正整数:w={w.w} h={w.h}")) + if w.x + w.w > self.grid_columns: + report.issues.append(LayoutIssue( + severity=Severity.ERROR, widget_id=wid, field="grid", + reason=f"越出 {self.grid_columns} 列网格:x={w.x}+w={w.w}" + f"={w.x + w.w} > {self.grid_columns}")) + + # 4c) bind 的 point_id 必须在点位字典内(trend/kpi_card) + if w.bind: + if point_ids is not None and w.bind not in point_ids: + report.issues.append(LayoutIssue( + severity=Severity.ERROR, widget_id=wid, field="bind", + reason=f"bind point_id {w.bind!r} 不在点位字典内" + f"(防模板漂移,对齐 point_dict.default.csv)")) + + # ------------------------------------------------------------------ + def _check_process_views(self, widgets: List[WidgetSpec], + report: LayoutReport) -> None: + """校验 process_view 的四状态覆盖完整。""" + pv = [w for w in widgets if w.type == "process_view"] + if not pv: + report.issues.append(LayoutIssue( + severity=Severity.ERROR, field="process_view", + reason="布局缺少 process_view(四状态工艺流程主视图必需)")) + return + + covered: Dict[str, WidgetSpec] = {} # stage_id → widget + for w in pv: + wid = f"[{w.index}](process_view)" + if not w.stages: + report.issues.append(LayoutIssue( + severity=Severity.ERROR, widget_id=wid, field="stages", + reason="process_view 缺少 stages 声明(四状态覆盖必需)")) + continue + + stage_ids: List[str] = [] + orders: List[int] = [] + seen: set = set() + for st in w.stages: + sid = str(st.get("id", "")).strip() + sname = str(st.get("name", "")).strip() + if not sid: + report.issues.append(LayoutIssue( + severity=Severity.ERROR, widget_id=wid, field="stages", + reason=f"stage 缺少 id(name={sname!r})")) + continue + if sid in seen: + report.issues.append(LayoutIssue( + severity=Severity.WARN, widget_id=wid, field="stages", + reason=f"stage id 重复:{sid!r}")) + continue + seen.add(sid) + stage_ids.append(sid) + covered.setdefault(sid, w) + order = st.get("order") + if order is not None: + try: + orders.append(int(order)) + except (TypeError, ValueError): + report.issues.append(LayoutIssue( + severity=Severity.ERROR, widget_id=wid, field="stages", + reason=f"stage {sid!r} order 非整数:{order!r}")) + + # order 单调递增校验 + if orders and len(orders) == len(stage_ids): + if orders != sorted(orders): + report.issues.append(LayoutIssue( + severity=Severity.ERROR, widget_id=wid, field="stages", + reason=f"stage order 非单调递增:{orders}")) + + # 必需四状态全覆盖 + missing = [s for s in self.required_stages if s not in covered] + if missing: + report.issues.append(LayoutIssue( + severity=Severity.ERROR, field="stages", + reason=f"process_view stages 未覆盖必需四状态:{missing}" + f"(氯化/精制/还原/蒸馏)")) + + +# --------------------------------------------------------------------------- +# 辅助 +# --------------------------------------------------------------------------- + +def _to_int(value: object, default: int) -> int: + """把 YAML 解析出的值(可能是 str/int)转为 int;失败返回 default。""" + if value is None or value == "": + return default + try: + return int(value) + except (TypeError, ValueError): + raise LayoutError(f"坐标值不是整数:{value!r}") + + +def _as_list_of_dict(value: object) -> List[Dict[str, object]]: + if not isinstance(value, list): + return [] + out: List[Dict[str, object]] = [] + for item in value: + if isinstance(item, dict): + out.append(item) + return out diff --git a/templates/ti-cl4/dashboard/tests/_bootstrap.py b/templates/ti-cl4/dashboard/tests/_bootstrap.py new file mode 100644 index 0000000..8de360c --- /dev/null +++ b/templates/ti-cl4/dashboard/tests/_bootstrap.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +"""测试引导:把 dashboard 测试根目录加入 sys.path,使 layout_validator 可导入。 + +dashboard 目录名是合法 Python 标识符,直接作为包导入;本引导把父目录 +(templates/ti-cl4/dashboard)挂到 sys.path,使 ``from layout_validator import ...`` +在 unittest 发现机制下可解析(与 core 模块测试引导同款)。 +""" +import os +import sys + +PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if PKG_DIR not in sys.path: + sys.path.insert(0, PKG_DIR) diff --git a/templates/ti-cl4/dashboard/tests/test_layout_validator.py b/templates/ti-cl4/dashboard/tests/test_layout_validator.py new file mode 100644 index 0000000..920daca --- /dev/null +++ b/templates/ti-cl4/dashboard/tests/test_layout_validator.py @@ -0,0 +1,307 @@ +# -*- coding: utf-8 -*- +"""海绵钛驾驶舱布局校验器测试(Issue #55)。 + +覆盖: +1. 真实 cockpit.ti.yaml + point_dict.default.csv 全部通过(端到端); +2. widget 类型合法集合(非法类型 → ERROR); +3. 12 列网格校验(越界/负坐标/非正 w/h); +4. bind point_id 在点位字典内(漂移 → ERROR); +5. process_view 四状态覆盖(缺 stage id / order 非单调 / 缺必需状态); +6. $schema 头校验; +7. YAML 解析(flow map / 嵌套); +8. 点位字典 CSV 加载(缺表头/空文件); +9. 空布局 / 边界。 +""" +import os +import unittest + +import _bootstrap # noqa: F401 (sys.path 挂载) + +from layout_validator import ( + GRID_COLUMNS, + LayoutError, + LayoutValidator, + REQUIRED_STAGES, + Severity, + load_point_ids, +) + +HERE = os.path.dirname(os.path.abspath(__file__)) +DASHBOARD_DIR = os.path.dirname(HERE) +LAYOUT_YAML = os.path.join(DASHBOARD_DIR, "cockpit.ti.yaml") +POINT_DICT_CSV = os.path.join( + DASHBOARD_DIR, os.pardir, "point-dict", "point_dict.default.csv") + + +def _write_layout(tmp_path: str, content: str) -> str: + """把布局内容写到临时文件,返回路径。""" + path = os.path.join(tmp_path, "cockpit.test.yaml") + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + return path + + +def _write_point_dict(tmp_path: str, ids: list) -> str: + """写一个最小点位字典 CSV(仅 point_id 列)。""" + path = os.path.join(tmp_path, "points.csv") + with open(path, "w", encoding="utf-8") as fh: + fh.write("device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol\n") + for i, pid in enumerate(ids): + fh.write(f"D{i},{pid},n,u,float,1000,true,n,simulator\n") + return path + + +# 最小合法布局模板(便于构造各类变形) +_VALID_LAYOUT = """\ +$schema: iAOP-cockpit-layout-v1 +title: 测试驾驶舱 +theme: dark +widgets: + - type: process_view + src: ti_four_state.svg + x: 0 + y: 0 + w: 12 + h: 4 + description: 四状态工艺流程 + stages: + - id: chlorination + name: 氯化 + order: 1 + - id: purification + name: 精制 + order: 2 + - id: reduction + name: 还原 + order: 3 + - id: distillation + name: 蒸馏 + order: 4 + - type: trend + bind: CLF-01.TEMP + x: 0 + y: 4 + w: 6 + h: 2 + description: 氯化炉温度 +""" + + +class TestEndToEndRealAssets(unittest.TestCase): + """真实 cockpit.ti.yaml + point_dict.default.csv 端到端校验。""" + + def test_real_layout_passes(self): + report = LayoutValidator(LAYOUT_YAML, POINT_DICT_CSV).validate() + if not report.passed: + for issue in report.errors: + print("ERROR:", issue.widget_id, issue.field, issue.reason) + self.assertTrue(report.passed, "真实布局应通过全部校验") + self.assertGreater(report.widget_count, 0) + + def test_real_layout_has_process_view_with_four_stages(self): + report = LayoutValidator(LAYOUT_YAML, POINT_DICT_CSV).validate() + # 无 stages 相关 ERROR + stage_errors = [i for i in report.errors if i.field == "stages"] + self.assertEqual(stage_errors, []) + + +class TestWidgetType(unittest.TestCase): + """widget 类型合法性。""" + + def test_invalid_widget_type_error(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + layout = _VALID_LAYOUT.replace("type: trend", "type: radar_chart") + path = _write_layout(td, layout) + report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() + errors = [i for i in report.errors if i.field == "type"] + self.assertEqual(len(errors), 1) + self.assertIn("非法 widget 类型", errors[0].reason) + + +class TestGridBounds(unittest.TestCase): + """12 列网格校验。""" + + def test_x_plus_w_exceeds_columns(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + # trend x=10 w=6 → 16 > 12 + layout = _VALID_LAYOUT.replace( + " bind: CLF-01.TEMP\n x: 0\n y: 4\n w: 6\n h: 2", + " bind: CLF-01.TEMP\n x: 10\n y: 4\n w: 6\n h: 2") + path = _write_layout(td, layout) + report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() + grid_errors = [i for i in report.errors if i.field == "grid" + and "越出" in i.reason] + self.assertEqual(len(grid_errors), 1) + + def test_negative_x_rejected(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + layout = _VALID_LAYOUT.replace(" x: 0\n y: 4\n w: 6\n h: 2", + " x: -1\n y: 4\n w: 6\n h: 2") + path = _write_layout(td, layout) + report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() + neg = [i for i in report.errors if "坐标不能为负" in i.reason] + self.assertEqual(len(neg), 1) + + def test_zero_width_rejected(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + layout = _VALID_LAYOUT.replace(" x: 0\n y: 4\n w: 6\n h: 2", + " x: 0\n y: 4\n w: 0\n h: 2") + path = _write_layout(td, layout) + report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() + wh = [i for i in report.errors if "w/h 必须为正整数" in i.reason] + self.assertEqual(len(wh), 1) + + +class TestBindPointId(unittest.TestCase): + """bind point_id 在点位字典内。""" + + def test_bind_not_in_dict_error(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + path = _write_layout(td, _VALID_LAYOUT) + # 点位字典不含 CLF-01.TEMP + csv_path = _write_point_dict(td, ["OTHER-01.X"]) + report = LayoutValidator(path, csv_path).validate() + bind_err = [i for i in report.errors if i.field == "bind"] + self.assertEqual(len(bind_err), 1) + self.assertIn("不在点位字典内", bind_err[0].reason) + + def test_bind_in_dict_passes(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + path = _write_layout(td, _VALID_LAYOUT) + csv_path = _write_point_dict(td, ["CLF-01.TEMP"]) + report = LayoutValidator(path, csv_path).validate() + bind_err = [i for i in report.errors if i.field == "bind"] + self.assertEqual(bind_err, []) + + +class TestProcessViewStages(unittest.TestCase): + """process_view 四状态覆盖。""" + + def test_missing_process_view_error(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + # 删除 process_view 块(保留 trend) + layout = """\ +$schema: iAOP-cockpit-layout-v1 +title: t +widgets: + - type: trend + bind: CLF-01.TEMP + x: 0 + y: 0 + w: 6 + h: 2 +""" + path = _write_layout(td, layout) + report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() + pv_err = [i for i in report.errors if i.field == "process_view"] + self.assertEqual(len(pv_err), 1) + + def test_missing_required_stage_error(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + # 删除 distillation stage + layout = _VALID_LAYOUT.replace( + " - id: distillation\n name: 蒸馏\n order: 4\n", "") + path = _write_layout(td, layout) + report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() + missing = [i for i in report.errors if "未覆盖必需四状态" in i.reason] + self.assertEqual(len(missing), 1) + self.assertIn("distillation", missing[0].reason) + + def test_non_monotonic_order_error(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + # 把 reduction order 改为 5(> distillation 的 4)→ 非单调 + layout = _VALID_LAYOUT.replace(" - id: reduction\n name: 还原\n order: 3", + " - id: reduction\n name: 还原\n order: 5") + path = _write_layout(td, layout) + report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() + order_err = [i for i in report.errors if "order 非单调递增" in i.reason] + self.assertEqual(len(order_err), 1) + + def test_duplicate_stage_id_warn(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + # 重复 chlorination(覆盖必需状态校验仍过,但 WARN 重复) + layout = _VALID_LAYOUT + """\ +""" + # 构造一个有重复 stage 的 process_view(替换 stages 块) + dup_layout = _VALID_LAYOUT.replace( + " - id: distillation\n name: 蒸馏\n order: 4", + " - id: distillation\n name: 蒸馏\n order: 4\n" + " - id: chlorination\n name: 氯化2\n order: 5") + path = _write_layout(td, dup_layout) + report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() + dup = [i for i in report.issues if "stage id 重复" in i.reason] + self.assertEqual(len(dup), 1) + + +class TestSchemaHeader(unittest.TestCase): + """$schema 头校验。""" + + def test_wrong_schema_error(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + layout = _VALID_LAYOUT.replace("iAOP-cockpit-layout-v1", "some-other-schema") + path = _write_layout(td, layout) + report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() + schema_err = [i for i in report.errors if i.field == "$schema"] + self.assertEqual(len(schema_err), 1) + + +class TestPointDictLoader(unittest.TestCase): + """点位字典 CSV 加载。""" + + def test_load_point_ids(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + csv_path = _write_point_dict(td, ["A.X", "B.Y"]) + ids = load_point_ids(csv_path) + self.assertEqual(ids, ["A.X", "B.Y"]) + + def test_missing_csv_raises(self): + with self.assertRaises(LayoutError): + load_point_ids("/nonexistent/points.csv") + + def test_csv_missing_point_id_column_raises(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + path = os.path.join(td, "bad.csv") + with open(path, "w", encoding="utf-8") as fh: + fh.write("device_id,name\nD1,n\n") + with self.assertRaises(LayoutError): + load_point_ids(path) + + +class TestReportExport(unittest.TestCase): + """报告序列化 + 边界。""" + + def test_empty_layout_error(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + layout = """\ +$schema: iAOP-cockpit-layout-v1 +title: t +widgets: [] +""" + path = _write_layout(td, layout) + report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() + self.assertFalse(report.passed) + + def test_report_to_dict(self): + report = LayoutValidator(LAYOUT_YAML, POINT_DICT_CSV).validate() + d = report.to_dict() + self.assertEqual(d["passed"], True) + self.assertIn("widget_count", d) + self.assertEqual(d["error_count"], 0) + + +if __name__ == "__main__": + unittest.main() -- 2.54.0 From b0ae3781506f7c6891672075ba6e0215c13477d0 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 05:29:53 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat(#64):=20=E9=85=8D=E7=BD=AE=E9=A1=B9=20?= =?UTF-8?q?CRUD=20=E5=AD=98=E5=82=A8=E5=BC=95=E6=93=8E=EF=BC=88=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=B6=85=E5=8F=82/RAG/=E5=B8=83=E5=B1=80=E4=B8=89?= =?UTF-8?q?=E7=B1=BB=EF=BC=8C=E6=96=87=E4=BB=B6=E7=B3=BB=E7=BB=9F=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8C=96=20JSON+=E6=A0=A1=E9=AA=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/template-console/config_store.py | 291 ++++++++++++++++++ .../tests/test_config_store.py | 191 ++++++++++++ 2 files changed, 482 insertions(+) create mode 100644 core/template-console/config_store.py create mode 100644 core/template-console/tests/test_config_store.py diff --git a/core/template-console/config_store.py b/core/template-console/config_store.py new file mode 100644 index 0000000..de513af --- /dev/null +++ b/core/template-console/config_store.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- +"""⑤.7 配置项 CRUD 存储引擎 —— issue #64 / PRD ⑤.7。 + +配置台要管理三类业务配置:**模型超参 / RAG / 布局**。这些配置是模板交付物的 +"活"部分——实施工程师按现场调参,每次改动都要**可解释、可校验、可版本化** +(为 #66 发布/回滚提供快照源)。本模块提供基于文件系统的版本化 JSON 存储: + +- 三类配置各对应一个 JSON 文件(``model_params.json`` / ``rag_configs.json`` / + ``layout.json``),存放在一个 store 根目录下; +- 每条配置项是一个 ``ConfigItem``(key + value + 含义 + 校验规则); +- 提供 ``list / get / upsert / delete`` CRUD,所有写操作都先**校验**再落盘, + 并记录 ``updated_by`` / ``reason``(对齐 PRD「可解释可溯源」); +- 校验规则按类别内置(模型超参的范围/类型、RAG 的来源数、布局的 widget 类型), + 非法值在 upsert 阶段即被拒绝,避免坏数据进入版本快照。 + +存储格式(每类一个 JSON,内容为 ``{items: [ConfigItem, ...], schema_version}``) +刻意简单、人可读,便于实施工程师直接查看/备份。 + +零运行时依赖:仅用 json / dataclass / Enum / 标准库。 +""" +from __future__ import annotations + +import json +import os +import re +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple + + +# --------------------------------------------------------------------------- +# 配置类别 +# --------------------------------------------------------------------------- + +class ConfigKind(str, Enum): + """三类业务配置(对齐 #64 需求)。""" + + MODEL_PARAM = "model_param" # 模型超参(学习率/迭代数/特征开关…) + RAG_CONFIG = "rag_config" # RAG 知识库配置(top_k/相似度阈值/来源…) + LAYOUT = "layout" # 驾驶舱布局(widget 列表) + + +#: 各类别对应的存储文件名 +KIND_FILENAME: Dict[ConfigKind, str] = { + ConfigKind.MODEL_PARAM: "model_params.json", + ConfigKind.RAG_CONFIG: "rag_configs.json", + ConfigKind.LAYOUT: "layout.json", +} + +#: 存储结构版本(schema 演进时升级,发布快照会带上) +STORE_SCHEMA_VERSION = 1 + +#: 驾驶舱布局允许的 widget 类型(对齐 iAOP-cockpit-layout-v1 / resin cockpit) +ALLOWED_WIDGET_TYPES = {"process_view", "trend", "kpi_card", "alarm_panel", "nl_query"} + + +# --------------------------------------------------------------------------- +# 配置项数据模型 +# --------------------------------------------------------------------------- + +@dataclass +class ConfigItem: + """一条配置项(可解释:带含义、更新人、原因)。""" + + key: str # 配置键(类别内唯一,如 learning_rate) + value: Any # 配置值(标量或结构化) + kind: ConfigKind # 所属类别 + meaning: str = "" # 业务含义(供配置台展示与审计) + updated_by: str = "system" # 最后修改人(对接 RBAC 用户名) + reason: str = "" # 本次修改原因(可解释可溯源) + updated_at: str = "" # ISO8601 时间戳 + + def to_dict(self) -> dict: + d = asdict(self) + d["kind"] = self.kind.value # 枚举序列化为字符串 + return d + + @classmethod + def from_dict(cls, raw: dict) -> "ConfigItem": + return cls( + key=raw["key"], + value=raw.get("value"), + kind=ConfigKind(raw.get("kind")), + meaning=raw.get("meaning", ""), + updated_by=raw.get("updated_by", "system"), + reason=raw.get("reason", ""), + updated_at=raw.get("updated_at", ""), + ) + + +def _now_iso() -> str: + """当前 UTC 时间 ISO8601(无时区歧义)。""" + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# --------------------------------------------------------------------------- +# 校验(按类别内置规则) +# --------------------------------------------------------------------------- + +@dataclass +class ValidationResult: + """配置项校验结果。""" + + ok: bool + errors: List[str] = field(default_factory=list) + + def __bool__(self) -> bool: + return self.ok + + +def validate_item(kind: ConfigKind, key: str, value: Any) -> ValidationResult: + """按类别校验配置项的 key/value 合法性。 + + 校验规则(配置台 upsert 前置门禁,防止坏数据进快照): + - 通用:key 非空、匹配 ``[a-z0-9_.-]+``; + - model_param:value 为标量(int/float/bool/str)或标量列表; + - rag_config:top_k 为 1~50 的正整数、similarity_threshold 为 0~1 浮点、 + sources 为非空字符串列表; + - layout:value 为 widget 列表,每个 widget 有合法 type 与 x/y/w/h。 + """ + errors: List[str] = [] + if not key or not isinstance(key, str): + errors.append("key 不能为空") + elif not re.match(r"^[a-z0-9_.\-]+$", key): + errors.append(f"key '{key}' 仅允许小写字母/数字/._-") + + if kind == ConfigKind.MODEL_PARAM: + if not isinstance(value, (int, float, bool, str, list)): + errors.append("model_param 的 value 必须为标量或标量列表") + elif isinstance(value, list) and any( + not isinstance(v, (int, float, bool, str)) for v in value): + errors.append("model_param 列表 value 仅允许标量元素") + # 常见超参范围提示(软约束,仅对已知键) + if key == "learning_rate" and isinstance(value, (int, float)): + if not (0 < value < 1): + errors.append("learning_rate 应在 (0, 1) 区间") + if key == "iterations" and isinstance(value, int): + if value <= 0: + errors.append("iterations 必须为正整数") + + elif kind == ConfigKind.RAG_CONFIG: + if key == "top_k": + if not (isinstance(value, int) and 1 <= value <= 50): + errors.append("top_k 必须为 1~50 的整数") + elif key == "similarity_threshold": + if not (isinstance(value, (int, float)) and 0 <= value <= 1): + errors.append("similarity_threshold 必须为 0~1 的数") + elif key == "sources": + if not (isinstance(value, list) and value + and all(isinstance(s, str) and s for s in value)): + errors.append("sources 必须为非空字符串列表") + + elif kind == ConfigKind.LAYOUT: + if not isinstance(value, list): + errors.append("layout 的 value 必须为 widget 列表") + else: + for i, w in enumerate(value): + if not isinstance(w, dict): + errors.append(f"widget[{i}] 必须为对象") + continue + wt = w.get("type") + if wt not in ALLOWED_WIDGET_TYPES: + errors.append( + f"widget[{i}] 非法 type '{wt}'(合法:{sorted(ALLOWED_WIDGET_TYPES)})") + for coord in ("x", "y", "w", "h"): + if not isinstance(w.get(coord), int) or w.get(coord) < 0: + errors.append(f"widget[{i}] {coord} 必须为非负整数") + + return ValidationResult(ok=not errors, errors=errors) + + +# --------------------------------------------------------------------------- +# 存储引擎 +# --------------------------------------------------------------------------- + +class ConfigStore: + """基于文件系统的版本化配置存储(三类配置各一 JSON)。 + + 用法: + store = ConfigStore("/path/to/store") + store.upsert(ConfigKind.MODEL_PARAM, "learning_rate", 0.001, + meaning="学习率", updated_by="li", reason="首次标定") + items = store.list(ConfigKind.MODEL_PARAM) + """ + + def __init__(self, root: str) -> None: + self.root = root + os.makedirs(root, exist_ok=True) + + # -- 路径 -- + def _path(self, kind: ConfigKind) -> str: + return os.path.join(self.root, KIND_FILENAME[kind]) + + def _read(self, kind: ConfigKind) -> List[ConfigItem]: + path = self._path(kind) + if not os.path.isfile(path): + return [] + with open(path, "r", encoding="utf-8") as fh: + blob = json.load(fh) + return [ConfigItem.from_dict(r) for r in blob.get("items", [])] + + def _write(self, kind: ConfigKind, items: List[ConfigItem]) -> None: + blob = { + "schema_version": STORE_SCHEMA_VERSION, + "kind": kind.value, + "items": [it.to_dict() for it in items], + } + path = self._path(kind) + # 先写临时文件再替换,避免写一半被读到(原子写) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(blob, fh, ensure_ascii=False, indent=2) + os.replace(tmp, path) + + # -- 查询 -- + def list(self, kind: ConfigKind) -> List[ConfigItem]: + """列出某类全部配置项。""" + return self._read(kind) + + def get(self, kind: ConfigKind, key: str) -> Optional[ConfigItem]: + """取单条配置项(不存在返回 None)。""" + for it in self._read(kind): + if it.key == key: + return it + return None + + # -- 写 -- + def upsert( + self, + kind: ConfigKind, + key: str, + value: Any, + meaning: str = "", + updated_by: str = "system", + reason: str = "", + ) -> ConfigItem: + """新增或更新一条配置项(先校验,再落盘)。 + + Raises: + ValueError: 校验失败(带全部错误明细)。 + """ + vr = validate_item(kind, key, value) + if not vr: + raise ValueError(f"配置项校验失败 [{kind.value}:{key}]:{'; '.join(vr.errors)}") + items = self._read(kind) + now = _now_iso() + existing_idx = next((i for i, it in enumerate(items) if it.key == key), None) + item = ConfigItem( + key=key, value=value, kind=kind, meaning=meaning, + updated_by=updated_by, reason=reason, updated_at=now, + ) + if existing_idx is None: + items.append(item) + else: + items[existing_idx] = item + self._write(kind, items) + return item + + def delete(self, kind: ConfigKind, key: str) -> bool: + """删除一条配置项。返回是否实际删除。""" + items = self._read(kind) + new_items = [it for it in items if it.key != key] + if len(new_items) == len(items): + return False + self._write(kind, new_items) + return True + + # -- 快照(供 #66 release 使用) -- + def snapshot(self) -> Dict[str, Any]: + """全量配置快照(三类聚合,供发布版本固化)。""" + return { + "schema_version": STORE_SCHEMA_VERSION, + "captured_at": _now_iso(), + "kinds": { + kind.value: [it.to_dict() for it in self._read(kind)] + for kind in ConfigKind + }, + } + + def restore(self, snapshot: Dict[str, Any]) -> None: + """从快照恢复全部配置(#66 回滚入口)。""" + kinds = snapshot.get("kinds", {}) + for kind in ConfigKind: + raw_items = kinds.get(kind.value, []) + items = [ConfigItem.from_dict(r) for r in raw_items] + self._write(kind, items) + + def item_counts(self) -> Dict[str, int]: + """各类配置项数量(配置台仪表盘用)。""" + return {kind.value: len(self._read(kind)) for kind in ConfigKind} diff --git a/core/template-console/tests/test_config_store.py b/core/template-console/tests/test_config_store.py new file mode 100644 index 0000000..77790f7 --- /dev/null +++ b/core/template-console/tests/test_config_store.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +"""配置项 CRUD 存储引擎测试(issue #64)。 + +覆盖: +1. 三类配置 CRUD(list/get/upsert/delete); +2. 原子写 + 持久化(重开 store 仍在); +3. 校验规则(model_param/rag_config/layout,非法值拒绝); +4. 快照 snapshot/restore(为 #66 提供基础); +5. 可解释字段(meaning/reason/updated_by/updated_at 落盘)。 +""" +import json +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _bootstrap # noqa: F401 + +from template_console.config_store import ( # noqa: E402 + ALLOWED_WIDGET_TYPES, + ConfigItem, + ConfigKind, + ConfigStore, + ValidationResult, + validate_item, +) + + +class _TmpStore: + def __init__(self): + self._tmp = tempfile.mkdtemp() + self.store = ConfigStore(self._tmp) + + def cleanup(self): + import shutil + shutil.rmtree(self._tmp, ignore_errors=True) + + +class ValidationTest(unittest.TestCase): + """校验规则。""" + + def test_model_param_scalar_ok(self): + self.assertTrue(validate_item(ConfigKind.MODEL_PARAM, "learning_rate", 0.001)) + + def test_model_param_learning_rate_range(self): + vr = validate_item(ConfigKind.MODEL_PARAM, "learning_rate", 1.5) + self.assertFalse(vr) + self.assertTrue(any("learning_rate" in e for e in vr.errors)) + + def test_model_param_bad_key(self): + vr = validate_item(ConfigKind.MODEL_PARAM, "Bad Key!", 1) + self.assertFalse(vr) + + def test_rag_top_k_bounds(self): + self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "top_k", 0)) + self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "top_k", 51)) + self.assertTrue(validate_item(ConfigKind.RAG_CONFIG, "top_k", 10)) + + def test_rag_similarity_threshold(self): + self.assertTrue(validate_item(ConfigKind.RAG_CONFIG, "similarity_threshold", 0.5)) + self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "similarity_threshold", 1.5)) + + def test_rag_sources_must_be_nonempty_list(self): + self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "sources", [])) + self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "sources", ["", "x"])) + self.assertTrue(validate_item(ConfigKind.RAG_CONFIG, "sources", ["sop", "gb"])) + + def test_layout_widget_type(self): + bad = [{"type": "unknown", "x": 0, "y": 0, "w": 1, "h": 1}] + self.assertFalse(validate_item(ConfigKind.LAYOUT, "dashboard", bad)) + good = [{"type": "trend", "x": 0, "y": 0, "w": 6, "h": 2}] + self.assertTrue(validate_item(ConfigKind.LAYOUT, "dashboard", good)) + + def test_layout_widget_coords_nonneg_int(self): + bad = [{"type": "trend", "x": -1, "y": 0, "w": 1, "h": 1}] + vr = validate_item(ConfigKind.LAYOUT, "dashboard", bad) + self.assertFalse(vr) + + +class CrudTest(unittest.TestCase): + """CRUD + 持久化。""" + + def setUp(self): + self.ctx = _TmpStore() + self.store = self.ctx.store + + def tearDown(self): + self.ctx.cleanup() + + def test_upsert_and_get(self): + self.store.upsert(ConfigKind.MODEL_PARAM, "iterations", 100, + meaning="迭代数", updated_by="li", reason="标定") + it = self.store.get(ConfigKind.MODEL_PARAM, "iterations") + self.assertIsNotNone(it) + self.assertEqual(it.value, 100) + self.assertEqual(it.updated_by, "li") + self.assertEqual(it.reason, "标定") + self.assertTrue(it.updated_at) # 时间戳已写 + + def test_upsert_rejects_invalid(self): + with self.assertRaises(ValueError): + self.store.upsert(ConfigKind.RAG_CONFIG, "top_k", 999) + + def test_upsert_overwrites(self): + self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.1) + self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.01, reason="调小") + it = self.store.get(ConfigKind.MODEL_PARAM, "lr") + self.assertEqual(it.value, 0.01) + self.assertEqual(it.reason, "调小") + + def test_list_and_delete(self): + self.store.upsert(ConfigKind.RAG_CONFIG, "top_k", 5) + self.store.upsert(ConfigKind.RAG_CONFIG, "similarity_threshold", 0.6) + self.assertEqual(len(self.store.list(ConfigKind.RAG_CONFIG)), 2) + self.assertTrue(self.store.delete(ConfigKind.RAG_CONFIG, "top_k")) + self.assertIsNone(self.store.get(ConfigKind.RAG_CONFIG, "top_k")) + self.assertFalse(self.store.delete(ConfigKind.RAG_CONFIG, "nope")) + + def test_persistence_across_reopen(self): + self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + # 重开一个指向同一目录的 store + store2 = ConfigStore(self.ctx._tmp) + it = store2.get(ConfigKind.MODEL_PARAM, "lr") + self.assertIsNotNone(it) + self.assertEqual(it.value, 0.001) + + def test_json_file_is_human_readable(self): + self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001, meaning="学习率") + path = os.path.join(self.ctx._tmp, "model_params.json") + with open(path, encoding="utf-8") as fh: + blob = json.load(fh) + self.assertEqual(blob["schema_version"], 1) + self.assertEqual(blob["kind"], "model_param") + self.assertEqual(blob["items"][0]["meaning"], "学习率") + + +class SnapshotTest(unittest.TestCase): + """快照与恢复(#66 基础)。""" + + def setUp(self): + self.ctx = _TmpStore() + self.store = self.ctx.store + + def tearDown(self): + self.ctx.cleanup() + + def test_snapshot_captures_all_kinds(self): + self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + self.store.upsert(ConfigKind.RAG_CONFIG, "top_k", 8) + snap = self.store.snapshot() + self.assertIn("captured_at", snap) + self.assertEqual(set(snap["kinds"].keys()), + {"model_param", "rag_config", "layout"}) + self.assertEqual(len(snap["kinds"]["model_param"]), 1) + + def test_restore_replicates_state(self): + self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + self.store.upsert(ConfigKind.RAG_CONFIG, "top_k", 8) + snap = self.store.snapshot() + # 清空再恢复 + self.store.delete(ConfigKind.MODEL_PARAM, "lr") + self.store.delete(ConfigKind.RAG_CONFIG, "top_k") + self.store.restore(snap) + self.assertEqual(self.store.get(ConfigKind.MODEL_PARAM, "lr").value, 0.001) + self.assertEqual(self.store.get(ConfigKind.RAG_CONFIG, "top_k").value, 8) + + def test_item_counts(self): + self.store.upsert(ConfigKind.LAYOUT, "dashboard", + [{"type": "trend", "x": 0, "y": 0, "w": 6, "h": 2}]) + counts = self.store.item_counts() + self.assertEqual(counts["layout"], 1) + self.assertEqual(counts["model_param"], 0) + + +class ConfigItemSerializationTest(unittest.TestCase): + """ConfigItem 序列化往返。""" + + def test_roundtrip(self): + it = ConfigItem(key="lr", value=0.1, kind=ConfigKind.MODEL_PARAM, + meaning="学习率", updated_by="li", reason="init", + updated_at="2026-01-01T00:00:00Z") + d = it.to_dict() + self.assertEqual(d["kind"], "model_param") + it2 = ConfigItem.from_dict(d) + self.assertEqual(it2.value, 0.1) + self.assertEqual(it2.kind, ConfigKind.MODEL_PARAM) + + +if __name__ == "__main__": + unittest.main() -- 2.54.0 From c5a5b8468c21f582e31d73d5770fd5e9538a1c6d Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 05:30:26 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix(#55):=20=E4=BF=AE=E5=A4=8D=20layout=5Fv?= =?UTF-8?q?alidator=20=E6=B5=8B=E8=AF=95=E5=BC=95=E5=AF=BC=20sys.path=20?= =?UTF-8?q?=E6=8C=82=E8=BD=BD=EF=BC=88=E4=B8=8E=20core=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E4=B8=80=E8=87=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/ti-cl4/dashboard/tests/test_layout_validator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/templates/ti-cl4/dashboard/tests/test_layout_validator.py b/templates/ti-cl4/dashboard/tests/test_layout_validator.py index 920daca..c9c56f7 100644 --- a/templates/ti-cl4/dashboard/tests/test_layout_validator.py +++ b/templates/ti-cl4/dashboard/tests/test_layout_validator.py @@ -13,8 +13,10 @@ 9. 空布局 / 边界。 """ import os +import sys import unittest +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _bootstrap # noqa: F401 (sys.path 挂载) from layout_validator import ( -- 2.54.0 From 0660a4b256ee9cf604f6754d356910750a94e525 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 05:31:36 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat(#65):=20=E9=85=8D=E7=BD=AE=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E6=B8=B2=E6=9F=93=E5=BC=95=E6=93=8E=EF=BC=88=E5=B8=83?= =?UTF-8?q?=E5=B1=80/=E5=91=8A=E8=AD=A6/NL=E6=9F=A5=E8=AF=A2=EF=BC=8C?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20cockpit-layout-v1=20widget=20=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/template-console/config_store.py | 11 +- core/template-console/preview.py | 329 ++++++++++++++++++++ core/template-console/tests/test_preview.py | 188 +++++++++++ 3 files changed, 523 insertions(+), 5 deletions(-) create mode 100644 core/template-console/preview.py create mode 100644 core/template-console/tests/test_preview.py diff --git a/core/template-console/config_store.py b/core/template-console/config_store.py index de513af..e00d3f5 100644 --- a/core/template-console/config_store.py +++ b/core/template-console/config_store.py @@ -114,7 +114,8 @@ def validate_item(kind: ConfigKind, key: str, value: Any) -> ValidationResult: 校验规则(配置台 upsert 前置门禁,防止坏数据进快照): - 通用:key 非空、匹配 ``[a-z0-9_.-]+``; - - model_param:value 为标量(int/float/bool/str)或标量列表; + - model_param:value 为标量(int/float/bool/str)、标量列表,或结构化 dict + (如 optimizer 配置 / 告警规则等复合超参); - rag_config:top_k 为 1~50 的正整数、similarity_threshold 为 0~1 浮点、 sources 为非空字符串列表; - layout:value 为 widget 列表,每个 widget 有合法 type 与 x/y/w/h。 @@ -126,11 +127,11 @@ def validate_item(kind: ConfigKind, key: str, value: Any) -> ValidationResult: errors.append(f"key '{key}' 仅允许小写字母/数字/._-") if kind == ConfigKind.MODEL_PARAM: - if not isinstance(value, (int, float, bool, str, list)): - errors.append("model_param 的 value 必须为标量或标量列表") + if not isinstance(value, (int, float, bool, str, list, dict)): + errors.append("model_param 的 value 必须为标量/标量列表/结构化对象") elif isinstance(value, list) and any( - not isinstance(v, (int, float, bool, str)) for v in value): - errors.append("model_param 列表 value 仅允许标量元素") + not isinstance(v, (int, float, bool, str, dict)) for v in value): + errors.append("model_param 列表 value 仅允许标量或对象元素") # 常见超参范围提示(软约束,仅对已知键) if key == "learning_rate" and isinstance(value, (int, float)): if not (0 < value < 1): diff --git a/core/template-console/preview.py b/core/template-console/preview.py new file mode 100644 index 0000000..e92df8c --- /dev/null +++ b/core/template-console/preview.py @@ -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) diff --git a/core/template-console/tests/test_preview.py b/core/template-console/tests/test_preview.py new file mode 100644 index 0000000..dd4f9a5 --- /dev/null +++ b/core/template-console/tests/test_preview.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- +"""配置预览渲染引擎测试(issue #65)。 + +覆盖: +1. 布局预览(widget 卡片 + 网格占用率 + 越界/重叠检测); +2. 告警预览(规则渲染 + 模拟触发评估); +3. NL 查询预览(模板 + 示例问句生成); +4. 从 ConfigStore 一键预览(含空配置的宽容处理); +5. PreviewResult 的 ok/to_dict。 +""" +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _bootstrap # noqa: F401 + +from template_console.config_store import ConfigKind, ConfigStore # noqa: E402 +from template_console.preview import ( # noqa: E402 + AlarmRule, + GRID_COLUMNS, + NLQueryTemplate, + PreviewKind, + PreviewResult, + preview_from_store, + render_alarm_preview, + render_layout_preview, + render_nl_query_preview, +) + + +class LayoutPreviewTest(unittest.TestCase): + """布局预览。""" + + def test_basic_render(self): + widgets = [ + {"type": "process_view", "src": "x.svg", "x": 0, "y": 0, "w": 12, "h": 4, + "description": "工艺流程"}, + {"type": "trend", "bind": "R-801.TEMP", "x": 0, "y": 4, "w": 6, "h": 2}, + ] + r = render_layout_preview(widgets) + self.assertEqual(len(r.items), 2) + self.assertEqual(r.items[0]["type"], "process_view") + self.assertEqual(r.items[1]["bind"], "R-801.TEMP") + self.assertTrue(r.ok) # 无越界/重叠 + self.assertTrue(any("网格占用率" in n for n in r.notes)) + + def test_overflow_warning(self): + # x+w 超过 12 列 + widgets = [{"type": "trend", "bind": "p", "x": 8, "y": 0, "w": 6, "h": 2}] + r = render_layout_preview(widgets) + self.assertFalse(r.ok) + self.assertTrue(any("越界" in w for w in r.warnings)) + + def test_overlap_warning(self): + widgets = [ + {"type": "trend", "bind": "a", "x": 0, "y": 0, "w": 6, "h": 2}, + {"type": "kpi_card", "metric": "m", "label": "L", "x": 3, "y": 0, "w": 6, "h": 2}, + ] + r = render_layout_preview(widgets) + self.assertFalse(r.ok) + self.assertTrue(any("重叠" in w for w in r.warnings)) + + def test_bad_widget_type(self): + widgets = [{"type": "unknown", "x": 0, "y": 0, "w": 1, "h": 1}] + r = render_layout_preview(widgets) + self.assertFalse(r.ok) + self.assertTrue(any("非法类型" in w for w in r.warnings)) + + def test_kpi_card_carries_metric_and_label(self): + widgets = [{"type": "kpi_card", "metric": "yield", "label": "产率", + "x": 0, "y": 0, "w": 3, "h": 2}] + r = render_layout_preview(widgets) + self.assertEqual(r.items[0]["metric"], "yield") + self.assertEqual(r.items[0]["label"], "产率") + + def test_empty_widgets(self): + r = render_layout_preview([]) + self.assertEqual(r.items, []) + self.assertTrue(r.ok) + + +class AlarmPreviewTest(unittest.TestCase): + """告警预览。""" + + def test_render_rules(self): + rules = [AlarmRule("R-801.TEMP", "反应釜温度", ">", 120.0, "critical")] + r = render_alarm_preview(rules) + self.assertEqual(len(r.items), 1) + self.assertIn("critical", r.items[0]["text"]) + self.assertTrue(r.ok) + + def test_evaluate_triggered(self): + rules = [AlarmRule("R-801.TEMP", "温度", ">", 120.0, "critical")] + r = render_alarm_preview(rules, current_values={"R-801.TEMP": 130.0}) + self.assertTrue(r.items[0]["triggered"]) + self.assertEqual(r.items[0]["state"], "触发") + self.assertTrue(any("1/1" in n for n in r.notes)) + + def test_evaluate_not_triggered(self): + rules = [AlarmRule("P1", "温度", ">", 120.0, "warn")] + r = render_alarm_preview(rules, current_values={"P1": 100.0}) + self.assertFalse(r.items[0]["triggered"]) + self.assertEqual(r.items[0]["state"], "正常") + + def test_operators(self): + for op, val, thr in [(">=", 120, 120), ("<", 50, 100), ("<=", 100, 100), ("==", 5, 5)]: + rule = AlarmRule("P", "m", op, thr, "warn") + self.assertTrue(rule.evaluate(val), f"{op} {val} {thr} 应触发") + + def test_bad_level_and_operator(self): + rules = [AlarmRule("P", "m", "~", 1.0, level="boom")] + r = render_alarm_preview(rules) + self.assertFalse(r.ok) + self.assertTrue(any("非法级别" in w for w in r.warnings)) + self.assertTrue(any("非法运算符" in w for w in r.warnings)) + + +class NLQueryPreviewTest(unittest.TestCase): + """NL 查询预览。""" + + def test_render_with_examples(self): + t = NLQueryTemplate( + name="batch_query", + question_template="最近一批的{metric}是多少?", + slots={"metric": ["产率", "能耗"]}, + data_source="tdengine", + answer_hint="返回当批聚合值", + ) + r = render_nl_query_preview([t]) + self.assertEqual(len(r.items), 1) + self.assertEqual(len(r.items[0]["examples"]), 2) + self.assertIn("产率", r.items[0]["examples"][0]) + self.assertEqual(r.items[0]["data_source"], "tdengine") + + def test_empty_question_warns(self): + t = NLQueryTemplate(name="x", question_template="", slots={}) + r = render_nl_query_preview([t]) + self.assertFalse(r.ok) + + def test_no_slots_returns_template(self): + t = NLQueryTemplate(name="x", question_template="整体能耗?", slots={}) + self.assertEqual(t.render_examples(), ["整体能耗?"]) + + +class StorePreviewTest(unittest.TestCase): + """从 ConfigStore 一键预览。""" + + def setUp(self): + self._tmp = tempfile.mkdtemp() + self.store = ConfigStore(self._tmp) + + def tearDown(self): + import shutil + shutil.rmtree(self._tmp, ignore_errors=True) + + def test_layout_preview_from_store(self): + self.store.upsert(ConfigKind.LAYOUT, "dashboard", + [{"type": "trend", "bind": "p", "x": 0, "y": 0, "w": 6, "h": 2}]) + r = preview_from_store(self.store, PreviewKind.LAYOUT) + self.assertEqual(len(r.items), 1) + self.assertEqual(r.items[0]["bind"], "p") + + def test_empty_layout_is_graceful(self): + r = preview_from_store(self.store, PreviewKind.LAYOUT) + self.assertEqual(r.items, []) + self.assertTrue(any("未配置" in n for n in r.notes)) + + def test_alarm_preview_from_store(self): + self.store.upsert(ConfigKind.MODEL_PARAM, "alarm_temp", + {"point_id": "R-801.TEMP", "metric": "温度", + "operator": ">", "threshold": 120, "level": "critical"}) + r = preview_from_store(self.store, PreviewKind.ALARM, + current_values={"R-801.TEMP": 130}) + self.assertTrue(r.items[0]["triggered"]) + + def test_nl_query_preview_from_store(self): + self.store.upsert(ConfigKind.RAG_CONFIG, "nl_batch", + {"name": "批次查询", "question_template": "{m}多少?", + "slots": {"m": ["产率"]}, "data_source": "tdengine"}) + r = preview_from_store(self.store, PreviewKind.NL_QUERY) + self.assertEqual(len(r.items), 1) + self.assertEqual(r.items[0]["name"], "批次查询") + + +if __name__ == "__main__": + unittest.main() -- 2.54.0 From 263404895a09356c6452b0831cd6e5c2d63c32f3 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 05:32:23 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat(#66):=20=E7=89=88=E6=9C=AC=E5=8F=91?= =?UTF-8?q?=E5=B8=83+=E5=9B=9E=E6=BB=9A=E7=82=B9=EF=BC=88=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=20config=5Fstore=20=E5=BF=AB=E7=85=A7=EF=BC=8Csemver?= =?UTF-8?q?=20=E5=8D=95=E8=B0=83=E9=80=92=E5=A2=9E=EF=BC=8C=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A=E5=8F=AF=E8=BF=BD=E6=BA=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/template-console/release.py | 238 ++++++++++++++++++++ core/template-console/tests/test_release.py | 193 ++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 core/template-console/release.py create mode 100644 core/template-console/tests/test_release.py diff --git a/core/template-console/release.py b/core/template-console/release.py new file mode 100644 index 0000000..8f5fd6f --- /dev/null +++ b/core/template-console/release.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +"""⑤.7 版本发布 + 回滚点 —— issue #66 / PRD ⑤.7。 + +配置台的每次发布都应是一个**可回滚的版本**:实施工程师改了配置 → 预览确认 → +管理员发布;发布即固化当时全量配置快照为一个带 semver 的 Release;若线上出问题, +一键回滚到上一个版本,把 ``ConfigStore`` 恢复成那份快照。这样配置变更"可追溯、 +可逆转",对齐 PRD「版本化发布与回滚点」。 + +本模块提供: + +- ``Release`` 数据类(semver 版本号 / 时间戳 / 快照 / 发布人 / 变更说明); +- ``ReleaseManager``:list / publish / rollback; + - ``publish``:固化 ConfigStore 快照为新版本,semver 单调递增校验 + (新版本必须严格大于当前最新版),拒绝重复发布空快照; + - ``rollback``:把 ConfigStore 恢复为指定历史版本的快照,并记一条"回滚事件" + (不删除任何历史版本——回滚本身也是一次可追溯的变更); +- semver 校验(``MAJOR.MINOR.PATCH``,单调递增)。 + +发布记录持久化为 ``releases.json``(与 ConfigStore 同根目录),人可读、可备份。 + +零运行时依赖:仅用 json / dataclass / 标准库。 +""" +from __future__ import annotations + +import json +import os +import re +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from .config_store import ConfigStore + + +# --------------------------------------------------------------------------- +# semver +# --------------------------------------------------------------------------- + +_SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +RELEASES_FILENAME = "releases.json" +RELEASES_SCHEMA_VERSION = 1 + + +def is_valid_semver(version: str) -> bool: + """是否合法 semver(MAJOR.MINOR.PATCH,无预发布后缀)。""" + return bool(_SEMVER_RE.match(version)) + + +def semver_tuple(version: str) -> Tuple[int, int, int]: + """semver → (major, minor, patch) 元组(用于比较)。""" + m = _SEMVER_RE.match(version) + if not m: + raise ValueError(f"非法 semver:{version}") + return tuple(int(x) for x in m.groups()) # type: ignore[return-value] + + +def semver_gt(a: str, b: str) -> bool: + """a 是否严格大于 b。""" + return semver_tuple(a) > semver_tuple(b) + + +def bump_patch(version: str) -> str: + """patch 位 +1(默认递增策略,发布时若用户未指定版本号则用此)。""" + major, minor, patch = semver_tuple(version) + return f"{major}.{minor}.{patch + 1}" + + +# --------------------------------------------------------------------------- +# Release 数据模型 +# --------------------------------------------------------------------------- + +@dataclass +class Release: + """一次发布版本(可解释:含发布人、变更说明、来源)。""" + + version: str # semver,如 1.2.0 + created_at: str # ISO8601 发布时间 + snapshot: Dict[str, Any] # 全量配置快照(ConfigStore.snapshot()) + released_by: str = "system" # 发布人(对接 RBAC 用户名) + changelog: str = "" # 变更说明(本次发布改了什么、为什么) + reason: str = "" # 发布理由(可解释可溯源) + + def to_dict(self) -> dict: + return asdict(self) + + @classmethod + def from_dict(cls, raw: dict) -> "Release": + return cls( + version=raw["version"], + created_at=raw.get("created_at", ""), + snapshot=raw.get("snapshot", {}), + released_by=raw.get("released_by", "system"), + changelog=raw.get("changelog", ""), + reason=raw.get("reason", ""), + ) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# --------------------------------------------------------------------------- +# 发布管理器 +# --------------------------------------------------------------------------- + +class ReleaseManager: + """版本发布 + 回滚管理器。 + + 用法: + store = ConfigStore("/path/to/store") + rm = ReleaseManager(store) # releases 落在 store 同目录 + rel = rm.publish("1.0.0", released_by="admin", changelog="首次发布") + rm.rollback("0.9.9", released_by="admin") # 回滚到 0.9.9 的快照 + """ + + def __init__(self, store: ConfigStore, releases_path: Optional[str] = None) -> None: + self.store = store + self.releases_path = releases_path or os.path.join(store.root, RELEASES_FILENAME) + + # -- 持久化 -- + def _read_all(self) -> List[Release]: + if not os.path.isfile(self.releases_path): + return [] + with open(self.releases_path, "r", encoding="utf-8") as fh: + blob = json.load(fh) + return [Release.from_dict(r) for r in blob.get("releases", [])] + + def _write_all(self, releases: List[Release]) -> None: + blob = { + "schema_version": RELEASES_SCHEMA_VERSION, + "releases": [r.to_dict() for r in releases], + } + tmp = self.releases_path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(blob, fh, ensure_ascii=False, indent=2) + os.replace(tmp, self.releases_path) + + # -- 查询 -- + def list(self) -> List[Release]: + """全部发布版本(按版本号升序)。""" + rels = self._read_all() + return sorted(rels, key=lambda r: semver_tuple(r.version)) + + def latest(self) -> Optional[Release]: + """最新发布版本(无则 None)。""" + rels = self.list() + return rels[-1] if rels else None + + def get(self, version: str) -> Optional[Release]: + """取指定版本(不存在返回 None)。""" + for r in self._read_all(): + if r.version == version: + return r + return None + + # -- 发布 -- + def publish( + self, + version: str, + released_by: str = "system", + changelog: str = "", + reason: str = "", + ) -> Release: + """发布新版本(固化当前 ConfigStore 快照)。 + + Raises: + ValueError: semver 非法 / 版本号非单调递增 / 版本号已存在 / + 快照为空(无任何配置不允许发布)。 + """ + if not is_valid_semver(version): + raise ValueError(f"版本号 '{version}' 非法(须为 MAJOR.MINOR.PATCH)") + releases = self._read_all() + existing = {r.version for r in releases} + if version in existing: + raise ValueError(f"版本号 '{version}' 已存在,不可重复发布") + # 单调递增:新版本必须严格大于当前最新 + if releases: + current_latest = max((r.version for r in releases), key=semver_tuple) + if not semver_gt(version, current_latest): + raise ValueError( + f"新版本 '{version}' 必须大于当前最新 '{current_latest}'(单调递增)") + snapshot = self.store.snapshot() + total_items = sum(len(v) for v in snapshot.get("kinds", {}).values()) + if total_items == 0: + raise ValueError("配置快照为空,不允许发布(先在配置台录入配置)") + release = Release( + version=version, created_at=_now_iso(), snapshot=snapshot, + released_by=released_by, changelog=changelog, reason=reason, + ) + releases.append(release) + self._write_all(releases) + return release + + # -- 回滚 -- + def rollback( + self, + target_version: str, + released_by: str = "system", + reason: str = "", + ) -> Release: + """回滚到指定历史版本的快照(把 ConfigStore 恢复成该版本快照)。 + + 回滚**不删除**任何历史版本,而是:恢复快照 + 记一条回滚说明。返回 + 目标版本(便于调用方确认恢复到哪)。 + + Raises: + ValueError: 目标版本不存在 / 回滚到当前已是的状态。 + """ + target = self.get(target_version) + if target is None: + raise ValueError(f"回滚目标版本 '{target_version}' 不存在") + self.store.restore(target.snapshot) + # 记录回滚事件(作为一条带 changelog 的元信息,不新增版本号) + rollback_note = ( + f"[回滚] 已把配置恢复到 {target_version}(发布于 {target.created_at});" + f"操作人={released_by};原因={reason or '未说明'}") + # 把回滚事件追加到目标版本的 reason 字段(可追溯,不污染版本号序列) + target.reason = (target.reason + " | " + rollback_note).strip(" |") if target.reason else rollback_note + releases = self._read_all() + for i, r in enumerate(releases): + if r.version == target_version: + releases[i] = target + self._write_all(releases) + return target + + def history(self) -> List[Dict[str, Any]]: + """发布历史摘要(配置台版本列表展示用)。""" + return [ + { + "version": r.version, + "created_at": r.created_at, + "released_by": r.released_by, + "changelog": r.changelog, + "item_count": sum(len(v) for v in r.snapshot.get("kinds", {}).values()), + "reason": r.reason, + } + for r in self.list() + ] diff --git a/core/template-console/tests/test_release.py b/core/template-console/tests/test_release.py new file mode 100644 index 0000000..bd96662 --- /dev/null +++ b/core/template-console/tests/test_release.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +"""版本发布 + 回滚点测试(issue #66)。 + +覆盖: +1. semver 校验 / 比较 / 递增; +2. publish 发布(快照固化、单调递增、重复拒绝、空快照拒绝); +3. rollback 回滚(恢复快照、不删历史、回滚事件可追溯); +4. list/latest/get/history 查询; +5. 持久化(重开 manager 仍在)。 +""" +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _bootstrap # noqa: F401 + +from template_console.config_store import ConfigKind, ConfigStore # noqa: E402 +from template_console.release import ( # noqa: E402 + Release, + ReleaseManager, + bump_patch, + is_valid_semver, + semver_gt, + semver_tuple, +) + + +class _Tmp: + def __init__(self): + self._tmp = tempfile.mkdtemp() + self.store = ConfigStore(self._tmp) + self.rm = ReleaseManager(self.store) + + def cleanup(self): + import shutil + shutil.rmtree(self._tmp, ignore_errors=True) + + +class SemverTest(unittest.TestCase): + """semver 工具。""" + + def test_valid(self): + self.assertTrue(is_valid_semver("1.0.0")) + self.assertTrue(is_valid_semver("0.0.1")) + self.assertTrue(is_valid_semver("10.20.30")) + + def test_invalid(self): + self.assertFalse(is_valid_semver("1.0")) + self.assertFalse(is_valid_semver("1.0.0.0")) + self.assertFalse(is_valid_semver("v1.0.0")) + self.assertFalse(is_valid_semver("1.0.0-rc")) + + def test_tuple_and_gt(self): + self.assertEqual(semver_tuple("1.2.3"), (1, 2, 3)) + self.assertTrue(semver_gt("1.0.1", "1.0.0")) + self.assertTrue(semver_gt("2.0.0", "1.9.9")) + self.assertFalse(semver_gt("1.0.0", "1.0.0")) + + def test_bump_patch(self): + self.assertEqual(bump_patch("1.0.0"), "1.0.1") + self.assertEqual(bump_patch("0.9.9"), "0.9.10") + + +class PublishTest(unittest.TestCase): + """发布。""" + + def setUp(self): + self.ctx = _Tmp() + + def tearDown(self): + self.ctx.cleanup() + + def test_publish_requires_nonempty_store(self): + with self.assertRaises(ValueError): + self.ctx.rm.publish("1.0.0") + + def test_publish_first_version(self): + self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + rel = self.ctx.rm.publish("1.0.0", released_by="admin", changelog="首次发布") + self.assertEqual(rel.version, "1.0.0") + self.assertEqual(rel.released_by, "admin") + self.assertIn("model_param", rel.snapshot["kinds"]) + + def test_publish_monotonic_increase(self): + self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + self.ctx.rm.publish("1.0.0") + # 较低版本号应被拒绝 + with self.assertRaises(ValueError): + self.ctx.rm.publish("0.9.0") + # 相同版本号应被拒绝 + with self.assertRaises(ValueError): + self.ctx.rm.publish("1.0.0") + # 更高版本 OK + self.ctx.rm.publish("1.0.1") + + def test_publish_invalid_semver(self): + self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + with self.assertRaises(ValueError): + self.ctx.rm.publish("1.0") + + def test_snapshot_captures_current_state(self): + self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + self.ctx.rm.publish("1.0.0") + # 发布后改配置,原版本快照不受影响 + self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.01) + rel1 = self.ctx.rm.get("1.0.0") + self.assertEqual( + rel1.snapshot["kinds"]["model_param"][0]["value"], 0.001) + + +class RollbackTest(unittest.TestCase): + """回滚。""" + + def setUp(self): + self.ctx = _Tmp() + + def tearDown(self): + self.ctx.cleanup() + + def test_rollback_restores_snapshot(self): + self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + self.ctx.rm.publish("1.0.0", changelog="v1 lr=0.001") + self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.01) + self.ctx.rm.publish("1.1.0", changelog="v2 lr=0.01") + # 当前 store 的 lr 应是 0.01 + self.assertEqual(self.ctx.store.get(ConfigKind.MODEL_PARAM, "lr").value, 0.01) + # 回滚到 1.0.0 + target = self.ctx.rm.rollback("1.0.0", released_by="admin", reason="线上异常") + # store 恢复成 1.0.0 的快照 + self.assertEqual(self.ctx.store.get(ConfigKind.MODEL_PARAM, "lr").value, 0.001) + self.assertEqual(target.version, "1.0.0") + + def test_rollback_keeps_history(self): + self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + self.ctx.rm.publish("1.0.0") + self.ctx.rm.rollback("1.0.0") + # 回滚不删除任何版本 + self.assertEqual(len(self.ctx.rm.list()), 1) + self.assertIsNotNone(self.ctx.rm.get("1.0.0")) + + def test_rollback_records_event(self): + self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + self.ctx.rm.publish("1.0.0") + self.ctx.rm.rollback("1.0.0", released_by="admin", reason="紧急回滚") + rel = self.ctx.rm.get("1.0.0") + self.assertIn("回滚", rel.reason) + self.assertIn("紧急回滚", rel.reason) + + def test_rollback_unknown_version(self): + self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + self.ctx.rm.publish("1.0.0") + with self.assertRaises(ValueError): + self.ctx.rm.rollback("9.9.9") + + +class QueryTest(unittest.TestCase): + """查询 + 持久化。""" + + def setUp(self): + self.ctx = _Tmp() + + def tearDown(self): + self.ctx.cleanup() + + def test_list_latest_history(self): + self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + self.ctx.rm.publish("1.0.0", changelog="c1") + self.ctx.rm.publish("1.1.0", changelog="c2") + self.assertEqual([r.version for r in self.ctx.rm.list()], ["1.0.0", "1.1.0"]) + self.assertEqual(self.ctx.rm.latest().version, "1.1.0") + hist = self.ctx.rm.history() + self.assertEqual(len(hist), 2) + self.assertEqual(hist[1]["changelog"], "c2") + self.assertEqual(hist[1]["item_count"], 1) + + def test_persistence_across_reopen(self): + self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + self.ctx.rm.publish("1.0.0") + # 重开 manager(同一 store 目录) + store2 = ConfigStore(self.ctx._tmp) + rm2 = ReleaseManager(store2) + self.assertIsNotNone(rm2.get("1.0.0")) + self.assertEqual(rm2.latest().version, "1.0.0") + + def test_get_nonexistent(self): + self.assertIsNone(self.ctx.rm.get("9.9.9")) + self.assertIsNone(self.ctx.rm.latest()) + + +if __name__ == "__main__": + unittest.main() -- 2.54.0 From 23b374f83637f0a761a9ca172499155b7f708a29 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 05:33:46 +0800 Subject: [PATCH 8/9] =?UTF-8?q?feat(#67):=20=E9=85=8D=E7=BD=AE=E5=8F=B0?= =?UTF-8?q?=E2=86=94=E5=86=85=E6=A0=B8=E9=85=8D=E7=BD=AE=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=EF=BC=88JSON=20manifest+SHA256=20=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=92=8C+=E5=B9=82=E7=AD=89=E6=8E=A8=E9=80=81/?= =?UTF-8?q?=E6=92=A4=E5=9B=9E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/template-console/push_channel.py | 263 ++++++++++++++++++ .../tests/test_push_channel.py | 215 ++++++++++++++ 2 files changed, 478 insertions(+) create mode 100644 core/template-console/push_channel.py create mode 100644 core/template-console/tests/test_push_channel.py diff --git a/core/template-console/push_channel.py b/core/template-console/push_channel.py new file mode 100644 index 0000000..c255e49 --- /dev/null +++ b/core/template-console/push_channel.py @@ -0,0 +1,263 @@ +# -*- coding: utf-8 -*- +"""⑤.7 配置台 ↔ 内核配置推送契约 —— issue #67 / PRD ⑤.7。 + +发布(#66)之后的下一步是**把配置真正送进内核**让 edge-gateway / rag-kb / +model-framework 生效。配置台与内核是两个独立部署单元,二者通过**配置推送契约** +解耦:配置台把一份已发布版本打包为内核可消费的 **JSON manifest**(带校验和), +内核侧拉取/接收后先验完整性再加载。本模块实现这个契约的"配置台侧": + +- ``PushManifest``:推送给内核的清单(版本 / 快照 / 校验和 / 生成时间 / 来源); +- ``PushChannel``:推送通道。 + - ``build_manifest(release)``:把 Release 打包成 manifest,计算 SHA256 校验和 + (对快照做规范 JSON 序列化后哈希,确保内核侧可复算验证); + - ``push(release)``:模拟推送——把 manifest 写到内核预期的接收目录 + (``/manifest-.json``),并记录推送日志(幂等:同版本不重复推送); + - ``pushed_versions()``:已成功推送的版本清单; + - ``verify(manifest)``:校验 manifest 的校验和是否一致(内核侧或配置台侧复用)。 + +幂等性:同一版本重复 push 返回已推送的旧记录(不覆盖、不重复写文件),避免内核 +重复加载;要重推需先 ``retract``(撤回)该版本。 + +零运行时依赖:仅用 json / hashlib / dataclass / 标准库。 +""" +from __future__ import annotations + +import hashlib +import json +import os +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from .release import Release + + +MANIFEST_SCHEMA_VERSION = 1 +MANIFEST_FILENAME_FMT = "manifest-{version}.json" +PUSH_LOG_FILENAME = "push_log.json" +PUSH_LOG_SCHEMA_VERSION = 1 + + +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _canonical_json(obj: Any) -> str: + """规范 JSON 序列化(排序键、无空白),用于稳定哈希。""" + return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":")) + + +def checksum(snapshot: Dict[str, Any]) -> str: + """计算配置快照的 SHA256 校验和(规范序列化后哈希)。 + + 内核侧收到 manifest 后,对 ``snapshot`` 用同样算法复算,比对 ``checksum`` + 即可确认传输无损/未篡改。 + """ + return hashlib.sha256(_canonical_json(snapshot).encode("utf-8")).hexdigest() + + +# --------------------------------------------------------------------------- +# 推送清单 +# --------------------------------------------------------------------------- + +@dataclass +class PushManifest: + """推送给内核的配置清单(自描述:版本/快照/校验和/来源)。""" + + schema_version: int = MANIFEST_SCHEMA_VERSION + version: str = "" # 对应 Release 的 semver + snapshot: Dict[str, Any] = field(default_factory=dict) + checksum: str = "" # snapshot 的 SHA256 + generated_at: str = "" # manifest 生成时间 + source: str = "template-console" # 来源标识(内核侧据此识别推送方) + description: str = "" # 推送说明(可解释) + + def to_dict(self) -> dict: + return asdict(self) + + def to_json(self) -> str: + """manifest 序列化为 JSON 文本(推送载荷)。""" + return json.dumps(self.to_dict(), ensure_ascii=False, indent=2) + + @classmethod + def from_dict(cls, raw: dict) -> "PushManifest": + return cls( + schema_version=raw.get("schema_version", MANIFEST_SCHEMA_VERSION), + version=raw["version"], + snapshot=raw.get("snapshot", {}), + checksum=raw.get("checksum", ""), + generated_at=raw.get("generated_at", ""), + source=raw.get("source", "template-console"), + description=raw.get("description", ""), + ) + + +# --------------------------------------------------------------------------- +# 推送通道 +# --------------------------------------------------------------------------- + +@dataclass +class PushRecord: + """一次推送的记录(幂等判定与审计依据)。""" + + version: str + checksum: str + pushed_at: str + pushed_by: str + manifest_path: str + status: str = "pushed" # pushed / retracted + reason: str = "" + + +class PushChannel: + """配置台 → 内核的配置推送通道(基于文件系统的模拟推送)。 + + 用法: + rm = ReleaseManager(store) + rel = rm.publish("1.0.0", ...) + ch = PushChannel(inbox="/path/to/kernel/inbox") + manifest = ch.push(rel, pushed_by="admin") + # 内核侧:读 manifest,复算 checksum 比对,加载 snapshot + """ + + def __init__(self, inbox: str, push_log_path: Optional[str] = None) -> None: + """``inbox`` 是内核侧接收目录(模拟推送就是把 manifest 写到此处)。 + + ``push_log_path`` 推送日志路径(默认与 inbox 同目录的 push_log.json), + 记录每个版本的推送状态,支撑幂等与撤回。 + """ + self.inbox = inbox + os.makedirs(inbox, exist_ok=True) + self.push_log_path = push_log_path or os.path.join(inbox, PUSH_LOG_FILENAME) + + # -- manifest 构建 -- + def build_manifest( + self, release: Release, description: str = "", + ) -> PushManifest: + """把 Release 打包为 PushManifest(含校验和)。""" + snap = release.snapshot + return PushManifest( + schema_version=MANIFEST_SCHEMA_VERSION, + version=release.version, + snapshot=snap, + checksum=checksum(snap), + generated_at=_now_iso(), + source="template-console", + description=description or f"推送版本 {release.version}", + ) + + # -- 推送日志 -- + def _read_log(self) -> List[PushRecord]: + if not os.path.isfile(self.push_log_path): + return [] + with open(self.push_log_path, "r", encoding="utf-8") as fh: + blob = json.load(fh) + return [PushRecord(**r) for r in blob.get("records", [])] + + def _write_log(self, records: List[PushRecord]) -> None: + blob = { + "schema_version": PUSH_LOG_SCHEMA_VERSION, + "records": [asdict(r) for r in records], + } + tmp = self.push_log_path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(blob, fh, ensure_ascii=False, indent=2) + os.replace(tmp, self.push_log_path) + + def _find_record(self, version: str) -> Optional[PushRecord]: + for r in self._read_log(): + if r.version == version: + return r + return None + + # -- 推送 / 撤回 -- + def push( + self, release: Release, pushed_by: str = "system", + description: str = "", force: bool = False, + ) -> PushManifest: + """推送一个已发布版本到内核接收目录(幂等:同版本不重复推送)。 + + 幂等性:若该版本已成功推送且未撤回,直接返回原 manifest(不重复写文件、 + 不重复触发内核加载)。要强制重推,先 ``retract`` 或传 ``force=True``。 + + Args: + release: 已发布的版本(含快照); + pushed_by: 推送人(对接 RBAC); + description: 推送说明; + force: 强制重推(覆盖既有 manifest)。 + + Returns: + 推送的 PushManifest。 + """ + existing = self._find_record(release.version) + if existing and existing.status == "pushed" and not force: + # 幂等:返回已推送的 manifest(从 inbox 读回) + if os.path.isfile(existing.manifest_path): + with open(existing.manifest_path, "r", encoding="utf-8") as fh: + return PushManifest.from_dict(json.load(fh)) + + manifest = self.build_manifest(release, description=description) + manifest_path = os.path.join( + self.inbox, MANIFEST_FILENAME_FMT.format(version=release.version)) + tmp = manifest_path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + fh.write(manifest.to_json()) + os.replace(tmp, manifest_path) + + # 更新推送日志(覆盖同版本旧记录) + records = [r for r in self._read_log() if r.version != release.version] + records.append(PushRecord( + version=release.version, checksum=manifest.checksum, + pushed_at=_now_iso(), pushed_by=pushed_by, + manifest_path=manifest_path, status="pushed", + reason=description or f"推送 {release.version}", + )) + self._write_log(records) + return manifest + + def retract(self, version: str, by: str = "system", reason: str = "") -> bool: + """撤回一个已推送版本(标记为 retracted,不删 manifest 文件,可追溯)。 + + 撤回后该版本可重新 push(幂等解除)。返回是否实际撤回。 + """ + rec = self._find_record(version) + if rec is None or rec.status != "pushed": + return False + records = self._read_log() + for i, r in enumerate(records): + if r.version == version: + records[i] = PushRecord( + version=r.version, checksum=r.checksum, + pushed_at=r.pushed_at, pushed_by=r.pushed_by, + manifest_path=r.manifest_path, status="retracted", + reason=f"撤回 by {by}:{reason or '未说明'}", + ) + self._write_log(records) + return True + + # -- 查询 / 校验 -- + def pushed_versions(self) -> List[Dict[str, Any]]: + """已推送版本摘要(配置台推送状态列表用)。""" + return [ + {"version": r.version, "checksum": r.checksum, + "pushed_at": r.pushed_at, "pushed_by": r.pushed_by, + "status": r.status} + for r in self._read_log() + ] + + @staticmethod + def verify(manifest: PushManifest) -> bool: + """校验 manifest 的 checksum 与其 snapshot 是否一致。 + + 内核侧收到 manifest 后调用此方法,确认传输无损;配置台侧也可在推送前自检。 + """ + return manifest.checksum == checksum(manifest.snapshot) + + @staticmethod + def verify_payload(payload: Dict[str, Any]) -> bool: + """从原始 payload(dict)校验:用同算法复算 checksum 比对。""" + try: + manifest = PushManifest.from_dict(payload) + except (KeyError, TypeError): + return False + return PushChannel.verify(manifest) diff --git a/core/template-console/tests/test_push_channel.py b/core/template-console/tests/test_push_channel.py new file mode 100644 index 0000000..aa8a244 --- /dev/null +++ b/core/template-console/tests/test_push_channel.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +"""配置推送契约测试(issue #67)。 + +覆盖: +1. manifest 构建(版本/快照/校验和/来源); +2. checksum 稳定性 + 完整性校验(verify); +3. 推送幂等(同版本不重复写文件、返回原 manifest); +4. force 强制重推; +5. retract 撤回 + 重新推送; +6. 模拟传输损坏(篡改 snapshot → verify 失败); +7. 推送日志与查询。 +""" +import json +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _bootstrap # noqa: F401 + +from template_console.config_store import ConfigKind, ConfigStore # noqa: E402 +from template_console.push_channel import ( # noqa: E402 + PushChannel, + PushManifest, + PushRecord, + checksum, +) +from template_console.release import Release, ReleaseManager # noqa: E402 + + +def _make_release(version: str = "1.0.0") -> Release: + """构造一个带快照的 Release(不走文件系统,直接内存构造)。""" + snap = { + "schema_version": 1, + "captured_at": "2026-01-01T00:00:00Z", + "kinds": {"model_param": [ + {"key": "lr", "value": 0.001, "kind": "model_param", + "meaning": "学习率", "updated_by": "li", "reason": "init", + "updated_at": "2026-01-01T00:00:00Z"}]}, + } + return Release(version=version, created_at="2026-01-01T00:00:00Z", + snapshot=snap, released_by="admin", changelog="t", reason="r") + + +class _Tmp: + def __init__(self): + self._tmp = tempfile.mkdtemp() + self.inbox = os.path.join(self._tmp, "inbox") + + def cleanup(self): + import shutil + shutil.rmtree(self._tmp, ignore_errors=True) + + +class ChecksumTest(unittest.TestCase): + """校验和稳定性 + 完整性。""" + + def test_checksum_stable(self): + snap = {"kinds": {"a": [1, 2, 3]}} + self.assertEqual(checksum(snap), checksum(snap)) + + def test_checksum_key_order_independent(self): + # 键序不影响校验和(规范序列化) + a = checksum({"x": 1, "y": 2}) + b = checksum({"y": 2, "x": 1}) + self.assertEqual(a, b) + + def test_checksum_changes_on_value_change(self): + self.assertNotEqual(checksum({"v": 1}), checksum({"v": 2})) + + def test_checksum_is_sha256_hex(self): + cs = checksum({"v": 1}) + self.assertEqual(len(cs), 64) + self.assertTrue(all(c in "0123456789abcdef" for c in cs)) + + +class ManifestTest(unittest.TestCase): + """manifest 构建。""" + + def test_build_manifest_has_checksum(self): + ch = PushChannel(inbox=tempfile.mkdtemp()) + rel = _make_release() + m = ch.build_manifest(rel) + self.assertEqual(m.version, "1.0.0") + self.assertTrue(m.checksum) + self.assertEqual(m.source, "template-console") + self.assertTrue(m.generated_at) + + def test_manifest_roundtrip(self): + m = PushManifest(version="1.0.0", snapshot={"a": 1}, + checksum=checksum({"a": 1}), generated_at="t") + text = m.to_json() + m2 = PushManifest.from_dict(json.loads(text)) + self.assertEqual(m2.version, "1.0.0") + self.assertEqual(m2.checksum, m.checksum) + + +class PushIdempotencyTest(unittest.TestCase): + """推送幂等。""" + + def setUp(self): + self.ctx = _Tmp() + self.ch = PushChannel(inbox=self.ctx.inbox) + self.rel = _make_release() + + def tearDown(self): + self.ctx.cleanup() + + def test_push_writes_manifest_file(self): + self.ch.push(self.rel, pushed_by="admin") + path = os.path.join(self.ctx.inbox, "manifest-1.0.0.json") + self.assertTrue(os.path.isfile(path)) + + def test_push_is_idempotent(self): + m1 = self.ch.push(self.rel, pushed_by="a") + m2 = self.ch.push(self.rel, pushed_by="b") # 重复推送 + # 同版本返回同一 manifest(校验和一致) + self.assertEqual(m1.checksum, m2.checksum) + # 推送日志只有一条记录 + self.assertEqual(len(self.ch.pushed_versions()), 1) + + def test_force_overrides_idempotency(self): + self.ch.push(self.rel, pushed_by="a") + before = self.ch.pushed_versions()[0]["pushed_at"] + # force 重推(时间戳可能更新) + self.ch.push(self.rel, pushed_by="b", force=True) + records = self.ch.pushed_versions() + self.assertEqual(len(records), 1) + self.assertEqual(records[0]["pushed_by"], "b") + + def test_push_log_records_pushed_by(self): + self.ch.push(self.rel, pushed_by="admin_zhang") + rec = self.ch.pushed_versions()[0] + self.assertEqual(rec["pushed_by"], "admin_zhang") + self.assertEqual(rec["status"], "pushed") + + +class RetractTest(unittest.TestCase): + """撤回 + 重新推送。""" + + def setUp(self): + self.ctx = _Tmp() + self.ch = PushChannel(inbox=self.ctx.inbox) + self.rel = _make_release() + + def tearDown(self): + self.ctx.cleanup() + + def test_retract_marks_status(self): + self.ch.push(self.rel) + self.assertTrue(self.ch.retract("1.0.0", by="admin", reason="有问题")) + rec = self.ch.pushed_versions()[0] + self.assertEqual(rec["status"], "retracted") + + def test_retract_unknown_returns_false(self): + self.assertFalse(self.ch.retract("9.9.9")) + + def test_retract_allows_repush(self): + self.ch.push(self.rel) + self.ch.retract("1.0.0") + # 撤回后可重新推送(幂等解除) + m = self.ch.push(self.rel, pushed_by="admin2") + rec = self.ch.pushed_versions()[0] + self.assertEqual(rec["status"], "pushed") + self.assertEqual(rec["pushed_by"], "admin2") + + +class VerifyTest(unittest.TestCase): + """完整性校验。""" + + def test_verify_valid_manifest(self): + ch = PushChannel(inbox=tempfile.mkdtemp()) + m = ch.build_manifest(_make_release()) + self.assertTrue(PushChannel.verify(m)) + + def test_verify_tampered_snapshot_fails(self): + ch = PushChannel(inbox=tempfile.mkdtemp()) + m = ch.build_manifest(_make_release()) + # 篡改 snapshot 但不改 checksum → 校验失败 + m.snapshot["kinds"]["model_param"][0]["value"] = 0.999 + self.assertFalse(PushChannel.verify(m)) + + def test_verify_payload_dict(self): + ch = PushChannel(inbox=tempfile.mkdtemp()) + m = ch.build_manifest(_make_release()) + self.assertTrue(PushChannel.verify_payload(m.to_dict())) + + def test_verify_payload_bad_dict(self): + self.assertFalse(PushChannel.verify_payload({"nope": 1})) + + +class IntegrationTest(unittest.TestCase): + """端到端:store → publish → push → verify。""" + + def test_store_publish_push_flow(self): + tmp = tempfile.mkdtemp() + try: + store = ConfigStore(tmp) + store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001) + rm = ReleaseManager(store) + rel = rm.publish("1.0.0", released_by="admin", changelog="首发") + inbox = os.path.join(tmp, "inbox") + ch = PushChannel(inbox=inbox) + m = ch.push(rel, pushed_by="admin") + # 内核侧校验通过 + self.assertTrue(PushChannel.verify(m)) + self.assertEqual(len(ch.pushed_versions()), 1) + finally: + import shutil + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + unittest.main() -- 2.54.0 From 4ad7aba5a3dc6ee93da2d7ce1875f09fa653f877 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 05:34:33 +0800 Subject: [PATCH 9/9] =?UTF-8?q?docs(#62-#67):=20=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8F=B0=20README=EF=BC=886=20=E5=AD=90?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=98=A0=E5=B0=84=EF=BC=89+=20=E7=A6=BB?= =?UTF-8?q?=E7=BA=BF=20=5Fsanity=5Fcheck=EF=BC=88105=20=E6=B5=8B=E8=AF=95+?= =?UTF-8?q?=E7=AB=AF=E5=88=B0=E7=AB=AF=E5=86=92=E7=83=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/template-console/README.md | 82 +++++++++++++++ core/template-console/_sanity_check.py | 139 +++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 core/template-console/README.md create mode 100644 core/template-console/_sanity_check.py diff --git a/core/template-console/README.md b/core/template-console/README.md new file mode 100644 index 0000000..8b3b62e --- /dev/null +++ b/core/template-console/README.md @@ -0,0 +1,82 @@ +# ⑤.7 模板配置台(Template Console)内核引擎 + +> 父 EPIC:#9「⑤.7 模板配置台 Template Console」 +> 子 issue:#62 / #63 / #64 / #65 / #66 / #67(同一 feature 分支 `feature/issue-62`,单 PR 关联全部 6 个 issue) + +配置台是一个**无代码、配置驱动**的内核能力,让实施工程师(而非开发者)按现场调 +模板:点位字典、模型超参、RAG、驾驶舱布局全部在配置台编排,预览确认后发布版本, +再把版本推送给内核(edge-gateway / rag-kb / model-framework)生效。本目录是配置台 +的**纯标准库核心引擎**(不是 Web 前端——前端由 cockpit 渲染本引擎产出的结构化输出)。 + +## 为什么放在 `core/`? + +配置台是**跨模板通用的内核能力**(RBAC / 配置存储 / 版本 / 推送契约服务于所有行业 +模板:氯化 ti-cl4、树脂 resin、…),与 `core/edge-gateway`、`core/rag-kb`、 +`core/model-framework` 同级,而非属于某个具体模板,故置于 `core/template-console/`。 + +## 6 个子任务映射 + +| issue | 模块 | 职责 | +|-------|------|------| +| #62 | `rbac.py` | 三级 RBAC(管理员 admin / 行业工程师 engineer / 只读 readonly),角色继承、`has_permission(resource, action)` 带理由判定、细粒度收窄 | +| #63 | `point_importer.py` | 点位字典 CSV 导入 + 自动校验页面。**复用** `core/edge-gateway/point_dict` 校验器(量纲/数据类型/采样率/重复点号/协议),增加 OPC 节点格式校验、表头列序校验、模板级量纲收窄(resin/ti)、行级结果聚合 | +| #64 | `config_store.py` | 配置项 CRUD(模型超参 / RAG / 布局三类),文件系统版本化 JSON 存储,list/get/upsert/delete + 按类别校验,原子写,快照 snapshot/restore | +| #65 | `preview.py` | 预览渲染引擎:布局(widget 卡片 + 网格占用率/越界/重叠检测)/ 告警(规则渲染 + 模拟触发评估)/ NL 查询(模板 → 示例问句)。对齐 `iAOP-cockpit-layout-v1` widget 类型 | +| #66 | `release.py` | 版本发布 + 回滚点。基于 `config_store` 快照的 Release,semver 单调递增校验,publish 固化快照、rollback 恢复快照(不删历史、回滚事件可追溯) | +| #67 | `push_channel.py` | 配置台↔内核配置推送契约。PushManifest(版本/快照/SHA256 校验和),PushChannel 模拟推送(写 manifest 到内核 inbox)、幂等(同版本不重复推送)、retract 撤回、verify 完整性校验 | + +## 设计原则(对齐 PRD「可解释可溯源」与既有内核范式) + +- **纯标准库零运行时依赖**:不 import pyyaml/numpy/pandas。需要哈希用 `hashlib`, + JSON 用 `json`,CSV 用 `csv`。 +- **dataclass + Enum + 类型注解 + 中文 docstring**,与 `core/data-bus`、 + `core/edge-gateway` 风格一致。 +- **可解释性**:关键决策都带 `meaning` / `reason` 字段(RBAC 判定理据、配置项修改 + 原因、发布 changelog、回滚事件、推送日志),便于审计与配置台展示。 +- **复用而非重造**:#63 直接复用 `core/edge-gateway/point_dict`(schema/loader/validator), + 只增加配置台专属校验维度,避免与内核点位字典机制漂移。 + +## 目录结构 + +``` +core/template-console/ +├── __init__.py # 包入口(导出 RBAC 公共 API) +├── rbac.py # #62 三级 RBAC +├── point_importer.py # #63 点位字典 CSV 导入+校验 +├── config_store.py # #64 配置项 CRUD 存储 +├── preview.py # #65 预览渲染引擎 +├── release.py # #66 版本发布+回滚 +├── push_channel.py # #67 配置推送契约 +├── _sanity_check.py # 离线基本校验(跑全部测试 + 冒烟) +├── README.md # 本文件 +└── tests/ + ├── _bootstrap.py # 挂载 template_console 包 + 暴露 edge-gateway/point_dict + ├── test_rbac.py + ├── test_point_importer.py + ├── test_config_store.py + ├── test_preview.py + ├── test_release.py + └── test_push_channel.py +``` + +## 运行测试 + +```bash +# 嵌入式 Python(无 pip/pyyaml) +/c/gitea/python312/python.exe -m unittest discover \ + -s core/template-console/tests -p "test_*.py" -v + +# 离线基本校验(跑全部测试 + 冒烟) +/c/gitea/python312/python.exe core/template-console/_sanity_check.py +``` + +## 数据流(配置台典型用例) + +``` +实施工程师导入点位字典(#63) ─┐ +行业工程师调模型超参/RAG/布局(#64) ─┼─▶ 预览确认(#65) ─▶ 管理员发布版本(#66) + │ │ + │ ▼ + └──────────────────── 配置推送内核(#67) ─▶ edge-gateway/rag-kb/... +全程受三级 RBAC(#62) 权限管控;每次变更可解释、可溯源、可回滚。 +``` diff --git a/core/template-console/_sanity_check.py b/core/template-console/_sanity_check.py new file mode 100644 index 0000000..33d1539 --- /dev/null +++ b/core/template-console/_sanity_check.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +"""⑤.7 模板配置台离线基本校验(无构建环境下的离线验证)。 + +检查项: +1. 全部单元测试通过(unittest discover); +2. 冒烟:6 个子模块可导入; +3. 冒烟:端到端数据流跑通—— + RBAC 判定(#62) → 点位导入(#63) → 配置 CRUD(#64) → 预览(#65) + → 发布版本(#66) → 推送内核(#67) → 完整性校验通过。 + +用法:python _sanity_check.py +退出码:0 成功 / 1 失败。 +""" +from __future__ import annotations + +import os +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _run_unit_tests() -> tuple[int, int]: + """跑 tests/ 下全部测试,返回 (run, failures+errors)。""" + # 挂载 template_console 包 + edge-gateway/point_dict(同 tests/_bootstrap.py) + sys.path.insert(0, HERE) + import types + if "template_console" not in sys.modules: + pkg = types.ModuleType("template_console") + pkg.__path__ = [HERE] + sys.modules["template_console"] = pkg + edge_gw = os.path.join(os.path.dirname(HERE), "edge-gateway") + if os.path.isdir(edge_gw) and edge_gw not in sys.path: + sys.path.insert(0, edge_gw) + + loader = unittest.TestLoader() + suite = loader.discover(os.path.join(HERE, "tests"), pattern="test_*.py") + runner = unittest.TextTestRunner(verbosity=1, stream=sys.stdout) + result = runner.run(suite) + return result.testsRun, len(result.failures) + len(result.errors) + + +def _smoke_flow() -> list[str]: + """端到端冒烟:返回问题列表(空=通过)。""" + problems: list[str] = [] + try: + from template_console.rbac import ( # type: ignore + Action, Resource, RoleKind, User, has_permission, + ) + from template_console.point_importer import ( # type: ignore + TemplateKind, import_csv_string, + ) + from template_console.config_store import ConfigKind, ConfigStore # type: ignore + from template_console.preview import preview_from_store, PreviewKind # type: ignore + from template_console.release import ReleaseManager # type: ignore + from template_console.push_channel import PushChannel # type: ignore + except Exception as exc: # noqa: BLE001 + problems.append(f"模块导入失败:{exc}") + return problems + + # 1) RBAC:admin 可发布,readonly 不可 + admin = User("a", RoleKind.ADMIN) + viewer = User("v", RoleKind.READONLY) + if not has_permission(admin, Resource.RELEASE, Action.PUBLISH).allow: + problems.append("RBAC:admin 应能发布") + if has_permission(viewer, Resource.RELEASE, Action.PUBLISH).allow: + problems.append("RBAC:readonly 不应能发布") + + # 2) 点位字典导入 + csv_text = ( + "device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol\n" + "CLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,ns=2;s=CLF.Temp,opcua\n" + ) + _, rep = import_csv_string(csv_text, template=TemplateKind.TI) + if not rep.ok: + problems.append(f"点位导入应通过:{rep.summary()}") + + # 3) 配置 CRUD + 4) 预览 + 5) 发布 + 6) 推送 + tmp = tempfile.mkdtemp() + try: + store = ConfigStore(tmp) + store.upsert(ConfigKind.LAYOUT, "dashboard", + [{"type": "trend", "bind": "CLF-01.TEMP", + "x": 0, "y": 0, "w": 6, "h": 2}], + updated_by="li", reason="冒烟") + # 预览 + prev = preview_from_store(store, PreviewKind.LAYOUT) + if not prev.items: + problems.append("预览:布局应渲染出 widget") + + # 发布 + rm = ReleaseManager(store) + rel = rm.publish("1.0.0", released_by="admin", changelog="冒烟发布") + if rm.latest().version != "1.0.0": + problems.append("发布:最新版本应为 1.0.0") + + # 推送 + 完整性校验 + inbox = os.path.join(tmp, "inbox") + ch = PushChannel(inbox=inbox) + manifest = ch.push(rel, pushed_by="admin") + if not PushChannel.verify(manifest): + problems.append("推送:manifest 完整性校验失败") + if not os.path.isfile(os.path.join(inbox, "manifest-1.0.0.json")): + problems.append("推送:manifest 文件未写入 inbox") + finally: + import shutil + shutil.rmtree(tmp, ignore_errors=True) + + return problems + + +def main() -> int: + print("=" * 60) + print("⑤.7 模板配置台 离线基本校验") + print("=" * 60) + + # 1) 单元测试 + print("\n[1/2] 单元测试") + run_count, fail_count = _run_unit_tests() + if fail_count: + print(f"\nFAIL: 单元测试 {fail_count} 项失败(共 {run_count} 项)") + return 1 + + # 2) 冒烟 + print("\n[2/2] 端到端冒烟") + problems = _smoke_flow() + if problems: + print("FAIL") + for p in problems: + print(" -", p) + return 1 + + print(f"\nOK: 单元测试 {run_count} 项全过;端到端冒烟通过(#62→#67 数据流正常)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) -- 2.54.0