Files
X-Financial/server/src/app/services/agent_foundation_risk_rules.py
caoxiaozhu 34457f9c3e feat: 本体字段治理与风险规则模板执行器重构
- 新增本体字段注册表与字段治理审计脚本
- 重构风险规则模板执行器、DSL 验证与清单分类器
- 完善票据夹服务与差旅请求详情页交互
- 优化趋势图表与总览页数据展示
- 增强报销平台风险分级与模拟公司筛选
- 补充本体字段、风险规则生成与票据夹服务测试覆盖
2026-06-03 15:46:56 +08:00

607 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import select
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetStatus,
AgentAssetType,
AgentReviewStatus,
)
from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import (
RISK_RULES_LIBRARY,
)
from app.services.agent_foundation_constants import (
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
logger = get_logger("app.services.agent_foundation")
EXPENSE_TYPE_SCENARIO_LABELS = {
"all": "全部",
"travel": "差旅费",
"hotel": "住宿费",
"transport": "交通费",
"meal": "业务招待费",
"meeting": "会务费",
"marketing": "市场推广费",
"office": "办公用品费",
"training": "培训费",
"software": "软件服务费",
"communication": "通信费",
"welfare": "福利费",
}
class AgentFoundationRiskRuleMixin:
def _iter_platform_risk_manifests(self) -> list[tuple[str, dict[str, object]]]:
manager = AgentAssetRuleLibraryManager()
manifests: list[tuple[str, dict[str, object]]] = []
for file_name in sorted(
manager.list_rule_library_json_files(library=RISK_RULES_LIBRARY)
):
payload = manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
)
if payload.get("enabled") is False:
continue
if self._is_user_generated_risk_manifest(payload):
continue
if is_budget_risk_manifest(payload):
continue
manifests.append((file_name, payload))
return manifests
@staticmethod
def _is_user_generated_risk_manifest(manifest: dict[str, object]) -> bool:
rule_code = str(manifest.get("rule_code") or "").strip().lower()
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
stability = str(metadata.get("stability") or "").strip().lower()
source_ref = str(metadata.get("source_ref") or "").strip()
if stability == "generated_draft":
return True
if source_ref == "自然语言风险规则":
return True
return ".generated_" in rule_code
@staticmethod
def _resolve_platform_risk_category(manifest: dict[str, object]) -> str:
explicit = str(manifest.get("risk_category") or "").strip()
if explicit:
return explicit
rule_code = str(manifest.get("rule_code") or "").strip().lower()
applies_to = (
manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
)
domains = {str(item or "").strip().lower() for item in applies_to.get("domains") or []}
expense_types = {
str(item or "").strip().lower() for item in applies_to.get("expense_types") or []
}
if rule_code.startswith("risk.invoice."):
return "发票"
if "meal" in domains or "entertainment" in expense_types:
return "餐饮招待"
if "transport" in expense_types or "consecutive_transport" in rule_code:
return "交通出行"
if "office" in expense_types:
return "办公物料"
if "travel" in domains or rule_code.startswith("risk.travel."):
return "差旅"
if rule_code.startswith("risk.expense."):
return "费用科目"
return "通用"
@staticmethod
def _resolve_manifest_expense_types(manifest: dict[str, object]) -> list[str]:
def _collect(value: object) -> list[str]:
if isinstance(value, str):
return [value]
if isinstance(value, (list, tuple, set)):
return [str(item or "").strip() for item in value]
return []
candidates: list[str] = []
candidates.extend(_collect(manifest.get("expense_types")))
applies_to = (
manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
)
candidates.extend(_collect(applies_to.get("expense_types")))
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
candidates.extend(_collect(metadata.get("expense_types")))
all_scope_values = {"all", "*", "overall", "general", "全部", "通用"}
if any(str(item or "").strip().lower() in all_scope_values for item in candidates):
return ["all"]
normalized: list[str] = []
seen: set[str] = set()
for item in candidates:
value = item.strip().lower()
if not value or value in seen:
continue
seen.add(value)
normalized.append(value)
return normalized
@staticmethod
def _expense_type_scenario_labels(expense_types: list[str]) -> list[str]:
if any(str(item or "").strip().lower() in {"all", "*", "overall", "general"} for item in expense_types):
return ["全部"]
labels: list[str] = []
seen: set[str] = set()
for expense_type in expense_types:
label = EXPENSE_TYPE_SCENARIO_LABELS.get(expense_type)
if not label or label in seen:
continue
seen.add(label)
labels.append(label)
return labels
def _platform_risk_scenario_json(self, manifest: dict[str, object]) -> list[str]:
labels = self._expense_type_scenario_labels(self._resolve_manifest_expense_types(manifest))
if labels:
return labels
category = self._resolve_platform_risk_category(manifest)
return [category] if category else ["通用"]
def _platform_risk_config_json(
self, file_name: str, manifest: dict[str, object]
) -> dict[str, object]:
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
risk_category = self._resolve_platform_risk_category(manifest)
config = {
"severity": str(fail_outcome.get("severity") or "medium"),
"enabled": True,
"tag": "风险规则",
"detail_mode": "json_risk",
"risk_category": risk_category,
"rule_library": RISK_RULES_LIBRARY,
"rule_document": {
"file_name": file_name,
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
},
"ontology_signal": str(manifest.get("ontology_signal") or "").strip(),
"evaluator": str(manifest.get("evaluator") or "").strip(),
"source_ref": (
(manifest.get("metadata") or {}).get("source_ref")
if isinstance(manifest.get("metadata"), dict)
else ""
),
}
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
for key in (
"finance_rule_code",
"finance_rule_sheet",
"business_stage",
"expense_types",
"budget_required",
):
value = manifest.get(key)
if value is None and isinstance(metadata, dict):
value = metadata.get(key)
if value is not None:
config[key] = value
return config
def _build_platform_risk_seed_assets(self) -> list[AgentAsset]:
assets: list[AgentAsset] = []
for file_name, manifest in self._iter_platform_risk_manifests():
rule_code = str(manifest.get("rule_code") or "").strip()
if not rule_code:
continue
metadata = (
manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
)
source_ref = str(metadata.get("source_ref") or "").strip()
rule_description = str(manifest.get("description") or "").strip()
assets.append(
AgentAsset(
asset_type=AgentAssetType.RULE.value,
code=rule_code,
name=str(manifest.get("name") or rule_code),
description=rule_description
or f"平台通用风险规则:{source_ref or manifest.get('name') or rule_code}",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=self._platform_risk_scenario_json(manifest),
owner=str(metadata.get("owner") or "风控与审计部"),
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
working_version="v1.0.0",
config_json=self._platform_risk_config_json(file_name, manifest),
)
)
return assets
def sync_platform_risk_rules_from_library(self) -> int:
existing_codes = set(self.db.scalars(select(AgentAsset.code)).all())
before_count = len(existing_codes)
self._ensure_platform_risk_rules_from_library(existing_codes)
manifest_codes = {
str(manifest.get("rule_code") or "").strip()
for _, manifest in self._iter_platform_risk_manifests()
if str(manifest.get("rule_code") or "").strip()
}
self._hide_stale_demo_risk_rules(manifest_codes)
self.db.flush()
after_codes = set(self.db.scalars(select(AgentAsset.code)).all())
synced = max(len(after_codes) - before_count, 0)
manifest_count = len(self._iter_platform_risk_manifests())
logger.info(
"Platform risk rules synced from library",
extra={
"manifest_count": manifest_count,
"created_count": synced,
"total": len(after_codes),
},
)
return manifest_count
def _hide_stale_demo_risk_rules(self, manifest_codes: set[str]) -> None:
assets = self.db.scalars(
select(AgentAsset).where(AgentAsset.asset_type == AgentAssetType.RULE.value)
).all()
for asset in assets:
config = asset.config_json if isinstance(asset.config_json, dict) else {}
if config.get("source_ref") != "费用管控 Demo 风险规则库":
continue
if asset.code in manifest_codes:
continue
asset.status = AgentAssetStatus.DISABLED.value
asset.config_json = {
**config,
"enabled": False,
"tag": "废弃风险规则",
"deprecated": True,
"deprecated_reason": "对应风险规则 JSON 已删除,不再参与费用管控 Demo。",
}
def _ensure_platform_risk_rules_from_library(self, existing_codes: set[str]) -> None:
for file_name, manifest in self._iter_platform_risk_manifests():
rule_code = str(manifest.get("rule_code") or "").strip()
if not rule_code:
continue
metadata = (
manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
)
source_ref = str(metadata.get("source_ref") or "").strip()
rule_description = str(manifest.get("description") or "").strip()
config_json = self._platform_risk_config_json(file_name, manifest)
scenario_json = self._platform_risk_scenario_json(manifest)
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == rule_code))
if asset is None and rule_code not in existing_codes:
asset = self._create_seed_asset(
asset_type=AgentAssetType.RULE.value,
code=rule_code,
name=str(manifest.get("name") or rule_code),
description=rule_description
or f"平台通用风险规则:{source_ref or manifest.get('name') or rule_code}",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=scenario_json,
owner=str(metadata.get("owner") or "风控与审计部"),
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
config_json=config_json,
)
if asset is None:
continue
if not str(asset.current_version or "").strip():
asset.current_version = "v1.0.0"
if not str(asset.working_version or "").strip():
asset.working_version = asset.current_version
if not str(asset.published_version or "").strip():
asset.published_version = asset.current_version
asset.status = asset.status or AgentAssetStatus.ACTIVE.value
asset.name = str(manifest.get("name") or asset.name or rule_code)
if rule_description:
asset.description = rule_description
asset.config_json = config_json
asset.scenario_json = scenario_json
self._ensure_asset_version(
asset,
version="v1.0.0",
content=self._platform_risk_rule_markdown(
asset,
manifest=manifest,
file_name=file_name,
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note=f"平台通用风险规则:{asset.name}",
created_by="系统初始化",
)
self._ensure_asset_review(
asset,
version="v1.0.0",
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="平台内置风险规则,供提交验审与风险问答共用。",
reviewed_at=datetime.now(UTC),
)
@staticmethod
def _platform_risk_rule_markdown(
asset: AgentAsset,
*,
manifest: dict[str, object] | None = None,
file_name: str = "",
) -> str:
config = asset.config_json if isinstance(asset.config_json, dict) else {}
rule_document = (
config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
)
resolved_file_name = file_name or str(rule_document.get("file_name") or "").strip()
evaluator = str(config.get("evaluator") or (manifest or {}).get("evaluator") or "").strip()
ontology_signal = str(
config.get("ontology_signal") or (manifest or {}).get("ontology_signal") or ""
).strip()
source_ref = str(config.get("source_ref") or "").strip()
if not source_ref and isinstance(manifest, dict):
metadata = (
manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
)
source_ref = str(metadata.get("source_ref") or "").strip()
lines = [
f"# {asset.name}",
"",
"## 规则类型",
"",
"- 平台内置通用风险规则(`json_risk`",
]
if evaluator:
lines.append(f"- 检查器:`{evaluator}`")
if ontology_signal:
lines.append(f"- 本体信号:`{ontology_signal}`")
if source_ref:
lines.extend(["", "## 来源", "", f"- {source_ref}"])
if resolved_file_name:
lines.extend(
[
"",
"## 配置文件",
"",
f"- `rules/{RISK_RULES_LIBRARY}/{resolved_file_name}`",
]
)
return "\n".join(lines)
@staticmethod
def _platform_destination_location_risk_markdown() -> str:
return AgentFoundationRiskRuleMixin._platform_risk_rule_markdown(
AgentAsset(
name="申报地点与票据地点一致",
config_json={"evaluator": "location_consistency"},
),
manifest={
"evaluator": "location_consistency",
"ontology_signal": "location_mismatch",
"metadata": {"source_ref": "常用risk.txt / 一、出差类 / 行程不符"},
},
file_name=PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)