Files
mini-openclaw-v2/backend/services/component_registry.py
T

284 lines
20 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": []},
]
items.append({'id': 'corporate.title', 'kind': 'skill', 'name': '租赁物权属证据核查',
'engine': 'required', 'inputs': ['lease.ownership_evidence'],
'code': 'lease.ownership.missing', 'title': '租赁物权属证据待补充',
'description': '核对购置合同、发票与设备编号对应关系;缺证保持待核实。'})
items.append({'id': 'corporate.prescreen', 'kind': 'plan', 'name': '对公租赁五输入预审',
'audience': ['manager', 'admin'], 'run_kind': 'pre_review',
'skills': [s['id'] for s in skills] + ['corporate.title'],
'rules': ['demo.coverage'], 'template': 'template.ai-pre-review',
'budget': {'timeout_seconds': 180, 'max_tool_calls': 18}, '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"]] + body.get("cards", []):
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