Files
iAOP/core/template-console/config_store.py
T

293 lines
12 KiB
Python
Raw Normal View History

# -*- 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)、标量列表,或结构化 dict
(如 optimizer 配置 / 告警规则等复合超参);
- 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, dict)):
errors.append("model_param 的 value 必须为标量/标量列表/结构化对象")
elif isinstance(value, list) and any(
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):
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}