Files
mini-openclaw-v2/backend/services/knowledge_cards.py
T
qiaoqichaoandClaude Code 2e7e19471c feat(v2): 纠偏第二批——接通历史经验加工闭环
覆盖 AC-50/51/52(BR-48/49、E-28)。审计确认的 P1-3 缺口"历史提炼与卡片
不在实时 API 调用图上"由此关闭:提炼、候选治理、卡片进方案全部有真实调用方。

后端:
- contracts:新增 E-28 EXTRACTION_UNAVAILABLE(运行级,不进 HTTP 信封)与
  KnowledgeExtraction 记录类型。契约层强制"失败不得带候选",
  AC-50「模型不可用⇒不产生候选」是类型约束而不是文档约定。
- services/experience_extraction.py(新):真实提炼入口。模型不可用或调用失败
  → 记 E-28 且 candidate_id 为空;不产半成品候选。传输纪律同 assistant_model
  (禁进程内代理、零重试),且 tools=[] 使"越权读材料"在结构上不可能。
  自然语言修订同样必须真实调用模型,没有"把中文猜成条件"的回退路径。
- services/knowledge_cards.py:新增 current_draft/revise/list_cards 与
  registry_conditions。修订只产草稿、保留 origin 与来源、升版本记 parent_version;
  test_cases 纳入可改字段——它是 component_evaluation 对自定义 Skill 的发布门禁,
  不开放则提炼出的候选永远卡在评测门外。
- services/project_assistant.py:enqueue 改用 review_plan.assemble(),
  让 T06-b 的依赖预检(E-19 可定位文案)与维度解析不再被绕过。
- api/prescreen.py:case-timeline、knowledge-extractions、knowledge-candidates
  (创建/修订/状态机)、review-plans(装配/状态机)。角色门禁与既有
  /components 入口一致;id 与数据域由服务端生成;按 id 的路由一律在 run() 内
  查归属,否则 KeyError 会变成 500 而不是 404。
- component_registry/review_plan:下沉 current_draft/version_key 与 list_plans。

前端:
- 新增 /experience-center 独立入口(BR-50)与 ExperienceCenter 组件;
  AgentNav 增加「历史经验加工」。
- originBadge 此前零调用方、.origin-* 无任何 CSS——守卫只 grep 库文件字符串,
  于是"测试通过但功能不存在"。本批补齐消费方与样式,并用左边框样式区分四种来源,
  保证去色仍可区分(AC-42/BR-48)。
- 守卫语义按 AC-55 调整:由"禁止新建路由"改为"禁止重复页面"
  (面板不得搬到独立路由、已合并页必须保持重定向),并新增
  experience-center-ui 守卫(已登记 verify.py,共 12 项检查)。

测试:新增 36 项(提炼 15 / 候选治理 15 / 卡片到运行 6),全量 unittest 无回归;
两次同项目运行对照证明卡片确实改变了核查结果,且 basis_ids 指向 卡片id@版本。

未闭合(记录在 17 的剩余缺口):真实模型提炼尚未实测(需端点);
界面未经浏览器点击验证;例外边界未进入可求值语言;规则类候选不在本批。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 00:31:42 +08:00

