feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -0,0 +1,215 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from app.core.agent_enums import AgentAssetStatus, AgentAssetType, AgentReviewStatus
from app.models.agent_asset import AgentAsset, AgentAssetReview
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
class AgentAssetRiskRulePublishMixin:
"""风险规则发布逻辑,支持普通待审核版本和已上线规则修订版本。"""
def publish_risk_rule(
self,
asset_id: str,
*,
actor: str,
request_id: str | None = None,
) -> AgentAsset:
asset = self._resolve_asset(asset_id)
self._require_json_risk_asset(asset)
revision = self._resolve_publishable_revision(asset)
if revision is not None:
return self._publish_revision(asset, revision, actor=actor, request_id=request_id)
return self._publish_reviewed_working_version(asset, actor=actor, request_id=request_id)
def _publish_reviewed_working_version(
self,
asset: AgentAsset,
*,
actor: str,
request_id: str | None,
) -> AgentAsset:
version = self._resolve_target_version(asset, None)
if asset.status != AgentAssetStatus.REVIEW.value:
raise ValueError("只有待审核风险规则可以发布上线。")
if not self.get_latest_risk_rule_test_summary(asset, version=version).test_passed:
raise PermissionError("当前规则版本尚未完成测试通过确认,不能发布。")
before = self._asset_snapshot(asset)
self._ensure_approved_review(asset, version=version, actor=actor, note="发布上线前审核通过。")
asset.reviewer = actor
asset.published_version = version
asset.status = AgentAssetStatus.ACTIVE.value
self.db.add(asset)
self.db.commit()
self.audit_service.log_action(
actor=actor,
action="publish_agent_asset",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=before,
after_json=self._asset_snapshot(asset),
request_id=request_id,
)
return self._refresh_asset(asset.id)
def _publish_revision(
self,
asset: AgentAsset,
revision: dict[str, Any],
*,
actor: str,
request_id: str | None,
) -> AgentAsset:
version = str(revision.get("version") or "").strip()
if not self.get_latest_risk_rule_test_summary(asset, version=version).test_passed:
raise PermissionError("当前修订版本尚未完成测试通过确认,不能发布。")
rule_document = revision.get("rule_document") if isinstance(revision.get("rule_document"), dict) else {}
file_name = str(rule_document.get("file_name") or "").strip()
if not file_name:
raise ValueError("修订版本尚未生成可发布的 JSON 规则文件。")
before = self._asset_snapshot(asset)
manifest = self.rule_library_manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
)
manifest = normalize_risk_rule_manifest(manifest)
manifest["enabled"] = True
self.rule_library_manager.write_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
payload=manifest,
)
config = dict(asset.config_json or {})
previous_rule_document = config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
published_at = datetime.now(UTC).isoformat()
history = list(config.get("revision_history") if isinstance(config.get("revision_history"), list) else [])
history.insert(
0,
{
"version": version,
"base_version": revision.get("base_version"),
"change_reason": revision.get("change_reason"),
"published_by": actor,
"published_at": published_at,
"previous_rule_document": previous_rule_document,
"rule_document": rule_document,
},
)
config.update(self._config_from_published_manifest(manifest, rule_document))
config["revision_history"] = history[:20]
config.pop("revision_draft", None)
config["last_operation"] = {
"action": "publish_revision",
"actor": actor,
"at": published_at,
"target_version": version,
}
asset.name = str(manifest.get("name") or asset.name)
asset.description = str(manifest.get("description") or asset.description)
risk_category = str(manifest.get("risk_category") or "").strip()
if risk_category:
asset.scenario_json = [risk_category]
asset.config_json = config
asset.current_version = version
asset.working_version = version
asset.published_version = version
asset.reviewer = actor
asset.status = AgentAssetStatus.ACTIVE.value
self._ensure_approved_review(asset, version=version, actor=actor, note="修订版本发布上线。")
self.db.add(asset)
self.db.commit()
self.audit_service.log_action(
actor=actor,
action="publish_risk_rule_revision",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=before,
after_json=self._asset_snapshot(asset),
request_id=request_id,
)
return self._refresh_asset(asset.id)
def _resolve_publishable_revision(self, asset: AgentAsset) -> dict[str, Any] | None:
config = dict(asset.config_json or {})
revision = config.get("revision_draft")
if not isinstance(revision, dict):
return None
version = str(revision.get("version") or "").strip()
if not version or version != str(asset.working_version or "").strip():
return None
if version == str(asset.published_version or "").strip():
return None
if revision.get("generation_status") != "completed":
raise ValueError("修订版本尚未重新生成,不能发布上线。")
return dict(revision)
def _ensure_approved_review(
self,
asset: AgentAsset,
*,
version: str,
actor: str,
note: str,
) -> None:
approved_review = self.repository.get_review(
asset.id, version, AgentReviewStatus.APPROVED.value
)
if approved_review is not None:
return
self.db.add(
AgentAssetReview(
asset_id=asset.id,
version=version,
reviewer=actor,
review_status=AgentReviewStatus.APPROVED.value,
review_note=note,
reviewed_at=datetime.now(UTC),
)
)
@staticmethod
def _config_from_published_manifest(
manifest: dict[str, Any],
rule_document: dict[str, Any],
) -> dict[str, Any]:
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
risk_score_detail = metadata.get("risk_score_detail") if isinstance(metadata.get("risk_score_detail"), dict) else {}
risk_level = str(metadata.get("risk_level") or manifest.get("outcomes", {}).get("fail", {}).get("severity") or "medium")
risk_score = int(metadata.get("risk_score") or manifest.get("outcomes", {}).get("fail", {}).get("risk_score") or 0)
return {
"severity": risk_level,
"risk_score": risk_score,
"risk_level": risk_level,
"risk_level_label": metadata.get("risk_level_label"),
"risk_score_detail": risk_score_detail,
"enabled": True,
"requires_attachment": bool(metadata.get("requires_attachment") or manifest.get("requires_attachment")),
"detail_mode": "json_risk",
"business_stage": metadata.get("business_stage"),
"business_stage_label": metadata.get("business_stage_label"),
"expense_category": metadata.get("expense_category"),
"expense_category_label": metadata.get("expense_category_label"),
"risk_category": manifest.get("risk_category"),
"rule_library": RISK_RULES_LIBRARY,
"rule_document": rule_document,
"ontology_signal": manifest.get("ontology_signal"),
"evaluator": manifest.get("evaluator"),
"generated_by": "natural_language",
"source_ref": "自然语言风险规则",
"flow_diagram_svg": manifest.get("flow_diagram_svg"),
}
def _refresh_asset(self, asset_id: str) -> AgentAsset:
refreshed = self.repository.get(asset_id)
if refreshed is None:
raise LookupError("Asset not found")
return refreshed