"""Configurable risk-agent catalog for the Xingbang phase-one experience.""" import json import sqlite3 import math import os import ipaddress from urllib.parse import urlparse import requests from contextlib import contextmanager, closing from copy import deepcopy from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 from fastapi import APIRouter, HTTPException, Depends, Request from pydantic import BaseModel, Field from api.risk_agent_access import authorize, check_project, visible_result from api.risk_agent_alignment import align from api.risk_agent_connectors import execute from services.business_demo_database import DEFAULT_PATH as BUSINESS_DB_PATH, build_business_demo_database router = APIRouter(prefix="/api/xingbang/risk-agent", tags=["风险研判 Agent"], dependencies=[Depends(authorize)]) DB_PATH = Path(__file__).resolve().parents[1] / "xingbang_data" / "risk_agent.sqlite3" SKILLS_BASE_DIR = Path(__file__).resolve().parents[1] SEED = { "connections": [{"id":"conn-erp","name":"核心 ERP 只读库","type":"PostgreSQL","host":"10.20.8.16","database":"xb_erp","status":"healthy","latency":38,"updated":"今天 09:42"}], "cards": [ {"id":"card-project","name":"项目主数据","category":"项目","description":"项目、客户、授信方案与流程状态","connection":"conn-erp","tables":["vw_project","vw_customer"],"parameter":"project_id","sql":"SELECT ... FROM vw_project p JOIN vw_customer c ON ... WHERE p.project_no = :project_id","grain":"每个项目一条","fields":["project.id","customer.name","facility.amount","facility.term"],"skills":["skill-quality"],"status":"published","version":"v1.4","coverage":100,"lastRun":"2 分钟前"}, {"id":"card-finance","name":"多期财务报表","category":"财务","description":"资产负债、利润、现金流及报表口径","connection":"conn-erp","tables":["fin_report","fin_balance","fin_cashflow"],"parameter":"project_id","sql":"SELECT ... FROM fin_report r LEFT JOIN fin_balance b ON ... WHERE r.project_id = :project_id","grain":"客户 + 期间 + 口径","fields":["financial.period","financial.scope","financial.total_assets","financial.total_liabilities","financial.operating_cashflow"],"skills":["skill-quality","skill-finance","skill-trend"],"status":"published","version":"v2.1","coverage":92,"lastRun":"6 分钟前"}, {"id":"card-credit","name":"征信与涉诉","category":"征信","description":"征信时点、逾期、被执行及诉讼记录","connection":"conn-erp","tables":["credit_summary","legal_case"],"parameter":"project_id","sql":"SELECT ... FROM credit_summary WHERE project_id = :project_id","grain":"客户 + 记录日期","fields":["credit.report_date","credit.overdue_count","legal.case_count","legal.amount"],"skills":["skill-quality","skill-credit"],"status":"published","version":"v1.7","coverage":78,"lastRun":"11 分钟前"}, {"id":"card-lease","name":"租赁物与增信","category":"增信","description":"租赁物价值、保额、保证金与担保信息","connection":"conn-erp","tables":["lease_asset","insurance_policy","guarantee"],"parameter":"project_id","sql":"SELECT ... FROM lease_asset WHERE project_id = :project_id","grain":"项目 + 增信措施","fields":["lease.total_value","insurance.amount","deposit.amount","guarantee.amount"],"skills":["skill-quality","skill-coverage"],"status":"draft","version":"v0.8","coverage":67,"lastRun":"尚未发布"} ], "skills": [ {"id":"skill-quality","name":"数据质量体检","category":"质量检查","description":"检查缺失、重复、期间、单位与口径冲突","inputs":["*"],"output":"quality.findings","mode":"Python","status":"published","version":"v1.5"}, {"id":"skill-finance","name":"财务偿债分析","category":"确定性计算","description":"计算杠杆、流动性、利息保障和现金流覆盖","inputs":["financial.total_assets","financial.total_liabilities"],"output":"metrics.financial","mode":"Python","status":"published","version":"v2.3"}, {"id":"skill-trend","name":"多期趋势识别","category":"模型分析","description":"识别连续恶化与异常波动,生成核查建议","inputs":["financial.period","financial.total_assets"],"output":"risks.trend","mode":"本地模型","status":"published","version":"v1.2"}, {"id":"skill-credit","name":"征信风险扫描","category":"规则检查","description":"检查逾期、被执行、查询频次和数据新鲜度","inputs":["credit.report_date","credit.overdue_count"],"output":"risks.credit","mode":"规则 + Python","status":"published","version":"v1.9"}, {"id":"skill-coverage","name":"风险缓释覆盖测算","category":"情景测算","description":"测算租赁物、保险和保证金的折价覆盖水平","inputs":["lease.total_value","insurance.amount"],"output":"metrics.coverage","mode":"Python","status":"draft","version":"v0.9"} ], "rules": [{"id":"rules-corporate","name":"对公项目提交前检查","description":"财务、征信、涉诉与增信基础规则","count":24,"status":"published","version":"v3.2"},{"id":"rules-energy","name":"新能源行业补充规则","description":"补贴依赖、设备残值与集中度规则","count":9,"status":"published","version":"v1.6"}], "plans": [{"id":"plan-default","name":"对公项目提交前自查","description":"客户经理提交评审前的完整性与风险自查","cards":["card-project","card-finance","card-credit"],"skills":["skill-quality","skill-finance","skill-trend","skill-credit"],"rules":["rules-corporate"],"status":"published","version":"v2.4","runs":128},{"id":"plan-energy","name":"新能源设备租赁自查","description":"在通用自查基础上增加租赁物和行业风险","cards":["card-project","card-finance","card-credit","card-lease"],"skills":["skill-quality","skill-finance","skill-trend","skill-credit","skill-coverage"],"rules":["rules-corporate","rules-energy"],"status":"draft","version":"v1.0","runs":12}], } # Sample tables use the same controlled query path as configured local SQLite sources. for connection in SEED["connections"]: connection.update(type="demo", host="", database="", status="published", latency=None, updated="", name="演示项目数据库") SAMPLES = { "project": [{"project_id":"XB-2026-001","customer_name":"示例精密制造有限公司","amount":5000,"term":36}], "finance": [{"project_id":"XB-2026-001","period":"2025","scope":"合并 / 万元","assets":18000,"liabilities":13200,"cashflow":None}], "credit": [{"project_id":"XB-2026-001","report_date":"2026-05-01","overdue_count":1}], "collateral": [{"project_id":"XB-2026-001","value":5400,"insurance":4500}], } MAPPINGS = [ {"project.id":"project_id","customer.name":"customer_name","facility.amount":"amount","facility.term":"term"}, {"financial.period":"period","financial.scope":"scope","financial.total_assets":"assets","financial.total_liabilities":"liabilities","financial.operating_cashflow":"cashflow"}, {"credit.report_date":"report_date","credit.overdue_count":"overdue_count"}, {"lease.total_value":"value","insurance.amount":"insurance"}, ] for card, table, mapping in zip(SEED["cards"], SAMPLES, MAPPINGS): card.update(sql=f"SELECT * FROM {table} WHERE project_id = :project_id", tables=[table], mapping=mapping, fields=list(mapping), coverage=None, lastRun="尚未运行", status="published", unit="万元", period="2025", scope="合并") for skill, engine in zip(SEED["skills"], ["quality","ratio","model","overdue","ratio"]): skill.update(engine=engine, instructions=skill["description"], status="published") SEED["skills"][1].update(numerator="financial.total_liabilities", denominator="financial.total_assets", output="metrics.debt_ratio") SEED["skills"][4].update(numerator="lease.total_value", denominator="facility.amount", inputs=["lease.total_value","facility.amount"], output="metrics.coverage") SEED["skills"][0].update(description="识别标准字段空值与映射缺失") SEED["skills"][1].update(description="根据资产与负债计算资产负债率") SEED["skills"][3].update(description="检查征信输入依赖,由规则集判断逾期风险") SEED["skills"][4].update(description="计算租赁物价值与授信金额的覆盖比例") SEED["rules"][0]["conditions"] = [ {"field":"metrics.debt_ratio","operator":"gt","threshold":0.7,"title":"财务杠杆偏高","suggestion":"补充最新财报及新增借款用途说明"}, {"field":"credit.overdue_count","operator":"gt","threshold":0,"title":"存在逾期记录","suggestion":"核实逾期原因、金额与结清情况"}, ] SEED["rules"][1]["conditions"] = [{"field":"metrics.coverage","operator":"lt","threshold":1.2,"title":"租赁物价值覆盖不足","suggestion":"核实评估依据及补充增信措施"}] for plan in SEED["plans"]: plan.update(runs=0, status="published") plan["skills"] = [x for x in plan["skills"] if x != "skill-trend"] BUSINESS_TABLES = [ "corporate_customer", "lease_project", "financing_scheme", "financial_statement", "lease_asset", "insurance_policy", "guarantee_measure", "credit_snapshot", "legal_case", "repayment_source", "project_document", ] SEED["connections"].append({ "id": "conn-business-demo", "name": "对公租赁多表业务库(演示)", "type": "SQLite", "database": str(BUSINESS_DB_PATH), "data_domain": "demo", "allowed_tables": BUSINESS_TABLES, "timeout": 10, "max_rows": 500, "status": "published", "latency": None, "updated": "随演示库自动生成", }) SEED["cards"].extend([ { "id": "card-business-project", "name": "项目与授信方案", "category": "项目", "description": "从客户、项目和当前融资方案三张表读取项目基础事实", "connection": "conn-business-demo", "tables": ["corporate_customer", "lease_project", "financing_scheme"], "parameter": "project_id", "sql": """SELECT p.project_id, c.customer_name, f.facility_amount, f.facility_term_months FROM lease_project p JOIN corporate_customer c ON c.customer_id = p.customer_id JOIN financing_scheme f ON f.project_id = p.project_id AND f.status = '生效' WHERE p.project_id = :project_id""", "grain": "每个项目一条当前生效方案", "mapping": { "project.id": "project_id", "customer.name": "customer_name", "facility.amount": "facility_amount", "facility.term": "facility_term_months", }, "field_units": {"facility.amount": "万元"}, "alignment": {"project_field": "project_id", "selection": "single"}, "skills": ["skill-quality"], "status": "published", "version": "v1.0", "coverage": None, "lastRun": "尚未运行", }, { "id": "card-business-finance", "name": "多期财务报表", "category": "财务", "description": "按项目读取多期合并报表,由数据卡选择最新一期并保留历史记录", "connection": "conn-business-demo", "tables": ["financial_statement"], "parameter": "project_id", "sql": """SELECT project_id, period, scope, currency_unit, total_assets, total_liabilities, operating_cashflow FROM financial_statement WHERE project_id = :project_id ORDER BY period""", "grain": "项目 + 期间 + 口径", "mapping": { "financial.period": "period", "financial.scope": "scope", "financial.total_assets": "total_assets", "financial.total_liabilities": "total_liabilities", "financial.operating_cashflow": "operating_cashflow", }, "field_units": {"financial.total_assets": "万元", "financial.total_liabilities": "万元", "financial.operating_cashflow": "万元"}, "alignment": {"project_field": "project_id", "period_field": "period", "scope_field": "scope", "selection": "latest", "target_scope": "合并"}, "skills": ["skill-quality", "skill-finance"], "status": "published", "version": "v1.0", "coverage": None, "lastRun": "尚未运行", }, { "id": "card-business-credit", "name": "征信与涉诉汇总", "category": "征信", "description": "读取最新征信快照,并汇总项目涉诉数量和金额", "connection": "conn-business-demo", "tables": ["credit_snapshot", "legal_case"], "parameter": "project_id", "sql": """SELECT c.project_id, c.report_date, c.overdue_count, c.query_count_6m, COUNT(l.case_id) AS legal_case_count, COALESCE(SUM(l.amount), 0) AS legal_amount FROM credit_snapshot c LEFT JOIN legal_case l ON l.project_id = c.project_id WHERE c.project_id = :project_id AND c.report_date = (SELECT MAX(c2.report_date) FROM credit_snapshot c2 WHERE c2.project_id = :project_id) GROUP BY c.project_id, c.report_date, c.overdue_count, c.query_count_6m""", "grain": "项目 + 征信报告日期", "mapping": { "credit.report_date": "report_date", "credit.overdue_count": "overdue_count", "credit.query_count_6m": "query_count_6m", "legal.case_count": "legal_case_count", "legal.amount": "legal_amount", }, "field_units": {"legal.amount": "万元"}, "period": "最新征信", "alignment": {"project_field": "project_id", "selection": "single"}, "skills": ["skill-quality", "skill-credit"], "status": "published", "version": "v1.0", "coverage": None, "lastRun": "尚未运行", }, { "id": "card-business-lease", "name": "租赁物与增信汇总", "category": "增信", "description": "按项目汇总租赁物价值、重复编号、保险、保证和保证金", "connection": "conn-business-demo", "tables": ["lease_project", "lease_asset", "insurance_policy", "guarantee_measure"], "parameter": "project_id", "sql": """WITH asset_summary AS ( SELECT project_id, SUM(appraised_value) AS lease_total_value, COUNT(*) - COUNT(DISTINCT serial_no) AS duplicate_asset_count FROM lease_asset GROUP BY project_id ), policy_summary AS ( SELECT project_id, SUM(insured_amount) AS insurance_amount FROM insurance_policy WHERE status = '有效' GROUP BY project_id ), guarantee_summary AS ( SELECT project_id, SUM(CASE WHEN guarantee_type = '保证金' THEN guarantee_amount ELSE 0 END) AS deposit_amount, SUM(CASE WHEN guarantee_type <> '保证金' THEN guarantee_amount ELSE 0 END) AS guarantee_amount FROM guarantee_measure GROUP BY project_id ) SELECT p.project_id, a.lease_total_value, a.duplicate_asset_count, COALESCE(i.insurance_amount, 0) AS insurance_amount, COALESCE(g.deposit_amount, 0) AS deposit_amount, COALESCE(g.guarantee_amount, 0) AS guarantee_amount FROM lease_project p LEFT JOIN asset_summary a ON a.project_id = p.project_id LEFT JOIN policy_summary i ON i.project_id = p.project_id LEFT JOIN guarantee_summary g ON g.project_id = p.project_id WHERE p.project_id = :project_id""", "grain": "每个项目一条增信汇总", "mapping": { "lease.total_value": "lease_total_value", "lease.duplicate_count": "duplicate_asset_count", "insurance.amount": "insurance_amount", "deposit.amount": "deposit_amount", "guarantee.amount": "guarantee_amount", }, "field_units": {"lease.total_value": "万元", "insurance.amount": "万元", "deposit.amount": "万元", "guarantee.amount": "万元"}, "alignment": {"project_field": "project_id", "selection": "single"}, "skills": ["skill-quality", "skill-coverage"], "status": "published", "version": "v1.0", "coverage": None, "lastRun": "尚未运行", }, { "id": "card-business-repayment", "name": "还款来源与材料", "category": "还款", "description": "读取项目还款来源、预计金额和佐证材料状态", "connection": "conn-business-demo", "tables": ["repayment_source"], "parameter": "project_id", "sql": """SELECT project_id, source_type, annual_amount, evidence_status, CASE WHEN source_type = '特定项目回款' THEN 'true' ELSE 'false' END AS collection_dependency FROM repayment_source WHERE project_id = :project_id""", "grain": "项目 + 还款来源", "mapping": { "collection.dependency": "collection_dependency", "collection.evidence": "evidence_status", "repayment.annual_amount": "annual_amount", "repayment.source_type": "source_type", }, "field_units": {"repayment.annual_amount": "万元"}, "alignment": {"project_field": "project_id", "selection": "single"}, "skills": ["skill-quality"], "status": "published", "version": "v1.0", "coverage": None, "lastRun": "尚未运行", }, ]) SEED["plans"].append({ "id": "plan-business-sql", "name": "对公多表业务库预审", "description": "通过受控 SQL 从多张业务表读取项目、财务、征信、租赁物和还款来源", "cards": ["card-business-project", "card-business-finance", "card-business-credit", "card-business-lease", "card-business-repayment"], "skills": ["skill-quality", "skill-finance", "skill-credit", "skill-coverage"], "rules": ["rules-corporate"], "status": "published", "version": "v1.0", "runs": 0, }) FINANCIAL_LEASING_SKILLS = [ { "id": "skill-fl-lessee", "name": "承租人及还款能力核查", "category": "承租人尽调", "description": "核查经营、财务、信用、还款来源及尽调证据是否足以支持送审", "engine": "model", "mode": "mini-openclaw + 本地模型", "inputs": [ "customer.name", "financial.total_assets", "financial.total_liabilities", "financial.operating_cashflow", "repayment.source_type", "repayment.annual_amount", "collection.evidence", "credit.report_date", "credit.overdue_count", "legal.case_count", "legal.amount", ], "output": "advisory.lessee", "instructions": "按承租人尽调方法核查第一还款来源、经营财务、征信涉诉和资料真实性,逐项引用证据。", "native_skills": ["financial-leasing-lessee-review/SKILL.md"], "status": "published", "version": "v1.0", }, { "id": "skill-fl-asset", "name": "租赁物适格性与权属核查", "category": "租赁物核验", "description": "核查租赁物真实性、权属、特定化、价值、保险和可处置性", "engine": "model", "mode": "mini-openclaw + 本地模型", "inputs": ["lease.total_value", "lease.duplicate_count", "insurance.amount", "facility.amount"], "output": "advisory.lease_asset", "instructions": "逐项核对租赁物适格性、权属、设备标识、估值与保险证据,不作权属或审批结论。", "native_skills": ["financial-leasing-asset-review/SKILL.md"], "status": "published", "version": "v1.0", }, { "id": "skill-fl-sale-leaseback", "name": "售后回租真实性核查", "category": "售后回租", "description": "核查原始权属、所有权转移、估值、融资需求和资金用途真实性", "engine": "model", "mode": "mini-openclaw + 本地模型", "inputs": ["facility.amount", "lease.total_value", "repayment.source_type", "collection.evidence"], "output": "advisory.sale_leaseback", "instructions": "先判断是否适用售后回租,再核查原始权属、转移、估值、融资需求和资金用途证据。", "native_skills": ["financial-leasing-sale-leaseback-review/SKILL.md"], "status": "published", "version": "v1.0", }, { "id": "skill-fl-guarantee", "name": "增信、关联交易与集中度核查", "category": "增信与集中度", "description": "核查保证与抵质押有效性,识别关联关系和集中度数据缺口", "engine": "model", "mode": "mini-openclaw + 本地模型", "inputs": ["guarantee.amount", "deposit.amount", "insurance.amount", "legal.case_count", "credit.overdue_count"], "output": "advisory.guarantee", "instructions": "分开评价各类增信,穿透关联方,并明确集中度判断所需的组合数据。", "native_skills": ["financial-leasing-guarantee-concentration/SKILL.md"], "status": "published", "version": "v1.0", }, { "id": "skill-fl-review-report", "name": "独立评审与报告证据核查", "category": "评审报告", "description": "将数据来源、AI判断、评审员意见和最终报告逐项建立证据对应", "engine": "model", "mode": "mini-openclaw + 本地模型", "inputs": [], "output": "advisory.review_report", "instructions": "全链路独立复核并形成可追溯报告候选,不覆盖人工确认内容。", "native_skills": ["financial-leasing-review-report/SKILL.md"], "status": "published", "version": "v1.0", }, ] SEED["skills"].extend(FINANCIAL_LEASING_SKILLS) SEED["plans"].append({ "id": "plan-financial-leasing-corporate", "name": "金租对公项目全链路预审", "description": "按现行金租监管框架核查承租人、租赁物、售后回租、增信与集中度", "cards": ["card-business-project", "card-business-finance", "card-business-credit", "card-business-lease", "card-business-repayment"], "skills": ["skill-quality", "skill-finance", "skill-credit", "skill-coverage", "skill-fl-lessee", "skill-fl-asset", "skill-fl-sale-leaseback", "skill-fl-guarantee"], "rules": ["rules-corporate"], "status": "published", "version": "v1.0", "runs": 0, }) def _now(): return datetime.now(timezone.utc).isoformat() @contextmanager def _db(): build_business_demo_database() DB_PATH.parent.mkdir(parents=True, exist_ok=True) db=sqlite3.connect(DB_PATH); db.execute("CREATE TABLE IF NOT EXISTS catalog (kind TEXT, id TEXT, body TEXT, PRIMARY KEY(kind,id))") db.execute("CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY, body TEXT)") db.execute("CREATE TABLE IF NOT EXISTS revisions (kind TEXT, id TEXT, version TEXT, body TEXT)") db.execute("CREATE TABLE IF NOT EXISTS reviews (id TEXT PRIMARY KEY, kind TEXT, item_id TEXT, body TEXT)") db.execute("CREATE TABLE IF NOT EXISTS audit (time TEXT, actor TEXT, action TEXT, detail TEXT)") for kind, rows in SEED.items(): for row in rows: db.execute("INSERT OR IGNORE INTO catalog VALUES (?,?,?)",(kind,row["id"],json.dumps(row,ensure_ascii=False))) db.commit() try: with db: yield db finally: db.close() def _all(db,kind): return [json.loads(x[0]) for x in db.execute("SELECT body FROM catalog WHERE kind=? ORDER BY rowid",(kind,))] class Item(BaseModel): name: str = Field(min_length=1,max_length=120) data: dict = Field(default_factory=dict) def validate(kind, body): if body.get("status") not in ("draft", "published", "archived"): raise HTTPException(422, "状态不合法") if kind == "cards": if not isinstance(body.get("mapping", {}), dict): raise HTTPException(422, "字段映射须为标准字段到源字段的对象") if not all(isinstance(k,str) and isinstance(v,str) for k,v in body.get("mapping",{}).items()): raise HTTPException(422, "映射字段名称须为文本") alignment=body.get("alignment",{}) if not isinstance(alignment,dict) or alignment.get("selection","single") not in ("single","latest","period","all"): raise HTTPException(422,"请选择有效的数据对齐方式") if not isinstance(body.get("field_units",{}),dict): raise HTTPException(422,"字段单位必须为对象") if kind == "connections": if any(k in body for k in ("password","secret","token","dsn")): raise HTTPException(422,"不要提交明文凭据,请使用服务器环境变量引用") if not isinstance(body.get("allowed_tables",[]),list) or not all(isinstance(t,str) for t in body.get("allowed_tables",[])): raise HTTPException(422,"允许访问的表必须为字符串数组") for field in ("timeout","max_rows"): try: int(body.get(field,15 if field=="timeout" else 1000)) except (ValueError,TypeError): raise HTTPException(422,f"{field} 必须为整数") if kind == "skills" and body.get("engine") not in ("quality","ratio","overdue","model"): raise HTTPException(422, "请选择受支持的 Skill 执行器") if kind == "skills": native=body.get("native_skills",[]) if not isinstance(native,list) or not all(isinstance(s,str) for s in native): raise HTTPException(422,"原生 Skills 必须为勾选列表") if native: from tools.skills_scanner import discover_skills available={s["id"] for s in discover_skills(SKILLS_BASE_DIR)} if not set(native).issubset(available): raise HTTPException(422,"所选原生 Skill 已不存在") if kind == "skills" and body.get("engine")=="ratio": if not all(isinstance(body.get(k),str) and body[k].strip() for k in ("numerator","denominator","output")): raise HTTPException(422,"比率 Skill 必须填写分子、分母和输出字段") if kind == "rules": if not isinstance(body.get("natural_language",""),str) or len(body.get("natural_language",""))>12000: raise HTTPException(422,"自然语言规则必须为文本,最多 12000 字符") if not isinstance(body.get("conditions", []),list): raise HTTPException(422,"规则条件必须是数组") for c in body.get("conditions", []): if not isinstance(c,dict) or c.get("operator") not in ("gt","lt","eq") or not isinstance(c.get("threshold"),(int,float)) or not math.isfinite(c["threshold"]) or not isinstance(c.get("field"),str) or not c["field"].strip(): raise HTTPException(422, "规则需包含字段、gt/lt/eq 运算符及有限数值阈值") if kind in ("cards","plans","skills"): for field in ({"plans":["cards","skills","rules"],"cards":["skills"],"skills":["inputs"]}[kind]): if not isinstance(body.get(field,[]),list) or not all(isinstance(x,str) for x in body.get(field,[])): raise HTTPException(422, f"{field} 须为字符串数组") def persist(db, kind, body): validate(kind,body) if body["status"]=="published": references={"plans":[("cards","cards"),("skills","skills"),("rules","rules")],"cards":[("connections","connection"),("skills","skills")]}.get(kind,[]) for target_kind,field in references: ids=body.get(field,[]) if isinstance(ids,str): ids=[ids] for item_id in ids: target=next((x for x in _all(db,target_kind) if x["id"]==item_id and x["status"]=="published"),None) if not target: raise HTTPException(409,f"依赖 {item_id} 未发布或不存在") db.execute("INSERT OR REPLACE INTO catalog VALUES (?,?,?)", (kind,body["id"],json.dumps(body,ensure_ascii=False))) db.execute("INSERT INTO revisions VALUES (?,?,?,?)",(kind,body["id"],body["version"],json.dumps(body,ensure_ascii=False))) @router.get("/catalog") def catalog(request:Request): with _db() as db: result={k:_all(db,k) for k in SEED} if request.state.identity["role"]=="manager": return {**{k:[] for k in SEED},"plans":[{k:p.get(k) for k in ("id","name","description","status","version")} for p in result["plans"] if p["status"]=="published"]} return result @router.get("/access/context") def context(request:Request): return request.state.identity @router.get("/native-skills/list") def native_skills(): from tools.skills_scanner import discover_skills return [{k:s[k] for k in ("id","name","description")} for s in discover_skills(SKILLS_BASE_DIR)] class NativeSkillUpload(BaseModel): skill_md: str = Field(min_length=1, max_length=65536) class NativeSkillImport(BaseModel): skills: list[NativeSkillUpload] = Field(min_length=1, max_length=20) @router.post("/native-skills/import", status_code=201) def import_native_skills(payload: NativeSkillImport, request: Request): if request.state.identity["role"] != "admin": raise HTTPException(403, "仅配置管理员可以导入本地 Skill") from tools.skills_scanner import install_skill, parse_skill, skill_slug prepared = [] for uploaded in payload.skills: try: meta = parse_skill(uploaded.skill_md) prepared.append((uploaded.skill_md, meta, skill_slug(meta))) except ValueError as exc: raise HTTPException(422, str(exc)) from exc if len({slug for _, _, slug in prepared}) != len(prepared): raise HTTPException(409, "所选文件夹中存在同名 Skill") imported = [] with _db() as db: existing = {item["id"] for item in _all(db, "skills")} skill_root = SKILLS_BASE_DIR / "skills" / "imported" for _, _, slug in prepared: if (skill_root / slug / "SKILL.md").exists() or "skill-native-" + slug in existing: raise HTTPException(409, f"同名 Skill 已存在:{slug}") for content, meta, slug in prepared: try: installed = install_skill(SKILLS_BASE_DIR, content) except FileExistsError as exc: raise HTTPException(409, str(exc)) from exc except (OSError, ValueError) as exc: raise HTTPException(422, str(exc)) from exc item_id = "skill-native-" + slug display_name = meta.get("display_name", meta["name"]) body = { "id": item_id, "name": str(display_name)[:120], "category": str(meta.get("category", "本地导入"))[:40], "description": meta["description"], "engine": "model", "mode": "mini-openclaw + 本地模型", "inputs": meta.get("inputs", []), "output": str(meta.get("output", "advisory." + slug.replace("-", "_"))), "instructions": str(meta.get("instructions", meta["description"])), "native_skills": [installed["legacy_id"]], "status": "published" if request.state.identity["preview"] else "draft", "version": str(meta.get("version", "v1")), "source": "local-folder", "updatedAt": _now(), "edited_by": request.state.identity["id"], } persist(db, "skills", body) audit(db, request, "native_skill_import", {"id": item_id, "native_skill": installed["legacy_id"], "status": body["status"]}) existing.add(item_id) imported.append(body) return {"count": len(imported), "skills": imported} def audit(db, request, action, detail): db.execute("INSERT INTO audit VALUES (?,?,?,?)",(_now(),request.state.identity["id"],action,json.dumps(detail,ensure_ascii=False))) def editable_data(data): return {k:v for k,v in data.items() if k not in ("edited_by","approved_by","approved_at")} @router.post("/{kind}") def create(kind:str,item:Item,request:Request): if kind not in SEED: raise HTTPException(404,"不支持的配置类型") body={**editable_data(item.data),"id":f"{kind}-{uuid4().hex[:8]}","name":item.name,"status":"draft","version":"v1","updatedAt":_now(),"edited_by":request.state.identity["id"]} with _db() as db: persist(db,kind,body) audit(db,request,"create",{"kind":kind,"id":body["id"],"version":body["version"]}) return body @router.put("/{kind}/{item_id}") def update(kind:str,item_id:str,item:Item,request:Request): if kind not in SEED: raise HTTPException(404,"不支持的配置类型") with _db() as db: db.execute("BEGIN IMMEDIATE") row=db.execute("SELECT body FROM catalog WHERE kind=? AND id=?",(kind,item_id)).fetchone() if not row: raise HTTPException(404,"配置不存在") old=json.loads(row[0]) if item.data.get("version") != old["version"]: raise HTTPException(409,"配置已更新,请刷新后编辑") revision=db.execute("SELECT COUNT(*) FROM revisions WHERE kind=? AND id=?",(kind,item_id)).fetchone()[0]+1 if not request.state.identity["preview"] and item.data.get("status")!="draft": raise HTTPException(403,"正式模式只能保存草稿,请提交独立审核员发布") body={**old,**editable_data(item.data),"id":item_id,"version":f"r{revision}","name":item.name,"updatedAt":_now(),"edited_by":request.state.identity["id"]} body.pop("approved_by",None);body.pop("approved_at",None) persist(db,kind,body) audit(db,request,"update",{"kind":kind,"id":item_id,"version":body["version"]}) return body @router.delete("/{kind}/{item_id}") def archive(kind:str,item_id:str,request:Request): if kind not in SEED: raise HTTPException(404,"不支持的配置类型") with _db() as db: db.execute("BEGIN IMMEDIATE") row=db.execute("SELECT body FROM catalog WHERE kind=? AND id=?",(kind,item_id)).fetchone() if not row: raise HTTPException(404,"配置不存在") for parent_kind, key in [("plans",kind),("cards","connection" if kind=="connections" else kind)]: for parent in _all(db,parent_kind): reference=parent.get(key,[]) if parent["status"]!="archived" and (reference==item_id or isinstance(reference,list) and item_id in reference): raise HTTPException(409,f"仍被 {parent['name']} 引用,请先解除关联") body=json.loads(row[0]); body["status"]="archived"; body["updatedAt"]=_now(); body["version"] += "-archived" persist(db,kind,body) audit(db,request,"archive",{"kind":kind,"id":item_id}) return body class RunInput(BaseModel): project_id: str = Field(min_length=1,max_length=120) period: str | None = Field(default=None,max_length=10) def query_card(card, connection, project_id, demo_samples=None, exact_decimals=False): if connection.get("type") != "demo": try: return execute(connection,card.get("sql",""),project_id, exact_decimals=exact_decimals) except ImportError: raise ValueError("数据库驱动未安装,请安装 backend/requirements.txt 中的依赖") with closing(sqlite3.connect(":memory:")) as conn: for table, rows in (SAMPLES if demo_samples is None else demo_samples).items(): keys=list(rows[0]) conn.execute(f"CREATE TABLE {table} ({','.join(keys)})") conn.executemany(f"INSERT INTO {table} VALUES ({','.join('?' for _ in keys)})", [tuple(r[k] for k in keys) for r in rows]) conn.commit() # SQLite authorizer enforces read-only access independently of SQL spelling. allowed={sqlite3.SQLITE_SELECT,sqlite3.SQLITE_READ,sqlite3.SQLITE_FUNCTION} conn.set_authorizer(lambda action,*_: sqlite3.SQLITE_OK if action in allowed else sqlite3.SQLITE_DENY) steps=[0] def timeout(): steps[0]+=1 return int(steps[0]>1000) conn.set_progress_handler(timeout,1000) conn.row_factory=sqlite3.Row cursor=conn.execute(card.get("sql",""),{"project_id":project_id}) rows=[dict(r) for r in cursor.fetchmany(1001)] if len(rows)>1000: raise ValueError("结果超过 1000 行,请缩小查询范围") return rows @router.post("/cards/{card_id}/test") def test_card(card_id:str, payload:RunInput,request:Request): check_project(request,payload.project_id) with _db() as db: card=next((x for x in _all(db,"cards") if x["id"]==card_id),None) if not card: raise HTTPException(404,"数据卡不存在") connection=next((x for x in _all(db,"connections") if x["id"]==card.get("connection")),{}) try: rows=query_card(card,connection,payload.project_id) try: records, selected=align(card,rows,payload.project_id,payload.period) alignment={"records":records,"selected":selected} except ValueError as e: alignment={"error":str(e)} with _db() as db: audit(db,request,"card_test",{"id":card_id,"project":payload.project_id,"rows":len(rows)}) return {"rows":rows,"alignment":alignment,"version":card["version"],"mode":connection.get("type")} except (ValueError,sqlite3.Error) as e: raise HTTPException(422,str(e)) @router.get("/runs/history") def history(request:Request): user=request.state.identity with _db() as db: results=[json.loads(r[0]) for r in db.execute("SELECT body FROM runs ORDER BY rowid DESC")] results=[r for r in results if "*" in user.get("projects",[]) or r["project_id"] in user.get("projects",[])] return [visible_result(r,user) for r in results[:30]] class ReviewInput(BaseModel): approve: bool note: str = Field(min_length=1,max_length=2000) @router.post("/{kind}/{item_id}/submit") def submit(kind:str,item_id:str,request:Request): with _db() as db: db.execute("BEGIN IMMEDIATE") row=db.execute("SELECT body FROM catalog WHERE kind=? AND id=?",(kind,item_id)).fetchone() if not row: raise HTTPException(404,"配置不存在") item=json.loads(row[0]) if item["status"]!="draft": raise HTTPException(409,"仅草稿可提交审核") review={"id":uuid4().hex,"kind":kind,"item_id":item_id,"snapshot":item,"submitted_by":request.state.identity["id"],"time":_now(),"status":"pending"} db.execute("INSERT INTO reviews VALUES (?,?,?,?)",(review["id"],kind,item_id,json.dumps(review,ensure_ascii=False))) audit(db,request,"submit",{"review":review["id"],"version":item["version"]}) return review @router.get("/reviews/list") def reviews(): with _db() as db: return [json.loads(r[0]) for r in db.execute("SELECT body FROM reviews ORDER BY rowid DESC LIMIT 100")] @router.get("/audit/events") def audit_events(): with _db() as db: return [{"time":r[0],"actor":r[1],"action":r[2],"detail":json.loads(r[3])} for r in db.execute("SELECT * FROM audit ORDER BY rowid DESC LIMIT 100")] @router.post("/reviews/{review_id}/review") def review_config(review_id:str,payload:ReviewInput,request:Request): user=request.state.identity if not user["preview"] and user["role"]!="reviewer": raise HTTPException(403,"需要发布审核员权限") with _db() as db: db.execute("BEGIN IMMEDIATE") row=db.execute("SELECT body FROM reviews WHERE id=?",(review_id,)).fetchone() if not row: raise HTTPException(404,"审核记录不存在") review=json.loads(row[0]);snapshot=review["snapshot"] if review["status"]!="pending": raise HTTPException(409,"此版本已审核") if not user["preview"] and user["id"] in (review["submitted_by"],snapshot.get("edited_by")): raise HTTPException(403,"编辑人、提交人不能审核自己的配置") current=db.execute("SELECT body FROM catalog WHERE kind=? AND id=?",(review["kind"],review["item_id"])).fetchone() if not current or json.loads(current[0])["version"]!=snapshot["version"]: raise HTTPException(409,"配置已修改,请重新提交") if payload.approve: published={**snapshot,"status":"published","approved_by":user["id"],"approved_at":_now(),"version":snapshot["version"]+"-approved"} persist(db,review["kind"],published) review.update(status="approved" if payload.approve else "rejected",reviewed_by=user["id"],note=payload.note,reviewed_at=_now()) db.execute("UPDATE reviews SET body=? WHERE id=?",(json.dumps(review,ensure_ascii=False),review_id)) audit(db,request,"review",{"review":review_id,"approved":payload.approve,"note":payload.note}) return review def local_model(skill, values, evidence, system_prompt=None): endpoint=os.getenv("XINGBANG_LOCAL_MODEL_URL", "") if not endpoint: raise ValueError("未配置 XINGBANG_LOCAL_MODEL_URL") url=urlparse(endpoint) allowed=url.hostname=="localhost" try: address=ipaddress.ip_address(url.hostname or "") allowed=address.is_loopback or any(address in ipaddress.ip_network(net) for net in ("10.0.0.0/8","172.16.0.0/12","192.168.0.0/16") if address.version==4) except ValueError: pass if url.scheme not in ("http","https") or not allowed or url.username or url.password: raise ValueError("模型地址必须使用 localhost 或内网 IP,禁止外部模型地址") from graph.risk_task import invoke_risk_task return invoke_risk_task(skill, values, evidence, endpoint, system_prompt=system_prompt) @router.post("/plans/{plan_id}/run") def run(plan_id:str,payload:RunInput,request:Request): check_project(request,payload.project_id) with _db() as db: objects={k:_all(db,k) for k in SEED} plan=next((x for x in objects["plans"] if x["id"]==plan_id),None) if not plan: raise HTTPException(404,"方案不存在") if plan["status"]!="published": raise HTTPException(409,"请先发布研判方案") chosen={} for kind in ("cards","skills","rules"): chosen[kind]=[] for item_id in plan.get(kind,[]): item=next((x for x in objects[kind] if x["id"]==item_id),None) if not item or item["status"]!="published": raise HTTPException(409,f"依赖 {item_id} 不存在或未发布") chosen[kind].append(item) if not chosen["cards"] or not chosen["skills"]: raise HTTPException(422,"至少选择一张数据卡与一个 Skill") result={"id":uuid4().hex,"time":_now(),"project_id":payload.project_id,"requested_period":payload.period,"mode":"仅提供核查建议", "plan":deepcopy(plan),"configuration":chosen,"steps":[],"findings":[],"evidence":{},"values":{},"datasets":[],"actor":request.state.identity["id"]} values=result["values"]; evidence=result["evidence"] def issue(title,detail,refs=None): result["findings"].append({"category":"信息缺失","title":title,"suggestion":detail,"evidence":refs or []}) for card in chosen["cards"]: connection=next((x for x in objects["connections"] if x["id"]==card.get("connection") and x["status"]=="published"),{}) try: rows=query_card(card,connection,payload.project_id) result["steps"].append({"name":card["name"],"status":"完成","detail":f"取得 {len(rows)} 行","rows":rows,"version":card["version"]}) records,selected=align(card,rows,payload.project_id,payload.period) result["datasets"].append({"card":card["name"],"version":card["version"],"records":records,"source_type":connection.get("type")}) if selected is None: result["steps"][-1]["detail"]+= ";保留多期数据供综合研判,未静默汇总为单值" continue for field,source in card.get("mapping",{}).items(): ref={"card":card["name"],"version":card["version"],"field":source,"value":selected["values"].get(field),"raw_value":selected["raw"].get(source),"time":result["time"],"unit":selected["units"].get(field),"period":selected["period"],"scope":selected["scope"],"project_id":payload.project_id} if field in values: values[field]=None; issue(field+"来源冲突","多个数据卡提供同一标准字段,请明确唯一来源",[ref]); continue values[field]=selected["values"].get(field); evidence[field]=[ref] except (ValueError,sqlite3.Error) as e: issue(card["name"]+"取数失败",str(e)); result["steps"].append({"name":card["name"],"status":"失败","detail":str(e)}) model_skills=[] for skill in chosen["skills"]: if skill.get("engine")=="model": model_skills.append(skill) continue missing=[f for f in skill.get("inputs",[]) if f!="*" and values.get(f) is None] step={"name":skill["name"],"version":skill["version"],"status":"完成","detail":"检查完成"} if missing: issue(skill["name"]+"输入不足","缺少:"+"、".join(missing)); step.update(status="缺失",detail="输入依赖不足") elif skill.get("engine")=="quality": for f,v in list(values.items()): if v is None: issue(f+"数据缺失","请补充材料或修正字段映射",evidence.get(f)) elif skill.get("engine")=="ratio": a,b=skill.get("numerator"),skill.get("denominator") n,d=values.get(a),values.get(b) source_refs=evidence.get(a,[])+evidence.get(b,[]) units={ref.get("unit") for ref in source_refs if ref.get("unit")} financial_refs=[ref for field in (a,b) if field and field.startswith("financial.") for ref in evidence.get(field,[])] periods={ref.get("period") for ref in financial_refs} scopes={ref.get("scope") for ref in financial_refs} if len(units)>1: issue(skill["name"]+"金额单位不一致","统一数据卡金额单位后重新计算",source_refs) step.update(status="缺失",detail="单位冲突,未计算") elif len(periods)>1 or len(scopes)>1: issue(skill["name"]+"期间或口径不一致","请对齐报表期间及合并/母公司口径,不能交叉计算",source_refs) step.update(status="缺失",detail="期间或口径冲突,未计算") elif not all(isinstance(v,(int,float)) and not isinstance(v,bool) and math.isfinite(v) for v in (n,d)) or d<=0: issue(skill["name"]+"无法计算","输入须为数值,分母必须大于零"); step.update(status="缺失",detail="无有效计算结果") else: output=skill.get("output","metrics.ratio"); values[output]=n/d evidence[output]=evidence.get(a,[])+evidence.get(b,[]) step["detail"]=f"{a} / {b} = {n/d:.4f}" elif skill.get("engine")!="overdue": step.update(status="失败",detail="执行器不支持") result["steps"].append(step) for rules in chosen["rules"]: for c in rules.get("conditions",[]): value=values.get(c["field"]) if not isinstance(value,(int,float)) or isinstance(value,bool): issue(c.get("title",c["field"])+"未完成检查","缺少规则需要的有效指标"); continue hit={"gt":value>c["threshold"],"lt":value120000: raise ValueError("模型上下文超过限制,请缩小数据卡查询范围") core_result=local_model(skill,context,evidence) advisory=core_result.get("text") if isinstance(core_result,dict) else core_result if not isinstance(advisory,str) or not advisory.strip(): raise ValueError("模型未返回有效建议") result.setdefault("advisories",[]).append({"skill":skill["name"],"version":skill["version"],"text":advisory,"rules":language_rules,"status":"模型建议,待人工核实"}) if isinstance(core_result,dict): result.setdefault("agent_traces",[]).append({"skill":skill["name"],**core_result}) result["engine"]=core_result["engine"] step["detail"]="已综合所选数据卡与人为规则生成建议" except Exception as e: step.update(status="未完成",detail=str(e));issue(skill["name"]+"未完成",str(e)) result["steps"].append(step) result["summary"]={"issues":len(result["findings"]),"cards":len(chosen["cards"]),"skills":len(chosen["skills"])} result["summary"]["incomplete"]=any(f["category"]=="信息缺失" for f in result["findings"]) with _db() as db: db.execute("INSERT INTO runs VALUES (?,?)",(result["id"],json.dumps(result,ensure_ascii=False))) audit(db,request,"run",{"id":result["id"],"project_id":payload.project_id,"plan":plan["id"]}) return visible_result(result,request.state.identity)