Files
mini-openclaw-v2/backend/services/knowledge_cards.py
T
qiaoqichaoandClaude Code f8c4ba9e1d feat(v2): T05-b 知识卡片与硬规则依据门禁
T05 第二步:候选与治理。设计明令"复用卡片治理、不新建第二套发布库",
故本模块**只做投影**,状态机与发布完全委托 component_registry。

新增 contracts:Origin / GovernanceStatus / RuleEffect / Condition /
RuleContent / KnowledgeCard。Condition 只允许字段+有限算子,**不执行表达式**。
RuleContent 在契约层就要求 hard 必须带 policy_ref 或 authorization_ref 且附依据证据。

新增 services/knowledge_cards.py:
- propose():投影字段(origin/谱系/范围/条件)后委托 registry.draft(),**只产草稿**
- publish():发布时**再查一次** hard 门禁——草稿可能在提议后被改,不能只信提议时的校验
- effective():按数据域/有效期/范围过滤已发布版本(BR-04)

既有治理的保证原样生效,有测试锁定:自批被 release() 拒绝、无评测不能发布、
已发布需新版本号、提议不产生已发布版本。

三处必须点明的边界与映射:
1. **同键不同义**:本设计的 data_domain ↔ 注册表的 scope(demo/production),
   而本设计的 scope 是 project/team。投影层显式互换并存为 card_scope,绝不混用;
   调用方私传 data_domain 会被拒
2. **条件形状不同**:注册表条件是 {code,field,operator,threshold,title,suggestion},
   本设计是 {field,operator,value}。做**翻译**而非强加;算子只映射注册表实际
   会产出的 5 个(gt/lt/eq/gte/lte),出现其它取值即报错而非当作已支持
3. **内置种子组件是全局的**,没有 project_id/edited_by,**不是项目知识卡片**。
   投影时明确报错,避免把内置 Skill 误当项目卡片

测试:新增 test_knowledge_cards.py 20 项(门禁 6 + 来源谱系 6 + 治理复用 5 + 有效过滤 3)。
全量 310 项 OK(290 → 310);verify.py 10/10 PASS。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 18:47:59 +08:00

199 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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