676 lines
48 KiB
Python
676 lines
48 KiB
Python
"""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)
|
|
official_deepseek=url.scheme=="https" and url.hostname=="api.deepseek.com" and url.port in (None,443)
|
|
allowed=url.hostname=="localhost" or official_deepseek
|
|
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 or url.query or url.fragment:
|
|
raise ValueError("模型地址必须使用 localhost、内网 IP 或 DeepSeek 官方 HTTPS 地址")
|
|
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":value<c["threshold"],"eq":value==c["threshold"]}[c["operator"]]
|
|
if hit: result["findings"].append({"category":"待核查疑点","title":c.get("title",c["field"]),"value":value,"suggestion":c.get("suggestion","请核实"),"rule":rules["name"]+" "+rules["version"],"condition":c,"evidence":evidence.get(c["field"],[])})
|
|
language_rules=[{"name":r["name"],"version":r["version"],"instructions":r["natural_language"]} for r in chosen["rules"] if r.get("natural_language","").strip()]
|
|
if not model_skills:
|
|
model_skills=[{"name":"mini-openclaw 综合研判","version":"system-2","instructions":"执行所选的人为自然语言规则,综合平级数据卡,只输出有证据的核查建议。","inputs":[],"native_skills":["risk_evidence/SKILL.md"]}]
|
|
available_fields={k for k,v in values.items() if v is not None}
|
|
for dataset in result["datasets"]:
|
|
for record in dataset["records"]:
|
|
available_fields.update(k for k,v in record["values"].items() if v is not None)
|
|
for skill in model_skills:
|
|
step={"name":skill["name"],"version":skill["version"],"status":"完成"}
|
|
missing=[f for f in skill.get("inputs",[]) if f!="*" and f not in available_fields]
|
|
try:
|
|
if missing: raise ValueError("模型输入不足:"+"、".join(missing))
|
|
context={"scalar_values":values,"peer_datasets":result["datasets"],"rule_findings":result["findings"],"human_rules":language_rules}
|
|
if len(json.dumps(context,ensure_ascii=False))>120000: 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)
|