2026-09-08 01:07:59 +08:00
"""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
router = APIRouter ( prefix = "/api/xingbang/risk-agent" , tags = [ "风险研判 Agent" ], dependencies = [ Depends ( authorize )])
DB_PATH = Path ( __file__ ) . resolve () . parents [ 1 ] / "xingbang_data" / "risk_agent.sqlite3"
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" ]
def _now (): return datetime . now ( timezone . utc ) . isoformat ()
@contextmanager
def _db ():
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)" )
if not db . execute ( "SELECT 1 FROM catalog LIMIT 1" ) . fetchone ():
for kind , rows in SEED . items ():
for row in rows : db . execute ( "INSERT 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 ( Path ( __file__ ) . resolve () . parents [ 1 ])}
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 ( Path ( __file__ ) . resolve () . parents [ 1 ])]
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 ):
if connection . get ( "type" ) != "demo" :
try : return execute ( connection , card . get ( "sql" , "" ), project_id )
except ImportError : raise ValueError ( "数据库驱动未安装,请安装 backend/requirements.txt 中的依赖" )
with closing ( sqlite3 . connect ( ":memory:" )) as conn :
for table , rows in 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 ):
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 user [ "role" ] != "manager" or "*" 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
2026-09-08 02:49:06 +08:00
def local_model ( skill , values , evidence , system_prompt = None ):
2026-09-08 01:07:59 +08:00
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
2026-09-08 02:49:06 +08:00
return invoke_risk_task ( skill , values , evidence , endpoint , system_prompt = system_prompt )
2026-09-08 01:07:59 +08:00
@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 )