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>
This commit is contained in:
2026-09-29 18:47:59 +08:00
co-authored by Claude Code
parent 04ef3972a8
commit f8c4ba9e1d
3 changed files with 534 additions and 0 deletions
+112
View File
@@ -415,3 +415,115 @@ class CaseTimeline(Contract):
if self.status == "ready" and any(e.occurred_at is None for e in self.events):
raise ValueError("存在缺发生时间的事件时状态不得为 ready")
return self
# ---------------------------------------------------------------- 知识卡片(T05-b)
class Origin(StrEnum):
"""知识创建来源,与 data_domain 正交(docs/v2/14-experience-loop.md §4)。"""
history_derived = "history_derived"
manual = "manual"
policy_import = "policy_import"
class GovernanceStatus(StrEnum):
draft = "draft"
in_review = "in_review"
published = "published"
rejected = "rejected"
withdrawn = "withdrawn"
class RuleEffect(StrEnum):
advisory = "advisory"
hard = "hard"
class Condition(Contract):
"""安全条件表达:只允许字段白名单 + 有限算子,不执行表达式(03 §9.1)。"""
field: str = Field(min_length=1, max_length=ID_MAX)
operator: Literal["eq", "in", "present", "absent", "gt", "gte", "lt", "lte"]
value: str | bool | list[str] | None = None
@model_validator(mode="after")
def _check_value_shape(self) -> Condition:
if self.operator == "in":
if not isinstance(self.value, list) or not self.value:
raise ValueError("in 算子需要非空列表")
elif self.operator in ("present", "absent"):
if self.value is not None:
raise ValueError("present/absent 不接受取值")
elif self.operator in ("gt", "gte", "lt", "lte"):
# 数值算子必须声明可解析的数值;比较口径由程序校验,不做表达式求值
try:
float(str(self.value))
except (TypeError, ValueError):
raise ValueError("数值算子需要可解析的数值") from None
return self
class RuleContent(Contract):
"""规则内容。hard 必须具备正式依据与独立审核(BR-26 / E-18)。"""
effect: RuleEffect
predicate: list[Condition] = Field(min_length=1, max_length=32)
on_match: Literal["flag", "block"]
basis_evidence_ids: list[Identifier] = Field(default_factory=list, max_length=EVIDENCE_MAX)
policy_ref: Ref | None = None
authorization_ref: Ref | None = None
@model_validator(mode="after")
def _check_hard_basis(self) -> RuleContent:
if self.effect == RuleEffect.hard:
if self.policy_ref is None and self.authorization_ref is None:
raise ValueError("硬性规则需要正式制度或授权依据,仅凭历史频次不足")
if not self.basis_evidence_ids:
raise ValueError("硬性规则必须附依据证据")
return self
class KnowledgeCard(Contract):
"""Skill/Rule 卡片的目标投影。
**命名冲突提示**:本类的 `card_scope` 是 project/team(03 §9.2),而既有
`component_registry` 里的 `scope` 表示数据域 demo/production。二者语义不同,
投影层必须显式映射,不得互相顶替。
"""
id: Identifier
project_id: Identifier
data_domain: DataDomain
revision: int = Field(ge=1)
version: str = Field(min_length=1, max_length=70)
kind: str = Field(min_length=1, max_length=40)
title: str = Field(min_length=1, max_length=200)
origin: Origin
status: GovernanceStatus
source_event_ids: list[Identifier] = Field(default_factory=list, max_length=EVIDENCE_MAX)
source_evidence_ids: list[Identifier] = Field(default_factory=list, max_length=EVIDENCE_MAX)
parent_version: Ref | None = None
created_by: Identifier
edited_by: Identifier
card_scope: Literal["project", "team"]
team_id: str | None = Field(default=None, max_length=ID_MAX)
conditions: list[Condition] = Field(default_factory=list, max_length=32)
exceptions: list[Condition] = Field(default_factory=list, max_length=32)
effective_at: AwareDatetime
expires_at: AwareDatetime | None = None
reviewer_id: Identifier | None = None
evaluation_ref: Ref | None = None
rule: RuleContent | None = None
@model_validator(mode="after")
def _check_card(self) -> KnowledgeCard:
if self.card_scope == "team" and not self.team_id:
raise ValueError("team 范围的卡片必须有 team_id")
if self.origin == Origin.history_derived and not (
self.source_event_ids or self.source_evidence_ids):
raise ValueError("历史提炼卡片必须保留来源(事件或证据)")
if self.status == GovernanceStatus.published and self.reviewer_id is None:
raise ValueError("已发布卡片必须有审核人")
if self.effective_at and self.expires_at and self.expires_at <= self.effective_at:
raise ValueError("有效期必须晚于生效时间")
return self