feat: 增强规则资产管理与审计页面运行时调试

后端新增规则资产版本管理和规则文件 CRUD 接口,优化风险
规则生成模板执行和员工数据模型字段,知识库 RAG 增强本
地回退和文档提取能力,清理旧风险规则文件统一由生成引擎
管理,前端审计页面增加运行时调试面板和规则资产编辑交互,
补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-24 21:44:17 +08:00
parent 575f093c74
commit 50b1c3f9a9
113 changed files with 13896 additions and 5044 deletions

View File

@@ -14,6 +14,7 @@ from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.audit import AuditLogService
from app.services.expense_type_keywords import EXPENSE_TYPE_LABEL_BY_CODE
from app.services.risk_rule_flow_diagram import (
RiskRuleFlowDiagramField,
RiskRuleFlowDiagramRenderer,
@@ -43,6 +44,24 @@ RISK_LEVEL_LABELS: dict[str, str] = {
"high": "高风险",
}
EXPENSE_RISK_CATEGORY_CODES: tuple[str, ...] = (
"travel",
"hotel",
"transport",
"meal",
"meeting",
"office",
"training",
"communication",
"welfare",
)
EXPENSE_RISK_CATEGORY_LABELS: dict[str, str] = {
code: EXPENSE_TYPE_LABEL_BY_CODE[code] for code in EXPENSE_RISK_CATEGORY_CODES
}
EXPENSE_RISK_CATEGORY_ALIASES = {
"entertainment": "meal",
}
FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由", "说明", "理由", "用途")),
RiskRuleField(
@@ -156,17 +175,23 @@ class RiskRuleGenerationService:
risk_level = str(body.risk_level or "medium").strip().lower()
if risk_level not in RISK_LEVEL_LABELS:
raise ValueError("风险等级仅支持 low、medium、high。")
requires_attachment = bool(body.requires_attachment)
expense_category = self._normalize_expense_category(body.expense_category, domain)
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
created_at = datetime.now(UTC)
fields = self._resolve_fields(natural_language, domain=domain)
draft = self._compile_with_model(
natural_language=natural_language,
domain=domain,
expense_category=expense_category,
expense_category_label=expense_category_label,
risk_level=risk_level,
fields=fields,
) or self._build_fallback_draft(
natural_language=natural_language,
domain=domain,
expense_category_label=expense_category_label,
risk_level=risk_level,
fields=fields,
)
@@ -179,10 +204,13 @@ class RiskRuleGenerationService:
draft,
natural_language=natural_language,
domain=domain,
expense_category=expense_category,
expense_category_label=expense_category_label,
risk_level=risk_level,
fields=fields,
created_at=created_at,
actor=actor,
requires_attachment=requires_attachment,
)
rule_code = str(payload["rule_code"])
file_name = f"{rule_code}.json"
@@ -209,8 +237,11 @@ class RiskRuleGenerationService:
config_json={
"severity": risk_level,
"enabled": True,
"requires_attachment": requires_attachment,
"tag": "风险规则",
"detail_mode": "json_risk",
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"risk_category": payload.get("risk_category"),
"rule_library": RISK_RULES_LIBRARY,
"rule_document": {
@@ -241,7 +272,13 @@ class RiskRuleGenerationService:
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=None,
after_json={"rule_code": rule_code, "risk_level": risk_level, "domain": domain},
after_json={
"rule_code": rule_code,
"risk_level": risk_level,
"domain": domain,
"expense_category": expense_category,
"requires_attachment": requires_attachment,
},
request_id=request_id,
)
self.db.refresh(asset)
@@ -252,6 +289,8 @@ class RiskRuleGenerationService:
*,
natural_language: str,
domain: str,
expense_category: str | None,
expense_category_label: str,
risk_level: str,
fields: list[RiskRuleField],
) -> dict[str, Any] | None:
@@ -279,6 +318,8 @@ class RiskRuleGenerationService:
{
"business_domain": domain,
"business_domain_label": BUSINESS_DOMAIN_LABELS[domain],
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"risk_level": risk_level,
"risk_level_label": RISK_LEVEL_LABELS[risk_level],
"natural_language": natural_language,
@@ -370,6 +411,7 @@ class RiskRuleGenerationService:
*,
natural_language: str,
domain: str,
expense_category_label: str,
risk_level: str,
fields: list[RiskRuleField],
) -> dict[str, Any]:
@@ -381,8 +423,9 @@ class RiskRuleGenerationService:
fields=fields,
)
name = self._infer_rule_name(natural_language)
business_label = expense_category_label or BUSINESS_DOMAIN_LABELS[domain]
description = (
f"{BUSINESS_DOMAIN_LABELS[domain]}业务满足“{natural_language}”时,系统会按"
f"{business_label}业务满足“{natural_language}”时,系统会按"
f"{RISK_LEVEL_LABELS[risk_level]}进行提示,并要求经办人或审核人补充核对依据。"
)
return {
@@ -393,7 +436,7 @@ class RiskRuleGenerationService:
"condition_summary": condition_summary,
"keywords": self._infer_keywords(natural_language),
"flow": {
"start": f"{BUSINESS_DOMAIN_LABELS[domain]}单据提交",
"start": f"{business_label}单据提交",
"evidence": "读取" + "".join(item.label for item in fields[:3]),
"decision": condition_summary,
"pass": "未命中风险,继续业务流转",
@@ -407,14 +450,18 @@ class RiskRuleGenerationService:
*,
natural_language: str,
domain: str,
expense_category: str | None,
expense_category_label: str,
risk_level: str,
fields: list[RiskRuleField],
created_at: datetime,
actor: str,
requires_attachment: bool,
) -> dict[str, Any]:
created_stamp = created_at.strftime("%Y%m%d%H%M%S")
created_stamp = created_at.strftime("%Y%m%d%H%M%S%f")
domain_slug = {"expense": "expense", "ar": "ar", "ap": "ap"}[domain]
rule_code = f"risk.{domain_slug}.generated_{created_stamp}"
category_slug = f".{expense_category}" if expense_category else ""
rule_code = f"risk.{domain_slug}{category_slug}.generated_{created_stamp}"
template_key = str(draft.get("template_key") or "field_required_v1").strip()
field_keys = [
str(item or "").strip()
@@ -424,7 +471,7 @@ class RiskRuleGenerationService:
condition_summary = (
self._clean_text(draft.get("condition_summary")) or "判断是否符合自然语言规则描述"
)
risk_category = BUSINESS_DOMAIN_LABELS[domain]
risk_category = expense_category_label or BUSINESS_DOMAIN_LABELS[domain]
keywords = list(draft.get("keywords") or [])
field_by_key = {item.key: item for item in fields}
params: dict[str, Any] = {
@@ -440,6 +487,9 @@ class RiskRuleGenerationService:
if template_key == "keyword_match_v1":
params["keywords"] = keywords
params["search_fields"] = field_keys
applies_to: dict[str, Any] = {"domains": [domain]}
if expense_category:
applies_to["expense_categories"] = [expense_category]
payload = {
"schema_version": "2.0",
@@ -447,12 +497,13 @@ class RiskRuleGenerationService:
"name": self._clean_text(draft.get("name")) or self._infer_rule_name(natural_language),
"description": self._clean_text(draft.get("description")) or natural_language,
"enabled": True,
"requires_attachment": requires_attachment,
"risk_dimension": "natural_language_rule",
"risk_category": risk_category,
"ontology_signal": "natural_language_risk",
"evaluator": "template_rule",
"template_key": template_key,
"applies_to": {"domains": [domain]},
"applies_to": applies_to,
"inputs": {
"fields": [
{
@@ -478,6 +529,9 @@ class RiskRuleGenerationService:
"source_ref": "自然语言风险规则",
"created_at": created_at.isoformat(),
"created_by": actor,
"requires_attachment": requires_attachment,
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"natural_language": natural_language,
"business_explanation": self._clean_text(draft.get("description")),
"condition_summary": condition_summary,
@@ -488,6 +542,7 @@ class RiskRuleGenerationService:
payload,
fields=[field_by_key[key] for key in field_keys if key in field_by_key],
domain=domain,
domain_label=risk_category,
risk_level=risk_level,
)
return payload
@@ -498,6 +553,7 @@ class RiskRuleGenerationService:
*,
fields: list[RiskRuleField],
domain: str,
domain_label: str | None = None,
risk_level: str,
) -> str:
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
@@ -506,7 +562,7 @@ class RiskRuleGenerationService:
return self.flow_diagram_renderer.render(
RiskRuleFlowDiagramSpec(
title=self._clean_text(payload.get("name")) or "风险规则判断流程",
domain_label=BUSINESS_DOMAIN_LABELS.get(domain, "业务"),
domain_label=domain_label or BUSINESS_DOMAIN_LABELS.get(domain, "业务"),
severity=risk_level,
severity_label=RISK_LEVEL_LABELS.get(risk_level, "中风险"),
fields=tuple(
@@ -528,6 +584,21 @@ class RiskRuleGenerationService:
)
)
@staticmethod
def _normalize_expense_category(value: str | None, domain: str) -> str | None:
if domain != AgentAssetDomain.EXPENSE.value:
return None
normalized = str(value or "").strip().lower()
if not normalized:
return None
normalized = EXPENSE_RISK_CATEGORY_ALIASES.get(normalized, normalized)
if normalized not in EXPENSE_RISK_CATEGORY_LABELS:
allowed = "".join(EXPENSE_RISK_CATEGORY_LABELS.values())
raise ValueError(f"费用领域仅支持:{allowed}")
return normalized
def _resolve_fields(self, text: str, *, domain: str) -> list[RiskRuleField]:
prefixes = DOMAIN_FIELD_PREFIXES.get(domain, ())
candidates = [field for field in FIELD_ONTOLOGY if field.key.startswith(prefixes)]