Files
X-Financial/server/src/app/services/agent_foundation_risk_rules.py

475 lines
13 KiB
Python
Raw Normal View History

from __future__ import annotations
import hashlib
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from sqlalchemy import inspect, select, text
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetStatus,
AgentAssetType,
AgentName,
AgentPermissionLevel,
AgentReviewStatus,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.audit_log import AuditLog
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
ExpenseClaim,
ExpenseClaimItem,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager,
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
RISK_RULES_LIBRARY,
)
from app.services.expense_rule_runtime import (
build_scene_submission_standard_markdown,
build_travel_risk_control_standard_markdown,
)
from app.services.agent_foundation_constants import (
ATTACHMENT_RULE_ASSET_CODE,
ATTACHMENT_RULE_RUNTIME_CONFIG,
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
DEMO_EXPENSE_CLAIM_SIGNATURES,
DEMO_PAYABLE_SIGNATURES,
DEMO_RECEIVABLE_SIGNATURES,
LEGACY_RULE_CODES,
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)
from app.core.logging import get_logger
logger = get_logger("app.services.agent_foundation")
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
manifests.append((file_name, payload))
return manifests
@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 "通用"
def _platform_risk_scenario_json(self, manifest: dict[str, object]) -> list[str]:
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)
return {
"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 ""
),
}
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)
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 _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,
)