Files
mini-openclaw-v2/backend/services/component_registry.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

275 lines
19 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.
"""Published component snapshots, controlled release and legacy catalog adapter."""
from copy import deepcopy
from pathlib import Path
import re
from services.assistant_store import get, put, listing, digest, now, event, Conflict
from services.skill_executor import EXECUTORS, number
KINDS = {"data_source", "data_card", "skill", "rule_set", "plan", "template", "experience"}
TOOLS = {"read_project_snapshot", "read_document", "read_checks", "read_method", "read_experiences"}
def seed_components():
skills = [
{"id": "corporate.consistency", "name": "负债金额来源不一致", "engine": "compare", "left": "financial.total_liabilities", "right": "due_diligence.liabilities", "inputs": ["financial.total_liabilities", "due_diligence.liabilities"], "code": "financial.liabilities.conflict"},
{"id": "corporate.cashflow", "name": "现金流完整性", "engine": "required", "inputs": ["financial.operating_cashflow"], "code": "financial.cashflow.missing", "title": "经营现金流资料缺失"},
{"id": "corporate.assets", "name": "设备清单存在重复项", "engine": "duplicate", "inputs": ["lease.duplicate_count"], "field": "lease.duplicate_count", "code": "lease.asset.duplicate"},
{"id": "corporate.coverage", "name": "租赁物价值覆盖测算", "engine": "ratio", "inputs": ["lease.total_value", "facility.amount"], "numerator": "lease.total_value", "denominator": "facility.amount", "output": "metrics.coverage"},
{"id": "corporate.analysis", "name": "金租对公业务综合核查", "engine": "model", "inputs": [], "allowed_tools": sorted(TOOLS), "native_skills": ["risk_evidence/SKILL.md", "financial-leasing-lessee-review/SKILL.md", "financial-leasing-asset-review/SKILL.md", "financial-leasing-sale-leaseback-review/SKILL.md", "financial-leasing-guarantee-concentration/SKILL.md"], "instructions": "按金租对公业务方法逐项核查承租人、租赁物、售后回租适用性、增信与集中度,输出有证据的核查建议,不作审批结论。"},
]
items = [dict(s, kind="skill") for s in skills]
items.append({"id": "review.analysis", "kind": "skill", "name": "独立评审复核", "engine": "model", "inputs": [],
"allowed_tools": sorted(TOOLS), "native_skills": ["risk_evidence/SKILL.md", "financial-leasing-lessee-review/SKILL.md", "financial-leasing-asset-review/SKILL.md", "financial-leasing-sale-leaseback-review/SKILL.md", "financial-leasing-guarantee-concentration/SKILL.md", "financial-leasing-review-report/SKILL.md"],
"instructions": "按金租对公业务全链路独立复核送审事实、计算、风险回应与方案,只引用授权证据,明确分歧和待决策事项。"})
items += [{"id": "demo.coverage", "kind": "rule_set", "name": "演示覆盖规则", "conditions": [{"code": "lease.coverage.below_demo_threshold", "field": "metrics.coverage", "operator": "lt", "threshold": "1.2", "title": "租赁物价值覆盖低于演示阈值", "suggestion": "比较融资金额情景;120%仅为演示口径"}]}]
for template_id, name in (("due-diligence", "尽调草稿"), ("review", "评审底稿"), ("committee", "上会摘要")):
items.append({"id": "template." + template_id, "kind": "template", "name": name,
"sections": [{"id": "overview", "title": "项目事实", "dependencies": ["facility.amount", "lease.total_value"]},
{"id": "findings", "title": "待核实事项", "dependencies": ["*"]},
{"id": "judgment", "title": "核查意见与建议", "dependencies": ["*"]}]})
items += [
{"id": "template.ai-pre-review", "kind": "template", "name": "AI预审报告(业务提交前)",
"audience": ["manager"], "sections": [
{"id": "overview", "title": "一、项目概况", "dependencies": ["facility.amount", "lease.total_value"]},
{"id": "completeness", "title": "二、资料完整性", "dependencies": ["*"]},
{"id": "financial", "title": "三、财务与现金流", "dependencies": ["financial.total_assets", "financial.total_liabilities", "financial.operating_cashflow"]},
{"id": "findings", "title": "四、主要风险与证据", "dependencies": ["*"]},
{"id": "manager_response", "title": "五、客户经理说明与补件", "dependencies": ["*"]},
{"id": "judgment", "title": "六、提交前建议", "dependencies": ["*"]}]},
{"id": "template.independent-review", "kind": "template", "name": "对公项目独立评审报告",
"audience": ["reviewer"], "sections": [
{"id": "overview", "title": "一、项目与授信概况", "dependencies": ["facility.amount", "lease.total_value"]},
{"id": "verification", "title": "二、资料与数据复核", "dependencies": ["*"]},
{"id": "financial", "title": "三、财务、现金流与偿债能力", "dependencies": ["*"]},
{"id": "scenario", "title": "四、方案与增信分析", "dependencies": ["facility.amount", "lease.total_value"]},
{"id": "findings", "title": "五、风险分歧与缓释措施", "dependencies": ["*"]},
{"id": "judgment", "title": "六、主审员独立意见", "dependencies": ["*"]}]},
{"id": "template.committee-brief", "kind": "template", "name": "上会审议摘要",
"audience": ["reviewer"], "sections": [
{"id": "overview", "title": "项目摘要", "dependencies": ["*"]},
{"id": "findings", "title": "核心风险", "dependencies": ["*"]},
{"id": "scenario", "title": "建议方案", "dependencies": ["*"]},
{"id": "judgment", "title": "待决策事项", "dependencies": ["*"]}]},
]
items += [
{"id": "corporate.default", "kind": "plan", "version": "1.1.0", "name": "对公送审前检查(兼容)", "audience": ["manager"], "run_kind": "pre_review", "skills": [s["id"] for s in skills], "rules": ["demo.coverage"], "template": "template.due-diligence", "legacy_plan_id": "plan-default", "budget": {"timeout_seconds": 180, "max_tool_calls": 18}, "dependencies": []},
{"id": "corporate.pre-review", "kind": "plan", "version": "1.1.0", "name": "客户经理AI预审", "audience": ["manager"], "run_kind": "pre_review", "skills": [s["id"] for s in skills], "rules": ["demo.coverage"], "template": "template.ai-pre-review", "legacy_plan_id": "plan-default", "budget": {"timeout_seconds": 180, "max_tool_calls": 18}, "dependencies": []},
{"id": "corporate.independent-review", "kind": "plan", "name": "评审员独立复核", "audience": ["reviewer"], "run_kind": "independent_review", "skills": [s["id"] for s in skills if s["id"] != "corporate.analysis"] + ["review.analysis"], "rules": ["demo.coverage"], "template": "template.independent-review", "legacy_plan_id": "plan-default", "budget": {"timeout_seconds": 180, "max_tool_calls": 18}, "dependencies": []},
{"id": "corporate.business-sql", "kind": "plan", "name": "多表业务库AI预审", "audience": ["manager"], "run_kind": "pre_review", "skills": [s["id"] for s in skills if s["id"] != "corporate.coverage"], "rules": ["demo.coverage"], "template": "template.ai-pre-review", "legacy_plan_id": "plan-business-sql", "budget": {"timeout_seconds": 120, "max_tool_calls": 12}, "dependencies": []},
]
return items
def normalize(body, actor):
result = deepcopy(body)
result.setdefault("schema_version", 1)
result.setdefault("version", "1.0.0")
result.setdefault("scope", "demo")
result.setdefault("dependencies", [])
result.setdefault("description", result.get("name", ""))
result.setdefault("owner", actor)
result.setdefault("effective_from", now())
result.setdefault("allowed_tools", sorted(TOOLS) if result.get("engine") == "model" else [])
result.setdefault("status", "draft")
result["content_hash"] = digest({k: v for k, v in result.items() if k not in ("content_hash", "revision")})
return result
def validate(body):
if body.get("kind") not in KINDS or not re.fullmatch(r"[A-Za-z0-9_.@-]{1,180}", str(body.get("id", ""))) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,70}", str(body.get("version", ""))):
raise ValueError("组件需要稳定 ID、类型和版本")
if body.get("schema_version") != 1 or body.get("scope") not in ("demo", "production"):
raise ValueError("组件协议或数据域不支持")
if body["kind"] == "skill":
if body.get("engine") not in EXECUTORS or not set(body.get("allowed_tools", [])).issubset(TOOLS):
raise ValueError("Skill 执行器或工具权限不受支持")
if body.get("side_effects", "none") != "none":
raise ValueError("首版仅支持无外部副作用的 Skill")
required = {"ratio": ("numerator", "denominator", "output"), "compare": ("left", "right", "code"), "duplicate": ("field", "code"), "required": ("inputs",), "model": ("instructions",)}
if any(not body.get(key) for key in required.get(body["engine"], ())):
raise ValueError("Skill 缺少执行器必需参数")
if body["engine"] == "model" and not {"read_project_snapshot", "read_checks"}.issubset(body["allowed_tools"]):
raise ValueError("模型 Skill 必须允许读取项目快照及规则")
if body.get("native_skills") and "read_method" not in body["allowed_tools"]:
raise ValueError("原生方法需要 read_method 权限")
if body["kind"] == "plan":
if not body.get("skills") or not body.get("template") or not isinstance(body.get("rules", []), list):
raise ValueError("方案需要技能、规则列表和模板")
budget = body.get("budget", {})
if not 1 <= budget.get("timeout_seconds", 120) <= 300 or not 2 <= budget.get("max_tool_calls", 12) <= 30:
raise ValueError("方案超时预算须为 1–300 秒,工具预算须为 2–30 次")
if body["kind"] == "data_source":
if body.get("type") not in ("demo", "SQLite", "PostgreSQL", "MySQL"):
raise ValueError("数据源类型不支持")
if any(body.get(k) for k in ("password", "token", "api_key")):
raise ValueError("禁止在组件中保存凭据,请使用服务器环境变量引用")
if body["kind"] == "data_card":
if not body.get("source_id") or not body.get("mapping") or not body.get("sql"):
raise ValueError("数据卡需要数据源、字段映射和受控 SQL")
if body["kind"] == "rule_set":
for condition in body.get("conditions", []):
if condition.get("operator") not in ("gt", "lt", "eq", "gte", "lte") or not isinstance(condition.get("field"), str):
raise ValueError("规则条件无效")
number(condition.get("threshold"))
if body["kind"] == "template" and (not body.get("sections") or len({s["id"] for s in body["sections"]}) != len(body["sections"])):
raise ValueError("模板章节不能为空或重复")
def seed(db):
for item in seed_components():
body = normalize(dict(item, status="published"), "system-demo")
validate(body)
version_id = body["id"] + "@" + body["version"]
if not any(v["id"] == version_id for v in listing(db, "component_version")):
put(db, "component_version", dict(body, id=version_id, component_id=body["id"]), immutable=True)
if not any(r["id"] == body["id"] for r in listing(db, "release")):
put(db, "release", {"id": body["id"], "version_id": version_id, "scope": body["scope"]})
def resolve(db, component_id):
version_id = get(db, "release", component_id)["version_id"]
body = get(db, "component_version", version_id)
return dict(body, id=body["component_id"])
def ordered(components):
items = {s["id"]: s for s in components}
result, visiting, done = [], set(), set()
def visit(key):
if key in done:
return
if key in visiting:
raise ValueError("Skill 存在循环依赖")
if key not in items:
raise ValueError("缺少 Skill 依赖:" + key)
visiting.add(key)
for dep in items[key].get("depends_on", items[key].get("dependencies", [])):
visit(dep)
visiting.remove(key)
done.add(key)
result.append(items[key])
for key in items:
visit(key)
outputs = [s["output"] for s in result if s.get("engine") == "ratio"]
if len(outputs) != len(set(outputs)):
raise ValueError("Skill 输出字段冲突")
producers = {s["output"]: s["id"] for s in result if s.get("engine") == "ratio"}
seen = set()
for item in result:
if any(f in producers and producers[f] not in seen for f in item.get("inputs", [])):
raise ValueError("输入依赖的输出尚未产生,请声明正确的 depends_on")
seen.add(item["id"])
return result
def freeze(db, plan_id, legacy_catalog=None):
plan = resolve(db, plan_id)
if plan["kind"] != "plan":
raise ValueError("所选组件不是业务方案")
skills = [resolve(db, key) for key in plan["skills"]]
rules = [resolve(db, key) for key in plan["rules"]]
result = {"plan": plan, "skills": skills, "rules": rules, "template": resolve(db, plan["template"])}
if any(s["kind"] != "skill" for s in skills) or any(r["kind"] != "rule_set" for r in rules) or result["template"]["kind"] != "template":
raise ValueError("方案引用了错误类型的组件")
if plan.get("legacy_plan_id") and legacy_catalog is not None:
old = next((p for p in legacy_catalog["plans"] if p["id"] == plan["legacy_plan_id"] and p["status"] == "published"), None)
if old is None:
raise Conflict("关联旧业务方案尚未发布")
result["legacy_plan"] = deepcopy(old)
for kind, target in (("skills", skills), ("rules", rules)):
for key in old.get(kind, []):
item = next((s for s in legacy_catalog[kind] if s["id"] == key and s["status"] == "published"), None)
if not item:
raise Conflict("旧方案依赖未发布:" + key)
target.append(normalize(dict(item, kind="skill" if kind == "skills" else "rule_set"), "legacy-catalog"))
result["legacy_cards"] = []
result["legacy_connections"] = []
for key in old.get("cards", []):
card = next((c for c in legacy_catalog["cards"] if c["id"] == key and c["status"] == "published"), None)
if not card:
raise Conflict("旧方案数据卡未发布:" + key)
result["legacy_cards"].append(deepcopy(card))
connection = next((c for c in legacy_catalog["connections"] if c["id"] == card["connection"] and c["status"] == "published"), None)
if not connection:
raise Conflict("数据源尚未发布")
if connection not in result["legacy_connections"]:
result["legacy_connections"].append(deepcopy(connection))
for key in plan.get("cards", []):
card = resolve(db, key)
connection = resolve(db, card.get("source_id", ""))
if card["kind"] != "data_card" or connection["kind"] != "data_source" or card["scope"] != plan["scope"] or connection["scope"] != plan["scope"]:
raise ValueError("数据卡及数据源类型或数据域不匹配")
result.setdefault("legacy_cards", []).append(dict(card, connection=connection["id"]))
result.setdefault("legacy_connections", []).append(dict(connection, data_domain=connection["scope"]))
# Resolve native method text now, so edits during the run cannot change it.
from tools.skills_scanner import discover_skills
native = {s["legacy_id"]: s for s in discover_skills(Path(__file__).resolve().parents[1])}
for skill in skills:
skill["methods"] = {}
for key in skill.get("native_skills", []):
if key not in native:
raise ValueError("原生 Skill 不存在:" + key)
skill["methods"][key] = native[key]["content"]
skill["content_hash"] = digest({k: v for k, v in skill.items() if k != "content_hash"})
result["skills"] = ordered(skills)
for item in skills + rules + [result["template"]]:
validate(item)
if item.get("scope", plan["scope"]) != plan["scope"]:
raise ValueError("演示与正式组件不能混用")
result["content_hash"] = digest(result)
return result
def version_key(version: str) -> tuple:
"""点分版本的比较键;非数字段按 0 处理,不做语义猜测。"""
return tuple(int(part) if part.isdigit() else 0 for part in str(version).split("."))
def current_draft(db, component_ref: str) -> dict:
"""按草稿 id(`组件id@版本`)或组件 id 找当前草稿。
组件 id 在"修订 → 升版本"后仍指向同一对象,业务侧更适合用它;草稿 id 用于对某个
具体版本操作。找不到即 KeyError(调用方转 404)。
"""
if "@" in str(component_ref):
return get(db, "component_draft", component_ref)
matches = [row for row in listing(db, "component_draft")
if row.get("component_id") == component_ref]
if not matches:
raise KeyError(f"组件草稿不存在:{component_ref}")
# 先比**版本**再比 revision:升版本后旧版本草稿仍然存在(历史不可覆盖),
# 而它的 revision 可能比新草稿大,只按 revision 取会退回旧版本。
return max(matches, key=lambda row: (version_key(row.get("version", "")),
row.get("revision", 0)))
def draft(db, body, actor):
body = normalize({**{k: v for k, v in body.items() if k not in ("evaluation", "evaluation_id", "approved_by", "published_at")}, "status": "draft", "edited_by": actor}, actor)
validate(body)
version_id = body["id"] + "@" + body["version"]
if any(v["id"] == version_id for v in listing(db, "component_version")):
raise Conflict("该版本已发布,请使用新版本号")
return put(db, "component_draft", dict(body, id=version_id, component_id=body["id"]), expected=body.get("revision"))
def release(db, draft_id, actor, preview=False):
body = get(db, "component_draft", draft_id)
if not preview and body["edited_by"] == actor:
raise PermissionError("需要独立审核人发布")
evaluation = get(db, "component_evaluation", body.get("evaluation_id", "")) if body.get("evaluation_id") else None
current_hash = digest({k: v for k, v in body.items() if k not in ("revision", "evaluation", "evaluation_id")})
if not evaluation or not evaluation["passed"] or evaluation["candidate_hash"] != current_hash:
raise Conflict("候选需要通过当前版本的隔离评测才能发布")
validate(body)
if body["kind"] == "plan":
ordered([resolve(db, key) for key in body["skills"]])
for key in body["rules"] + [body["template"]]:
resolve(db, key)
if body["kind"] == "skill":
ordered([dict(body, id=body["component_id"])] + [resolve(db, key) for key in body.get("depends_on", [])])
published = normalize(dict(body, status="published", approved_by=actor, published_at=now()), actor)
put(db, "component_version", published, immutable=True)
put(db, "release", {"id": body["component_id"], "version_id": body["id"], "scope": body["scope"]})
event(db, "", "component.release", actor, {"version_id": body["id"]})
return published