Files
mini-openclaw-v2/backend/services/component_registry.py
T
qiaoqichaoandClaude Code d56c699ce6 chore(v2): 从兴邦预审v2白名单抽取独立可运行基座
按 docs/v2/05-implementation-plan.md §1 候选迁移表与 claude_use/T00 白名单,
从 55e2cdb 抽取 224 个文件(2.48 MB)到独立目录 F:/mini-OpenClaw-v2。

- 排除:凭据(.env/server-deploy.conf/旧DELIVERY.md)、运行数据(xingbang_data 等
  4 个 SQLite)、依赖(venv/node_modules 符号链接)、归档(_backup*/test-evidence/
  ori_file)、Office 临时文件
- 目标目录独立 git 仓库,初始恢复点;Gitea 远程与分支待 OQ-06 Q2 定案
- 未部署、未启动服务、未调用模型

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 12:58:26 +08:00

252 lines
18 KiB
Python

"""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", "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": 120, "max_tool_calls": 12}, "dependencies": []},
{"id": "corporate.pre-review", "kind": "plan", "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": 120, "max_tool_calls": 12}, "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 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