feat: implement configurable Xingbang risk agent on mini-openclaw core

This commit is contained in:
NathanAlex7
2026-09-08 01:08:34 +08:00
parent f2439ee5a3
commit 58fb2854dc
26 changed files with 1990 additions and 208 deletions
+447
View File
@@ -0,0 +1,447 @@
"""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
def local_model(skill, values, evidence):
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)
@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)