"""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