Files
mini-openclaw-v2/backend/services/knowledge_cards.py
T

199 lines
9.9 KiB
Python
Raw Normal View History

"""T05-b 知识卡片:既有 Skill/Rule 治理的目标投影。
**不新建第二套发布库**(docs/v2/03-detailed-design.md §9.2 明文)。本模块只负责三件事:
1. **投影字段**:origin、谱系(parent_version)、数据域、卡片范围(project/team)、条件与例外
2. **硬规则门禁**:hard 必须有正式制度/授权依据与依据证据(BR-26 / E-18)
3. **有效性过滤**:按数据域 / 状态 / 有效期 / 范围筛选可应用的已发布版本(BR-04)
状态机与发布**完全委托** `component_registry.draft()` / `release()`,因此既有保证
原样生效、不重复实现:
- 自批拒绝:`release()` 里 `edited_by == actor` → PermissionError(AC-03 / AC-43)
- 评测门禁:候选哈希必须匹配通过的隔离评测(AC-34)
- 已发布不可覆盖:`component_version` 以 immutable 写入;改已发布需新版本号
- 工具白名单、方案预算等既有校验
**命名映射(不得互相顶替)**:
- 本设计的 `card_scope`(project/team)↔ 注册表无此概念,作为附加键保存
- 本设计的 `data_domain` ↔ 注册表的 `scope`(demo/production)——**同一个键名、不同语义**,
投影时显式互换,绝不混用
"""
from __future__ import annotations
from contracts.prescreen import Condition, KnowledgeCard, Origin, RuleEffect
from services import assistant_store as store
from services import component_registry as registry
ORIGINS = tuple(item.value for item in Origin)
EFFECTS = tuple(item.value for item in RuleEffect)
HARD_BASIS_REQUIRED = "硬性规则需要正式制度或授权依据,仅凭历史频次不足;当前仅可保存为建议草稿"
def _check_hard_basis(body: dict) -> None:
"""hard 规则的资格校验(BR-26 / E-18)。advisory 不设门槛。"""
if body.get("kind") != "rule_set":
return
effect = body.get("effect", "advisory")
if effect not in EFFECTS:
raise ValueError(f"规则效果取值须为 {' 或 '.join(EFFECTS)}")
if effect != "hard":
return
if not (body.get("policy_ref") or body.get("authorization_ref")):
raise ValueError(HARD_BASIS_REQUIRED)
if not body.get("basis_evidence_ids"):
raise ValueError("硬性规则必须附依据证据,不能只有结论")
# 注册表既有的条件形状与本设计的 Condition **不是同一套**:
# 注册表:{code, field, operator, threshold, title, suggestion}
# 本设计:{field, operator, value}
# 因此投影层做**翻译**,而不是把一方强加给另一方——注册表体按它自己的形状保存,
# 卡片侧只暴露可安全求值的字段,展示性字段(title/suggestion)不进契约。
# 只映射注册表**实际可能产出**的算子:component_registry.validate() 要求
# operator ∈ (gt, lt, eq, gte, lte) 且 threshold 可解析为数值。
# 本设计的 Condition 还允许 in/present/absent,但它们不可能来自注册表条件,
# 故不在此表中——出现即报错,避免把不可能的取值当成已支持。
_OPERATORS = {"eq": "eq", "lt": "lt", "lte": "lte", "gt": "gt", "gte": "gte"}
def _translate_conditions(conditions, label: str) -> list[dict]:
"""把注册表条件翻译为设计的安全条件;无法表达即报错,不静默丢弃。"""
parsed = []
for index, item in enumerate(conditions or ()):
if not isinstance(item, dict):
raise ValueError(f"{label}第 {index + 1} 条不是对象")
operator = _OPERATORS.get(str(item.get("operator", "")).strip().lower())
if operator is None:
raise ValueError(f"{label}第 {index + 1} 条算子不受支持:{item.get('operator')!r}")
raw = item.get("threshold", item.get("value"))
value = raw if isinstance(raw, (list, bool)) else (None if raw is None else str(raw))
try:
parsed.append(Condition(field=str(item.get("field", "")), operator=operator,
value=value).model_dump(mode="json"))
except Exception as exc:
raise ValueError(f"{label}第 {index + 1} 条不合法:{exc}") from exc
if not parsed and conditions:
raise ValueError(f"{label}无法表达为安全条件")
return parsed
def propose(db, body: dict, actor: str, project_id: str, origin: str = "manual",
source_event_ids=(), source_evidence_ids=(), parent_version: dict | None = None,
card_scope: str = "project", team_id: str | None = None):
"""提议一张知识卡片草稿。**只生成草稿,绝不直接生效**(BR-03 / AC-26)。"""
if origin not in ORIGINS:
raise ValueError(f"来源取值须为 {' 或 '.join(ORIGINS)}")
if card_scope not in ("project", "team"):
raise ValueError("卡片范围须为 project 或 team")
if card_scope == "team" and not team_id:
raise ValueError("team 范围的卡片必须有 team_id")
if origin == "history_derived" and not (source_event_ids or source_evidence_ids):
raise ValueError("历史提炼卡片必须保留来源(事件或证据),不得成为无源知识")
if "data_domain" in body:
raise ValueError("请用 scope 声明数据域(注册表既有约定),不要另传 data_domain")
_check_hard_basis(body)
enriched = dict(
body,
origin=origin,
project_id=project_id,
card_scope=card_scope,
team_id=team_id,
parent_version=parent_version,
source_event_ids=list(source_event_ids),
source_evidence_ids=list(source_evidence_ids),
)
# 只校验"能否表达为安全条件",不改写注册表既有的条件形状
_translate_conditions(body.get("conditions"), "适用条件")
_translate_conditions(body.get("exceptions"), "例外")
# 委托既有治理:normalize + validate + 写入 component_draft
return registry.draft(db, enriched, actor)
def publish(db, draft_id: str, actor: str, preview: bool = False):
"""发布草稿。自批与评测门禁由 `component_registry.release()` 保证。"""
body = store.get(db, "component_draft", draft_id)
# 门禁在发布时**再查一次**:草稿可能在提议之后被改动,不能只信提议时的校验
_check_hard_basis(body)
return registry.release(db, draft_id, actor, preview)
def _project(body: dict) -> dict:
"""把注册表对象投影为 KnowledgeCard。scope↔data_domain 在此完成互换。"""
domain = body.get("scope")
if domain not in ("demo", "production"):
raise ValueError("组件的数据域不合法,无法投影为卡片")
# 内置种子组件是**全局**的(无 project_id / edited_by),不是项目知识卡片。
# 明确报错而不是让 pydantic 抛出难懂的信息——否则容易把内置 Skill 误当项目卡片。
if not body.get("project_id") or not body.get("edited_by"):
raise ValueError(
"该组件不是项目知识卡片(缺少 project_id 或 edited_by);"
"内置 Skill/规则集属全局配置,请勿投影为卡片"
)
card = {
"id": body.get("component_id") or body["id"],
"project_id": body.get("project_id") or "",
"data_domain": domain, # ← 注册表 scope
"revision": body.get("revision", 1),
"version": body.get("version", "1.0.0"),
"kind": body.get("kind", ""),
"title": body.get("name") or body.get("title") or "",
"origin": body.get("origin", "manual"),
"status": body.get("status", "draft"),
"source_event_ids": body.get("source_event_ids", []),
"source_evidence_ids": body.get("source_evidence_ids", []),
"parent_version": body.get("parent_version"),
"created_by": body.get("owner") or body.get("edited_by", ""),
"edited_by": body.get("edited_by", ""),
"card_scope": body.get("card_scope", "project"),
"team_id": body.get("team_id"),
"conditions": _translate_conditions(body.get("conditions"), "适用条件"),
"exceptions": _translate_conditions(body.get("exceptions"), "例外"),
"effective_at": body.get("effective_from") or store.now(),
"expires_at": body.get("expires_at"),
"reviewer_id": body.get("approved_by"),
"rule": None,
}
if body.get("kind") == "rule_set":
card["rule"] = {
"effect": body.get("effect", "advisory"),
"predicate": _translate_conditions(
body.get("predicate") or body.get("conditions"), "规则条件") or [
{"field": "unknown", "operator": "present"}],
"on_match": body.get("on_match", "flag"),
"basis_evidence_ids": body.get("basis_evidence_ids", []),
"policy_ref": body.get("policy_ref"),
"authorization_ref": body.get("authorization_ref"),
}
return KnowledgeCard(**card).model_dump(mode="json")
def draft_card(db, draft_id: str) -> dict:
return _project(store.get(db, "component_draft", draft_id))
def published_cards(db, domain: str) -> list[dict]:
return [_project(row) for row in store.listing(db, "component_version")
if row.get("status") == "published" and row.get("scope") == domain]
def effective(db, domain: str, project_id: str | None = None, at: str | None = None) -> list[dict]:
"""可应用的已发布卡片(BR-04:先按数据域/状态/有效期/范围过滤)。
用户级权限(OQ-02)尚未建设,故此处只做域、有效期与范围过滤;
**不得**据此宣称已完成授权过滤。
"""
moment = at or store.now()
result = []
for card in published_cards(db, domain):
if card["expires_at"] and card["expires_at"] <= moment:
continue
if card["card_scope"] == "project" and project_id and card["project_id"] != project_id:
continue
if card["card_scope"] == "team" and not card["team_id"]:
continue
result.append(card)
return result