feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
215
server/src/app/services/agent_asset_risk_rule_publish.py
Normal file
215
server/src/app/services/agent_asset_risk_rule_publish.py
Normal 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
|
||||
Reference in New Issue
Block a user