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
+52
View File
@@ -0,0 +1,52 @@
"""Server-configured identities; preview mode is limited to a local backend."""
import hmac
import json
import os
from urllib.parse import urlsplit
from fastapi import HTTPException, Request
def authorize(request: Request):
configured = os.getenv("XINGBANG_AUTH_USERS", "")
if not configured:
forwarded_host = request.headers.get("x-xingbang-client-host", request.headers.get("host", ""))
hostname = urlsplit("//"+forwarded_host).hostname
if os.getenv("XINGBANG_REQUIRE_AUTH") == "1" or not request.client or request.client.host not in ("127.0.0.1", "::1", "testclient"):
raise HTTPException(401, "请配置服务器身份认证后访问")
if hostname not in ("localhost", "127.0.0.1", "::1", "testserver"):
raise HTTPException(401, "本机预览仅接受回环地址;远程访问必须配置身份认证")
user = {"id": "local-preview", "role": "admin", "projects": ["*"], "preview": True}
else:
try:
accounts = json.loads(configured)
except ValueError:
raise HTTPException(503, "身份配置无效")
supplied = request.headers.get("authorization", "").removeprefix("Bearer ")
user = next((dict(u) for u in accounts if isinstance(u.get("token"), str) and hmac.compare_digest(u["token"], supplied)), None)
if not user or user.get("role") not in ("admin", "reviewer", "manager"):
raise HTTPException(401, "访问令牌无效")
user.pop("token", None)
user["preview"] = False
request.state.identity = user
path = request.url.path.split("/risk-agent/", 1)[-1]
if user["role"] == "manager":
permitted = path in ("catalog", "access/context", "runs/history") or (path.startswith("plans/") and path.endswith("/run"))
if not permitted:
raise HTTPException(403, "客户经理无权管理配置")
if user["role"] == "reviewer" and request.method != "GET" and not path.endswith("/review"):
raise HTTPException(403, "审核员仅可审核发布,不能修改配置或执行查询")
def check_project(request: Request, project_id: str):
user = request.state.identity
if user["role"] == "manager" and "*" not in user.get("projects", []) and project_id not in user.get("projects", []):
raise HTTPException(403, "无权访问该项目")
def visible_result(result, user):
if user["role"] != "manager":
return result
# Project evidence remains visible, configuration and SQL stay in the admin area.
filtered={k: v for k, v in result.items() if k not in ("configuration", "connections")}
filtered["plan"]={k:result["plan"].get(k) for k in ("id","name","version","description")}
return filtered
+73
View File
@@ -0,0 +1,73 @@
"""Normalize peer data cards without assuming an upstream/downstream dependency."""
import math
import re
from decimal import Decimal, InvalidOperation
from datetime import date
MONEY_FIELDS = {"facility.amount", "financial.total_assets", "financial.total_liabilities", "financial.operating_cashflow", "lease.total_value", "insurance.amount", "deposit.amount", "guarantee.amount"}
SCALE = {"元": Decimal("0.0001"), "万元": Decimal(1), "亿元": Decimal(10000)}
def period_key(value):
text = str(value or "")
if re.fullmatch(r"\d{4}", text):
return date(int(text), 12, 31)
if re.fullmatch(r"\d{4}-\d{2}", text):
return date.fromisoformat(text + "-01")
return date.fromisoformat(text)
def align(card, rows, project_id, requested_period=None):
config = card.get("alignment", {})
project_field = config.get("project_field", "project_id")
period_field = config.get("period_field", "")
scope_field = config.get("scope_field", "")
selection = config.get("selection", "single")
if not rows:
raise ValueError("未取得项目记录,不得解释为无风险")
if not project_field or any(str(row.get(project_field, "")) != project_id for row in rows):
raise ValueError("返回记录的项目标识缺失或不一致,请检查查询和项目关联字段")
records = []
for row in rows:
period = row.get(period_field) if period_field else card.get("period")
scope = row.get(scope_field) if scope_field else card.get("scope")
mapped, units = {}, {}
for field, source in card.get("mapping", {}).items():
value = row.get(source)
unit = card.get("field_units", {}).get(field, card.get("unit") if field in MONEY_FIELDS else None)
if value is not None and unit:
if unit not in SCALE:
raise ValueError(f"{field} 的金额单位不支持:{unit}")
try:
value = float(Decimal(str(value)) * SCALE[unit])
if not math.isfinite(value): raise ValueError("非有限值")
except (ValueError, InvalidOperation):
raise ValueError(f"{field} 金额不是有效数值")
units[field] = "万元"
mapped[field] = value
records.append({"period": period, "scope": scope, "values": mapped, "units": units, "raw": row})
target_scope = config.get("target_scope", "")
candidates = [r for r in records if not target_scope or r["scope"] == target_scope]
if not candidates:
raise ValueError("未取得指定财务口径的记录")
if len({r["scope"] for r in candidates}) > 1:
raise ValueError("返回多个财务口径,请明确目标口径,不能混合计算")
if selection in ("latest", "period", "all"):
try:
for r in candidates: period_key(r["period"])
except (ValueError, TypeError):
raise ValueError("多期记录缺少有效期间,应使用 YYYY、YYYY-MM 或 YYYY-MM-DD")
keys = [(r["period"], r["scope"]) for r in candidates]
if len(set(keys)) != len(keys):
raise ValueError("同一期间与口径存在重复记录,可能由多表关联放大行数")
if (requested_period and period_field) or selection == "period":
target = requested_period or config.get("target_period")
candidates = [r for r in candidates if str(r["period"]) == str(target)]
elif selection == "latest":
newest = max(period_key(r["period"]) for r in candidates)
candidates = [r for r in candidates if period_key(r["period"]) == newest]
if selection == "all" and not (requested_period and period_field):
return records, None
if len(candidates) != 1:
raise ValueError(f"取得 {len(candidates)} 条候选记录,请指定期间或明确唯一粒度")
return records, candidates[0]
+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)
+93
View File
@@ -0,0 +1,93 @@
"""Read-only SQLAlchemy connectors with server-side credential references."""
import os
from pathlib import Path
from datetime import date, datetime
from decimal import Decimal
import sqlite3
def normalize(value):
if isinstance(value, (date, datetime)): return value.isoformat()
if isinstance(value, Decimal): return float(value)
if isinstance(value, bytes): raise ValueError("查询包含二进制数据,请返回附件索引而非文件内容")
return value
def validate_query(sql, allowed_tables, dialect):
import sqlglot
from sqlglot import exp
try:
statements = sqlglot.parse(sql, read=dialect)
except Exception:
raise ValueError("SQL 无法解析,请使用单条只读 SELECT")
if len(statements) != 1 or not isinstance(statements[0], (exp.Select, exp.Union)):
raise ValueError("只允许单条 SELECT 查询")
tree = statements[0]
if any(tree.find_all(exp.Insert, exp.Update, exp.Delete, exp.Create, exp.Drop, exp.Command, exp.Into, exp.Lock)):
raise ValueError("不允许写入、锁定或导出语句")
# User-defined functions may have side effects even when embedded in SELECT.
if any(tree.find_all(exp.Anonymous)):
raise ValueError("不允许自定义函数,请使用标准聚合和表达式")
aliases = {c.alias for c in tree.find_all(exp.CTE)}
tables = {".".join(part.name for part in t.parts).lower() for t in tree.find_all(exp.Table) if t.name not in aliases}
if not allowed_tables or not tables.issubset({t.lower() for t in allowed_tables}):
raise ValueError("查询使用了未授权的库表或视图")
def execute(connection, sql, project_id):
from sqlalchemy import create_engine, text
from sqlalchemy.engine import URL
kind = connection.get("type")
dialects = {"PostgreSQL": ("postgresql+psycopg", "postgres"), "MySQL": ("mysql+pymysql", "mysql"), "SQLite": ("sqlite", "sqlite")}
if kind not in dialects: raise ValueError("该数据库连接器暂不支持")
driver, dialect = dialects[kind]
validate_query(sql, connection.get("allowed_tables", []), dialect)
seconds = max(1, min(int(connection.get("timeout", 15)), 60))
max_rows = max(1, min(int(connection.get("max_rows", 1000)), 10000))
if kind == "SQLite":
root = os.getenv("XINGBANG_SQLITE_ROOT")
if not root: raise ValueError("服务器未配置 XINGBANG_SQLITE_ROOT")
file = Path(connection.get("database", "")).resolve()
if not file.is_relative_to(Path(root).resolve()) or not file.is_file():
raise ValueError("SQLite 文件不存在或不在服务器允许目录内")
def creator():
db = sqlite3.connect(file.as_uri()+"?mode=ro", uri=True)
def authorize(action, arg1, arg2, *_):
if action in {sqlite3.SQLITE_SELECT, sqlite3.SQLITE_READ, sqlite3.SQLITE_FUNCTION}:
return sqlite3.SQLITE_OK
# SQLAlchemy reads the isolation mode during connection initialization.
if action == sqlite3.SQLITE_PRAGMA and arg1 == "read_uncommitted" and arg2 is None:
return sqlite3.SQLITE_OK
return sqlite3.SQLITE_DENY
db.set_authorizer(authorize)
import time
deadline=time.monotonic()+seconds
db.set_progress_handler(lambda: int(time.monotonic()>deadline),1000)
return db
engine = create_engine("sqlite://", creator=creator)
else:
ref = connection.get("password_env", "")
if not ref.startswith("XINGBANG_DB_") or not os.getenv(ref):
raise ValueError("请配置 XINGBANG_DB_ 前缀的服务器密码引用")
url = URL.create(driver, username=connection.get("username"),password=os.environ[ref],host=connection.get("host"),port=int(connection.get("port") or (5432 if kind=="PostgreSQL" else 3306)),database=connection.get("database"))
args = {"connect_timeout": seconds}
if kind == "PostgreSQL": args["sslmode"]="require"
else: args.update(read_timeout=seconds,write_timeout=seconds)
engine = create_engine(url,connect_args=args,pool_pre_ping=True)
try:
with engine.connect() as db:
if kind == "PostgreSQL":
db.exec_driver_sql("SET TRANSACTION READ ONLY")
db.exec_driver_sql(f"SET LOCAL statement_timeout = {seconds*1000}")
elif kind == "MySQL":
db.exec_driver_sql("SET SESSION TRANSACTION READ ONLY")
db.exec_driver_sql(f"SET SESSION MAX_EXECUTION_TIME = {seconds*1000}")
rows = db.execute(text(sql), {"project_id": project_id}).mappings().fetchmany(max_rows+1)
if len(rows)>max_rows: raise ValueError(f"查询结果超过 {max_rows} 行,请缩小范围")
return [{k:normalize(v) for k,v in row.items()} for row in rows]
except ValueError:
raise
except Exception:
raise ValueError("数据库连接或查询失败,请检查驱动、只读账号、网络与字段配置") from None
finally:
engine.dispose()
+169
View File
@@ -0,0 +1,169 @@
"""Decision-record prototype: shared, persisted project state; no external model calls."""
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
router = APIRouter(prefix="/api/xingbang/workspace", tags=["兴邦方案工作台(演示)"])
DB_PATH = Path(__file__).resolve().parents[1] / "xingbang_data" / "workspace.sqlite3"
def now():
return datetime.now(timezone.utc).isoformat()
def connect():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(DB_PATH)
db.execute("CREATE TABLE IF NOT EXISTS projects (id TEXT PRIMARY KEY, body TEXT NOT NULL)")
return db
def get(db, project_id):
row = db.execute("SELECT body FROM projects WHERE id=?", (project_id,)).fetchone()
if not row:
raise HTTPException(404, "项目不存在")
return json.loads(row[0])
def save(db, project):
db.execute("INSERT OR REPLACE INTO projects VALUES (?,?)", (project["id"], json.dumps(project, ensure_ascii=False)))
class ProjectInput(BaseModel):
name: str = Field(min_length=1, max_length=120)
client: str = Field(min_length=1, max_length=120)
amount: float = Field(gt=0, allow_inf_nan=False)
assets: float | None = Field(default=None, gt=0, allow_inf_nan=False)
liabilities: float | None = Field(default=None, ge=0, allow_inf_nan=False)
lease_value: float | None = Field(default=None, ge=0, allow_inf_nan=False)
insurance: float | None = Field(default=None, ge=0, allow_inf_nan=False)
class Action(BaseModel):
kind: str
target: str = ""
note: str = Field(default="", max_length=4000)
actor: str = Field(min_length=1, max_length=80)
amount: float | None = Field(default=None, gt=0, allow_inf_nan=False)
def assess(project):
snapshot = project["snapshots"][-1]
data = snapshot["data"]
items = []
rules = [
("debt", "资产负债率", ["liabilities", "assets"], "负债总额 / 资产总额", 0.7, "gt"),
("lease", "租赁物价值覆盖", ["lease_value", "amount"], "租赁物价值 / 授信金额", 1.2, "lt"),
("insurance", "保额覆盖", ["insurance", "amount"], "保额 / 授信金额", 1, "lt"),
]
for key, title, fields, formula, threshold, op in rules:
missing = [f for f in fields if data.get(f) is None]
value = None if missing else round(data[fields[0]] / data[fields[1]], 6)
triggered = value is not None and (value > threshold if op == "gt" else value < threshold)
items.append({"id": key, "title": title, "value": value,
"category": "信息缺失" if missing else "待核查疑点" if triggered else "指标未触发(非无风险)",
"status": "待复核", "note": "", "evidence": {"snapshot": snapshot["id"],
"source": snapshot["source"], "time": snapshot["time"], "fields": {f: data.get(f) for f in fields},
"formula": formula, "rule_version": "demo-1", "threshold": threshold,
"operator": op, "basis": "演示阈值,非兴邦正式制度"}})
project["risks"] = items
project["assessment_snapshot"] = snapshot["id"]
project["stale"] = False
return items
@router.get("/projects")
def projects():
with connect() as db:
return [json.loads(row[0]) for row in db.execute("SELECT body FROM projects ORDER BY rowid DESC")]
@router.post("/projects")
def create(data: ProjectInput):
project = {"id": uuid4().hex, "name": data.name, "client": data.client, "mode": "演示 / 人工录入",
"snapshots": [], "risks": [], "tasks": [], "reports": [], "cards": [], "audit": [], "stale": True}
project["snapshots"].append({"id": uuid4().hex, "time": now(), "source": "人工录入(未接入ERP)",
"template_version": "manual-1", "mapping_version": "manual-1",
"unit": "万元", "data": data.model_dump()})
with connect() as db:
save(db, project)
return project
@router.post("/projects/{project_id}/actions")
def action(project_id: str, request: Action):
with connect() as db:
db.execute("BEGIN IMMEDIATE")
p = get(db, project_id)
k = request.kind
if k == "assess":
if any(r["status"] != "待复核" for r in p["risks"]) and not p["stale"]:
raise HTTPException(409, "已有人工判断;请保留当前结果,数据变更后再重新研判")
previous = p["risks"]
assess(p)
p["comparison"] = [{"title": r["title"], "before": next((x["category"] for x in previous if x["id"] == r["id"]), "首次研判"), "after": r["category"]} for r in p["risks"]]
elif k == "change":
if request.amount is None or not request.note.strip():
raise HTTPException(422, "请填写新额度和变更原因")
data = dict(p["snapshots"][-1]["data"], amount=request.amount)
p["snapshots"].append(dict(p["snapshots"][-1], id=uuid4().hex, time=now(), data=data))
p["stale"] = True
p["impacts"] = ["租赁物价值覆盖率", "保额覆盖率", "担保与保证金条件(待人工复核)", "报告授信方案与风险章节"]
for report in p["reports"]:
report["stale"] = True
elif k in ("confirm", "reject", "task"):
if p["stale"]:
raise HTTPException(409, "请先对当前快照重新研判")
risk = next((r for r in p["risks"] if r["id"] == request.target), None)
if not risk or not request.note.strip():
raise HTTPException(422, "请选择风险项并填写判断依据或补件要求")
if k == "task":
p["tasks"].append({"id": uuid4().hex, "risk": risk["title"], "owner": request.actor,
"requirement": request.note, "status": "待提交", "snapshot": p["snapshots"][-1]["id"]})
else:
risk.update(status="人工确认" if k == "confirm" else "人工驳回", note=request.note, actor=request.actor)
elif k in ("submit_task", "close_task"):
task = next((t for t in p["tasks"] if t["id"] == request.target), None)
if not task or not request.note.strip():
raise HTTPException(422, "请提供任务及处理说明")
expected = "待提交" if k == "submit_task" else "已提交待确认"
if task["status"] != expected:
raise HTTPException(409, "任务状态不允许此操作")
task.update(status="已提交待确认" if k == "submit_task" else "已解决", note=request.note, actor=request.actor)
elif k == "report":
if p["stale"] or not p["risks"] or any(r["status"] == "待复核" for r in p["risks"]):
raise HTTPException(409, "请先完成当前快照的研判和逐项人工复核")
snapshot = p["snapshots"][-1]
body = f"# {p['name']} 评审材料草稿\n\n客户:{p['client']}\n\n授信金额:{snapshot['data']['amount']} 万元\n\n数据来源:人工录入;快照:{snapshot['id']}\n\n"
body += "\n\n".join(f"## {r['title']}\n{r['category']} / {r['status']}\n\n判断:{r['note']}\n\n证据:{json.dumps(r['evidence'], ensure_ascii=False)}" for r in p["risks"])
body += "\n\n审批结果、法律有效性、附件原文及租后表现:待补充。本文使用演示规则,不能作为正式审批依据。"
p["reports"].append({"id": uuid4().hex, "body": body, "snapshot": snapshot["id"], "stale": False, "status": "草稿"})
elif k == "finalize":
report = next((r for r in p["reports"] if r["id"] == request.target), None)
if not report or report["stale"] or report["status"] != "草稿" or not request.note.strip():
raise HTTPException(409, "仅当前版本草稿可定稿,且需填写确认意见")
report.update(status="已定稿", actor=request.actor, note=request.note)
p["cards"].append({"id": uuid4().hex, "status": "草稿", "report": report["id"], "snapshot": report["snapshot"],
"facts": json.loads(json.dumps(p["risks"])), "history": [], "outcome": ""})
elif k == "card":
card = next((c for c in p["cards"] if c["id"] == request.target), None)
if not card or not request.note.strip():
raise HTTPException(422, "请选择经验卡并填写审核依据或租后结果")
states = ["草稿", "主审员已确认", "部门审核", "可检索", "已验证", "已过期/归档"]
index = states.index(card["status"])
if index == len(states) - 1:
raise HTTPException(409, "经验卡已归档")
card["status"] = states[index + 1]
card["history"].append({"status": card["status"], "actor": request.actor, "note": request.note, "time": now()})
if card["status"] == "已验证":
card["outcome"] = request.note
else:
raise HTTPException(422, "不支持的操作")
p["audit"].append({"time": now(), **request.model_dump()})
save(db, p)
return p