364 lines
18 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 registry_conditions(mapping: dict) -> list[dict]:
"""{标准字段: 触发值} → 注册表既有的等值条件形状。
注册表条件只能是 `{code, field, operator, threshold}` 且算子限于数值阈值,
因此这里固定用 `eq`——**不试图表达"字段存在/缺失"**,那种语义在注册表条件语言里
没有对应算子(`present` 被 test_knowledge_cards 的既有断言锁定拒绝)。
历史提炼与人工修订共用本函数,避免两处各写一份形状。
"""
return [{"code": "history.condition." + str(index + 1), "field": str(field),
"operator": "eq", "threshold": value}
for index, (field, value) in enumerate(sorted((mapping or {}).items()))]
def draft_card(db, draft_id: str) -> dict:
return _project(store.get(db, "component_draft", draft_id))
# ---------------------------------------------------------------- 候选读取与修订(AC-51)
def current_draft(db, card_ref: str) -> dict:
"""按草稿 id(`组件id@版本`)或组件 id 找当前候选草稿。
判据与方案草稿完全相同,因此直接复用注册表实现,不在这里再写一份排序规则。
"""
return registry.current_draft(db, card_ref)
def list_cards(db, project_id: str, domain: str | None = None) -> list[dict]:
"""项目下的知识卡片(草稿与已发布)。内置全局组件不是项目卡片,按 `_project` 的
既有判据排除,不在这里另写一套过滤。
"""
cards = []
for kind in ("component_draft", "component_version"):
for row in store.listing(db, kind, project_id):
if row.get("project_id") != project_id:
continue
if domain and row.get("scope") != domain:
continue
try:
cards.append(_project(row))
except ValueError:
continue
cards.sort(key=lambda card: (card["id"], card["status"] != "published", card["revision"]))
return cards
# 可改字段:适用范围、例外说明,以及**独立测试样本**。
# test_cases 在这里不是可选项——`component_evaluation` 对自定义 Skill 的发布门禁就是
# "必须有独立测试样本与期望输出"(14 §4 的"专家修改 → 测试"步骤)。不开放它,
# 提炼出来的候选就永远卡在评测门外,闭环无法走通。
EDITABLE_CHANGES = ("conditions", "exception_note", "test_cases")
def _bump(version: str) -> str:
parts = str(version).split(".")
if len(parts) == 3 and all(part.isdigit() for part in parts):
return f"{parts[0]}.{parts[1]}.{int(parts[2]) + 1}"
return f"{version}.1"
def _redraft_body(row: dict) -> dict:
"""把已存在的草稿行还原成可再次提交的注册表体。
必须去掉 `registry.draft()` 自己维护或校验用的键:`revision`(否则会被当成
expected 与 `id` 拼接重复计版本)、`status`/`edited_by`/`approved_by` 等由服务端
决定的字段、以及 `content_hash`(会重算)。
"""
body = {key: value for key, value in row.items()
if key not in ("revision", "content_hash", "status", "edited_by", "approved_by",
"published_at", "evaluation_id", "schema_version", "dependencies")}
body["id"] = row.get("component_id") or row["id"]
return body
def revise(db, card_ref: str, changes: dict, actor: str, note: str, expected_revision=None):
"""按人工修改形成**草稿修订**(AC-51 / BR-42)。
三条不变量,全部由既有治理保证而不是本函数自己承诺:
- **只产草稿**:走 `propose()` → `registry.draft()`,发布仍需独立审核与隔离评测;
- **来源不可洗**:origin 与来源事件原样继承,历史卡片修订后仍是 `history_derived`;
- **不自批**:本函数不发布;发布时 `registry.release()` 才检查 `edited_by == actor`。
已发布的当前版本会被**升版本**并记 `parent_version`;未发布时原地产生新 revision
(与既有"草稿可反复修订"一致)。
"""
if not str(note or "").strip():
raise ValueError("修订必须填写变更说明,否则无法追溯改了什么")
unknown = sorted(set(changes or ()) - set(EDITABLE_CHANGES))
if unknown:
raise ValueError(f"不支持的修订字段:{unknown};本版只允许改适用范围、例外说明与独立测试样本")
if not changes:
raise ValueError("修订必须至少改动一个字段")
draft = current_draft(db, card_ref)
component_id = draft["component_id"]
version_id = f"{component_id}@{draft['version']}"
try:
pointer = store.get(db, "release", component_id)
except KeyError:
pointer = None
if pointer and pointer.get("version_id") == version_id:
version = _bump(draft["version"])
parent = {"id": component_id, "version": draft["version"]}
else:
version = draft["version"]
parent = draft.get("parent_version")
body = _redraft_body(draft)
body["version"] = version
body["change_note"] = str(note)
if "conditions" in changes:
body["conditions"] = registry_conditions(changes["conditions"])
if "exception_note" in changes:
body["exception_note"] = str(changes["exception_note"])
if "test_cases" in changes:
cases = changes["test_cases"]
if not isinstance(cases, list) or any(
not isinstance(case, dict) or not str(case.get("name") or "").strip()
for case in cases):
raise ValueError("测试样本必须是带 name 的对象列表")
body["test_cases"] = cases
if expected_revision is not None:
body["revision"] = expected_revision # registry.draft 用它做乐观并发校验
return propose(db, body, actor, draft["project_id"], origin=draft["origin"],
source_event_ids=draft.get("source_event_ids"),
source_evidence_ids=draft.get("source_evidence_ids"),
parent_version=parent,
card_scope=draft.get("card_scope", "project"),
team_id=draft.get("team_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
# ---------------------------------------------------------------- AC-26
# 运行时的"本次预审补充说明"只影响**本次运行**(run_instruction 落在运行对象上,
# 见 03 §9.3)。要把它变成持久知识,唯一途径是**显式提议为草稿**,且草稿走正常
# 审核流程;指令本身不得改变工具权限。
RUN_SCOPE_FIELDS = ("run_instruction", "tools", "allowed_tools", "permissions", "engine")
def draft_from_run_instruction(db, project_id: str, run_id: str, body: dict, actor: str):
"""把一次运行的补充说明转成**待审草稿**(AC-26)。
三条约束:
1. 只产草稿,绝不因"运行中说过"而直接生效
2. 指令不得携带工具/权限/引擎字段——扩大工具授权必须走独立提议与审核
3. 保留来源运行,便于回溯"这条知识从哪次运行来"
"""
forbidden = sorted(field for field in RUN_SCOPE_FIELDS if field in body)
if forbidden:
raise ValueError(
"本次预审补充说明不能持久化工具权限或执行引擎;"
f"请移除这些字段并通过独立提议与审核:{forbidden}"
)
if not body.get("name") or not body.get("kind"):
raise ValueError("转为持久知识需要明确的名称与类型")
origin = body.get("origin", "manual")
if origin not in ORIGINS:
raise ValueError(f"来源取值须为 {' 或 '.join(ORIGINS)}")
# 运行指令天然没有历史来源,历史来源必须另行提供证据
if origin == "history_derived":
raise ValueError("运行补充说明不能声明为历史提炼;历史提炼需提供来源事件或证据")
enriched = dict(body, derived_from_run=run_id, proposed_via="run_instruction")
return propose(db, enriched, actor, project_id, origin=origin,
card_scope=body.get("card_scope", "project"),
team_id=body.get("team_id"))